From 1609b57433134784b780bde7d0622e92a60a16a3 Mon Sep 17 00:00:00 2001 From: leookun Date: Sun, 30 Aug 2026 13:35:03 +0800 Subject: [PATCH] plugin runtime --- Cargo.lock | 22 + apps/desktop/src/App.tsx | 2 + .../plugins/PluginManagementPage.module.scss | 86 ++++ .../features/plugins/PluginManagementPage.tsx | 162 +++++++ apps/desktop/src/i18n/generated/catalog.json | 418 +++++++++++++++++- apps/desktop/src/i18n/locales/en-US.json | 28 ++ apps/desktop/src/i18n/locales/zh-CN.json | 28 ++ apps/desktop/src/shared/api.ts | 16 + apps/desktop/src/shared/store/appStore.ts | 40 +- apps/desktop/src/shared/ui/icons.ts | 2 + apps/desktop/src/shell/AppLayout.tsx | 6 +- server/Cargo.toml | 3 +- server/src/control/mod.rs | 7 + server/src/control/plugins.rs | 24 + server/src/control/service.rs | 15 + server/src/lib.rs | 1 + server/src/plugin/asset.rs | 88 ++++ server/src/plugin/installation.rs | 268 +++++++++++ server/src/plugin/mod.rs | 6 + server/src/plugin/runtime.rs | 221 +++++++++ 20 files changed, 1417 insertions(+), 26 deletions(-) create mode 100644 apps/desktop/src/features/plugins/PluginManagementPage.module.scss create mode 100644 apps/desktop/src/features/plugins/PluginManagementPage.tsx create mode 100644 server/src/control/plugins.rs create mode 100644 server/src/plugin/asset.rs create mode 100644 server/src/plugin/installation.rs create mode 100644 server/src/plugin/mod.rs create mode 100644 server/src/plugin/runtime.rs diff --git a/Cargo.lock b/Cargo.lock index cc349c4..d13038d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1248,6 +1248,7 @@ dependencies = [ "uuid", "windows-sys 0.61.2", "x509-parser", + "zip", ] [[package]] @@ -1929,6 +1930,7 @@ checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" dependencies = [ "crc32fast", "miniz_oxide", + "zlib-rs", ] [[package]] @@ -9079,16 +9081,36 @@ checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1" dependencies = [ "arbitrary", "crc32fast", + "flate2", "indexmap 2.14.0", "memchr", + "zopfli", ] +[[package]] +name = "zlib-rs" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" + [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" +[[package]] +name = "zopfli" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249" +dependencies = [ + "bumpalo", + "crc32fast", + "log", + "simd-adler32", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx index a6786fa..7bf987e 100644 --- a/apps/desktop/src/App.tsx +++ b/apps/desktop/src/App.tsx @@ -8,6 +8,7 @@ import { CallsPage } from "./features/calls/CallsPage"; import { CallDetailsPage } from "./features/calls/CallDetailsPage"; import { CursorSettingsPage } from "./features/models/CursorSettingsPage"; import { HomePage } from "./features/home/HomePage"; +import { PluginManagementPage } from "./features/plugins/PluginManagementPage"; import { SettingsPage } from "./features/settings/SettingsPage"; import { useAppStore } from "./shared/store/appStore"; import { updateStore } from "./shared/store/updateStore"; @@ -23,6 +24,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.module.scss b/apps/desktop/src/features/plugins/PluginManagementPage.module.scss new file mode 100644 index 0000000..8381c4a --- /dev/null +++ b/apps/desktop/src/features/plugins/PluginManagementPage.module.scss @@ -0,0 +1,86 @@ +@use "../../styles/typography" as type; + +.page { + display: grid; + gap: 16px; +} + +.gate { + min-height: 250px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 9px; + color: var(--vscode-descriptionForeground); + text-align: center; + border: 1px dashed var(--vscode-sideBar-border); + border-radius: var(--oa-overlay-radius); + + strong { + color: var(--vscode-foreground); + font-size: type.$font-size-base; + } + + span { + max-width: 500px; + font-size: type.$font-size-xs; + } + + button { + margin-top: 6px; + } +} + +.runtimeDetails { + display: grid; + padding: 8px 16px; + + > div { + min-height: 38px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--vscode-sideBar-border); + + &:last-child { + border-bottom: 0; + } + } + + span { + color: var(--vscode-descriptionForeground); + font-size: type.$font-size-xs; + } + + .ready { + color: var(--vscode-testing-iconPassed, #73c991); + } +} + +.progressContent { + display: flex; + flex-direction: column; + gap: 12px; + + strong { + font-size: type.$font-size-base; + } + + progress { + width: 100%; + height: 8px; + accent-color: var(--vscode-progressBar-background); + } + + span { + color: var(--vscode-descriptionForeground); + font-size: type.$font-size-xs; + } + + .error { + color: var(--vscode-errorForeground); + overflow-wrap: anywhere; + } +} diff --git a/apps/desktop/src/features/plugins/PluginManagementPage.tsx b/apps/desktop/src/features/plugins/PluginManagementPage.tsx new file mode 100644 index 0000000..61fbdf3 --- /dev/null +++ b/apps/desktop/src/features/plugins/PluginManagementPage.tsx @@ -0,0 +1,162 @@ +import { useEffect, useRef, useState } from "react"; +import type { PluginRuntimePhase, PluginRuntimeStatus } from "../../shared/api"; +import { PageContent } from "../../shell/layout/PageContent"; +import { appStore, useAppStore } from "../../shared/store/appStore"; +import { Button } from "../../shared/ui/Button"; +import { Modal } from "../../shared/ui/Modal"; +import { TitledCard } from "../../shared/ui/TitledCard"; +import styles from "./PluginManagementPage.module.scss"; + +export function PluginManagementPage() { + const { pluginRuntime } = useAppStore(); + const [progressOpen, setProgressOpen] = useState(false); + const [starting, setStarting] = useState(false); + const cancelRequested = useRef(false); + + useEffect(() => { + if (!pluginRuntime) void appStore.refreshPluginRuntime(); + }, [pluginRuntime]); + + useEffect(() => { + if (pluginRuntime?.state !== "initializing") return; + if (!cancelRequested.current) setProgressOpen(true); + const timer = window.setInterval(() => void appStore.refreshPluginRuntime(), 300); + return () => window.clearInterval(timer); + }, [pluginRuntime?.state]); + + const initialize = async () => { + if (starting) return; + cancelRequested.current = false; + setStarting(true); + setProgressOpen(true); + const status = await appStore.initializePluginRuntime(); + setStarting(false); + if (!status) { + setProgressOpen(false); + } else if (cancelRequested.current && status.state === "initializing") { + void appStore.cancelPluginRuntimeInitialization(); + } + }; + + const closeProgress = () => { + setProgressOpen(false); + cancelRequested.current = true; + if (pluginRuntime?.state === "initializing") { + void appStore.cancelPluginRuntimeInitialization(); + } + }; + + const content = pluginRuntime?.state === "ready" + ? + : void initialize()} />; + + return <> + + + ; +} + +function RuntimeGate({ status, starting, onInitialize }: { status: PluginRuntimeStatus | null; starting: boolean; onInitialize: () => void }) { + const checking = status === null; + const initializing = starting || status?.state === "initializing"; + const failed = status?.state === "failed"; + const unsupported = status?.state === "unsupported"; + const title = checking + ? t("正在检查插件运行时") + : failed + ? t("插件运行时初始化失败") + : unsupported + ? t("当前系统不支持插件运行时") + : t("需要先初始化插件运行时"); + const description = failed + ? t("请重试初始化") + : unsupported + ? status.error ?? t("当前操作系统或 CPU 架构暂不受支持") + : t("初始化将下载并安装插件运行时。"); + + return
+ {title} + {description} + {!unsupported && } +
; +} + +function RuntimeReady({ status }: { status: PluginRuntimeStatus }) { + return
+ +
+
{t("状态")}{t("已就绪")}
+
{t("插件运行时版本")}{status.version}
+
{t("运行平台")}{status.target}
+
+
+
; +} + +function RuntimeProgressModal({ open, status, starting, onClose }: { open: boolean; status: PluginRuntimeStatus | null; starting: boolean; onClose: () => void }) { + const initializing = starting || status?.state === "initializing"; + const downloaded = status?.downloaded_bytes ?? 0; + const total = status?.total_bytes ?? null; + const percent = total && total > 0 ? Math.min(100, Math.round((downloaded / total) * 100)) : null; + const stage = status?.state === "ready" + ? t("插件运行时初始化完成") + : status?.state === "failed" + ? t("插件运行时初始化失败") + : phaseText(status?.phase ?? null); + + return +
+ {stage} + {status?.phase === "downloading" && <> + + + {total ? t("已下载 {downloaded} / {total}", { downloaded: formatBytes(downloaded), total: formatBytes(total) }) : t("已下载 {downloaded}", { downloaded: formatBytes(downloaded) })} + + } + {status?.state === "failed" && {t("请重试初始化")}} + {status?.state === "ready" && {t("插件运行时 {version} 已安装,可以开始使用插件。", { version: status.version })}} +
+
; +} + +function phaseText(phase: PluginRuntimePhase | null) { + switch (phase) { + case "checking": return t("正在检查插件运行时"); + case "downloading": return t("正在下载插件运行时"); + case "verifying": return t("正在验证插件运行时下载文件"); + case "installing": return t("正在安装插件运行时"); + case "validating": return t("正在验证插件运行时"); + default: return t("正在准备插件运行时"); + } +} + +function formatBytes(bytes: number) { + if (bytes < 1024) return `${bytes} B`; + const units = ["KB", "MB", "GB"]; + let value = bytes / 1024; + let unit = 0; + while (value >= 1024 && unit < units.length - 1) { + value /= 1024; + unit += 1; + } + return `${value < 10 ? value.toFixed(1) : value.toFixed(0)} ${units[unit]}`; +} diff --git a/apps/desktop/src/i18n/generated/catalog.json b/apps/desktop/src/i18n/generated/catalog.json index e78a3bc..bd3b035 100644 --- a/apps/desktop/src/i18n/generated/catalog.json +++ b/apps/desktop/src/i18n/generated/catalog.json @@ -124,6 +124,18 @@ } ] }, + "06619f339fa0ab46": { + "source": "正在准备插件运行时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 130, + "column": 21 + } + ] + }, "076832c1b2de22c3": { "source": "缓存写入:{tokens}", "kind": "template", @@ -752,6 +764,20 @@ } ] }, + "2400fbd0aeab9e13": { + "source": "已下载 {downloaded}", + "kind": "template", + "placeholders": [ + "downloaded" + ], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 114, + "column": 122 + } + ] + }, "24a0a24864454575": { "source": "已存在,跳过", "kind": "text", @@ -797,7 +823,7 @@ "refs": [ { "file": "shell/AppLayout.tsx", - "line": 238, + "line": 240, "column": 14 } ] @@ -883,12 +909,12 @@ }, { "file": "shell/AppLayout.tsx", - "line": 228, + "line": 230, "column": 20 }, { "file": "shell/AppLayout.tsx", - "line": 239, + "line": 241, "column": 20 } ] @@ -993,7 +1019,7 @@ "refs": [ { "file": "shell/AppLayout.tsx", - "line": 227, + "line": 229, "column": 14 } ] @@ -1046,6 +1072,23 @@ } ] }, + "35fcbd57d58a9394": { + "source": "插件管理", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 37, + "column": 14 + }, + { + "file": "shell/AppLayout.tsx", + "line": 80, + "column": 46 + } + ] + }, "36f33adaf0942634": { "source": "确认", "kind": "text", @@ -1058,7 +1101,7 @@ }, { "file": "shell/AppLayout.tsx", - "line": 240, + "line": 242, "column": 21 } ] @@ -1275,6 +1318,18 @@ } ] }, + "3e0b34ddc2121f7d": { + "source": "运行平台", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 81, + "column": 23 + } + ] + }, "3f6c25aa329163a4": { "source": "原接口路径会追加到此服务地址。", "kind": "text", @@ -1308,6 +1363,23 @@ "file": "features/models/CursorSettingsPage.tsx", "line": 262, "column": 80 + }, + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 102, + "column": 55 + } + ] + }, + "402495402ce333b1": { + "source": "重新初始化插件", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 70, + "column": 68 } ] }, @@ -1541,6 +1613,18 @@ } ] }, + "4a861200ad513a3c": { + "source": "初始化插件运行时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 100, + "column": 12 + } + ] + }, "4a8d6841b4023edf": { "source": "确认导入", "kind": "text", @@ -1553,6 +1637,18 @@ } ] }, + "4aca6a31090fe2b8": { + "source": "初始化中…", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 70, + "column": 46 + } + ] + }, "4b458e6e147221d7": { "source": "系统会根据请求协议自动追加标准端点路径。", "kind": "text", @@ -1577,7 +1673,7 @@ }, { "file": "shell/AppLayout.tsx", - "line": 200, + "line": 202, "column": 67 } ] @@ -1871,6 +1967,18 @@ } ] }, + "5c62e36c152dfc7c": { + "source": "插件运行时初始化完成", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 93, + "column": 7 + } + ] + }, "5d59857bf039cac9": { "source": "Cursor 助手 v{version}", "kind": "template", @@ -1904,7 +2012,7 @@ "refs": [ { "file": "shell/AppLayout.tsx", - "line": 244, + "line": 246, "column": 11 } ] @@ -1994,6 +2102,11 @@ "file": "features/calls/CallTable.tsx", "line": 16, "column": 15 + }, + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 79, + "column": 23 } ] }, @@ -2028,7 +2141,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 318, + "line": 333, "column": 43 } ] @@ -2280,12 +2393,12 @@ "refs": [ { "file": "shared/store/appStore.ts", - "line": 153, + "line": 177, "column": 23 }, { "file": "shared/store/appStore.ts", - "line": 160, + "line": 184, "column": 25 } ] @@ -2333,7 +2446,7 @@ "refs": [ { "file": "shared/api.ts", - "line": 313, + "line": 328, "column": 43 } ] @@ -2362,6 +2475,18 @@ } ] }, + "7c10d97162c96dbd": { + "source": "正在验证插件运行时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 129, + "column": 31 + } + ] + }, "7cea2f3c46565d29": { "source": "OpenAI 额外参数", "kind": "text", @@ -2388,7 +2513,7 @@ "refs": [ { "file": "App.tsx", - "line": 50, + "line": 52, "column": 19 } ] @@ -2448,11 +2573,23 @@ "refs": [ { "file": "shared/api.ts", - "line": 262, + "line": 275, "column": 21 } ] }, + "8213941f12320ce1": { + "source": "当前操作系统或 CPU 架构暂不受支持", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 63, + "column": 25 + } + ] + }, "83c4efccd9a6bf69": { "source": "连通性测试已取消:成功 {successful},失败 {failed}", "kind": "template", @@ -2468,6 +2605,21 @@ } ] }, + "83e8d0b7aff2b394": { + "source": "已下载 {downloaded} / {total}", + "kind": "template", + "placeholders": [ + "downloaded", + "total" + ], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 114, + "column": 20 + } + ] + }, "83fcfb4c1f2c1641": { "source": "获取模型", "kind": "text", @@ -2572,6 +2724,18 @@ } ] }, + "8911e4f1407d58cb": { + "source": "正在下载插件运行时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 126, + "column": 32 + } + ] + }, "89a101b809be7cfc": { "source": "系统会原样使用此地址,不追加或修改请求路径。", "kind": "text", @@ -2649,7 +2813,7 @@ "refs": [ { "file": "shell/AppLayout.tsx", - "line": 246, + "line": 248, "column": 16 } ] @@ -2757,6 +2921,18 @@ } ] }, + "945fb1c67eca8493": { + "source": "正在安装插件运行时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 128, + "column": 31 + } + ] + }, "946b3ffc02f026c0": { "source": "确定删除这个模型吗?", "kind": "text", @@ -2824,7 +3000,7 @@ "refs": [ { "file": "shell/AppLayout.tsx", - "line": 233, + "line": 235, "column": 11 } ] @@ -2856,6 +3032,23 @@ } ] }, + "9db205c6055bacc4": { + "source": "插件运行时初始化失败", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 56, + "column": 9 + }, + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 95, + "column": 9 + } + ] + }, "9e356080c56877f8": { "source": "已关闭静默启动", "kind": "text", @@ -2880,6 +3073,18 @@ } ] }, + "9ed11266ead88f5b": { + "source": "正在验证插件运行时下载文件", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 127, + "column": 30 + } + ] + }, "9f6fee1aba17a565": { "source": "语言", "kind": "text", @@ -3069,7 +3274,7 @@ "refs": [ { "file": "shell/AppLayout.tsx", - "line": 229, + "line": 231, "column": 21 } ] @@ -3176,6 +3381,18 @@ } ] }, + "ab27f80d046f3d7f": { + "source": "已就绪", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 79, + "column": 72 + } + ] + }, "ab9084a640fbb864": { "source": "全不选", "kind": "text", @@ -3281,12 +3498,12 @@ }, { "file": "shell/AppLayout.tsx", - "line": 260, + "line": 262, "column": 64 }, { "file": "shell/AppLayout.tsx", - "line": 260, + "line": 262, "column": 125 } ] @@ -3320,6 +3537,23 @@ } ] }, + "b254ff315d861346": { + "source": "请重试初始化", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 61, + "column": 7 + }, + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 117, + "column": 70 + } + ] + }, "b4411558b932266f": { "source": "上游类型", "kind": "text", @@ -3332,6 +3566,18 @@ } ] }, + "b4c9e08870d41aa2": { + "source": "需要先初始化插件运行时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 59, + "column": 11 + } + ] + }, "b502b1d414664337": { "source": "提示词:{tokens}", "kind": "template", @@ -3556,11 +3802,23 @@ "refs": [ { "file": "shell/AppLayout.tsx", - "line": 251, + "line": 253, "column": 24 } ] }, + "bda74b5674b6a57d": { + "source": "初始化插件", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 70, + "column": 83 + } + ] + }, "bf57afd709694b55": { "source": "概览时间范围", "kind": "text", @@ -3587,6 +3845,18 @@ } ] }, + "c0b3fbff51ccc40b": { + "source": "完成", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 102, + "column": 45 + } + ] + }, "c1e98892a77f7a19": { "source": "{count} 条/页", "kind": "template", @@ -3618,6 +3888,18 @@ } ] }, + "c54863655e879b36": { + "source": "当前系统不支持插件运行时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 58, + "column": 11 + } + ] + }, "c7ea2c9bc43134bd": { "source": "编辑模型", "kind": "text", @@ -3719,6 +4001,18 @@ } ] }, + "cb99f0138b032687": { + "source": "初始化将下载并安装插件运行时。", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 64, + "column": 9 + } + ] + }, "cea1aafe9416de7b": { "source": "请求头", "kind": "text", @@ -3905,6 +4199,32 @@ } ] }, + "d6e61888b07853ea": { + "source": "高级", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "shell/AppLayout.tsx", + "line": 79, + "column": 29 + } + ] + }, + "d766536c18e8e990": { + "source": "插件运行时 {version} 已安装,可以开始使用插件。", + "kind": "template", + "placeholders": [ + "version" + ], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 118, + "column": 44 + } + ] + }, "d86fa42c3848c680": { "source": "使用系统代理", "kind": "text", @@ -3929,7 +4249,7 @@ "refs": [ { "file": "shell/AppLayout.tsx", - "line": 186, + "line": 188, "column": 54 } ] @@ -4270,6 +4590,18 @@ } ] }, + "e7cef7b834f301e7": { + "source": "插件运行时版本", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 80, + "column": 23 + } + ] + }, "e825a2a42c22380e": { "source": "模型类型", "kind": "text", @@ -4451,6 +4783,18 @@ } ] }, + "f4f4c81a4d719711": { + "source": "插件运行时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 77, + "column": 24 + } + ] + }, "f4fa9f31ea2ae58d": { "source": "过去一年的 Token 用量日历", "kind": "text", @@ -4575,11 +4919,28 @@ } ] }, + "fad86bf65f72c747": { + "source": "下载进度", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 109, + "column": 23 + } + ] + }, "fb11aa6f29827095": { "source": "检查中…", "kind": "text", "placeholders": [], "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 70, + "column": 19 + }, { "file": "features/settings/AppLifecycleSettingsCard.tsx", "line": 160, @@ -4599,6 +4960,23 @@ } ] }, + "fc22d1ab9ac73c6f": { + "source": "正在检查插件运行时", + "kind": "text", + "placeholders": [], + "refs": [ + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 54, + "column": 7 + }, + { + "file": "features/plugins/PluginManagementPage.tsx", + "line": 125, + "column": 29 + } + ] + }, "fc3947ebe6b2177b": { "source": "默认 {defaultRate} / 计入创建 {reuseRate}", "kind": "template", diff --git a/apps/desktop/src/i18n/locales/en-US.json b/apps/desktop/src/i18n/locales/en-US.json index 921a806..cf5bee9 100644 --- a/apps/desktop/src/i18n/locales/en-US.json +++ b/apps/desktop/src/i18n/locales/en-US.json @@ -7,6 +7,7 @@ "051836569928a9f9": "Edit", "05468af47054d488": "Connectivity test for {model} succeeded ({duration} ms)", "0580e0a99a6f1afc": "Artifacts", + "06619f339fa0ab46": "Preparing the plugin runtime", "076832c1b2de22c3": "Cache write: {tokens}", "07879e064ae16542": "Estimated output: {tokens}", "07c657ed4747126e": "Anthropic extra parameters", @@ -50,6 +51,7 @@ "22d7895ea5fca72e": "By provider", "23ae7a90b1b9816d": "Clear scope", "23e49479e15e6770": "Version {version} is available", + "2400fbd0aeab9e13": "Downloaded {downloaded}", "24a0a24864454575": "Existing, skipped", "2555d6c7fbb7e070": "Enter a model ID directly or load models returned by the API.", "29585d7193539200": "Current version {version}", @@ -68,6 +70,7 @@ "2f7dec3be28d7597": "{count} selected", "2f9daa828907b93f": "Delete", "346ff60e6c7c5181": "Reading…", + "35fcbd57d58a9394": "Plugin management", "36f33adaf0942634": "Confirm", "37125ef2e1d707cb": "Server address or complete request URL, API Key, model name, display name, and note are required", "378bb0eec39fa8a2": "Last page", @@ -85,9 +88,11 @@ "3cfae5728b92b334": "Token usage: {tokens}", "3d13868593ae4eeb": "Display language", "3da0bf1610ff5db5": "Recommended", + "3e0b34ddc2121f7d": "Runtime platform", "3f6c25aa329163a4": "The original endpoint path is appended to this service address.", "3fd118e2ffe0b2b6": "Cancel all tests", "3fd47edce45b3603": "Close", + "402495402ce333b1": "Reinitialize plugins", "40a08e7cf320ae07": "Clear detailed records?", "4125fc7ba333524c": "Default light", "42655ed8e4108ae2": "Input (non-cached)", @@ -104,7 +109,9 @@ "497c85690c4cc0fc": "No data", "499c729eb09aa2a6": "Context window tokens", "49be72e6045c007d": "Cancel test", + "4a861200ad513a3c": "Initialize plugin runtime", "4a8d6841b4023edf": "Confirm import", + "4aca6a31090fe2b8": "Initializing…", "4b458e6e147221d7": "The standard endpoint path is appended automatically for the selected protocol.", "4d0680f9efaef147": "Unread", "4e30d7c9ed2b0eee": "Not set", @@ -127,6 +134,7 @@ "5b17f59d33bde39e": "Error: {error}", "5ba65a74c4e792c5": "By type", "5c55a67935af8f45": "All", + "5c62e36c152dfc7c": "Plugin runtime initialized", "5d59857bf039cac9": "Cursor Assistant v{version}", "5f8d556a9c47da3c": "Launch at login disabled", "5f9acfb945229062": "Are you sure you no longer want to see this ad?", @@ -163,6 +171,7 @@ "7a2229f6a6d330a5": "Open a terminal from the desktop app to install the CA", "7a3cec4ca715de80": "Call statistics", "7ba2d6728fe2531b": "Confirm clear", + "7c10d97162c96dbd": "Validating the plugin runtime", "7cea2f3c46565d29": "OpenAI extra parameters", "7d9f043f8f7ab45c": "Version {version} is available in Settings", "7e0891860c9e6374": "TAB service address is required", @@ -170,7 +179,9 @@ "7f3c8312816fe26a": "Refreshing…", "7f68ebad19ba6bcd": "Check for updates", "811a3b22a5a7f2d5": "Unable to connect to the local management service", + "8213941f12320ce1": "This operating system or CPU architecture is not currently supported", "83c4efccd9a6bf69": "Connectivity test cancelled: {successful} succeeded, {failed} failed", + "83e8d0b7aff2b394": "Downloaded {downloaded} / {total}", "83fcfb4c1f2c1641": "Fetch models", "842b9f11cdd96bda": "Launch at login", "843ac7e15a5047a7": "Confirm legacy model configuration import", @@ -178,6 +189,7 @@ "86b7355ec3bd55ef": "Hide API Key", "8716e1344b0daddb": "Cursor official", "878a8ab176429a86": "View instructions", + "8911e4f1407d58cb": "Downloading the plugin runtime", "89a101b809be7cfc": "This address is used exactly as entered without changing or appending the request path.", "8a8542f6964852dc": "Next page", "8b6ff498515bcc2f": "Time", @@ -192,14 +204,17 @@ "91aaf184cfc17ffd": "Overview", "92156a483d4ba248": "Only request, response, and trace attachments are deleted; call summaries, metrics, and configuration are kept.", "940a168911ade998": "Items per page", + "945fb1c67eca8493": "Installing the plugin runtime", "946b3ffc02f026c0": "Delete this model?", "966498853d801a52": "TAB connection", "9850ed41a5bfbb0c": "{count} selected", "997ec8201c2adeda": "Open terminal to install CA", "9b1b7ed518ee401d": "This will open the tutorial in your system browser. Continue?", "9c41b3a9e12ac994": "Reasoning effort", + "9db205c6055bacc4": "Plugin runtime initialization failed", "9e356080c56877f8": "Silent start disabled", "9e46da6923836182": "For example: 2026-08-23 09:00, 1 hour ago", + "9ed11266ead88f5b": "Verifying the plugin runtime download", "9f6fee1aba17a565": "Language", "9fb48101d237ff96": "Last week", "a026f37e613cf48b": "Output Tokens", @@ -220,6 +235,7 @@ "a7617f42f898b2bf": "Use complete request URL", "a8036485f9227f2c": "Drag to reorder", "a98585871c5313ff": "Display name", + "ab27f80d046f3d7f": "Ready", "ab9084a640fbb864": "Deselect all", "abecab6701177721": "Launch at login enabled", "ac58d0f9a3f8d389": "Enter model notes", @@ -230,7 +246,9 @@ "aee88743413144a2": "Refresh", "b06325c5660f0c29": "Direct", "b16c3b2ecedd6fe1": "Cursor integration is active. Add a model configuration to use a BYOK model.", + "b254ff315d861346": "Try initializing again", "b4411558b932266f": "Provider type", + "b4c9e08870d41aa2": "Initialize the plugin runtime first", "b502b1d414664337": "Prompt: {tokens}", "b5141d3d19e9a048": "Yes", "b6725f218ebaef26": "Dock icon shown", @@ -247,10 +265,13 @@ "bb2b7736433ae867": "Cursor tracing", "bb7efdcb6af6e805": "Default dark", "bda62ce1d5e4ace9": "Tell us why", + "bda74b5674b6a57d": "Initialize plugins", "bf57afd709694b55": "Overview time range", "bfc01caf9fe0c841": "Cache hit rate {rate}", + "c0b3fbff51ccc40b": "Done", "c1e98892a77f7a19": "{count} per page", "c3760858cdb6d9f4": "Request body", + "c54863655e879b36": "Plugin runtime is not supported on this system", "c7ea2c9bc43134bd": "Edit model", "c8c14507b2d37395": "Reasoning effort", "c8df3c14a003bfcd": "Unable to load call details", @@ -258,6 +279,7 @@ "c9b9ae7a61444ab7": "Previous page", "c9d146d006993cc1": "Cache statistics policy: default ({rate})", "cb2f1709f983d2f4": "Model name", + "cb99f0138b032687": "Initialization downloads and installs the plugin runtime.", "cea1aafe9416de7b": "Request headers", "cfae1a14d2120c57": "Detailed mode", "cfe085015632e9c8": "The local management service port used by the desktop frontend. Enter 0 to select a random port at startup.", @@ -271,6 +293,8 @@ "d58c88688e1a949d": "Presets", "d60669bb26a22f5d": "Leave blank to use the default", "d6b1f203680f5496": "Leave blank to use adaptive thinking", + "d6e61888b07853ea": "Advanced", + "d766536c18e8e990": "Plugin runtime {version} is installed and ready to use.", "d86fa42c3848c680": "Use system proxy", "d8c47e9776cf1082": "Main menu", "da521d1c1cbd36af": "Authorization is required to install the certificate", @@ -299,6 +323,7 @@ "e59ae97924d62f01": "First page", "e5b9961a0d5242e3": "Port settings saved. Restart the app to apply them.", "e77e3d58b0dcffaa": "Duration", + "e7cef7b834f301e7": "Plugin runtime version", "e825a2a42c22380e": "Model type", "e828bd3a0151edc2": "The local CA must be trusted by the system", "e8b1268c1e3610f2": "Existing", @@ -313,6 +338,7 @@ "f2bdc88464c51c2e": "Show API Key", "f4694c46b1e19602": "Final request type", "f4dcb6a3ceb32247": "Page {page} of {count}", + "f4f4c81a4d719711": "Plugin runtime", "f4fa9f31ea2ae58d": "Token usage calendar for the past year", "f50276449943286c": "End time", "f69273dbbebfb3a1": "Format", @@ -323,8 +349,10 @@ "f9aa11dbb15ce647": "Saturday", "f9b55ca75425161b": "Response content was not recorded. Enable detailed records and try again.", "fa5b4b8a751c7d1b": "The local proxy port used by Cursor. Enter 0 to select a random port at startup.", + "fad86bf65f72c747": "Download progress", "fb11aa6f29827095": "Checking…", "fbe8778fa8b9bab5": "Initialize the local CA first", + "fc22d1ab9ac73c6f": "Checking the plugin runtime", "fc3947ebe6b2177b": "Default {defaultRate} / include creation {reuseRate}", "fcd311fd8ad42462": "Open model list", "fd415f8e0097c832": "Cache reads and writes are included in prompt-side statistics.", diff --git a/apps/desktop/src/i18n/locales/zh-CN.json b/apps/desktop/src/i18n/locales/zh-CN.json index e6e5f1e..b0bd884 100644 --- a/apps/desktop/src/i18n/locales/zh-CN.json +++ b/apps/desktop/src/i18n/locales/zh-CN.json @@ -7,6 +7,7 @@ "051836569928a9f9": "编辑", "05468af47054d488": "模型 {model} 连通性测试成功({duration} ms)", "0580e0a99a6f1afc": "工件数", + "06619f339fa0ab46": "正在准备插件运行时", "076832c1b2de22c3": "缓存写入:{tokens}", "07879e064ae16542": "输出推算:{tokens}", "07c657ed4747126e": "Anthropic 额外参数", @@ -50,6 +51,7 @@ "22d7895ea5fca72e": "按供应商", "23ae7a90b1b9816d": "清理范围", "23e49479e15e6770": "发现新版本 {version}", + "2400fbd0aeab9e13": "已下载 {downloaded}", "24a0a24864454575": "已存在,跳过", "2555d6c7fbb7e070": "可以直接输入模型标识,也可以读取接口返回的模型列表。", "29585d7193539200": "当前版本 {version}", @@ -68,6 +70,7 @@ "2f7dec3be28d7597": "已选择 {count} 个", "2f9daa828907b93f": "删除", "346ff60e6c7c5181": "读取中…", + "35fcbd57d58a9394": "插件管理", "36f33adaf0942634": "确认", "37125ef2e1d707cb": "服务器地址或完整请求 URL、API Key、模型名称、显示名称和备注不能为空", "378bb0eec39fa8a2": "最后一页", @@ -85,9 +88,11 @@ "3cfae5728b92b334": "Token 用量:{tokens}", "3d13868593ae4eeb": "界面语言", "3da0bf1610ff5db5": "推荐内容", + "3e0b34ddc2121f7d": "运行平台", "3f6c25aa329163a4": "原接口路径会追加到此服务地址。", "3fd118e2ffe0b2b6": "取消全部测试", "3fd47edce45b3603": "关闭", + "402495402ce333b1": "重新初始化插件", "40a08e7cf320ae07": "确定要清理详细记录吗?", "4125fc7ba333524c": "默认亮色", "42655ed8e4108ae2": "输入(非缓存)", @@ -104,7 +109,9 @@ "497c85690c4cc0fc": "暂无数据", "499c729eb09aa2a6": "上下文窗口 Token", "49be72e6045c007d": "取消测试", + "4a861200ad513a3c": "初始化插件运行时", "4a8d6841b4023edf": "确认导入", + "4aca6a31090fe2b8": "初始化中…", "4b458e6e147221d7": "系统会根据请求协议自动追加标准端点路径。", "4d0680f9efaef147": "未读", "4e30d7c9ed2b0eee": "不设置", @@ -127,6 +134,7 @@ "5b17f59d33bde39e": "错误:{error}", "5ba65a74c4e792c5": "按类型", "5c55a67935af8f45": "全部", + "5c62e36c152dfc7c": "插件运行时初始化完成", "5d59857bf039cac9": "Cursor 助手 v{version}", "5f8d556a9c47da3c": "已关闭开机启动", "5f9acfb945229062": "你确认不想再看到此广告吗?", @@ -163,6 +171,7 @@ "7a2229f6a6d330a5": "请在桌面应用中打开终端安装 CA", "7a3cec4ca715de80": "调用统计", "7ba2d6728fe2531b": "确认清理", + "7c10d97162c96dbd": "正在验证插件运行时", "7cea2f3c46565d29": "OpenAI 额外参数", "7d9f043f8f7ab45c": "发现新版本 {version},可在设置中安装", "7e0891860c9e6374": "TAB 服务地址不能为空", @@ -170,7 +179,9 @@ "7f3c8312816fe26a": "刷新中…", "7f68ebad19ba6bcd": "检查更新", "811a3b22a5a7f2d5": "无法连接本地管理服务", + "8213941f12320ce1": "当前操作系统或 CPU 架构暂不受支持", "83c4efccd9a6bf69": "连通性测试已取消:成功 {successful},失败 {failed}", + "83e8d0b7aff2b394": "已下载 {downloaded} / {total}", "83fcfb4c1f2c1641": "获取模型", "842b9f11cdd96bda": "开机启动", "843ac7e15a5047a7": "确认导入旧版模型配置", @@ -178,6 +189,7 @@ "86b7355ec3bd55ef": "隐藏 API Key", "8716e1344b0daddb": "Cursor 官方", "878a8ab176429a86": "查看说明", + "8911e4f1407d58cb": "正在下载插件运行时", "89a101b809be7cfc": "系统会原样使用此地址,不追加或修改请求路径。", "8a8542f6964852dc": "下一页", "8b6ff498515bcc2f": "时间", @@ -192,14 +204,17 @@ "91aaf184cfc17ffd": "数据概览", "92156a483d4ba248": "仅删除请求、响应和追踪附件等详细内容,保留调用汇总、统计指标和配置。", "940a168911ade998": "每页条数", + "945fb1c67eca8493": "正在安装插件运行时", "946b3ffc02f026c0": "确定删除这个模型吗?", "966498853d801a52": "TAB 选择", "9850ed41a5bfbb0c": "已选 {count} 项", "997ec8201c2adeda": "打开终端安装 CA", "9b1b7ed518ee401d": "将在系统浏览器中打开使用教程,是否继续?", "9c41b3a9e12ac994": "思考强度", + "9db205c6055bacc4": "插件运行时初始化失败", "9e356080c56877f8": "已关闭静默启动", "9e46da6923836182": "如:2026-08-23 09:00、1小时前", + "9ed11266ead88f5b": "正在验证插件运行时下载文件", "9f6fee1aba17a565": "语言", "9fb48101d237ff96": "近一周", "a026f37e613cf48b": "输出 Token", @@ -220,6 +235,7 @@ "a7617f42f898b2bf": "使用完整请求地址", "a8036485f9227f2c": "拖动排序", "a98585871c5313ff": "显示名称", + "ab27f80d046f3d7f": "已就绪", "ab9084a640fbb864": "全不选", "abecab6701177721": "已开启开机启动", "ac58d0f9a3f8d389": "请输入模型备注", @@ -230,7 +246,9 @@ "aee88743413144a2": "刷新", "b06325c5660f0c29": "直连", "b16c3b2ecedd6fe1": "Cursor 接管已生效;添加模型配置后即可使用 BYOK 模型。", + "b254ff315d861346": "请重试初始化", "b4411558b932266f": "上游类型", + "b4c9e08870d41aa2": "需要先初始化插件运行时", "b502b1d414664337": "提示词:{tokens}", "b5141d3d19e9a048": "是", "b6725f218ebaef26": "已显示 Dock 栏图标", @@ -247,10 +265,13 @@ "bb2b7736433ae867": "Cursor 追踪", "bb7efdcb6af6e805": "默认暗色", "bda62ce1d5e4ace9": "可以告诉我们原因", + "bda74b5674b6a57d": "初始化插件", "bf57afd709694b55": "概览时间范围", "bfc01caf9fe0c841": "缓存命中率 {rate}", + "c0b3fbff51ccc40b": "完成", "c1e98892a77f7a19": "{count} 条/页", "c3760858cdb6d9f4": "请求体", + "c54863655e879b36": "当前系统不支持插件运行时", "c7ea2c9bc43134bd": "编辑模型", "c8c14507b2d37395": "推理强度", "c8df3c14a003bfcd": "无法加载调用详情", @@ -258,6 +279,7 @@ "c9b9ae7a61444ab7": "上一页", "c9d146d006993cc1": "缓存统计策略:默认口径({rate})", "cb2f1709f983d2f4": "模型名称", + "cb99f0138b032687": "初始化将下载并安装插件运行时。", "cea1aafe9416de7b": "请求头", "cfae1a14d2120c57": "详细模式", "cfe085015632e9c8": "桌面前端连接的本地管理服务端口;填写 0 时启动时随机选择。", @@ -271,6 +293,8 @@ "d58c88688e1a949d": "常用预设", "d60669bb26a22f5d": "留空使用默认值", "d6b1f203680f5496": "留空使用 adaptive thinking", + "d6e61888b07853ea": "高级", + "d766536c18e8e990": "插件运行时 {version} 已安装,可以开始使用插件。", "d86fa42c3848c680": "使用系统代理", "d8c47e9776cf1082": "主菜单", "da521d1c1cbd36af": "需要授权安装证书", @@ -299,6 +323,7 @@ "e59ae97924d62f01": "第一页", "e5b9961a0d5242e3": "端口设置已保存,重启软件后生效", "e77e3d58b0dcffaa": "耗时", + "e7cef7b834f301e7": "插件运行时版本", "e825a2a42c22380e": "模型类型", "e828bd3a0151edc2": "需要在系统中信任本地 CA", "e8b1268c1e3610f2": "已存在", @@ -313,6 +338,7 @@ "f2bdc88464c51c2e": "显示 API Key", "f4694c46b1e19602": "最终请求类型", "f4dcb6a3ceb32247": "第 {page} / {count} 页", + "f4f4c81a4d719711": "插件运行时", "f4fa9f31ea2ae58d": "过去一年的 Token 用量日历", "f50276449943286c": "结束时间", "f69273dbbebfb3a1": "格式化", @@ -323,8 +349,10 @@ "f9aa11dbb15ce647": "周六", "f9b55ca75425161b": "未记录响应内容,请开启详细记录后重试。", "fa5b4b8a751c7d1b": "Cursor 使用的本地代理端口;填写 0 时启动时随机选择。", + "fad86bf65f72c747": "下载进度", "fb11aa6f29827095": "检查中…", "fbe8778fa8b9bab5": "需要先初始化本地 CA", + "fc22d1ab9ac73c6f": "正在检查插件运行时", "fc3947ebe6b2177b": "默认 {defaultRate} / 计入创建 {reuseRate}", "fcd311fd8ad42462": "打开模型列表", "fd415f8e0097c832": "缓存读写已计入提示词侧统计。", diff --git a/apps/desktop/src/shared/api.ts b/apps/desktop/src/shared/api.ts index 5a249f0..f0c3791 100644 --- a/apps/desktop/src/shared/api.ts +++ b/apps/desktop/src/shared/api.ts @@ -148,6 +148,19 @@ export interface DesktopSettings { show_dock_icon: boolean; } +export type PluginRuntimeState = "uninitialized" | "initializing" | "ready" | "failed" | "unsupported"; +export type PluginRuntimePhase = "checking" | "downloading" | "verifying" | "installing" | "validating"; + +export interface PluginRuntimeStatus { + state: PluginRuntimeState; + version: string; + target: string | null; + phase: PluginRuntimePhase | null; + downloaded_bytes: number; + total_bytes: number | null; + error: string | null; +} + export interface OverviewMetrics { llm_calls: number; successful_calls: number; @@ -309,6 +322,9 @@ export const api = { }, cursorHarness: () => request("/harness/cursor/status"), initializeCursorCa: () => request("/harness/cursor/ca/initialize", { method: "POST" }), + pluginRuntime: () => request("/plugins/runtime"), + initializePluginRuntime: () => request("/plugins/runtime", { method: "POST" }), + cancelPluginRuntimeInitialization: () => request("/plugins/runtime", { method: "DELETE" }), openCursorCaInstallTerminal: async (command: string) => { if (!packagedDesktop) throw new Error(t("请在桌面应用中打开终端安装 CA")); const { invoke } = await import("@tauri-apps/api/core"); diff --git a/apps/desktop/src/shared/store/appStore.ts b/apps/desktop/src/shared/store/appStore.ts index 9a6f192..e4f7d7f 100644 --- a/apps/desktop/src/shared/store/appStore.ts +++ b/apps/desktop/src/shared/store/appStore.ts @@ -1,5 +1,5 @@ import { useSyncExternalStore } from "react"; -import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PortSettings } from "../api"; +import { api, type CursorHarnessStatus, type LlmCall, type Model, type ModelInput, type Overview, type PluginRuntimeStatus, type PortSettings } from "../api"; import { applyTheme, isThemeId, type ThemeId } from "../theme/theme"; export type AppSnapshot = { @@ -13,6 +13,7 @@ export type AppSnapshot = { theme: ThemeId; cursorHarness: CursorHarnessStatus | null; cursorBusy: boolean; + pluginRuntime: PluginRuntimeStatus | null; }; const savedTheme = (): ThemeId => { @@ -45,6 +46,7 @@ let snapshot: AppSnapshot = { theme: savedTheme(), cursorHarness: null, cursorBusy: false, + pluginRuntime: null, }; const listeners = new Set<() => void>(); @@ -73,15 +75,16 @@ export const appStore = { async refresh() { update({ busy: true, error: null }); try { - const [models, calls, overview, settings, ports, cursorHarness] = await Promise.all([ + const [models, calls, overview, settings, ports, cursorHarness, pluginRuntime] = await Promise.all([ api.models(), api.calls(), api.overview(), api.observability(), api.ports(), api.cursorHarness(), + api.pluginRuntime(), ]); - update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness }); + update({ models, calls, overview, detailed: settings.detailed, ports, cursorHarness, pluginRuntime }); } catch (cause) { update({ error: cause instanceof Error ? cause.message : String(cause) }); } finally { @@ -107,6 +110,37 @@ export const appStore = { return null; } finally { update({ cursorBusy: false }); } }, + async initializePluginRuntime() { + update({ error: null }); + try { + const pluginRuntime = await api.initializePluginRuntime(); + update({ pluginRuntime }); + return pluginRuntime; + } catch (cause) { + update({ error: cause instanceof Error ? cause.message : String(cause) }); + return null; + } + }, + async refreshPluginRuntime() { + try { + const pluginRuntime = await api.pluginRuntime(); + update({ pluginRuntime }); + return pluginRuntime; + } catch (cause) { + update({ error: cause instanceof Error ? cause.message : String(cause) }); + return null; + } + }, + async cancelPluginRuntimeInitialization() { + try { + const pluginRuntime = await api.cancelPluginRuntimeInitialization(); + update({ pluginRuntime }); + return pluginRuntime; + } catch (cause) { + update({ error: cause instanceof Error ? cause.message : String(cause) }); + return null; + } + }, async setCursorEnabled(enabled: boolean) { update({ cursorBusy: true, error: null }); try { update({ cursorHarness: await api.setCursorEnabled(enabled) }); } diff --git a/apps/desktop/src/shared/ui/icons.ts b/apps/desktop/src/shared/ui/icons.ts index 55f077d..e8ed338 100644 --- a/apps/desktop/src/shared/ui/icons.ts +++ b/apps/desktop/src/shared/ui/icons.ts @@ -13,6 +13,8 @@ export const flatColorComboChartIcon = icon('', 48, 48); export const flatColorSettingsIcon = icon('', 48, 48); +export const flatColorBriefcaseIcon = icon('', 48, 48); // flat-color-icons:briefcase + export const claudeIcon = icon('', 256, 257); export const openAiIcon = icon(''); // Menu icons intentionally use filled glyphs from different collections so they diff --git a/apps/desktop/src/shell/AppLayout.tsx b/apps/desktop/src/shell/AppLayout.tsx index a67889f..966aaec 100644 --- a/apps/desktop/src/shell/AppLayout.tsx +++ b/apps/desktop/src/shell/AppLayout.tsx @@ -13,7 +13,7 @@ import { ConfirmDialog } from "../shared/ui/ConfirmDialog"; import controls from "../shared/ui/Controls.module.scss"; import { Icon } from "../shared/ui/Icon"; import { TooltipTrigger } from "../shared/ui/TooltipTrigger"; -import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons"; +import { flatColorAboutIcon, flatColorAreaChartIcon, flatColorBriefcaseIcon, flatColorSalesPerformanceIcon, flatColorSettingsIcon, refreshIcon } from "../shared/ui/icons"; import { useMessage } from "../shared/ui/message"; import { VirtualList } from "../shared/virtual/VirtualList"; import { useI18n } from "../i18n/store"; @@ -27,7 +27,7 @@ type MenuItem = | { kind: "external"; id: string; label: string; icon: IconifyIcon | string } | { kind: "group"; label: string }; -const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor"]; +const keptAlivePages = ["/", "/calls", "/settings", "/harness/cursor", "/plugins"]; const readAdStorageKey = "cursor-byok:read-ad-ids"; const dismissedAdStorageKey = "cursor-byok:dismissed-ad-ids"; const tutorialReadStorageKey = "cursor-byok:tutorial-read"; @@ -76,6 +76,8 @@ export function AppLayout() { { kind: "page", path: "/harness/cursor", label: t("Cursor 配置"), icon: cursorIconUrl }, { kind: "page", path: "/settings", label: t("系统设置"), icon: flatColorSettingsIcon }, { kind: "external", id: "tutorial", label: t("使用教程"), icon: flatColorAboutIcon }, + { kind: "group", label: t("高级") }, + { kind: "page", path: "/plugins", label: t("插件管理"), icon: flatColorBriefcaseIcon }, ]; const openTutorial = useCallback(() => { diff --git a/server/Cargo.toml b/server/Cargo.toml index 9be7524..7f1f7f0 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -48,7 +48,7 @@ similar = "2" sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] } thiserror = "2" time = "0.3" -tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "net"] } +tokio = { version = "1", features = ["fs", "io-util", "macros", "process", "rt-multi-thread", "signal", "sync", "time", "net"] } tokio-stream = { version = "0.1", features = ["sync"] } tokio-util = "0.7" tracing = "0.1" @@ -57,6 +57,7 @@ tower-http = { version = "0.6", features = ["cors", "decompression-gzip", "fs"] url = "2" uuid = { version = "1", features = ["v4"] } x509-parser = "0.18" +zip = { version = "4", default-features = false, features = ["deflate"] } [build-dependencies] prost-build = "0.13" protoc-bin-vendored = "3" diff --git a/server/src/control/mod.rs b/server/src/control/mod.rs index 6bb084d..ea4c925 100644 --- a/server/src/control/mod.rs +++ b/server/src/control/mod.rs @@ -4,6 +4,7 @@ mod calls; mod harness; mod models; mod overview; +mod plugins; mod service; mod settings; @@ -136,6 +137,12 @@ pub fn api_router(service: ControlService) -> Router { ) .route("/__byok-api__/api/llm-calls", get(calls::list)) .route("/__byok-api__/api/llm-calls/{call_id}", get(calls::detail)) + .route( + "/__byok-api__/api/plugins/runtime", + get(plugins::runtime_status) + .post(plugins::initialize_runtime) + .delete(plugins::cancel_runtime_initialization), + ) .route( "/__byok-api__/api/settings/observability", get(settings::get).put(settings::update), diff --git a/server/src/control/plugins.rs b/server/src/control/plugins.rs new file mode 100644 index 0000000..ae17cd5 --- /dev/null +++ b/server/src/control/plugins.rs @@ -0,0 +1,24 @@ +//! Exposes plugin runtime initialization and status endpoints. +use axum::{extract::State, Json}; + +use crate::{plugin::PluginRuntimeStatus, Result}; + +use super::ControlService; + +pub async fn runtime_status( + State(service): State, +) -> Result> { + Ok(Json(service.plugin_runtime_status())) +} + +pub async fn initialize_runtime( + State(service): State, +) -> Result> { + Ok(Json(service.initialize_plugin_runtime())) +} + +pub async fn cancel_runtime_initialization( + State(service): State, +) -> Result> { + Ok(Json(service.cancel_plugin_runtime_initialization())) +} diff --git a/server/src/control/service.rs b/server/src/control/service.rs index 7c6958a..3bc3198 100644 --- a/server/src/control/service.rs +++ b/server/src/control/service.rs @@ -25,6 +25,7 @@ use crate::{ ModelRequest, ModelSpec, ModelType, Overview, ProjectedContent, ProjectedMessage, PromptSpec, ProviderType, Role, }, + plugin::{PluginRuntime, PluginRuntimeStatus}, provider::{is_valid_response_event, ModelEvent, Provider}, store::{ DesktopSettings, PortSettings, ProxySettings, ProxySettingsInput, StatisticsStorage, Store, @@ -38,6 +39,7 @@ pub struct ControlService { store: Store, cursor_harness: CursorHarness, provider: Arc, + plugin_runtime: PluginRuntime, model_tests: Arc>>, } @@ -148,6 +150,7 @@ impl ControlService { cursor_harness: CursorHarness::new(store.clone())?, store, provider, + plugin_runtime: PluginRuntime::managed()?, model_tests: Arc::new(Mutex::new(BTreeMap::new())), }) } @@ -156,6 +159,18 @@ impl ControlService { &self.cursor_harness } + pub fn plugin_runtime_status(&self) -> PluginRuntimeStatus { + self.plugin_runtime.status() + } + + pub fn initialize_plugin_runtime(&self) -> PluginRuntimeStatus { + self.plugin_runtime.initialize(self.store.clone()) + } + + pub fn cancel_plugin_runtime_initialization(&self) -> PluginRuntimeStatus { + self.plugin_runtime.cancel_initialization() + } + pub(super) async fn ads( &self, disabled_ad_ids: Option<&str>, diff --git a/server/src/lib.rs b/server/src/lib.rs index fe5cc3c..fa10ddb 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -8,6 +8,7 @@ pub mod error; pub mod local_app; pub mod model; pub mod network; +pub mod plugin; pub mod provider; pub mod run; pub mod search; diff --git a/server/src/plugin/asset.rs b/server/src/plugin/asset.rs new file mode 100644 index 0000000..eefe68b --- /dev/null +++ b/server/src/plugin/asset.rs @@ -0,0 +1,88 @@ +//! Maps supported desktop platforms to pinned Deno release assets. +pub(super) const DENO_VERSION: &str = "2.9.6"; + +#[derive(Clone, Copy, Debug)] +pub(super) struct RuntimeAsset { + pub target: &'static str, + pub sha256: &'static str, +} + +impl RuntimeAsset { + pub fn current() -> Option { + Self::for_platform(std::env::consts::OS, std::env::consts::ARCH) + } + + pub(super) fn for_platform(os: &str, arch: &str) -> Option { + let (target, sha256) = match (os, arch) { + ("macos", "aarch64") => ( + "aarch64-apple-darwin", + "213a2f304f04d3c9cb5220669afad138f60a5aab1fe80962abdeb8f35807a472", + ), + ("macos", "x86_64") => ( + "x86_64-apple-darwin", + "7d4524b82bcc557fe020a1a5b56956ed42b992ae5b28026e8ad5d17329533f5f", + ), + ("windows", "aarch64") => ( + "aarch64-pc-windows-msvc", + "acb014afe2299847764e232b4993e162e3946cdeec36603e3f1a0b548cd1ea55", + ), + ("windows", "x86_64") => ( + "x86_64-pc-windows-msvc", + "15e5300b0ba3c3695a7621d90160a746ec9e710228cee639afa9d580f6e3cd11", + ), + ("linux", "aarch64") => ( + "aarch64-unknown-linux-gnu", + "9a46afc6c392c7cd2ff71a31558935545b46408d0e87f7a86908c712721c046e", + ), + ("linux", "x86_64") => ( + "x86_64-unknown-linux-gnu", + "394f07f4da2bebe6ce6f1e7ce0fa16429b29b08c35e3fac3fe25972676dff4b2", + ), + _ => return None, + }; + Some(Self { target, sha256 }) + } + + pub fn archive_name(self) -> String { + format!("deno-{}.zip", self.target) + } + + pub fn download_url(self) -> String { + format!( + "https://github.com/denoland/deno/releases/download/v{DENO_VERSION}/{}", + self.archive_name() + ) + } + + pub fn executable_name(self) -> &'static str { + if self.target.contains("windows") { + "deno.exe" + } else { + "deno" + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn maps_every_supported_desktop_target() { + let cases = [ + ("macos", "aarch64", "aarch64-apple-darwin"), + ("macos", "x86_64", "x86_64-apple-darwin"), + ("windows", "aarch64", "aarch64-pc-windows-msvc"), + ("windows", "x86_64", "x86_64-pc-windows-msvc"), + ("linux", "aarch64", "aarch64-unknown-linux-gnu"), + ("linux", "x86_64", "x86_64-unknown-linux-gnu"), + ]; + for (os, arch, expected) in cases { + assert_eq!( + RuntimeAsset::for_platform(os, arch).unwrap().target, + expected + ); + } + assert!(RuntimeAsset::for_platform("linux", "x86").is_none()); + } +} diff --git a/server/src/plugin/installation.rs b/server/src/plugin/installation.rs new file mode 100644 index 0000000..0c38ea4 --- /dev/null +++ b/server/src/plugin/installation.rs @@ -0,0 +1,268 @@ +//! Downloads, verifies, extracts, and validates a pinned Deno runtime. +use std::{ + io, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; + +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use tokio::io::AsyncWriteExt; +use tokio_util::sync::CancellationToken; + +use super::{ + asset::{RuntimeAsset, DENO_VERSION}, + runtime::PluginRuntimePhase, +}; +use crate::{network, store::Store, Error, Result}; + +const DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(10 * 60); +const VALIDATION_TIMEOUT: Duration = Duration::from_secs(15); +const MAX_ARCHIVE_BYTES: u64 = 128 * 1024 * 1024; + +pub(super) async fn install( + root: &Path, + store: &Store, + asset: RuntimeAsset, + cancellation: CancellationToken, + on_progress: impl Fn(PluginRuntimePhase, u64, Option), +) -> Result<()> { + let paths = RuntimePaths::new(root, asset); + tokio::fs::create_dir_all(&paths.download_dir).await?; + tokio::fs::create_dir_all(&paths.install_dir).await?; + remove_if_exists(&paths.archive).await?; + remove_if_exists(&paths.executable_staging).await?; + remove_if_exists(&paths.ready_marker).await?; + + let result = download_and_install(store, asset, &paths, &cancellation, &on_progress).await; + if result.is_err() { + let _ = remove_if_exists(&paths.archive).await; + let _ = remove_if_exists(&paths.executable_staging).await; + let _ = remove_if_exists(&paths.executable).await; + let _ = remove_if_exists(&paths.ready_marker).await; + } + result +} + +pub(super) fn runtime_complete(root: &Path, asset: RuntimeAsset) -> bool { + let paths = RuntimePaths::new(root, asset); + paths.executable.is_file() && paths.ready_marker.is_file() +} + +async fn download_and_install( + store: &Store, + asset: RuntimeAsset, + paths: &RuntimePaths, + cancellation: &CancellationToken, + on_progress: &impl Fn(PluginRuntimePhase, u64, Option), +) -> Result<()> { + ensure_not_cancelled(cancellation)?; + let client = network::client(store).await?; + let response = tokio::select! { + _ = cancellation.cancelled() => return Err(Error::Cancelled), + response = client + .get(asset.download_url()) + .timeout(DOWNLOAD_TIMEOUT) + .send() => response?, + } + .error_for_status()?; + let total_bytes = response.content_length(); + if total_bytes.is_some_and(|size| size > MAX_ARCHIVE_BYTES) { + return Err(Error::Config( + "Deno runtime archive is larger than allowed".into(), + )); + } + + on_progress(PluginRuntimePhase::Downloading, 0, total_bytes); + let mut archive = tokio::fs::File::create(&paths.archive).await?; + let mut hasher = Sha256::new(); + let mut downloaded_bytes = 0_u64; + let mut stream = response.bytes_stream(); + loop { + let next = tokio::select! { + _ = cancellation.cancelled() => return Err(Error::Cancelled), + next = stream.next() => next, + }; + let Some(chunk) = next else { break }; + let chunk = chunk?; + downloaded_bytes = downloaded_bytes.saturating_add(chunk.len() as u64); + if downloaded_bytes > MAX_ARCHIVE_BYTES { + return Err(Error::Config( + "Deno runtime archive is larger than allowed".into(), + )); + } + archive.write_all(&chunk).await?; + hasher.update(&chunk); + on_progress( + PluginRuntimePhase::Downloading, + downloaded_bytes, + total_bytes, + ); + } + archive.flush().await?; + archive.sync_all().await?; + drop(archive); + ensure_not_cancelled(cancellation)?; + + on_progress(PluginRuntimePhase::Verifying, downloaded_bytes, total_bytes); + let actual_hash = hex::encode(hasher.finalize()); + if actual_hash != asset.sha256 { + return Err(Error::Config(format!( + "Deno runtime checksum mismatch: expected {}, received {actual_hash}", + asset.sha256 + ))); + } + + on_progress( + PluginRuntimePhase::Installing, + downloaded_bytes, + total_bytes, + ); + let archive_path = paths.archive.clone(); + let staging_path = paths.executable_staging.clone(); + let executable_name = asset.executable_name(); + tokio::task::spawn_blocking(move || { + extract_runtime_archive(&archive_path, &staging_path, executable_name) + }) + .await + .map_err(|error| Error::Config(format!("Deno extraction task failed: {error}")))??; + ensure_not_cancelled(cancellation)?; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + tokio::fs::set_permissions( + &paths.executable_staging, + std::fs::Permissions::from_mode(0o700), + ) + .await?; + } + remove_if_exists(&paths.executable).await?; + tokio::fs::rename(&paths.executable_staging, &paths.executable).await?; + ensure_not_cancelled(cancellation)?; + + on_progress( + PluginRuntimePhase::Validating, + downloaded_bytes, + total_bytes, + ); + validate_runtime(&paths.executable, cancellation).await?; + ensure_not_cancelled(cancellation)?; + tokio::fs::write(&paths.ready_marker, format!("deno {DENO_VERSION}\n")).await?; + remove_if_exists(&paths.archive).await?; + tracing::info!( + version = DENO_VERSION, + target = asset.target, + path = %paths.executable.display(), + "plugin runtime initialized" + ); + Ok(()) +} + +struct RuntimePaths { + download_dir: PathBuf, + install_dir: PathBuf, + archive: PathBuf, + executable: PathBuf, + executable_staging: PathBuf, + ready_marker: PathBuf, +} + +impl RuntimePaths { + fn new(root: &Path, asset: RuntimeAsset) -> Self { + let download_dir = root.join(".downloads"); + let install_dir = root + .join("deno") + .join(format!("v{DENO_VERSION}")) + .join(asset.target); + let executable = install_dir.join(asset.executable_name()); + Self { + archive: download_dir.join(format!("{}.part", asset.archive_name())), + executable_staging: install_dir.join(format!("{}.part", asset.executable_name())), + ready_marker: install_dir.join(".ready"), + download_dir, + install_dir, + executable, + } + } +} + +fn extract_runtime_archive(archive: &Path, output: &Path, executable_name: &str) -> Result<()> { + let file = std::fs::File::open(archive)?; + let mut archive = zip::ZipArchive::new(file) + .map_err(|error| Error::Config(format!("invalid Deno runtime archive: {error}")))?; + let mut executable = archive + .by_name(executable_name) + .map_err(|error| Error::Config(format!("Deno executable missing from archive: {error}")))?; + let mut destination = std::fs::File::create(output)?; + io::copy(&mut executable, &mut destination)?; + destination.sync_all()?; + Ok(()) +} + +async fn validate_runtime(executable: &Path, cancellation: &CancellationToken) -> Result<()> { + let mut command = tokio::process::Command::new(executable); + command + .arg("--version") + .stdin(Stdio::null()) + .stderr(Stdio::piped()) + .stdout(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::select! { + _ = cancellation.cancelled() => return Err(Error::Cancelled), + result = tokio::time::timeout(VALIDATION_TIMEOUT, command.output()) => { + result.map_err(|_| Error::Config("Deno runtime validation timed out".into()))?? + } + }; + if !output.status.success() { + return Err(Error::Config(format!( + "Deno runtime validation failed: {}", + String::from_utf8_lossy(&output.stderr).trim() + ))); + } + let expected = format!("deno {DENO_VERSION}"); + let stdout = String::from_utf8_lossy(&output.stdout); + let version_line = stdout.lines().next().unwrap_or_default().trim(); + if version_line != expected && !version_line.starts_with(&format!("{expected} ")) { + return Err(Error::Config(format!( + "unexpected Deno runtime version: {version_line}" + ))); + } + Ok(()) +} + +fn ensure_not_cancelled(cancellation: &CancellationToken) -> Result<()> { + if cancellation.is_cancelled() { + Err(Error::Cancelled) + } else { + Ok(()) + } +} + +async fn remove_if_exists(path: &Path) -> Result<()> { + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn uses_versioned_runtime_directory() { + let root = PathBuf::from("/tmp/plugin-runtime"); + let asset = super::super::asset::RuntimeAsset::for_platform("macos", "aarch64").unwrap(); + let paths = RuntimePaths::new(&root, asset); + assert_eq!( + paths.executable, + root.join("deno") + .join(format!("v{DENO_VERSION}")) + .join(asset.target) + .join("deno") + ); + } +} diff --git a/server/src/plugin/mod.rs b/server/src/plugin/mod.rs new file mode 100644 index 0000000..20d06c5 --- /dev/null +++ b/server/src/plugin/mod.rs @@ -0,0 +1,6 @@ +//! Owns plugin runtime installation and lifecycle infrastructure. +mod asset; +mod installation; +mod runtime; + +pub use runtime::{PluginRuntime, PluginRuntimePhase, PluginRuntimeState, PluginRuntimeStatus}; diff --git a/server/src/plugin/runtime.rs b/server/src/plugin/runtime.rs new file mode 100644 index 0000000..842007f --- /dev/null +++ b/server/src/plugin/runtime.rs @@ -0,0 +1,221 @@ +//! Tracks Deno runtime readiness and coordinates one initialization task. +use std::{ + path::PathBuf, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +use parking_lot::{Mutex, RwLock}; +use serde::Serialize; +use tokio_util::sync::CancellationToken; + +use super::{ + asset::{RuntimeAsset, DENO_VERSION}, + installation, +}; +use crate::{config, store::Store, Error, Result}; + +#[derive(Clone)] +pub struct PluginRuntime { + inner: Arc, +} + +struct PluginRuntimeInner { + root: PathBuf, + asset: Option, + status: RwLock, + initializing: AtomicBool, + cancellation: Mutex>, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PluginRuntimeState { + Uninitialized, + Initializing, + Ready, + Failed, + Unsupported, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PluginRuntimePhase { + Checking, + Downloading, + Verifying, + Installing, + Validating, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +pub struct PluginRuntimeStatus { + pub state: PluginRuntimeState, + pub version: String, + pub target: Option, + pub phase: Option, + pub downloaded_bytes: u64, + pub total_bytes: Option, + pub error: Option, +} + +impl PluginRuntimeStatus { + fn uninitialized(asset: RuntimeAsset) -> Self { + Self::new(PluginRuntimeState::Uninitialized, Some(asset)) + } + + fn ready(asset: RuntimeAsset) -> Self { + Self::new(PluginRuntimeState::Ready, Some(asset)) + } + + fn unsupported() -> Self { + Self { + state: PluginRuntimeState::Unsupported, + version: DENO_VERSION.into(), + target: None, + phase: None, + downloaded_bytes: 0, + total_bytes: None, + error: Some(format!( + "unsupported platform: {}/{}", + std::env::consts::OS, + std::env::consts::ARCH + )), + } + } + + fn new(state: PluginRuntimeState, asset: Option) -> Self { + Self { + state, + version: DENO_VERSION.into(), + target: asset.map(|value| value.target.into()), + phase: None, + downloaded_bytes: 0, + total_bytes: None, + error: None, + } + } +} + +impl PluginRuntime { + pub fn managed() -> Result { + Self::new(config::managed_data_dir()?.join("plugins").join("runtime")) + } + + fn new(root: PathBuf) -> Result { + std::fs::create_dir_all(&root)?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&root, std::fs::Permissions::from_mode(0o700))?; + } + let asset = RuntimeAsset::current(); + let status = match asset { + Some(asset) if installation::runtime_complete(&root, asset) => { + PluginRuntimeStatus::ready(asset) + } + Some(asset) => PluginRuntimeStatus::uninitialized(asset), + None => PluginRuntimeStatus::unsupported(), + }; + Ok(Self { + inner: Arc::new(PluginRuntimeInner { + root, + asset, + status: RwLock::new(status), + initializing: AtomicBool::new(false), + cancellation: Mutex::new(None), + }), + }) + } + + pub fn status(&self) -> PluginRuntimeStatus { + let mut status = self.inner.status.write(); + if status.state == PluginRuntimeState::Ready { + if let Some(asset) = self.inner.asset { + if !installation::runtime_complete(&self.inner.root, asset) { + *status = PluginRuntimeStatus::uninitialized(asset); + } + } + } + status.clone() + } + + pub fn initialize(&self, store: Store) -> PluginRuntimeStatus { + let Some(asset) = self.inner.asset else { + return self.status(); + }; + if installation::runtime_complete(&self.inner.root, asset) { + let ready = PluginRuntimeStatus::ready(asset); + *self.inner.status.write() = ready.clone(); + return ready; + } + if self + .inner + .initializing + .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) + .is_err() + { + return self.status(); + } + + let mut initializing = + PluginRuntimeStatus::new(PluginRuntimeState::Initializing, Some(asset)); + initializing.phase = Some(PluginRuntimePhase::Checking); + *self.inner.status.write() = initializing.clone(); + + let cancellation = CancellationToken::new(); + *self.inner.cancellation.lock() = Some(cancellation.clone()); + let runtime = self.clone(); + tokio::spawn(async move { + let result = installation::install( + &runtime.inner.root, + &store, + asset, + cancellation, + |phase, downloaded, total| { + runtime.update_progress(phase, downloaded, total); + }, + ) + .await; + let status = match result { + Ok(()) => PluginRuntimeStatus::ready(asset), + Err(Error::Cancelled) => PluginRuntimeStatus::uninitialized(asset), + Err(error) => { + tracing::error!(%error, target = asset.target, "plugin runtime initialization failed"); + let mut failed = + PluginRuntimeStatus::new(PluginRuntimeState::Failed, Some(asset)); + failed.error = Some("plugin runtime initialization failed".into()); + failed + } + }; + *runtime.inner.status.write() = status; + runtime.inner.cancellation.lock().take(); + runtime.inner.initializing.store(false, Ordering::Release); + }); + + initializing + } + + pub fn cancel_initialization(&self) -> PluginRuntimeStatus { + if let Some(cancellation) = self.inner.cancellation.lock().as_ref() { + cancellation.cancel(); + } + self.status() + } + + fn update_progress( + &self, + phase: PluginRuntimePhase, + downloaded_bytes: u64, + total_bytes: Option, + ) { + let mut status = self.inner.status.write(); + status.state = PluginRuntimeState::Initializing; + status.phase = Some(phase); + status.downloaded_bytes = downloaded_bytes; + status.total_bytes = total_bytes; + status.error = None; + } +}