diff --git a/.github/workflows/_package-linux.yml b/.github/workflows/_package-linux.yml index ded7e6821e..0e9f54c485 100644 --- a/.github/workflows/_package-linux.yml +++ b/.github/workflows/_package-linux.yml @@ -35,6 +35,14 @@ permissions: env: CI: 'true' FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + # '1' builds cua for remote distribution instead of shipping it inside the + # app, and "Verify bundled plugins" then asserts its absence. Flipped for the + # unbundled distribution test; keep published catalog entries in sync before + # this ships. + DEEPCHAT_UNBUNDLE_CUA: '1' + # '1' ships without the bundled OCR runtime: the smoke step is replaced by + # an absence assertion and package-manifest tolerates the missing report. + DEEPCHAT_UNBUNDLE_OCR: '1' jobs: package: @@ -142,6 +150,7 @@ jobs: --resources-path "dist/${{ env.UNPACKED_DIRECTORY }}/resources" - name: Verify packaged Light OCR offline + if: env.DEEPCHAT_UNBUNDLE_OCR != '1' run: | sudo unshare --net --setuid "$(id -u)" --setgid "$(id -g)" -- \ env HOME="$HOME" PATH="$PATH" pnpm run smoke:light-ocr -- \ @@ -153,6 +162,33 @@ jobs: --require-execution \ --require-peak-rss + - name: Verify packaged OCR runtime is absent + if: env.DEEPCHAT_UNBUNDLE_OCR == '1' + run: | + ocr_runtime="dist/${UNPACKED_DIRECTORY}/resources/app.asar.unpacked/runtime/ocr" + if [[ -e "${ocr_runtime}" ]]; then + echo "::error::Bundled OCR runtime must not ship inside the app: ${ocr_runtime}" + exit 1 + fi + echo "Verified OCR runtime is absent (distributed remotely)" + + # Produces the remotely distributed OCR runtime payload plus a catalog + # that pins every staged artifact. The loopback base URL makes the + # catalog directly usable for L1-style local testing: serve + # build/remote-plugins over http and point DEEPCHAT_PLUGIN_CATALOG at + # plugin-catalog.json in a dev build. + - name: Stage and package the remote OCR runtime payload + if: env.DEEPCHAT_UNBUNDLE_OCR == '1' + run: | + node scripts/stage-ocr-runtime.mjs --platform linux --arch "${TARGET_ARCH}" --out build/ocr-runtime-staging + mkdir -p build/remote-plugins + node scripts/plugin-catalog.mjs generate \ + --runtime-dir "build/ocr-runtime-staging/resources/app.asar.unpacked/runtime" \ + --artifacts-dir build/remote-plugins \ + --catalog build/remote-plugins/plugin-catalog.json \ + --base-url "http://127.0.0.1:8787/" \ + --write + - name: Verify bundled CUA plugin if: inputs.arch == 'x64' run: >- @@ -170,6 +206,19 @@ jobs: --arch "${{ inputs.arch }}" --plugin-root "dist/${{ env.UNPACKED_DIRECTORY }}/resources/app.asar.unpacked/plugins" + - name: Upload remote plugin packages + if: env.DEEPCHAT_UNBUNDLE_CUA == '1' || env.DEEPCHAT_UNBUNDLE_OCR == '1' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: deepchat-remote-plugins-linux-${{ inputs.arch }} + path: | + build/remote-plugins/*.dcplugin + build/remote-plugins/*.zip + build/remote-plugins/plugin-catalog.json + if-no-files-found: warn + retention-days: 7 + overwrite: true + - name: Compare installer sizes if: inputs.enforce-installer-size run: | @@ -187,12 +236,16 @@ jobs: if [[ "${ENFORCE_INSTALLER_SIZE}" == 'true' ]]; then size_report=(--installer-size-report "dist/package-size-linux-${TARGET_ARCH}.json") fi + ocr_report=() + if [[ "${DEEPCHAT_UNBUNDLE_OCR}" != '1' ]]; then + ocr_report=(--report "dist/light-ocr-smoke-linux-${TARGET_ARCH}.json") + fi node scripts/ci/package-manifest.mjs \ --platform linux \ --arch "${TARGET_ARCH}" \ --source-sha "${SOURCE_SHA}" \ --purpose "${PACKAGE_PURPOSE}" \ - --report "dist/light-ocr-smoke-linux-${TARGET_ARCH}.json" \ + "${ocr_report[@]}" \ --workflow-run-id "${GITHUB_RUN_ID}" \ --workflow-run-attempt "${GITHUB_RUN_ATTEMPT}" \ "${size_report[@]}" diff --git a/.github/workflows/_package-macos.yml b/.github/workflows/_package-macos.yml index 15c92290dc..79cfd0900f 100644 --- a/.github/workflows/_package-macos.yml +++ b/.github/workflows/_package-macos.yml @@ -45,6 +45,14 @@ permissions: env: CI: 'true' FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + # '1' builds cua for remote distribution instead of shipping it inside the + # app, and "Verify bundled plugins" then asserts its absence. Flipped for the + # unbundled distribution test; keep published catalog entries in sync before + # this ships. + DEEPCHAT_UNBUNDLE_CUA: '1' + # '1' ships without the bundled OCR runtime: the smoke steps are replaced by + # an absence assertion and package-manifest tolerates the missing reports. + DEEPCHAT_UNBUNDLE_OCR: '1' jobs: package: @@ -190,6 +198,7 @@ jobs: # Verification packages are deliberately unsigned and never leave this runner as installers. # This keeps Apple credentials out of fork PR code while retaining native package coverage. - name: Verify packaged Light OCR offline + if: env.DEEPCHAT_UNBUNDLE_OCR != '1' run: | sandbox-exec -p '(version 1) (allow default) (deny network*)' \ pnpm run smoke:light-ocr -- \ @@ -201,6 +210,7 @@ jobs: --require-execution - name: Verify packaged Light OCR performance + if: env.DEEPCHAT_UNBUNDLE_OCR != '1' run: | pnpm run smoke:light-ocr -- \ --platform darwin \ @@ -212,12 +222,52 @@ jobs: --require-peak-rss \ --skip-compression + - name: Verify packaged OCR runtime is absent + if: env.DEEPCHAT_UNBUNDLE_OCR == '1' + run: | + ocr_runtime="${APP_DIRECTORY}/Contents/Resources/app.asar.unpacked/runtime/ocr" + if [[ -e "${ocr_runtime}" ]]; then + echo "::error::Bundled OCR runtime must not ship inside the app: ${ocr_runtime}" + exit 1 + fi + echo "Verified OCR runtime is absent (distributed remotely)" + + # Produces the remotely distributed OCR runtime payload plus a catalog + # that pins every staged artifact. The loopback base URL makes the + # catalog directly usable for L1-style local testing: serve + # build/remote-plugins over http and point DEEPCHAT_PLUGIN_CATALOG at + # plugin-catalog.json in a dev build. + - name: Stage and package the remote OCR runtime payload + if: env.DEEPCHAT_UNBUNDLE_OCR == '1' + run: | + node scripts/stage-ocr-runtime.mjs --platform darwin --arch "${TARGET_ARCH}" --out build/ocr-runtime-staging + mkdir -p build/remote-plugins + node scripts/plugin-catalog.mjs generate \ + --runtime-dir "build/ocr-runtime-staging/DeepChat.app/Contents/Resources/app.asar.unpacked/runtime" \ + --artifacts-dir build/remote-plugins \ + --catalog build/remote-plugins/plugin-catalog.json \ + --base-url "http://127.0.0.1:8787/" \ + --write + - name: Verify bundled plugins run: | plugin_root="${APP_DIRECTORY}/Contents/Resources/app.asar.unpacked/plugins" pnpm run plugin:verify -- --name cua --platform darwin --arch "${TARGET_ARCH}" --plugin-root "${plugin_root}" pnpm run plugin:verify -- --name feishu --platform darwin --arch "${TARGET_ARCH}" --plugin-root "${plugin_root}" + - name: Upload remote plugin packages + if: env.DEEPCHAT_UNBUNDLE_CUA == '1' || env.DEEPCHAT_UNBUNDLE_OCR == '1' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: deepchat-remote-plugins-darwin-${{ inputs.arch }} + path: | + build/remote-plugins/*.dcplugin + build/remote-plugins/*.zip + build/remote-plugins/plugin-catalog.json + if-no-files-found: warn + retention-days: 7 + overwrite: true + - name: Compare installer sizes if: inputs.enforce-installer-size run: | @@ -236,14 +286,20 @@ jobs: if [[ "${ENFORCE_INSTALLER_SIZE}" == 'true' ]]; then size_report=(--installer-size-report "dist/package-size-darwin-${TARGET_ARCH}.json") fi + ocr_report=() + if [[ "${DEEPCHAT_UNBUNDLE_OCR}" != '1' ]]; then + ocr_report=( + --report "dist/light-ocr-smoke-darwin-${TARGET_ARCH}-offline.json" + --report "dist/light-ocr-smoke-darwin-${TARGET_ARCH}.json" + ) + fi node scripts/ci/package-manifest.mjs \ --platform darwin \ --arch "${TARGET_ARCH}" \ --source-sha "${SOURCE_SHA}" \ --purpose "${PACKAGE_PURPOSE}" \ --mac-app-path "${APP_DIRECTORY}" \ - --report "dist/light-ocr-smoke-darwin-${TARGET_ARCH}-offline.json" \ - --report "dist/light-ocr-smoke-darwin-${TARGET_ARCH}.json" \ + "${ocr_report[@]}" \ --workflow-run-id "${GITHUB_RUN_ID}" \ --workflow-run-attempt "${GITHUB_RUN_ATTEMPT}" \ "${size_report[@]}" diff --git a/.github/workflows/_package-windows.yml b/.github/workflows/_package-windows.yml index 9b49d32daa..cf84c6374f 100644 --- a/.github/workflows/_package-windows.yml +++ b/.github/workflows/_package-windows.yml @@ -35,6 +35,14 @@ permissions: env: CI: 'true' FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: 'true' + # '1' builds cua for remote distribution instead of shipping it inside the + # app, and "Verify bundled plugins" then asserts its absence. Flipped for the + # unbundled distribution test; keep published catalog entries in sync before + # this ships. + DEEPCHAT_UNBUNDLE_CUA: '1' + # '1' ships without the bundled OCR runtime: the smoke step is replaced by + # an absence assertion and package-manifest tolerates the missing report. + DEEPCHAT_UNBUNDLE_OCR: '1' jobs: package: @@ -150,6 +158,7 @@ jobs: # Blocks outbound traffic from the runner Node used by smoke-light-ocr. # That Node version must remain 24.18.0 to match runtime-versions.json. - name: Verify packaged Light OCR offline + if: env.DEEPCHAT_UNBUNDLE_OCR != '1' shell: pwsh run: | $ErrorActionPreference = 'Stop' @@ -170,6 +179,35 @@ jobs: Remove-NetFirewallRule -DisplayName $ruleName -ErrorAction SilentlyContinue } + - name: Verify packaged OCR runtime is absent + if: env.DEEPCHAT_UNBUNDLE_OCR == '1' + shell: bash + run: | + ocr_runtime="dist/${UNPACKED_DIRECTORY}/resources/app.asar.unpacked/runtime/ocr" + if [[ -e "${ocr_runtime}" ]]; then + echo "::error::Bundled OCR runtime must not ship inside the app: ${ocr_runtime}" + exit 1 + fi + echo "Verified OCR runtime is absent (distributed remotely)" + + # Produces the remotely distributed OCR runtime payload plus a catalog + # that pins every staged artifact. The loopback base URL makes the + # catalog directly usable for L1-style local testing: serve + # build/remote-plugins over http and point DEEPCHAT_PLUGIN_CATALOG at + # plugin-catalog.json in a dev build. + - name: Stage and package the remote OCR runtime payload + if: env.DEEPCHAT_UNBUNDLE_OCR == '1' + shell: bash + run: | + node scripts/stage-ocr-runtime.mjs --platform win32 --arch "${TARGET_ARCH}" --out build/ocr-runtime-staging + mkdir -p build/remote-plugins + node scripts/plugin-catalog.mjs generate \ + --runtime-dir "build/ocr-runtime-staging/resources/app.asar.unpacked/runtime" \ + --artifacts-dir build/remote-plugins \ + --catalog build/remote-plugins/plugin-catalog.json \ + --base-url "http://127.0.0.1:8787/" \ + --write + - name: Verify bundled plugins shell: bash run: | @@ -177,6 +215,19 @@ jobs: pnpm run plugin:verify -- --name cua --platform win32 --arch "${TARGET_ARCH}" --plugin-root "${plugin_root}" pnpm run plugin:verify -- --name feishu --platform win32 --arch "${TARGET_ARCH}" --plugin-root "${plugin_root}" + - name: Upload remote plugin packages + if: env.DEEPCHAT_UNBUNDLE_CUA == '1' || env.DEEPCHAT_UNBUNDLE_OCR == '1' + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0 + with: + name: deepchat-remote-plugins-win32-${{ inputs.arch }} + path: | + build/remote-plugins/*.dcplugin + build/remote-plugins/*.zip + build/remote-plugins/plugin-catalog.json + if-no-files-found: warn + retention-days: 7 + overwrite: true + - name: Compare installer sizes if: inputs.enforce-installer-size shell: bash @@ -196,12 +247,16 @@ jobs: if [[ "${ENFORCE_INSTALLER_SIZE}" == 'true' ]]; then size_report=(--installer-size-report "dist/package-size-win32-${TARGET_ARCH}.json") fi + ocr_report=() + if [[ "${DEEPCHAT_UNBUNDLE_OCR}" != '1' ]]; then + ocr_report=(--report "dist/light-ocr-smoke-win32-${TARGET_ARCH}.json") + fi node scripts/ci/package-manifest.mjs \ --platform win32 \ --arch "${TARGET_ARCH}" \ --source-sha "${SOURCE_SHA}" \ --purpose "${PACKAGE_PURPOSE}" \ - --report "dist/light-ocr-smoke-win32-${TARGET_ARCH}.json" \ + "${ocr_report[@]}" \ --workflow-run-id "${GITHUB_RUN_ID}" \ --workflow-run-attempt "${GITHUB_RUN_ATTEMPT}" \ "${size_report[@]}" diff --git a/.gitignore b/.gitignore index d60b8a031b..38a689b5ae 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ build/macx64.json build/winarm.json build/winx64.json build/bundled-plugins/ +build/remote-plugins/ build/managed-helpers/ runtime/**/* plugins/*/runtime/**/* diff --git a/docs/architecture/baselines/renderer-application-boundaries-baseline.json b/docs/architecture/baselines/renderer-application-boundaries-baseline.json index 415ef45603..29f298125c 100644 --- a/docs/architecture/baselines/renderer-application-boundaries-baseline.json +++ b/docs/architecture/baselines/renderer-application-boundaries-baseline.json @@ -423,6 +423,10 @@ "file": "src/renderer/settings/components/ModelProviderSettingsDetail.vue", "specifier": "@/stores/uiSettingsStore" }, + { + "file": "src/renderer/settings/components/OcrSettings.vue", + "specifier": "@/components/plugins/RuntimeInstallControls.vue" + }, { "file": "src/renderer/settings/components/OllamaProviderSettingsDetail.vue", "specifier": "@/components/settings/ModelConfigItem.vue" @@ -564,5 +568,5 @@ "specifier": "@/i18n/bootstrap" } ], - "settingsToChatAppImportCount": 128 + "settingsToChatAppImportCount": 129 } diff --git a/docs/features/plugin-remote-distribution/plan.md b/docs/features/plugin-remote-distribution/plan.md new file mode 100644 index 0000000000..275d2a9734 --- /dev/null +++ b/docs/features/plugin-remote-distribution/plan.md @@ -0,0 +1,112 @@ +# Plugin Remote Distribution — Implementation Plan + +Tracker for the slices defined in [spec.md](./spec.md). Phase P0 first; P1a/P1b follow in +separate slices once P0 lands. + +## P0 — Host foundation + +### Slice 1: Shared contracts + +- [x] Catalog schema types + zod contract in `src/shared/types/` (artifact entry, target, + channel, mirrors) with validation helpers +- [x] Plugin routes contract additions: catalog list, install status, install/cancel/retry + invocations following `src/shared/contracts/routes/plugins.routes.ts` patterns +- [x] Install progress event contract in `src/shared/contracts/events` (bytes, totalBytes, + phase, error) +- Completion: typecheck passes; contracts reviewed against existing route conventions. + +### Slice 2: Catalog loader + +- [x] `resources/plugin-catalog.json` skeleton (schemaVersion 1, empty artifacts list) +- [x] Loader in main process: parse + validate against shared contract, resolve current + platform/arch target, filter by channel (stable builds resolve stable only) +- [x] Dev-only override hook: `DEEPCHAT_PLUGIN_CATALOG` env → alternate catalog path; + ignored when packaged +- Completion: unit tests cover schema validation, target resolution, channel filtering, + override behavior (packaged vs dev). + +### Slice 3: PluginRemoteInstaller + +- [x] Staging layout under plugin install root `.staging//` +- [x] URL attempt ordering: direct (probe) → mirror prefixes; fastest successful probe wins, + direct URL attempted even when every probe fails +- [x] sha256 + size verification before any move (toolchains `downloadVerifiedFile` reuse) +- [x] Package handoff through `PluginService.installOfficialPluginPackage` (existing + checksums + trust + install path, no duplication) +- [x] AbortController cancel; retry on transient errors; progress callback; single-flight + per plugin id +- Completion: unit tests with injected fetch cover download, mirror fallback, sha256 + failure, cancel, single-flight, package-id mismatch. + +### Slice 4: PluginService integration + routes + +- [x] Wire installer into `PluginService` (composition boundary) +- [x] Route handlers: catalog list merged with installed state; install / cancel +- [x] Progress events emitted through the existing typed event bus +- Completion: typecheck + main tests pass; smoke via dev override pending (covered by unit + tests until the e2e fixture lands). + +### Slice 5: Renderer UX + +- [x] `PluginClient` API additions mirroring the new routes + progress subscription +- [x] Plugins catalog page: "Downloadable plugins" section with install/progress/cancel/ + retry states +- [x] i18n strings for all 23 locales (validated) +- Completion: renderer typecheck + i18n validation pass. + +### Slice 6: L1 e2e fixture + gates + +- [x] L1 chain coverage: catalog override + injected fetch + real `.dcplugin` + real + `PluginService` install (`test/main/plugin/remoteDistribution.integration.test.ts`); + OCR chain: fixture payload → installer → resolver identity verification + (`test/main/ocr/runtimeAssetInstaller.test.ts`) +- [x] Run `pnpm run format`, `pnpm run i18n`, `pnpm run lint`, `pnpm run typecheck`, + `pnpm run test:main` (and renderer suite if Slice 5 touched it) +- Completion: all gates green on the feature branch. + +## P1a — CUA unbundle (host side landed; release flip pending) + +- [x] Enable flow: `plugins.enable` falls back to catalog install (silent download) when the + plugin's *payload* is missing, not merely when the plugin is undiscovered — a bundled or + development-tree manifest is discoverable while its runtime binary was never staged, and + `PluginListItem.installed` now reports payload presence so the UI offers install rather + than uninstall; catalog page install action enables after install +- [x] Release tooling: `scripts/plugin-catalog.mjs` (generate + verify) with npm scripts + `plugin:catalog` / `plugin:catalog:verify`; locally verified against fixtures +- [x] `resources/plugin-catalog.json` shipped via electron-builder extraResources +- [x] `DEEPCHAT_UNBUNDLE_CUA=1` build switch: `plugin:bundle -- --name cua` then writes the + `.dcplugin` to `build/remote-plugins` (outside the extraResources glob) and skips staging + the macOS managed helper into `Contents/Helpers`, while `plugin:verify --name cua` + inverts to assert absence. Declared as `'0'` in the three `_package-*.yml` workflows +- [ ] Flip the switch to `'1'` and publish: upload `build/remote-plugins/*.dcplugin` as release + assets and add them to the fail-closed assembly list — **requires GitHub release + publishing (remote); release owner flips after catalog entries carry real URLs** +- [ ] Catalog entry for cua with real artifact URLs + sha256 (generated at release time) +- Completion: staged on prerelease, verified via L2, then promoted. Host-side is complete. + +## P1b — LightOCR payload remote (landed) + +- [x] Catalog `runtimeAssets` schema (shared types + zod + loader resolution) +- [x] `OcrRuntimeAssetInstaller`: download → sha256 → unzip → structural validation → + versioned install root; payload layout mirrors the unpacked app root so the resolver + validates downloaded installs with the same identity checks as bundled ones +- [x] Resolver fallback: bundled root first, then installed roots + (`installedRuntimeRoots`) +- [x] First-use silent download via `OcrRuntimeInstallCoordinator` (attachment availability + gate); the triggering turn degrades per the existing unavailable path; 5-minute + failure cooldown; explicit install resets it +- [x] No auto-download opt-out: the `ocrRuntimeAutoDownload` setting was deliberately dropped. + First use downloads unconditionally, and the runtime card's install button is the manual + way to trigger the same download immediately (it also resets the failure cooldown) +- [x] `ocr.installRuntime` / `ocr.cancelRuntimeInstall` routes + `ocr.runtimeInstall.progress` + event + status extensions (`runtimeInstall`, `runtimeAsset`) +- [x] OCR settings page: runtime download section (install / progress / cancel / retry) +- [x] i18n for all 23 locales +- [x] Build-side packaging: `plugin-catalog.mjs generate` produces the OCR payload zip + (`runtime/ocr/**` + built helper) and pins its sha256 +- [x] `DEEPCHAT_UNBUNDLE_OCR=1` build switch: `afterPack` skips `packageLightOcrAssets`, so the + app ships without `runtime/ocr` and resolves the runtime from a downloaded payload +- [ ] Default the switch on in CI — **release flip, same gate as P1a; also needs the packaged + Light OCR smoke steps to stop requiring a bundled runtime** +- Completion: OCR installs from a downloaded payload on a clean profile (verified via the + fixture chain test); offline degradation path preserved. diff --git a/docs/features/plugin-remote-distribution/spec.md b/docs/features/plugin-remote-distribution/spec.md new file mode 100644 index 0000000000..bf8baee630 --- /dev/null +++ b/docs/features/plugin-remote-distribution/spec.md @@ -0,0 +1,192 @@ +# Plugin Remote Distribution — RFC + +Status: accepted (2026-09-16). Decisions recorded in this spec supersede earlier discussion; +the interactive planning draft lives outside the repository and is not authoritative. + +## Context + +DeepChat ships heavyweight optional capabilities inside the installer: + +- the CUA (Computer Use) plugin bundles a native driver app (~tens of MB per platform); +- LightOCR ships model + native engine packages (~124 MB per platform). + +Both are already managed by the plugin host (`src/main/plugin/`), but `.dcplugin` packages are +only ever bundled at build time into `app.asar.unpacked/plugins`. The manifest `source.url` +field is a trust anchor that the host never fetches; there is no runtime download path. + +The installer is therefore larger than necessary for users who never use these capabilities, +and GitHub release downloads are unreliable in mainland China without a mirror strategy. + +Out of scope (decided 2026-09-15): DuckDB, Knowledge, and Memory stay fully built-in. Their +extraction paths are archived in the interactive plan draft and intentionally not part of this +spec. `runtime/node|uv` on-demand slimming remains an open follow-up. + +## Goals + +1. Reduce installer size by ~150-180 MB/platform by distributing CUA and LightOCR payloads as + remotely downloadable artifacts. +2. Provide a host-side remote installer for official plugins: download → sha256 verify → + `.dcplugin` verify → atomic install → register, with progress, cancel, retry, and corrupt + artifact isolation. +3. Mirror chain resolution so mainland users can download artifacts reliably, with tamper + resistance from catalog-pinned sha256 values. +4. Plugin versions update independently of app versions, gated by `engines.deepchat` ranges. +5. Default UX: silent download on first use (OCR) or on enable (CUA); no explicit download + confirmation step. + +## Non-goals + +- Runtime `npm install` for plugin dependencies. Artifacts are prebuilt, lockfile-reproduced, + checksummed packages produced by CI (existing `pnpm plugin:bundle` tooling). The Feishu + `npx` pattern is explicitly not extended. +- A remote marketplace or runtime-refreshable catalog. The catalog ships inside the app. +- DuckDB / Knowledge / Memory pluginization. +- Draft-release testing support. Draft assets are not anonymously downloadable and ghproxy + cannot proxy the authenticated assets API, so a token-auth download path would diverge from + the production path and skip mirror testing. +- Generalizing the CUA-only launch-guard/integrity machinery to all plugins (deferred; does + not block this feature). + +## Design + +### Catalog + +A static JSON document shipped at `resources/plugin-catalog.json`: + +```jsonc +{ + "schemaVersion": 1, + "artifacts": [ + { + "pluginId": "com.deepchat.plugins.cua", + "version": "1.0.4-beta.3", + "channel": "stable", // 'stable' | 'pre-release' + "minAppVersion": "1.1.2", + "displayName": "CUA Computer Use Runtime", + "targets": [ + { + "platform": "darwin", // NodeJS.Platform values used by the host + "arch": "arm64", + "url": "https://github.com/.../.dcplugin", + "sha256": "<64 hex>", + "size": 123456, + "mirrors": ["https:///"] // ordered, applied as url prefixes + } + ] + } + ] +} +``` + +- The stable app resolves only `channel: 'stable'` entries. Dev/test builds may point at + `pre-release` entries through the override hook. +- Dev-only override: `DEEPCHAT_PLUGIN_CATALOG` env var names a catalog file plus artifact + base directory (or HTTP base) used by e2e fixtures. Packaged builds ignore the override. +- `minAppVersion` plus the manifest `engines.deepchat` range are independent gates. + +### Artifact URL resolution + +Ordered attempt list per target, tried with the toolchains downloader pattern (probe with +short connect timeout, fall through on failure): + +1. the app's configured HTTP proxy, if set (proxy takes GitHub direct with it); +2. GitHub direct, short connect timeout; +3. each mirror prefix from `mirrors` in order (`mirror + url` concatenation). + +Every attempt verifies the downloaded bytes against the catalog-pinned `sha256` before any +filesystem move, so a mirror cannot serve tampered content. Size mismatch aborts early. + +### PluginRemoteInstaller + +New module in `src/main/plugin/`: + +- `install(catalogEntry, target)` — staging download under + `userData/plugins/.staging//`, sha256 verification, `.dcplugin` package verification + (reuse the existing checksums.json + path-escape checks used for bundled packages), atomic + move into the plugin install root, then hand off to the existing official-plugin + registration path. +- Progress reporting (bytes / total / phase), cancellation via AbortController, retry on + transient network errors, corrupt-artifact quarantine into the staging dir before cleanup. +- Concurrency: one install per plugin id at a time; later requests join or are rejected + while running. + +### Manifest extension + +`plugin.json` `runtime.install` gains `strategy: 'download'` alongside the existing +bundled-helper and guideUrl forms. For catalog-distributed plugins the host resolves the +artifact from the catalog rather than the manifest `source.url` (the manifest URL remains the +trust anchor for bundled packages). + +### UX + +- Plugins catalog page: catalog-distributed plugins show Install/Installing/Installed states + with progress and failure retry. +- OCR (phase P1b): first use with missing runtime triggers a background download through the + same installer; the triggering attachment skips text extraction for that turn; runtime + status surfaces through the existing `getRuntimeStatus` route and settings page. +- A single settings toggle "automatically download feature runtimes" (default on). + +### Testing (three layers) + +| Layer | Mechanism | Coverage | +|---|---|---| +| L1 local e2e, CI-repeatable, offline | `DEEPCHAT_PLUGIN_CATALOG` override + local static fixture server | download → verify → install → launch guard → enable → failure retry → degradation | +| L2 prerelease staging, real network | CI publishes rc artifacts to a prerelease release; dev-build catalog points at `pre-release` entries | real GitHub URLs, mirror chain, sha256, platform×arch naming | +| L3 release gate | `plugin:verify --remote ` fetches and verifies every remote artifact before promote | prevents catalog/asset drift | + +Rationale for prerelease over draft: draft assets require PAT-authenticated API downloads +and cannot be mirrored; prerelease assets are publicly fetchable but invisible to stable +apps because the shipped catalog never references `pre-release` entries. The catalog is the +distribution gate, not the GitHub release page. + +### Signing posture (macOS) + +All signing stays in CI (sign-cua-helper): Developer ID + hardened runtime + entitlements +travel inside the `.dcplugin`. The runtime launch guard (`cuaRuntimeIntegrity`) already +verifies on-disk signature properties and already supports `plugin:` detect paths, so +downloaded installs verify identically to bundled ones. Host-written files carry no +quarantine xattr, so Gatekeeper never assesses them and the `.dcplugin` needs no separate +notarization. CI signing must be kept because TCC (accessibility / screen capture) grants are +keyed to the code-signing identity; unsigned or ad-hoc helpers lose grants on every plugin +update. Unbundling also removes the `Contents/Helpers` nested app and its `signIgnore` +special case from the main app. + +Windows remains unsigned (no certificate today); app-spawned exes carry no MOTW and do not +trigger SmartScreen. Linux has no signing system. + +## Phases + +- P0 (this RFC's implementation slice): catalog schema + loader + override hook, + PluginRemoteInstaller with mirror chain, manifest `strategy: 'download'`, plugins catalog + page install UX, L1 e2e fixture. +- P1a: unbundle CUA from build scripts and installer; catalog entry; CI publishes + `.dcplugin` per platform; silent download on enable; in-place migration for existing + bundled installs. +- P1b: LightOCR payload becomes a downloadable runtime asset; first-use silent download with + per-turn degradation; auto-download settings toggle; plain files replace gzip-base64 + encoding for downloaded payloads. + +## Compatibility & migration + +- Existing bundled CUA installs keep working; catalog entries supersede bundled packages on + next app update (`ensureOfficialPluginInstallation` version refresh already preserves user + config). +- Plugin data (app_db/, config.json) is never touched by artifact install/uninstall. +- Rollback: removing the catalog entry reverts to bundled distribution. + +## Acceptance criteria + +1. A catalog-declared plugin installs from a remote artifact on a clean profile, passes + `.dcplugin` verification, registers, and its MCP/skills/settings contributions activate. +2. sha256 mismatch (corrupted or tampered artifact, including from a mirror) fails the + install, quarantines the staging copy, and leaves no partial install. +3. A failed or cancelled download can be retried to success without app restart. +4. The dev override hook drives the full install path from local fixtures without network. +5. Installer, catalog parsing, and mirror ordering have unit coverage; the L1 e2e covers the + happy path and the corrupt-artifact path. +6. Stable builds never resolve `pre-release` catalog entries. + +## Open questions + +- `runtime/node|uv` on-demand slimming (deferred, does not block this work). +- Self-hosted mirror domain choice (ops decision needed before P1a ships to stable). diff --git a/docs/guides/plugin-packaging.md b/docs/guides/plugin-packaging.md index 2236c1097c..f55489c712 100644 --- a/docs/guides/plugin-packaging.md +++ b/docs/guides/plugin-packaging.md @@ -164,6 +164,81 @@ Managed macOS helpers copied into the Electron app bundle: build/managed-helpers/ ``` +## Testing Remote Installs Without a Release + +The distribution catalog accepts plain `http` for loopback hosts, so the full download → sha256 +verify → `.dcplugin` verify → install → enable chain runs against a local static server. Draft +GitHub releases are not usable (their assets need authentication and cannot be mirrored). + +```bash +# 1. Build the package, then move it out of the discovery path: a development +# build also loads `.dcplugin` files from build/bundled-plugins/ directly. +pnpm run plugin:bundle -- --name cua --platform darwin --arch arm64 +mkdir -p /tmp/dc-fixture && mv build/bundled-plugins/*.dcplugin /tmp/dc-fixture/ + +# 2. Remove the staged runtime so the plugins/ source tree no longer resolves a +# driver; the host then treats the payload as missing and offers a download. +rm -rf plugins/cua/runtime + +# 3. Pin the artifact into a dev-only catalog. --channel pre-release keeps the +# entry invisible to packaged stable builds. +node scripts/plugin-catalog.mjs generate \ + --artifacts-dir /tmp/dc-fixture \ + --base-url http://127.0.0.1:8787 \ + --channel pre-release \ + --catalog /tmp/dc-catalog.json \ + --write + +# 4. Serve the artifacts and run the app against the override. +(cd /tmp/dc-fixture && python3 -m http.server 8787) & +DEEPCHAT_PLUGIN_CATALOG=/tmp/dc-catalog.json pnpm run dev +``` + +`DEEPCHAT_PLUGIN_CATALOG` is ignored in packaged builds. To exercise mirror fallback, pass +`--mirror http://127.0.0.1:8788/` (mirrors are URL prefixes) and point the second port at a +server that serves a corrupted copy: the pinned sha256 must reject it. For the OCR payload, +stage the runtime layout first (`node scripts/stage-ocr-runtime.mjs --platform darwin --arch +arm64 --out build/ocr-runtime-staging`, after `pnpm run build` and the platform's +`installRuntime` script), then pass `--runtime-dir +build/ocr-runtime-staging//app.asar.unpacked/runtime` so `generate` packages the +full closure — `runtime/ocr/**`, the helper, the pinned Node binary, and the light-ocr +packages the manifest references. Build the app with `DEEPCHAT_UNBUNDLE_OCR=1` so the +bundled copy is absent. + +Staging on a real prerelease (spec layer L2) is the next step up: publish the `.dcplugin` assets +to a `--prerelease` GitHub release, regenerate the catalog with the real base URL, and verify +with `pnpm run plugin:catalog:verify`. + +## Building Without the Bundled Payloads + +Two environment switches move an optional payload out of the app so the catalog serves it on +demand. Both default to off, and both keep producing the artifact the catalog needs to pin. + +| Switch | Effect when set to `1` | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DEEPCHAT_UNBUNDLE_CUA` | `plugin:bundle -- --name cua` writes to `build/remote-plugins/` instead of `build/bundled-plugins/` and skips staging the macOS managed helper | +| `DEEPCHAT_UNBUNDLE_OCR` | `afterPack` skips `packageLightOcrAssets`, so the app ships without `runtime/ocr` | + +`build/remote-plugins/` sits outside the electron-builder `extraResources` glob, and the macOS +helper is what `detect`'s `app-helper:` candidate resolves — skipping both is what makes the +driver genuinely absent rather than merely unreferenced. `plugin:verify --name cua` inverts with +the same switch and fails if the artifact turns up inside the packaged app, so a half-applied +flip cannot pass CI. + +```bash +# Package an unbundled build, then pin its artifact for the catalog. +DEEPCHAT_UNBUNDLE_CUA=1 DEEPCHAT_UNBUNDLE_OCR=1 pnpm run build:mac:arm64 +node scripts/plugin-catalog.mjs generate \ + --artifacts-dir build/remote-plugins \ + --base-url https://github.com/ThinkInAIXYZ/deepchat/releases/download/v \ + --write +``` + +The three `_package-*.yml` workflows declare `DEEPCHAT_UNBUNDLE_CUA: '0'`. Flipping it to `'1'` +is the release-side change; it also requires publishing `build/remote-plugins/*.dcplugin` as +release assets and adding them to the fail-closed assembly list. Flipping the OCR switch +additionally requires the packaged Light OCR smoke steps to stop expecting a bundled runtime. + ## CI and Release Native plugin bundling belongs to the three reusable package workflows: @@ -195,8 +270,9 @@ On macOS, Electron Builder also embeds `build/managed-helpers/DeepChat Computer ``` Each reusable target job verifies the expected bundled `.dcplugin` files inside the packaged app -before creating its package manifest. A missing required plugin fails the job. Linux ARM64 never -invokes CUA packaging, and direct CUA packaging for that unsupported target remains rejected. +before creating its package manifest. A missing required plugin fails the job, unless the plugin +was built unbundled, in which case its presence fails the job instead. Linux ARM64 never invokes +CUA packaging, and direct CUA packaging for that unsupported target remains rejected. Build and Release use distribution mode; package regression uses verification mode. The latter uploads diagnostics only, so unsigned macOS verification installers and their embedded plugins diff --git a/electron-builder.yml b/electron-builder.yml index 6a05fe9ab3..625f16090e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -55,6 +55,8 @@ extraResources: - from: ./resources/skills/ to: app.asar.unpacked/resources/skills filter: ['**/*'] + - from: ./resources/plugin-catalog.json + to: plugin-catalog.json - from: ./build/bundled-plugins/ to: app.asar.unpacked/plugins filter: ['**/*.dcplugin'] diff --git a/package.json b/package.json index db4bdd89db..dfe7e9def7 100644 --- a/package.json +++ b/package.json @@ -55,7 +55,9 @@ "plugin:package": "node scripts/plugin.mjs package", "plugin:bundle": "node scripts/plugin.mjs bundle", "plugin:verify": "node scripts/plugin.mjs verify", - "plugin:bundle:clean": "node -e \"const fs=require('fs'); fs.rmSync('build/bundled-plugins',{recursive:true,force:true}); fs.rmSync('build/managed-helpers',{recursive:true,force:true})\"", + "plugin:catalog": "node scripts/plugin-catalog.mjs generate", + "plugin:catalog:verify": "node scripts/plugin-catalog.mjs verify --platform all --arch all", + "plugin:bundle:clean": "node -e \"const fs=require('fs'); fs.rmSync('build/bundled-plugins',{recursive:true,force:true}); fs.rmSync('build/remote-plugins',{recursive:true,force:true}); fs.rmSync('build/managed-helpers',{recursive:true,force:true})\"", "plugin:cua:build": "node scripts/build-cua-plugin-runtime.mjs", "plugin:cua:build:mac:arm64": "node scripts/build-cua-plugin-runtime.mjs --platform darwin --arch arm64", "plugin:cua:build:mac:x64": "node scripts/build-cua-plugin-runtime.mjs --platform darwin --arch x64", diff --git a/resources/plugin-catalog.json b/resources/plugin-catalog.json new file mode 100644 index 0000000000..bbbf74c47e --- /dev/null +++ b/resources/plugin-catalog.json @@ -0,0 +1,4 @@ +{ + "schemaVersion": 1, + "artifacts": [] +} diff --git a/scripts/afterPack.js b/scripts/afterPack.js index 2c835fed18..9ea074276c 100644 --- a/scripts/afterPack.js +++ b/scripts/afterPack.js @@ -838,7 +838,14 @@ async function afterPack(context) { await copyFffNativePackages(context) await copyParcelWatcherNativePackages(context) await copyOpendalNativePackages(context) - await packageLightOcrAssets(context) + // Set DEEPCHAT_UNBUNDLE_OCR=1 to build without the bundled OCR runtime and + // exercise the remote distribution flow (download / manual install). + // Release builds keep bundling until the published catalog is stable. + if (process.env.DEEPCHAT_UNBUNDLE_OCR !== '1') { + await packageLightOcrAssets(context) + } else { + console.info('[afterPack] DEEPCHAT_UNBUNDLE_OCR=1: skipping bundled OCR runtime') + } await validateNativeKitPrebuilds(context) await encodeMacVssExtension(context) diff --git a/scripts/ci/package-manifest.mjs b/scripts/ci/package-manifest.mjs index dddc5bf5c3..aea0987d70 100644 --- a/scripts/ci/package-manifest.mjs +++ b/scripts/ci/package-manifest.mjs @@ -18,7 +18,10 @@ import { promisify } from 'node:util' import { validateAppleTeamId } from '../apple-notarization.js' import { verifyDmgDistribution } from '../notarize-dmg.js' -import { verifyCuaMacHelperDistribution } from './verify-cua-macos-helper.mjs' +import { + verifyCuaMacHelperDistribution, + verifyCuaMacHelperUnbundled +} from './verify-cua-macos-helper.mjs' import { createDefaultPackageSizePolicy, DARWIN_DISTRIBUTION_CHECK_NAMES, @@ -555,9 +558,10 @@ export function validateInstallerSizeReport( } } -export function validateSmokeReports(reports, expectedTarget) { +export function validateSmokeReports(reports, expectedTarget, { allowMissingLightOcr = false } = {}) { const lightOcrReports = reports.filter(({ name }) => name.startsWith('light-ocr-smoke-')) if (lightOcrReports.length === 0) { + if (allowMissingLightOcr) return throw new Error(`Missing Light OCR smoke report for ${expectedTarget}`) } for (const lightOcrReport of lightOcrReports) { @@ -606,6 +610,8 @@ export async function createPackageManifest({ actualSourceSha, macAppPath, appleTeamId, + allowMissingLightOcrReports = false, + cuaUnbundled = false, verifyMacApp = verifyMacAppDistribution, verifyCuaMacHelper = verifyCuaMacHelperDistribution, verifyMacZip = verifyMacZipDistribution, @@ -687,7 +693,9 @@ export async function createPackageManifest({ await copyReport(path.resolve(reportPath), reportsDirectory, stagedReportNames) ) } - validateSmokeReports(stagedReports, definition.id) + validateSmokeReports(stagedReports, definition.id, { + allowMissingLightOcr: allowMissingLightOcrReports + }) let installerSize = 'not-run' if (installerSizeReportPath) { @@ -721,9 +729,16 @@ export async function createPackageManifest({ resolvedOutputDirectory, updaterPayload.storagePath ) - await verifyCuaMacHelper(resolvedAppPath, { teamId: appleTeamId }) + // When cua ships remotely (DEEPCHAT_UNBUNDLE_CUA=1) the helper travels + // inside the .dcplugin instead of Contents/Helpers, so the distribution + // check inverts to assert its absence from the app and updater ZIP. + const cuaHelperVerifier = cuaUnbundled ? verifyCuaMacHelperUnbundled : verifyCuaMacHelper + await cuaHelperVerifier(resolvedAppPath, { teamId: appleTeamId }) await verifyMacApp(resolvedAppPath, { teamId: appleTeamId }) - await verifyMacZip(resolvedZipPath, { teamId: appleTeamId }) + await verifyMacZip(resolvedZipPath, { + teamId: appleTeamId, + ...(cuaUnbundled && { verifyCuaMacHelper: verifyCuaMacHelperUnbundled }) + }) await verifyMacDmg(resolvedDmgPath, { teamId: appleTeamId }) for (const checkName of DARWIN_DISTRIBUTION_CHECK_NAMES) { checks[checkName] = 'passed' @@ -819,7 +834,11 @@ export async function main(argv = process.argv.slice(2)) { runAttempt: options['workflow-run-attempt'] }, macAppPath: options['mac-app-path'], - appleTeamId: process.env.DEEPCHAT_APPLE_NOTARY_TEAM_ID + appleTeamId: process.env.DEEPCHAT_APPLE_NOTARY_TEAM_ID, + // The package workflows export the unbundle switches; honoring them here + // keeps the manifest step consistent with what the app actually contains. + allowMissingLightOcrReports: process.env.DEEPCHAT_UNBUNDLE_OCR === '1', + cuaUnbundled: process.env.DEEPCHAT_UNBUNDLE_CUA === '1' }) } diff --git a/scripts/ci/verify-cua-macos-helper.mjs b/scripts/ci/verify-cua-macos-helper.mjs index a350fa6451..23f926a473 100644 --- a/scripts/ci/verify-cua-macos-helper.mjs +++ b/scripts/ci/verify-cua-macos-helper.mjs @@ -1,4 +1,5 @@ import { execFile } from 'node:child_process' +import { existsSync } from 'node:fs' import { lstat, mkdtemp, @@ -316,3 +317,24 @@ export async function verifyCuaMacHelperDistribution( inspectedMachOCount: inspections.length } } + +/** + * Unbundled (remote-distribution) counterpart of verifyCuaMacHelperDistribution: + * the helper must not ship inside the app because it travels inside the + * remotely installed .dcplugin. Asserting its absence keeps the distribution + * check fail-closed when DEEPCHAT_UNBUNDLE_CUA=1. + */ +export async function verifyCuaMacHelperUnbundled(macAppPath, _options = {}) { + const helperAppPath = path.join( + macAppPath, + 'Contents', + 'Helpers', + CUA_DARWIN_HELPER_APP_NAME + ) + if (existsSync(helperAppPath)) { + throw new Error( + `Unbundled CUA helper must not ship inside the app: ${helperAppPath}` + ) + } + return { helperAppPath, unbundled: true } +} diff --git a/scripts/plugin-catalog.mjs b/scripts/plugin-catalog.mjs new file mode 100644 index 0000000000..c0a575b16b --- /dev/null +++ b/scripts/plugin-catalog.mjs @@ -0,0 +1,449 @@ +// Plugin distribution catalog tooling. +// +// generate — scan built artifacts (.dcplugin packages + the OCR runtime +// payload), pin their sha256/size, and merge entries into +// resources/plugin-catalog.json. +// verify — fetch every catalog artifact (canonical URL first, then each +// mirror) and verify the pinned sha256/size. Release gate for +// "the published assets match the catalog" (spec §4.4 L3). +// +// Usage: +// node scripts/plugin-catalog.mjs generate [--artifacts-dir ] +// [--base-url ] [--mirror ]... [--channel stable|pre-release] +// [--min-app-version ] [--runtime-dir ] [--write] +// node scripts/plugin-catalog.mjs verify [--catalog ] +// [--platform

] [--arch ] [--timeout-ms ] +import { createHash } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { zipSync, unzipSync } from 'fflate' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const repositoryRoot = path.resolve(scriptDir, '..') +const CATALOG_PATH = path.join(repositoryRoot, 'resources', 'plugin-catalog.json') +const CATALOG_SCHEMA_VERSION = 1 +const OCR_RUNTIME_ASSET_ID = 'light-ocr' +const PLUGIN_PACKAGE_SUFFIX = '.dcplugin' +const SHA256_PATTERN = /^[a-f0-9]{64}$/ +const PLATFORMS = new Set(['darwin', 'win32', 'linux']) +const ARCHS = new Set(['arm64', 'x64']) + +const appVersion = JSON.parse( + readFileSync(path.join(repositoryRoot, 'package.json'), 'utf8') +).version + +function parseArgs(argv) { + const args = { + action: argv[0], + baseUrl: null, + mirrors: [], + channel: 'stable', + minAppVersion: appVersion, + artifactsDir: path.join(repositoryRoot, 'build', 'bundled-plugins'), + runtimeDir: path.join(repositoryRoot, 'runtime'), + catalogPath: CATALOG_PATH, + platform: process.env.TARGET_PLATFORM || process.platform, + arch: process.env.TARGET_ARCH || process.arch, + write: false, + timeoutMs: 60_000 + } + if (!args.action || !['generate', 'verify'].includes(args.action)) { + console.error( + 'Usage: node scripts/plugin-catalog.mjs [options] — see header comment' + ) + process.exit(1) + } + for (let i = 1; i < argv.length; i += 1) { + const argument = argv[i] + if (argument === '--base-url') { + args.baseUrl = argv[++i] + } else if (argument === '--mirror') { + const mirror = argv[++i] + if (!mirror) { + console.error('Missing value for --mirror') + process.exit(1) + } + args.mirrors.push(mirror) + } else if (argument === '--channel') { + args.channel = argv[++i] + } else if (argument === '--min-app-version') { + args.minAppVersion = argv[++i] + } else if (argument === '--artifacts-dir') { + args.artifactsDir = path.resolve(argv[++i]) + } else if (argument === '--runtime-dir') { + args.runtimeDir = path.resolve(argv[++i]) + } else if (argument === '--catalog') { + args.catalogPath = path.resolve(argv[++i]) + } else if (argument === '--platform') { + args.platform = String(argv[++i]).toLowerCase() + } else if (argument === '--arch') { + args.arch = String(argv[++i]).toLowerCase() + } else if (argument === '--write') { + args.write = true + } else if (argument === '--timeout-ms') { + args.timeoutMs = Number(argv[++i]) + } else { + console.error(`Unknown argument: ${argument}`) + process.exit(1) + } + } + if (args.action === 'generate' && !args.baseUrl) { + console.error('generate requires --base-url (the release download root)') + process.exit(1) + } + if (!['stable', 'pre-release'].includes(args.channel)) { + console.error('--channel must be stable or pre-release') + process.exit(1) + } + return args +} + +function sha256File(filePath) { + return createHash('sha256').update(readFileSync(filePath)).digest('hex') +} + +function readJson(filePath) { + return JSON.parse(readFileSync(filePath, 'utf8')) +} + +// https-only for remote hosts; plain http is allowed for loopback hosts so +// local fixture flows can drive installs without a TLS server. +function assertHttpUrl(value, label) { + try { + const url = new URL(value) + const isLoopback = + url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]' + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && isLoopback)) { + throw new Error('not https (or loopback http)') + } + } catch (error) { + throw new Error( + `${label} must be an https URL (plain http only for loopback): ${value} — ${error.message}` + ) + } +} + +function readCatalog(catalogPath) { + if (!existsSync(catalogPath)) { + throw new Error(`Catalog not found: ${catalogPath}`) + } + const catalog = readJson(catalogPath) + if (catalog.schemaVersion !== CATALOG_SCHEMA_VERSION) { + throw new Error(`Unsupported catalog schemaVersion: ${catalog.schemaVersion}`) + } + return catalog +} + +function buildTargetEntry(options, url, filePath, platform, arch) { + const size = statSync(filePath).size + const sha256 = sha256File(filePath) + return { platform, arch, url, sha256, size, mirrors: [...options.mirrors] } +} + +function platformArchFromArtifactName(fileName, suffix) { + const match = new RegExp(`-(darwin|win32|linux)-(arm64|x64)\\${suffix}$`).exec(fileName) + return match ? { platform: match[1], arch: match[2] } : null +} + +function generate(args) { + assertHttpUrl(args.baseUrl, '--base-url') + for (const mirror of args.mirrors) { + assertHttpUrl(mirror, '--mirror') + } + if (!existsSync(args.artifactsDir)) { + throw new Error(`Artifacts directory not found: ${args.artifactsDir}`) + } + + const artifacts = [] + const packagesByName = new Map() + for (const entry of readdirSync(args.artifactsDir, { withFileTypes: true })) { + if (!entry.isFile() || !entry.name.endsWith(PLUGIN_PACKAGE_SUFFIX)) continue + const filePath = path.join(args.artifactsDir, entry.name) + const target = platformArchFromArtifactName(entry.name, PLUGIN_PACKAGE_SUFFIX) + if (!target) { + throw new Error( + `Cannot derive platform/arch from artifact name: ${entry.name} (expected --.dcplugin)` + ) + } + const files = unzipSync(new Uint8Array(readFileSync(filePath))) + const manifestFile = files['plugin.json'] + if (!manifestFile) { + throw new Error(`Plugin package is missing plugin.json: ${entry.name}`) + } + const manifest = JSON.parse(Buffer.from(manifestFile).toString('utf8')) + if (!manifest.id || !manifest.version) { + throw new Error(`Plugin package manifest is incomplete: ${entry.name}`) + } + const url = `${args.baseUrl.replace(/\/$/, '')}/${entry.name}` + const targetEntry = buildTargetEntry(args, url, filePath, target.platform, target.arch) + const existing = packagesByName.get(manifest.id) + if (existing) { + existing.targets.push(targetEntry) + } else { + packagesByName.set(manifest.id, { + pluginId: manifest.id, + version: manifest.version, + channel: args.channel, + displayName: manifest.name, + minAppVersion: args.minAppVersion, + targets: [targetEntry] + }) + } + } + artifacts.push(...packagesByName.values()) + + const runtimeAssets = [] + const ocrRuntimeDir = path.join(args.runtimeDir, 'ocr') + const ocrManifestPath = path.join(ocrRuntimeDir, 'manifest.json') + if (existsSync(ocrManifestPath)) { + const manifest = readJson(ocrManifestPath) + const helperPath = manifest.paths?.helper + if (!helperPath) { + throw new Error('OCR runtime manifest does not declare a helper path') + } + // The payload mirrors the unpacked app root layout that the runtime + // resolver validates after installation: runtime/ocr/**, the staged + // helper closure, the pinned Node binary, and the light-ocr packages + // declared by manifest.paths. Point --runtime-dir at a staged layout + // (scripts/stage-ocr-runtime.mjs); a repository root has no such layout + // and would produce an uninstallable payload. + const unpackedRoot = path.dirname(args.runtimeDir) + const payloadFiles = {} + collectPayloadEntries(ocrRuntimeDir, ocrRuntimeDir, payloadFiles, 'runtime/ocr') + const collectPayloadDir = (relativeDir, label) => { + const absoluteDir = path.join(unpackedRoot, relativeDir) + if (!existsSync(absoluteDir)) { + throw new Error(`OCR runtime payload directory not found: ${label} at ${absoluteDir}`) + } + collectPayloadEntries(absoluteDir, absoluteDir, payloadFiles, relativeDir) + } + const collectPayloadFile = (relativePath, label) => { + const absolutePath = path.join(unpackedRoot, relativePath) + if (!existsSync(absolutePath)) { + throw new Error(`OCR runtime payload file not found: ${label} at ${absolutePath}`) + } + payloadFiles[relativePath] = new Uint8Array(readFileSync(absolutePath)) + } + const helperDir = path.dirname(helperPath) + if (helperDir && helperDir !== '.') { + collectPayloadDir(helperDir, 'helper closure') + } else { + collectPayloadFile(helperPath, 'helper entry') + } + for (const key of ['facade', 'runtime', 'bundle', 'native']) { + const relativeDir = manifest.paths?.[key] + if (relativeDir) collectPayloadDir(relativeDir, `${key} package`) + } + if (manifest.paths?.bundle) { + // The resolver verifies the model bundle identity against the package + // manifest one level above the bundle directory. + const bundleParent = path.dirname(manifest.paths.bundle) + if (bundleParent && bundleParent !== '.') { + collectPayloadFile(`${bundleParent}/package.json`, 'bundle package manifest') + } + } + if (manifest.paths?.node) { + collectPayloadFile(manifest.paths.node, 'Node binary') + } + const payload = zipSync(payloadFiles, { level: 6 }) + const fileName = `${OCR_RUNTIME_ASSET_ID}-${manifest.bundleId}-${manifest.platform}-${manifest.arch}.zip` + const url = `${args.baseUrl.replace(/\/$/, '')}/${fileName}` + const targetEntry = { + platform: manifest.platform, + arch: manifest.arch, + url, + sha256: createHash('sha256').update(payload).digest('hex'), + size: payload.length, + mirrors: [...args.mirrors] + } + runtimeAssets.push({ + id: OCR_RUNTIME_ASSET_ID, + version: manifest.bundleId, + channel: args.channel, + displayName: 'LightOCR Runtime', + minAppVersion: args.minAppVersion, + targets: [targetEntry] + }) + if (args.write) { + const outDir = args.artifactsDir + mkdirSync(outDir, { recursive: true }) + writeFileSync(path.join(outDir, fileName), Buffer.from(payload)) + console.log(`Packaged ${path.relative(repositoryRoot, path.join(outDir, fileName))}`) + } + } + + const catalog = existsSync(args.catalogPath) + ? readCatalog(args.catalogPath) + : { schemaVersion: CATALOG_SCHEMA_VERSION, artifacts: [] } + // Regeneration replaces entries with the same ids and keeps the rest. + const regeneratedPluginIds = new Set(artifacts.map((entry) => entry.pluginId)) + const mergedArtifacts = [ + ...artifacts, + ...(catalog.artifacts ?? []).filter((entry) => !regeneratedPluginIds.has(entry.pluginId)) + ] + const regeneratedAssetIds = new Set(runtimeAssets.map((entry) => entry.id)) + const mergedRuntimeAssets = [ + ...runtimeAssets, + ...(catalog.runtimeAssets ?? []).filter((entry) => !regeneratedAssetIds.has(entry.id)) + ] + const nextCatalog = { + schemaVersion: CATALOG_SCHEMA_VERSION, + artifacts: mergedArtifacts, + runtimeAssets: mergedRuntimeAssets + } + validateCatalogShape(nextCatalog) + + if (args.write) { + writeFileSync(args.catalogPath, `${JSON.stringify(nextCatalog, null, 2)}\n`) + console.log(`Updated ${path.relative(repositoryRoot, args.catalogPath)}`) + } else { + console.log(JSON.stringify(nextCatalog, null, 2)) + } +} + +function collectPayloadEntries(rootDir, currentDir, into, prefix = '') { + for (const entry of readdirSync(currentDir, { withFileTypes: true })) { + const absolute = path.join(currentDir, entry.name) + if (entry.isDirectory()) { + collectPayloadEntries(rootDir, absolute, into, prefix) + continue + } + if (!entry.isFile()) { + // Symlinks and other special entries would silently drop payload + // content; a staged layout only contains regular files. + throw new Error(`OCR runtime payload must only contain regular files: ${absolute}`) + } + const relative = path.relative(rootDir, absolute).split(path.sep).join('/') + into[`${prefix}${prefix ? '/' : ''}${relative}`] = new Uint8Array(readFileSync(absolute)) + } +} + +function validateCatalogShape(catalog) { + for (const artifact of catalog.artifacts ?? []) { + if (!artifact.pluginId || !artifact.version || !Array.isArray(artifact.targets)) { + throw new Error(`Catalog artifact entry is incomplete: ${JSON.stringify(artifact).slice(0, 120)}`) + } + validateTargets(artifact.targets, `plugin ${artifact.pluginId}`) + } + for (const asset of catalog.runtimeAssets ?? []) { + if (!asset.id || !asset.version || !Array.isArray(asset.targets)) { + throw new Error(`Catalog runtime asset entry is incomplete: ${JSON.stringify(asset).slice(0, 120)}`) + } + validateTargets(asset.targets, `runtime asset ${asset.id}`) + } +} + +function validateTargets(targets, label) { + if (targets.length === 0) { + throw new Error(`Catalog ${label} has no targets`) + } + for (const target of targets) { + if (!PLATFORMS.has(target.platform) || !ARCHS.has(target.arch)) { + throw new Error( + `Catalog ${label} has an invalid platform/arch pair: ${target.platform}/${target.arch}` + ) + } + if (!SHA256_PATTERN.test(target.sha256 ?? '')) { + throw new Error(`Catalog ${label} has an invalid sha256 pin`) + } + if (!Number.isInteger(target.size) || target.size <= 0) { + throw new Error(`Catalog ${label} has an invalid size`) + } + assertHttpUrl(target.url, `Catalog ${label} url`) + for (const mirror of target.mirrors ?? []) { + assertHttpUrl(mirror, `Catalog ${label} mirror`) + } + } +} + +async function fetchWithTimeout(url, timeoutMs) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + try { + const response = await fetch(url, { redirect: 'follow', signal: controller.signal }) + return response + } finally { + clearTimeout(timer) + } +} + +async function verifyTarget(url, expected, timeoutMs) { + const response = await fetchWithTimeout(url, timeoutMs) + if (!response.ok) { + throw new Error(`HTTP ${response.status}`) + } + const bytes = new Uint8Array(await response.arrayBuffer()) + if (bytes.length !== expected.size) { + throw new Error(`size mismatch: expected ${expected.size}, got ${bytes.length}`) + } + const sha256 = createHash('sha256').update(bytes).digest('hex') + if (sha256 !== expected.sha256) { + throw new Error(`sha256 mismatch: expected ${expected.sha256}, got ${sha256}`) + } +} + +async function verify(args) { + const catalog = readCatalog(args.catalogPath) + validateCatalogShape(catalog) + const targets = [] + for (const artifact of catalog.artifacts ?? []) { + for (const target of artifact.targets) { + if ( + (args.platform === 'all' || target.platform === args.platform) && + (args.arch === 'all' || target.arch === args.arch) + ) { + targets.push({ label: `plugin ${artifact.pluginId}`, target }) + } + } + } + for (const asset of catalog.runtimeAssets ?? []) { + for (const target of asset.targets) { + if ( + (args.platform === 'all' || target.platform === args.platform) && + (args.arch === 'all' || target.arch === args.arch) + ) { + targets.push({ label: `runtime asset ${asset.id}`, target }) + } + } + } + if (targets.length === 0) { + throw new Error('No catalog targets match the verification filters') + } + + let failures = 0 + for (const { label, target } of targets) { + const candidates = [target.url, ...(target.mirrors ?? []).map((mirror) => `${mirror}${target.url}`)] + let verified = false + for (const url of candidates) { + try { + await verifyTarget(url, target, args.timeoutMs) + console.log(`ok ${label} ${url}`) + verified = true + break + } catch (error) { + console.warn(`fail ${label} ${url}: ${error.message}`) + } + } + if (!verified) { + // Try the canonical URL once more without mirrors to surface its error. + failures += 1 + } + } + if (failures > 0) { + throw new Error(`${failures} catalog target(s) failed verification`) + } + console.log(`verified ${targets.length} catalog target(s)`) +} + +const args = parseArgs(process.argv.slice(2)) +try { + if (args.action === 'generate') { + generate(args) + } else { + await verify(args) + } +} catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) +} diff --git a/scripts/plugin.mjs b/scripts/plugin.mjs index 183ef9fa81..afe77e44ac 100644 --- a/scripts/plugin.mjs +++ b/scripts/plugin.mjs @@ -5,6 +5,19 @@ import path from 'node:path' const OFFICIAL_PLUGIN_SOURCE = 'deepchat-official' const CUA_MANAGED_HELPER_APP = 'DeepChat Computer Use.app' const CUA_MANAGED_HELPER_EXECUTABLE = 'deepchat-cua-driver' +const BUNDLED_PLUGIN_DIR = path.join('build', 'bundled-plugins') +const REMOTE_PLUGIN_DIR = path.join('build', 'remote-plugins') + +/** + * Set DEEPCHAT_UNBUNDLE_CUA=1 to build cua for remote distribution instead of + * shipping it inside the app: the `.dcplugin` lands in build/remote-plugins, + * which the electron-builder extraResources glob does not read, and the macOS + * managed helper is not staged into `Contents/Helpers`. The artifact is still + * produced so release tooling can pin and publish it. + */ +function isUnbundled(pluginName) { + return pluginName === 'cua' && process.env.DEEPCHAT_UNBUNDLE_CUA === '1' +} function parseArgs(argv) { const args = { @@ -140,6 +153,13 @@ function verifyArtifacts(options) { for (const plugin of expected) { const fileName = artifactFileName(plugin, options.platform, options.arch) const artifactPath = path.join(pluginRoot, fileName) + if (isUnbundled(plugin.name)) { + if (existsSync(artifactPath)) { + throw new Error(`Unbundled official plugin must not ship inside the app: ${artifactPath}`) + } + console.log(`Verified ${fileName} is absent (distributed remotely)`) + continue + } if (!existsSync(artifactPath)) { throw new Error(`Missing bundled official plugin: ${artifactPath}`) } @@ -192,7 +212,8 @@ try { execFileSync('node', buildArgs, { stdio: 'inherit' }) } - if (args.action === 'bundle' && args.name === 'cua') { + const unbundled = isUnbundled(args.name) + if (args.action === 'bundle' && args.name === 'cua' && !unbundled) { stageCuaManagedHelper(pluginDir, args.platform, args.arch) } @@ -203,7 +224,9 @@ try { if (args.platform) pkgArgs.push('--target-platform', args.platform) if (args.arch) pkgArgs.push('--target-arch', args.arch) if (args.purpose) pkgArgs.push('--purpose', args.purpose) - if (args.action === 'bundle') pkgArgs.push('--out', path.resolve('build/bundled-plugins')) + if (args.action === 'bundle') { + pkgArgs.push('--out', path.resolve(unbundled ? REMOTE_PLUGIN_DIR : BUNDLED_PLUGIN_DIR)) + } pkgArgs.push(pluginDir) execFileSync('node', pkgArgs, { stdio: 'inherit' }) diff --git a/scripts/stage-ocr-runtime.mjs b/scripts/stage-ocr-runtime.mjs new file mode 100644 index 0000000000..792aa3a9d0 --- /dev/null +++ b/scripts/stage-ocr-runtime.mjs @@ -0,0 +1,90 @@ +// Stages the packaged OCR runtime layout into a directory without running +// electron-builder, so DEEPCHAT_UNBUNDLE_OCR=1 builds can still produce the +// remote-distribution payload via plugin:catalog generate. +// +// Usage: +// node scripts/stage-ocr-runtime.mjs --platform +// --arch --out

+// +// Requires the same inputs the bundled layout needs: the light-ocr packages +// (pnpm install), the repository runtime/node binary (installRuntime::), +// and the built out/main helper (pnpm run build). The staged unpacked root is +// printed and feeds plugin:catalog generate --runtime-dir /runtime. +import { cpSync, existsSync, readFileSync, rmSync } from 'node:fs' +import path from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' + +import { packageLightOcrAssets } from './afterPack.js' + +const scriptDir = path.dirname(fileURLToPath(import.meta.url)) +const repositoryRoot = path.resolve(scriptDir, '..') + +function parseArgs(argv) { + const args = { platform: process.platform, arch: process.arch, out: null } + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index] + if (argument === '--platform') { + args.platform = argv[++index] + } else if (argument === '--arch') { + args.arch = argv[++index] + } else if (argument === '--out') { + args.out = argv[++index] + } else { + throw new Error(`Unknown argument: ${argument}`) + } + } + for (const [key, value] of Object.entries(args)) { + if (typeof value !== 'string' || value.length === 0) { + throw new Error(`Missing or empty value for --${key}`) + } + } + if (!args.out) throw new Error('--out is required') + return args +} + +function resolveResourcesDir(platform, appOutDir) { + // Mirrors getResourcesDir in afterPack.js for a synthetic context. + if (platform === 'darwin') { + return path.join(appOutDir, 'DeepChat.app', 'Contents', 'Resources') + } + return path.join(appOutDir, 'resources') +} + +async function main() { + const { platform, arch, out } = parseArgs(process.argv.slice(2)) + const appOutDir = path.resolve(out) + rmSync(appOutDir, { recursive: true, force: true }) + const resourcesDir = resolveResourcesDir(platform, appOutDir) + const unpackedRoot = path.join(resourcesDir, 'app.asar.unpacked') + + // The bundled app receives runtime/node through electron-builder + // extraResources when the repository runtime carries it; mirror whatever + // the repository has so the staged manifest matches the bundled layout. + // Node is optional: builds without it simply omit manifest.paths.node. + const repoNodeDir = path.join(repositoryRoot, 'runtime', 'node') + if (existsSync(repoNodeDir)) { + cpSync(repoNodeDir, path.join(unpackedRoot, 'runtime', 'node'), { + recursive: true, + dereference: true + }) + } + + await packageLightOcrAssets({ + packager: { projectDir: repositoryRoot, appInfo: { productFilename: 'DeepChat' } }, + electronPlatformName: platform, + arch, + appOutDir + }) + + const manifestPath = path.join(unpackedRoot, 'runtime', 'ocr', 'manifest.json') + const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) + if (manifest.supported !== true) { + throw new Error( + `Staged OCR runtime manifest is not an installable payload: ${manifestPath}` + ) + } + console.log(`[stage-ocr-runtime] staged installable OCR runtime layout at ${unpackedRoot}`) +} + +await main() diff --git a/src/main/app/composition.ts b/src/main/app/composition.ts index e1b392fee6..738b271928 100644 --- a/src/main/app/composition.ts +++ b/src/main/app/composition.ts @@ -66,9 +66,14 @@ import { RuntimeHelper } from '@/lib/runtimeHelper' import { mergeDetectionEnv, noteNodeDemandFromMcp, ToolchainService } from '@/toolchains' import { ToolchainResolutionError } from '@/toolchains/errors' import { createToolchainRoutes } from '@/toolchains/routes' -import { AttachmentCapabilityRouter } from '@/ocr/attachmentCapabilityRouter' +import { + AttachmentCapabilityRouter, + type AttachmentOcrRuntimePort +} from '@/ocr/attachmentCapabilityRouter' import { OcrRuntimeService } from '@/ocr/ocrRuntimeService' import { OcrSettings } from '@/ocr/ocrSettings' +import { OcrRuntimeAssetInstaller } from '@/ocr/runtimeAssetInstaller' +import { OcrRuntimeInstallCoordinator } from '@/ocr/runtimeInstallCoordinator' import { createOcrRoutes } from '@/ocr/routes' import { McpService } from '../mcp' import { ImportMode, SyncService, type SyncImportDatabasePort } from '../sync' @@ -206,6 +211,10 @@ import type { RemoteServiceLike } from '../remote/ports' import { PluginService, type PluginServicePort } from '../plugin' import { createPluginRoutes } from '../plugin/routes' import { PluginRuntimeSupervisor } from '../plugin/runtimeSupervisor' +import { PluginCatalogService, LIGHT_OCR_RUNTIME_ASSET_ID } from '../plugin/catalog' +import { PluginRemoteInstaller } from '../plugin/remoteInstaller' +import { sweepStagingRoot } from '@/lib/remoteArtifactDownload' +import { PLUGIN_INSTALL_DIRECTORY } from '@shared/pluginPaths' import { AgentRepository } from '../agent/repository' import { AgentDatabase } from '@/agent/data/database' import { DeepChatDefaults } from '../agent/deepchat/defaults' @@ -1231,6 +1240,32 @@ export async function createMainProcessControl(dependencies: { shortcutPresenter = new ShortcutPresenter(desktopSettings, windowPresenter, publishDeepchatEvent) fileService = new FileService(dependencies.settingsStore) ocrSettings = new OcrSettings(dependencies.settingsStore, publishDeepchatEvent) + const pluginCatalogService = new PluginCatalogService({ + appPath: app.getAppPath(), + resourcesPath: process.resourcesPath, + isPackaged: app.isPackaged, + platform: process.platform, + arch: process.arch, + appVersion: app.getVersion(), + env: process.env + }) + const ocrRuntimeAssetInstallRoot = path.join(app.getPath('userData'), 'runtimes', 'ocr') + // Startup-only sweep: a crash mid-download leaves staging directories + // behind; nothing can be running this early, so all of them are stale. + sweepStagingRoot(path.join(ocrRuntimeAssetInstallRoot, '.staging')) + const ocrRuntimeAssetInstaller = new OcrRuntimeAssetInstaller({ + installRoot: () => ocrRuntimeAssetInstallRoot, + stagingRoot: () => path.join(ocrRuntimeAssetInstallRoot, '.staging'), + platform: process.platform, + arch: process.arch, + onProgress: (state) => + publishDeepchatEvent('ocr.runtimeInstall.progress', { ...state, updatedAt: Date.now() }) + }) + const ocrRuntimeInstallCoordinator = new OcrRuntimeInstallCoordinator({ + resolveAsset: () => pluginCatalogService.resolveRuntimeAsset(LIGHT_OCR_RUNTIME_ASSET_ID), + installer: ocrRuntimeAssetInstaller, + onInstalled: () => ocrRuntimeService?.refreshAvailability() + }) const runtimeHelper = RuntimeHelper.getInstance() runtimeHelper.initializeRuntimes() const toolchainHomeDir = app.getPath('home') @@ -1270,6 +1305,7 @@ export async function createMainProcessControl(dependencies: { appPath: app.getAppPath(), isPackaged: app.isPackaged, nodeRuntimePath: null, + installedRuntimeRoots: () => ocrRuntimeAssetInstaller.listInstalledRoots(), resolveNode: () => { const resolved = toolchainService.resolve('node', { purpose: 'ocr' }) if (!resolved.version) { @@ -1290,8 +1326,20 @@ export async function createMainProcessControl(dependencies: { artifactSpool, log: logger }) + const attachmentOcrRuntimePort: AttachmentOcrRuntimePort = { + getAvailability: async () => { + const availability = await ocrRuntimeService.getAvailability() + if (availability.status === 'unavailable') { + ocrRuntimeInstallCoordinator.maybeStartInstall() + } + return availability + }, + extract: (input) => ocrRuntimeService.extract(input), + extractBatch: (inputs) => ocrRuntimeService.extractBatch(inputs), + extractDocument: (input) => ocrRuntimeService.extractDocument(input) + } const attachmentRouter = new AttachmentCapabilityRouter({ - extraction: ocrRuntimeService, + extraction: attachmentOcrRuntimePort, getAutomaticOcrEnabled: () => ocrSettings.getAutomaticExtractionEnabled(), getBackendPreference: () => ocrSettings.getBackend(), getMaxFileSize: () => dependencies.settingsStore.get('maxFileSize') ?? 30 * 1024 * 1024, @@ -1761,6 +1809,16 @@ export async function createMainProcessControl(dependencies: { settingsWindow: pluginSettingsWindow, runtimeSupervisor: pluginRuntimeSupervisor }) + const pluginRemoteInstaller = new PluginRemoteInstaller({ + stagingRoot: () => path.join(app.getPath('userData'), PLUGIN_INSTALL_DIRECTORY, '.staging'), + installPackage: (packagePath, expectedPluginId) => + pluginService.installOfficialPluginPackage(packagePath, expectedPluginId), + onProgress: (state) => + publishDeepchatEvent('plugins.install.progress', { ...state, updatedAt: Date.now() }) + }) + // Startup-only sweep: a crash mid-download leaves staging directories + // behind; nothing can be running this early, so all of them are stale. + sweepStagingRoot(path.join(app.getPath('userData'), PLUGIN_INSTALL_DIRECTORY, '.staging')) // Initialize Skill Sync service skillSyncService = new SkillSyncService(skillService, skillSettings, publishDeepchatEvent) @@ -2633,6 +2691,13 @@ export async function createMainProcessControl(dependencies: { } async function destroy(): Promise { + // Drain remote installers first: an in-flight download must not call + // into pluginService/ocrRuntimeService after they are shut down. + await runDestroyStep('remoteInstallers.cancelAll', () => + Promise.all([pluginRemoteInstaller.cancelAll(), ocrRuntimeAssetInstaller.cancelAll()]).then( + () => undefined + ) + ) await runDestroyStep('agentCliTokenAuthority.clear', () => agentCliTokenAuthority.clear()) await runDestroyStep('cliServer.stop', () => cliServer.stop()) await runDestroyStep('tapeInspectorHeadWatcher.close', () => tapeInspectorHeadWatcher.close()) @@ -2785,7 +2850,10 @@ export async function createMainProcessControl(dependencies: { recordSettingsActivity: (input) => settingsDatabase.recordSettingsActivity(input) }) const toolRoutes = createToolRoutes(toolService) - const pluginRoutes = createPluginRoutes(pluginService) + const pluginRoutes = createPluginRoutes(pluginService, { + catalog: pluginCatalogService, + installer: pluginRemoteInstaller + }) const skillRoutes = createSkillRoutes({ skillService, skillSyncService, @@ -2836,7 +2904,50 @@ export async function createMainProcessControl(dependencies: { } }) const fileRoutes = createFileRoutes(fileService) - const ocrRoutes = createOcrRoutes({ runtime: ocrRuntimeService }) + const ocrRoutes = createOcrRoutes({ + runtime: ocrRuntimeService, + runtimeInstall: { + getInstallState: () => ocrRuntimeInstallCoordinator.getInstallState(), + getAssetInfo: () => { + const asset = pluginCatalogService + .listVisibleRuntimeAssets() + .find((candidate) => candidate.id === LIGHT_OCR_RUNTIME_ASSET_ID) + if (!asset) return null + const { availability, target } = + pluginCatalogService.describeRuntimeAssetAvailability(asset) + return { + version: asset.version, + channel: asset.channel, + availability, + sizeBytes: target?.size ?? null, + installedVersion: ocrRuntimeAssetInstaller.listInstalledVersions()[0] ?? null + } + }, + install: async () => { + const result = await ocrRuntimeInstallCoordinator.install() + return { ok: result.ok, error: result.ok ? undefined : (result.error ?? undefined) } + }, + installFromFile: async (filePath: string) => { + const result = await ocrRuntimeInstallCoordinator.installFromFile(filePath) + return { ok: result.ok, error: result.ok ? undefined : (result.error ?? undefined) } + }, + uninstall: async () => { + try { + // Retire the running helper before deleting the files it loaded. + await ocrRuntimeService.refreshAvailability() + ocrRuntimeAssetInstaller.removeInstalled() + await ocrRuntimeService.refreshAvailability() + return { ok: true } + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Failed to remove OCR runtime' + } + } + }, + cancel: () => ocrRuntimeAssetInstaller.cancel(LIGHT_OCR_RUNTIME_ASSET_ID) + } + }) const toolchainRoutes = createToolchainRoutes({ service: toolchainService, pickPath: () => deviceService.selectFiles({ multiple: false }) diff --git a/src/main/app/settingsRoutes.ts b/src/main/app/settingsRoutes.ts index e763e3d2c5..51dcbd81c5 100644 --- a/src/main/app/settingsRoutes.ts +++ b/src/main/app/settingsRoutes.ts @@ -153,6 +153,7 @@ export function createAppSettingsRoutes(deps: { return case 'ocrBackend': deps.ocr.setBackend(change.value) + return } } const recordChange = (change: SettingsChange): void => { diff --git a/src/main/lib/remoteArtifactDownload.ts b/src/main/lib/remoteArtifactDownload.ts new file mode 100644 index 0000000000..4fa352d6e9 --- /dev/null +++ b/src/main/lib/remoteArtifactDownload.ts @@ -0,0 +1,151 @@ +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs' +import path from 'node:path' +import { downloadVerifiedFile, probeArtifactUrl, type FetchLike } from '@/toolchains/downloader' + +const DEFAULT_PROBE_TIMEOUT_MS = 4_000 + +/** + * A remotely distributed artifact pinned by the distribution catalog. Mirrors + * are ghproxy-style prefixes concatenated with the canonical URL; the pinned + * sha256 makes any mirror bit-for-bit verifiable. + */ +export interface RemoteArtifactDescriptor { + url: string + sha256: string + size: number + mirrors: string[] +} + +export type DownloadPhase = 'probing' | 'downloading' + +export interface RemoteArtifactDownloadProgress { + phase: DownloadPhase + receivedBytes: number + totalBytes: number | null +} + +export type StagedArtifact = { + operationId: string + stagingDir: string + archivePath: string +} + +export function buildArtifactCandidateUrls(descriptor: RemoteArtifactDescriptor): string[] { + const candidates = [ + descriptor.url, + ...descriptor.mirrors.map((mirror) => `${mirror}${descriptor.url}`) + ] + return Array.from(new Set(candidates)) +} + +/** + * Removes stale operation directories left behind by a crash mid-download. + * Only call this during startup, before any install can be running. + */ +export function sweepStagingRoot(stagingRoot: string): void { + if (!existsSync(stagingRoot)) return + for (const entry of readdirSync(stagingRoot, { withFileTypes: true })) { + if (!entry.isDirectory()) continue + rmSync(path.join(stagingRoot, entry.name), { recursive: true, force: true }) + } +} + +/** + * Probes every candidate in parallel with a short timeout and picks the + * fastest successful one (declared order breaks ties). If no candidate + * answers, the canonical URL is still attempted — a failed probe is not a + * guarantee that the download would fail. + */ +export async function selectArtifactUrl( + descriptor: RemoteArtifactDescriptor, + options: { + fetchImpl?: FetchLike + signal?: AbortSignal + probeTimeoutMs?: number + now?: () => number + } = {} +): Promise { + const fetchImpl = options.fetchImpl ?? fetch + const candidates = buildArtifactCandidateUrls(descriptor) + const timeoutMs = options.probeTimeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS + const now = options.now ?? Date.now + const results = await Promise.all( + candidates.map(async (url, index) => { + const probeSignal = options.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(timeoutMs)]) + : AbortSignal.timeout(timeoutMs) + const started = now() + const ok = await probeArtifactUrl(url, fetchImpl, probeSignal) + return { url, index, ok, elapsedMs: now() - started } + }) + ) + const success = results + .filter((result) => result.ok && !options.signal?.aborted) + .sort((left, right) => left.elapsedMs - right.elapsedMs || left.index - right.index)[0] + return success?.url ?? candidates[0] +} + +/** + * Downloads an artifact into a fresh staging directory and verifies it against + * the catalog-pinned sha256 before returning the archive path. The staging + * directory is owned by the caller and must be cleaned up after use. + */ +export async function downloadArtifactToStaging(options: { + descriptor: RemoteArtifactDescriptor + stagingRoot: string + fetchImpl?: FetchLike + signal?: AbortSignal + probeTimeoutMs?: number + now?: () => number + onProgress?: (progress: RemoteArtifactDownloadProgress) => void +}): Promise { + const operationId = randomUUID() + const stagingDir = path.join(options.stagingRoot, operationId) + const archivePath = path.join(stagingDir, 'artifact.zip') + rmSync(stagingDir, { recursive: true, force: true }) + mkdirSync(stagingDir, { recursive: true }) + + try { + options.onProgress?.({ + phase: 'probing', + receivedBytes: 0, + totalBytes: options.descriptor.size + }) + const selectedUrl = await selectArtifactUrl(options.descriptor, { + fetchImpl: options.fetchImpl, + signal: options.signal, + probeTimeoutMs: options.probeTimeoutMs, + now: options.now + }) + if (options.signal?.aborted) { + throw new Error('Download cancelled') + } + + options.onProgress?.({ + phase: 'downloading', + receivedBytes: 0, + totalBytes: options.descriptor.size + }) + await downloadVerifiedFile({ + url: selectedUrl, + destPath: archivePath, + sha256: options.descriptor.sha256, + fetch: options.fetchImpl, + signal: options.signal, + onProgress: (progress) => { + options.onProgress?.({ + phase: 'downloading', + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes + }) + } + }) + + return { operationId, stagingDir, archivePath } + } catch (error) { + // A failed download (including checksum mismatch) leaves nothing behind. + rmSync(stagingDir, { recursive: true, force: true }) + throw error + } +} diff --git a/src/main/ocr/ocrRuntimeAssetResolver.ts b/src/main/ocr/ocrRuntimeAssetResolver.ts index bd26b0ec31..693e8b9257 100644 --- a/src/main/ocr/ocrRuntimeAssetResolver.ts +++ b/src/main/ocr/ocrRuntimeAssetResolver.ts @@ -37,10 +37,18 @@ export interface OcrRuntimeAssets { bundleId: string } +/** + * Where the resolved OCR runtime came from: the app bundle, the + * development working tree, or a payload downloaded by the runtime + * asset installer. + */ +export type OcrRuntimeSource = 'development' | 'bundled' | 'downloaded' + export type OcrRuntimeAvailability = | { status: 'available' assets: OcrRuntimeAssets + source: OcrRuntimeSource } | { status: 'unavailable' @@ -56,6 +64,13 @@ export interface OcrRuntimeAssetResolverOptions { arch?: string nodeRuntimePath?: string | null resolveNode?: () => { executable: string; version: string } + /** + * Runtime roots materialized by the remote runtime asset installer + * (newest first). Tried after the unpacked app bundle, so a downloaded + * payload is validated with exactly the same identity checks as a bundled + * one and stale versions simply fail validation. + */ + installedRuntimeRoots?: () => string[] } interface PackagedRuntimeManifest { @@ -100,6 +115,7 @@ const NATIVE_ARTIFACT_INVENTORY_GROUPS: ReadonlyArray { - const unpackedRoot = resolveUnpackedAppRoot(this.options.appPath) + const roots = [ + { root: resolveUnpackedAppRoot(this.options.appPath), source: 'bundled' as const }, + ...(this.options.installedRuntimeRoots?.() ?? []).map((root) => ({ + root, + source: 'downloaded' as const + })) + ] + let lastError: unknown = null + for (const candidate of roots) { + try { + const resolved = await this.resolvePackagedFromRoot(candidate.root, nativePackage) + await this.verifyIdentity(resolved.assets, resolved.expectedNativeArtifactInventory) + return { ...resolved, source: candidate.source } + } catch (error) { + lastError = error + } + } + throw lastError ?? new RuntimeAssetError('assets_missing', 'OCR runtime assets are missing') + } + + private async resolvePackagedFromRoot( + unpackedRoot: string, + nativePackage: string + ): Promise { const manifestPath = path.join(unpackedRoot, 'runtime', 'ocr', 'manifest.json') let parsedManifest: unknown try { @@ -187,7 +231,8 @@ export class OcrRuntimeAssetResolver { lightOcrVersion: runtimeVersions.lightOcr.facadeVersion, bundleId: runtimeVersions.lightOcr.bundleId }, - expectedNativeArtifactInventory: manifest.nativeArtifactInventory + expectedNativeArtifactInventory: manifest.nativeArtifactInventory, + source: 'bundled' } } @@ -232,7 +277,8 @@ export class OcrRuntimeAssetResolver { lightOcrVersion: runtimeVersions.lightOcr.facadeVersion, bundleId: runtimeVersions.lightOcr.bundleId }, - expectedNativeArtifactInventory: null + expectedNativeArtifactInventory: null, + source: 'development' } } diff --git a/src/main/ocr/ocrRuntimeService.ts b/src/main/ocr/ocrRuntimeService.ts index 04a41a5182..ac9d274314 100644 --- a/src/main/ocr/ocrRuntimeService.ts +++ b/src/main/ocr/ocrRuntimeService.ts @@ -30,6 +30,7 @@ export interface OcrRuntimeServiceOptions { userDataDir: string platform?: NodeJS.Platform arch?: string + installedRuntimeRoots?: () => string[] onDiagnostic?: (event: { code: 'cache_read_failed' | 'cache_write_failed' }) => void } @@ -70,15 +71,21 @@ export class OcrRuntimeService { nodeRuntimePath: options.nodeRuntimePath, resolveNode: options.resolveNode, platform: options.platform, - arch: options.arch + arch: options.arch, + installedRuntimeRoots: options.installedRuntimeRoots }) } - refreshAvailability(kind?: ToolchainKind): void { + /** + * Drops cached availability and retires the current helper resources. The + * returned promise settles once the pending disposal has run, so callers + * that are about to delete runtime files on disk can wait for it. + */ + refreshAvailability(kind?: ToolchainKind): Promise { this.availabilityPromise = null - if (kind === 'uv') return + if (kind === 'uv') return this.closingResources const existing = this.resourcesPromise - if (!existing) return + if (!existing) return this.closingResources this.closingResources = this.closingResources .then(async () => { const resources = await existing.catch(() => null) @@ -95,6 +102,7 @@ export class OcrRuntimeService { await this.disposeResources(resources) }) .catch(() => {}) + return this.closingResources } async getAvailability(): Promise { diff --git a/src/main/ocr/routes.ts b/src/main/ocr/routes.ts index 0336d0d1cd..abb243992a 100644 --- a/src/main/ocr/routes.ts +++ b/src/main/ocr/routes.ts @@ -1,11 +1,35 @@ -import { ocrClearCacheRoute, ocrGetRuntimeStatusRoute } from '@shared/contracts/routes' +import { + ocrCancelRuntimeInstallRoute, + ocrClearCacheRoute, + ocrGetRuntimeStatusRoute, + ocrInstallRuntimeFromPathRoute, + ocrInstallRuntimeRoute, + ocrUninstallRuntimeRoute +} from '@shared/contracts/routes' import type { OcrEngine, OcrRuntimeStatus } from '@shared/contracts/routes/ocr.routes' import { createRouteMap, type DeepchatRouteMap } from '@/routes/routeRegistry' import type { LightOcrEngineStatus } from './lightOcrProtocol' import type { OcrRuntimeService, OcrRuntimeServiceStatus } from './ocrRuntimeService' +import type { RuntimeAssetInstallState } from '@shared/types/pluginCatalog' + +export type OcrRuntimeAssetStatusProvider = { + getInstallState(): RuntimeAssetInstallState | null + getAssetInfo(): { + version: string + channel: 'stable' | 'pre-release' + availability: 'available' | 'incompatible-app' | 'unsupported-platform' + sizeBytes: number | null + installedVersion: string | null + } | null + install(): Promise<{ ok: boolean; error?: string }> + installFromFile(filePath: string): Promise<{ ok: boolean; error?: string }> + uninstall(): Promise<{ ok: boolean; error?: string }> + cancel(): boolean +} export function createOcrRoutes(deps: { runtime: Pick + runtimeInstall?: OcrRuntimeAssetStatusProvider platform?: string arch?: string }): DeepchatRouteMap { @@ -13,7 +37,9 @@ export function createOcrRoutes(deps: { toPublicOcrStatus( await deps.runtime.getStatus(), deps.platform ?? process.platform, - deps.arch ?? process.arch + deps.arch ?? process.arch, + deps.runtimeInstall?.getInstallState() ?? null, + deps.runtimeInstall?.getAssetInfo() ?? null ) return createRouteMap([ @@ -33,6 +59,60 @@ export function createOcrRoutes(deps: { if (!status.cache) throw new Error('OCR cache status is unavailable after clearing') return ocrClearCacheRoute.output.parse({ cache: status.cache }) } + ], + [ + ocrInstallRuntimeRoute.name, + async (rawInput) => { + ocrInstallRuntimeRoute.input.parse(rawInput) + if (!deps.runtimeInstall) { + return ocrInstallRuntimeRoute.output.parse({ + result: { ok: false, error: 'OCR runtime download is not available' } + }) + } + const result = await deps.runtimeInstall.install() + return ocrInstallRuntimeRoute.output.parse({ + result: { ok: result.ok, error: result.ok ? undefined : result.error } + }) + } + ], + [ + ocrInstallRuntimeFromPathRoute.name, + async (rawInput) => { + const input = ocrInstallRuntimeFromPathRoute.input.parse(rawInput) + if (!deps.runtimeInstall) { + return ocrInstallRuntimeFromPathRoute.output.parse({ + result: { ok: false, error: 'OCR runtime download is not available' } + }) + } + const result = await deps.runtimeInstall.installFromFile(input.path) + return ocrInstallRuntimeFromPathRoute.output.parse({ + result: { ok: result.ok, error: result.ok ? undefined : result.error } + }) + } + ], + [ + ocrUninstallRuntimeRoute.name, + async (rawInput) => { + ocrUninstallRuntimeRoute.input.parse(rawInput) + if (!deps.runtimeInstall) { + return ocrUninstallRuntimeRoute.output.parse({ + result: { ok: false, error: 'OCR runtime download is not available' } + }) + } + const result = await deps.runtimeInstall.uninstall() + return ocrUninstallRuntimeRoute.output.parse({ + result: { ok: result.ok, error: result.ok ? undefined : result.error } + }) + } + ], + [ + ocrCancelRuntimeInstallRoute.name, + async (rawInput) => { + ocrCancelRuntimeInstallRoute.input.parse(rawInput) + return ocrCancelRuntimeInstallRoute.output.parse({ + cancelled: deps.runtimeInstall?.cancel() ?? false + }) + } ] ]) } @@ -40,7 +120,9 @@ export function createOcrRoutes(deps: { export function toPublicOcrStatus( status: OcrRuntimeServiceStatus, platform: string, - arch: string + arch: string, + runtimeInstall: RuntimeAssetInstallState | null = null, + runtimeAsset: OcrRuntimeStatus['runtimeAsset'] = null ): OcrRuntimeStatus { const availability = status.availability.status === 'available' @@ -55,6 +137,7 @@ export function toPublicOcrStatus( platform, arch, availability, + runtimeSource: status.availability.status === 'available' ? status.availability.source : null, process: status.process ? { state: status.process.state, @@ -64,7 +147,17 @@ export function toPublicOcrStatus( engine: status.process.engine ? toPublicOcrEngine(status.process.engine) : null } : null, - cache: status.cache + cache: status.cache, + runtimeInstall: runtimeInstall + ? { + phase: runtimeInstall.phase, + receivedBytes: runtimeInstall.receivedBytes, + totalBytes: runtimeInstall.totalBytes, + error: runtimeInstall.error, + updatedAt: runtimeInstall.updatedAt + } + : null, + runtimeAsset } } diff --git a/src/main/ocr/runtimeAssetInstaller.ts b/src/main/ocr/runtimeAssetInstaller.ts new file mode 100644 index 0000000000..945f219d61 --- /dev/null +++ b/src/main/ocr/runtimeAssetInstaller.ts @@ -0,0 +1,513 @@ +import { randomUUID } from 'node:crypto' +import fs from 'node:fs' +import path from 'node:path' +import { unzip as unzipAsync } from 'fflate' +import logger from '@shared/logger' +import type { FetchLike } from '@/toolchains/downloader' +import { isToolchainDownloadError } from '@/toolchains/errors' +import { + downloadArtifactToStaging, + type RemoteArtifactDescriptor +} from '@/lib/remoteArtifactDownload' +import { isPackagedRuntimeManifest } from './ocrRuntimeAssetResolver' +import type { + PluginCatalogTarget, + RuntimeAssetInstallPhase, + RuntimeAssetInstallState, + RuntimeCatalogAsset +} from '@shared/types/pluginCatalog' + +const OCR_RUNTIME_MANIFEST_ENTRY = 'runtime/ocr/manifest.json' +// Generous bounds for the decompressed payload relative to the pinned +// compressed size: the real OCR payload decompresses at roughly 2.5x, so an +// 8x ratio with a 256 MiB floor rejects zip bombs without rejecting builds. +const MAX_DECOMPRESSION_RATIO = 8 +const MAX_DECOMPRESSED_BYTES_FLOOR = 256 * 1024 * 1024 +// Manual installs learn the version only from the payload manifest, which is +// read mid-install. Progress events must carry a non-empty version (the +// ocr.runtimeInstall.progress contract enforces min(1)), so pre-manifest +// phases report this placeholder instead of an empty string. +const PENDING_INSTALL_VERSION = 'pending' + +/** The subset of the packaged runtime manifest the installer relies on. */ +type PackagedRuntimeManifestLike = { + bundleId: string + platform: string + arch: string + paths?: { helper?: string } & Record +} + +export type OcrRuntimeAssetInstallResult = { + ok: boolean + assetId: string + version: string + reason: string | null + error: string | null +} + +export type OcrRuntimeAssetInstallerDeps = { + installRoot: () => string + stagingRoot: () => string + platform?: NodeJS.Platform + arch?: string + fetchImpl?: FetchLike + probeTimeoutMs?: number + onProgress?: (state: RuntimeAssetInstallState) => void + now?: () => number +} + +/** + * Downloads and materializes the OCR runtime payload declared by the + * distribution catalog. The payload is a zip of the packaged OCR runtime + * root (manifest.json + helper entry + facade/runtime/model/native package + * directories) — the exact layout `OcrRuntimeAssetResolver` validates — so a + * downloaded install is verified with the same identity checks as a bundled + * one. Payloads land in versioned directories under the install root; stale + * or foreign-version directories simply fail resolver validation and are + * ignored. + */ +export class OcrRuntimeAssetInstaller { + private readonly deps: OcrRuntimeAssetInstallerDeps + private readonly running = new Map() + private readonly activeOperations = new Map>() + private readonly states = new Map() + + constructor(deps: OcrRuntimeAssetInstallerDeps) { + this.deps = deps + } + + getInstallState(assetId: string): RuntimeAssetInstallState | null { + return this.states.get(assetId) ?? null + } + + getInstallStates(): RuntimeAssetInstallState[] { + return Array.from(this.states.values()) + } + + isRunning(assetId: string): boolean { + return this.running.has(assetId) + } + + cancel(assetId: string): boolean { + const controller = this.running.get(assetId) + if (!controller || controller.signal.aborted) return false + controller.abort() + return true + } + + /** Aborts every running install and waits for them to settle. */ + async cancelAll(): Promise { + for (const controller of this.running.values()) { + if (!controller.signal.aborted) controller.abort() + } + await Promise.allSettled(Array.from(this.activeOperations.values())) + } + + /** Installed runtime root directories, newest version name first. */ + listInstalledRoots(): string[] { + const root = this.deps.installRoot() + if (!fs.existsSync(root)) return [] + return fs + .readdirSync(root, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && !entry.name.startsWith('.')) + .map((entry) => entry.name) + .sort((left, right) => right.localeCompare(left)) + .map((name) => path.join(root, name)) + } + + /** Installed version directory names (not roots). */ + listInstalledVersions(): string[] { + return this.listInstalledRoots().map((root) => path.basename(root)) + } + + /** Removes every downloaded runtime version directory. */ + removeInstalled(): number { + const roots = this.listInstalledRoots() + for (const root of roots) { + fs.rmSync(root, { recursive: true, force: true }) + } + // A retained `installed` phase would make the first-use coordinator treat + // the removed payload as present and never download it again. + this.states.clear() + return roots.length + } + + async install( + asset: RuntimeCatalogAsset, + target: PluginCatalogTarget, + options: { signal?: AbortSignal } = {} + ): Promise { + if (this.running.has(asset.id)) { + return { + ok: false, + assetId: asset.id, + version: asset.version, + reason: 'busy', + error: 'An install for this runtime asset is already running' + } + } + + const controller = new AbortController() + if (options.signal) { + if (options.signal.aborted) { + controller.abort() + } else { + options.signal.addEventListener('abort', () => controller.abort(), { once: true }) + } + } + this.running.set(asset.id, controller) + const operation = this.runInstall(asset, target, controller).finally(() => { + this.running.delete(asset.id) + this.activeOperations.delete(asset.id) + }) + this.activeOperations.set(asset.id, operation) + return await operation + } + + /** + * Installs a runtime payload from a local archive the user selected + * manually (offline / restricted-network path). The archive goes through + * the same structural validation, decompressed-size cap, platform check, + * and atomic swap as a downloaded payload; only the download step is + * skipped. The installed version is taken from the payload's own manifest + * bundle id, so no catalog entry is required. + */ + async installFromFile( + filePath: string, + options: { signal?: AbortSignal } = {} + ): Promise { + const assetId = 'light-ocr' + if (this.running.has(assetId)) { + return { + ok: false, + assetId, + version: '', + reason: 'busy', + error: 'An install for this runtime asset is already running' + } + } + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + return { + ok: false, + assetId, + version: '', + reason: 'invalid_file', + error: 'Selected file does not exist' + } + } + + const controller = new AbortController() + if (options.signal) { + if (options.signal.aborted) { + controller.abort() + } else { + options.signal.addEventListener('abort', () => controller.abort(), { once: true }) + } + } + this.running.set(assetId, controller) + const operation = this.runInstallFromFile(assetId, filePath, controller).finally(() => { + this.running.delete(assetId) + this.activeOperations.delete(assetId) + }) + this.activeOperations.set(assetId, operation) + return await operation + } + + private async runInstallFromFile( + assetId: string, + filePath: string, + controller: AbortController + ): Promise { + const update = ( + phase: RuntimeAssetInstallPhase, + version: string, + patch: Partial = {} + ): void => { + const state: RuntimeAssetInstallState = { + assetId, + version, + phase, + receivedBytes: patch.receivedBytes ?? 0, + totalBytes: patch.totalBytes ?? 0, + error: patch.error ?? null, + updatedAt: (this.deps.now ?? Date.now)() + } + this.states.set(assetId, state) + this.deps.onProgress?.(state) + } + + update('verifying', PENDING_INSTALL_VERSION) + const stagingDir = path.join(this.deps.stagingRoot(), randomUUID()) + try { + fs.rmSync(stagingDir, { recursive: true, force: true }) + fs.mkdirSync(stagingDir, { recursive: true }) + const archiveSize = fs.statSync(filePath).size + const { payloadDir, manifest } = await this.extractPayload(filePath, stagingDir, { + size: archiveSize + }) + if (controller.signal.aborted) { + throw new Error('Install cancelled') + } + + update('installing', manifest.bundleId) + const installRoot = this.deps.installRoot() + fs.mkdirSync(installRoot, { recursive: true }) + const versionDir = path.join(installRoot, this.safeDirectoryName(manifest.bundleId)) + this.swapVersionDirectory(payloadDir, versionDir) + + update('installed', manifest.bundleId) + return { ok: true, assetId, version: manifest.bundleId, reason: null, error: null } + } catch (error) { + const cancelled = controller.signal.aborted + const { reason, message } = cancelled + ? { reason: 'cancelled', message: 'Install cancelled' } + : describeInstallError(error) + const phase: RuntimeAssetInstallPhase = reason === 'cancelled' ? 'cancelled' : 'error' + update(phase, PENDING_INSTALL_VERSION, { error: message }) + if (phase === 'error') { + logger.warn('[OcrRuntimeAssetInstaller] Manual install failed', { + filePath, + reason, + error: message + }) + } + return { ok: false, assetId, version: '', reason, error: message } + } finally { + fs.rmSync(stagingDir, { recursive: true, force: true }) + } + } + + private async runInstall( + asset: RuntimeCatalogAsset, + target: PluginCatalogTarget, + controller: AbortController + ): Promise { + const descriptor: RemoteArtifactDescriptor = { + url: target.url, + sha256: target.sha256, + size: target.size, + mirrors: target.mirrors + } + + const update = ( + phase: RuntimeAssetInstallPhase, + patch: Partial = {} + ): void => { + const state: RuntimeAssetInstallState = { + assetId: asset.id, + version: asset.version, + phase, + receivedBytes: patch.receivedBytes ?? 0, + totalBytes: patch.totalBytes ?? target.size, + error: patch.error ?? null, + updatedAt: (this.deps.now ?? Date.now)() + } + this.states.set(asset.id, state) + this.deps.onProgress?.(state) + } + + let stagingDir: string | null = null + try { + update('probing', { totalBytes: target.size }) + const staged = await downloadArtifactToStaging({ + descriptor, + stagingRoot: this.deps.stagingRoot(), + fetchImpl: this.deps.fetchImpl, + signal: controller.signal, + probeTimeoutMs: this.deps.probeTimeoutMs, + now: this.deps.now, + onProgress: (progress) => { + update(progress.phase, { + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes + }) + } + }) + stagingDir = staged.stagingDir + + update('verifying', { receivedBytes: target.size, totalBytes: target.size }) + const { payloadDir } = await this.extractPayload( + staged.archivePath, + staged.stagingDir, + target + ) + + update('installing', { receivedBytes: target.size, totalBytes: target.size }) + const installRoot = this.deps.installRoot() + fs.mkdirSync(installRoot, { recursive: true }) + const versionDir = path.join(installRoot, this.safeDirectoryName(asset.version)) + this.swapVersionDirectory(payloadDir, versionDir) + + update('installed', { receivedBytes: target.size, totalBytes: target.size }) + return { ok: true, assetId: asset.id, version: asset.version, reason: null, error: null } + } catch (error) { + const cancelled = controller.signal.aborted + const { reason, message } = cancelled + ? { reason: 'cancelled', message: 'Install cancelled' } + : describeInstallError(error) + const phase: RuntimeAssetInstallPhase = reason === 'cancelled' ? 'cancelled' : 'error' + update(phase, { error: message }) + if (phase === 'error') { + logger.warn('[OcrRuntimeAssetInstaller] Install failed', { + assetId: asset.id, + version: asset.version, + reason, + error: message + }) + } + return { ok: false, assetId: asset.id, version: asset.version, reason, error: message } + } finally { + if (stagingDir) { + fs.rmSync(stagingDir, { recursive: true, force: true }) + } + } + } + + /** + * Unzips the payload into `/payload` and performs structural + * validation: the packaged runtime manifest must parse and its declared + * helper entry must exist inside the payload. Full identity verification + * (versions, hashes, inventory) is delegated to the runtime resolver, which + * applies the same checks as for bundled payloads. + * + * Decompression runs through fflate's async API (worker thread) so the + * main-process event loop is not blocked, and the total decompressed size + * is capped relative to the catalog-pinned compressed size: a zip bomb + * cannot exhaust memory before the cap rejects it. + */ + private async extractPayload( + archivePath: string, + stagingDir: string, + target: Pick + ): Promise<{ payloadDir: string; manifest: PackagedRuntimeManifestLike }> { + const archive = new Uint8Array(fs.readFileSync(archivePath)) + const maxDecompressedBytes = Math.max( + MAX_DECOMPRESSED_BYTES_FLOOR, + target.size * MAX_DECOMPRESSION_RATIO + ) + let oversizedEntry: string | null = null + const files = await new Promise>((resolve, reject) => { + unzipAsync( + archive, + { + // Entries beyond the cap are skipped before decompression; the + // check after unzip rejects the whole payload if any entry was + // skipped. + filter: (file) => { + if (file.originalSize > maxDecompressedBytes) { + oversizedEntry = file.name + return false + } + return true + } + }, + (error, data) => { + if (error) reject(error) + else resolve(data) + } + ) + }) + if (oversizedEntry) { + throw new Error( + `OCR runtime payload entry exceeds the decompressed size cap: ${oversizedEntry}` + ) + } + const manifestEntry = files[OCR_RUNTIME_MANIFEST_ENTRY] + if (!manifestEntry) { + throw new Error('OCR runtime payload is missing manifest.json') + } + let manifest: unknown + try { + manifest = JSON.parse(Buffer.from(manifestEntry).toString('utf8')) as unknown + } catch (error) { + throw new Error('OCR runtime payload manifest.json is not valid JSON', { cause: error }) + } + if (!isPackagedRuntimeManifest(manifest)) { + throw new Error('OCR runtime payload manifest has an invalid shape') + } + if ( + this.deps.platform && + this.deps.arch && + (manifest.platform !== this.deps.platform || manifest.arch !== this.deps.arch) + ) { + throw new Error( + `OCR runtime payload is built for ${manifest.platform}/${manifest.arch}, not ${this.deps.platform}/${this.deps.arch}` + ) + } + // The payload layout mirrors the unpacked app root: manifest paths are + // root-relative (runtime/ocr/..., out/main/lightOcrHelper.js). + const helperPath = manifest.paths?.helper + if (!helperPath || !files[helperPath]) { + throw new Error('OCR runtime payload does not contain the declared helper entry') + } + + const payloadDir = path.join(stagingDir, 'payload') + fs.mkdirSync(payloadDir, { recursive: true }) + for (const [relativePath, content] of Object.entries(files)) { + if (relativePath.endsWith('/')) continue + const outputPath = this.resolveSafePayloadPath(payloadDir, relativePath) + fs.mkdirSync(path.dirname(outputPath), { recursive: true }) + fs.writeFileSync(outputPath, Buffer.from(content)) + } + return { payloadDir, manifest } + } + + /** + * Replaces the version directory atomically: the previous install is + * renamed aside (open files in a running helper keep working), the new + * payload is renamed into place, and only then is the old copy removed. A + * crash mid-swap leaves either the old or the new directory intact. + */ + private swapVersionDirectory(extractedDir: string, versionDir: string): void { + const previous = `${versionDir}.old-${randomUUID()}` + fs.rmSync(previous, { recursive: true, force: true }) + let movedPrevious = false + if (fs.existsSync(versionDir)) { + fs.renameSync(versionDir, previous) + movedPrevious = true + } + try { + fs.renameSync(extractedDir, versionDir) + } catch (error) { + if (movedPrevious && !fs.existsSync(versionDir)) { + fs.renameSync(previous, versionDir) + } + throw error + } + fs.rmSync(previous, { recursive: true, force: true }) + } + + private resolveSafePayloadPath(payloadRoot: string, relativePath: string): string { + const normalized = relativePath.replace(/\\/g, '/') + if ( + !normalized || + normalized.startsWith('/') || + normalized.includes('..') || + /^[A-Za-z]:/.test(normalized) + ) { + throw new Error(`Unsafe OCR runtime payload path: ${relativePath}`) + } + const resolved = path.resolve(payloadRoot, ...normalized.split('/').filter(Boolean)) + const relative = path.relative(payloadRoot, resolved) + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error(`OCR runtime payload path escapes the payload root: ${relativePath}`) + } + return resolved + } + + private safeDirectoryName(version: string): string { + const safe = version.replace(/[^a-zA-Z0-9._-]/g, '-') + if (!safe || safe === '.' || safe === '..') { + throw new Error(`Invalid runtime asset version: ${version}`) + } + return safe + } +} + +function describeInstallError(error: unknown): { reason: string; message: string } { + if (isToolchainDownloadError(error)) { + return { reason: error.reason, message: error.message } + } + const message = error instanceof Error ? error.message : String(error) + return { reason: 'install_failed', message } +} diff --git a/src/main/ocr/runtimeInstallCoordinator.ts b/src/main/ocr/runtimeInstallCoordinator.ts new file mode 100644 index 0000000000..074d4980ea --- /dev/null +++ b/src/main/ocr/runtimeInstallCoordinator.ts @@ -0,0 +1,106 @@ +import logger from '@shared/logger' +import type { RuntimeAssetInstallState } from '@shared/types/pluginCatalog' +import type { RuntimeAssetResolution } from '@/plugin/catalog' +import type { + OcrRuntimeAssetInstallResult, + OcrRuntimeAssetInstaller +} from './runtimeAssetInstaller' + +const DEFAULT_RETRY_COOLDOWN_MS = 5 * 60 * 1000 + +export type OcrRuntimeInstallCoordinatorDeps = { + resolveAsset: () => RuntimeAssetResolution | null + installer: Pick< + OcrRuntimeAssetInstaller, + 'install' | 'installFromFile' | 'isRunning' | 'getInstallState' + > + onInstalled: () => void + retryCooldownMs?: number + now?: () => number +} + +/** + * Coordinates OCR runtime downloads. Attachment routing consults OCR + * availability on every turn; when the runtime is missing, a background + * install starts silently. The triggering turn still degrades (skips OCR + * text extraction) — only later turns benefit. A failed automatic install + * enters a cooldown so a bad network does not retrigger a download on every + * message; explicit installs (settings page) bypass and reset the cooldown. + */ +export class OcrRuntimeInstallCoordinator { + private readonly deps: OcrRuntimeInstallCoordinatorDeps + private cooldownUntil = 0 + + constructor(deps: OcrRuntimeInstallCoordinatorDeps) { + this.deps = deps + } + + getInstallState(): RuntimeAssetInstallState | null { + return this.deps.installer.getInstallState(this.assetId) + } + + private get assetId(): string { + return this.deps.resolveAsset()?.asset.id ?? 'light-ocr' + } + + /** Fire-and-forget trigger from first-use (attachment routing) paths. */ + maybeStartInstall(): void { + if (this.deps.installer.isRunning(this.assetId)) return + const state = this.deps.installer.getInstallState(this.assetId) + if (state?.phase === 'installed') return + const now = (this.deps.now ?? Date.now)() + if (state?.phase === 'error' && now < this.cooldownUntil) return + + const resolution = this.deps.resolveAsset() + if (!resolution) return + void this.runInstall(resolution, { automatic: true }) + } + + /** Explicit install (settings page); bypasses and resets the cooldown. */ + async install(): Promise { + const resolution = this.deps.resolveAsset() + if (!resolution) { + return { + ok: false, + assetId: this.assetId, + version: '', + reason: 'unavailable', + error: 'OCR runtime is not available for this platform or app version' + } + } + return await this.runInstall(resolution, { automatic: false }) + } + + /** Manual install from a locally selected archive; resets the cooldown. */ + async installFromFile(filePath: string): Promise { + this.cooldownUntil = 0 + const result = await this.deps.installer.installFromFile(filePath) + if (result.ok) { + this.cooldownUntil = 0 + this.deps.onInstalled() + } + return result + } + + private async runInstall( + resolution: NonNullable>, + options: { automatic: boolean } + ): Promise { + if (options.automatic) { + logger.info('[OcrRuntimeInstall] Starting automatic runtime download', { + version: resolution.asset.version + }) + } else { + this.cooldownUntil = 0 + } + const result = await this.deps.installer.install(resolution.asset, resolution.target) + if (result.ok) { + this.cooldownUntil = 0 + this.deps.onInstalled() + } else if (options.automatic && result.reason !== 'busy' && result.reason !== 'cancelled') { + this.cooldownUntil = + (this.deps.now ?? Date.now)() + (this.deps.retryCooldownMs ?? DEFAULT_RETRY_COOLDOWN_MS) + } + return result + } +} diff --git a/src/main/plugin/catalog.ts b/src/main/plugin/catalog.ts new file mode 100644 index 0000000000..41bad44e9d --- /dev/null +++ b/src/main/plugin/catalog.ts @@ -0,0 +1,205 @@ +import logger from '@shared/logger' +import fs from 'node:fs' +import path from 'node:path' +import compareVersions from 'compare-versions' +import { parsePluginCatalog } from '@shared/contracts/routes' +import type { + PluginCatalog, + PluginCatalogArtifact, + PluginCatalogTarget, + RuntimeCatalogAsset +} from '@shared/types/pluginCatalog' + +export const PLUGIN_CATALOG_OVERRIDE_ENV = 'DEEPCHAT_PLUGIN_CATALOG' +export const PLUGIN_CATALOG_FILE_NAME = 'plugin-catalog.json' +export const LIGHT_OCR_RUNTIME_ASSET_ID = 'light-ocr' + +export type PluginCatalogResolution = { + artifact: PluginCatalogArtifact + target: PluginCatalogTarget +} + +export type RuntimeAssetResolution = { + asset: RuntimeCatalogAsset + target: PluginCatalogTarget +} + +export type CatalogEntryAvailability = 'available' | 'incompatible-app' | 'unsupported-platform' + +export type PluginCatalogServiceDeps = { + appPath?: string + resourcesPath?: string + isPackaged?: boolean + platform?: NodeJS.Platform + arch?: NodeJS.Architecture + appVersion?: string + env?: NodeJS.ProcessEnv +} + +const EMPTY_CATALOG: PluginCatalog = { schemaVersion: 1, artifacts: [] } + +/** + * Loads the static plugin distribution catalog and resolves per-platform + * artifacts. Stable packaged builds only resolve `stable` channel entries; + * dev builds also resolve `pre-release` entries for staging tests. + */ +export class PluginCatalogService { + private readonly appPath?: string + private readonly resourcesPath?: string + private readonly isPackaged: boolean + private readonly platform: NodeJS.Platform + private readonly arch: NodeJS.Architecture + private readonly appVersion: string + private readonly env: NodeJS.ProcessEnv + private catalog: PluginCatalog | null = null + + constructor(deps: PluginCatalogServiceDeps = {}) { + this.appPath = deps.appPath + this.resourcesPath = deps.resourcesPath + this.isPackaged = deps.isPackaged ?? false + this.platform = deps.platform ?? process.platform + this.arch = deps.arch ?? process.arch + this.appVersion = deps.appVersion ?? '0.0.0' + this.env = deps.env ?? process.env + } + + getCatalog(): PluginCatalog { + if (this.catalog) return this.catalog + this.catalog = this.loadCatalog() + return this.catalog + } + + reload(): PluginCatalog { + this.catalog = this.loadCatalog() + return this.catalog + } + + private loadCatalog(): PluginCatalog { + const catalogPath = this.resolveCatalogPath() + if (!catalogPath) { + return EMPTY_CATALOG + } + if (!fs.existsSync(catalogPath)) { + // A missing catalog is a valid state: no remotely distributable plugins. + return EMPTY_CATALOG + } + try { + return parsePluginCatalog( + JSON.parse(fs.readFileSync(catalogPath, 'utf8')) as unknown, + catalogPath + ) + } catch (error) { + logger.error('[PluginCatalog] Failed to load plugin catalog', { + catalogPath, + error: error instanceof Error ? error.message : String(error) + }) + throw error + } + } + + private resolveCatalogPath(): string | undefined { + // Dev/test override. Never honored in packaged builds so a user-set env + // cannot redirect artifact downloads in production. + const override = this.env[PLUGIN_CATALOG_OVERRIDE_ENV] + if (override && !this.isPackaged) { + return path.resolve(override) + } + if (this.isPackaged) { + return this.resourcesPath + ? path.join(this.resourcesPath, PLUGIN_CATALOG_FILE_NAME) + : undefined + } + return this.appPath ? path.join(this.appPath, 'resources', PLUGIN_CATALOG_FILE_NAME) : undefined + } + + /** + * All artifacts visible to this build (channel filtered) with availability + * for the current platform/arch/app version. + */ + listVisibleArtifacts(): PluginCatalogArtifact[] { + return this.getCatalog().artifacts.filter((artifact) => this.isChannelVisible(artifact.channel)) + } + + resolveArtifact(pluginId: string): PluginCatalogResolution | null { + const artifact = this.listVisibleArtifacts().find( + (candidate) => candidate.pluginId === pluginId + ) + if (!artifact) return null + const target = this.resolveTarget(artifact.targets, artifact.minAppVersion) + return target ? { artifact, target } : null + } + + listVisibleRuntimeAssets(): RuntimeCatalogAsset[] { + return (this.getCatalog().runtimeAssets ?? []).filter((asset) => + this.isChannelVisible(asset.channel) + ) + } + + resolveRuntimeAsset(assetId: string): RuntimeAssetResolution | null { + const asset = this.listVisibleRuntimeAssets().find((candidate) => candidate.id === assetId) + if (!asset) return null + const target = this.resolveTarget(asset.targets, asset.minAppVersion) + return target ? { asset, target } : null + } + + describeRuntimeAssetAvailability(asset: RuntimeCatalogAsset): { + availability: CatalogEntryAvailability + target: PluginCatalogTarget | null + } { + return this.describeTargetAvailability(asset.targets, asset.minAppVersion) + } + + private resolveTarget( + targets: PluginCatalogTarget[], + minAppVersion: string | undefined + ): PluginCatalogTarget | null { + const target = targets.find( + (candidate) => candidate.platform === this.platform && candidate.arch === this.arch + ) + if (!target) return null + if (!this.satisfiesAppVersion(minAppVersion)) return null + return target + } + + describeAvailability(artifact: PluginCatalogArtifact): { + availability: CatalogEntryAvailability + target: PluginCatalogTarget | null + } { + return this.describeTargetAvailability(artifact.targets, artifact.minAppVersion) + } + + private describeTargetAvailability( + targets: PluginCatalogTarget[], + minAppVersion: string | undefined + ): { + availability: CatalogEntryAvailability + target: PluginCatalogTarget | null + } { + const target = + targets.find( + (candidate) => candidate.platform === this.platform && candidate.arch === this.arch + ) ?? null + if (!target) { + return { availability: 'unsupported-platform', target: null } + } + if (!this.satisfiesAppVersion(minAppVersion)) { + return { availability: 'incompatible-app', target } + } + return { availability: 'available', target } + } + + private isChannelVisible(channel: PluginCatalog['artifacts'][number]['channel']): boolean { + if (channel === 'stable') return true + return !this.isPackaged + } + + private satisfiesAppVersion(minAppVersion: string | undefined): boolean { + if (!minAppVersion) return true + try { + return compareVersions.compare(this.appVersion, minAppVersion, '>=') + } catch { + logger.warn('[PluginCatalog] Invalid minAppVersion in catalog', { minAppVersion }) + return false + } + } +} diff --git a/src/main/plugin/index.ts b/src/main/plugin/index.ts index e74d9ffac6..3502eb3762 100644 --- a/src/main/plugin/index.ts +++ b/src/main/plugin/index.ts @@ -30,6 +30,7 @@ import type { PluginListItem, PluginResourceRecord, PluginRuntimeManifest, + PluginRuntimeState, PluginRuntimeStatus, PluginSettingsContribution, RuntimeDependencyRecord @@ -138,6 +139,15 @@ export interface PluginServicePort { inspectSource(source: UserPluginSource, requestId: string): Promise installUserPlugin(input: UserPluginInstallInput): Promise uninstallUserPlugin(pluginId: string): Promise + installOfficialPluginPackage( + packagePath: string, + expectedPluginId?: string + ): Promise<{ + pluginId: string + version: string + }> + uninstallOfficialPlugin(pluginId: string): Promise + isRuntimePayloadInstalled(pluginId: string): boolean discardPrepared(operationId: string): Promise configurePluginMcp( pluginId: string, @@ -309,6 +319,85 @@ export class PluginService implements PluginServicePort { await this.contextHooks.retry(pluginId, invocationId) } + /** + * Installs an official plugin from a verified `.dcplugin` package file + * (e.g. downloaded by the remote distribution installer). Runs the same + * package checksum verification and trust checks as bundled packages. When + * `expectedPluginId` is provided, a package declaring a different plugin id + * is rejected before anything is extracted or persisted. + */ + async installOfficialPluginPackage( + packagePath: string, + expectedPluginId?: string + ): Promise<{ + pluginId: string + version: string + }> { + if (!fs.existsSync(packagePath)) { + throw new Error(`Plugin package does not exist: ${packagePath}`) + } + const metadata = this.readPackageMetadata(packagePath) + if (expectedPluginId && metadata.manifest.id !== expectedPluginId) { + throw new Error( + `Plugin package declares ${metadata.manifest.id}; expected ${expectedPluginId}` + ) + } + const resolved: ResolvedOfficialPlugin = { + ...metadata, + root: packagePath, + sourcePath: packagePath, + sourceType: 'package' + } + const installation = this.ensureOfficialPluginInstallation(resolved) + return { pluginId: installation.pluginId, version: installation.version } + } + + /** + * Removes an installed official plugin: disables every contribution it + * owns, deletes the installed payload directory, and drops the + * installation record (user config is part of the payload and goes with + * it). Bundled plugins re-install from their bundled package on the next + * discovery pass, so for them this acts as a reset; remotely installed + * plugins disappear until downloaded again. + */ + async uninstallOfficialPlugin(pluginId: string): Promise { + try { + await this.loadOfficialPlugins() + const installation = this.getInstallation(pluginId) + if (!installation) { + throw new Error(`Official plugin ${pluginId} is not installed`) + } + this.settingsWindow.close(pluginId) + unregisterPluginToolPolicies(pluginId) + await this.disableByOwner(pluginId) + this.store.set( + 'installations', + this.getInstallations().filter((item) => item.pluginId !== pluginId) + ) + this.officialPlugins.delete(pluginId) + this.activationErrors.delete(pluginId) + // The directory name is normalized to [a-zA-Z0-9._-], so this cannot + // escape the install root. + fs.rmSync(this.getInstalledPluginRoot(pluginId), { recursive: true, force: true }) + return { ok: true } + } catch (error) { + return this.errorResult(error) + } + } + + /** + * Whether the plugin's heavy payload is present, i.e. whether the plugin + * can actually run. Discovery only proves that a manifest exists: a + * development source tree (or an install whose files were removed) can + * declare a runtime whose binary was never staged. Install/uninstall + * affordances key off this rather than off discovery, so it must stay + * side-effect free. + */ + isRuntimePayloadInstalled(pluginId: string): boolean { + const plugin = this.officialPlugins.get(pluginId) + return plugin ? this.hasRuntimePayload(plugin) : false + } + private async applyRuntimeMigrations(): Promise { const migrations = this.store.get('migrations') ?? {} if ( @@ -558,7 +647,13 @@ export class PluginService implements PluginServicePort { this.registerSettingsContributions(plugin) if (runtime && runtime.state !== 'installed' && runtime.state !== 'running') { - return + // Reporting success here would leave the plugin "enabled" with none of + // its contributions registered. Failing lets the caller download the + // missing payload (catalog install) and retry. + throw new Error( + runtime.lastError ?? + `Runtime "${runtime.displayName || runtime.runtimeId}" is not installed` + ) } const registeredServerNames = await this.registerMcpServers(plugin, runtime) @@ -889,6 +984,50 @@ export class PluginService implements PluginServicePort { return status } + private hasRuntimePayload(plugin: ResolvedOfficialPlugin): boolean { + if (!plugin.manifest.runtime) { + return true + } + // A `.dcplugin` carries its runtime inside the archive and materializes it + // on first enable, so the payload counts as present before extraction. + if (plugin.sourceType === 'package') { + return true + } + return this.probeRuntimeCommand(plugin) !== null + } + + /** + * Resolves the runtime executable of a discovered plugin root without + * running it. Returns null only when absence is provable: a candidate + * resolved from `PATH` cannot be checked cheaply, so such runtimes are + * reported as present and left to `detectRuntime`. + */ + private probeRuntimeCommand(plugin: ResolvedOfficialPlugin): string | null { + const runtime = plugin.manifest.runtime + if (!runtime) { + return null + } + for (const candidate of runtime.detect) { + let command: string | null = null + try { + command = this.resolveRuntimeCandidate(candidate, plugin.root) + } catch { + // An unsafe manifest path is reported by detectRuntime/activation. + continue + } + if (!command) { + continue + } + if (!path.isAbsolute(command)) { + return command + } + if (fs.lstatSync(command, { throwIfNoEntry: false })?.isFile()) { + return command + } + } + return null + } + private async detectRuntime( runtime: PluginRuntimeManifest, pluginRoot: string @@ -1327,15 +1466,10 @@ export class PluginService implements PluginServicePort { : [...sourceDirectories, ...packages, ...installedDirectories] const usablePluginIds = new Set() + // Unusable candidates are logged and cleaned up by the main pass below. for (const plugin of plugins) { - if (!this.isPluginPlatformSupported(plugin.manifest)) { - continue - } - try { - this.assertTrustedOfficialPlugin(plugin.manifest) + if (this.isPluginCandidateUsable(plugin)) { usablePluginIds.add(plugin.manifest.id) - } catch { - // The main discovery pass logs untrusted plugin details and performs cleanup. } } @@ -1359,11 +1493,47 @@ export class PluginService implements PluginServicePort { } continue } - console.info(`[PluginHost] Discovered plugin: ${plugin.manifest.id} at ${plugin.root}`) - this.officialPlugins.set(plugin.manifest.id, plugin) + const selected = this.selectPluginCandidate(plugin, plugins) + console.info(`[PluginHost] Discovered plugin: ${selected.manifest.id} at ${selected.root}`) + this.officialPlugins.set(selected.manifest.id, selected) } } + private isPluginCandidateUsable(plugin: ResolvedOfficialPlugin): boolean { + if (!this.isPluginPlatformSupported(plugin.manifest)) { + return false + } + try { + this.assertTrustedOfficialPlugin(plugin.manifest) + return true + } catch { + return false + } + } + + /** + * Discovery prefers source trees in development builds, but a manifest + * without its runtime payload must not shadow a candidate that carries one: + * that would both hide a downloaded install and let `installResolvedPlugin` + * overwrite it with the payload-less copy. + */ + private selectPluginCandidate( + preferred: ResolvedOfficialPlugin, + candidates: ResolvedOfficialPlugin[] + ): ResolvedOfficialPlugin { + if (this.hasRuntimePayload(preferred)) { + return preferred + } + const replacement = candidates.find( + (candidate) => + candidate !== preferred && + candidate.manifest.id === preferred.manifest.id && + this.isPluginCandidateUsable(candidate) && + this.hasRuntimePayload(candidate) + ) + return replacement ?? preferred + } + private resolveOfficialPluginDirectories(): ResolvedOfficialPlugin[] { const sourceRoots = this.isPackaged ? [this.getPluginInstallRoot()] @@ -1905,14 +2075,20 @@ export class PluginService implements PluginServicePort { const installation = this.getInstallation(pluginId) const runtimeRecord = this.getRuntimeRecord(pluginId, plugin.manifest.runtime?.id) const settings = this.getSettingsContribution(pluginId) + const payloadInstalled = this.hasRuntimePayload(plugin) + const probedCommand = plugin.manifest.runtime ? this.probeRuntimeCommand(plugin) : null const runtime = plugin.manifest.runtime ? { runtimeId: plugin.manifest.runtime.id, displayName: plugin.manifest.runtime.displayName, - state: runtimeRecord?.state ?? 'missing', - command: runtimeRecord?.command, - helperAppPath: runtimeRecord?.helperAppPath, - version: runtimeRecord?.version, + // The persisted record is only refreshed while (de)activating, so an + // on-disk probe decides between "absent" and "present but unstarted". + state: this.reportedRuntimeState(runtimeRecord?.state, payloadInstalled), + command: payloadInstalled + ? (runtimeRecord?.command ?? probedCommand ?? undefined) + : undefined, + helperAppPath: payloadInstalled ? runtimeRecord?.helperAppPath : undefined, + version: payloadInstalled ? runtimeRecord?.version : undefined, lastError: runtimeRecord?.lastError, checkedAt: runtimeRecord?.checkedAt } @@ -1923,7 +2099,7 @@ export class PluginService implements PluginServicePort { name: plugin.manifest.name, version: plugin.manifest.version, publisher: plugin.manifest.publisher, - installed: true, + installed: payloadInstalled, enabled: Boolean(installation?.enabled), trusted: true, trustState: 'trusted', @@ -1936,6 +2112,16 @@ export class PluginService implements PluginServicePort { } } + private reportedRuntimeState( + recorded: PluginRuntimeState | undefined, + payloadInstalled: boolean + ): PluginRuntimeState { + if (!payloadInstalled) { + return 'missing' + } + return recorded && recorded !== 'missing' ? recorded : 'installed' + } + private getOfficialPluginOrThrow(pluginId: string): ResolvedOfficialPlugin { const plugin = this.officialPlugins.get(pluginId) if (!plugin) { diff --git a/src/main/plugin/remoteInstaller.ts b/src/main/plugin/remoteInstaller.ts new file mode 100644 index 0000000000..718d50e2ea --- /dev/null +++ b/src/main/plugin/remoteInstaller.ts @@ -0,0 +1,202 @@ +import fs from 'node:fs' +import logger from '@shared/logger' +import type { FetchLike } from '@/toolchains/downloader' +import { isToolchainDownloadError } from '@/toolchains/errors' +import { + downloadArtifactToStaging, + type RemoteArtifactDescriptor +} from '@/lib/remoteArtifactDownload' +import type { + PluginCatalogArtifact, + PluginCatalogInstallPhase, + PluginCatalogInstallState, + PluginCatalogTarget +} from '@shared/types/pluginCatalog' + +export type PluginRemoteInstallResult = { + ok: boolean + pluginId: string + version: string + reason: string | null + error: string | null +} + +export type PluginRemoteInstallerDeps = { + stagingRoot: () => string + installPackage: ( + packagePath: string, + expectedPluginId: string + ) => Promise<{ pluginId: string; version: string }> + fetchImpl?: FetchLike + probeTimeoutMs?: number + onProgress?: (state: PluginCatalogInstallState) => void + now?: () => number +} + +/** + * Downloads official plugin packages declared by the distribution catalog. + * The artifact is fetched into a staging directory, verified against the + * catalog-pinned sha256 (mirrors therefore cannot serve tampered content), + * and handed to the plugin service for package verification + installation. + */ +export class PluginRemoteInstaller { + private readonly deps: PluginRemoteInstallerDeps + private readonly running = new Map() + private readonly activeOperations = new Map>() + private readonly states = new Map() + + constructor(deps: PluginRemoteInstallerDeps) { + this.deps = deps + } + + getInstallState(pluginId: string): PluginCatalogInstallState | null { + return this.states.get(pluginId) ?? null + } + + getInstallStates(): PluginCatalogInstallState[] { + return Array.from(this.states.values()) + } + + isRunning(pluginId: string): boolean { + return this.running.has(pluginId) + } + + cancel(pluginId: string): boolean { + const controller = this.running.get(pluginId) + if (!controller || controller.signal.aborted) return false + controller.abort() + return true + } + + /** Aborts every running install and waits for them to settle. */ + async cancelAll(): Promise { + for (const controller of this.running.values()) { + if (!controller.signal.aborted) controller.abort() + } + await Promise.allSettled(Array.from(this.activeOperations.values())) + } + + async install( + artifact: PluginCatalogArtifact, + target: PluginCatalogTarget, + options: { signal?: AbortSignal } = {} + ): Promise { + const { pluginId, version } = artifact + if (this.running.has(pluginId)) { + return { + ok: false, + pluginId, + version, + reason: 'busy', + error: 'An install for this plugin is already running' + } + } + + const controller = new AbortController() + if (options.signal) { + if (options.signal.aborted) { + controller.abort() + } else { + options.signal.addEventListener('abort', () => controller.abort(), { once: true }) + } + } + this.running.set(pluginId, controller) + const operation = this.runInstall(artifact, target, controller).finally(() => { + this.running.delete(pluginId) + this.activeOperations.delete(pluginId) + }) + this.activeOperations.set(pluginId, operation) + return await operation + } + + private async runInstall( + artifact: PluginCatalogArtifact, + target: PluginCatalogTarget, + controller: AbortController + ): Promise { + const { pluginId, version } = artifact + const descriptor: RemoteArtifactDescriptor = { + url: target.url, + sha256: target.sha256, + size: target.size, + mirrors: target.mirrors + } + + const update = ( + phase: PluginCatalogInstallPhase, + patch: Partial = {} + ): void => { + const state: PluginCatalogInstallState = { + pluginId, + version, + phase, + receivedBytes: patch.receivedBytes ?? 0, + totalBytes: patch.totalBytes ?? target.size, + error: patch.error ?? null, + updatedAt: (this.deps.now ?? Date.now)() + } + this.states.set(pluginId, state) + this.deps.onProgress?.(state) + } + + let stagingDir: string | null = null + try { + update('probing', { totalBytes: target.size }) + const staged = await downloadArtifactToStaging({ + descriptor, + stagingRoot: this.deps.stagingRoot(), + fetchImpl: this.deps.fetchImpl, + signal: controller.signal, + probeTimeoutMs: this.deps.probeTimeoutMs, + now: this.deps.now, + onProgress: (progress) => { + update(progress.phase, { + receivedBytes: progress.receivedBytes, + totalBytes: progress.totalBytes + }) + } + }) + stagingDir = staged.stagingDir + + update('verifying', { receivedBytes: target.size, totalBytes: target.size }) + update('installing', { receivedBytes: target.size, totalBytes: target.size }) + // installPackage enforces the expected plugin id before extraction; the + // returned id is re-checked here as defense in depth. + const installed = await this.deps.installPackage(staged.archivePath, pluginId) + if (installed.pluginId !== pluginId) { + throw new Error(`Installed package declares a different plugin id: ${installed.pluginId}`) + } + + update('installed', { receivedBytes: target.size, totalBytes: target.size }) + return { ok: true, pluginId, version, reason: null, error: null } + } catch (error) { + const cancelled = controller.signal.aborted + const { reason, message } = cancelled + ? { reason: 'cancelled', message: 'Install cancelled' } + : describeInstallError(error) + const phase: PluginCatalogInstallPhase = reason === 'cancelled' ? 'cancelled' : 'error' + update(phase, { error: message }) + if (phase === 'error') { + logger.warn('[PluginRemoteInstaller] Install failed', { + pluginId, + version, + reason, + error: message + }) + } + return { ok: false, pluginId, version, reason, error: message } + } finally { + if (stagingDir) { + fs.rmSync(stagingDir, { recursive: true, force: true }) + } + } + } +} + +function describeInstallError(error: unknown): { reason: string; message: string } { + if (isToolchainDownloadError(error)) { + return { reason: error.reason, message: error.message } + } + const message = error instanceof Error ? error.message : String(error) + return { reason: 'install_failed', message } +} diff --git a/src/main/plugin/routes.ts b/src/main/plugin/routes.ts index 287fee2df9..68a2208124 100644 --- a/src/main/plugin/routes.ts +++ b/src/main/plugin/routes.ts @@ -9,13 +9,60 @@ import { pluginsEnableRoute, pluginsGetRoute, pluginsInvokeActionRoute, - pluginsListRoute + pluginsListRoute, + pluginsCatalogListRoute, + pluginsCatalogInstallRoute, + pluginsCatalogCancelRoute, + pluginsCatalogInstallFromPathRoute, + pluginsUninstallOfficialRoute } from '@shared/contracts/routes' import { createRouteMap, type DeepchatRouteMap } from '@/routes/routeRegistry' +import type { PluginActionResult } from '@shared/types/plugin' import type { PluginServicePort } from './index' +import type { PluginCatalogService } from './catalog' +import type { PluginRemoteInstaller } from './remoteInstaller' -export function createPluginRoutes(pluginService: PluginServicePort): DeepchatRouteMap { +export type PluginDistributionDeps = { + catalog: Pick< + PluginCatalogService, + 'listVisibleArtifacts' | 'resolveArtifact' | 'describeAvailability' + > + installer: Pick +} + +export function createPluginRoutes( + pluginService: PluginServicePort, + distribution?: PluginDistributionDeps +): DeepchatRouteMap { return createRouteMap([ + [ + pluginsUninstallOfficialRoute.name, + async (rawInput) => { + const input = pluginsUninstallOfficialRoute.input.parse(rawInput) + return pluginsUninstallOfficialRoute.output.parse({ + result: await pluginService.uninstallOfficialPlugin(input.pluginId) + }) + } + ], + [ + pluginsCatalogInstallFromPathRoute.name, + async (rawInput) => { + const input = pluginsCatalogInstallFromPathRoute.input.parse(rawInput) + try { + const installed = await pluginService.installOfficialPluginPackage(input.path) + return pluginsCatalogInstallFromPathRoute.output.parse({ + result: { ok: true, pluginId: installed.pluginId } + }) + } catch (error) { + return pluginsCatalogInstallFromPathRoute.output.parse({ + result: { + ok: false, + error: error instanceof Error ? error.message : 'Plugin package install failed' + } + }) + } + } + ], [ pluginsInspectSourceRoute.name, async (rawInput) => { @@ -90,7 +137,7 @@ export function createPluginRoutes(pluginService: PluginServicePort): DeepchatRo async (rawInput) => { const input = pluginsEnableRoute.input.parse(rawInput) return pluginsEnableRoute.output.parse({ - result: await pluginService.enablePlugin(input.pluginId) + result: await enablePluginWithRemoteInstall(pluginService, distribution, input.pluginId) }) } ], @@ -111,6 +158,123 @@ export function createPluginRoutes(pluginService: PluginServicePort): DeepchatRo result: await pluginService.invokeAction(input.pluginId, input.actionId, input.payload) }) } + ], + ...(distribution ? createDistributionRoutes(pluginService, distribution) : []) + ]) +} + +function createDistributionRoutes( + pluginService: PluginServicePort, + distribution: PluginDistributionDeps +): DeepchatRouteMap { + return createRouteMap([ + [ + pluginsCatalogListRoute.name, + async (rawInput) => { + pluginsCatalogListRoute.input.parse(rawInput) + const installedPlugins = await pluginService.listPlugins() + const installedById = new Map(installedPlugins.map((plugin) => [plugin.id, plugin])) + const entries = distribution.catalog.listVisibleArtifacts().map((artifact) => { + const installed = installedById.get(artifact.pluginId) + const { availability, target } = distribution.catalog.describeAvailability(artifact) + // `PluginListItem.installed` tracks the payload, not mere discovery, + // so a plugin whose runtime was never downloaded stays installable. + return { + pluginId: artifact.pluginId, + version: artifact.version, + channel: artifact.channel, + displayName: artifact.displayName, + description: artifact.description, + availability, + sizeBytes: target?.size ?? null, + installed: installed?.installed === true, + installedVersion: installed?.version ?? null, + installState: distribution.installer.getInstallState(artifact.pluginId) + } + }) + return pluginsCatalogListRoute.output.parse({ entries }) + } + ], + [ + pluginsCatalogInstallRoute.name, + async (rawInput) => { + const input = pluginsCatalogInstallRoute.input.parse(rawInput) + const resolution = distribution.catalog.resolveArtifact(input.pluginId) + if (!resolution) { + return pluginsCatalogInstallRoute.output.parse({ + result: { + ok: false, + error: 'Plugin artifact is not available for this platform or app version' + } + }) + } + const result = await distribution.installer.install(resolution.artifact, resolution.target) + return pluginsCatalogInstallRoute.output.parse({ + result: { ok: result.ok, error: result.error ?? undefined } + }) + } + ], + [ + pluginsCatalogCancelRoute.name, + async (rawInput) => { + const input = pluginsCatalogCancelRoute.input.parse(rawInput) + return pluginsCatalogCancelRoute.output.parse({ + cancelled: distribution.installer.cancel(input.pluginId) + }) + } ] ]) } + +/** + * Enables a plugin, transparently downloading and installing it first when it + * is declared by the distribution catalog but not present locally. The enable + * action is the user's opt-in moment; the artifact download needs no second + * confirmation. + * + * `PluginService.enablePlugin` reports failures through the returned + * `PluginActionResult` (it does not throw for a missing plugin), so both the + * result and thrown-error paths must fall back to the catalog. + */ +async function enablePluginWithRemoteInstall( + pluginService: PluginServicePort, + distribution: PluginDistributionDeps | undefined, + pluginId: string +): Promise { + const first = await enablePluginSafely(pluginService, pluginId) + if (first.ok || !distribution) return first + + // Only a missing payload can be repaired by a remote install; other + // enablement failures (e.g. activation errors) must surface unchanged. + // Discovery is not the test: a bundled manifest or a development source + // tree is discoverable while its runtime binary was never downloaded. + if (pluginService.isRuntimePayloadInstalled(pluginId)) { + return first + } + + const resolution = distribution.catalog.resolveArtifact(pluginId) + if (!resolution) return first + + const installResult = await distribution.installer.install(resolution.artifact, resolution.target) + if (!installResult.ok) { + return { + ok: false, + error: installResult.error ?? 'Plugin artifact download failed' + } + } + return await enablePluginSafely(pluginService, pluginId) +} + +async function enablePluginSafely( + pluginService: PluginServicePort, + pluginId: string +): Promise { + try { + return await pluginService.enablePlugin(pluginId) + } catch (error) { + return { + ok: false, + error: error instanceof Error ? error.message : 'Plugin enablement failed' + } + } +} diff --git a/src/renderer/api/OcrClient.ts b/src/renderer/api/OcrClient.ts index 92d49ae88d..a049833e0f 100644 --- a/src/renderer/api/OcrClient.ts +++ b/src/renderer/api/OcrClient.ts @@ -1,5 +1,13 @@ import type { DeepchatBridge } from '@shared/contracts/bridge' -import { ocrClearCacheRoute, ocrGetRuntimeStatusRoute } from '@shared/contracts/routes' +import { + ocrCancelRuntimeInstallRoute, + ocrClearCacheRoute, + ocrGetRuntimeStatusRoute, + ocrInstallRuntimeFromPathRoute, + ocrInstallRuntimeRoute, + ocrUninstallRuntimeRoute +} from '@shared/contracts/routes' +import { ocrRuntimeInstallProgressEvent, type DeepchatEventPayload } from '@shared/contracts/events' import { getDeepchatBridge } from './core' export function createOcrClient(bridge: DeepchatBridge = getDeepchatBridge()) { @@ -11,7 +19,37 @@ export function createOcrClient(bridge: DeepchatBridge = getDeepchatBridge()) { return await bridge.invoke(ocrClearCacheRoute.name, {}) } - return { getRuntimeStatus, clearCache } + async function installRuntime() { + return await bridge.invoke(ocrInstallRuntimeRoute.name, {}) + } + + async function cancelRuntimeInstall() { + return await bridge.invoke(ocrCancelRuntimeInstallRoute.name, {}) + } + + async function installRuntimeFromPath(filePath: string) { + return await bridge.invoke(ocrInstallRuntimeFromPathRoute.name, { path: filePath }) + } + + async function uninstallRuntime() { + return await bridge.invoke(ocrUninstallRuntimeRoute.name, {}) + } + + function onRuntimeInstallProgress( + listener: (payload: DeepchatEventPayload) => void + ) { + return bridge.on(ocrRuntimeInstallProgressEvent.name, listener) + } + + return { + getRuntimeStatus, + clearCache, + installRuntime, + cancelRuntimeInstall, + installRuntimeFromPath, + uninstallRuntime, + onRuntimeInstallProgress + } } export type OcrClient = ReturnType diff --git a/src/renderer/api/PluginClient.ts b/src/renderer/api/PluginClient.ts index d2cc0e0e59..08f2e72250 100644 --- a/src/renderer/api/PluginClient.ts +++ b/src/renderer/api/PluginClient.ts @@ -11,8 +11,14 @@ import { pluginsEnableRoute, pluginsGetRoute, pluginsInvokeActionRoute, - pluginsListRoute + pluginsListRoute, + pluginsCatalogListRoute, + pluginsCatalogInstallRoute, + pluginsCatalogCancelRoute, + pluginsCatalogInstallFromPathRoute, + pluginsUninstallOfficialRoute } from '@shared/contracts/routes' +import { pluginInstallProgressEvent, type DeepchatEventPayload } from '@shared/contracts/events' import type { PluginInvokeActionRequest } from '@shared/types/plugin' import { getDeepchatBridge } from './core' @@ -57,6 +63,22 @@ export function createPluginClient(bridge: DeepchatBridge = getDeepchatBridge()) retryHook: async (pluginId: string, invocationId: string) => { await bridge.invoke(pluginsRetryHookRoute.name, { pluginId, invocationId }) }, + listCatalogEntries: async () => (await bridge.invoke(pluginsCatalogListRoute.name, {})).entries, + installCatalogPlugin: async (pluginId: string) => + (await bridge.invoke(pluginsCatalogInstallRoute.name, { pluginId })).result, + cancelCatalogInstall: async (pluginId: string) => + (await bridge.invoke(pluginsCatalogCancelRoute.name, { pluginId })).cancelled, + installCatalogPluginFromPath: async (filePath: string) => + ( + await bridge.invoke(pluginsCatalogInstallFromPathRoute.name, { + path: filePath + }) + ).result, + uninstallOfficialPlugin: async (pluginId: string) => + (await bridge.invoke(pluginsUninstallOfficialRoute.name, { pluginId })).result, + onInstallProgress: ( + listener: (payload: DeepchatEventPayload) => void + ) => bridge.on(pluginInstallProgressEvent.name, listener), listPlugins, getPlugin, enablePlugin, diff --git a/src/renderer/settings/components/OcrSettings.vue b/src/renderer/settings/components/OcrSettings.vue index b20037149f..84674c08ed 100644 --- a/src/renderer/settings/components/OcrSettings.vue +++ b/src/renderer/settings/components/OcrSettings.vue @@ -247,6 +247,47 @@ + + + + + + + + ('auto') const status = ref(null) +const liveRuntimeInstall = ref(null) +let unsubscribeRuntimeInstallProgress: (() => void) | null = null const settingsReady = ref(false) const settingsOperationPending = ref(false) const statusLoading = ref(false) @@ -319,6 +367,19 @@ const windowFocused = useWindowFocus() const statusStale = computed(() => statusHasError.value && status.value !== null) const pollingAllowed = computed(() => documentVisibility.value === 'visible' && windowFocused.value) +const { pause: pausePolling, resume: resumePolling } = useIntervalFn(pollRuntimeStatus, 5_000, { + immediate: false, + immediateCallback: false +}) +let mounted = false +let disposed = false + +const activatePolling = () => { + if (!mounted || disposed || !pollingAllowed.value) return + void refreshStatus() + resumePolling() +} + const availabilityLabel = computed(() => status.value?.availability.status === 'available' ? t('settings.ocr.available') @@ -381,28 +442,58 @@ const canClearCache = computed(() => { ) }) -const { pause: pausePolling, resume: resumePolling } = useIntervalFn(pollRuntimeStatus, 5_000, { - immediate: false, - immediateCallback: false +const runtimeAssetInfo = computed(() => status.value?.runtimeAsset ?? null) +const runtimeDownloaded = computed(() => Boolean(runtimeAssetInfo.value?.installedVersion)) +// Only a bundled payload makes the runtime ready without a user-managed +// copy. In development the engine resolves from the working tree, and the +// install flow stays exercisable. +const runtimeReady = computed( + () => + status.value?.availability.status === 'available' && status.value?.runtimeSource === 'bundled' +) +const runtimeReadyLabel = computed(() => + status.value?.runtimeSource === 'bundled' + ? t('settings.ocr.runtimeReadyBundled') + : t('settings.ocr.runtimeReady') +) +const runtimeAssetSizeLabel = computed(() => { + const size = runtimeAssetInfo.value?.sizeBytes + if (size == null || size <= 0) return null + return formatBytes(size) +}) +const runtimeInstallState = computed(() => { + const live = liveRuntimeInstall.value + const polled = status.value?.runtimeInstall ?? null + // The newer of the live event stream and the polled status wins, so a + // terminal state received through polling replaces stale live progress. + if (!live) return polled + if (!polled) return live + return live.updatedAt >= polled.updatedAt ? live : polled }) -let mounted = false -let disposed = false - -const activatePolling = () => { - if (!mounted || disposed || !pollingAllowed.value) return - void refreshStatus() - resumePolling() -} onMounted(() => { mounted = true void loadSettings() activatePolling() + unsubscribeRuntimeInstallProgress = ocrClient.onRuntimeInstallProgress((payload) => { + liveRuntimeInstall.value = { + phase: payload.phase, + receivedBytes: payload.receivedBytes, + totalBytes: payload.totalBytes, + error: payload.error, + updatedAt: payload.updatedAt + } + if (payload.phase === 'installed') { + void refreshStatus() + } + }) }) onBeforeUnmount(() => { disposed = true pausePolling() + unsubscribeRuntimeInstallProgress?.() + unsubscribeRuntimeInstallProgress = null }) watch(pollingAllowed, (allowed) => { @@ -422,11 +513,6 @@ async function loadSettings(): Promise { automaticExtractionEnabled.value = values.ocrAutoExtractForNonVisionModels ?? true backend.value = values.ocrBackend ?? 'auto' settingsReady.value = true - notifyRenderer({ - kind: 'success', - code: 'settings.ocr.loaded', - title: t('common.saved') - }) } catch (error) { settingsReady.value = false console.error('[OcrSettings] Failed to load settings', error) @@ -486,6 +572,98 @@ async function updateBackend(value: AcceptableValue): Promise { } } +async function installRuntime(): Promise { + runtimeActionPending.value = true + try { + const result = await ocrClient.installRuntime() + if (!result.result.ok) { + notifyRenderer({ + kind: 'error', + code: 'settings.ocr.runtimeInstallFailed', + title: t('settings.ocr.runtimeInstallFailed'), + description: result.result.error + }) + return + } + await refreshStatus() + } catch (error) { + console.error('[OcrSettings] Failed to install OCR runtime', error) + notifyRenderer({ + kind: 'error', + code: 'settings.ocr.runtimeInstallFailed', + title: t('settings.ocr.runtimeInstallFailed') + }) + } finally { + runtimeActionPending.value = false + } +} + +async function installRuntimeFromFile(): Promise { + runtimeActionPending.value = true + try { + const selection = await deviceClient.selectFiles({ + filters: [{ name: 'ZIP', extensions: ['zip'] }] + }) + const filePath = selection.filePaths[0] + if (!filePath) return + const result = await ocrClient.installRuntimeFromPath(filePath) + if (!result.result.ok) { + notifyRenderer({ + kind: 'error', + code: 'settings.ocr.runtimeInstallFailed', + title: t('settings.ocr.runtimeInstallFailed'), + description: result.result.error + }) + return + } + await refreshStatus() + } catch (error) { + console.error('[OcrSettings] Failed to install OCR runtime from file', error) + notifyRenderer({ + kind: 'error', + code: 'settings.ocr.runtimeInstallFailed', + title: t('settings.ocr.runtimeInstallFailed') + }) + } finally { + runtimeActionPending.value = false + } +} + +async function uninstallRuntime(): Promise { + runtimeActionPending.value = true + try { + const result = await ocrClient.uninstallRuntime() + if (!result.result.ok) { + notifyRenderer({ + kind: 'error', + code: 'settings.ocr.runtimeUninstallFailed', + title: t('settings.ocr.uninstallRuntimeTitle'), + description: result.result.error + }) + return + } + uninstallDialogOpen.value = false + await refreshStatus() + } catch (error) { + console.error('[OcrSettings] Failed to uninstall OCR runtime', error) + notifyRenderer({ + kind: 'error', + code: 'settings.ocr.runtimeUninstallFailed', + title: t('settings.ocr.uninstallRuntimeTitle') + }) + } finally { + runtimeActionPending.value = false + } +} + +async function cancelRuntimeInstall(): Promise { + try { + await ocrClient.cancelRuntimeInstall() + } catch (error) { + console.error('[OcrSettings] Failed to cancel OCR runtime install', error) + } +} + async function refreshStatus(): Promise { if (statusLoading.value || cacheClearInFlight.value) return statusLoading.value = true diff --git a/src/renderer/src/components/plugins/RuntimeInstallControls.vue b/src/renderer/src/components/plugins/RuntimeInstallControls.vue new file mode 100644 index 0000000000..87e7747106 --- /dev/null +++ b/src/renderer/src/components/plugins/RuntimeInstallControls.vue @@ -0,0 +1,155 @@ + + + diff --git a/src/renderer/src/i18n/bo-CN/settings.json b/src/renderer/src/i18n/bo-CN/settings.json index 8f799bdf42..197a46382b 100644 --- a/src/renderer/src/i18n/bo-CN/settings.json +++ b/src/renderer/src/i18n/bo-CN/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "འདྲ་མཛོད་ཀྱི་གཞི་གྲངས་མཛོད་ཁ་ཕྱེ་མི་ཐུབ་པ་དང་། རྒྱུན་སྲིང་གི་འདྲ་མཛོད་བཀོལ་སྤྱོད་བྱེད་མི་ཐུབ་པ་རེད།", "safe_storage_unavailable": "མ་ལག་གི་བདེ་འཇགས་ལྡེ་མིག་གསོག་ཉར་བྱེད་མི་ཐུབ་པ་དང་། རྒྱུན་མཐུད་ཀྱི་འདྲ་མཛོད་མེད་པར་བཟོས་པ་རེད།" - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "བཟོ་བཅོས་ད་དུང་ཉར་ཚགས་བྱས་མེད།", @@ -3026,7 +3041,10 @@ "readyOnDemand": "དགོས་མཁོར་གཞིགས་ནས་གྲ་སྒྲིག་བྱས་པ།", "quarantined": "ལོགས་སུ་བཀར་ནས་བསྡད་ཡོད།", "error": "ནོར་འཛོལ་བྱུང་བ།" - } + }, + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "pluginsHub": { "subtitle": "DeepChat Skills དང་། MCP། གཞུང་ཕྱོགས་ཀྱི་ལྷུ་ལག་དང་རྒྱང་རིང་ཐབས་ལམ་བཅས་གཅིག་གྱུར་གྱིས་དོ་དམ།", @@ -3043,7 +3061,22 @@ "agentScopeUnsupported": "Agent རིམ་བསྲིངས་བཀོད་སྒྲིག་གིས་DeepChat Agent ཁོ་ནར་རྒྱབ་སྐྱོར་བྱེད་པ་ཡིན།", "scopeGlobalPlugins": "ལྷུ་ལག་སྒྲིག་སྦྱོར་དང་། ནུས་པ། བརྒྱུད་རིམ་གྱི་གནས་ཚུལ་བཅས་ཚང་མ་མཉམ་སྤྱོད་DeepChat Agent ཡིན།", "scopeCurrentAgent": "མིག་སྔའི་ཤོག་ངོས་ནི་སྲིད་ཇུས་Agent ལ་དམིགས་ཏེ། དེས་\"{agent}\"ལ་སྤྱོད་ཆོག་པའི་Skills/MCP ཁོ་ནར་ཤུགས་རྐྱེན་ཐེབས་པ་ལས། གོ་ལ་ཧྲིལ་པོའི་ལྷུ་ལག་གི་སྒྲིག་སྦྱོར་དང་། ནུས་པ་ཐོན་པའི་གནས་ཚུལ། ཡང་ན་བརྒྱུད་རིམ་གྱི་གནས་ཚུལ་སོགས་ལ་ཤུགས་རྐྱེན་མི་ཐེབས།", - "currentAgentFallback": "མིག་སྔའི་Agent" + "currentAgentFallback": "མིག་སྔའི་Agent", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "No download is offered for this version. Install from a file instead." }, "controlCenter": { "groups": { diff --git a/src/renderer/src/i18n/da-DK/settings.json b/src/renderer/src/i18n/da-DK/settings.json index 2d945b6bc9..03ac70b69b 100644 --- a/src/renderer/src/i18n/da-DK/settings.json +++ b/src/renderer/src/i18n/da-DK/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "Den vedvarende cache er utilgængelig, fordi databasen ikke kunne åbnes.", "safe_storage_unavailable": "Den vedvarende cache er utilgængelig, fordi sikker nøglelagring ikke er tilgængelig." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "Ikke-gemte ændringer", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Agentafgrænsede udvidelser er kun tilgængelige for DeepChat-agenter.", "scopeGlobalPlugins": "Denne side er app-global: installation, aktivering og processtatus for officielle plugins deles af alle DeepChat-agenter.", "scopeCurrentAgent": "Denne side er agentpolitik: ændringer påvirker kun Skills/MCP-tilgængelighed for “{agent}”, ikke globale installationer.", - "currentAgentFallback": "aktuel agent" + "currentAgentFallback": "aktuel agent", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "Der tilbydes ingen download til denne version. Installer fra en fil." }, "debug": { "description": "Værktøjer kun til udvikling til forhåndsvisning af mock-tilstande og guidede forløb.", diff --git a/src/renderer/src/i18n/de-DE/settings.json b/src/renderer/src/i18n/de-DE/settings.json index 75f1b54049..b4930d9066 100644 --- a/src/renderer/src/i18n/de-DE/settings.json +++ b/src/renderer/src/i18n/de-DE/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "Der persistente Cache ist nicht verfügbar, weil seine Datenbank nicht geöffnet werden konnte.", "safe_storage_unavailable": "Der persistente Cache ist nicht verfügbar, weil keine sichere Schlüsselspeicherung verfügbar ist." - } + }, + "runtimeDownloadTitle": "Laufzeit-Download", + "runtimeDownloadDescription": "Die OCR-Engine kann bei Bedarf heruntergeladen werden. Sie wird beim ersten Bedarf automatisch heruntergeladen.", + "runtimeInstall": "OCR-Engine herunterladen", + "runtimeInstalling": "Wird heruntergeladen {percent}%", + "runtimeCancelInstall": "Abbrechen", + "runtimeRetryInstall": "Erneut versuchen", + "runtimeInstallFailed": "Download der OCR-Engine fehlgeschlagen", + "runtimeUnavailablePlatform": "Auf dieser Plattform nicht verfügbar", + "runtimeIncompatibleApp": "Erfordert eine neuere App-Version", + "runtimeReady": "Die OCR-Engine ist auf diesem Gerät einsatzbereit", + "runtimeDownloadHint": "Lade die OCR-Engine herunter, um sie auf diesem Gerät zu nutzen", + "runtimeDownloadedVersion": "Heruntergeladene Version", + "uninstallRuntimeTitle": "OCR-Engine entfernen", + "uninstallRuntimeDescription": "Die heruntergeladene OCR-Engine wird von diesem Gerät entfernt. Sie wird beim nächsten Bedarf erneut heruntergeladen.", + "runtimeReadyBundled": "OCR-Engine ist bereit (in der App enthalten)" }, "leaveGuard": { "dirtyTitle": "Nicht gespeicherte Änderungen", @@ -2972,7 +2987,10 @@ "readyOnDemand": "Bei Bedarf bereit", "quarantined": "Unter Quarantäne", "error": "Fehler" - } + }, + "uninstallTitle": "{name} deinstallieren?", + "uninstallDescription": "Das Plugin wird deaktiviert und die installierten Dateien werden entfernt. Heruntergeladene Plugins verschwinden bis zur Neuinstallation.", + "uninstallFailed": "Deinstallation fehlgeschlagen" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Agentenspezifische Erweiterungen sind nur für DeepChat-Agenten verfügbar.", "scopeGlobalPlugins": "Diese Seite ist app-weit: Installation, Aktivierung und Prozessstatus offizieller Plugins gelten für alle DeepChat-Agenten.", "scopeCurrentAgent": "Diese Seite ist Agent-Richtlinie: Änderungen betreffen nur die Skills/MCP-Verfügbarkeit von „{agent}“, nicht die globalen Installationen.", - "currentAgentFallback": "aktueller Agent" + "currentAgentFallback": "aktueller Agent", + "downloadable": "Herunterladbare Plugins", + "installPlugin": "Installieren", + "installing": "Wird heruntergeladen {percent}%", + "retryInstall": "Erneut versuchen", + "cancelInstall": "Abbrechen", + "installFailed": "Installation fehlgeschlagen", + "preRelease": "Vorabversion", + "manualInstall": "Aus Datei installieren", + "uninstall": "Deinstallieren", + "runtimeCardTitle": "Laufzeit", + "incompatibleApp": "Erfordert eine neuere App-Version", + "unavailablePlatform": "Auf dieser Plattform nicht verfügbar", + "manageCardTitle": "Verwalten", + "installNow": "Jetzt installieren", + "notDistributed": "Für diese Version wird kein Download angeboten. Bitte aus einer Datei installieren." }, "debug": { "description": "Nur für die Entwicklung bestimmte Werkzeuge zur Vorschau von Mock-Zuständen und geführten Abläufen.", diff --git a/src/renderer/src/i18n/en-US/settings.json b/src/renderer/src/i18n/en-US/settings.json index 351cdec3ae..47c6f202fa 100644 --- a/src/renderer/src/i18n/en-US/settings.json +++ b/src/renderer/src/i18n/en-US/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "Persistent cache is unavailable because its database could not be opened.", "safe_storage_unavailable": "Persistent cache is unavailable because secure key storage is not available." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "Unsaved changes", @@ -3026,7 +3041,10 @@ "readyOnDemand": "Ready on demand", "quarantined": "Quarantined", "error": "Error" - } + }, + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "pluginsHub": { "subtitle": "Work with DeepChat across skills, MCP servers, official plugins, and remote channels.", @@ -3043,7 +3061,22 @@ "agentScopeUnsupported": "Agent-scoped extensions are only available for DeepChat agents.", "scopeGlobalPlugins": "Plugin installation, enablement and process state are shared by all DeepChat agents.", "scopeCurrentAgent": "This page is agent policy: changes affect only \"{agent}\" Skills/MCP availability, not global installs.", - "currentAgentFallback": "current agent" + "currentAgentFallback": "current agent", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "No download is offered for this version. Install from a file instead." }, "controlCenter": { "groups": { diff --git a/src/renderer/src/i18n/es-ES/settings.json b/src/renderer/src/i18n/es-ES/settings.json index 8678a8769b..fd07837a32 100644 --- a/src/renderer/src/i18n/es-ES/settings.json +++ b/src/renderer/src/i18n/es-ES/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "La caché persistente no está disponible porque no se pudo abrir su base de datos.", "safe_storage_unavailable": "La caché persistente no está disponible porque no hay almacenamiento seguro de claves." - } + }, + "runtimeDownloadTitle": "Descarga del motor", + "runtimeDownloadDescription": "El motor OCR se puede descargar a petición. Se descarga automáticamente la primera vez que se necesita.", + "runtimeInstall": "Descargar motor OCR", + "runtimeInstalling": "Descargando {percent}%", + "runtimeCancelInstall": "Cancelar", + "runtimeRetryInstall": "Reintentar", + "runtimeInstallFailed": "Error al descargar el motor OCR", + "runtimeUnavailablePlatform": "No disponible en esta plataforma", + "runtimeIncompatibleApp": "Requiere una versión más reciente de la aplicación", + "runtimeReady": "El motor OCR está listo en este dispositivo", + "runtimeDownloadHint": "Descarga el motor OCR para usarlo en este dispositivo", + "runtimeDownloadedVersion": "Versión descargada", + "uninstallRuntimeTitle": "Eliminar motor OCR", + "uninstallRuntimeDescription": "El motor OCR descargado se elimina de este dispositivo. Se descargará de nuevo la próxima vez que se necesite.", + "runtimeReadyBundled": "El motor OCR está listo (incluido en la app)" }, "leaveGuard": { "dirtyTitle": "Cambios sin guardar", @@ -2972,7 +2987,10 @@ "readyOnDemand": "Listo bajo demanda", "quarantined": "En cuarentena", "error": "error" - } + }, + "uninstallTitle": "¿Desinstalar {name}?", + "uninstallDescription": "El plugin se desactiva y sus archivos instalados se eliminan. Los plugins descargados desaparecen hasta volver a instalarse.", + "uninstallFailed": "Error al desinstalar" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Las extensiones específicas de cada agente solo están disponibles para los agentes de DeepChat.", "scopeGlobalPlugins": "Esta página es global de la app: la instalación, habilitación y estado de proceso de los plugins oficiales se comparten entre todos los agentes DeepChat.", "scopeCurrentAgent": "Esta página es política del agente: los cambios solo afectan la disponibilidad de Skills/MCP de «{agent}», no las instalaciones globales.", - "currentAgentFallback": "agente actual" + "currentAgentFallback": "agente actual", + "downloadable": "Plugins descargables", + "installPlugin": "Instalar", + "installing": "Descargando {percent}%", + "retryInstall": "Reintentar", + "cancelInstall": "Cancelar", + "installFailed": "Error de instalación", + "preRelease": "Prelanzamiento", + "manualInstall": "Instalar desde archivo", + "uninstall": "Desinstalar", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requiere una versión más reciente de la aplicación", + "unavailablePlatform": "No disponible en esta plataforma", + "manageCardTitle": "Gestionar", + "installNow": "Instalar ahora", + "notDistributed": "No hay descarga disponible para esta versión. Instala desde un archivo." }, "debug": { "description": "Herramientas exclusivas para desarrollo que permiten previsualizar estados simulados y flujos guiados.", diff --git a/src/renderer/src/i18n/fa-IR/settings.json b/src/renderer/src/i18n/fa-IR/settings.json index 1a79e96249..aa2508ba31 100644 --- a/src/renderer/src/i18n/fa-IR/settings.json +++ b/src/renderer/src/i18n/fa-IR/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "حافظهٔ نهان پایدار در دسترس نیست، زیرا پایگاه‌دادهٔ آن باز نشد.", "safe_storage_unavailable": "حافظهٔ نهان پایدار در دسترس نیست، زیرا ذخیره‌سازی امن کلید فراهم نیست." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "تغییرات ذخیره‌نشده", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "افزونه‌های ویژهٔ هر عامل فقط برای عامل‌های DeepChat در دسترس هستند.", "scopeGlobalPlugins": "این صفحه سراسری برنامه است: نصب، فعال‌سازی و وضعیت فرایند افزونه‌های رسمی بین همه عامل‌های DeepChat مشترک است.", "scopeCurrentAgent": "این صفحه سیاست عامل است: تغییرات فقط روی در دسترس بودن Skills/MCP برای «{agent}» اثر می‌گذارد، نه روی نصب‌های سراسری.", - "currentAgentFallback": "عامل فعلی" + "currentAgentFallback": "عامل فعلی", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "برای این نسخه دانلودی ارائه نشده است. از فایل نصب کنید." }, "debug": { "description": "ابزارهای مخصوص توسعه برای پیش‌نمایش وضعیت‌های شبیه‌سازی‌شده و جریان‌های راهنما.", diff --git a/src/renderer/src/i18n/fr-FR/settings.json b/src/renderer/src/i18n/fr-FR/settings.json index 2f4a9dfbb1..7acfc15ac2 100644 --- a/src/renderer/src/i18n/fr-FR/settings.json +++ b/src/renderer/src/i18n/fr-FR/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "Le cache persistant est indisponible, car sa base de données n’a pas pu être ouverte.", "safe_storage_unavailable": "Le cache persistant est indisponible, car aucun stockage sécurisé des clés n’est disponible." - } + }, + "runtimeDownloadTitle": "Téléchargement du moteur", + "runtimeDownloadDescription": "Le moteur OCR peut être téléchargé à la demande. Il se télécharge automatiquement lors du premier besoin.", + "runtimeInstall": "Télécharger le moteur OCR", + "runtimeInstalling": "Téléchargement {percent}%", + "runtimeCancelInstall": "Annuler", + "runtimeRetryInstall": "Réessayer", + "runtimeInstallFailed": "Échec du téléchargement du moteur OCR", + "runtimeUnavailablePlatform": "Indisponible sur cette plateforme", + "runtimeIncompatibleApp": "Nécessite une version plus récente de l'application", + "runtimeReady": "Le moteur OCR est prêt sur cet appareil", + "runtimeDownloadHint": "Téléchargez le moteur OCR pour l'utiliser sur cet appareil", + "runtimeDownloadedVersion": "Version téléchargée", + "uninstallRuntimeTitle": "Supprimer le moteur OCR", + "uninstallRuntimeDescription": "Le moteur OCR téléchargé est supprimé de cet appareil. Il sera retéléchargé au prochain besoin.", + "runtimeReadyBundled": "Moteur OCR prêt (inclus dans l'application)" }, "leaveGuard": { "dirtyTitle": "Modifications non enregistrées", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "Désinstaller {name} ?", + "uninstallDescription": "Le plugin est désactivé et ses fichiers installés sont supprimés. Les plugins téléchargés disparaissent jusqu'à réinstallation.", + "uninstallFailed": "Échec de la désinstallation" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Les extensions propres à un agent ne sont disponibles que pour les agents DeepChat.", "scopeGlobalPlugins": "Cette page est globale à l’application : l’installation, l’activation et l’état des processus des plugins officiels sont partagés par tous les agents DeepChat.", "scopeCurrentAgent": "Cette page est une politique d’agent : les modifications n’affectent que la disponibilité Skills/MCP de « {agent} », pas les installations globales.", - "currentAgentFallback": "agent actuel" + "currentAgentFallback": "agent actuel", + "downloadable": "Extensions téléchargeables", + "installPlugin": "Installer", + "installing": "Téléchargement {percent}%", + "retryInstall": "Réessayer", + "cancelInstall": "Annuler", + "installFailed": "Échec de l'installation", + "preRelease": "Préversion", + "manualInstall": "Installer depuis un fichier", + "uninstall": "Désinstaller", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Nécessite une version plus récente de l'application", + "unavailablePlatform": "Indisponible sur cette plateforme", + "manageCardTitle": "Gérer", + "installNow": "Installer maintenant", + "notDistributed": "Aucun téléchargement n'est proposé pour cette version. Installez depuis un fichier." }, "debug": { "description": "Outils réservés au développement pour prévisualiser des états simulés et des parcours guidés.", diff --git a/src/renderer/src/i18n/he-IL/settings.json b/src/renderer/src/i18n/he-IL/settings.json index 6a5dc953f9..066768f988 100644 --- a/src/renderer/src/i18n/he-IL/settings.json +++ b/src/renderer/src/i18n/he-IL/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "המטמון המתמשך אינו זמין משום שלא ניתן לפתוח את מסד הנתונים שלו.", "safe_storage_unavailable": "המטמון המתמשך אינו זמין משום שאין אחסון מאובטח למפתחות." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "שינויים שלא נשמרו", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "הרחבות ברמת הסוכן זמינות רק לסוכני DeepChat.", "scopeGlobalPlugins": "עמוד זה הוא גלובלי לאפליקציה: התקנה, הפעלה ומצב תהליך של תוספים רשמיים משותפים לכל סוכני DeepChat.", "scopeCurrentAgent": "עמוד זה הוא מדיניות סוכן: השינויים משפיעים רק על זמינות Skills/MCP של „{agent}”, לא על התקנות גלובליות.", - "currentAgentFallback": "הסוכן הנוכחי" + "currentAgentFallback": "הסוכן הנוכחי", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "לגרסה זו לא מוצעת הורדה. התקן מקובץ." }, "debug": { "description": "כלים לפיתוח בלבד להצגת תצוגה מקדימה של מצבי דמה ותהליכים מודרכים.", diff --git a/src/renderer/src/i18n/id-ID/settings.json b/src/renderer/src/i18n/id-ID/settings.json index 670c63ea04..cebc2d19dc 100644 --- a/src/renderer/src/i18n/id-ID/settings.json +++ b/src/renderer/src/i18n/id-ID/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "Cache persisten tidak tersedia karena basis datanya tidak dapat dibuka.", "safe_storage_unavailable": "Cache persisten tidak tersedia karena penyimpanan kunci aman tidak tersedia." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "Perubahan belum disimpan", @@ -2972,7 +2987,10 @@ "readyOnDemand": "Siap saat diperlukan", "quarantined": "Dikarantina", "error": "Kesalahan" - } + }, + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Ekstensi khusus agen hanya tersedia untuk agen DeepChat.", "scopeGlobalPlugins": "Halaman ini bersifat global aplikasi: instalasi, pengaktifan, dan status proses plugin resmi dibagikan ke semua agen DeepChat.", "scopeCurrentAgent": "Halaman ini adalah kebijakan agen: perubahan hanya memengaruhi ketersediaan Skills/MCP untuk “{agent}”, bukan instalasi global.", - "currentAgentFallback": "agen saat ini" + "currentAgentFallback": "agen saat ini", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "Tidak ada unduhan untuk versi ini. Instal dari file." }, "debug": { "description": "Alat khusus pengembangan untuk mempratinjau status tiruan dan alur terpandu.", diff --git a/src/renderer/src/i18n/it-IT/settings.json b/src/renderer/src/i18n/it-IT/settings.json index 284403fb42..2c371cb766 100644 --- a/src/renderer/src/i18n/it-IT/settings.json +++ b/src/renderer/src/i18n/it-IT/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "La cache persistente non è disponibile perché non è stato possibile aprire il database.", "safe_storage_unavailable": "La cache persistente non è disponibile perché non è disponibile l’archiviazione sicura delle chiavi." - } + }, + "runtimeDownloadTitle": "Download del motore", + "runtimeDownloadDescription": "Il motore OCR può essere scaricato on demand. Viene scaricato automaticamente al primo utilizzo.", + "runtimeInstall": "Scarica il motore OCR", + "runtimeInstalling": "Download {percent}%", + "runtimeCancelInstall": "Annulla", + "runtimeRetryInstall": "Riprova", + "runtimeInstallFailed": "Download del motore OCR non riuscito", + "runtimeUnavailablePlatform": "Non disponibile su questa piattaforma", + "runtimeIncompatibleApp": "Richiede una versione più recente dell'app", + "runtimeReady": "Il motore OCR è pronto su questo dispositivo", + "runtimeDownloadHint": "Scarica il motore OCR per usarlo su questo dispositivo", + "runtimeDownloadedVersion": "Versione scaricata", + "uninstallRuntimeTitle": "Rimuovi motore OCR", + "uninstallRuntimeDescription": "Il motore OCR scaricato viene rimosso da questo dispositivo. Verrà riscaricato al prossimo utilizzo.", + "runtimeReadyBundled": "Motore OCR pronto (incluso nell'app)" }, "leaveGuard": { "dirtyTitle": "Modifiche non salvate", @@ -2972,7 +2987,10 @@ "readyOnDemand": "Pronto su richiesta", "quarantined": "In quarantena", "error": "Errore" - } + }, + "uninstallTitle": "Disinstallare {name}?", + "uninstallDescription": "Il plugin viene disattivato e i suoi file installati rimossi. I plugin scaricati scompaiono fino alla reinstallazione.", + "uninstallFailed": "Disinstallazione non riuscita" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Le estensioni specifiche per agente sono disponibili solo per gli agenti DeepChat.", "scopeGlobalPlugins": "Questa pagina è globale all’app: installazione, abilitazione e stato dei processi dei plugin ufficiali sono condivisi da tutti gli agenti DeepChat.", "scopeCurrentAgent": "Questa pagina è policy dell’agente: le modifiche riguardano solo la disponibilità Skills/MCP di «{agent}», non le installazioni globali.", - "currentAgentFallback": "agente corrente" + "currentAgentFallback": "agente corrente", + "downloadable": "Plugin scaricabili", + "installPlugin": "Installa", + "installing": "Download {percent}%", + "retryInstall": "Riprova", + "cancelInstall": "Annulla", + "installFailed": "Installazione non riuscita", + "preRelease": "Prerelease", + "manualInstall": "Installa da file", + "uninstall": "Disinstalla", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Richiede una versione più recente dell'app", + "unavailablePlatform": "Non disponibile su questa piattaforma", + "manageCardTitle": "Gestisci", + "installNow": "Installa ora", + "notDistributed": "Nessun download disponibile per questa versione. Installa da file." }, "debug": { "description": "Strumenti riservati allo sviluppo per visualizzare in anteprima stati simulati e flussi guidati.", diff --git a/src/renderer/src/i18n/ja-JP/settings.json b/src/renderer/src/i18n/ja-JP/settings.json index 71c16c83c5..f3d65454c0 100644 --- a/src/renderer/src/i18n/ja-JP/settings.json +++ b/src/renderer/src/i18n/ja-JP/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "データベースを開けないため、永続キャッシュを利用できません。", "safe_storage_unavailable": "安全な鍵ストレージを利用できないため、永続キャッシュを利用できません。" - } + }, + "runtimeDownloadTitle": "ランタイムのダウンロード", + "runtimeDownloadDescription": "OCR エンジンはオンデマンドでダウンロードできます。初回必要時に自動でダウンロードされます。", + "runtimeInstall": "OCR エンジンをダウンロード", + "runtimeInstalling": "ダウンロード中 {percent}%", + "runtimeCancelInstall": "キャンセル", + "runtimeRetryInstall": "再試行", + "runtimeInstallFailed": "OCR エンジンのダウンロードに失敗しました", + "runtimeUnavailablePlatform": "このプラットフォームでは利用できません", + "runtimeIncompatibleApp": "新しいバージョンのアプリが必要です", + "runtimeReady": "OCR エンジンはこの端末で利用できます", + "runtimeDownloadHint": "OCR エンジンをダウンロードしてこの端末で利用する", + "runtimeDownloadedVersion": "ダウンロード済みバージョン", + "uninstallRuntimeTitle": "OCR エンジンを削除", + "uninstallRuntimeDescription": "ダウンロードした OCR エンジンをこの端末から削除します。次回必要時に再ダウンロードされます。", + "runtimeReadyBundled": "OCR エンジンは利用可能です(アプリ同梱)" }, "leaveGuard": { "dirtyTitle": "未保存の変更", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "{name} をアンインストールしますか?", + "uninstallDescription": "プラグインを無効化し、インストール済みファイルを削除します。ダウンロードしたプラグインは再インストールが必要です。", + "uninstallFailed": "アンインストールに失敗しました" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "エージェント単位の拡張機能は DeepChat エージェントでのみ利用できます。", "scopeGlobalPlugins": "このページはアプリ全体設定です。公式プラグインのインストール・有効化・プロセス状態は、すべての DeepChat エージェントで共有されます。", "scopeCurrentAgent": "このページはエージェント方針です。変更は「{agent}」の Skills/MCP 利用可否にのみ影響し、グローバルなインストールには影響しません。", - "currentAgentFallback": "現在のエージェント" + "currentAgentFallback": "現在のエージェント", + "downloadable": "ダウンロード可能なプラグイン", + "installPlugin": "インストール", + "installing": "ダウンロード中 {percent}%", + "retryInstall": "再試行", + "cancelInstall": "キャンセル", + "installFailed": "インストールに失敗しました", + "preRelease": "プレリリース", + "manualInstall": "ファイルからインストール", + "uninstall": "アンインストール", + "runtimeCardTitle": "ランタイム", + "incompatibleApp": "新しいバージョンのアプリが必要です", + "unavailablePlatform": "このプラットフォームでは利用できません", + "manageCardTitle": "管理", + "installNow": "今すぐインストール", + "notDistributed": "このバージョンではダウンロードを提供していません。ファイルからインストールしてください。" }, "debug": { "description": "モック状態やガイド付きフローをプレビューするための、開発専用ツールです。", diff --git a/src/renderer/src/i18n/ko-KR/settings.json b/src/renderer/src/i18n/ko-KR/settings.json index 6d24dc358f..936594e222 100644 --- a/src/renderer/src/i18n/ko-KR/settings.json +++ b/src/renderer/src/i18n/ko-KR/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "데이터베이스를 열 수 없어 영구 캐시를 사용할 수 없습니다.", "safe_storage_unavailable": "안전한 키 저장소를 사용할 수 없어 영구 캐시를 사용할 수 없습니다." - } + }, + "runtimeDownloadTitle": "런타임 다운로드", + "runtimeDownloadDescription": "OCR 엔진은 필요할 때 다운로드할 수 있습니다. 처음 필요할 때 자동으로 다운로드됩니다.", + "runtimeInstall": "OCR 엔진 다운로드", + "runtimeInstalling": "다운로드 중 {percent}%", + "runtimeCancelInstall": "취소", + "runtimeRetryInstall": "다시 시도", + "runtimeInstallFailed": "OCR 엔진 다운로드 실패", + "runtimeUnavailablePlatform": "이 플랫폼에서는 사용할 수 없습니다", + "runtimeIncompatibleApp": "새 버전의 앱이 필요합니다", + "runtimeReady": "OCR 엔진이 준비되었습니다", + "runtimeDownloadHint": "OCR 엔진을 다운로드하여 이 기기에서 사용", + "runtimeDownloadedVersion": "다운로드된 버전", + "uninstallRuntimeTitle": "OCR 엔진 삭제", + "uninstallRuntimeDescription": "다운로드한 OCR 엔진이 이 기기에서 삭제됩니다. 다음에 필요할 때 다시 다운로드됩니다.", + "runtimeReadyBundled": "OCR 엔진 준비 완료(앱 내장)" }, "leaveGuard": { "dirtyTitle": "저장하지 않은 변경 사항", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "{name}을(를) 제거할까요?", + "uninstallDescription": "플러그인이 비활성화되고 설치된 파일이 삭제됩니다. 다운로드한 플러그인은 다시 설치해야 합니다.", + "uninstallFailed": "제거 실패" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "에이전트별 확장 기능은 DeepChat 에이전트에서만 사용할 수 있습니다.", "scopeGlobalPlugins": "이 페이지는 앱 전역 설정입니다. 공식 플러그인의 설치·활성화·프로세스 상태는 모든 DeepChat 에이전트에서 공유됩니다.", "scopeCurrentAgent": "이 페이지는 에이전트 정책입니다. 변경 사항은 “{agent}”의 Skills/MCP 사용 가능 여부에만 영향을 주며, 전역 설치에는 영향을 주지 않습니다.", - "currentAgentFallback": "현재 에이전트" + "currentAgentFallback": "현재 에이전트", + "downloadable": "다운로드 가능한 플러그인", + "installPlugin": "설치", + "installing": "다운로드 중 {percent}%", + "retryInstall": "다시 시도", + "cancelInstall": "취소", + "installFailed": "설치 실패", + "preRelease": "사전 출시", + "manualInstall": "파일에서 설치", + "uninstall": "제거", + "runtimeCardTitle": "런타임", + "incompatibleApp": "새 버전의 앱이 필요합니다", + "unavailablePlatform": "이 플랫폼에서는 사용할 수 없습니다", + "manageCardTitle": "관리", + "installNow": "지금 설치", + "notDistributed": "이 버전에서는 다운로드를 제공하지 않습니다. 파일에서 설치하세요." }, "debug": { "description": "모의 상태와 안내 흐름을 미리 보기 위한 개발 전용 도구입니다.", diff --git a/src/renderer/src/i18n/mn-Mong-CN/settings.json b/src/renderer/src/i18n/mn-Mong-CN/settings.json index efa507f1f5..3ae8ee68fe 100644 --- a/src/renderer/src/i18n/mn-Mong-CN/settings.json +++ b/src/renderer/src/i18n/mn-Mong-CN/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "ᠶᠢᠨ ᠬᠥᠮᠥᠷᠭᠡ ᠳ᠋ᠠᠶᠢᠲ᠋ᠠ ᠬᠥᠮᠥᠷᠭᠡ ᠶᠢ ᠨᠡᠭᠡᠭᠡᠵᠦ ᠳᠡᠶᠢᠯᠬᠦ ᠦᠭᠡᠶ ᠂ ᠤᠳᠠᠭᠠᠨ ᠳᠠᠭᠠᠭᠠᠮᠠᠭᠠᠶ ᠬᠠᠳᠠᠭᠠᠯᠠᠮᠵᠢ ᠶᠢᠨ ᠬᠥᠮᠥᠷᠭᠡ ᠪᠠᠶᠢᠬᠤ ᠦᠭᠡᠶ ᠃", "safe_storage_unavailable": "ᠶᠢᠨ ᠠᠶᠤᠯ ᠦᠭᠡᠶ ᠶᠢᠨ ᠲᠦᠯᠬᠢᠭᠦᠷ ᠦᠨ ᠬᠠᠳᠠᠭᠠᠯᠠᠮᠵᠢ ᠶᠢ ᠬᠡᠷᠡᠭᠯᠡᠬᠦ ᠪᠡᠨ ᠪᠣᠯᠢᠭᠰᠠᠨ ᠂ ᠳᠠᠭᠠᠭᠠᠮᠠᠭᠠᠶ ᠴᠢᠨᠠᠷ ᠲᠠᠶ ᠡᠭᠡᠪᠴᠢ ᠬᠠᠳᠠᠭᠠᠯᠠᠮᠵᠢ ᠶᠢ ᠬᠡᠷᠡᠭᠯᠡᠬᠦ ᠪᠡᠨ ᠲᠠᠰᠤᠯᠵᠠᠶ ᠃" - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "ᠵᠠᠰᠠᠭᠰᠠᠨ ᠵᠦᠢᠯ ᠢ ᠬᠠᠳᠠᠭᠠᠯᠠᠭᠰᠠᠨ ᠦᠭᠡᠢ", @@ -3026,7 +3041,10 @@ "readyOnDemand": "ᠳᠤ ᠪᠡᠯᠡᠳᠬᠡᠬᠦ", "quarantined": "ᠡᠴᠡ ᠭᠠᠳᠠᠭᠤᠷᠴᠢᠯᠠᠭᠳᠠᠬᠤ", "error": "ᠠᠯᠳᠠᠭ᠎ᠠ" - } + }, + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "pluginsHub": { "subtitle": "ᠶᠢᠨ Skills ᠂ MCP ᠶᠢᠨ ᠨᠣᠢᠯᠭᠠᠯᠲᠠ ᠂ ᠠᠯᠤᠰ ᠡᠵᠡᠮᠰᠢᠯDeepChat ᠦᠨ ᠠᠷᠭ᠎ᠠ ᠵᠠᠮ  ᠢ ᠨᠢᠭᠡᠳᠦᠯᠲᠡᠶ ᠬᠠᠮᠢᠶᠠᠷᠤᠨ᠎ᠠ ᠃", @@ -3043,7 +3061,22 @@ "agentScopeUnsupported": "Agent ᠳᠡᠰ ᠤᠨ ᠤᠷᠲᠤᠳᠬᠠᠯᠲᠠ ᠶᠢᠨ ᠲᠣᠬᠢᠷᠠᠭᠤᠯᠤᠯᠲᠠ ᠨᠢ ᠵᠥᠪᠬᠡᠨ DeepChat Agent ᠶᠢ ᠳᠡᠮᠵᠢᠨ᠎ᠡ ᠃", "scopeGlobalPlugins": "ᠶᠢᠨ ᠲᠣᠨᠣᠭᠯᠠᠯ ᠂ ᠨᠡᠭᠡᠭᠡᠯᠲᠡ ᠬᠢᠬᠦ ᠪᠠ ᠶᠠᠪᠤᠴᠠ ᠶᠢᠨ ᠪᠠᠶᠢᠳᠠᠯ ᠢ ᠡᠯ᠎ᠡ ᠮᠡᠳᠡᠭᠡᠨ ᠦ ᠲᠣᠬᠢᠶᠠᠯᠳᠤᠭᠠᠨ ᠤ DeepChat Agent ᠃", "scopeCurrentAgent": "ᠣᠳᠣ ᠶᠢᠨ ᠬᠠᠭᠤᠳᠠᠰᠤ ᠪᠣᠯ ᠲᠥᠷᠥ ᠶᠢᠨ Agent ᠵᠥᠪᠬᠡᠨ 《 {agent} ᠶᠢᠨ ᠳᠣᠲᠣᠷᠠᠬᠢ Skills MCP ᠢ ᠨᠥᠯᠥᠭᠡᠯᠡᠵᠦ ᠂ ᠪᠦᠬᠦ ᠪᠥᠮᠪᠥᠷᠴᠡᠭ ᠦᠨ ᠬᠠᠮᠢᠶ᠎ᠠ ᠶᠢ ᠤᠭᠰᠠᠷᠠᠬᠤ ᠂ ᠨᠥᠬᠥᠴᠡᠯ ᠦᠨ ᠪᠠᠶᠢᠳᠠᠯ ᠪᠤᠶᠤ ᠶᠠᠪᠤᠴᠠ ᠶᠢᠨ ᠪᠠᠶᠢᠳᠠᠯ ᠢ ᠨᠥᠯᠥᠭᠡᠯᠡᠬᠦ ᠦᠭᠡᠢ ᠃", - "currentAgentFallback": "ᠣᠳᠣᠭ᠎ᠠ ᠶᠢᠨ Agent" + "currentAgentFallback": "ᠣᠳᠣᠭ᠎ᠠ ᠶᠢᠨ Agent", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "No download is offered for this version. Install from a file instead." }, "controlCenter": { "groups": { diff --git a/src/renderer/src/i18n/ms-MY/settings.json b/src/renderer/src/i18n/ms-MY/settings.json index fdf2dc9463..72501d7d72 100644 --- a/src/renderer/src/i18n/ms-MY/settings.json +++ b/src/renderer/src/i18n/ms-MY/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "Cache berterusan tidak tersedia kerana pangkalan datanya tidak dapat dibuka.", "safe_storage_unavailable": "Cache berterusan tidak tersedia kerana storan kunci selamat tidak tersedia." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "Perubahan belum disimpan", @@ -2972,7 +2987,10 @@ "readyOnDemand": "Sedia apabila diperlukan", "quarantined": "Dikuarantin", "error": "kesilapan" - } + }, + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Sambungan khusus ejen hanya tersedia untuk ejen DeepChat.", "scopeGlobalPlugins": "Halaman ini bersifat global aplikasi: pemasangan, pengaktifan dan status proses plugin rasmi dikongsi oleh semua ejen DeepChat.", "scopeCurrentAgent": "Halaman ini ialah polisi ejen: perubahan hanya menjejaskan ketersediaan Skills/MCP untuk “{agent}”, bukan pemasangan global.", - "currentAgentFallback": "ejen semasa" + "currentAgentFallback": "ejen semasa", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "Tiada muat turun untuk versi ini. Pasang daripada fail." }, "debug": { "description": "Alat khusus pembangunan untuk pratonton keadaan olok-olok dan aliran berpandu.", diff --git a/src/renderer/src/i18n/pl-PL/settings.json b/src/renderer/src/i18n/pl-PL/settings.json index 406f0193a3..5fd157478c 100644 --- a/src/renderer/src/i18n/pl-PL/settings.json +++ b/src/renderer/src/i18n/pl-PL/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "Trwała pamięć podręczna jest niedostępna, ponieważ nie można otworzyć jej bazy danych.", "safe_storage_unavailable": "Trwała pamięć podręczna jest niedostępna, ponieważ bezpieczny magazyn kluczy nie jest dostępny." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "Niezapisane zmiany", @@ -2972,7 +2987,10 @@ "readyOnDemand": "Gotowe na żądanie", "quarantined": "W kwarantannie", "error": "Błąd" - } + }, + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Rozszerzenia na poziomie agenta są dostępne tylko dla agentów DeepChat.", "scopeGlobalPlugins": "Ta strona jest globalna dla aplikacji: instalacja, włączanie i stan procesów oficjalnych wtyczek są wspólne dla wszystkich agentów DeepChat.", "scopeCurrentAgent": "Ta strona to polityka agenta: zmiany dotyczą tylko dostępności Skills/MCP dla „{agent}”, a nie globalnych instalacji.", - "currentAgentFallback": "bieżący agent" + "currentAgentFallback": "bieżący agent", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "Dla tej wersji nie ma pobierania. Zainstaluj z pliku." }, "debug": { "description": "Narzędzia dostępne wyłącznie w środowisku programistycznym do podglądu stanów symulowanych i procesów z przewodnikiem.", diff --git a/src/renderer/src/i18n/pt-BR/settings.json b/src/renderer/src/i18n/pt-BR/settings.json index ea5ca81c92..12d39f71bc 100644 --- a/src/renderer/src/i18n/pt-BR/settings.json +++ b/src/renderer/src/i18n/pt-BR/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "O cache persistente está indisponível porque não foi possível abrir o banco de dados.", "safe_storage_unavailable": "O cache persistente está indisponível porque o armazenamento seguro de chaves não está disponível." - } + }, + "runtimeDownloadTitle": "Download do runtime", + "runtimeDownloadDescription": "O engine de OCR pode ser baixado sob demanda. Ele é baixado automaticamente na primeira vez que for necessário.", + "runtimeInstall": "Baixar engine de OCR", + "runtimeInstalling": "Baixando {percent}%", + "runtimeCancelInstall": "Cancelar", + "runtimeRetryInstall": "Tentar novamente", + "runtimeInstallFailed": "Falha ao baixar o engine de OCR", + "runtimeUnavailablePlatform": "Indisponível nesta plataforma", + "runtimeIncompatibleApp": "Requer uma versão mais recente do aplicativo", + "runtimeReady": "O engine de OCR está pronto neste dispositivo", + "runtimeDownloadHint": "Baixe o engine de OCR para usá-lo neste dispositivo", + "runtimeDownloadedVersion": "Versão baixada", + "uninstallRuntimeTitle": "Remover engine de OCR", + "uninstallRuntimeDescription": "O engine de OCR baixado é removido deste dispositivo. Ele será baixado novamente quando necessário.", + "runtimeReadyBundled": "Engine de OCR pronto (incluído no app)" }, "leaveGuard": { "dirtyTitle": "Alterações não salvas", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "Desinstalar {name}?", + "uninstallDescription": "O plugin é desativado e seus arquivos instalados são removidos. Plugins baixados desaparecem até serem reinstalados.", + "uninstallFailed": "Falha ao desinstalar" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "As extensões por agente estão disponíveis apenas para agentes DeepChat.", "scopeGlobalPlugins": "Esta página é global do app: instalação, ativação e estado de processo dos plugins oficiais são compartilhados por todos os agentes DeepChat.", "scopeCurrentAgent": "Esta página é política do agente: as alterações afetam apenas a disponibilidade de Skills/MCP de “{agent}”, não as instalações globais.", - "currentAgentFallback": "agente atual" + "currentAgentFallback": "agente atual", + "downloadable": "Plugins para download", + "installPlugin": "Instalar", + "installing": "Baixando {percent}%", + "retryInstall": "Tentar novamente", + "cancelInstall": "Cancelar", + "installFailed": "Falha na instalação", + "preRelease": "Pré-lançamento", + "manualInstall": "Instalar do arquivo", + "uninstall": "Desinstalar", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requer uma versão mais recente do aplicativo", + "unavailablePlatform": "Indisponível nesta plataforma", + "manageCardTitle": "Gerenciar", + "installNow": "Instalar agora", + "notDistributed": "Nenhum download é oferecido para esta versão. Instale a partir de um arquivo." }, "debug": { "description": "Ferramentas exclusivas de desenvolvimento para visualizar estados simulados e fluxos guiados.", diff --git a/src/renderer/src/i18n/ru-RU/settings.json b/src/renderer/src/i18n/ru-RU/settings.json index f1b14c9705..84e7a82664 100644 --- a/src/renderer/src/i18n/ru-RU/settings.json +++ b/src/renderer/src/i18n/ru-RU/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "Постоянный кэш недоступен, поскольку не удалось открыть его базу данных.", "safe_storage_unavailable": "Постоянный кэш недоступен, поскольку защищённое хранилище ключей недоступно." - } + }, + "runtimeDownloadTitle": "Загрузка среды", + "runtimeDownloadDescription": "Движок OCR можно загрузить по требованию. Он загружается автоматически при первой необходимости.", + "runtimeInstall": "Скачать движок OCR", + "runtimeInstalling": "Загрузка {percent}%", + "runtimeCancelInstall": "Отмена", + "runtimeRetryInstall": "Повторить", + "runtimeInstallFailed": "Не удалось скачать движок OCR", + "runtimeUnavailablePlatform": "Недоступно на этой платформе", + "runtimeIncompatibleApp": "Требуется более новая версия приложения", + "runtimeReady": "Движок OCR готов на этом устройстве", + "runtimeDownloadHint": "Скачайте движок OCR, чтобы использовать его на этом устройстве", + "runtimeDownloadedVersion": "Скачанная версия", + "uninstallRuntimeTitle": "Удалить движок OCR", + "uninstallRuntimeDescription": "Скачанный движок OCR удаляется с этого устройства. Он будет скачан снова при следующей необходимости.", + "runtimeReadyBundled": "Движок OCR готов (встроен в приложение)" }, "leaveGuard": { "dirtyTitle": "Несохранённые изменения", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "Удалить {name}?", + "uninstallDescription": "Плагин отключается, а его установленные файлы удаляются. Скачанные плагины исчезнут до повторной установки.", + "uninstallFailed": "Не удалось удалить" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Расширения на уровне агента доступны только агентам DeepChat.", "scopeGlobalPlugins": "Эта страница глобальна для приложения: установка, включение и состояние процессов официальных плагинов общие для всех агентов DeepChat.", "scopeCurrentAgent": "Эта страница — политика агента: изменения влияют только на доступность Skills/MCP у «{agent}», а не на глобальные установки.", - "currentAgentFallback": "текущий агент" + "currentAgentFallback": "текущий агент", + "downloadable": "Доступные для скачивания плагины", + "installPlugin": "Установить", + "installing": "Загрузка {percent}%", + "retryInstall": "Повторить", + "cancelInstall": "Отмена", + "installFailed": "Не удалось установить", + "preRelease": "Предварительная версия", + "manualInstall": "Установить из файла", + "uninstall": "Удалить", + "runtimeCardTitle": "Среда выполнения", + "incompatibleApp": "Требуется более новая версия приложения", + "unavailablePlatform": "Недоступно на этой платформе", + "manageCardTitle": "Управление", + "installNow": "Установить сейчас", + "notDistributed": "Для этой версии загрузка не предоставляется. Установите из файла." }, "debug": { "description": "Инструменты только для разработки для предварительного просмотра имитируемых состояний и управляемых сценариев.", diff --git a/src/renderer/src/i18n/tr-TR/settings.json b/src/renderer/src/i18n/tr-TR/settings.json index 0291530a40..7058d9b40d 100644 --- a/src/renderer/src/i18n/tr-TR/settings.json +++ b/src/renderer/src/i18n/tr-TR/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "Veritabanı açılamadığı için kalıcı önbellek kullanılamıyor.", "safe_storage_unavailable": "Güvenli anahtar depolama kullanılamadığı için kalıcı önbellek kullanılamıyor." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "Kaydedilmemiş değişiklikler", @@ -2972,7 +2987,10 @@ "readyOnDemand": "İstek üzerine hazır", "quarantined": "Karantinada", "error": "Hata" - } + }, + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Ajan düzeyindeki uzantılar yalnızca DeepChat ajanları tarafından kullanılabilir.", "scopeGlobalPlugins": "Bu sayfa uygulama genelindedir: resmi eklentilerin kurulumu, etkinleştirilmesi ve süreç durumu tüm DeepChat ajanları arasında paylaşılır.", "scopeCurrentAgent": "Bu sayfa ajan politikasıdır: değişiklikler yalnızca “{agent}” için Skills/MCP kullanılabilirliğini etkiler, global kurulumları etkilemez.", - "currentAgentFallback": "geçerli ajan" + "currentAgentFallback": "geçerli ajan", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "Bu sürüm için indirme sunulmuyor. Dosyadan yükleyin." }, "debug": { "description": "Sahte durumları ve rehberli akışları önizlemek için yalnızca geliştirmeye yönelik araçlar.", diff --git a/src/renderer/src/i18n/ug-CN/settings.json b/src/renderer/src/i18n/ug-CN/settings.json index f892e3aa37..15775b51c9 100644 --- a/src/renderer/src/i18n/ug-CN/settings.json +++ b/src/renderer/src/i18n/ug-CN/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "غەملەك ساندىنى ئاچالمىدى، مەڭگۈلۈك غەملەكنى ئىشلەتكىلى بولمايدۇ.", "safe_storage_unavailable": "سىستېمىنىڭ بىخەتەر ئاچقۇچ ساقلاش مۇلازىمىتىنى ئىشلەتكىلى بولمايدۇ، مەڭگۈلۈك غەملەك توختىتىلدى." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "ئۆزگەرتىشلەر تېخى ساقلانمىدى", @@ -3026,7 +3041,10 @@ "readyOnDemand": "تەلەب بويىچە تەييار", "quarantined": "ئايرىلدى", "error": "خاتالىق" - } + }, + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "pluginsHub": { "subtitle": "DeepChat نىڭ Skills، MCP، رەسمىي قوللىنىش ۋە يىراقتىكى قاناللارنى بىرلەشتۈرۈپ باشقۇرۇش.", @@ -3043,7 +3061,22 @@ "agentScopeUnsupported": "Agent دەرىجىلىك كېڭەيتىش تەڭشىكى پەقەت DeepChat Agent نى قوللايدۇ.", "scopeGlobalPlugins": "قوللىنىشنىڭ ئورنىتىلىشى، قوزغىتىلىشى ۋە جەريان ئەھۋالى بارلىق DeepChat Agent ئارقىلىق مەشغۇلات قىلىنىدۇ.", "scopeCurrentAgent": "ھازىرقى بېتى Agent سىياسىتى: پەقەت «{agent}» ئۈچۈن Skills / MCP غا تەسىر قىلىدۇ، گېنېرال قوللىنىشنىڭ ئورنىتىلىشى، قوزغىتىلىشى ياكى جەريان ئەھۋالىغا تەسىر قىلمايدۇ.", - "currentAgentFallback": "ھازىرقى Agent" + "currentAgentFallback": "ھازىرقى Agent", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "No download is offered for this version. Install from a file instead." }, "controlCenter": { "groups": { diff --git a/src/renderer/src/i18n/vi-VN/settings.json b/src/renderer/src/i18n/vi-VN/settings.json index 6d25f89d4e..52a871a0d3 100644 --- a/src/renderer/src/i18n/vi-VN/settings.json +++ b/src/renderer/src/i18n/vi-VN/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "Bộ nhớ đệm lâu dài không khả dụng vì không thể mở cơ sở dữ liệu.", "safe_storage_unavailable": "Bộ nhớ đệm lâu dài không khả dụng vì không có kho khóa an toàn." - } + }, + "runtimeDownloadTitle": "Runtime download", + "runtimeDownloadDescription": "The OCR engine can be downloaded on demand. It downloads automatically the first time it is needed.", + "runtimeInstall": "Download OCR engine", + "runtimeInstalling": "Downloading {percent}%", + "runtimeCancelInstall": "Cancel", + "runtimeRetryInstall": "Retry", + "runtimeInstallFailed": "OCR engine download failed", + "runtimeUnavailablePlatform": "Not available on this platform", + "runtimeIncompatibleApp": "Requires a newer app version", + "runtimeReady": "OCR engine is ready on this device", + "runtimeDownloadHint": "Download the OCR engine to use it on this device", + "runtimeDownloadedVersion": "Downloaded version", + "uninstallRuntimeTitle": "Remove OCR engine", + "uninstallRuntimeDescription": "The downloaded OCR engine is removed from this device. It downloads again the next time it is needed.", + "runtimeReadyBundled": "OCR engine is ready (bundled with the app)" }, "leaveGuard": { "dirtyTitle": "Thay đổi chưa lưu", @@ -2972,7 +2987,10 @@ "readyOnDemand": "Sẵn sàng theo yêu cầu", "quarantined": "Đã cách ly", "error": "Lỗi" - } + }, + "uninstallTitle": "Uninstall {name}?", + "uninstallDescription": "The plugin is disabled and its installed files are removed. Downloaded plugins disappear until installed again.", + "uninstallFailed": "Uninstall failed" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Tiện ích mở rộng theo từng tác nhân chỉ khả dụng cho tác nhân DeepChat.", "scopeGlobalPlugins": "Trang này mang tính toàn ứng dụng: cài đặt, bật/tắt và trạng thái tiến trình của plugin chính thức được chia sẻ cho mọi tác nhân DeepChat.", "scopeCurrentAgent": "Trang này là chính sách tác nhân: thay đổi chỉ ảnh hưởng đến khả dụng Skills/MCP của “{agent}”, không ảnh hưởng cài đặt toàn cục.", - "currentAgentFallback": "tác nhân hiện tại" + "currentAgentFallback": "tác nhân hiện tại", + "downloadable": "Downloadable plugins", + "installPlugin": "Install", + "installing": "Downloading {percent}%", + "retryInstall": "Retry", + "cancelInstall": "Cancel", + "installFailed": "Installation failed", + "preRelease": "Pre-release", + "manualInstall": "Install from file", + "uninstall": "Uninstall", + "runtimeCardTitle": "Runtime", + "incompatibleApp": "Requires a newer app version", + "unavailablePlatform": "Not available on this platform", + "manageCardTitle": "Manage", + "installNow": "Install now", + "notDistributed": "Phiên bản này không có bản tải xuống. Hãy cài đặt từ tệp." }, "debug": { "description": "Công cụ chỉ dành cho phát triển để xem trước các trạng thái giả lập và quy trình có hướng dẫn.", diff --git a/src/renderer/src/i18n/zh-CN/settings.json b/src/renderer/src/i18n/zh-CN/settings.json index 84b9dd688a..0e5b556c7b 100644 --- a/src/renderer/src/i18n/zh-CN/settings.json +++ b/src/renderer/src/i18n/zh-CN/settings.json @@ -174,7 +174,22 @@ "cacheFallbackReasons": { "database_error": "缓存数据库无法打开,持久化缓存不可用。", "safe_storage_unavailable": "系统安全密钥存储不可用,持久化缓存已停用。" - } + }, + "runtimeDownloadTitle": "运行时下载", + "runtimeDownloadDescription": "OCR 引擎可按需下载,首次需要时将自动下载。", + "runtimeInstall": "下载 OCR 引擎", + "runtimeInstalling": "下载中 {percent}%", + "runtimeCancelInstall": "取消", + "runtimeRetryInstall": "重试", + "runtimeInstallFailed": "OCR 引擎下载失败", + "runtimeUnavailablePlatform": "当前平台不可用", + "runtimeIncompatibleApp": "需要更新版本的应用", + "runtimeReady": "OCR 引擎已就绪", + "runtimeDownloadHint": "下载 OCR 引擎后即可在本机使用", + "runtimeDownloadedVersion": "已下载版本", + "uninstallRuntimeTitle": "删除 OCR 引擎", + "uninstallRuntimeDescription": "将从本机删除已下载的 OCR 引擎,下次需要时会重新下载。", + "runtimeReadyBundled": "OCR 引擎已就绪(应用内置)" }, "leaveGuard": { "dirtyTitle": "修改尚未保存", @@ -3026,7 +3041,10 @@ "readyOnDemand": "按需就绪", "quarantined": "已隔离", "error": "错误" - } + }, + "uninstallTitle": "卸载 {name}?", + "uninstallDescription": "插件将被停用并删除已安装的文件。下载安装的插件需重新安装后才能使用。", + "uninstallFailed": "卸载失败" }, "pluginsHub": { "subtitle": "统一管理 DeepChat 的 Skills、MCP、官方插件和远程渠道。", @@ -3043,7 +3061,22 @@ "agentScopeUnsupported": "Agent 级扩展配置仅支持 DeepChat Agent。", "scopeGlobalPlugins": "插件的安装、启用与进程状态由所有 DeepChat Agent 共享。", "scopeCurrentAgent": "当前页为 Agent 策略:仅影响「{agent}」可用的 Skills / MCP,不会影响全局插件的安装、启用状态或进程状态。", - "currentAgentFallback": "当前 Agent" + "currentAgentFallback": "当前 Agent", + "downloadable": "可下载插件", + "installPlugin": "安装", + "installing": "下载中 {percent}%", + "retryInstall": "重试", + "cancelInstall": "取消", + "installFailed": "安装失败", + "preRelease": "预发布", + "manualInstall": "从文件安装", + "uninstall": "卸载", + "runtimeCardTitle": "运行时", + "incompatibleApp": "需要更新版本的应用", + "unavailablePlatform": "当前平台不可用", + "manageCardTitle": "管理", + "installNow": "立即安装", + "notDistributed": "当前版本未提供下载,请从文件安装。" }, "controlCenter": { "groups": { diff --git a/src/renderer/src/i18n/zh-HK/settings.json b/src/renderer/src/i18n/zh-HK/settings.json index b3ef4a230c..d70a0df1a6 100644 --- a/src/renderer/src/i18n/zh-HK/settings.json +++ b/src/renderer/src/i18n/zh-HK/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "快取資料庫無法開啟,持久化快取不可用。", "safe_storage_unavailable": "系統安全金鑰儲存不可用,持久化快取已停用。" - } + }, + "runtimeDownloadTitle": "執行時下載", + "runtimeDownloadDescription": "OCR 引擎可按需下載,首次需要時將自動下載。", + "runtimeInstall": "下載 OCR 引擎", + "runtimeInstalling": "下載中 {percent}%", + "runtimeCancelInstall": "取消", + "runtimeRetryInstall": "重試", + "runtimeInstallFailed": "OCR 引擎下載失敗", + "runtimeUnavailablePlatform": "目前平台不可用", + "runtimeIncompatibleApp": "需要更新版本的應用", + "runtimeReady": "OCR 引擎已就緒", + "runtimeDownloadHint": "下載 OCR 引擎後即可在本機使用", + "runtimeDownloadedVersion": "已下載版本", + "uninstallRuntimeTitle": "刪除 OCR 引擎", + "uninstallRuntimeDescription": "將從本機刪除已下載的 OCR 引擎,下次需要時會重新下載。", + "runtimeReadyBundled": "OCR 引擎已就緒(應用內建)" }, "leaveGuard": { "dirtyTitle": "變更尚未儲存", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "解除安裝 {name}?", + "uninstallDescription": "外掛將被停用並刪除已安裝的檔案。下載安裝的外掛需重新安裝後才能使用。", + "uninstallFailed": "解除安裝失敗" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Agent 層級的擴充功能只適用於 DeepChat Agent。", "scopeGlobalPlugins": "外掛的安裝、啟用與程序狀態由所有 DeepChat Agent 共用。", "scopeCurrentAgent": "目前頁面為 Agent 策略:僅影響「{agent}」可用的 Skills / MCP,不會全域停用資源。", - "currentAgentFallback": "目前 Agent" + "currentAgentFallback": "目前 Agent", + "downloadable": "可下載外掛", + "installPlugin": "安裝", + "installing": "下載中 {percent}%", + "retryInstall": "重試", + "cancelInstall": "取消", + "installFailed": "安裝失敗", + "preRelease": "預發布", + "manualInstall": "從檔案安裝", + "uninstall": "解除安裝", + "runtimeCardTitle": "執行時", + "incompatibleApp": "需要更新版本的應用", + "unavailablePlatform": "目前平台不可用", + "manageCardTitle": "管理", + "installNow": "立即安裝", + "notDistributed": "目前版本未提供下載,請從檔案安裝。" }, "debug": { "description": "僅在開發模式下顯示的情境與引導偵錯工具。", diff --git a/src/renderer/src/i18n/zh-TW/settings.json b/src/renderer/src/i18n/zh-TW/settings.json index b849a10d8f..180f2851ee 100644 --- a/src/renderer/src/i18n/zh-TW/settings.json +++ b/src/renderer/src/i18n/zh-TW/settings.json @@ -2697,7 +2697,22 @@ "cacheFallbackReasons": { "database_error": "快取資料庫無法開啟,持久化快取不可用。", "safe_storage_unavailable": "系統安全金鑰儲存不可用,持久化快取已停用。" - } + }, + "runtimeDownloadTitle": "執行時下載", + "runtimeDownloadDescription": "OCR 引擎可按需下載,首次需要時將自動下載。", + "runtimeInstall": "下載 OCR 引擎", + "runtimeInstalling": "下載中 {percent}%", + "runtimeCancelInstall": "取消", + "runtimeRetryInstall": "重試", + "runtimeInstallFailed": "OCR 引擎下載失敗", + "runtimeUnavailablePlatform": "目前平台不可用", + "runtimeIncompatibleApp": "需要更新版本的應用", + "runtimeReady": "OCR 引擎已就緒", + "runtimeDownloadHint": "下載 OCR 引擎後即可在本機使用", + "runtimeDownloadedVersion": "已下載版本", + "uninstallRuntimeTitle": "刪除 OCR 引擎", + "uninstallRuntimeDescription": "將從本機刪除已下載的 OCR 引擎,下次需要時會重新下載。", + "runtimeReadyBundled": "OCR 引擎已就緒(應用內建)" }, "leaveGuard": { "dirtyTitle": "變更尚未儲存", @@ -3026,7 +3041,10 @@ "emptyTitle": "No built-in plugins available", "emptyDescription": "This platform has no built-in official plugins in this DeepChat version.", "installFromFile": "Choose .dcplugin", - "openRelease": "GitHub Release" + "openRelease": "GitHub Release", + "uninstallTitle": "解除安裝 {name}?", + "uninstallDescription": "外掛將被停用並刪除已安裝的檔案。下載安裝的外掛需重新安裝後才能使用。", + "uninstallFailed": "解除安裝失敗" }, "controlCenter": { "groups": { @@ -3283,7 +3301,22 @@ "agentScopeUnsupported": "Agent 層級的擴充功能僅適用於 DeepChat Agent。", "scopeGlobalPlugins": "外掛的安裝、啟用與程序狀態由所有 DeepChat Agent 共用。", "scopeCurrentAgent": "目前頁面為 Agent 策略:僅影響「{agent}」可用的 Skills / MCP,不會全域停用資源。", - "currentAgentFallback": "目前 Agent" + "currentAgentFallback": "目前 Agent", + "downloadable": "可下載外掛", + "installPlugin": "安裝", + "installing": "下載中 {percent}%", + "retryInstall": "重試", + "cancelInstall": "取消", + "installFailed": "安裝失敗", + "preRelease": "預發布", + "manualInstall": "從檔案安裝", + "uninstall": "解除安裝", + "runtimeCardTitle": "執行時", + "incompatibleApp": "需要更新版本的應用", + "unavailablePlatform": "目前平台不可用", + "manageCardTitle": "管理", + "installNow": "立即安裝", + "notDistributed": "目前版本未提供下載,請從檔案安裝。" }, "debug": { "description": "僅在開發模式下顯示的情境與引導偵錯工具。", diff --git a/src/renderer/src/pages/plugins/OfficialPluginDetailPage.vue b/src/renderer/src/pages/plugins/OfficialPluginDetailPage.vue index 58f73e449c..b261895877 100644 --- a/src/renderer/src/pages/plugins/OfficialPluginDetailPage.vue +++ b/src/renderer/src/pages/plugins/OfficialPluginDetailPage.vue @@ -162,7 +162,12 @@
- + {{ t('settings.plugins.enable') }} @@ -208,7 +213,30 @@ - + + + + + + +
+ diff --git a/src/renderer/src/pages/plugins/PluginsCatalogPage.vue b/src/renderer/src/pages/plugins/PluginsCatalogPage.vue index c9841ed8eb..8c9b9c6bcb 100644 --- a/src/renderer/src/pages/plugins/PluginsCatalogPage.vue +++ b/src/renderer/src/pages/plugins/PluginsCatalogPage.vue @@ -96,6 +96,80 @@ {{ t('settings.pluginsHub.emptySearch') }} + +
+
+

{{ t('settings.pluginsHub.downloadable') }}

+
+ +
+
+
+ +
+ +
+
+

+ {{ entry.displayName || entry.pluginId }} +

+ + v{{ entry.version }} + + + {{ t('settings.pluginsHub.preRelease') }} + +
+

+ {{ + installError(entry.pluginId) + ? t('settings.pluginsHub.installFailed') + : entry.description || formatSize(entry.sizeBytes) + }} +

+
+ +
+ + {{ + t('settings.pluginsHub.installing', { percent: installPercent(entry.pluginId) }) + }} + + + {{ t('settings.pluginsHub.cancelInstall') }} + + +
+
+
+
import UserPluginInstallDialog from './UserPluginInstallDialog.vue' -import { computed, onMounted, ref } from 'vue' +import { computed, onMounted, onUnmounted, ref } from 'vue' import { storeToRefs } from 'pinia' import { useRouter } from 'vue-router' import { useI18n } from 'vue-i18n' @@ -116,8 +190,10 @@ import { DcButton } from '@dc-ui/components/button' import { ScrollArea } from '@shadcn/components/ui/scroll-area' import { createOcrClient } from '@api/OcrClient' import { createPluginClient } from '@api/PluginClient' +import { createDeviceClient } from '@api/DeviceClient' import { createRemoteControlClient } from '@api/RemoteControlClient' import { CUA_PLUGIN_ID, type PluginActionResult, type PluginListItem } from '@shared/types/plugin' +import type { PluginCatalogEntry, PluginCatalogInstallState } from '@shared/types/pluginCatalog' import type { RemoteChannel } from '@shared/types/remote' import { usePluginCatalogStore } from '@/stores/pluginCatalog' @@ -186,6 +262,7 @@ const { t } = useI18n() const router = useRouter() const ocrClient = createOcrClient() const pluginClient = createPluginClient() +const deviceClient = createDeviceClient() const remoteControlClient = createRemoteControlClient() const pluginCatalogStore = usePluginCatalogStore() const { plugins, remoteChannels, remoteStatuses, ocrStatus, ocrStatusHasError } = @@ -306,7 +383,8 @@ async function loadCatalog(): Promise { const [pluginItems] = await Promise.all([ pluginClient.listPlugins(), loadRemoteCatalog(), - loadOcrCatalog() + loadOcrCatalog(), + loadDistributableCatalog() ]) pluginCatalogStore.replacePlugins(pluginItems, pluginVersion) } catch (error) { @@ -316,6 +394,122 @@ async function loadCatalog(): Promise { } } +const catalogEntries = ref([]) +const installStates = ref>({}) +let unsubscribeInstallProgress: (() => void) | null = null + +const downloadableItems = computed(() => + catalogEntries.value.filter((entry) => !entry.installed && entry.availability === 'available') +) + +const installPhase = (pluginId: string): PluginCatalogInstallState['phase'] | null => + installStates.value[pluginId]?.phase ?? null + +const installError = (pluginId: string): string | null => + installStates.value[pluginId]?.error ?? null + +const isInstalling = (pluginId: string): boolean => { + const phase = installPhase(pluginId) + return ( + phase === 'probing' || + phase === 'downloading' || + phase === 'verifying' || + phase === 'installing' + ) +} + +const installPercent = (pluginId: string): number => { + const state = installStates.value[pluginId] + if (!state || !state.totalBytes || state.totalBytes <= 0) return 0 + return Math.min(100, Math.round((state.receivedBytes / state.totalBytes) * 100)) +} + +function formatSize(sizeBytes: number | null): string { + if (sizeBytes == null || sizeBytes <= 0) return '' + const megabytes = sizeBytes / (1024 * 1024) + if (megabytes >= 1) return `${megabytes.toFixed(0)} MB` + return `${Math.max(1, Math.round(sizeBytes / 1024))} KB` +} + +async function loadDistributableCatalog(): Promise { + try { + const entries = await pluginClient.listCatalogEntries() + catalogEntries.value = entries + // Hydrate install states for runs that started before this page mounted, + // keeping whichever copy (local or response) is newer. + for (const entry of entries) { + if (!entry.installState) continue + const existing = installStates.value[entry.pluginId] + if (!existing || entry.installState.updatedAt >= existing.updatedAt) { + installStates.value = { + ...installStates.value, + [entry.pluginId]: entry.installState + } + } + } + } catch (error) { + console.warn('[PluginsCatalogPage] Failed to load catalog entries:', error) + } +} + +async function refreshAfterInstall(): Promise { + await Promise.all([loadCatalog(), loadDistributableCatalog()]) +} + +async function handleInstallEntry(entry: PluginCatalogEntry): Promise { + errorMessage.value = '' + try { + const result = await pluginClient.installCatalogPlugin(entry.pluginId) + if (!result.ok) { + errorMessage.value = result.error || t('settings.pluginsHub.installFailed') + return + } + // The install action is the opt-in moment: enable immediately after a + // successful download instead of requiring a second confirmation. + const enabled = await pluginClient.enablePlugin(entry.pluginId) + if (!enabled.ok) { + errorMessage.value = enabled.error || t('settings.pluginsHub.installFailed') + } + } catch (error) { + errorMessage.value = + error instanceof Error ? error.message : t('settings.pluginsHub.installFailed') + } finally { + await refreshAfterInstall() + } +} + +function handleCancelInstall(pluginId: string): void { + void pluginClient.cancelCatalogInstall(pluginId) +} + +async function handleManualInstallEntry(): Promise { + errorMessage.value = '' + try { + const selection = await deviceClient.selectFiles({ + filters: [{ name: 'DeepChat Plugin', extensions: ['dcplugin'] }] + }) + const filePath = selection.filePaths[0] + if (!filePath) return + const result = await pluginClient.installCatalogPluginFromPath(filePath) + if (!result.ok) { + errorMessage.value = result.error || t('settings.pluginsHub.installFailed') + return + } + if (result.pluginId) { + // The manual install is an explicit opt-in: enable immediately. + const enabled = await pluginClient.enablePlugin(result.pluginId) + if (!enabled.ok) { + errorMessage.value = enabled.error || t('settings.pluginsHub.installFailed') + } + } + } catch (error) { + errorMessage.value = + error instanceof Error ? error.message : t('settings.pluginsHub.installFailed') + } finally { + await refreshAfterInstall() + } +} + async function loadRemoteCatalog(): Promise { const version = pluginCatalogStore.captureRemoteRefresh() try { @@ -396,5 +590,16 @@ function onInstalled(plugin: PluginListItem): void { onMounted(() => { void loadCatalog() + unsubscribeInstallProgress = pluginClient.onInstallProgress((payload) => { + installStates.value = { ...installStates.value, [payload.pluginId]: payload } + if (payload.phase === 'installed') { + void refreshAfterInstall() + } + }) +}) + +onUnmounted(() => { + unsubscribeInstallProgress?.() + unsubscribeInstallProgress = null }) diff --git a/src/shared/contracts/events.ts b/src/shared/contracts/events.ts index af1f220f59..1e9a017d1d 100644 --- a/src/shared/contracts/events.ts +++ b/src/shared/contracts/events.ts @@ -38,6 +38,8 @@ import { } from './events/context-menu.events' import { dialogRequestedEvent } from './events/dialog.events' import { knowledgeFileProgressEvent, knowledgeFileUpdatedEvent } from './events/knowledge.events' +import { pluginInstallProgressEvent } from './events/plugins.events' +import { ocrRuntimeInstallProgressEvent } from './events/ocr.events' import { memoryUpdatedEvent } from './events/memory.events' import { configCustomPromptsChangedEvent, @@ -156,6 +158,8 @@ export * from './events/config.events' export * from './events/context-menu.events' export * from './events/dialog.events' export * from './events/knowledge.events' +export * from './events/plugins.events' +export * from './events/ocr.events' export * from './events/memory.events' export * from './events/mcp.events' export * from './events/misc.providers.events' @@ -247,6 +251,8 @@ export const DEEPCHAT_EVENT_CATALOG = { [providersOllamaPullProgressEvent.name]: providersOllamaPullProgressEvent, [knowledgeFileUpdatedEvent.name]: knowledgeFileUpdatedEvent, [knowledgeFileProgressEvent.name]: knowledgeFileProgressEvent, + [pluginInstallProgressEvent.name]: pluginInstallProgressEvent, + [ocrRuntimeInstallProgressEvent.name]: ocrRuntimeInstallProgressEvent, [memoryUpdatedEvent.name]: memoryUpdatedEvent, [modelsChangedEvent.name]: modelsChangedEvent, [modelsStatusChangedEvent.name]: modelsStatusChangedEvent, diff --git a/src/shared/contracts/events/ocr.events.ts b/src/shared/contracts/events/ocr.events.ts new file mode 100644 index 0000000000..4a7710ff3d --- /dev/null +++ b/src/shared/contracts/events/ocr.events.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { TimestampMsSchema, defineEventContract } from '../common' +import { PluginCatalogInstallPhaseSchema } from '../routes/plugins.routes' + +export const ocrRuntimeInstallProgressEvent = defineEventContract({ + name: 'ocr.runtimeInstall.progress', + payload: z.object({ + assetId: z.string().min(1).max(128), + version: z.string().min(1).max(128), + phase: PluginCatalogInstallPhaseSchema, + receivedBytes: z.number().nonnegative(), + totalBytes: z.number().nonnegative().nullable(), + error: z.string().max(2048).nullable(), + updatedAt: TimestampMsSchema + }) +}) diff --git a/src/shared/contracts/events/plugins.events.ts b/src/shared/contracts/events/plugins.events.ts new file mode 100644 index 0000000000..82b35e49c9 --- /dev/null +++ b/src/shared/contracts/events/plugins.events.ts @@ -0,0 +1,16 @@ +import { z } from 'zod' +import { TimestampMsSchema, defineEventContract } from '../common' +import { PluginCatalogInstallPhaseSchema } from '../routes/plugins.routes' + +export const pluginInstallProgressEvent = defineEventContract({ + name: 'plugins.install.progress', + payload: z.object({ + pluginId: z.string().min(1).max(128), + version: z.string().min(1).max(64), + phase: PluginCatalogInstallPhaseSchema, + receivedBytes: z.number().nonnegative(), + totalBytes: z.number().nonnegative().nullable(), + error: z.string().max(2048).nullable(), + updatedAt: TimestampMsSchema + }) +}) diff --git a/src/shared/contracts/routes.ts b/src/shared/contracts/routes.ts index 75533556c4..0ea55376bb 100644 --- a/src/shared/contracts/routes.ts +++ b/src/shared/contracts/routes.ts @@ -310,7 +310,11 @@ import { ocrClearCacheRoute, ocrExtractArtifactRoute, ocrExtractUploadRoute, - ocrGetRuntimeStatusRoute + ocrGetRuntimeStatusRoute, + ocrInstallRuntimeRoute, + ocrInstallRuntimeFromPathRoute, + ocrUninstallRuntimeRoute, + ocrCancelRuntimeInstallRoute } from './routes/ocr.routes' import { onboardingCompleteRoute, @@ -431,7 +435,12 @@ import { pluginsEnableRoute, pluginsGetRoute, pluginsInvokeActionRoute, - pluginsListRoute + pluginsListRoute, + pluginsCatalogListRoute, + pluginsCatalogInstallRoute, + pluginsCatalogCancelRoute, + pluginsCatalogInstallFromPathRoute, + pluginsUninstallOfficialRoute } from './routes/plugins.routes' import { settingsActivityListRoute, @@ -804,7 +813,12 @@ const DEEPCHAT_ROUTE_CATALOG_PART_1 = { [pluginsGetRoute.name]: pluginsGetRoute, [pluginsEnableRoute.name]: pluginsEnableRoute, [pluginsDisableRoute.name]: pluginsDisableRoute, - [pluginsInvokeActionRoute.name]: pluginsInvokeActionRoute + [pluginsInvokeActionRoute.name]: pluginsInvokeActionRoute, + [pluginsCatalogListRoute.name]: pluginsCatalogListRoute, + [pluginsCatalogInstallRoute.name]: pluginsCatalogInstallRoute, + [pluginsCatalogCancelRoute.name]: pluginsCatalogCancelRoute, + [pluginsCatalogInstallFromPathRoute.name]: pluginsCatalogInstallFromPathRoute, + [pluginsUninstallOfficialRoute.name]: pluginsUninstallOfficialRoute } satisfies Record const DEEPCHAT_ROUTE_CATALOG_PART_2 = { @@ -1132,6 +1146,10 @@ const DEEPCHAT_ROUTE_CATALOG_PART_5 = { [memoryDeleteDirectiveRoute.name]: memoryDeleteDirectiveRoute, [ocrGetRuntimeStatusRoute.name]: ocrGetRuntimeStatusRoute, [ocrClearCacheRoute.name]: ocrClearCacheRoute, + [ocrInstallRuntimeRoute.name]: ocrInstallRuntimeRoute, + [ocrInstallRuntimeFromPathRoute.name]: ocrInstallRuntimeFromPathRoute, + [ocrUninstallRuntimeRoute.name]: ocrUninstallRuntimeRoute, + [ocrCancelRuntimeInstallRoute.name]: ocrCancelRuntimeInstallRoute, [ocrExtractUploadRoute.name]: ocrExtractUploadRoute, [ocrExtractArtifactRoute.name]: ocrExtractArtifactRoute, [skillsListMetadataRoute.name]: skillsListMetadataRoute, diff --git a/src/shared/contracts/routes/ocr.routes.ts b/src/shared/contracts/routes/ocr.routes.ts index a54f64da89..6399a7824c 100644 --- a/src/shared/contracts/routes/ocr.routes.ts +++ b/src/shared/contracts/routes/ocr.routes.ts @@ -8,6 +8,7 @@ import { PDF_PAGE_COUNT_SANITY_LIMIT } from '../../types/attachment' import { ArtifactIdSchema } from './artifacts.routes' +import { PluginCatalogInstallPhaseSchema } from './plugins.routes' export const OCR_EXTRACTION_MAX_INPUT_BYTES = 50 * 1024 * 1024 @@ -72,12 +73,35 @@ const OcrCacheSchema = z.object({ maxBytes: z.number().int().positive() }) +export const OcrRuntimeInstallStateSchema = z + .object({ + phase: PluginCatalogInstallPhaseSchema, + receivedBytes: z.number().nonnegative(), + totalBytes: z.number().nonnegative().nullable(), + error: z.string().max(2048).nullable(), + updatedAt: z.number().nonnegative() + }) + .strict() + +export const OcrRuntimeAssetInfoSchema = z + .object({ + version: z.string().min(1).max(128), + channel: z.enum(['stable', 'pre-release']), + availability: z.enum(['available', 'incompatible-app', 'unsupported-platform']), + sizeBytes: z.number().int().positive().nullable(), + installedVersion: z.string().min(1).max(128).nullable() + }) + .strict() + export const OcrRuntimeStatusSchema = z.object({ platform: z.string(), arch: z.string(), availability: OcrAvailabilitySchema, + runtimeSource: z.enum(['development', 'bundled', 'downloaded']).nullable(), process: OcrProcessSchema.nullable(), - cache: OcrCacheSchema.nullable() + cache: OcrCacheSchema.nullable(), + runtimeInstall: OcrRuntimeInstallStateSchema.nullable(), + runtimeAsset: OcrRuntimeAssetInfoSchema.nullable() }) export const ocrGetRuntimeStatusRoute = defineRouteContract({ @@ -86,6 +110,47 @@ export const ocrGetRuntimeStatusRoute = defineRouteContract({ output: OcrRuntimeStatusSchema }) +export const ocrInstallRuntimeRoute = defineRouteContract({ + name: 'ocr.installRuntime', + input: z.object({}).default({}), + output: z.object({ + result: z.object({ + ok: z.boolean(), + error: z.string().max(2048).optional() + }) + }) +}) + +export const ocrInstallRuntimeFromPathRoute = defineRouteContract({ + name: 'ocr.installRuntimeFromPath', + input: z.object({ path: z.string().min(1).max(4096) }).strict(), + output: z.object({ + result: z.object({ + ok: z.boolean(), + error: z.string().max(2048).optional() + }) + }) +}) + +export const ocrUninstallRuntimeRoute = defineRouteContract({ + name: 'ocr.uninstallRuntime', + input: z.object({}).default({}), + output: z.object({ + result: z.object({ + ok: z.boolean(), + error: z.string().max(2048).optional() + }) + }) +}) + +export const ocrCancelRuntimeInstallRoute = defineRouteContract({ + name: 'ocr.cancelRuntimeInstall', + input: z.object({}).default({}), + output: z.object({ + cancelled: z.boolean() + }) +}) + export const ocrClearCacheRoute = defineRouteContract({ name: 'ocr.clearCache', input: z.object({}).default({}), diff --git a/src/shared/contracts/routes/plugins.routes.ts b/src/shared/contracts/routes/plugins.routes.ts index 83c7067026..feb3a1bd0e 100644 --- a/src/shared/contracts/routes/plugins.routes.ts +++ b/src/shared/contracts/routes/plugins.routes.ts @@ -5,10 +5,154 @@ import type { PluginInvokeActionRequest, PluginListItem } from '@shared/types/plugin' +import type { PluginCatalog, PluginCatalogEntry } from '@shared/types/pluginCatalog' const PluginListItemSchema = z.custom() const PluginActionResultSchema = z.custom() +const Sha256Schema = z.string().regex(/^[a-f0-9]{64}$/) + +/** + * Artifact URLs must be https. Plain http is only allowed for loopback hosts + * so the local-fixture e2e flow (spec §4.4) can drive installs without a TLS + * server; remote hosts can never be fetched over plaintext. + */ +const ArtifactUrlSchema = z + .url({ protocol: /^https?$/ }) + .max(8192) + .refine(isHttpsOrLoopbackUrl, { + message: 'Artifact URLs must be https (plain http is only allowed for loopback hosts)' + }) + +function isHttpsOrLoopbackUrl(url: string): boolean { + if (url.startsWith('https://')) return true + try { + const parsed = new URL(url) + return ( + parsed.protocol === 'http:' && + (parsed.hostname === 'localhost' || + parsed.hostname === '127.0.0.1' || + parsed.hostname === '[::1]') + ) + } catch { + return false + } +} + +const PluginCatalogTargetSchema = z + .object({ + platform: z.enum(['darwin', 'win32', 'linux']), + arch: z.enum(['arm64', 'x64']), + url: ArtifactUrlSchema, + sha256: Sha256Schema, + size: z.number().int().positive(), + // Mirror prefixes are concatenated with the canonical URL (ghproxy style). + mirrors: z.array(ArtifactUrlSchema.max(2048)).max(8) + }) + .strict() + +export const PluginCatalogArtifactSchema = z + .object({ + pluginId: z.string().min(1).max(128), + version: z.string().min(1).max(64), + channel: z.enum(['stable', 'pre-release']), + displayName: z.string().min(1).max(256).optional(), + description: z.string().max(2048).optional(), + minAppVersion: z.string().min(1).max(64).optional(), + targets: z.array(PluginCatalogTargetSchema).min(1).max(8) + }) + .strict() + +export const RuntimeCatalogAssetSchema = z + .object({ + id: z.string().min(1).max(128), + version: z.string().min(1).max(128), + channel: z.enum(['stable', 'pre-release']), + displayName: z.string().min(1).max(256).optional(), + description: z.string().max(2048).optional(), + minAppVersion: z.string().min(1).max(64).optional(), + targets: z.array(PluginCatalogTargetSchema).min(1).max(8) + }) + .strict() + +export const PluginCatalogSchema = z + .object({ + schemaVersion: z.literal(1), + artifacts: z.array(PluginCatalogArtifactSchema).max(64), + runtimeAssets: z.array(RuntimeCatalogAssetSchema).max(16).optional() + }) + .strict() + +export const PluginCatalogInstallPhaseSchema = z.enum([ + 'idle', + 'probing', + 'downloading', + 'verifying', + 'installing', + 'installed', + 'error', + 'cancelled' +]) + +export type ParsedPluginCatalog = z.infer + +export function parsePluginCatalog(input: unknown, source = ''): PluginCatalog { + try { + return PluginCatalogSchema.parse(input) as PluginCatalog + } catch (error) { + if (error instanceof z.ZodError) { + const issue = error.issues[0] + const pointer = issue ? ` (${issue.path.join('.')}: ${issue.message})` : '' + throw new Error(`Invalid plugin catalog ${source}${pointer}`) + } + throw error + } +} + +export const pluginsCatalogListRoute = defineRouteContract({ + name: 'plugins.catalog.list', + input: z.object({}).strict(), + output: z.object({ + entries: z.array(z.custom()) + }) +}) + +export const pluginsCatalogInstallRoute = defineRouteContract({ + name: 'plugins.catalog.install', + input: z.object({ pluginId: z.string().min(1).max(128) }).strict(), + output: z.object({ + result: PluginActionResultSchema + }) +}) + +export const pluginsCatalogCancelRoute = defineRouteContract({ + name: 'plugins.catalog.cancel', + input: z.object({ pluginId: z.string().min(1).max(128) }).strict(), + output: z.object({ + cancelled: z.boolean() + }) +}) + +export const pluginsCatalogInstallFromPathRoute = defineRouteContract({ + name: 'plugins.catalog.installFromPath', + input: z.object({ path: z.string().min(1).max(4096) }).strict(), + output: z.object({ + result: z.object({ + ok: z.boolean(), + pluginId: z.string().min(1).max(128).optional(), + error: z.string().max(2048).optional() + }) + }) +}) + +export const pluginsUninstallOfficialRoute = defineRouteContract({ + name: 'plugins.uninstallOfficial', + input: z.object({ pluginId: z.string().min(1).max(128) }).strict(), + output: z.object({ + result: PluginActionResultSchema + }) +}) + export const pluginsListRoute = defineRouteContract({ name: 'plugins.list', input: z.object({}), diff --git a/src/shared/types/pluginCatalog.ts b/src/shared/types/pluginCatalog.ts new file mode 100644 index 0000000000..809daa7199 --- /dev/null +++ b/src/shared/types/pluginCatalog.ts @@ -0,0 +1,104 @@ +export type PluginCatalogChannel = 'stable' | 'pre-release' + +export interface PluginCatalogTarget { + platform: string + arch: string + url: string + sha256: string + size: number + mirrors: string[] +} + +export interface PluginCatalogArtifact { + pluginId: string + version: string + channel: PluginCatalogChannel + displayName?: string + description?: string + minAppVersion?: string + targets: PluginCatalogTarget[] +} + +export interface PluginCatalog { + schemaVersion: 1 + artifacts: PluginCatalogArtifact[] + runtimeAssets?: RuntimeCatalogAsset[] +} + +export interface RuntimeCatalogAsset { + id: string + version: string + channel: PluginCatalogChannel + displayName?: string + description?: string + minAppVersion?: string + targets: PluginCatalogTarget[] +} + +export type RuntimeAssetInstallPhase = + | 'idle' + | 'probing' + | 'downloading' + | 'verifying' + | 'installing' + | 'installed' + | 'error' + | 'cancelled' + +export interface RuntimeAssetInstallState { + assetId: string + version: string + phase: RuntimeAssetInstallPhase + receivedBytes: number + totalBytes: number | null + error: string | null + updatedAt: number +} + +export type RuntimeAssetAvailability = 'available' | 'incompatible-app' | 'unsupported-platform' + +export interface RuntimeAssetCatalogEntry { + assetId: string + version: string + channel: PluginCatalogChannel + displayName?: string + description?: string + availability: RuntimeAssetAvailability + sizeBytes: number | null + installState: RuntimeAssetInstallState | null +} + +export type PluginCatalogInstallPhase = + | 'idle' + | 'probing' + | 'downloading' + | 'verifying' + | 'installing' + | 'installed' + | 'error' + | 'cancelled' + +export interface PluginCatalogInstallState { + pluginId: string + version: string + phase: PluginCatalogInstallPhase + receivedBytes: number + totalBytes: number | null + error: string | null + updatedAt: number +} + +export type PluginCatalogAvailability = 'available' | 'incompatible-app' | 'unsupported-platform' + +export interface PluginCatalogEntry { + pluginId: string + version: string + channel: PluginCatalogChannel + displayName?: string + description?: string + availability: PluginCatalogAvailability + sizeBytes: number | null + installed: boolean + installedVersion: string | null + installState: PluginCatalogInstallState | null +} diff --git a/test/main/build/electronBuilderConfig.test.ts b/test/main/build/electronBuilderConfig.test.ts index 07b65abe5f..70f345cde3 100644 --- a/test/main/build/electronBuilderConfig.test.ts +++ b/test/main/build/electronBuilderConfig.test.ts @@ -152,9 +152,14 @@ describe('Linux ARM64 packaging', () => { expect(steps.find((step) => step.name === 'Bundle Feishu plugin')?.if).toBeUndefined() const ocrSmoke = steps.find((step) => step.name === 'Verify packaged Light OCR offline') - expect(ocrSmoke?.if).toBeUndefined() + expect(ocrSmoke?.if).toBe("env.DEEPCHAT_UNBUNDLE_OCR != '1'") expect(ocrSmoke?.run).toContain('--expect-supported') expect(ocrSmoke?.run).toContain('dist/${UNPACKED_DIRECTORY}/resources') + const ocrAbsent = steps.find( + (step) => step.name === 'Verify packaged OCR runtime is absent' + ) + expect(ocrAbsent?.if).toBe("env.DEEPCHAT_UNBUNDLE_OCR == '1'") + expect(ocrAbsent?.run).toContain('runtime/ocr') expect( steps.find((step) => step.name?.includes('OCR is unavailable')) ).toBeUndefined() diff --git a/test/main/ocr/ocrRuntimeAssetResolver.test.ts b/test/main/ocr/ocrRuntimeAssetResolver.test.ts index b538d8a11e..178d0779be 100644 --- a/test/main/ocr/ocrRuntimeAssetResolver.test.ts +++ b/test/main/ocr/ocrRuntimeAssetResolver.test.ts @@ -437,4 +437,159 @@ describe('OcrRuntimeAssetResolver', () => { assets: { bundlePath: await realpath(path.join(modelDir, 'bundle')) } }) }) + + it('falls back to an installed runtime root when the bundle is missing', async () => { + const appPath = path.join(tempDir, 'resources', 'app.asar') + // The unpacked app root stays empty; only the installed root is seeded. + const installedRoot = path.join(tempDir, 'runtimes', 'ocr', 'installed-version') + const { facadeDir } = await seedAssetIdentity(installedRoot) + await writeText(path.join(installedRoot, 'out', 'main', 'lightOcrHelper.js')) + await writeText(path.join(installedRoot, 'runtime', 'node', 'bin', 'node')) + await writeJson(path.join(installedRoot, 'runtime', 'ocr', 'manifest.json'), { + schemaVersion: 3, + supported: true, + platform: 'darwin', + arch: 'arm64', + facadeVersion: lightOcrVersion, + runtimeVersion, + modelVersion, + nativeVersion, + pdfSupport: true, + bundleId, + nativePayloadEncoding: 'gzip-base64-v1', + nativePackage, + nativeArtifactInventory, + paths: { + node: 'runtime/node/bin/node', + helper: 'out/main/lightOcrHelper.js', + facade: path.relative(installedRoot, facadeDir), + runtime: path.relative( + installedRoot, + path.join(installedRoot, 'node_modules', '@arcships', 'light-ocr-runtime') + ), + bundle: path.relative( + installedRoot, + path.join( + installedRoot, + 'node_modules', + '@arcships', + 'light-ocr-model-ppocrv6-small', + 'bundle' + ) + ), + native: path.relative( + installedRoot, + path.join(installedRoot, 'node_modules', '@arcships', 'light-ocr-darwin-arm64') + ) + } + }) + + const availability = await new OcrRuntimeAssetResolver({ + appPath, + isPackaged: true, + platform: 'darwin', + arch: 'arm64', + installedRuntimeRoots: () => [installedRoot] + }).resolve() + + expect(availability).toMatchObject({ + status: 'available', + source: 'downloaded', + assets: { + bundleId, + helperEntryPath: path.join(installedRoot, 'out', 'main', 'lightOcrHelper.js') + } + }) + }) + + it('prefers the bundled runtime root over installed roots', async () => { + const appPath = path.join(tempDir, 'resources', 'app.asar') + const unpackedRoot = path.join(tempDir, 'resources', 'app.asar.unpacked') + const { facadeDir } = await seedAssetIdentity(unpackedRoot) + await writeText(path.join(unpackedRoot, 'runtime', 'node', 'bin', 'node')) + await writeText(path.join(unpackedRoot, 'out', 'main', 'lightOcrHelper.js')) + await writeJson(path.join(unpackedRoot, 'runtime', 'ocr', 'manifest.json'), { + schemaVersion: 3, + supported: true, + platform: 'darwin', + arch: 'arm64', + facadeVersion: lightOcrVersion, + runtimeVersion, + modelVersion, + nativeVersion, + pdfSupport: true, + bundleId, + nativePayloadEncoding: 'gzip-base64-v1', + nativePackage, + nativeArtifactInventory, + paths: { + node: 'runtime/node/bin/node', + helper: 'out/main/lightOcrHelper.js', + facade: path.relative(unpackedRoot, facadeDir), + runtime: path.relative( + unpackedRoot, + path.join(unpackedRoot, 'node_modules', '@arcships', 'light-ocr-runtime') + ), + bundle: path.relative( + unpackedRoot, + path.join( + unpackedRoot, + 'node_modules', + '@arcships', + 'light-ocr-model-ppocrv6-small', + 'bundle' + ) + ), + native: path.relative( + unpackedRoot, + path.join(unpackedRoot, 'node_modules', '@arcships', 'light-ocr-darwin-arm64') + ) + } + }) + // A fully valid installed root proves precedence: if the resolver tried + // the installed root first, the helper would resolve inside it. + const installedRoot = path.join(tempDir, 'runtimes', 'ocr', 'installed-version') + const installed = await seedAssetIdentity(installedRoot) + await writeText(path.join(installedRoot, 'runtime', 'node', 'bin', 'node')) + await writeText(path.join(installedRoot, 'out', 'main', 'lightOcrHelper.js')) + await writeJson(path.join(installedRoot, 'runtime', 'ocr', 'manifest.json'), { + schemaVersion: 3, + supported: true, + platform: 'darwin', + arch: 'arm64', + facadeVersion: lightOcrVersion, + runtimeVersion, + modelVersion, + nativeVersion, + pdfSupport: true, + bundleId, + nativePayloadEncoding: 'gzip-base64-v1', + nativePackage, + nativeArtifactInventory, + paths: { + node: 'runtime/node/bin/node', + helper: 'out/main/lightOcrHelper.js', + facade: path.relative(installedRoot, installed.facadeDir), + runtime: path.relative(installedRoot, installed.runtimeDir), + bundle: path.relative(installedRoot, path.join(installed.modelDir, 'bundle')), + native: path.relative(installedRoot, installed.nativeDir) + } + }) + + const availability = await new OcrRuntimeAssetResolver({ + appPath, + isPackaged: true, + platform: 'darwin', + arch: 'arm64', + installedRuntimeRoots: () => [installedRoot] + }).resolve() + + expect(availability).toMatchObject({ + status: 'available', + source: 'bundled', + assets: { + helperEntryPath: path.join(unpackedRoot, 'out', 'main', 'lightOcrHelper.js') + } + }) + }) }) diff --git a/test/main/ocr/routes.test.ts b/test/main/ocr/routes.test.ts index 7223e9c854..41921d4ed5 100644 --- a/test/main/ocr/routes.test.ts +++ b/test/main/ocr/routes.test.ts @@ -7,6 +7,7 @@ import type { OcrRuntimeServiceStatus } from '@/ocr/ocrRuntimeService' const INTERNAL_STATUS: OcrRuntimeServiceStatus = { availability: { status: 'available', + source: 'bundled', assets: { nodeExecutable: '/private/runtime/node', helperEntryPath: '/private/runtime/helper.js', @@ -71,6 +72,7 @@ describe('OCR routes', () => { lightOcrVersion: '0.3.4', bundleId: 'ppocrv6-small-native-20260719.1' }, + runtimeSource: 'bundled', process: { state: 'ready', nodeVersion: 'v24.18.0', @@ -85,7 +87,9 @@ describe('OCR routes', () => { recognition: { providerChain: ['cpu'], precision: 'fp32' } } }, - cache: INTERNAL_STATUS.cache + cache: INTERNAL_STATUS.cache, + runtimeInstall: null, + runtimeAsset: null }) const serialized = JSON.stringify(result) expect(serialized).not.toContain('/private/') diff --git a/test/main/ocr/runtimeAssetInstaller.test.ts b/test/main/ocr/runtimeAssetInstaller.test.ts new file mode 100644 index 0000000000..10ca855878 --- /dev/null +++ b/test/main/ocr/runtimeAssetInstaller.test.ts @@ -0,0 +1,572 @@ +import { createHash } from 'node:crypto' +import { spawnSync } from 'node:child_process' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { mkdir, mkdtemp, readdir, rm, stat, writeFile } from 'node:fs/promises' +import { unzipSync, zipSync } from 'fflate' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('fs') +vi.unmock('node:fs') +vi.unmock('path') +vi.unmock('node:path') + +import { OcrRuntimeAssetResolver } from '@/ocr/ocrRuntimeAssetResolver' +import { OcrRuntimeAssetInstaller } from '@/ocr/runtimeAssetInstaller' +import { resetProbeCacheForTests, type FetchLike } from '@/toolchains/downloader' +import { ocrRuntimeInstallProgressEvent } from '@shared/contracts/events' +import type { PluginCatalogTarget, RuntimeCatalogAsset } from '@shared/types/pluginCatalog' + +/** + * Mirrors the production wiring in composition.ts: every installer progress + * state must satisfy the ocr.runtimeInstall.progress event contract, which + * rejects empty versions. + */ +function createContractValidatingProgressCollector(): { + phases: string[] + onProgress: ConstructorParameters[0]['onProgress'] +} { + const phases: string[] = [] + return { + phases, + onProgress: (state) => { + ocrRuntimeInstallProgressEvent.payload.parse({ ...state, updatedAt: Date.now() }) + phases.push(state.phase) + } + } +} + +const lightOcrVersion = '0.5.7' +const runtimeVersion = '0.1.7' +const modelVersion = '0.3.4' +const nativeVersion = '0.5.7' +const bundleId = 'ppocrv6-small-native-20260719.1' +const nativeArtifactInventory = { + nativeCode: ['native/light_ocr_node.node'], + pdfiumCode: ['pdfium/libpdfium.dylib', 'pdfium/pdfium.node'], + pdfiumLoader: ['pdfium/index.cjs'], + other: [ + 'native/runtime-descriptor.json', + 'pdfium/fonts/NotoSansSC-Regular.otf', + 'pdfium/fonts/OFL.txt' + ] +} + +const tempDirs: string[] = [] + +async function createTempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'deepchat-ocr-runtime-install-')) + tempDirs.push(dir) + return dir +} + +beforeEach(() => { + vi.restoreAllMocks() +}) + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs.length = 0 + resetProbeCacheForTests() +}) + +async function writeJson(filePath: string, value: unknown) { + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`) +} + +async function writeText(filePath: string, value = '') { + await mkdir(path.dirname(filePath), { recursive: true }) + await writeFile(filePath, value) +} + +/** Seeds a valid packaged OCR runtime root (the unpacked-app-root layout). */ +async function seedRuntimeRoot(root: string): Promise { + const facadeDir = path.join(root, 'node_modules', '@arcships', 'light-ocr') + const runtimeDir = path.join(root, 'node_modules', '@arcships', 'light-ocr-runtime') + const modelDir = path.join(root, 'node_modules', '@arcships', 'light-ocr-model-ppocrv6-small') + const nativeDir = path.join(root, 'node_modules', '@arcships', 'light-ocr-darwin-arm64') + await writeJson(path.join(facadeDir, 'package.json'), { + name: '@arcships/light-ocr', + version: lightOcrVersion, + main: 'src/index.cjs', + dependencies: { + '@arcships/light-ocr-runtime': runtimeVersion, + '@arcships/light-ocr-model-ppocrv6-small': modelVersion + } + }) + await writeText(path.join(facadeDir, 'src', 'index.cjs')) + await writeJson(path.join(runtimeDir, 'package.json'), { + name: '@arcships/light-ocr-runtime', + version: runtimeVersion, + main: 'src/index.cjs', + optionalDependencies: { '@arcships/light-ocr-darwin-arm64': nativeVersion } + }) + await writeText(path.join(runtimeDir, 'src', 'index.cjs')) + await writeJson(path.join(modelDir, 'package.json'), { + name: '@arcships/light-ocr-model-ppocrv6-small', + version: modelVersion, + exports: { './bundle/manifest.json': './bundle/manifest.json' } + }) + await writeJson(path.join(modelDir, 'bundle', 'manifest.json'), { bundleId }) + await writeJson(path.join(nativeDir, 'package.json'), { + name: '@arcships/light-ocr-darwin-arm64', + version: nativeVersion, + main: 'native/light_ocr_node.node' + }) + await writeJson(path.join(nativeDir, 'artifact-hashes.json'), { + files: [ + { path: 'native/light_ocr_node.node' }, + { path: 'native/runtime-descriptor.json' }, + { path: 'pdfium/fonts/NotoSansSC-Regular.otf' }, + { path: 'pdfium/fonts/OFL.txt' }, + { path: 'pdfium/index.cjs' }, + { path: 'pdfium/libpdfium.dylib' }, + { path: 'pdfium/pdfium.node' } + ] + }) + await writeText(path.join(nativeDir, 'native', 'light_ocr_node.node')) + await writeText(path.join(nativeDir, 'native', 'runtime-descriptor.json'), '{}') + await writeText(path.join(nativeDir, 'pdfium', 'fonts', 'NotoSansSC-Regular.otf')) + await writeText(path.join(nativeDir, 'pdfium', 'fonts', 'OFL.txt')) + await writeText(path.join(nativeDir, 'pdfium', 'index.cjs')) + await writeText(path.join(nativeDir, 'pdfium', 'libpdfium.dylib')) + await writeText(path.join(nativeDir, 'pdfium', 'libpdfium.dylib.gz.b64')) + await writeText(path.join(nativeDir, 'pdfium', 'pdfium.node')) + await writeText(path.join(nativeDir, 'pdfium', 'pdfium.node.gz.b64')) + await writeText(path.join(root, 'out', 'main', 'lightOcrHelper.js')) + await writeText(path.join(root, 'runtime', 'node', 'bin', 'node')) + await writeJson(path.join(root, 'runtime', 'ocr', 'manifest.json'), { + schemaVersion: 3, + supported: true, + platform: 'darwin', + arch: 'arm64', + facadeVersion: lightOcrVersion, + runtimeVersion, + modelVersion, + nativeVersion, + pdfSupport: true, + bundleId, + nativePayloadEncoding: 'gzip-base64-v1', + nativePackage: '@arcships/light-ocr-darwin-arm64', + nativeArtifactInventory, + paths: { + node: 'runtime/node/bin/node', + helper: 'out/main/lightOcrHelper.js', + facade: 'node_modules/@arcships/light-ocr', + runtime: 'node_modules/@arcships/light-ocr-runtime', + bundle: 'node_modules/@arcships/light-ocr-model-ppocrv6-small/bundle', + native: 'node_modules/@arcships/light-ocr-darwin-arm64' + } + }) +} + +async function collectFiles(dir: string, base = dir): Promise> { + const entries: Record = {} + for (const name of await readdir(dir)) { + const absolute = path.join(dir, name) + if ((await stat(absolute)).isDirectory()) { + Object.assign(entries, await collectFiles(absolute, base)) + continue + } + const relative = path.relative(base, absolute).split(path.sep).join('/') + entries[relative] = new Uint8Array(fs.readFileSync(absolute)) + } + return entries +} + +function sha256(content: Uint8Array): string { + return createHash('sha256').update(content).digest('hex') +} + +function createAssetAndTarget( + payload: Uint8Array, + overrides: { sha256?: string } = {} +): { asset: RuntimeCatalogAsset; target: PluginCatalogTarget } { + const target: PluginCatalogTarget = { + platform: 'darwin', + arch: 'arm64', + url: 'https://example.com/ocr-runtime.zip', + sha256: overrides.sha256 ?? sha256(payload), + size: payload.length, + mirrors: [] + } + const asset: RuntimeCatalogAsset = { + id: 'light-ocr', + version: bundleId, + channel: 'stable', + displayName: 'LightOCR Runtime', + targets: [target] + } + return { asset, target } +} + +function fetchServingContent(content: Uint8Array): FetchLike { + return async () => new Response(new Uint8Array(content), { status: 200 }) +} + +describe('OcrRuntimeAssetInstaller', () => { + it('downloads, validates and materializes a runtime payload the resolver accepts', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const payload = zipSync(await collectFiles(payloadRoot)) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const progress = createContractValidatingProgressCollector() + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + fetchImpl: fetchServingContent(payload), + probeTimeoutMs: 250, + onProgress: progress.onProgress + }) + const { asset, target } = createAssetAndTarget(payload) + + const result = await installer.install(asset, target) + + expect(result.ok).toBe(true) + expect([...new Set(progress.phases)]).toEqual([ + 'probing', + 'downloading', + 'verifying', + 'installing', + 'installed' + ]) + expect(installer.listInstalledRoots()).toEqual([path.join(installRoot, bundleId)]) + // Staging is cleaned up after success (only the empty staging root remains). + expect(fs.readdirSync(path.join(installRoot, '.staging'))).toEqual([]) + + // The installed root passes the resolver's full identity verification. + const availability = await new OcrRuntimeAssetResolver({ + appPath: path.join(dir, 'resources', 'app.asar'), + isPackaged: true, + platform: 'darwin', + arch: 'arm64', + installedRuntimeRoots: () => installer.listInstalledRoots() + }).resolve() + expect(availability).toMatchObject({ status: 'available' }) + }) + + it('rejects a payload whose bytes do not match the pinned sha256', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const payload = zipSync(await collectFiles(payloadRoot)) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + fetchImpl: fetchServingContent(payload), + probeTimeoutMs: 250 + }) + const { asset, target } = createAssetAndTarget(payload, { sha256: '0'.repeat(64) }) + + const result = await installer.install(asset, target) + + expect(result.ok).toBe(false) + expect(result.reason).toBe('checksum_mismatch') + expect(installer.listInstalledRoots()).toEqual([]) + }) + + it('rejects a payload without a packaged runtime manifest', async () => { + const dir = await createTempDir() + const payload = zipSync({ 'readme.txt': new TextEncoder().encode('not a runtime') }) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + fetchImpl: fetchServingContent(payload), + probeTimeoutMs: 250 + }) + const { asset, target } = createAssetAndTarget(payload) + + const result = await installer.install(asset, target) + + expect(result.ok).toBe(false) + expect(result.error).toContain('manifest') + expect(installer.listInstalledRoots()).toEqual([]) + }) + + it('rejects a payload whose decompressed size exceeds the cap', async () => { + const dir = await createTempDir() + // 260 MiB of zeros deflates to a few hundred KB but declares an + // originalSize above the 256 MiB floor; the filter must reject the entry + // before fflate allocates the decompressed buffer. + const bomb = new Uint8Array(260 * 1024 * 1024) + const payload = zipSync({ 'runtime/ocr/native/engine.bin': bomb }) + expect(payload.length).toBeLessThan(4 * 1024 * 1024) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + fetchImpl: fetchServingContent(payload), + probeTimeoutMs: 250 + }) + const { asset, target } = createAssetAndTarget(payload) + + const result = await installer.install(asset, target) + + expect(result.ok).toBe(false) + expect(result.error).toContain('decompressed size cap') + expect(installer.listInstalledRoots()).toEqual([]) + }) + + it('rejects a payload that declares a helper entry it does not contain', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const files = await collectFiles(payloadRoot) + delete files['out/main/lightOcrHelper.js'] + const payload = zipSync(files) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + fetchImpl: fetchServingContent(payload), + probeTimeoutMs: 250 + }) + const { asset, target } = createAssetAndTarget(payload) + + const result = await installer.install(asset, target) + + expect(result.ok).toBe(false) + expect(result.error).toContain('helper') + expect(installer.listInstalledRoots()).toEqual([]) + }) + + it('rejects zip entries that escape the payload root', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const files = await collectFiles(payloadRoot) + files['../outside/evil.txt'] = new TextEncoder().encode('evil') + const payload = zipSync(files) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + fetchImpl: fetchServingContent(payload), + probeTimeoutMs: 250 + }) + const { asset, target } = createAssetAndTarget(payload) + + const result = await installer.install(asset, target) + + expect(result.ok).toBe(false) + expect(result.error).toContain('Unsafe') + expect(fs.existsSync(path.join(dir, 'outside'))).toBe(false) + }) + + it('replaces an existing install of the same version', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const payload = zipSync(await collectFiles(payloadRoot)) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + fetchImpl: fetchServingContent(payload), + probeTimeoutMs: 250 + }) + const { asset, target } = createAssetAndTarget(payload) + + await installer.install(asset, target) + const second = await installer.install(asset, target) + + expect(second.ok).toBe(true) + expect(installer.listInstalledRoots()).toEqual([path.join(installRoot, bundleId)]) + }) + + it('unzipped payload files match the archive contents', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const payload = zipSync(await collectFiles(payloadRoot)) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + fetchImpl: fetchServingContent(payload), + probeTimeoutMs: 250 + }) + const { asset, target } = createAssetAndTarget(payload) + await installer.install(asset, target) + + const materialized = await collectFiles(path.join(installRoot, bundleId)) + const archived = unzipSync(payload) + expect(Object.keys(materialized).sort()).toEqual(Object.keys(archived).sort()) + }) + + it('installs a manually selected payload file without a catalog entry', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const payload = zipSync(await collectFiles(payloadRoot)) + const archivePath = path.join(dir, 'manual-payload.zip') + fs.writeFileSync(archivePath, Buffer.from(payload)) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const progress = createContractValidatingProgressCollector() + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + platform: 'darwin', + arch: 'arm64', + onProgress: progress.onProgress + }) + + const result = await installer.installFromFile(archivePath) + + expect(result.ok).toBe(true) + expect(result.version).toBe(bundleId) + // Manual installs only learn the version from the payload manifest, but + // every progress event still carries a non-empty version. + expect(progress.phases).toEqual(['verifying', 'installing', 'installed']) + expect(installer.listInstalledVersions()).toEqual([bundleId]) + + // The manually installed payload passes the resolver's identity checks. + const availability = await new OcrRuntimeAssetResolver({ + appPath: path.join(dir, 'resources', 'app.asar'), + isPackaged: true, + platform: 'darwin', + arch: 'arm64', + installedRuntimeRoots: () => installer.listInstalledRoots() + }).resolve() + expect(availability).toMatchObject({ status: 'available' }) + }) + + it('installs the payload produced by the catalog generator', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const artifactsDir = path.join(dir, 'remote-plugins') + await mkdir(artifactsDir, { recursive: true }) + const catalogPath = path.join(artifactsDir, 'plugin-catalog.json') + + // Run the real generator CLI against the staged unpacked-root layout, + // exactly as the package workflow does. + const generate = spawnSync( + process.execPath, + [ + path.join(process.cwd(), 'scripts', 'plugin-catalog.mjs'), + 'generate', + '--runtime-dir', + path.join(payloadRoot, 'runtime'), + '--artifacts-dir', + artifactsDir, + '--catalog', + catalogPath, + '--base-url', + 'http://127.0.0.1:8787/', + '--write' + ], + { encoding: 'utf8' } + ) + expect(generate.stderr).toBe('') + expect(generate.status).toBe(0) + + const payloadName = `light-ocr-${bundleId}-darwin-arm64.zip` + const payloadPath = path.join(artifactsDir, payloadName) + expect(fs.existsSync(payloadPath)).toBe(true) + + // The catalog pins the exact payload bytes. + const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8')) as { + runtimeAssets?: Array<{ + id: string + version: string + targets: Array<{ platform: string; arch: string; sha256: string; size: number }> + }> + } + const asset = catalog.runtimeAssets?.find((entry) => entry.id === 'light-ocr') + expect(asset?.version).toBe(bundleId) + const target = asset?.targets.find( + (candidate) => candidate.platform === 'darwin' && candidate.arch === 'arm64' + ) + expect(target?.sha256).toBe(sha256(new Uint8Array(fs.readFileSync(payloadPath)))) + expect(target?.size).toBe(fs.statSync(payloadPath).size) + + // The generated payload is installable and passes the resolver checks — + // the regression guard against incomplete payload packaging. + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + platform: 'darwin', + arch: 'arm64' + }) + + const result = await installer.installFromFile(payloadPath) + + expect(result.ok).toBe(true) + expect(result.version).toBe(bundleId) + const availability = await new OcrRuntimeAssetResolver({ + appPath: path.join(dir, 'resources', 'app.asar'), + isPackaged: true, + platform: 'darwin', + arch: 'arm64', + installedRuntimeRoots: () => installer.listInstalledRoots() + }).resolve() + expect(availability).toMatchObject({ status: 'available' }) + }) + + it('rejects a manually selected payload built for another platform', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const files = await collectFiles(payloadRoot) + const manifest = JSON.parse(Buffer.from(files['runtime/ocr/manifest.json']).toString('utf8')) + manifest.platform = 'win32' + files['runtime/ocr/manifest.json'] = new TextEncoder().encode(JSON.stringify(manifest)) + const payload = zipSync(files) + const archivePath = path.join(dir, 'foreign-payload.zip') + fs.writeFileSync(archivePath, Buffer.from(payload)) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + platform: 'darwin', + arch: 'arm64' + }) + + const result = await installer.installFromFile(archivePath) + + expect(result.ok).toBe(false) + expect(result.error).toContain('win32') + expect(installer.listInstalledRoots()).toEqual([]) + }) + + it('removes downloaded runtime versions on uninstall', async () => { + const dir = await createTempDir() + const payloadRoot = path.join(dir, 'payload-root') + await seedRuntimeRoot(payloadRoot) + const payload = zipSync(await collectFiles(payloadRoot)) + const archivePath = path.join(dir, 'manual-payload.zip') + fs.writeFileSync(archivePath, Buffer.from(payload)) + + const installRoot = path.join(dir, 'runtimes', 'ocr') + const installer = new OcrRuntimeAssetInstaller({ + installRoot: () => installRoot, + stagingRoot: () => path.join(installRoot, '.staging'), + platform: 'darwin', + arch: 'arm64' + }) + await installer.installFromFile(archivePath) + expect(installer.listInstalledVersions()).toEqual([bundleId]) + + const removed = installer.removeInstalled() + + expect(removed).toBe(1) + expect(installer.listInstalledRoots()).toEqual([]) + }) +}) diff --git a/test/main/ocr/runtimeInstallCoordinator.test.ts b/test/main/ocr/runtimeInstallCoordinator.test.ts new file mode 100644 index 0000000000..2002013b04 --- /dev/null +++ b/test/main/ocr/runtimeInstallCoordinator.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, it, vi } from 'vitest' +import { OcrRuntimeInstallCoordinator } from '@/ocr/runtimeInstallCoordinator' +import type { RuntimeAssetResolution } from '@/plugin/catalog' +import type { + OcrRuntimeAssetInstallResult, + OcrRuntimeAssetInstaller +} from '@/ocr/runtimeAssetInstaller' +import type { + PluginCatalogTarget, + RuntimeAssetInstallState, + RuntimeCatalogAsset +} from '@shared/types/pluginCatalog' + +type InstallerHarness = { + installer: Pick + install: ReturnType + setState: (state: RuntimeAssetInstallState | null) => void +} + +function createHarness(options: { + installResult?: OcrRuntimeAssetInstallResult + running?: boolean + state?: RuntimeAssetInstallState | null +}): InstallerHarness { + let currentState: RuntimeAssetInstallState | null = options.state ?? null + const install = vi.fn(async (): Promise => { + const result = options.installResult ?? { + ok: true, + assetId: 'light-ocr', + version: 'ppocrv6-small-native-20260719.1', + reason: null, + error: null + } + // Mirror the real installer's terminal state transition. + currentState = { + assetId: result.assetId, + version: result.version, + phase: result.ok ? 'installed' : 'error', + receivedBytes: 1, + totalBytes: 1, + error: result.error, + updatedAt: 1 + } + return result + }) + const installer: Pick = { + install, + isRunning: () => options.running ?? false, + getInstallState: () => currentState + } + return { installer, install, setState: (state) => (currentState = state) } +} + +function createResolution(): RuntimeAssetResolution { + const target: PluginCatalogTarget = { + platform: 'darwin', + arch: 'arm64', + url: 'https://example.com/ocr-runtime.zip', + sha256: 'a'.repeat(64), + size: 100, + mirrors: [] + } + const asset: RuntimeCatalogAsset = { + id: 'light-ocr', + version: 'ppocrv6-small-native-20260719.1', + channel: 'stable', + targets: [target] + } + return { asset, target } +} + +describe('OcrRuntimeInstallCoordinator', () => { + it('starts an automatic install when enabled and resolvable', async () => { + const harness = createHarness({}) + const onInstalled = vi.fn() + const coordinator = new OcrRuntimeInstallCoordinator({ + resolveAsset: () => createResolution(), + installer: harness.installer, + onInstalled + }) + + coordinator.maybeStartInstall() + await vi.waitFor(() => expect(harness.install).toHaveBeenCalledOnce()) + await vi.waitFor(() => expect(onInstalled).toHaveBeenCalledOnce()) + }) + + it('installs from a manually selected file and resets the cooldown', async () => { + let clock = 1_000 + const harness = createHarness({ + installResult: { + ok: false, + assetId: 'light-ocr', + version: 'ppocrv6-small-native-20260719.1', + reason: 'http', + error: 'HTTP 500' + } + }) + const manualInstallFromFile = vi.fn(async () => ({ + ok: true, + assetId: 'light-ocr', + version: 'manual-bundle', + reason: null, + error: null + })) + const installer = { + ...harness.installer, + installFromFile: manualInstallFromFile + } + const onInstalled = vi.fn() + const coordinator = new OcrRuntimeInstallCoordinator({ + resolveAsset: () => createResolution(), + installer, + onInstalled, + retryCooldownMs: 60_000, + now: () => clock + }) + + // A failed automatic install enters the cooldown. + coordinator.maybeStartInstall() + await vi.waitFor(() => expect(harness.install).toHaveBeenCalledOnce()) + clock += 1_000 + + // The manual file install bypasses the cooldown and succeeds. + const result = await coordinator.installFromFile('/tmp/payload.zip') + expect(result.ok).toBe(true) + expect(result.version).toBe('manual-bundle') + expect(manualInstallFromFile).toHaveBeenCalledWith('/tmp/payload.zip') + expect(onInstalled).toHaveBeenCalledOnce() + + // The successful manual install reset the cooldown. + coordinator.maybeStartInstall() + await vi.waitFor(() => expect(harness.install).toHaveBeenCalledTimes(2)) + }) + + it('does not auto-install without a catalog resolution', () => { + const harness = createHarness({}) + const coordinator = new OcrRuntimeInstallCoordinator({ + resolveAsset: () => null, + installer: harness.installer, + onInstalled: vi.fn() + }) + + coordinator.maybeStartInstall() + + expect(harness.install).not.toHaveBeenCalled() + }) + + it('skips automatic install while one is running or already installed', () => { + const running = createHarness({ running: true }) + const coordinator = new OcrRuntimeInstallCoordinator({ + resolveAsset: () => createResolution(), + installer: running.installer, + onInstalled: vi.fn() + }) + coordinator.maybeStartInstall() + expect(running.install).not.toHaveBeenCalled() + + const installed = createHarness({ + state: { + assetId: 'light-ocr', + version: 'v', + phase: 'installed', + receivedBytes: 1, + totalBytes: 1, + error: null, + updatedAt: 1 + } + }) + const coordinatorInstalled = new OcrRuntimeInstallCoordinator({ + resolveAsset: () => createResolution(), + installer: installed.installer, + onInstalled: vi.fn() + }) + coordinatorInstalled.maybeStartInstall() + expect(installed.install).not.toHaveBeenCalled() + }) + + it('enters a cooldown after a failed automatic install', async () => { + let clock = 1_000 + const harness = createHarness({ + installResult: { + ok: false, + assetId: 'light-ocr', + version: 'ppocrv6-small-native-20260719.1', + reason: 'http', + error: 'HTTP 500' + } + }) + const coordinator = new OcrRuntimeInstallCoordinator({ + resolveAsset: () => createResolution(), + installer: harness.installer, + onInstalled: vi.fn(), + retryCooldownMs: 60_000, + now: () => clock + }) + + coordinator.maybeStartInstall() + await vi.waitFor(() => expect(harness.install).toHaveBeenCalledOnce()) + + // Within the cooldown: no automatic retry. + clock += 10_000 + coordinator.maybeStartInstall() + expect(harness.install).toHaveBeenCalledOnce() + + // After the cooldown: retries automatically. + clock += 60_000 + coordinator.maybeStartInstall() + await vi.waitFor(() => expect(harness.install).toHaveBeenCalledTimes(2)) + }) + + it('explicit install bypasses and resets the cooldown', async () => { + let clock = 1_000 + const harness = createHarness({ + installResult: { + ok: false, + assetId: 'light-ocr', + version: 'ppocrv6-small-native-20260719.1', + reason: 'http', + error: 'HTTP 500' + } + }) + const coordinator = new OcrRuntimeInstallCoordinator({ + resolveAsset: () => createResolution(), + installer: harness.installer, + onInstalled: vi.fn(), + retryCooldownMs: 60_000, + now: () => clock + }) + + coordinator.maybeStartInstall() + await vi.waitFor(() => expect(harness.install).toHaveBeenCalledOnce()) + + // Explicit install resets the cooldown even though no time has passed. + await coordinator.install() + expect(harness.install).toHaveBeenCalledTimes(2) + + coordinator.maybeStartInstall() + await vi.waitFor(() => expect(harness.install).toHaveBeenCalledTimes(3)) + }) + + it('reports an explicit install as unavailable without a catalog resolution', async () => { + const harness = createHarness({}) + const coordinator = new OcrRuntimeInstallCoordinator({ + resolveAsset: () => null, + installer: harness.installer, + onInstalled: vi.fn() + }) + + const result = await coordinator.install() + + expect(result.ok).toBe(false) + expect(result.reason).toBe('unavailable') + expect(harness.install).not.toHaveBeenCalled() + }) +}) diff --git a/test/main/plugin/pluginCatalog.test.ts b/test/main/plugin/pluginCatalog.test.ts new file mode 100644 index 0000000000..e8a9ce9f9c --- /dev/null +++ b/test/main/plugin/pluginCatalog.test.ts @@ -0,0 +1,276 @@ +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('fs') +vi.unmock('node:fs') +vi.unmock('path') +vi.unmock('node:path') + +import { + PluginCatalogService, + PLUGIN_CATALOG_FILE_NAME, + PLUGIN_CATALOG_OVERRIDE_ENV +} from '@/plugin/catalog' +import type { PluginCatalogArtifact } from '@shared/types/pluginCatalog' +import { parsePluginCatalog } from '@shared/contracts/routes' + +const tempDirs: string[] = [] + +async function createTempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'deepchat-plugin-catalog-')) + tempDirs.push(dir) + return dir +} + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs.length = 0 +}) + +function writeCatalogFile(dir: string, content: unknown): string { + const resourcesDir = path.join(dir, 'resources') + fs.mkdirSync(resourcesDir, { recursive: true }) + const filePath = path.join(resourcesDir, PLUGIN_CATALOG_FILE_NAME) + fs.writeFileSync(filePath, JSON.stringify(content), 'utf8') + return filePath +} + +function createArtifact(overrides: Partial = {}): PluginCatalogArtifact { + return { + pluginId: 'com.deepchat.plugins.example', + version: '1.0.0', + channel: 'stable', + displayName: 'Example', + targets: [ + { + platform: 'darwin', + arch: 'arm64', + url: 'https://example.com/plugin.dcplugin', + sha256: 'a'.repeat(64), + size: 1234, + mirrors: [] + } + ], + ...overrides + } +} + +describe('parsePluginCatalog', () => { + it('accepts a valid catalog', () => { + const catalog = parsePluginCatalog({ + schemaVersion: 1, + artifacts: [createArtifact()] + }) + expect(catalog.artifacts).toHaveLength(1) + expect(catalog.artifacts[0].pluginId).toBe('com.deepchat.plugins.example') + }) + + it('rejects an unknown schema version', () => { + expect(() => parsePluginCatalog({ schemaVersion: 2, artifacts: [] })).toThrow( + /Invalid plugin catalog/ + ) + }) + + it('rejects an invalid sha256 pin', () => { + expect(() => + parsePluginCatalog({ + schemaVersion: 1, + artifacts: [ + createArtifact({ + targets: [ + { + platform: 'darwin', + arch: 'arm64', + url: 'https://example.com/plugin.dcplugin', + sha256: 'not-a-hash', + size: 10, + mirrors: [] + } + ] + }) + ] + }) + ).toThrow(/Invalid plugin catalog/) + }) + + it('rejects an invalid mirror protocol', () => { + expect(() => + parsePluginCatalog({ + schemaVersion: 1, + artifacts: [ + createArtifact({ + targets: [ + { + platform: 'darwin', + arch: 'arm64', + url: 'https://example.com/plugin.dcplugin', + sha256: 'a'.repeat(64), + size: 10, + mirrors: ['ftp://mirror.example.com/'] + } + ] + }) + ] + }) + ).toThrow(/Invalid plugin catalog/) + }) +}) + +describe('PluginCatalogService', () => { + it('returns an empty catalog when the catalog file is missing', () => { + const service = new PluginCatalogService({ + appPath: os.tmpdir(), + isPackaged: false, + platform: 'darwin', + arch: 'arm64', + appVersion: '1.0.0', + env: {} + }) + expect(service.getCatalog().artifacts).toHaveLength(0) + }) + + it('resolves the artifact matching the current platform and arch', async () => { + const dir = await createTempDir() + writeCatalogFile(dir, { + schemaVersion: 1, + artifacts: [ + createArtifact({ pluginId: 'plugin.a' }), + createArtifact({ + pluginId: 'plugin.b', + targets: [ + { + platform: 'win32', + arch: 'x64', + url: 'https://example.com/b.dcplugin', + sha256: 'b'.repeat(64), + size: 10, + mirrors: [] + } + ] + }) + ] + }) + const service = new PluginCatalogService({ + appPath: dir, + isPackaged: false, + platform: 'darwin', + arch: 'arm64', + appVersion: '1.0.0', + env: {} + }) + + expect(service.resolveArtifact('plugin.a')?.artifact.pluginId).toBe('plugin.a') + expect(service.resolveArtifact('plugin.b')).toBeNull() + expect(service.listVisibleArtifacts().map((a) => a.pluginId)).toEqual(['plugin.a', 'plugin.b']) + }) + + it('hides pre-release entries from packaged builds', async () => { + const dir = await createTempDir() + writeCatalogFile(dir, { + schemaVersion: 1, + artifacts: [ + createArtifact({ pluginId: 'plugin.stable' }), + createArtifact({ pluginId: 'plugin.rc', channel: 'pre-release' }) + ] + }) + + const packaged = new PluginCatalogService({ + resourcesPath: path.join(dir, 'resources'), + isPackaged: true, + platform: 'darwin', + arch: 'arm64', + appVersion: '1.0.0', + env: { [PLUGIN_CATALOG_OVERRIDE_ENV]: '/should/be/ignored.json' } + }) + expect(packaged.listVisibleArtifacts().map((a) => a.pluginId)).toEqual(['plugin.stable']) + expect(packaged.resolveArtifact('plugin.rc')).toBeNull() + + const dev = new PluginCatalogService({ + appPath: dir, + isPackaged: false, + platform: 'darwin', + arch: 'arm64', + appVersion: '1.0.0', + env: {} + }) + expect(dev.listVisibleArtifacts().map((a) => a.pluginId)).toEqual([ + 'plugin.stable', + 'plugin.rc' + ]) + }) + + it('gates artifacts by minAppVersion', async () => { + const dir = await createTempDir() + writeCatalogFile(dir, { + schemaVersion: 1, + artifacts: [ + createArtifact({ pluginId: 'plugin.new', minAppVersion: '2.0.0' }), + createArtifact({ pluginId: 'plugin.old', minAppVersion: '0.5.0' }) + ] + }) + const service = new PluginCatalogService({ + appPath: dir, + isPackaged: false, + platform: 'darwin', + arch: 'arm64', + appVersion: '1.0.0', + env: {} + }) + + expect(service.resolveArtifact('plugin.new')).toBeNull() + expect(service.describeAvailability(service.listVisibleArtifacts()[0]).availability).toBe( + 'incompatible-app' + ) + expect(service.resolveArtifact('plugin.old')?.artifact.pluginId).toBe('plugin.old') + }) + + it('loads an overridden catalog from the env hook in dev builds only', async () => { + const dir = await createTempDir() + const overridePath = path.join(dir, 'override-catalog.json') + await writeFile( + overridePath, + JSON.stringify({ + schemaVersion: 1, + artifacts: [createArtifact({ pluginId: 'plugin.override', version: '9.9.9' })] + }), + 'utf8' + ) + writeCatalogFile(dir, { + schemaVersion: 1, + artifacts: [createArtifact({ pluginId: 'plugin.bundled' })] + }) + + const dev = new PluginCatalogService({ + appPath: dir, + isPackaged: false, + platform: 'darwin', + arch: 'arm64', + appVersion: '1.0.0', + env: { [PLUGIN_CATALOG_OVERRIDE_ENV]: overridePath } + }) + expect(dev.listVisibleArtifacts().map((a) => a.pluginId)).toEqual(['plugin.override']) + }) + + it('describes unsupported platforms', async () => { + const dir = await createTempDir() + writeCatalogFile(dir, { + schemaVersion: 1, + artifacts: [createArtifact({ pluginId: 'plugin.a' })] + }) + const service = new PluginCatalogService({ + appPath: dir, + isPackaged: false, + platform: 'linux', + arch: 'arm64', + appVersion: '1.0.0', + env: {} + }) + expect(service.describeAvailability(service.listVisibleArtifacts()[0])).toEqual({ + availability: 'unsupported-platform', + target: null + }) + }) +}) diff --git a/test/main/plugin/pluginCatalogScript.test.ts b/test/main/plugin/pluginCatalogScript.test.ts new file mode 100644 index 0000000000..77006c5e92 --- /dev/null +++ b/test/main/plugin/pluginCatalogScript.test.ts @@ -0,0 +1,186 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { unzipSync, zipSync } from 'fflate' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('fs') +vi.unmock('node:fs') +vi.unmock('path') +vi.unmock('node:path') + +import { PluginCatalogSchema } from '@shared/contracts/routes' + +const execFileAsync = promisify(execFile) + +const tempDirs: string[] = [] + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs.length = 0 +}) + +async function createTempDir(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'deepchat-plugin-catalog-script-')) + tempDirs.push(dir) + return dir +} + +function createPluginPackageBytes(pluginId: string): Uint8Array { + const manifest = { + id: pluginId, + name: 'Fixture Runtime', + version: '0.2.3', + publisher: 'DeepChat', + engines: { deepchat: '>=0.2.3', platforms: ['darwin', 'win32', 'linux'] }, + activationEvents: ['onEnable'], + capabilities: ['mcp.register'], + source: { + type: 'deepchat-official', + url: 'https://github.com/ThinkInAIXYZ/deepchat/releases/download/v0.2.3/x.dcplugin', + publisher: 'DeepChat' + } + } + const files: Record = { + 'plugin.json': new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`) + } + files['checksums.json'] = new TextEncoder().encode( + JSON.stringify({ + 'plugin.json': createHash('sha256').update(Buffer.from(files['plugin.json'])).digest('hex') + }) + ) + return zipSync(files) +} + +async function seedOcrRuntimeDirs(root: string): Promise { + const runtimeDir = path.join(root, 'runtime') + await mkdir(path.join(runtimeDir, 'ocr', 'native'), { recursive: true }) + await mkdir(path.join(runtimeDir, 'ocr', 'facade'), { recursive: true }) + await mkdir(path.join(runtimeDir, 'ocr', 'runtime'), { recursive: true }) + await mkdir(path.join(runtimeDir, 'ocr', 'bundle'), { recursive: true }) + await mkdir(path.join(root, 'out', 'main'), { recursive: true }) + await writeFile( + path.join(runtimeDir, 'ocr', 'manifest.json'), + JSON.stringify({ + schemaVersion: 3, + supported: true, + platform: 'darwin', + arch: 'arm64', + facadeVersion: '0.5.7', + runtimeVersion: '0.1.7', + modelVersion: '0.3.4', + nativeVersion: '0.5.7', + pdfSupport: true, + bundleId: 'ppocrv6-small-native-test', + nativePayloadEncoding: 'gzip-base64-v1', + nativePackage: '@arcships/light-ocr-darwin-arm64', + paths: { + helper: 'out/main/lightOcrHelper.js', + facade: 'runtime/ocr/facade', + runtime: 'runtime/ocr/runtime', + bundle: 'runtime/ocr/bundle', + native: 'runtime/ocr/native' + } + }) + ) + await writeFile(path.join(runtimeDir, 'ocr', 'native', 'engine.bin'), 'engine') + await writeFile(path.join(runtimeDir, 'ocr', 'facade', 'index.cjs'), 'facade') + await writeFile(path.join(runtimeDir, 'ocr', 'runtime', 'index.cjs'), 'runtime') + await writeFile( + path.join(runtimeDir, 'ocr', 'package.json'), + JSON.stringify({ name: '@arcships/light-ocr-model-fixture', version: '0.3.4' }) + ) + await writeFile(path.join(runtimeDir, 'ocr', 'bundle', 'manifest.json'), '{"bundleId":"x"}') + await writeFile(path.join(root, 'out', 'main', 'lightOcrHelper.js'), 'helper') +} + +describe('plugin-catalog.mjs generate', () => { + it('produces a catalog the app schema accepts, with platform/arch pins', async () => { + const root = await createTempDir() + const artifactsDir = path.join(root, 'artifacts') + await mkdir(artifactsDir, { recursive: true }) + await writeFile( + path.join(artifactsDir, 'deepchat-plugin-fixture-0.2.3-darwin-arm64.dcplugin'), + Buffer.from(createPluginPackageBytes('com.deepchat.plugins.fixture')) + ) + await seedOcrRuntimeDirs(root) + const catalogPath = path.join(root, 'plugin-catalog.json') + + // Loopback http is allowed so fixture-driven flows work without TLS. + await execFileAsync('node', [ + 'scripts/plugin-catalog.mjs', + 'generate', + '--artifacts-dir', + artifactsDir, + '--runtime-dir', + path.join(root, 'runtime'), + '--base-url', + 'https://github.com/ThinkInAIXYZ/deepchat/releases/download/v1.1.2', + '--mirror', + 'https://mirror.example.com/', + '--catalog', + catalogPath, + '--write' + ]) + + const generated = JSON.parse(fs.readFileSync(catalogPath, 'utf8')) as unknown + // The generated catalog must satisfy the same zod contract the app + // enforces when loading it. + const catalog = PluginCatalogSchema.parse(generated) + + expect(catalog.artifacts).toHaveLength(1) + expect(catalog.artifacts[0].targets[0]).toMatchObject({ + platform: 'darwin', + arch: 'arm64', + sha256: expect.stringMatching(/^[a-f0-9]{64}$/) + }) + expect(catalog.runtimeAssets).toHaveLength(1) + expect(catalog.runtimeAssets?.[0].targets[0]).toMatchObject({ + platform: 'darwin', + arch: 'arm64' + }) + // The OCR payload zip is emitted next to the plugin artifacts. + const packaged = fs.readdirSync(artifactsDir).filter((name) => name.endsWith('.zip')) + expect(packaged).toEqual(['light-ocr-ppocrv6-small-native-test-darwin-arm64.zip']) + // The payload carries the full closure the runtime resolver validates: + // the manifest, the helper, and every package directory the manifest + // references — not just the runtime/ocr subtree. + const payloadEntries = Object.keys( + unzipSync(new Uint8Array(fs.readFileSync(path.join(artifactsDir, packaged[0])))) + ).sort() + expect(payloadEntries).toEqual([ + 'out/main/lightOcrHelper.js', + 'runtime/ocr/bundle/manifest.json', + 'runtime/ocr/facade/index.cjs', + 'runtime/ocr/manifest.json', + 'runtime/ocr/native/engine.bin', + 'runtime/ocr/package.json', + 'runtime/ocr/runtime/index.cjs' + ]) + }) + + it('rejects plain http base urls for remote hosts', async () => { + const root = await createTempDir() + const artifactsDir = path.join(root, 'artifacts') + await mkdir(artifactsDir, { recursive: true }) + const catalogPath = path.join(root, 'plugin-catalog.json') + + await expect( + execFileAsync('node', [ + 'scripts/plugin-catalog.mjs', + 'generate', + '--artifacts-dir', + artifactsDir, + '--base-url', + 'http://example.com/releases', + '--catalog', + catalogPath, + '--write' + ]) + ).rejects.toThrow(/https/) + }) +}) diff --git a/test/main/plugin/pluginRemoteInstaller.test.ts b/test/main/plugin/pluginRemoteInstaller.test.ts new file mode 100644 index 0000000000..726174225d --- /dev/null +++ b/test/main/plugin/pluginRemoteInstaller.test.ts @@ -0,0 +1,275 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { mkdtemp, rm } from 'node:fs/promises' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.unmock('fs') +vi.unmock('node:fs') +vi.unmock('path') +vi.unmock('node:path') + +import { PluginRemoteInstaller } from '@/plugin/remoteInstaller' +import { resetProbeCacheForTests, type FetchLike } from '@/toolchains/downloader' +import type { + PluginCatalogArtifact, + PluginCatalogInstallState, + PluginCatalogTarget +} from '@shared/types/pluginCatalog' + +const tempDirs: string[] = [] +let stagingRootPath = '' + +async function createStagingRoot(): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'deepchat-plugin-installer-')) + tempDirs.push(dir) + stagingRootPath = path.join(dir, 'staging') + fs.mkdirSync(stagingRootPath, { recursive: true }) + return stagingRootPath +} + +afterEach(async () => { + await Promise.all(tempDirs.map((dir) => rm(dir, { recursive: true, force: true }))) + tempDirs.length = 0 + resetProbeCacheForTests() + vi.restoreAllMocks() +}) + +function createArtifactAndTarget(overrides: { url?: string; mirrors?: string[] }): { + artifact: PluginCatalogArtifact + target: PluginCatalogTarget +} { + const artifact: PluginCatalogArtifact = { + pluginId: 'com.deepchat.plugins.example', + version: '1.0.0', + channel: 'stable', + targets: [ + { + platform: 'darwin', + arch: 'arm64', + url: overrides.url ?? 'https://example.com/plugin.dcplugin', + sha256: 'a'.repeat(64), + size: 100, + mirrors: overrides.mirrors ?? [] + } + ] + } + return { artifact, target: artifact.targets[0] } +} + +function sha256(content: string): string { + return createHash('sha256').update(content).digest('hex') +} + +type InstallerHarness = { + installer: PluginRemoteInstaller + installCalls: string[] + states: PluginCatalogInstallState[] +} + +function createHarness(options: { + fetchImpl?: FetchLike + installPackage?: ( + packagePath: string, + expectedPluginId: string + ) => Promise<{ pluginId: string; version: string }> +}): InstallerHarness { + const states: PluginCatalogInstallState[] = [] + const installCalls: string[] = [] + const installer = new PluginRemoteInstaller({ + stagingRoot: () => stagingRootPath, + installPackage: + options.installPackage ?? + (async (packagePath: string, expectedPluginId: string) => { + installCalls.push(packagePath) + void expectedPluginId + return { + pluginId: 'com.deepchat.plugins.example', + version: '1.0.0' + } + }), + fetchImpl: options.fetchImpl, + probeTimeoutMs: 250, + onProgress: (state) => states.push(state) + }) + return { installer, installCalls, states } +} + +function fetchServingContent(content: string, failingUrls: Set = new Set()): FetchLike { + return async (url: string, init?: RequestInit) => { + if (failingUrls.has(url)) { + return new Response('not found', { status: 404 }) + } + // Mirror requests carry the canonical URL as a path suffix; the response + // content is the same regardless of which candidate serves it. + void init + return new Response(content, { status: 200 }) + } +} + +describe('PluginRemoteInstaller', () => { + it('downloads, verifies and installs through the install callback', async () => { + await createStagingRoot() + const content = 'plugin-package-bytes' + const { artifact, target } = createArtifactAndTarget({}) + target.sha256 = sha256(content) + const harness = createHarness({ fetchImpl: fetchServingContent(content) }) + + const result = await harness.installer.install(artifact, target) + + expect(result.ok).toBe(true) + expect(harness.installCalls).toHaveLength(1) + const phases = harness.states.map((state) => state.phase) + expect(phases).toContain('downloading') + expect(phases[phases.length - 1]).toBe('installed') + // Staging directory is cleaned up after a successful install. + expect(fs.readdirSync(stagingRootPath)).toHaveLength(0) + }) + + it('rejects an artifact whose bytes do not match the pinned sha256', async () => { + await createStagingRoot() + const content = 'plugin-package-bytes' + const { artifact, target } = createArtifactAndTarget({}) + target.sha256 = '0'.repeat(64) + const harness = createHarness({ fetchImpl: fetchServingContent(content) }) + + const result = await harness.installer.install(artifact, target) + + expect(result.ok).toBe(false) + expect(result.reason).toBe('checksum_mismatch') + expect(harness.installCalls).toHaveLength(0) + expect(harness.installer.getInstallState(artifact.pluginId)?.phase).toBe('error') + expect(fs.readdirSync(stagingRootPath)).toHaveLength(0) + }) + + it('prefers the fastest successful candidate and falls back to mirrors', async () => { + await createStagingRoot() + const content = 'mirrored-bytes' + const directUrl = 'https://direct.example.com/plugin.dcplugin' + const mirrorPrefix = 'https://mirror.example.com/proxy/' + const { artifact, target } = createArtifactAndTarget({ + url: directUrl, + mirrors: [mirrorPrefix] + }) + target.sha256 = sha256(content) + + // The direct URL fails both probe and download; the mirror serves. + const failing = new Set([directUrl]) + const harness = createHarness({ fetchImpl: fetchServingContent(content, failing) }) + + const result = await harness.installer.install(artifact, target) + + expect(result.ok).toBe(true) + expect(harness.installCalls).toHaveLength(1) + }) + + it('still attempts the direct url when every probe fails', async () => { + await createStagingRoot() + const content = 'fallback-bytes' + const directUrl = 'https://slow-probe.example.com/plugin.dcplugin' + const { artifact, target } = createArtifactAndTarget({ url: directUrl }) + target.sha256 = sha256(content) + + let downloadAttempts = 0 + const fetchImpl: FetchLike = async (url: string, init?: RequestInit) => { + const isProbe = init?.headers && 'Range' in (init.headers as Record) + if (isProbe) { + return new Response('not found', { status: 404 }) + } + downloadAttempts += 1 + expect(url).toBe(directUrl) + return new Response(content, { status: 200 }) + } + const harness = createHarness({ fetchImpl }) + + const result = await harness.installer.install(artifact, target) + + expect(result.ok).toBe(true) + expect(downloadAttempts).toBe(1) + }) + + it('reports cancelled when the download is aborted', async () => { + await createStagingRoot() + const { artifact, target } = createArtifactAndTarget({}) + // Each fetch call serves a fresh stream that stalls after one chunk and + // errors when the request signal aborts, mirroring undici behavior. + const fetchImpl: FetchLike = (_url: string, init?: RequestInit) => + new Response( + new ReadableStream({ + start(streamController) { + streamController.enqueue(new TextEncoder().encode('partial')) + init?.signal?.addEventListener( + 'abort', + () => + streamController.error(new DOMException('The operation was aborted', 'AbortError')), + { once: true } + ) + } + }), + { status: 200 } + ) + const harness = createHarness({ fetchImpl }) + + const controller = new AbortController() + const installPromise = harness.installer.install(artifact, target, { + signal: controller.signal + }) + + await vi.waitFor(() => { + expect(harness.installer.getInstallState(artifact.pluginId)?.phase).toBe('downloading') + }) + controller.abort() + const result = await installPromise + + expect(result.ok).toBe(false) + expect(result.reason).toBe('cancelled') + expect(harness.installer.getInstallState(artifact.pluginId)?.phase).toBe('cancelled') + expect(harness.installCalls).toHaveLength(0) + }) + + it('rejects a second install while one is running', async () => { + await createStagingRoot() + const fetchImpl: FetchLike = async () => + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('partial')) + } + }), + { status: 200 } + ) + const harness = createHarness({ fetchImpl }) + const { artifact, target } = createArtifactAndTarget({}) + + const first = harness.installer.install(artifact, target) + const second = await harness.installer.install(artifact, target) + + expect(second.ok).toBe(false) + expect(second.reason).toBe('busy') + harness.installer.cancel(artifact.pluginId) + const firstResult = await first + expect(firstResult.ok).toBe(false) + expect(firstResult.reason).toBe('cancelled') + }) + + it('fails when the installed package declares a different plugin id', async () => { + await createStagingRoot() + const content = 'package-bytes' + const { artifact, target } = createArtifactAndTarget({}) + target.sha256 = sha256(content) + const harness = createHarness({ + fetchImpl: fetchServingContent(content), + installPackage: async () => ({ + pluginId: 'com.deepchat.plugins.other', + version: '1.0.0' + }) + }) + + const result = await harness.installer.install(artifact, target) + + expect(result.ok).toBe(false) + expect(result.error).toContain('com.deepchat.plugins.other') + expect(harness.installer.getInstallState(artifact.pluginId)?.phase).toBe('error') + }) +}) diff --git a/test/main/plugin/pluginRoutes.test.ts b/test/main/plugin/pluginRoutes.test.ts new file mode 100644 index 0000000000..2106c9bc2c --- /dev/null +++ b/test/main/plugin/pluginRoutes.test.ts @@ -0,0 +1,251 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createRendererRouteContext } from '@/routes/routeRegistry' +import { createPluginRoutes } from '@/plugin/routes' +import type { PluginServicePort } from '@/plugin' +import type { PluginCatalogService } from '@/plugin/catalog' +import type { PluginRemoteInstaller } from '@/plugin/remoteInstaller' +import type { PluginActionResult, PluginListItem } from '@shared/types/plugin' +import type { PluginCatalogArtifact, PluginCatalogTarget } from '@shared/types/pluginCatalog' + +/** + * `installedIds` are the plugins whose payload is present. `discoveredIds` + * defaults to the same set, but the two diverge for a bundled or + * development-tree manifest whose runtime binary was never downloaded. + */ +function createPluginServiceWithInstalled( + installedIds: Set, + discoveredIds: Set = installedIds +) { + const enableCalls: string[] = [] + return { + enableCalls, + service: { + listPlugins: vi.fn( + async (): Promise => + [...discoveredIds].map( + (id) => + ({ + id, + name: id, + version: '1.0.0', + publisher: 'DeepChat', + installed: installedIds.has(id), + enabled: false, + trusted: true, + trustState: 'trusted', + official: true, + capabilities: [] + }) as PluginListItem + ) + ), + isRuntimePayloadInstalled: vi.fn((pluginId: string) => installedIds.has(pluginId)), + getPlugin: vi.fn(async () => undefined), + enablePlugin: vi.fn(async (pluginId: string): Promise => { + enableCalls.push(pluginId) + if (!installedIds.has(pluginId)) { + // Mirrors PluginService.enablePlugin, which reports failures + // through the returned result instead of throwing. + return { + ok: false, + error: `Official plugin ${pluginId} is not available` + } + } + return { ok: true } + }), + disablePlugin: vi.fn(async (): Promise => ({ ok: true })), + invokeAction: vi.fn(async (): Promise => ({ ok: true })) + } as unknown as PluginServicePort + } +} + +function createDistribution(options: { pluginId?: string } = {}) { + const pluginId = options.pluginId ?? 'com.deepchat.plugins.cua' + const target: PluginCatalogTarget = { + platform: 'darwin', + arch: 'arm64', + url: 'https://example.com/plugin.dcplugin', + sha256: 'a'.repeat(64), + size: 100, + mirrors: [] + } + const artifact: PluginCatalogArtifact = { + pluginId, + version: '1.0.0', + channel: 'stable', + targets: [target] + } + const install = vi.fn(async () => ({ + ok: true, + pluginId, + version: '1.0.0', + reason: null, + error: null + })) + const catalog: Pick< + PluginCatalogService, + 'listVisibleArtifacts' | 'resolveArtifact' | 'describeAvailability' + > = { + listVisibleArtifacts: vi.fn(() => [artifact]), + resolveArtifact: vi.fn((id: string) => (id === pluginId ? { artifact, target } : null)), + describeAvailability: vi.fn(() => ({ availability: 'available' as const, target })) + } + const installer: Pick = { + install, + cancel: vi.fn(() => false), + getInstallState: vi.fn(() => null) + } + return { catalog, installer, install, pluginId, target, artifact } +} + +function invokeEnable( + routes: ReturnType, + pluginId: string +): Promise<{ result: PluginActionResult }> { + const handler = routes.get('plugins.enable') + if (!handler) throw new Error('plugins.enable handler is missing') + return handler({ pluginId }, createRendererRouteContext(1, null)) as Promise<{ + result: PluginActionResult + }> +} + +describe('plugin routes with remote distribution', () => { + it('installs a catalog-declared plugin before enabling it when missing locally', async () => { + const installed = new Set(['com.deepchat.plugins.feishu']) + const { service, enableCalls } = createPluginServiceWithInstalled(installed) + const distribution = createDistribution() + const routes = createPluginRoutes(service, { + catalog: distribution.catalog, + installer: distribution.installer + }) + + // The remote install registers the plugin, so the second enable succeeds. + distribution.install.mockImplementation(async () => { + installed.add(distribution.pluginId) + return { + ok: true, + pluginId: distribution.pluginId, + version: '1.0.0', + reason: null, + error: null + } + }) + + const response = await invokeEnable(routes, distribution.pluginId) + + expect(response.result.ok).toBe(true) + expect(distribution.install).toHaveBeenCalledOnce() + expect(enableCalls).toEqual([distribution.pluginId, distribution.pluginId]) + }) + + it('installs the payload of a discovered plugin whose runtime is missing', async () => { + const installed = new Set() + const discovered = new Set(['com.deepchat.plugins.cua']) + const { service } = createPluginServiceWithInstalled(installed, discovered) + const distribution = createDistribution() + // Enablement fails because the declared runtime has no binary on disk. + const enablePlugin = service.enablePlugin as unknown as ReturnType + enablePlugin.mockResolvedValueOnce({ + ok: false, + error: 'Runtime "CUA Driver" is not installed' + }) + enablePlugin.mockResolvedValueOnce({ ok: true }) + const routes = createPluginRoutes(service, { + catalog: distribution.catalog, + installer: distribution.installer + }) + + const response = await invokeEnable(routes, distribution.pluginId) + + expect(response.result.ok).toBe(true) + expect(distribution.install).toHaveBeenCalledOnce() + }) + + it('returns the original failure when the plugin is not in the catalog', async () => { + const { service } = createPluginServiceWithInstalled(new Set()) + const distribution = createDistribution({ pluginId: 'com.deepchat.plugins.other' }) + const routes = createPluginRoutes(service, { + catalog: distribution.catalog, + installer: distribution.installer + }) + + const response = await invokeEnable(routes, 'com.deepchat.plugins.unknown') + + expect(response.result.ok).toBe(false) + expect(response.result.error).toContain('not available') + expect(distribution.install).not.toHaveBeenCalled() + }) + + it('surfaces enablement failures unchanged for already-installed plugins', async () => { + const installed = new Set(['com.deepchat.plugins.cua']) + const { service } = createPluginServiceWithInstalled(installed) + const distribution = createDistribution() + // The plugin is installed; enablement fails for an unrelated reason. + // This is not a missing-plugin case, so no remote install may start. + const enablePlugin = service.enablePlugin as unknown as ReturnType + enablePlugin.mockResolvedValue({ ok: false, error: 'activation failed' }) + const routes = createPluginRoutes(service, { + catalog: distribution.catalog, + installer: distribution.installer + }) + + const response = await invokeEnable(routes, distribution.pluginId) + + expect(response.result.ok).toBe(false) + expect(response.result.error).toContain('activation failed') + expect(enablePlugin).toHaveBeenCalledOnce() + expect(distribution.install).not.toHaveBeenCalled() + }) + + it('reports a failed artifact download as a non-ok action result', async () => { + const { service } = createPluginServiceWithInstalled(new Set()) + const distribution = createDistribution() + distribution.install.mockResolvedValue({ + ok: false, + pluginId: distribution.pluginId, + version: '1.0.0', + reason: 'checksum_mismatch', + error: 'sha256 mismatch' + }) + const routes = createPluginRoutes(service, { + catalog: distribution.catalog, + installer: distribution.installer + }) + + const response = await invokeEnable(routes, distribution.pluginId) + + expect(response.result.ok).toBe(false) + expect(response.result.error).toContain('sha256 mismatch') + }) + + it('lists catalog entries merged with installed state', async () => { + const installed = new Set(['com.deepchat.plugins.cua']) + const { service } = createPluginServiceWithInstalled(installed) + const distribution = createDistribution() + const routes = createPluginRoutes(service, { + catalog: distribution.catalog, + installer: distribution.installer + }) + const handler = routes.get('plugins.catalog.list') + if (!handler) throw new Error('plugins.catalog.list handler is missing') + + const response = (await handler({}, createRendererRouteContext(1, null))) as unknown as { + entries: Array<{ + pluginId: string + installed: boolean + installedVersion: string | null + availability: string + sizeBytes: number | null + }> + } + + expect(response.entries).toHaveLength(1) + expect(response.entries[0]).toMatchObject({ + pluginId: distribution.pluginId, + installed: true, + installedVersion: '1.0.0', + availability: 'available', + sizeBytes: 100 + }) + }) +}) diff --git a/test/main/plugin/remoteDistribution.integration.test.ts b/test/main/plugin/remoteDistribution.integration.test.ts new file mode 100644 index 0000000000..88031b1873 --- /dev/null +++ b/test/main/plugin/remoteDistribution.integration.test.ts @@ -0,0 +1,347 @@ +import { createHash } from 'node:crypto' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { zipSync } from 'fflate' +import { app } from 'electron' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('electron-store', () => ({ + default: class MockElectronStore { + private data: Record + + constructor(options?: { defaults?: Record }) { + this.data = JSON.parse(JSON.stringify(options?.defaults ?? {})) + } + + get(key: string) { + return this.data[key] + } + + set(key: string, value: unknown) { + this.data[key] = value + } + } +})) + +vi.unmock('fs') +vi.unmock('node:fs') +vi.unmock('path') +vi.unmock('node:path') + +import { PluginService } from '@/plugin' +import { PluginSettingsWindow } from '@/desktop/pluginSettingsWindow' +import { + PluginCatalogService, + PLUGIN_CATALOG_OVERRIDE_ENV, + PLUGIN_CATALOG_FILE_NAME +} from '@/plugin/catalog' +import { PluginRemoteInstaller } from '@/plugin/remoteInstaller' +import { resetProbeCacheForTests, type FetchLike } from '@/toolchains/downloader' + +const tempRoots: string[] = [] + +beforeEach(async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'deepchat-plugin-l1-')) + tempRoots.push(root) +}) + +afterEach(async () => { + await Promise.all(tempRoots.map((dir) => rm(dir, { recursive: true, force: true }))) + tempRoots.length = 0 + resetProbeCacheForTests() + vi.restoreAllMocks() +}) + +function createFixturePackageBytes(pluginId: string): Uint8Array { + const manifest = { + id: pluginId, + name: 'Fixture Runtime', + version: '0.2.3', + publisher: 'DeepChat', + engines: { + deepchat: '>=0.2.3', + platforms: ['darwin', 'win32', 'linux'] + }, + activationEvents: ['onEnable'], + capabilities: ['mcp.register'], + source: { + type: 'deepchat-official', + url: 'https://github.com/ThinkInAIXYZ/deepchat/releases/download/v0.2.3/deepchat-plugin-fixture.dcplugin', + publisher: 'DeepChat' + }, + mcpServers: [ + { + id: 'fixture-runtime', + displayName: 'Fixture Runtime', + transport: 'stdio', + command: '${runtime.fixture-runtime.command}', + args: ['mcp'] + } + ] + } + const files: Record = { + 'plugin.json': new TextEncoder().encode(`${JSON.stringify(manifest, null, 2)}\n`), + 'skills/fixture/SKILL.md': new TextEncoder().encode('# Fixture skill\n') + } + const checksums = Object.fromEntries( + Object.entries(files).map(([filePath, content]) => [ + filePath, + createHash('sha256').update(Buffer.from(content)).digest('hex') + ]) + ) + files['checksums.json'] = new TextEncoder().encode(`${JSON.stringify(checksums, null, 2)}\n`) + return zipSync(files, { level: 6 }) +} + +async function createPluginServiceL1(root: string): Promise { + const appPath = path.join(root, 'app') + const userDataPath = path.join(root, 'userData') + await mkdir(appPath, { recursive: true }) + await mkdir(userDataPath, { recursive: true }) + vi.mocked(app.getPath).mockImplementation((name: string) => { + if (name === 'userData') return userDataPath + if (name === 'temp' || name === 'home') return root + return '/mock/path' + }) + const mcpSettings = { + getMcpServers: vi.fn().mockResolvedValue({}), + addMcpServer: vi.fn(), + updateMcpServer: vi.fn(), + removeMcpServer: vi.fn(), + getMcpEnabled: vi.fn().mockResolvedValue(true) + } + const mcpService = { + isReady: vi.fn(() => true), + isServerRunning: vi.fn().mockResolvedValue(false), + getServerLastError: vi.fn().mockReturnValue(undefined), + checkPluginRuntimePermissions: vi.fn().mockResolvedValue(undefined) + } + const runtimeSupervisor = { + attachSafetyStore: vi.fn(), + registerServer: vi.fn(), + commitPluginRegistration: vi.fn(), + unregisterPlugin: vi.fn(), + reconcilePlugin: vi.fn(), + testRuntime: vi.fn(), + retryRuntime: vi.fn(), + getState: vi.fn().mockReturnValue(undefined) + } + const skillService = { + registerPluginSkill: vi.fn().mockResolvedValue(undefined), + unregisterPluginSkillsByOwner: vi.fn().mockResolvedValue(undefined) + } + return new PluginService({ + platform: process.platform, + arch: process.arch, + appPath, + isPackaged: true, + resourcesPath: path.join(root, 'resources'), + mcpSettings: mcpSettings as never, + mcpService: mcpService as never, + runtimeSupervisor: runtimeSupervisor as never, + skillService: skillService as never, + settingsWindow: new PluginSettingsWindow() + } as never) +} + +describe('remote plugin distribution (L1 chain)', () => { + it('installs a catalog plugin from a remote artifact end to end', async () => { + const root = tempRoots[0] + const pluginId = 'com.deepchat.plugins.fixture' + const packageBytes = createFixturePackageBytes(pluginId) + const sha256 = createHash('sha256').update(Buffer.from(packageBytes)).digest('hex') + + // Catalog via the dev override hook (the same mechanism the L1 e2e uses). + const resourcesDir = path.join(root, 'resources') + await mkdir(resourcesDir, { recursive: true }) + const overrideCatalogPath = path.join(root, 'override-catalog.json') + await writeFile( + overrideCatalogPath, + JSON.stringify({ + schemaVersion: 1, + artifacts: [ + { + pluginId, + version: '0.2.3', + channel: 'stable', + displayName: 'Fixture Runtime', + targets: [ + { + platform: process.platform, + arch: process.arch, + url: 'http://127.0.0.1:9/fixture.dcplugin', + sha256, + size: packageBytes.length, + mirrors: [] + } + ] + } + ] + }), + 'utf8' + ) + fs.writeFileSync( + path.join(resourcesDir, PLUGIN_CATALOG_FILE_NAME), + JSON.stringify({ schemaVersion: 1, artifacts: [] }) + ) + const catalog = new PluginCatalogService({ + appPath: path.join(root, 'app'), + resourcesPath: resourcesDir, + isPackaged: false, + platform: process.platform, + arch: process.arch, + appVersion: '1.0.0', + env: { [PLUGIN_CATALOG_OVERRIDE_ENV]: overrideCatalogPath } + }) + + const resolution = catalog.resolveArtifact(pluginId) + expect(resolution).not.toBeNull() + + const pluginService = await createPluginServiceL1(root) + const progressPhases: string[] = [] + const installer = new PluginRemoteInstaller({ + stagingRoot: () => path.join(root, 'userData', 'plugins', '.staging'), + installPackage: (packagePath, expectedPluginId) => + pluginService.installOfficialPluginPackage(packagePath, expectedPluginId), + fetchImpl: (async () => + new Response(new Uint8Array(packageBytes), { status: 200 })) satisfies FetchLike, + probeTimeoutMs: 250, + onProgress: (state) => progressPhases.push(state.phase) + }) + + const result = await installer.install(resolution!.artifact, resolution!.target) + + expect(result.ok).toBe(true) + expect(progressPhases).toContain('downloading') + expect(progressPhases[progressPhases.length - 1]).toBe('installed') + + // The plugin is registered and visible through the normal list flow. + const plugins = await pluginService.listPlugins() + const installed = plugins.find((plugin) => plugin.id === pluginId) + expect(installed).toMatchObject({ version: '0.2.3', official: true, trusted: true }) + + // The payload is materialized in the install root with checksums intact. + const installRoot = path.join(root, 'userData', 'plugins') + const pluginDir = fs.readdirSync(installRoot).find((entry) => entry.includes('fixture')) + expect(pluginDir).toBeDefined() + const manifestPath = path.join(installRoot, pluginDir!, 'plugin.json') + expect(fs.existsSync(manifestPath)).toBe(true) + + // Staging is cleaned up. + expect(fs.readdirSync(path.join(installRoot, '.staging'))).toEqual([]) + }) + + it('leaves no installation behind when the artifact fails checksum verification', async () => { + const root = tempRoots[0] + const pluginId = 'com.deepchat.plugins.fixture' + const packageBytes = createFixturePackageBytes(pluginId) + + const pluginService = await createPluginServiceL1(root) + const installer = new PluginRemoteInstaller({ + stagingRoot: () => path.join(root, 'userData', 'plugins', '.staging'), + installPackage: (packagePath, expectedPluginId) => + pluginService.installOfficialPluginPackage(packagePath, expectedPluginId), + fetchImpl: (async () => + new Response(new Uint8Array(packageBytes), { status: 200 })) satisfies FetchLike, + probeTimeoutMs: 250 + }) + + const artifact = { + pluginId, + version: '0.2.3', + channel: 'stable' as const, + targets: [ + { + platform: process.platform, + arch: process.arch, + url: 'http://127.0.0.1:9/fixture.dcplugin', + sha256: '0'.repeat(64), + size: packageBytes.length, + mirrors: [] + } + ] + } + + const result = await installer.install(artifact, artifact.targets[0]) + + expect(result.ok).toBe(false) + expect(result.reason).toBe('checksum_mismatch') + const plugins = await pluginService.listPlugins() + expect(plugins.find((plugin) => plugin.id === pluginId)).toBeUndefined() + const installRoot = path.join(root, 'userData', 'plugins') + if (fs.existsSync(installRoot)) { + expect(fs.readdirSync(installRoot).filter((entry) => entry !== '.staging')).toEqual([]) + } + }) + + it('rejects a package declaring a different plugin id without installing it', async () => { + const root = tempRoots[0] + // The artifact package declares plugin B while the catalog entry is for A. + const packagedPluginId = 'com.deepchat.plugins.imposter' + const catalogPluginId = 'com.deepchat.plugins.fixture' + const packageBytes = createFixturePackageBytes(packagedPluginId) + + const pluginService = await createPluginServiceL1(root) + const installer = new PluginRemoteInstaller({ + stagingRoot: () => path.join(root, 'userData', 'plugins', '.staging'), + installPackage: (packagePath, expectedPluginId) => + pluginService.installOfficialPluginPackage(packagePath, expectedPluginId), + fetchImpl: (async () => + new Response(new Uint8Array(packageBytes), { status: 200 })) satisfies FetchLike, + probeTimeoutMs: 250 + }) + + const artifact = { + pluginId: catalogPluginId, + version: '0.2.3', + channel: 'stable' as const, + targets: [ + { + platform: process.platform, + arch: process.arch, + url: 'http://127.0.0.1:9/fixture.dcplugin', + sha256: createHash('sha256').update(Buffer.from(packageBytes)).digest('hex'), + size: packageBytes.length, + mirrors: [] + } + ] + } + + const result = await installer.install(artifact, artifact.targets[0]) + + expect(result.ok).toBe(false) + expect(result.error).toContain(packagedPluginId) + // Nothing was extracted or registered for either plugin id. + const plugins = await pluginService.listPlugins() + expect(plugins.find((plugin) => plugin.id === catalogPluginId)).toBeUndefined() + expect(plugins.find((plugin) => plugin.id === packagedPluginId)).toBeUndefined() + const installRoot = path.join(root, 'userData', 'plugins') + if (fs.existsSync(installRoot)) { + expect(fs.readdirSync(installRoot).filter((entry) => entry !== '.staging')).toEqual([]) + } + }) + + it('uninstalls an installed official plugin and removes its payload', async () => { + const root = tempRoots[0] + const pluginId = 'com.deepchat.plugins.fixture' + const packageBytes = createFixturePackageBytes(pluginId) + const packagePath = path.join(root, 'fixture.dcplugin') + fs.writeFileSync(packagePath, Buffer.from(packageBytes)) + + const pluginService = await createPluginServiceL1(root) + await pluginService.installOfficialPluginPackage(packagePath, pluginId) + const before = await pluginService.listPlugins() + expect(before.find((plugin) => plugin.id === pluginId)).toBeDefined() + const installRoot = path.join(root, 'userData', 'plugins') + expect(fs.readdirSync(installRoot).some((entry) => entry.includes('fixture'))).toBe(true) + + const result = await pluginService.uninstallOfficialPlugin(pluginId) + + expect(result.ok).toBe(true) + const after = await pluginService.listPlugins() + expect(after.find((plugin) => plugin.id === pluginId)).toBeUndefined() + expect(fs.readdirSync(installRoot).filter((entry) => entry !== '.staging')).toEqual([]) + }) +}) diff --git a/test/main/scripts/packageContract.test.ts b/test/main/scripts/packageContract.test.ts index aa11b9acf5..2a7fe0504d 100644 --- a/test/main/scripts/packageContract.test.ts +++ b/test/main/scripts/packageContract.test.ts @@ -46,6 +46,7 @@ import { verifyMacZipDistribution } from '../../../scripts/ci/package-manifest.mjs' import { prepareReleaseContext } from '../../../scripts/ci/release-preflight.mjs' +import { verifyCuaMacHelperUnbundled } from '../../../scripts/ci/verify-cua-macos-helper.mjs' vi.unmock('fs') vi.unmock('node:fs') @@ -1002,4 +1003,104 @@ describe('package manifest staging', () => { macDmgDistribution: 'passed' }) }) + + it('supports the unbundled cua/OCR distribution without smoke reports', async () => { + const { dmgName, zipName } = await prepareMacPackage() + const outputDirectory = path.join(tempDirectory, 'mac-unbundled-output') + const appDirectory = path.join(tempDirectory, 'unbundled-app') + await mkdir(appDirectory, { recursive: true }) + const verifyMacApp = vi.fn(async () => {}) + const verifyMacZip = vi.fn(async () => {}) + const verifyMacDmg = vi.fn(async () => {}) + + const manifest = await createPackageManifest({ + projectDirectory, + distDirectory, + outputDirectory, + platform: 'darwin', + arch: 'arm64', + sourceSha, + purpose: 'distribution', + reportPaths: [], + actualSourceSha: sourceSha, + macAppPath: appDirectory, + appleTeamId: 'Y7P5QLKLYG', + allowMissingLightOcrReports: true, + cuaUnbundled: true, + verifyMacApp, + verifyMacZip, + verifyMacDmg + }) + + expect(verifyMacZip).toHaveBeenCalledWith(path.join(outputDirectory, 'files', zipName), { + teamId: 'Y7P5QLKLYG', + verifyCuaMacHelper: verifyCuaMacHelperUnbundled + }) + expect(manifest.checks).toMatchObject({ + cuaMacHelperDistribution: 'passed', + packageSmoke: 'passed' + }) + expect(manifest.files.map((file) => file.name)).toContain(dmgName) + }) + + it('rejects the unbundled manifest when the helper still ships inside the app', async () => { + await prepareMacPackage() + const outputDirectory = path.join(tempDirectory, 'mac-unbundled-reject-output') + const appDirectory = path.join(tempDirectory, 'bundled-app') + const helperPath = path.join( + appDirectory, + 'Contents', + 'Helpers', + 'DeepChat Computer Use.app' + ) + await mkdir(path.dirname(helperPath), { recursive: true }) + await writeFile(helperPath, 'helper') + + await expect( + createPackageManifest({ + projectDirectory, + distDirectory, + outputDirectory, + platform: 'darwin', + arch: 'arm64', + sourceSha, + purpose: 'distribution', + reportPaths: [], + actualSourceSha: sourceSha, + macAppPath: appDirectory, + appleTeamId: 'Y7P5QLKLYG', + allowMissingLightOcrReports: true, + cuaUnbundled: true, + verifyMacApp: vi.fn(async () => {}), + verifyMacZip: vi.fn(async () => {}), + verifyMacDmg: vi.fn(async () => {}) + }) + ).rejects.toThrow(/must not ship inside the app/) + }) + + it('requires light OCR smoke reports unless the unbundled mode allows them missing', async () => { + await prepareMacPackage() + const outputDirectory = path.join(tempDirectory, 'mac-missing-smoke-output') + const macAppPath = path.join(tempDirectory, 'no-helper-app') + await mkdir(macAppPath, { recursive: true }) + + await expect( + createPackageManifest({ + projectDirectory, + distDirectory, + outputDirectory, + platform: 'darwin', + arch: 'arm64', + sourceSha, + purpose: 'distribution', + reportPaths: [], + actualSourceSha: sourceSha, + macAppPath, + appleTeamId: 'Y7P5QLKLYG', + verifyMacApp: vi.fn(async () => {}), + verifyMacZip: vi.fn(async () => {}), + verifyMacDmg: vi.fn(async () => {}) + }) + ).rejects.toThrow(/Missing Light OCR smoke report/) + }) }) diff --git a/test/main/scripts/packageWorkflow.test.ts b/test/main/scripts/packageWorkflow.test.ts index 8c1623a6cc..937b3738b4 100644 --- a/test/main/scripts/packageWorkflow.test.ts +++ b/test/main/scripts/packageWorkflow.test.ts @@ -266,6 +266,28 @@ describe('native package reusable workflows', () => { } }) + it('uploads the unbundled remote plugin packages for release staging', () => { + for (const definition of Object.values(reusableWorkflows)) { + const workflow = readWorkflow(definition.name) + const remotePlugins = getStep(workflow, 'Upload remote plugin packages') + expect(remotePlugins.if).toBe( + "env.DEEPCHAT_UNBUNDLE_CUA == '1' || env.DEEPCHAT_UNBUNDLE_OCR == '1'" + ) + expect(remotePlugins.with).toMatchObject({ + name: definition.artifact.replace('deepchat-package-', 'deepchat-remote-plugins-'), + path: [ + 'build/remote-plugins/*.dcplugin', + 'build/remote-plugins/*.zip', + 'build/remote-plugins/plugin-catalog.json', + '' + ].join('\n'), + 'if-no-files-found': 'warn', + 'retention-days': 7, + overwrite: true + }) + } + }) + it('derives macOS distribution evidence and disables identity discovery for verification', () => { const source = readWorkflowSource(reusableWorkflows.macos.name) const workflow = readWorkflow(reusableWorkflows.macos.name) diff --git a/test/renderer/components/OcrSettings.test.ts b/test/renderer/components/OcrSettings.test.ts index c32b78b3fe..26a98a3245 100644 --- a/test/renderer/components/OcrSettings.test.ts +++ b/test/renderer/components/OcrSettings.test.ts @@ -11,8 +11,11 @@ const AVAILABLE_STATUS: OcrRuntimeStatus = { lightOcrVersion: '0.5.5', bundleId: 'ppocrv6-small-native-20260719.1' }, + runtimeSource: 'bundled', process: null, - cache: null + cache: null, + runtimeInstall: null, + runtimeAsset: null } const SELECT_UPDATE_KEY = Symbol('select-update') @@ -63,7 +66,12 @@ async function setup( logicalBytes: 0, maxBytes: 256 * 1024 * 1024 } - }) + }), + installRuntime: vi.fn().mockResolvedValue({ result: { ok: true } }), + cancelRuntimeInstall: vi.fn().mockResolvedValue({ cancelled: false }), + installRuntimeFromPath: vi.fn().mockResolvedValue({ result: { ok: true } }), + uninstallRuntime: vi.fn().mockResolvedValue({ result: { ok: true } }), + onRuntimeInstallProgress: vi.fn().mockReturnValue(() => {}) } const resumePolling = vi.fn() const pausePolling = vi.fn() @@ -74,6 +82,11 @@ async function setup( vi.doMock('@api/SettingsClient', () => ({ createSettingsClient: () => settingsClient })) vi.doMock('@api/OcrClient', () => ({ createOcrClient: () => ocrClient })) + vi.doMock('@api/DeviceClient', () => ({ + createDeviceClient: () => ({ + selectFiles: vi.fn().mockResolvedValue({ canceled: true, filePaths: [] }) + }) + })) vi.doMock('@renderer-notifications/rendererNotificationPort', () => ({ notifyRenderer })) @@ -184,6 +197,65 @@ async function openDiagnostics(wrapper: Awaited>['wrapp } describe('OcrSettings', () => { + it('shows the bundled-ready runtime state without uninstall actions', async () => { + const { wrapper } = await setup() + + const card = wrapper.findComponent({ name: 'RuntimeInstallControls' }) + expect(card.exists()).toBe(true) + expect(card.props('installed')).toBe(false) + expect(card.props('ready')).toBe(true) + expect(wrapper.find('[data-testid="runtime-install-uninstall"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="runtime-install-download"]').exists()).toBe(false) + expect(wrapper.text()).not.toContain('settings.ocr.uninstallRuntimeTitle') + }) + + it('keeps download actions available when the runtime resolves from development', async () => { + const { wrapper } = await setup({ + ...AVAILABLE_STATUS, + runtimeSource: 'development' + }) + + const card = wrapper.findComponent({ name: 'RuntimeInstallControls' }) + expect(card.props('installed')).toBe(false) + expect(card.props('ready')).toBe(false) + const installNow = wrapper.find('[data-testid="runtime-install-download"]') + expect(installNow.exists()).toBe(true) + // No catalog entry in this fixture: the action stays visible but disabled. + expect(installNow.attributes('disabled')).toBeDefined() + expect(wrapper.find('[data-testid="runtime-install-manual"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="runtime-install-uninstall"]').exists()).toBe(false) + }) + + it('offers uninstall only while a downloaded runtime copy exists', async () => { + const downloadedStatus: OcrRuntimeStatus = { + ...AVAILABLE_STATUS, + runtimeSource: 'downloaded', + runtimeAsset: { + version: 'ppocrv6-small-native-20260719.1', + channel: 'stable', + availability: 'available', + sizeBytes: 12345, + installedVersion: 'ppocrv6-small-native-20260719.1' + } + } + const { wrapper, ocrClient, useIntervalFn } = await setup(downloadedStatus) + + const card = wrapper.findComponent({ name: 'RuntimeInstallControls' }) + expect(card.props('installed')).toBe(true) + expect(wrapper.find('[data-testid="runtime-install-uninstall"]').exists()).toBe(true) + + // After uninstall the service falls back to the bundled payload: the + // uninstall action must disappear instead of reporting ready-as-installed. + ocrClient.getRuntimeStatus.mockResolvedValue(AVAILABLE_STATUS) + const pollStatus = useIntervalFn.mock.calls[0]?.[0] as () => Promise + await pollStatus() + await flushPromises() + + const refreshed = wrapper.findComponent({ name: 'RuntimeInstallControls' }) + expect(refreshed.props('installed')).toBe(false) + expect(refreshed.props('ready')).toBe(true) + expect(wrapper.find('[data-testid="runtime-install-uninstall"]').exists()).toBe(false) + }) it('keeps healthy runtime details behind progressive disclosure', async () => { const { wrapper, settingsClient, ocrClient, resumePolling, useIntervalFn } = await setup() diff --git a/test/renderer/components/OfficialPluginDetailPage.test.ts b/test/renderer/components/OfficialPluginDetailPage.test.ts index a05802932d..961edd60e2 100644 --- a/test/renderer/components/OfficialPluginDetailPage.test.ts +++ b/test/renderer/components/OfficialPluginDetailPage.test.ts @@ -101,6 +101,7 @@ async function mountDetail( lastError?: string running?: boolean } + installed?: boolean pluginId?: string remoteEnabled?: boolean runtimeState?: 'missing' | 'installed' | 'running' | 'error' @@ -119,6 +120,8 @@ async function mountDetail( name: pluginName, publisher: 'DeepChat', version: '1.0.4', + // `installed` tracks the payload on disk, not mere discovery. + installed: options.installed ?? true, enabled: options.enabled ?? false, activationError: options.activationError, capabilities: ['runtime.manage'], @@ -144,6 +147,12 @@ async function mountDetail( getPlugin: vi.fn().mockResolvedValue(pluginRecord), enablePlugin: vi.fn().mockResolvedValue({ ok: true }), disablePlugin: vi.fn().mockResolvedValue({ ok: true }), + listCatalogEntries: vi.fn().mockResolvedValue([]), + installCatalogPlugin: vi.fn().mockResolvedValue({ ok: true }), + installCatalogPluginFromPath: vi.fn().mockResolvedValue({ ok: true }), + cancelCatalogInstall: vi.fn().mockResolvedValue(false), + uninstallOfficialPlugin: vi.fn().mockResolvedValue({ ok: true }), + onInstallProgress: vi.fn().mockReturnValue(() => {}), invokeAction: vi.fn().mockResolvedValue({ ok: true, status: { @@ -186,6 +195,11 @@ async function mountDetail( vi.doMock('@api/RemoteControlClient', () => ({ createRemoteControlClient: () => remoteControlClient })) + vi.doMock('@api/DeviceClient', () => ({ + createDeviceClient: () => ({ + selectFiles: vi.fn().mockResolvedValue({ canceled: true, filePaths: [] }) + }) + })) vi.doMock('vue-router', async () => { const actual = await vi.importActual('vue-router') return { @@ -406,6 +420,23 @@ describe('OfficialPluginDetailPage', () => { expect(wrapper.text()).not.toContain('Ready on demand') }) + it('blocks enablement while the payload is missing and no download exists', async () => { + const { wrapper, pluginClient } = await mountDetail({ + pluginId: 'com.deepchat.plugins.cua', + installed: false, + runtimeState: 'missing' + }) + + const enableButton = wrapper.findAll('button').find((button) => button.text() === 'Enable')! + + expect(enableButton.attributes('disabled')).toBeDefined() + + await enableButton.trigger('click') + await flushPromises() + + expect(pluginClient.enablePlugin).not.toHaveBeenCalled() + }) + it('uses the plugin enable button to start Feishu remote too', async () => { const { wrapper, pluginClient, remoteControlClient } = await mountDetail() diff --git a/test/renderer/components/PluginsCatalogPage.test.ts b/test/renderer/components/PluginsCatalogPage.test.ts index 9535c29e6e..841b07de14 100644 --- a/test/renderer/components/PluginsCatalogPage.test.ts +++ b/test/renderer/components/PluginsCatalogPage.test.ts @@ -78,7 +78,15 @@ async function mountCatalog(options?: { ocrStatus?: OcrRuntimeStatus | Error }) mcpServers: [] } ]), - enablePlugin: vi.fn().mockResolvedValue({ ok: true }) + enablePlugin: vi.fn().mockResolvedValue({ ok: true }), + listCatalogEntries: vi.fn().mockResolvedValue([]), + installCatalogPlugin: vi.fn().mockResolvedValue({ ok: true }), + cancelCatalogInstall: vi.fn().mockResolvedValue(false), + installCatalogPluginFromPath: vi + .fn() + .mockResolvedValue({ ok: true, pluginId: 'com.deepchat.plugins.fixture' }), + uninstallOfficialPlugin: vi.fn().mockResolvedValue({ ok: true }), + onInstallProgress: vi.fn().mockReturnValue(() => {}) } const remoteControlClient = { listRemoteChannels: vi.fn().mockResolvedValue([ @@ -115,6 +123,11 @@ async function mountCatalog(options?: { ocrStatus?: OcrRuntimeStatus | Error }) vi.doMock('@api/PluginClient', () => ({ createPluginClient: () => pluginClient })) + vi.doMock('@api/DeviceClient', () => ({ + createDeviceClient: () => ({ + selectFiles: vi.fn().mockResolvedValue({ canceled: true, filePaths: [] }) + }) + })) vi.doMock('@api/RemoteControlClient', () => ({ createRemoteControlClient: () => remoteControlClient })) diff --git a/test/renderer/stores/pluginCatalogStore.test.ts b/test/renderer/stores/pluginCatalogStore.test.ts index a92cd1263b..cf41a8ab10 100644 --- a/test/renderer/stores/pluginCatalogStore.test.ts +++ b/test/renderer/stores/pluginCatalogStore.test.ts @@ -47,7 +47,9 @@ const ocrStatus: OcrRuntimeStatus = { bundleId: 'ppocrv6-small-native-20260719.1' }, process: null, - cache: null + cache: null, + runtimeInstall: null, + runtimeAsset: null } describe('pluginCatalogStore', () => {