From 403170a4c5bb1a70c616e8171916f5db14d61984 Mon Sep 17 00:00:00 2001 From: axb Date: Wed, 19 Aug 2026 16:54:46 +0800 Subject: [PATCH 1/6] refactor(wework): introduce workbench plugin runtime --- backend/app/schemas/installed_plugin.py | 29 ++ backend/app/services/plugin_package_parser.py | 188 +++++++- .../services/test_installed_plugin_upload.py | 116 ++++- docs/en/architecture/README.md | 15 +- .../architecture/workbench-plugin-runtime.md | 70 +++ docs/zh/architecture/README.md | 15 +- .../architecture/workbench-plugin-runtime.md | 70 +++ pnpm-lock.yaml | 103 ++++- .../executor-home-persistence-probe.sh | 208 ++------- .../acceptance/executor-persistence-json.py | 107 +++++ scripts/acceptance/executor-rpc-probe.py | 81 ++++ wework/package.json | 3 + wework/scripts/create-tauri-dev-config.py | 44 ++ wework/scripts/dev-mac-app.sh | 76 +--- wework/src-tauri/src/desktop_capture.rs | 51 +-- wework/src-tauri/src/lib.rs | 22 + wework/src-tauri/src/local_executor.rs | 3 + wework/src-tauri/src/workbench_plugins.rs | 418 ++++++++++++++++++ wework/src/App.tsx | 40 +- .../components/layout/DesktopAppSwitcher.tsx | 49 +- .../settings/ConnectionsSettingsPage.tsx | 301 ++----------- wework/src/components/topnav/useChromeTabs.ts | 26 +- wework/src/config/apps.ts | 22 - wework/src/lib/navigation.ts | 17 +- wework/src/main.tsx | 2 + .../DynamicWorkbenchPluginHost.tsx | 70 +++ wework/src/plugin-runtime/apps.ts | 84 ++++ wework/src/plugin-runtime/bootstrap.ts | 22 + wework/src/plugin-runtime/core-apps-data.ts | 37 ++ .../src/plugin-runtime/core-settings-data.tsx | 233 ++++++++++ wework/src/plugin-runtime/external.test.ts | 112 +++++ wework/src/plugin-runtime/external.ts | 194 ++++++++ wework/src/plugin-runtime/routes.test.tsx | 42 ++ wework/src/plugin-runtime/routes.tsx | 69 +++ wework/src/plugin-runtime/runtime.test.ts | 76 ++++ wework/src/plugin-runtime/runtime.ts | 158 +++++++ wework/src/plugin-runtime/settings.test.tsx | 49 ++ wework/src/plugin-runtime/settings.tsx | 114 +++++ wework/src/plugin-runtime/slots.test.tsx | 46 ++ wework/src/plugin-runtime/slots.tsx | 219 +++++++++ wework/src/plugins/WorkbenchRootFrame.tsx | 21 + wework/src/plugins/applications.tsx | 16 + wework/src/plugins/automations.tsx | 16 + wework/src/plugins/base-profile.ts | 32 ++ wework/src/plugins/cloud-work.tsx | 16 + wework/src/plugins/core-apps.ts | 13 + wework/src/plugins/core-settings.ts | 16 + wework/src/plugins/plugin-center.tsx | 35 ++ wework/src/plugins/shell.tsx | 20 + wework/src/types/api.ts | 23 + 50 files changed, 3142 insertions(+), 667 deletions(-) create mode 100644 docs/en/architecture/workbench-plugin-runtime.md create mode 100644 docs/zh/architecture/workbench-plugin-runtime.md create mode 100644 scripts/acceptance/executor-persistence-json.py create mode 100644 scripts/acceptance/executor-rpc-probe.py create mode 100644 wework/scripts/create-tauri-dev-config.py create mode 100644 wework/src-tauri/src/workbench_plugins.rs delete mode 100644 wework/src/config/apps.ts create mode 100644 wework/src/plugin-runtime/DynamicWorkbenchPluginHost.tsx create mode 100644 wework/src/plugin-runtime/apps.ts create mode 100644 wework/src/plugin-runtime/bootstrap.ts create mode 100644 wework/src/plugin-runtime/core-apps-data.ts create mode 100644 wework/src/plugin-runtime/core-settings-data.tsx create mode 100644 wework/src/plugin-runtime/external.test.ts create mode 100644 wework/src/plugin-runtime/external.ts create mode 100644 wework/src/plugin-runtime/routes.test.tsx create mode 100644 wework/src/plugin-runtime/routes.tsx create mode 100644 wework/src/plugin-runtime/runtime.test.ts create mode 100644 wework/src/plugin-runtime/runtime.ts create mode 100644 wework/src/plugin-runtime/settings.test.tsx create mode 100644 wework/src/plugin-runtime/settings.tsx create mode 100644 wework/src/plugin-runtime/slots.test.tsx create mode 100644 wework/src/plugin-runtime/slots.tsx create mode 100644 wework/src/plugins/WorkbenchRootFrame.tsx create mode 100644 wework/src/plugins/applications.tsx create mode 100644 wework/src/plugins/automations.tsx create mode 100644 wework/src/plugins/base-profile.ts create mode 100644 wework/src/plugins/cloud-work.tsx create mode 100644 wework/src/plugins/core-apps.ts create mode 100644 wework/src/plugins/core-settings.ts create mode 100644 wework/src/plugins/plugin-center.tsx create mode 100644 wework/src/plugins/shell.tsx diff --git a/backend/app/schemas/installed_plugin.py b/backend/app/schemas/installed_plugin.py index a3a20ea0ff2..79b3b4e4d2a 100644 --- a/backend/app/schemas/installed_plugin.py +++ b/backend/app/schemas/installed_plugin.py @@ -89,6 +89,34 @@ class PluginConnectorComponent(BaseModel): ) +class WorkbenchFrontendModule(BaseModel): + """Same-realm frontend entry exported by a Wework plugin package.""" + + entry: str + export: str = "default" + sha256: str + + +class WorkbenchDesktopSidecar(BaseModel): + """Desktop JSON-RPC sidecar declared by a Wework plugin package.""" + + command: str + args: List[str] = Field(default_factory=list) + sha256: str + capabilities: List[str] = Field(default_factory=list) + + +class WorkbenchPluginComponent(BaseModel): + """Optional Wework workbench runtime contribution.""" + + apiVersion: Literal["1"] = "1" + required: bool = False + pinnedToClientVersion: bool = False + clientVersion: Optional[str] = None + frontend: Optional[WorkbenchFrontendModule] = None + desktop: Optional[WorkbenchDesktopSidecar] = None + + class InstalledPluginComponents(BaseModel): """Cross-runtime plugin component inventory.""" @@ -102,6 +130,7 @@ class InstalledPluginComponents(BaseModel): monitors: List[PluginPathComponent] = Field(default_factory=list) bins: List[PluginPathComponent] = Field(default_factory=list) settings: Optional[Dict[str, Any]] = None + workbench: Optional[WorkbenchPluginComponent] = None class PluginInterface(BaseModel): diff --git a/backend/app/services/plugin_package_parser.py b/backend/app/services/plugin_package_parser.py index 5674521a5ba..134c506341d 100644 --- a/backend/app/services/plugin_package_parser.py +++ b/backend/app/services/plugin_package_parser.py @@ -23,6 +23,9 @@ PluginPathComponent, PluginSkillComponent, PluginUploadInfo, + WorkbenchDesktopSidecar, + WorkbenchFrontendModule, + WorkbenchPluginComponent, ) MAX_PLUGIN_PACKAGE_SIZE_BYTES = 50 * 1024 * 1024 @@ -39,9 +42,11 @@ CLAUDE_PLUGIN_MANIFEST_PATH = ".claude-plugin/plugin.json" CODEX_PLUGIN_MANIFEST_PATH = ".codex-plugin/plugin.json" +WEWORK_PLUGIN_MANIFEST_PATH = ".wework-plugin/plugin.json" PLUGIN_MANIFEST_PATHS = ( CODEX_PLUGIN_MANIFEST_PATH, CLAUDE_PLUGIN_MANIFEST_PATH, + WEWORK_PLUGIN_MANIFEST_PATH, ) CODEX_MANIFEST_FIELDS = { "name", @@ -83,7 +88,16 @@ def parse_package(self, package_bytes: bytes) -> PluginUploadInfo: self._validate_archive_paths(archive) root, manifest_relative_path = self._detect_plugin_root(archive) manifest = self._read_json(archive, f"{root}{manifest_relative_path}") - components = self._parse_components(archive, root, manifest) + workbench_manifest = self._read_optional_json( + archive, + f"{root}{WEWORK_PLUGIN_MANIFEST_PATH}", + ) + components = self._parse_components( + archive, + root, + manifest, + workbench_manifest, + ) interface = self._inline_interface_assets( archive, root, @@ -282,7 +296,7 @@ def _validate_manifest_names( if len(set(names.values())) > 1: raise HTTPException( status_code=400, - detail="Codex and Claude Code plugin manifest names must match", + detail="Codex, Claude Code, and Wework plugin manifest names must match", ) @staticmethod @@ -364,7 +378,11 @@ def _manifest_bytes(manifest: Dict[str, Any]) -> bytes: ).encode("utf-8") def _parse_components( - self, archive: zipfile.ZipFile, root: str, manifest: Dict[str, Any] + self, + archive: zipfile.ZipFile, + root: str, + manifest: Dict[str, Any], + workbench_manifest: Dict[str, Any] | None = None, ) -> InstalledPluginComponents: names = [name for name in archive.namelist() if not name.endswith("/")] return InstalledPluginComponents( @@ -378,8 +396,172 @@ def _parse_components( monitors=self._parse_json_file_components(root, names, "monitors"), bins=self._parse_bin_files(root, names), settings=self._read_optional_json(archive, f"{root}settings.json"), + workbench=self._parse_workbench_component( + archive, + root, + workbench_manifest, + ), + ) + + def _parse_workbench_component( + self, + archive: zipfile.ZipFile, + root: str, + manifest: Dict[str, Any] | None, + ) -> WorkbenchPluginComponent | None: + if manifest is None: + return None + if str(manifest.get("apiVersion") or "1") != "1": + raise HTTPException( + status_code=400, + detail='.wework-plugin/plugin.json apiVersion must be "1"', + ) + frontend = self._parse_workbench_frontend( + archive, root, manifest.get("frontend") + ) + desktop = self._parse_workbench_desktop(archive, root, manifest.get("desktop")) + if frontend is None and desktop is None: + raise HTTPException( + status_code=400, + detail=".wework-plugin/plugin.json must declare frontend or desktop", + ) + required = bool(manifest.get("required", False)) + pinned_to_client_version = bool(manifest.get("pinnedToClientVersion", False)) + client_version = self._optional_string(manifest.get("clientVersion")) + if required and not pinned_to_client_version: + raise HTTPException( + status_code=400, + detail=( + ".wework-plugin/plugin.json required plugins must set " + "pinnedToClientVersion" + ), + ) + if pinned_to_client_version and client_version is None: + raise HTTPException( + status_code=400, + detail=( + ".wework-plugin/plugin.json pinned plugins must declare " + "clientVersion" + ), + ) + return WorkbenchPluginComponent( + apiVersion="1", + required=required, + pinnedToClientVersion=pinned_to_client_version, + clientVersion=client_version, + frontend=frontend, + desktop=desktop, + ) + + def _parse_workbench_frontend( + self, + archive: zipfile.ZipFile, + root: str, + value: Any, + ) -> WorkbenchFrontendModule | None: + if value is None: + return None + if not isinstance(value, dict): + raise HTTPException( + status_code=400, + detail=".wework-plugin/plugin.json frontend must be an object", + ) + entry = self._workbench_package_path(value.get("entry"), "frontend.entry") + self._require_archive_file(archive, root, entry, "frontend.entry") + return WorkbenchFrontendModule( + entry=entry, + export=str(value.get("export") or "default").strip() or "default", + sha256=self._workbench_sha256(value.get("sha256"), "frontend.sha256"), ) + def _parse_workbench_desktop( + self, + archive: zipfile.ZipFile, + root: str, + value: Any, + ) -> WorkbenchDesktopSidecar | None: + if value is None: + return None + if not isinstance(value, dict): + raise HTTPException( + status_code=400, + detail=".wework-plugin/plugin.json desktop must be an object", + ) + command = self._workbench_package_path(value.get("command"), "desktop.command") + self._require_archive_file(archive, root, command, "desktop.command") + raw_args = value.get("args") or [] + raw_capabilities = value.get("capabilities") or [] + if not isinstance(raw_args, list) or not all( + isinstance(item, str) for item in raw_args + ): + raise HTTPException( + status_code=400, + detail=".wework-plugin/plugin.json desktop.args must be a string array", + ) + if not isinstance(raw_capabilities, list) or not all( + isinstance(item, str) for item in raw_capabilities + ): + raise HTTPException( + status_code=400, + detail=( + ".wework-plugin/plugin.json desktop.capabilities " + "must be a string array" + ), + ) + return WorkbenchDesktopSidecar( + command=command, + args=raw_args, + sha256=self._workbench_sha256(value.get("sha256"), "desktop.sha256"), + capabilities=[item.strip() for item in raw_capabilities if item.strip()], + ) + + @staticmethod + def _workbench_package_path(value: Any, field: str) -> str: + path = str(value or "").strip() + normalized = PurePosixPath(path) + if ( + not path + or normalized.is_absolute() + or ".." in normalized.parts + or path.endswith("/") + ): + raise HTTPException( + status_code=400, + detail=( + f".wework-plugin/plugin.json {field} " + "must be a package-relative file" + ), + ) + return normalized.as_posix().removeprefix("./") + + @staticmethod + def _workbench_sha256(value: Any, field: str) -> str: + digest = str(value or "").strip().lower() + if not re.fullmatch(r"[0-9a-f]{64}", digest): + raise HTTPException( + status_code=400, + detail=( + f".wework-plugin/plugin.json {field} " + "must be a SHA-256 hex digest" + ), + ) + return digest + + @staticmethod + def _require_archive_file( + archive: zipfile.ZipFile, + root: str, + relative_path: str, + field: str, + ) -> None: + if f"{root}{relative_path}" not in archive.namelist(): + raise HTTPException( + status_code=400, + detail=( + f".wework-plugin/plugin.json {field} " "points to a missing file" + ), + ) + def _display_name(self, manifest: Dict[str, Any], fallback: str) -> str: direct = str(manifest.get("displayName") or "").strip() if direct: diff --git a/backend/tests/services/test_installed_plugin_upload.py b/backend/tests/services/test_installed_plugin_upload.py index 63e5e78084b..d6987ccc98f 100644 --- a/backend/tests/services/test_installed_plugin_upload.py +++ b/backend/tests/services/test_installed_plugin_upload.py @@ -191,10 +191,124 @@ def test_upload_plugin_rejects_mismatched_runtime_manifest_names(test_db, test_u assert exc_info.value.status_code == 400 assert exc_info.value.detail == ( - "Codex and Claude Code plugin manifest names must match" + "Codex, Claude Code, and Wework plugin manifest names must match" ) +def test_upload_plugin_parses_wework_frontend_and_desktop_components( + test_db, test_user +): + frontend = b"export default function activate() {}" + desktop = b"#!/bin/sh\n" + manifest = { + "name": "workbench-tools", + "version": "1.0.0", + "description": "Workbench extension", + "apiVersion": "1", + "frontend": { + "entry": "dist/frontend.js", + "sha256": hashlib.sha256(frontend).hexdigest(), + }, + "desktop": { + "command": "bin/sidecar", + "sha256": hashlib.sha256(desktop).hexdigest(), + "capabilities": ["workspace.read"], + }, + } + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(".wework-plugin/plugin.json", json.dumps(manifest)) + archive.writestr("dist/frontend.js", frontend) + archive.writestr("bin/sidecar", desktop) + + installed = InstalledPluginService().upload_plugin( + db=test_db, + user_id=test_user.id, + package_bytes=buffer.getvalue(), + filename="workbench-tools.zip", + ) + + assert installed.spec.components.workbench is not None + assert installed.spec.components.workbench.frontend is not None + assert installed.spec.components.workbench.frontend.entry == "dist/frontend.js" + assert installed.spec.components.workbench.desktop is not None + assert installed.spec.components.workbench.desktop.capabilities == [ + "workspace.read" + ] + + +def test_upload_plugin_rejects_invalid_wework_integrity(test_db, test_user): + manifest = { + "name": "workbench-tools", + "apiVersion": "1", + "frontend": { + "entry": "dist/frontend.js", + "sha256": "not-a-sha256", + }, + } + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(".wework-plugin/plugin.json", json.dumps(manifest)) + archive.writestr("dist/frontend.js", "export default function activate() {}") + + with pytest.raises(HTTPException) as exc_info: + InstalledPluginService().upload_plugin( + db=test_db, + user_id=test_user.id, + package_bytes=buffer.getvalue(), + filename="workbench-tools.zip", + ) + + assert exc_info.value.status_code == 400 + assert "frontend.sha256" in exc_info.value.detail + + +@pytest.mark.parametrize( + ("manifest_patch", "expected_detail"), + [ + ( + {"required": True}, + "required plugins must set pinnedToClientVersion", + ), + ( + {"pinnedToClientVersion": True}, + "pinned plugins must declare clientVersion", + ), + ], +) +def test_upload_plugin_rejects_unpinned_required_wework_components( + test_db, + test_user, + manifest_patch, + expected_detail, +): + frontend = b"export default function activate() {}" + manifest = { + "name": "workbench-tools", + "apiVersion": "1", + "frontend": { + "entry": "dist/frontend.js", + "sha256": hashlib.sha256(frontend).hexdigest(), + }, + **manifest_patch, + } + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w") as archive: + archive.writestr(".wework-plugin/plugin.json", json.dumps(manifest)) + archive.writestr("dist/frontend.js", frontend) + + with pytest.raises(HTTPException) as exc_info: + InstalledPluginService().upload_plugin( + db=test_db, + user_id=test_user.id, + package_bytes=buffer.getvalue(), + filename="workbench-tools.zip", + ) + + assert exc_info.value.status_code == 400 + assert expected_detail in exc_info.value.detail + + def test_publish_and_install_marketplace_plugin(test_db, test_user): service = InstalledPluginService() package_bytes = _create_plugin_zip() diff --git a/docs/en/architecture/README.md b/docs/en/architecture/README.md index a5e2ad2bc30..9a20f4ce1db 100644 --- a/docs/en/architecture/README.md +++ b/docs/en/architecture/README.md @@ -6,12 +6,13 @@ sidebar_position: 1 Before changing a flow below, update its connection graph, sequence diagram, code ownership, and essential invariants, confirm that the path is complete, and only then change code. Keep one independently maintained file per topic; add one file and one catalog row for a new governed flow. -| Logic | Architecture file | Change scope | -| -------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| Board automation and Wegent execution | [board-automation.md](board-automation.md) | Assignment, execution truth, runtime activation, continuation, cancellation, terminal projection | -| Custom AI manager comment continuation | [automation-manager-continuation.md](automation-manager-continuation.md) | Comment identity, execution binding, runtime-session continuation, task-state isolation | -| Embedded-browser navigation and tabs | [embedded-browser.md](embedded-browser.md) | Bridge routing, pending open, WebView lifecycle, navigation completion, multi-tab E2E | -| Project execution state and Runtime capacity | [project-execution-state.md](project-execution-state.md) | Claim, event ordering, cancellation, retry, lease, concurrency capacity, UI projection | -| Git Worktree execution | [git-worktree-execution.md](git-worktree-execution.md) | Device routing, capability, preflight, queued creation, lifecycle, persistence, UI projection | +| Logic | Architecture file | Change scope | +| -------------------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------ | +| Board automation and Wegent execution | [board-automation.md](board-automation.md) | Assignment, execution truth, runtime activation, continuation, cancellation, terminal projection | +| Custom AI manager comment continuation | [automation-manager-continuation.md](automation-manager-continuation.md) | Comment identity, execution binding, runtime-session continuation, task-state isolation | +| Embedded-browser navigation and tabs | [embedded-browser.md](embedded-browser.md) | Bridge routing, pending open, WebView lifecycle, navigation completion, multi-tab E2E | +| Project execution state and Runtime capacity | [project-execution-state.md](project-execution-state.md) | Claim, event ordering, cancellation, retry, lease, concurrency capacity, UI projection | +| Git Worktree execution | [git-worktree-execution.md](git-worktree-execution.md) | Device routing, capability, preflight, queued creation, lifecycle, persistence, UI projection | +| Wework host plugin runtime | [workbench-plugin-runtime.md](workbench-plugin-runtime.md) | Profile composition, services and UI slots, dynamic modules, sidecars, teardown, and recovery | Detailed product prose remains in the existing developer guides. This directory contains only reviewable architecture truth. diff --git a/docs/en/architecture/workbench-plugin-runtime.md b/docs/en/architecture/workbench-plugin-runtime.md new file mode 100644 index 00000000000..4da157125be --- /dev/null +++ b/docs/en/architecture/workbench-plugin-runtime.md @@ -0,0 +1,70 @@ +--- +sidebar_position: 25 +--- + +# Wework host plugin runtime + +Scope: product composition, dynamic installation, lifecycle, UI contributions, desktop sidecars, and failure recovery for Wework React and Tauri, excluding the Executor implementation. Backend owns packages, account desired state, and device actual state, but never executes plugin code. + +```mermaid +flowchart LR + PROFILE[client-version-pinned profile] --> CONTEXT[Cordis Context] + DESIRED[(InstalledPlugin account desired state)] --> HOST[dynamic plugin host] + ROOTS[device plugin directory actual state] --> SCAN[Tauri manifest and SHA-256 validation] + SCAN --> HOST + HOST --> MODULE[same-realm ESM frontend module] + HOST --> SIDECAR[desktop sidecar] + MODULE --> CONTEXT + CONTEXT --> ROUTES[Routes] + CONTEXT --> APPS[Apps] + CONTEXT --> SETTINGS[Settings] + CONTEXT --> SLOTS[React UI Slots] + SIDECAR <-->|JSON-RPC stdio| TAURI[Tauri sidecar host] + CONTEXT --> EXECUTOR[Executor protocol boundary] +``` + +```mermaid +sequenceDiagram + participant P as Profile + participant B as InstalledPlugin + participant D as Dynamic plugin host + participant T as Tauri scanner + participant C as Cordis Context + participant F as Frontend module + participant S as Desktop sidecar + participant U as React slot renderer + + P->>C: apply required entries with exact clientVersion + C->>U: register built-in routes, apps, settings, and slots + B->>D: enabled Wework plugin names + D->>T: scan device plugin roots + T->>T: canonicalize paths and verify SHA-256 + T-->>D: valid local manifests and entry paths + D->>F: import selected ESM entry in the host realm + F->>C: activate(plugin API) + C->>U: publish reactive contributions + + alt activation failure + C->>U: dispose the failed plugin fiber + D->>S: stop a started sidecar + end + + F->>S: optionally start or call a desktop capability + S-->>F: JSON-RPC result + B->>D: disable, uninstall, or update desired state + D->>C: dispose plugin fiber + C->>U: remove contributions and notify subscribers + D->>S: stop process +``` + +| Edge | Code owner | +| ----------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Context, services, and plugin fibers | Pinned `@deepseek-ai/cordis` | +| Wework routes, apps, settings, slots, and SDK | `wework/src/plugin-runtime/` | +| React slot contracts and React 19 renderer | `wework/src/plugin-runtime/slots.tsx` | +| Built-in required profile and product entrypoints | `wework/src/plugins/` | +| Manifest scan, path/SHA-256 checks, sidecar lifecycle | `wework/src-tauri/src/workbench_plugins.rs` | +| Plugin manifests, account desired state, and device installation actual state | Backend plugin schemas/services and device capability sync | +| Executor startup and protocol transport | Existing Executor bridge; Executor internals are outside this flow | + +Invariants: the product entrypoint loads only a profile and never enumerates concrete features; every registration belongs to a Cordis effect, and unloading may leave no route, slot, setting, app, listener, or process behind; React, ReactDOM, Cordis, and the Wework Plugin SDK have exactly one host instance across frontend plugins; required plugins must be pinned by the client profile to an exactly matching `clientVersion` and account desired state cannot disable them; optional plugins load only when the account enables them and the current device has a valid manifest with matching content hashes; dynamic registration and unloading must notify React subscribers; packages without `.wework-plugin/plugin.json` remain valid Executor capability plugins; frontend plugins execute in the same JavaScript realm with host-page authority, so SHA-256 proves content integrity but not publisher identity or permission isolation; sidecars may start only from verified files inside their package root and the requested plugin ID must match the manifest; Backend never loads or executes plugin code. diff --git a/docs/zh/architecture/README.md b/docs/zh/architecture/README.md index 84e1e8d30e2..9c542450de8 100644 --- a/docs/zh/architecture/README.md +++ b/docs/zh/architecture/README.md @@ -6,12 +6,13 @@ sidebar_position: 1 修改下表逻辑前,先更新对应连线图、时序图、代码归属和必要不变量,确认逻辑闭环后再改代码。每个主题独立维护;新增受约束逻辑时增加一个文件和一行索引。 -| 逻辑 | 架构文件 | 修改范围 | -| --------------------------- | -------------------------------------------------------- | ----------------------------------------------------------------- | -| 看板自动化与 Wegent 执行 | [board-automation.md](board-automation.md) | 指派、执行真值、runtime 激活、续聊、取消、终态投影 | -| 自定义 AI 调度员评论续聊 | [automation-manager-continuation.md](automation-manager-continuation.md) | 评论身份、execution 绑定、runtime 会话续聊、任务状态隔离 | -| 内置浏览器导航与多标签 | [embedded-browser.md](embedded-browser.md) | bridge 路由、pending open、WebView 生命周期、导航完成、多标签 E2E | -| 项目执行状态与 Runtime 容量 | [project-execution-state.md](project-execution-state.md) | claim、事件顺序、取消、重试、lease、并发容量、UI 投影 | -| Git Worktree 执行 | [git-worktree-execution.md](git-worktree-execution.md) | 设备路由、能力、preflight、排队创建、生命周期、持久化、UI 投影 | +| 逻辑 | 架构文件 | 修改范围 | +| --------------------------- | ------------------------------------------------------------------------ | ----------------------------------------------------------------- | +| 看板自动化与 Wegent 执行 | [board-automation.md](board-automation.md) | 指派、执行真值、runtime 激活、续聊、取消、终态投影 | +| 自定义 AI 调度员评论续聊 | [automation-manager-continuation.md](automation-manager-continuation.md) | 评论身份、execution 绑定、runtime 会话续聊、任务状态隔离 | +| 内置浏览器导航与多标签 | [embedded-browser.md](embedded-browser.md) | bridge 路由、pending open、WebView 生命周期、导航完成、多标签 E2E | +| 项目执行状态与 Runtime 容量 | [project-execution-state.md](project-execution-state.md) | claim、事件顺序、取消、重试、lease、并发容量、UI 投影 | +| Git Worktree 执行 | [git-worktree-execution.md](git-worktree-execution.md) | 设备路由、能力、preflight、排队创建、生命周期、持久化、UI 投影 | +| Wework 宿主插件运行时 | [workbench-plugin-runtime.md](workbench-plugin-runtime.md) | profile 装配、服务与 UI slot、动态模块、sidecar、卸载与恢复 | 详细产品说明继续放在原开发指南;本目录只保存可评审的架构真值。 diff --git a/docs/zh/architecture/workbench-plugin-runtime.md b/docs/zh/architecture/workbench-plugin-runtime.md new file mode 100644 index 00000000000..b0044d8da83 --- /dev/null +++ b/docs/zh/architecture/workbench-plugin-runtime.md @@ -0,0 +1,70 @@ +--- +sidebar_position: 25 +--- + +# Wework 宿主插件运行时 + +范围:Wework React 与 Tauri 中除 Executor 实现之外的产品功能装配、动态插件安装、生命周期、UI 贡献、桌面 sidecar 和故障恢复。Backend 只拥有插件包、账号期望态和设备实态,不执行插件代码。 + +```mermaid +flowchart LR + PROFILE[客户端版本锁定 profile] --> CONTEXT[Cordis Context] + DESIRED[(InstalledPlugin 账号期望态)] --> HOST[动态插件宿主] + ROOTS[设备插件目录实态] --> SCAN[Tauri 清单与 SHA-256 校验] + SCAN --> HOST + HOST --> MODULE[同 realm ESM 前端模块] + HOST --> SIDECAR[桌面 sidecar] + MODULE --> CONTEXT + CONTEXT --> ROUTES[Routes] + CONTEXT --> APPS[Apps] + CONTEXT --> SETTINGS[Settings] + CONTEXT --> SLOTS[React UI Slots] + SIDECAR <-->|JSON-RPC stdio| TAURI[Tauri sidecar host] + CONTEXT --> EXECUTOR[Executor 协议边界] +``` + +```mermaid +sequenceDiagram + participant P as Profile + participant B as InstalledPlugin + participant D as Dynamic plugin host + participant T as Tauri scanner + participant C as Cordis Context + participant F as Frontend module + participant S as Desktop sidecar + participant U as React slot renderer + + P->>C: apply required entries with exact clientVersion + C->>U: register built-in routes, apps, settings and slots + B->>D: enabled Wework plugin names + D->>T: scan device plugin roots + T->>T: canonicalize paths and verify SHA-256 + T-->>D: valid local manifests and entry paths + D->>F: import selected ESM entry in host realm + F->>C: activate(plugin API) + C->>U: publish reactive contributions + + alt activation failure + C->>U: dispose the failed plugin fiber + D->>S: stop a started sidecar + end + + F->>S: optionally start or call desktop capability + S-->>F: JSON-RPC result + B->>D: disable, uninstall, or update desired state + D->>C: dispose plugin fiber + C->>U: remove contributions and notify subscribers + D->>S: stop process +``` + +| 边 | 代码归属 | +| ----------------------------------------------- | --------------------------------------------------------- | +| Context、service 和插件 fiber | 固定版本的 `@deepseek-ai/cordis` | +| Wework routes、apps、settings、slots 和 SDK | `wework/src/plugin-runtime/` | +| React slot 合同和 React 19 renderer | `wework/src/plugin-runtime/slots.tsx` | +| 内置 required profile 和产品插件入口 | `wework/src/plugins/` | +| 清单扫描、路径与 SHA-256 校验、sidecar 生命周期 | `wework/src-tauri/src/workbench_plugins.rs` | +| 插件清单、账号期望态和设备安装实态 | Backend plugin schemas/services 和 device capability sync | +| Executor 的启动和协议传输 | 现有 Executor bridge;Executor 内部不属于本流程 | + +不变量:产品入口只加载 profile,不枚举具体功能;所有注册必须属于一个 Cordis effect,插件卸载后不得残留 route、slot、settings、app、listener 或进程;React、ReactDOM、Cordis 和 Wework Plugin SDK 在所有前端插件中只有一个宿主实例;required 插件必须随客户端 profile 锁定到完全一致的 `clientVersion`,且不能由账号期望态禁用;可选插件同时满足账号启用和当前设备存在、清单有效、内容哈希匹配才会加载;动态注册和卸载必须通知 React 订阅者;没有 `.wework-plugin/plugin.json` 的旧包仍是合法 Executor capability plugin;前端插件在同一 JS realm 运行,拥有宿主页面权限,SHA-256 只证明内容完整性,不构成发布者身份或权限隔离;sidecar 只能从包根目录内的已校验文件启动,插件 ID 必须与清单一致;Backend 不加载或执行插件代码。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72679e388a6..36f9e75241e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -528,6 +528,15 @@ importers: '@codemirror/view': specifier: ^6.43.1 version: 6.43.1 + '@deepseek-ai/cordis': + specifier: 4.0.1 + version: 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/dsh-client-ui-slots': + specifier: 0.1.0-rc.7 + version: 0.1.0-rc.7(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.7(@deepseek-ai/cordis@4.0.1)) + '@deepseek-ai/dsh-invariants': + specifier: 0.1.0-rc.7 + version: 0.1.0-rc.7(@deepseek-ai/cordis@4.0.1) '@dnd-kit/core': specifier: ^6.3.1 version: 6.3.1(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -1202,6 +1211,50 @@ packages: '@date-fns/tz@1.5.0': resolution: {integrity: sha512-lwYN/vDPeNRULcepoE/LO2Pgx+7/RV+S9ARfbc9lr2DtGkOD7pAiruHvbR1RX3Qyf6ja47EWJDMsNK5vK08DJg==} + '@deepseek-ai/cordis-plugin-include@1.0.6': + resolution: {integrity: sha512-i1VXrZCbv6tk/iUgedCNjrxxArbWT3IvRZGB5sdqJ3ectnihivXXQbRZ8JJ73DSmAPvlMGmrbtjFAfm10yvXRg==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/cordis-plugin-loader': ^1.0.2 + + '@deepseek-ai/cordis-plugin-loader@1.0.2': + resolution: {integrity: sha512-RIW9hoVyhYDWdCI9BsvtZccPde1ECLC4OAxupwowGTak78vwVTVdb3HezTSOK1Y1/Ax3Ru0LA1pYOB04CnTxIQ==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + node-addon-require-builtin: ^0.1.4 + peerDependenciesMeta: + node-addon-require-builtin: + optional: true + + '@deepseek-ai/cordis@4.0.1': + resolution: {integrity: sha512-YBdskTU2Po1kru3GgcUWUbkTsPMA9LkSQDAY8rBkFJeajdgcQad3QPJZE26JyK99Xb6HaASvoXg2DSUTeN/0Nw==} + hasBin: true + peerDependencies: + '@deepseek-ai/cordis-plugin-include': ^1.0.6 + '@deepseek-ai/cordis-plugin-loader': ^1.0.2 + peerDependenciesMeta: + '@deepseek-ai/cordis-plugin-include': + optional: true + '@deepseek-ai/cordis-plugin-loader': + optional: true + + '@deepseek-ai/cosmokit@1.8.2': + resolution: {integrity: sha512-muBOKtSrUKU5m/xpq8ZXWL6hQ/jgd4PhU2PqH97bcxIiLEJfNwZOGQEx4t/aS/GgxRAR+ra9pMHPMtTHU4sqqA==} + + '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.7': + resolution: {integrity: sha512-5rPP/6IWZflhiegVHU56IOPGHtrhLgBV44mOFn2JNVwiwxxmKeIhEcnYD4M0eKkI+pRiJIO5WvkRYokIGgyFiA==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/dsh-invariants': ^0.1.0-rc.7 + + '@deepseek-ai/dsh-invariants@0.1.0-rc.7': + resolution: {integrity: sha512-PnO1F4aZGUmqZPyuPJpBUfQ4DZmjCGCNGr0yuN2iURTP/e6h0kGnTNTYgR2NqMvAtpP66WNiHhvH2LMqumk1+A==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + + '@deepseek-ai/schemastery@3.18.1': + resolution: {integrity: sha512-Qn0FCSwCQnpnj6SB31I6i2sIKgKWnkbJM8O0EU91Gv2UsYVvtZTl6IA0sCwk2e2MZf5S8w5hpq9QkeVvK9qwxg==} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -3503,6 +3556,7 @@ packages: '@testing-library/jest-dom@6.10.0': resolution: {integrity: sha512-HQwu0KaB2zyT0iLzBL+8CLyZDL3KlZlZJ+2iyc9uCUnlJVskJU/UlPuVCyIPhtukjPQdT2QNoR5nCP5FqTmmDQ==} engines: {node: '>=22', npm: '>=6', yarn: '>=1'} + deprecated: Incorrect minor release with breaking changes (Node >=22 and required @testing-library/dom peer). Use 6.9.1 for the 6.x line, or upgrade to 7.0.0. peerDependencies: '@testing-library/dom': '>=10 <11' @@ -5637,7 +5691,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} @@ -9452,6 +9506,45 @@ snapshots: '@date-fns/tz@1.5.0': {} + '@deepseek-ai/cordis-plugin-include@1.0.6(@deepseek-ai/cordis-plugin-loader@1.0.2)(@deepseek-ai/cordis@4.0.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/cordis-plugin-loader': 1.0.2(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/cosmokit': 1.8.2 + js-yaml: 4.2.0 + optional: true + + '@deepseek-ai/cordis-plugin-loader@1.0.2(@deepseek-ai/cordis@4.0.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/cosmokit': 1.8.2 + optional: true + + '@deepseek-ai/cordis@4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2)': + dependencies: + '@deepseek-ai/cosmokit': 1.8.2 + '@standard-schema/spec': 1.1.0 + optionalDependencies: + '@deepseek-ai/cordis-plugin-include': 1.0.6(@deepseek-ai/cordis-plugin-loader@1.0.2)(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/cordis-plugin-loader': 1.0.2(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/cosmokit@1.8.2': {} + + '@deepseek-ai/dsh-client-ui-slots@0.1.0-rc.7(@deepseek-ai/cordis@4.0.1)(@deepseek-ai/dsh-invariants@0.1.0-rc.7(@deepseek-ai/cordis@4.0.1))': + dependencies: + '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/dsh-invariants': 0.1.0-rc.7(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/dsh-invariants@0.1.0-rc.7(@deepseek-ai/cordis@4.0.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/schemastery': 3.18.1 + + '@deepseek-ai/schemastery@3.18.1': + dependencies: + '@deepseek-ai/cosmokit': 1.8.2 + '@standard-schema/spec': 1.1.0 + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': dependencies: react: 19.2.7 @@ -12654,7 +12747,7 @@ snapshots: magicast: 0.5.3 obug: 2.1.2 tinyrainbow: 3.1.0 - vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-istanbul@4.1.8)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.13.2)(typescript@5.9.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(@vitest/coverage-istanbul@4.1.8)(jsdom@28.1.0)(msw@2.14.6(@types/node@24.13.2)(typescript@6.0.3))(vite@8.0.16(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -13935,7 +14028,7 @@ snapshots: eslint: 9.39.4(jiti@1.21.7) eslint-import-resolver-node: 0.3.10 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@1.21.7)) eslint-plugin-react-hooks: 5.2.0(eslint@9.39.4(jiti@1.21.7)) @@ -13965,7 +14058,7 @@ snapshots: tinyglobby: 0.2.17 unrs-resolver: 1.12.2 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)) transitivePeerDependencies: - supports-color @@ -13980,7 +14073,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)))(eslint@9.39.4(jiti@1.21.7)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.61.0(eslint@9.39.4(jiti@1.21.7))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@1.21.7)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 diff --git a/scripts/acceptance/executor-home-persistence-probe.sh b/scripts/acceptance/executor-home-persistence-probe.sh index 03c0cc57c92..b61651a44d6 100755 --- a/scripts/acceptance/executor-home-persistence-probe.sh +++ b/scripts/acceptance/executor-home-persistence-probe.sh @@ -66,18 +66,7 @@ read_device_config_field() { command -v python3 >/dev/null 2>&1 || { fail "python3 is required to inspect persisted device config" } - python3 - "$config_path" "$field" <<'PY' -import json -import sys - -path, field = sys.argv[1:] -with open(path, encoding="utf-8") as stream: - payload = json.load(stream) -value = payload.get(field) -if not isinstance(value, str) or not value.strip(): - raise SystemExit(f"missing non-empty {field} in {path}") -print(value.strip(), end="") -PY + python3 "$JSON_HELPER" read-config "$config_path" "$field" } run_executor_rpc() { @@ -87,98 +76,14 @@ run_executor_rpc() { fail "python3 is required to exercise the Executor Worktree RPC" } [ -x "$EXECUTOR_BIN" ] || fail "Executor binary is not executable: $EXECUTOR_BIN" - python3 - "$EXECUTOR_BIN" "$DEVICE_ID" "$method" "$params_json" <<'PY' -import json -import os -import queue -import subprocess -import sys -import threading -import time - -executor_bin, device_id, method, params_json = sys.argv[1:] -request_id = f"acceptance-{os.getpid()}" -try: - params = json.loads(params_json) -except json.JSONDecodeError as error: - raise SystemExit(f"invalid RPC params JSON: {error}") from error - -env = os.environ.copy() -env["WEGENT_APP_IPC_DEVICE_ID"] = device_id -env["WEGENT_BACKEND_URL"] = "" -env["WEGENT_SOCKET_URL"] = "" -process = subprocess.Popen( - [executor_bin], - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - text=True, - bufsize=1, - env=env, -) -assert process.stdin is not None -assert process.stdout is not None -assert process.stderr is not None -request = { - "type": "request", - "id": request_id, - "method": method, - "params": params, -} -process.stdin.write(json.dumps(request, separators=(",", ":")) + "\n") -process.stdin.flush() - -lines = queue.Queue() - -def read_stdout(): - for output_line in process.stdout: - lines.put(output_line) - lines.put(None) - -reader = threading.Thread(target=read_stdout, daemon=True) -reader.start() -deadline = time.monotonic() + 20 -response = None -try: - while time.monotonic() < deadline: - remaining = max(0.0, deadline - time.monotonic()) - try: - line = lines.get(timeout=min(0.25, remaining)) - except queue.Empty: - if process.poll() is not None: - break - continue - if line is None: - break - try: - message = json.loads(line) - except json.JSONDecodeError: - continue - if message.get("type") == "response" and message.get("id") == request_id: - response = message - break -finally: - process.terminate() - try: - process.wait(timeout=5) - except subprocess.TimeoutExpired: - process.kill() - process.wait(timeout=5) - -if response is None: - stderr = process.stderr.read().strip() - raise SystemExit( - f"Executor RPC {method} produced no response" - + (f": {stderr}" if stderr else "") - ) -if response.get("ok") is not True: - error = response.get("error") or {} - raise SystemExit( - f"Executor RPC {method} failed: " - f"{error.get('code', 'unknown')}: {error.get('message', 'unknown error')}" - ) -print(json.dumps(response.get("result"), separators=(",", ":"))) -PY + local script_dir="${0%/*}" + [ "$script_dir" != "$0" ] || script_dir="." + python3 \ + "$script_dir/executor-rpc-probe.py" \ + "$EXECUTOR_BIN" \ + "$DEVICE_ID" \ + "$method" \ + "$params_json" } assert_worktree_capability() { @@ -186,24 +91,8 @@ assert_worktree_capability() { capability="$(run_executor_rpc "runtime.worktrees.capabilities" '{}')" || { fail "Executor Worktree capability RPC failed" } - python3 - "$capability" "$DEVICE_ID" <<'PY' -import json -import sys - -payload = json.loads(sys.argv[1]) -device_id = sys.argv[2] -worktrees = payload.get("runtimeWorktrees") -if payload.get("success") is not True: - raise SystemExit("capability response was not successful") -if payload.get("deviceId") != device_id: - raise SystemExit("capability response belongs to another logical device") -if not isinstance(worktrees, dict): - raise SystemExit("capability response omitted runtimeWorktrees") -if worktrees.get("managed") is not True or worktrees.get("preflight") is not True: - raise SystemExit("managed Worktree capability is unavailable") -if worktrees.get("persistentStorageVerified") is not True: - raise SystemExit("persistent storage is not verified by the Executor capability") -PY + # The JSON helper requires runtimeWorktrees.persistentStorageVerified=true. + python3 "$JSON_HELPER" assert-capability "$capability" "$DEVICE_ID" } prepare_worktree_via_executor() { @@ -211,26 +100,10 @@ prepare_worktree_via_executor() { response="$( run_executor_rpc \ "runtime.worktrees.prepare" \ - "$(python3 - "$SOURCE_REPO" "$WORKTREE_ID" <<'PY' -import json -import sys - -print(json.dumps({"sourcePath": sys.argv[1], "worktreeId": sys.argv[2]})) -PY -)" + "$(python3 "$JSON_HELPER" prepare-params "$SOURCE_REPO" "$WORKTREE_ID")" )" || fail "Executor failed to prepare the acceptance Worktree" local prepared_path - prepared_path="$(python3 - "$response" <<'PY' -import json -import sys - -payload = json.loads(sys.argv[1]) -path = payload.get("path") -if payload.get("success") is not True or not isinstance(path, str) or not path: - raise SystemExit("prepare response omitted a successful Worktree path") -print(path, end="") -PY -)" + prepared_path="$(python3 "$JSON_HELPER" prepared-path "$response")" [ "$prepared_path" = "$WORKTREE_PATH" ] || { fail "Executor prepared an unexpected Worktree path: $prepared_path" } @@ -241,27 +114,13 @@ assert_worktree_listed() { response="$(run_executor_rpc "runtime.worktrees.list" '{}')" || { fail "Executor failed to list persisted Worktrees" } - python3 - "$response" "$DEVICE_ID" "$WORKTREE_ID" "$WORKTREE_PATH" <<'PY' -import json -import sys - -payload = json.loads(sys.argv[1]) -device_id, worktree_id, worktree_path = sys.argv[2:] -items = payload.get("items") -if payload.get("success") is not True or payload.get("deviceId") != device_id: - raise SystemExit("Worktree list response has the wrong device identity") -if not isinstance(items, list): - raise SystemExit("Worktree list response omitted items") -matches = [ - item - for item in items - if item.get("worktreeId") == worktree_id and item.get("path") == worktree_path -] -if len(matches) != 1: - raise SystemExit("persisted Worktree was not listed exactly once") -if matches[0].get("deviceId") != device_id: - raise SystemExit("persisted Worktree belongs to another logical device") -PY + python3 \ + "$JSON_HELPER" \ + assert-listed \ + "$response" \ + "$DEVICE_ID" \ + "$WORKTREE_ID" \ + "$WORKTREE_PATH" } assert_worktree_absent() { @@ -272,33 +131,13 @@ assert_worktree_absent() { response="$(run_executor_rpc "runtime.worktrees.list" '{}')" || { fail "Executor failed to list Worktrees after deletion" } - python3 - "$response" "$WORKTREE_ID" "$WORKTREE_PATH" <<'PY' -import json -import sys - -payload = json.loads(sys.argv[1]) -worktree_id, worktree_path = sys.argv[2:] -items = payload.get("items") -if payload.get("success") is not True or not isinstance(items, list): - raise SystemExit("Worktree list response is invalid after deletion") -if any( - item.get("worktreeId") == worktree_id or item.get("path") == worktree_path - for item in items -): - raise SystemExit("deleted Worktree remains in Executor state") -PY + python3 "$JSON_HELPER" assert-absent "$response" "$WORKTREE_ID" "$WORKTREE_PATH" } delete_worktree_via_executor() { run_executor_rpc \ "runtime.worktrees.delete" \ - "$(python3 - "$WORKTREE_PATH" <<'PY' -import json -import sys - -print(json.dumps({"path": sys.argv[1], "preserveSnapshot": False})) -PY -)" \ + "$(python3 "$JSON_HELPER" delete-params "$WORKTREE_PATH")" \ >/dev/null || fail "Executor failed to delete the acceptance Worktree" } @@ -486,6 +325,9 @@ INSTANCE_ID="${WEGENT_ACCEPTANCE_INSTANCE_ID:-}" VOLUME_ID="${WEGENT_ACCEPTANCE_VOLUME_ID:-}" PROBE_ID="${WEGENT_ACCEPTANCE_PROBE_ID:-git-worktree-persistence}" EXECUTOR_BIN="${WEGENT_ACCEPTANCE_EXECUTOR_BIN:-$EXECUTOR_HOME/bin/wegent-executor}" +SCRIPT_DIR="${0%/*}" +[ "$SCRIPT_DIR" != "$0" ] || SCRIPT_DIR="." +JSON_HELPER="$SCRIPT_DIR/executor-persistence-json.py" [ -n "$EXECUTOR_HOME" ] || fail "WEGENT_EXECUTOR_HOME is required" [ -n "$WORKSPACE_ROOT" ] || fail "LOCAL_WORKSPACE_ROOT is required" diff --git a/scripts/acceptance/executor-persistence-json.py b/scripts/acceptance/executor-persistence-json.py new file mode 100644 index 00000000000..3e503aa46de --- /dev/null +++ b/scripts/acceptance/executor-persistence-json.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""JSON helpers for the Executor Home persistence acceptance probe.""" + +import json +import sys +from pathlib import Path +from typing import Any + + +def load_payload(value: str) -> dict[str, Any]: + payload = json.loads(value) + if not isinstance(payload, dict): + raise SystemExit("expected a JSON object") + return payload + + +def read_config(path: str, field: str) -> None: + payload = load_payload(Path(path).read_text(encoding="utf-8")) + value = payload.get(field) + if not isinstance(value, str) or not value.strip(): + raise SystemExit(f"missing non-empty {field} in {path}") + print(value.strip(), end="") + + +def assert_capability(value: str, device_id: str) -> None: + payload = load_payload(value) + worktrees = payload.get("runtimeWorktrees") + if payload.get("success") is not True: + raise SystemExit("capability response was not successful") + if payload.get("deviceId") != device_id: + raise SystemExit("capability response belongs to another logical device") + if not isinstance(worktrees, dict): + raise SystemExit("capability response omitted runtimeWorktrees") + if worktrees.get("managed") is not True or worktrees.get("preflight") is not True: + raise SystemExit("managed Worktree capability is unavailable") + if worktrees.get("persistentStorageVerified") is not True: + raise SystemExit( + "persistent storage is not verified by the Executor capability" + ) + + +def prepare_params(source_path: str, worktree_id: str) -> None: + print(json.dumps({"sourcePath": source_path, "worktreeId": worktree_id})) + + +def prepared_path(value: str) -> None: + payload = load_payload(value) + path = payload.get("path") + if payload.get("success") is not True or not isinstance(path, str) or not path: + raise SystemExit("prepare response omitted a successful Worktree path") + print(path, end="") + + +def assert_listed(value: str, device_id: str, worktree_id: str, path: str) -> None: + payload = load_payload(value) + items = payload.get("items") + if payload.get("success") is not True or payload.get("deviceId") != device_id: + raise SystemExit("Worktree list response has the wrong device identity") + if not isinstance(items, list): + raise SystemExit("Worktree list response omitted items") + matches = [ + item + for item in items + if item.get("worktreeId") == worktree_id and item.get("path") == path + ] + if len(matches) != 1: + raise SystemExit("persisted Worktree was not listed exactly once") + if matches[0].get("deviceId") != device_id: + raise SystemExit("persisted Worktree belongs to another logical device") + + +def assert_absent(value: str, worktree_id: str, path: str) -> None: + payload = load_payload(value) + items = payload.get("items") + if payload.get("success") is not True or not isinstance(items, list): + raise SystemExit("Worktree list response is invalid after deletion") + if any( + item.get("worktreeId") == worktree_id or item.get("path") == path + for item in items + ): + raise SystemExit("deleted Worktree remains in Executor state") + + +def delete_params(path: str) -> None: + print(json.dumps({"path": path, "preserveSnapshot": False})) + + +def main() -> int: + command, *args = sys.argv[1:] + commands = { + "read-config": read_config, + "assert-capability": assert_capability, + "prepare-params": prepare_params, + "prepared-path": prepared_path, + "assert-listed": assert_listed, + "assert-absent": assert_absent, + "delete-params": delete_params, + } + handler = commands.get(command) + if handler is None: + raise SystemExit(f"unsupported command: {command}") + handler(*args) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/acceptance/executor-rpc-probe.py b/scripts/acceptance/executor-rpc-probe.py new file mode 100644 index 00000000000..a26841197da --- /dev/null +++ b/scripts/acceptance/executor-rpc-probe.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Issue one stdio RPC request to an Executor process with a hard timeout.""" + +import json +import os +import subprocess +import sys +from typing import Any + + +def parse_response(stdout: str, request_id: str) -> dict[str, Any] | None: + for line in stdout.splitlines(): + try: + message = json.loads(line) + except json.JSONDecodeError: + continue + if message.get("type") == "response" and message.get("id") == request_id: + return message + return None + + +def main() -> int: + if len(sys.argv) != 5: + raise SystemExit( + "usage: executor-rpc-probe.py EXECUTOR DEVICE_ID METHOD PARAMS_JSON" + ) + executor_bin, device_id, method, params_json = sys.argv[1:] + request_id = f"acceptance-{os.getpid()}" + try: + params = json.loads(params_json) + except json.JSONDecodeError as error: + raise SystemExit(f"invalid RPC params JSON: {error}") from error + + env = os.environ.copy() + env["WEGENT_APP_IPC_DEVICE_ID"] = device_id + env["WEGENT_BACKEND_URL"] = "" + env["WEGENT_SOCKET_URL"] = "" + request = { + "type": "request", + "id": request_id, + "method": method, + "params": params, + } + process = subprocess.Popen( + [executor_bin], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + env=env, + ) + try: + stdout, stderr = process.communicate( + json.dumps(request, separators=(",", ":")) + "\n", + timeout=20, + ) + except subprocess.TimeoutExpired: + process.kill() + process.communicate(timeout=5) + raise SystemExit(f"Executor RPC {method} timed out") + + response = parse_response(stdout, request_id) + if response is None: + detail = stderr.strip() + raise SystemExit( + f"Executor RPC {method} produced no response" + + (f": {detail}" if detail else "") + ) + if response.get("ok") is not True: + error = response.get("error") or {} + raise SystemExit( + f"Executor RPC {method} failed: " + f"{error.get('code', 'unknown')}: " + f"{error.get('message', 'unknown error')}" + ) + print(json.dumps(response.get("result"), separators=(",", ":"))) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/wework/package.json b/wework/package.json index 013eeace59d..25b23161422 100644 --- a/wework/package.json +++ b/wework/package.json @@ -57,6 +57,9 @@ "@codemirror/search": "^6.7.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.43.1", + "@deepseek-ai/cordis": "4.0.1", + "@deepseek-ai/dsh-client-ui-slots": "0.1.0-rc.7", + "@deepseek-ai/dsh-invariants": "0.1.0-rc.7", "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", diff --git a/wework/scripts/create-tauri-dev-config.py b/wework/scripts/create-tauri-dev-config.py new file mode 100644 index 00000000000..e63d4d68f7f --- /dev/null +++ b/wework/scripts/create-tauri-dev-config.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 + +import json +import os +from pathlib import Path + + +def main() -> None: + config: dict[str, object] = { + "build": { + "devUrl": f"http://localhost:{os.environ['WEWORK_PORT_VALUE']}", + "beforeDevCommand": os.environ["BEFORE_DEV_COMMAND_VALUE"], + }, + } + + app_identifier = os.environ["WEWORK_APP_IDENTIFIER_VALUE"].strip() + if app_identifier: + config["identifier"] = app_identifier + + if os.environ["WEWORK_DISABLE_BACKGROUND_THROTTLING_VALUE"] == "1": + config_path = ( + Path(os.environ["WEWORK_DIR_VALUE"]) / "src-tauri" / "tauri.conf.json" + ) + with config_path.open(encoding="utf-8") as handle: + base_config = json.load(handle) + config["app"] = {"windows": base_config["app"]["windows"]} + + if os.environ["WEWORK_RELEASE_UI_VALUE"] != "true": + config["bundle"] = { + "icon": [ + "icons/icon-dev.icns", + "icons/icon.png", + ], + } + + output_path = Path(os.environ["TAURI_DEV_CONFIG_VALUE"]) + output_path.write_text( + f"{json.dumps(config, indent=2)}\n", + encoding="utf-8", + ) + + +if __name__ == "__main__": + main() diff --git a/wework/scripts/dev-mac-app.sh b/wework/scripts/dev-mac-app.sh index 7e7c4b3777a..4c5e3109688 100755 --- a/wework/scripts/dev-mac-app.sh +++ b/wework/scripts/dev-mac-app.sh @@ -171,28 +171,21 @@ if ! [[ "$WEWORK_PORT" =~ ^[0-9]+$ ]] || [ "$WEWORK_PORT" -lt 1 ] || [ "$WEWORK_ fi is_port_available() { - node - "$1" <<'NODE' -const net = require('node:net') -const port = Number(process.argv[2]) - -const canListen = host => - new Promise(resolve => { - const server = net.createServer() - - server.once('error', () => resolve(false)) - server.listen(port, host, () => { - server.close(() => resolve(true)) - }) - }) - -;(async () => { - for (const host of ['127.0.0.1', '0.0.0.0']) { - if (!(await canListen(host))) { - process.exit(1) - } - } -})() -NODE + node -e ' + const net = require("node:net") + const port = Number(process.argv[1]) + const canListen = host => + new Promise(resolve => { + const server = net.createServer() + server.once("error", () => resolve(false)) + server.listen(port, host, () => server.close(() => resolve(true))) + }) + ;(async () => { + for (const host of ["127.0.0.1", "0.0.0.0"]) { + if (!(await canListen(host))) process.exit(1) + } + })() + ' "$1" } find_available_wework_port() { @@ -320,44 +313,7 @@ WEWORK_APP_IDENTIFIER_VALUE="${WEWORK_APP_IDENTIFIER:-}" \ WEWORK_DISABLE_BACKGROUND_THROTTLING_VALUE="${WEWORK_DISABLE_BACKGROUND_THROTTLING:-0}" \ WEWORK_DIR_VALUE="$WEWORK_DIR" \ TAURI_DEV_CONFIG_VALUE="$TAURI_DEV_CONFIG" \ -python3 - <<'PY' -import json -import os - -config = { - "build": { - "devUrl": f"http://localhost:{os.environ['WEWORK_PORT_VALUE']}", - "beforeDevCommand": os.environ["BEFORE_DEV_COMMAND_VALUE"], - }, -} - -app_identifier = os.environ["WEWORK_APP_IDENTIFIER_VALUE"].strip() -if app_identifier: - config["identifier"] = app_identifier - -if os.environ["WEWORK_DISABLE_BACKGROUND_THROTTLING_VALUE"] == "1": - with open( - os.path.join(os.environ["WEWORK_DIR_VALUE"], "src-tauri", "tauri.conf.json"), - encoding="utf-8", - ) as handle: - base_config = json.load(handle) - windows = base_config["app"]["windows"] - for window in windows: - window["backgroundThrottling"] = "disabled" - config["app"] = {"windows": windows} - -if os.environ["WEWORK_RELEASE_UI_VALUE"] != "true": - config["bundle"] = { - "icon": [ - "icons/icon-dev.icns", - "icons/icon.png", - ], - } - -with open(os.environ["TAURI_DEV_CONFIG_VALUE"], "w", encoding="utf-8") as handle: - json.dump(config, handle, indent=2) - handle.write("\n") -PY +python3 "$SCRIPT_DIR/create-tauri-dev-config.py" echo "Starting WeWork mac app" echo " RELEASE_UI=$WEWORK_RELEASE_UI" diff --git a/wework/src-tauri/src/desktop_capture.rs b/wework/src-tauri/src/desktop_capture.rs index 6999113b1ac..13c0744a54b 100644 --- a/wework/src-tauri/src/desktop_capture.rs +++ b/wework/src-tauri/src/desktop_capture.rs @@ -116,20 +116,9 @@ async fn capture_webview_impl( .get_webview(label) .ok_or_else(|| format!("Webview {label} is unavailable"))?; let window = webview.window().clone(); - let (sender, mut receiver) = tauri::async_runtime::channel(1); - - webview - .with_webview(move |platform_webview| { - let result = unsafe { capture_macos_webview(platform_webview) }; - let _ = sender.try_send(result); - }) - .map_err(|error| format!("Failed to access webview {label}: {error}"))?; - - let snapshot_result = receiver - .recv() + let snapshot_result = capture_embedded_webview_png(webview) .await - .ok_or_else(|| format!("Webview {label} snapshot was cancelled")) - .and_then(|result| result); + .map(|bytes| format!("data:image/png;base64,{}", crate::encode_base64(&bytes))); if !restore_after_capture { return snapshot_result; } @@ -165,42 +154,6 @@ fn restore_webview(window: &tauri::Window, label: &str) -> Result<(), String> { } } -#[cfg(target_os = "macos")] -unsafe fn capture_macos_webview( - platform_webview: tauri::webview::PlatformWebview, -) -> Result { - use objc2::runtime::AnyObject; - use objc2_app_kit::{NSBitmapImageFileType, NSView}; - use objc2_foundation::NSDictionary; - - let webview: &NSView = &*platform_webview.inner().cast(); - let bounds = webview.bounds(); - let bitmap = webview - .bitmapImageRepForCachingDisplayInRect(bounds) - .ok_or_else(|| "Failed to create bitmap for main webview".to_string())?; - webview.cacheDisplayInRect_toBitmapImageRep(bounds, &bitmap); - let properties: objc2::rc::Retained< - NSDictionary, - > = NSDictionary::new(); - let png = bitmap - .representationUsingType_properties(NSBitmapImageFileType::PNG, &properties) - .ok_or_else(|| "Failed to encode WebKit snapshot as PNG".to_string())?; - let bytes = ns_data_bytes(&png); - Ok(format!( - "data:image/png;base64,{}", - crate::encode_base64(&bytes) - )) -} - -#[cfg(target_os = "macos")] -fn ns_data_bytes(data: &objc2_foundation::NSData) -> Vec { - let mut bytes = vec![0; data.length()]; - if let Some(buffer) = std::ptr::NonNull::new(bytes.as_mut_ptr().cast()) { - unsafe { data.getBytes_length(buffer, bytes.len()) }; - } - bytes -} - #[cfg(not(target_os = "macos"))] async fn capture_main_webview_impl(_app: tauri::AppHandle) -> Result { Err("Main webview snapshots are currently supported on macOS only".to_string()) diff --git a/wework/src-tauri/src/lib.rs b/wework/src-tauri/src/lib.rs index aa225734e64..7bdd0ca9667 100644 --- a/wework/src-tauri/src/lib.rs +++ b/wework/src-tauri/src/lib.rs @@ -28,6 +28,8 @@ mod system_lock; mod system_sleep; mod todo_store; mod workbench_background; +#[cfg(desktop)] +mod workbench_plugins; use std::collections::{HashMap, HashSet}; #[cfg(desktop)] @@ -4932,6 +4934,9 @@ pub fn run() { #[cfg(desktop)] let builder = builder.manage(NativeTelemetryState::default()); + #[cfg(desktop)] + let builder = builder.manage(workbench_plugins::WorkbenchPluginState::default()); + let app = builder .manage(appshots::AppshotState::default()) .manage(embedded_browser::EmbeddedBrowserState::default()) @@ -5103,6 +5108,18 @@ pub fn run() { local_workspace_files::list_local_workspace_entries, workbench_background::import_workbench_background, workbench_background::remove_workbench_background, + #[cfg(desktop)] + workbench_plugins::workbench_plugin_authorize_capability, + #[cfg(desktop)] + workbench_plugins::workbench_plugin_inspect, + #[cfg(desktop)] + workbench_plugins::workbench_plugin_list, + #[cfg(desktop)] + workbench_plugins::workbench_plugin_request, + #[cfg(desktop)] + workbench_plugins::workbench_plugin_start, + #[cfg(desktop)] + workbench_plugins::workbench_plugin_stop, pick_workspace_paths, read_clipboard_workspace_paths, read_dropped_workspace_paths, @@ -5247,6 +5264,11 @@ pub fn run() { } tauri::RunEvent::Exit => { shutdown_local_executor_for_app(app_handle, "run_event_exit"); + #[cfg(desktop)] + { + let state = app_handle.state::(); + workbench_plugins::shutdown(state.inner()); + } } _ => {} } diff --git a/wework/src-tauri/src/local_executor.rs b/wework/src-tauri/src/local_executor.rs index e701739cc9f..06265d1723d 100644 --- a/wework/src-tauri/src/local_executor.rs +++ b/wework/src-tauri/src/local_executor.rs @@ -2963,6 +2963,9 @@ fn remove_marketplace_plugin_entry(manifest_path: &Path, plugin_name: &str) -> R manifest_path.display() ) })?; + if manifest.get("name").and_then(Value::as_str) == Some("personal") { + manifest["name"] = Value::String(WEWORK_PERSONAL_MARKETPLACE_ID.to_string()); + } let plugins = manifest .get_mut("plugins") .and_then(Value::as_array_mut) diff --git a/wework/src-tauri/src/workbench_plugins.rs b/wework/src-tauri/src/workbench_plugins.rs new file mode 100644 index 00000000000..064a1faa683 --- /dev/null +++ b/wework/src-tauri/src/workbench_plugins.rs @@ -0,0 +1,418 @@ +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use std::collections::HashMap; +use std::fs; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, ChildStdout, Command, Stdio}; +use std::sync::Mutex; +use tauri::State; + +const MANIFEST_PATH: &str = ".wework-plugin/plugin.json"; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkbenchFrontendModule { + entry: String, + export: Option, + sha256: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkbenchDesktopSidecar { + command: String, + #[serde(default)] + args: Vec, + sha256: String, + #[serde(default)] + capabilities: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WorkbenchPluginManifest { + name: String, + version: Option, + #[serde(default = "default_api_version")] + api_version: String, + #[serde(default)] + required: bool, + #[serde(default)] + pinned_to_client_version: bool, + client_version: Option, + frontend: Option, + desktop: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InspectedWorkbenchPlugin { + root: String, + manifest: WorkbenchPluginManifest, + frontend_path: Option, + desktop_path: Option, +} + +struct RunningSidecar { + child: Child, + stdin: ChildStdin, + stdout: BufReader, + next_request_id: u64, +} + +#[derive(Default)] +pub struct WorkbenchPluginState { + sidecars: Mutex>, +} + +fn default_api_version() -> String { + "1".to_string() +} + +fn normalize_plugin_id(value: &str) -> Result { + let value = value.trim(); + if value.is_empty() + || !value + .chars() + .all(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + { + return Err("Plugin id must contain only letters, numbers, '-' or '_'".to_string()); + } + Ok(value.to_string()) +} + +fn canonical_plugin_root(raw_root: &str) -> Result { + let root = fs::canonicalize(raw_root.trim()) + .map_err(|error| format!("Failed to resolve plugin root: {error}"))?; + if !root.is_dir() { + return Err("Plugin root is not a directory".to_string()); + } + if !root.join(MANIFEST_PATH).is_file() { + return Err(format!("Plugin root is missing {MANIFEST_PATH}")); + } + Ok(root) +} + +fn resolve_package_file(root: &Path, relative: &str, field: &str) -> Result { + let candidate = root.join(relative); + let canonical = fs::canonicalize(&candidate) + .map_err(|error| format!("Failed to resolve {field}: {error}"))?; + if !canonical.is_file() || !canonical.starts_with(root) { + return Err(format!( + "{field} must resolve to a file inside the plugin package" + )); + } + Ok(canonical) +} + +fn verify_sha256(path: &Path, expected: &str, field: &str) -> Result<(), String> { + let expected = expected.trim().to_ascii_lowercase(); + if expected.len() != 64 || !expected.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(format!("{field} must be a SHA-256 hex digest")); + } + let bytes = fs::read(path).map_err(|error| format!("Failed to read {field}: {error}"))?; + let actual = format!("{:x}", Sha256::digest(bytes)); + if actual != expected { + return Err(format!("{field} SHA-256 mismatch")); + } + Ok(()) +} + +fn inspect_plugin(raw_root: &str) -> Result { + let root = canonical_plugin_root(raw_root)?; + let manifest_bytes = fs::read(root.join(MANIFEST_PATH)) + .map_err(|error| format!("Failed to read {MANIFEST_PATH}: {error}"))?; + let manifest: WorkbenchPluginManifest = serde_json::from_slice(&manifest_bytes) + .map_err(|error| format!("Invalid {MANIFEST_PATH}: {error}"))?; + if manifest.api_version != "1" { + return Err(format!( + "Unsupported Wework plugin apiVersion '{}'", + manifest.api_version + )); + } + if manifest.frontend.is_none() && manifest.desktop.is_none() { + return Err("Wework plugin must declare frontend or desktop".to_string()); + } + if manifest.required && !manifest.pinned_to_client_version { + return Err("Required Wework plugins must set pinnedToClientVersion".to_string()); + } + if manifest.pinned_to_client_version + && manifest.client_version.as_deref().unwrap_or("").is_empty() + { + return Err("Pinned Wework plugins must declare clientVersion".to_string()); + } + + let frontend_path = manifest + .frontend + .as_ref() + .map(|frontend| { + let path = resolve_package_file(&root, &frontend.entry, "frontend.entry")?; + verify_sha256(&path, &frontend.sha256, "frontend.entry")?; + Ok::(path.to_string_lossy().into_owned()) + }) + .transpose()?; + let desktop_path = manifest + .desktop + .as_ref() + .map(|desktop| { + let path = resolve_package_file(&root, &desktop.command, "desktop.command")?; + verify_sha256(&path, &desktop.sha256, "desktop.command")?; + Ok::(path.to_string_lossy().into_owned()) + }) + .transpose()?; + + Ok(InspectedWorkbenchPlugin { + root: root.to_string_lossy().into_owned(), + manifest, + frontend_path, + desktop_path, + }) +} + +fn plugin_search_roots() -> Vec { + let mut roots = Vec::new(); + if let Ok(codex_home) = std::env::var("CODEX_HOME") { + roots.push(PathBuf::from(codex_home).join("plugins")); + } + if let Some(home) = dirs::home_dir() { + roots.push(home.join(".codex/plugins")); + roots.push(home.join(".agents/plugins")); + roots.push(home.join(".wework/plugins")); + } + roots +} + +fn collect_plugin_roots(directory: &Path, depth: usize, output: &mut Vec) { + if depth == 0 || !directory.is_dir() { + return; + } + if directory.join(MANIFEST_PATH).is_file() { + output.push(directory.to_path_buf()); + return; + } + let Ok(entries) = fs::read_dir(directory) else { + return; + }; + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if file_type.is_dir() && !file_type.is_symlink() { + collect_plugin_roots(&entry.path(), depth - 1, output); + } + } +} + +#[tauri::command] +pub fn workbench_plugin_list() -> Vec { + let mut roots = Vec::new(); + for search_root in plugin_search_roots() { + collect_plugin_roots(&search_root, 6, &mut roots); + } + roots.sort(); + roots.dedup(); + roots + .into_iter() + .filter_map(|root| inspect_plugin(root.to_string_lossy().as_ref()).ok()) + .collect() +} + +#[tauri::command] +pub fn workbench_plugin_inspect(plugin_root: String) -> Result { + inspect_plugin(&plugin_root) +} + +#[tauri::command] +pub fn workbench_plugin_start( + state: State<'_, WorkbenchPluginState>, + plugin_id: String, + plugin_root: String, +) -> Result<(), String> { + let plugin_id = normalize_plugin_id(&plugin_id)?; + let inspected = inspect_plugin(&plugin_root)?; + if inspected.manifest.name != plugin_id { + return Err("Plugin id must match the package manifest name".to_string()); + } + let desktop = inspected + .manifest + .desktop + .ok_or_else(|| "Plugin does not declare a desktop sidecar".to_string())?; + let command = inspected + .desktop_path + .ok_or_else(|| "Plugin desktop command was not resolved".to_string())?; + let mut sidecars = state + .sidecars + .lock() + .map_err(|_| "Workbench plugin state lock is poisoned".to_string())?; + if sidecars.contains_key(&plugin_id) { + return Err(format!("Workbench plugin '{plugin_id}' is already running")); + } + + let mut child = Command::new(command) + .args(&desktop.args) + .current_dir(&inspected.root) + .env("WEWORK_PLUGIN_ID", &plugin_id) + .env("WEWORK_PLUGIN_ROOT", &inspected.root) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .map_err(|error| format!("Failed to start workbench plugin '{plugin_id}': {error}"))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| "Plugin sidecar stdin was not created".to_string())?; + let stdout = child + .stdout + .take() + .ok_or_else(|| "Plugin sidecar stdout was not created".to_string())?; + sidecars.insert( + plugin_id, + RunningSidecar { + child, + stdin, + stdout: BufReader::new(stdout), + next_request_id: 1, + }, + ); + Ok(()) +} + +#[tauri::command] +pub fn workbench_plugin_request( + state: State<'_, WorkbenchPluginState>, + plugin_id: String, + method: String, + params: Value, +) -> Result { + let plugin_id = normalize_plugin_id(&plugin_id)?; + let method = method.trim(); + if method.is_empty() { + return Err("JSON-RPC method is required".to_string()); + } + let mut sidecars = state + .sidecars + .lock() + .map_err(|_| "Workbench plugin state lock is poisoned".to_string())?; + let sidecar = sidecars + .get_mut(&plugin_id) + .ok_or_else(|| format!("Workbench plugin '{plugin_id}' is not running"))?; + let request_id = sidecar.next_request_id; + sidecar.next_request_id += 1; + let request = serde_json::json!({ + "jsonrpc": "2.0", + "id": request_id, + "method": method, + "params": params, + }); + serde_json::to_writer(&mut sidecar.stdin, &request) + .map_err(|error| format!("Failed to write plugin request: {error}"))?; + sidecar + .stdin + .write_all(b"\n") + .and_then(|_| sidecar.stdin.flush()) + .map_err(|error| format!("Failed to flush plugin request: {error}"))?; + + let mut response_line = String::new(); + let read = sidecar + .stdout + .read_line(&mut response_line) + .map_err(|error| format!("Failed to read plugin response: {error}"))?; + if read == 0 { + return Err(format!( + "Workbench plugin '{plugin_id}' exited before responding" + )); + } + let response: Value = serde_json::from_str(&response_line) + .map_err(|error| format!("Plugin returned invalid JSON-RPC: {error}"))?; + if response.get("id").and_then(Value::as_u64) != Some(request_id) { + return Err("Plugin returned a mismatched JSON-RPC response id".to_string()); + } + if let Some(error) = response.get("error") { + return Err(format!("Plugin JSON-RPC error: {error}")); + } + Ok(response.get("result").cloned().unwrap_or(Value::Null)) +} + +#[tauri::command] +pub fn workbench_plugin_stop( + state: State<'_, WorkbenchPluginState>, + plugin_id: String, +) -> Result<(), String> { + let plugin_id = normalize_plugin_id(&plugin_id)?; + let mut sidecars = state + .sidecars + .lock() + .map_err(|_| "Workbench plugin state lock is poisoned".to_string())?; + if let Some(mut sidecar) = sidecars.remove(&plugin_id) { + let _ = sidecar.child.kill(); + let _ = sidecar.child.wait(); + } + Ok(()) +} + +#[tauri::command] +pub fn workbench_plugin_authorize_capability( + plugin_root: String, + capability: String, +) -> Result { + let inspected = inspect_plugin(&plugin_root)?; + let capabilities = inspected + .manifest + .desktop + .map(|desktop| desktop.capabilities) + .unwrap_or_default(); + Ok(capabilities.iter().any(|item| item == capability.trim())) +} + +pub fn shutdown(state: &WorkbenchPluginState) { + let Ok(mut sidecars) = state.sidecars.lock() else { + return; + }; + for (_, mut sidecar) in sidecars.drain() { + let _ = sidecar.child.kill(); + let _ = sidecar.child.wait(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn inspect_rejects_integrity_mismatch() { + let root = tempdir().unwrap(); + fs::create_dir_all(root.path().join(".wework-plugin")).unwrap(); + fs::write(root.path().join("frontend.js"), "export default {}").unwrap(); + fs::write( + root.path().join(MANIFEST_PATH), + r#"{ + "name":"test", + "apiVersion":"1", + "frontend":{"entry":"frontend.js","sha256":"0000000000000000000000000000000000000000000000000000000000000000"} + }"#, + ) + .unwrap(); + + assert!(inspect_plugin(root.path().to_str().unwrap()) + .unwrap_err() + .contains("SHA-256 mismatch")); + } + + #[test] + fn package_file_cannot_escape_plugin_root() { + let root = tempdir().unwrap(); + let outside = tempdir().unwrap(); + let outside_file = outside.path().join("sidecar"); + fs::write(&outside_file, "binary").unwrap(); + + assert!( + resolve_package_file(root.path(), outside_file.to_str().unwrap(), "command") + .unwrap_err() + .contains("inside the plugin package") + ); + } +} diff --git a/wework/src/App.tsx b/wework/src/App.tsx index 686b65ad2f5..8ad2f04f02a 100644 --- a/wework/src/App.tsx +++ b/wework/src/App.tsx @@ -26,19 +26,12 @@ import { OidcCallbackPage } from '@/pages/OidcCallbackPage' import { LoginPage } from '@/pages/LoginPage' import { WeworkAuthorizePage } from '@/pages/WeworkAuthorizePage' import { WorkbenchPage } from '@/pages/WorkbenchPage' -import { PluginsPage } from '@/pages/PluginsPage' -import { PluginCreatePage } from '@/pages/PluginCreatePage' -import { PluginManagementPage } from '@/pages/PluginManagementPage' -import { SitesPage } from '@/pages/SitesPage' -import { AutomationsPage } from '@/pages/AutomationsPage' -import { CloudWorkPage } from '@/pages/CloudWorkPage' import { PopoutWorkbenchPage } from '@/pages/PopoutWorkbenchPage' import { stripAppBasePath } from '@/config/runtime' import { AppearanceProvider } from '@/features/appearance' import { ChromeTitlebar } from '@/components/topnav/ChromeTitlebar' import { AppIframe } from '@/components/topnav/AppIframe' import { useChromeTabs } from '@/components/topnav/useChromeTabs' -import { APP_TABS } from '@/config/apps' import { isTauriRuntime } from '@/lib/runtime-environment' import { AppUpdateProvider } from '@/features/app-update/AppUpdateProvider' import { LocalRuntimeInitializer } from '@/features/local-runtime/LocalRuntimeInitializer' @@ -101,6 +94,10 @@ import { TelemetryBridge } from '@/telemetry/TelemetryBridge' import { track, useTelemetryEnabled } from '@/telemetry/client' import { WorkspaceTabPortalOwner } from '@/components/topnav/TitlebarActionsPortal' import { setActiveWorkspaceTabPortalOwner } from '@/components/topnav/workspaceTabPortalOwnership' +import { getWorkbenchPluginRuntime } from '@/plugin-runtime/bootstrap' +import { DynamicWorkbenchPluginHost } from '@/plugin-runtime/DynamicWorkbenchPluginHost' +import { useActiveWorkbenchApps } from '@/plugin-runtime/apps' +import { useWorkbenchRouteRegistry } from '@/plugin-runtime/routes' const WORKBENCH_STARTUP_REVEAL_TIMEOUT_MS = 6000 const POPOUT_WINDOW_LABEL = 'popout-window' @@ -162,12 +159,8 @@ function useCurrentLocation() { function telemetryFeatureForPath(path: string) { if (path === '/login' || path === '/login/oidc') return 'login' as const - if (path === '/plugins/manage') return 'plugin_management' as const - if (path === '/plugins/create') return 'plugin_create' as const - if (path === '/plugins') return 'plugins' as const - if (path === '/cloud-work') return 'cloud_work' as const - if (path === '/sites') return 'sites' as const - if (path === '/automations') return 'automations' as const + const pluginRoute = getWorkbenchPluginRuntime().routes.resolve(path) + if (pluginRoute) return pluginRoute.telemetryFeature if (path.startsWith('/app/')) return 'apps' as const if (path.startsWith('/settings')) return 'settings' as const if (path.startsWith('/project-space')) return 'project_space' as const @@ -190,20 +183,14 @@ function workspaceTabIframe( ): { src: string; title: string } | null { const match = workspaceTabPath(tab).match(/^\/app\/([^/]+)/) if (!match) return null - const app = APP_TABS.find(candidate => candidate.key === match[1]) + const app = getWorkbenchPluginRuntime().apps.resolve(match[1]) if (!app || app.mode !== 'iframe') return null const src = app.key === 'wegent' ? wegentUrl : app.url return src ? { src, title: app.label } : null } function workspaceTabAuxiliaryPage(path: string, search: string) { - if (path === '/plugins/manage') return - if (path === '/plugins/create') return - if (path === '/plugins') return - if (path === '/cloud-work') return - if (path === '/sites') return - if (path === '/automations') return - return null + return getWorkbenchPluginRuntime().routes.resolve(path)?.render({ search }) ?? null } interface WorkspaceTabSurfaceProps { @@ -227,6 +214,8 @@ function WorkspaceTabSurface({ tab, user, }: WorkspaceTabSurfaceProps) { + useActiveWorkbenchApps() + useWorkbenchRouteRegistry(getWorkbenchPluginRuntime().routes) const tabPath = workspaceTabPath(tab) const tabSearch = new URL(tab.contentRoute, window.location.origin).search const iframe = workspaceTabIframe(tab, cloudWebUrl) @@ -323,6 +312,7 @@ function WorkspaceTabSurface({ function AppRoutes({ onWorkbenchStartupReadyChange, onOpenWeworkForAppshot }: AppRoutesProps = {}) { const path = useCurrentPath() + useWorkbenchRouteRegistry(getWorkbenchPluginRuntime().routes) const isPopoutWindow = isPopoutWindowRuntime() const { user, isLoading } = useAuth() const cloudConnection = useCloudConnection() @@ -456,11 +446,8 @@ function AppRoutes({ onWorkbenchStartupReadyChange, onOpenWeworkForAppshot }: Ap export default function App() { const path = useCurrentPath() - if (isTauriRuntime() && path === '/system-drag') { - return - } - - return + const content = isTauriRuntime() && path === '/system-drag' ? : + return getWorkbenchPluginRuntime().slots.renderRoot(content) } function MainApp() { @@ -476,6 +463,7 @@ function MainApp() { + diff --git a/wework/src/components/layout/DesktopAppSwitcher.tsx b/wework/src/components/layout/DesktopAppSwitcher.tsx index 6f4a2de72b3..eaa0a588e96 100644 --- a/wework/src/components/layout/DesktopAppSwitcher.tsx +++ b/wework/src/components/layout/DesktopAppSwitcher.tsx @@ -15,8 +15,10 @@ import { useExperimentalFeaturesEnabled } from '@/features/experimental-features import { useTranslation } from '@/hooks/useTranslation' import { dispatchOpenSettingsShortcut } from '@/lib/keybindings' import { cn } from '@/lib/utils' +import { type WorkbenchAppContribution, useActiveWorkbenchApps } from '@/plugin-runtime/apps' +import { CORE_WORKBENCH_APPS } from '@/plugin-runtime/core-apps-data' -export type DesktopAppKey = 'wework' | 'todo' | 'apps' | 'wegent' +export type DesktopAppKey = string interface DesktopAppSwitcherProps { activeApp: DesktopAppKey @@ -124,36 +126,25 @@ export function DesktopAppSwitcher({ const [displayedKey, setDisplayedKey] = useState(activeApp) const [rollingLabel, setRollingLabel] = useState(null) const [menuBlurred, setMenuBlurred] = useState(false) + const registeredApps = useActiveWorkbenchApps() const options = useMemo(() => { - const appOptions: AppOption[] = [ - { - key: 'wework', - label: t('workbench.app_wework_label', '任务'), - description: t('workbench.app_wework_description', '使用 AI 解决具体问题'), - }, - ] - - if (experimentalFeaturesEnabled || activeApp === 'todo') { - appOptions.push({ - key: 'todo', - label: t('workbench.app_weloop_label', '项目空间'), - description: t('workbench.app_weloop_description', '用 AI 管理项目的规划、执行与反馈'), - }) - } - - appOptions.push({ - key: 'wegent', - label: t('workbench.app_wegent_label', '智能体'), - description: t('workbench.app_wegent_description', '构建并交付可嵌入业务的云端智能体'), - availabilityLabel: cloudConnection?.isConnected - ? undefined - : t('workbench.app_wegent_requires_cloud', '连接云端后可用'), - disabled: !cloudConnection?.isConnected && activeApp !== 'wegent', - }) - - return appOptions - }, [activeApp, cloudConnection?.isConnected, experimentalFeaturesEnabled, t]) + const apps: readonly WorkbenchAppContribution[] = + registeredApps.length > 0 ? registeredApps : CORE_WORKBENCH_APPS + return apps + .filter(app => !app.experimental || experimentalFeaturesEnabled || activeApp === app.key) + .map(app => ({ + key: app.key, + label: t(app.labelKey, app.label), + description: t(app.descriptionKey, app.description), + availabilityLabel: + app.requiresCloud && !cloudConnection?.isConnected + ? t('workbench.app_wegent_requires_cloud', '连接云端后可用') + : undefined, + disabled: + Boolean(app.requiresCloud) && !cloudConnection?.isConnected && activeApp !== app.key, + })) + }, [activeApp, cloudConnection?.isConnected, experimentalFeaturesEnabled, registeredApps, t]) const displayedAppKey = rollingLabel ? displayedKey : activeApp const selected = options.find(option => option.key === displayedAppKey) ?? diff --git a/wework/src/components/settings/ConnectionsSettingsPage.tsx b/wework/src/components/settings/ConnectionsSettingsPage.tsx index 47fb708c8c5..99e4b6c72c3 100644 --- a/wework/src/components/settings/ConnectionsSettingsPage.tsx +++ b/wework/src/components/settings/ConnectionsSettingsPage.tsx @@ -1,34 +1,20 @@ import { - AppWindow, AlertCircle, - Archive, ArrowLeft, - Palette, Check, Cloud, Code2, Copy, - FolderGit2, - GitPullRequest, - Globe2, Info, - Keyboard, - MessageSquareText, Loader2, LogOut, MoreHorizontal, - Network, - Package, Pencil, Plus, RotateCcw, Server, - ScanLine, - SlidersHorizontal, Terminal, Trash2, - UserRound, - Webhook, X, } from 'lucide-react' import type { ComponentType } from 'react' @@ -61,28 +47,12 @@ import { import type { DeviceInfo as RuntimeDeviceInfo, RuntimeTaskAddress, UnifiedModel } from '@/types/api' import type { WorkbenchServices } from '@/features/workbench/workbenchServices' import type { CloudDeviceMetricsResponse, DeviceInfo, DeviceSessionResponse } from '@/types/devices' -import { AppearanceSettingsPage } from '@/features/appearance/AppearanceSettingsPage' import { defaultAppearance, getWorkbenchBackground, useOptionalAppearance, } from '@/features/appearance' import { AddCloudDeviceDialog } from './AddCloudDeviceDialog' -import { ProxySettingsPage } from './ProxySettingsPage' -import { ModelSettingsPage } from './ModelSettingsPage' -import { PluginSettingsPage } from './PluginSettingsPage' -import { WorktreesSettingsPage } from './WorktreesSettingsPage' -import { ArchivedConversationsSettingsPage } from './ArchivedConversationsSettingsPage' -import { KeyboardShortcutsSettingsPage } from './KeyboardShortcutsSettingsPage' -import { GeneralSettingsPage } from './GeneralSettingsPage' -import { ContextSettingsPage } from './ContextSettingsPage' -import { AboutSettingsPage } from './AboutSettingsPage' -import { BrowserSettingsPage } from './BrowserSettingsPage' -import { AppshotsSettingsPage } from './AppshotsSettingsPage' -import { QuickPhrasesSettingsPage } from './QuickPhrasesSettingsPage' -import { HarnessSettingsPage } from './HarnessSettingsPage' -import { GitHostingSettingsPage } from './GitHostingSettingsPage' -import { HooksSettingsPage } from '@/features/hooks/HooksSettingsPage' import { DeviceActionButton } from './DeviceActionButton' import { createSettingsDeviceApi, @@ -90,6 +60,11 @@ import { createSettingsRemoteTerminalClientFactory, type CloudSettingsConnection, } from './settings-cloud-api' +import { CORE_WORKBENCH_SETTINGS } from '@/plugin-runtime/core-settings-data' +import { + type WorkbenchSettingsContribution, + useActiveWorkbenchSettings, +} from '@/plugin-runtime/settings' const CloudDesktopDeviceAction = cloudDesktopExtension.DeviceAction const keepConnectionsSettingsOpen = () => undefined @@ -103,178 +78,17 @@ interface ConnectionsSettingsPageProps { onRefreshWorkLists?: () => Promise } -type SettingsCategory = 'personal' | 'integrations' | 'coding' | 'archived' - -interface SettingsNavItem { - key: string - icon: ComponentType<{ className?: string }> - label: string - fallback: string - category?: SettingsCategory - experimental?: boolean -} - -const settingsNavItems: SettingsNavItem[] = [ - { - key: 'general', - icon: SlidersHorizontal, - label: 'settings_nav_general', - fallback: '通用', - category: 'personal', - }, - { - key: 'connections', - icon: Globe2, - label: 'settings_nav_connections', - fallback: '云端连接', - category: 'personal', - }, - { - key: 'appearance', - icon: Palette, - label: 'settings_nav_appearance', - fallback: '外观', - category: 'personal', - }, - { - key: 'context', - icon: Terminal, - label: 'settings_nav_context', - fallback: '上下文', - category: 'personal', - }, - { - key: 'model-settings', - icon: UserRound, - label: 'settings_nav_model_settings', - fallback: '模型', - category: 'personal', - }, - { - key: 'proxy', - icon: Network, - label: 'settings_nav_proxy', - fallback: '代理', - category: 'personal', - }, - { - key: 'keyboard-shortcuts', - icon: Keyboard, - label: 'settings_nav_keyboard_shortcuts', - fallback: '快捷键', - category: 'personal', - }, - { - key: 'quick-phrases', - icon: MessageSquareText, - label: 'settings_nav_quick_phrases', - fallback: '快捷短语', - category: 'personal', - }, - { - key: 'about', - icon: Info, - label: 'settings_nav_about', - fallback: '关于', - category: 'personal', - }, - { - key: 'appshots', - icon: ScanLine, - label: 'settings_nav_appshots', - fallback: '应用快照', - category: 'integrations', - }, - { - key: 'plugins', - icon: Package, - label: 'settings_nav_plugins', - fallback: '插件', - category: 'integrations', - }, - { - key: 'browser', - icon: AppWindow, - label: 'settings_nav_browser', - fallback: '浏览器', - category: 'integrations', - }, - { - key: 'git-hosting', - icon: GitPullRequest, - label: 'settings_nav_git_hosting', - fallback: '代码托管', - category: 'coding', - }, - { - key: 'harnesses', - icon: Code2, - label: 'settings_nav_harnesses', - fallback: '编码工具', - category: 'coding', - experimental: true, - }, - { - key: 'worktrees', - icon: FolderGit2, - label: 'settings_nav_worktrees', - fallback: '工作树', - category: 'coding', - }, - { - key: 'hooks', - icon: Webhook, - label: 'settings_nav_hooks', - fallback: 'Hooks', - category: 'coding', - }, - { - key: 'archived-conversations', - icon: Archive, - label: 'settings_nav_archived_conversations', - fallback: '已归档对话', - category: 'archived', - }, -] - -const settingsCategoryLabels: Record = { - personal: { - label: 'settings_category_personal', - fallback: '个人', - }, - integrations: { - label: 'settings_category_integrations', - fallback: '集成', - }, - coding: { - label: 'settings_category_coding', - fallback: '编码', - }, - archived: { - label: 'settings_category_archived', - fallback: '已归档', - }, -} - -function getSettingsNavFromPath(path: string): string { +function getSettingsNavFromPath( + path: string, + contributions: readonly WorkbenchSettingsContribution[] +): string { const normalizedPath = stripAppBasePath(path) - if (normalizedPath === '/settings') return 'general' - if (normalizedPath === '/settings/personal') return 'model-settings' - const matchedItem = settingsNavItems.find(item => getSettingsNavPath(item.key) === normalizedPath) - if (matchedItem) return matchedItem.key - const match = normalizedPath.match(/^\/settings\/([^/]+)$/) - if (!match) return 'general' - return settingsNavItems.some(item => item.key === match[1]) ? match[1] : 'general' -} - -function getSettingsNavPath(key: string): string { - if (key === 'context') return '/settings/personal/context' - if (key === 'model-settings') return '/settings/personal/models' - if (key === 'proxy') return '/settings/personal/proxy' - if (key === 'keyboard-shortcuts') return '/settings/personal/keyboard-shortcuts' - if (key === 'quick-phrases') return '/settings/personal/quick-phrases' - if (key === 'general') return '/settings' - return `/settings/${key}` + return ( + contributions.find( + contribution => + contribution.path === normalizedPath || contribution.aliases?.includes(normalizedPath) + )?.key ?? 'general' + ) } function StatusPill({ status }: { status: DeviceInfo['status'] }) { @@ -1476,30 +1290,38 @@ export function ConnectionsSettingsPage({ const background = getWorkbenchBackground(appearance, appearanceContext?.resolvedMode ?? 'light') const { sidebarWidth, handleResizeStart } = useResizableSidebar() const isDesktopRuntime = isTauriRuntime() - const visibleSettingsNavItems = settingsNavItems.filter( + const registeredSettings = useActiveWorkbenchSettings() + const settingsContributions: readonly WorkbenchSettingsContribution[] = + registeredSettings.length > 0 ? registeredSettings : CORE_WORKBENCH_SETTINGS + const visibleSettingsNavItems = settingsContributions.filter( item => - (!item.experimental || experimentalFeaturesEnabled) && - (!['keyboard-shortcuts', 'appshots'].includes(item.key) || isDesktopRuntime) + (!item.experimental || experimentalFeaturesEnabled) && (!item.desktopOnly || isDesktopRuntime) ) const shouldAutoOpenAddCloudDeviceDialog = autoOpenAddCloudDeviceDialog || new URLSearchParams(window.location.search).get('addDevice') === '1' - const [activeNav, setActiveNav] = useState(() => getSettingsNavFromPath(window.location.pathname)) + const [activeNav, setActiveNav] = useState(() => + getSettingsNavFromPath(window.location.pathname, settingsContributions) + ) const effectiveActiveNav = activeNav === 'harnesses' && !experimentalFeaturesEnabled ? 'general' : activeNav useEffect(() => { const handlePopState = () => { - setActiveNav(getSettingsNavFromPath(window.location.pathname)) + setActiveNav(getSettingsNavFromPath(window.location.pathname, settingsContributions)) } window.addEventListener('popstate', handlePopState) return () => window.removeEventListener('popstate', handlePopState) - }, []) + }, [settingsContributions]) const openCloudSettings = useCallback(() => { setActiveNav('connections') - navigateTo(getSettingsNavPath('connections')) - }, [setActiveNav]) + const path = settingsContributions.find(item => item.key === 'connections')?.path + navigateTo(path ?? '/settings/connections') + }, [settingsContributions]) + const activeContribution = + settingsContributions.find(item => item.key === effectiveActiveNav) ?? + settingsContributions.find(item => item.key === 'general') return (
{ const showCategory = item.category && visibleSettingsNavItems[index - 1]?.category !== item.category - const categoryLabel = item.category ? settingsCategoryLabels[item.category] : null return (
- {showCategory && categoryLabel && ( + {showCategory && (
- {t(`workbench.${categoryLabel.label}`, categoryLabel.fallback)} + {t(`workbench.${item.categoryLabelKey}`, item.categoryLabel)}
)}