diff --git a/docs/en/architecture/README.md b/docs/en/architecture/README.md index 2fcc652363f..f97a122c9d5 100644 --- a/docs/en/architecture/README.md +++ b/docs/en/architecture/README.md @@ -6,15 +6,16 @@ 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 | | Text-model vision delegation | [model-vision-delegation.md](model-vision-delegation.md) | Explicit model reference, catalog capability, sidecar configuration, image replacement, failure isolation | -| IM private-chat runtime streaming | [im-runtime-streaming.md](im-runtime-streaming.md) | Callback key, `runtime:event` envelope, relay and IM forwarding, terminal event, failure isolation | +| Wework host plugin runtime | [workbench-plugin-runtime.md](workbench-plugin-runtime.md) | Profile composition, services and UI slots, dynamic modules, sidecars, teardown, and recovery | +| IM private-chat runtime streaming | [im-runtime-streaming.md](im-runtime-streaming.md) | Callback key, `runtime:event` envelope, relay and IM forwarding, terminal event, failure isolation | | Codex notification isolation | [codex-notification-routing.md](codex-notification-routing.md) | Shared app-server, thread routing, burst isolation, process exit, terminal projection | 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..4d713c75dbf --- /dev/null +++ b/docs/en/architecture/workbench-plugin-runtime.md @@ -0,0 +1,70 @@ +--- +sidebar_position: 25 +--- + +# Wework host plugin runtime + +Scope: local product composition, plugin lifecycle, UI contributions, desktop sidecars, and failure recovery for Wework React and Tauri, excluding the Executor implementation. This flow does not depend on Backend or handle cloud plugin distribution. + +```mermaid +flowchart LR + PROFILE[client-version-pinned profile] --> CONTEXT[Cordis Context] + INSTALLED[locally installed Codex plugins] --> HOST[dynamic plugin host] + ROOTS[local plugin directories] --> 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 I as Local installed inventory + 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, right panels, and slots + I->>D: installed and enabled 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 + I->>D: local disable, uninstall, or update event + 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, right panels, 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` | +| Local installed inventory and plugin change events | Wework local Codex plugin API and plugin workspace | +| 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, right panel, 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`; optional plugins load only when locally installed and enabled and when the device has a valid manifest with matching content hashes; local install, disable, uninstall, or update operations must trigger serialized rescans; 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 under approved local plugin directories and inside their package root, and the requested plugin ID must match the manifest; each JSON-RPC method requires an identically named manifest `capabilities` entry, requests are serialized per plugin, notifications and unrelated responses are ignored, and the sidecar is terminated after a 30-second timeout or process exit; this flow does not read cloud desired state, and Backend never loads or executes plugin code. diff --git a/docs/zh/architecture/README.md b/docs/zh/architecture/README.md index 7ad4354fb71..7cf7e83a804 100644 --- a/docs/zh/architecture/README.md +++ b/docs/zh/architecture/README.md @@ -6,15 +6,16 @@ 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 投影 | | 文本模型视觉委托 | [model-vision-delegation.md](model-vision-delegation.md) | 显式模型引用、catalog 能力、sidecar 配置、图片替换、失败隔离 | -| IM 私聊续聊本地 Runtime | [im-runtime-streaming.md](im-runtime-streaming.md) | callback key、`runtime:event` 信封、中继与 IM 转发、终态、失败隔离 | +| Wework 宿主插件运行时 | [workbench-plugin-runtime.md](workbench-plugin-runtime.md) | profile 装配、服务与 UI slot、动态模块、sidecar、卸载与恢复 | +| IM 私聊续聊本地 Runtime | [im-runtime-streaming.md](im-runtime-streaming.md) | callback key、`runtime:event` 信封、中继与 IM 转发、终态、失败隔离 | | Codex 通知流隔离 | [codex-notification-routing.md](codex-notification-routing.md) | 共享 app-server、线程路由、突发隔离、进程退出、终态投影 | 详细产品说明继续放在原开发指南;本目录只保存可评审的架构真值。 diff --git a/docs/zh/architecture/workbench-plugin-runtime.md b/docs/zh/architecture/workbench-plugin-runtime.md new file mode 100644 index 00000000000..f38c2cc8515 --- /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] + INSTALLED[本地 Codex 已安装插件] --> 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 I as 本地已安装清单 + 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, right panels and slots + I->>D: 已安装且启用的插件名 + 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 + I->>D: 本地禁用、卸载或更新事件 + 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、right panels、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` | +| 本地安装清单与插件变更事件 | Wework local Codex plugin API 和插件工作区 | +| Executor 的启动和协议传输 | 现有 Executor bridge;Executor 内部不属于本流程 | + +不变量:产品入口只加载 profile,不枚举具体功能;所有注册必须属于一个 Cordis effect,插件卸载后不得残留 route、slot、settings、app、right panel、listener 或进程;React、ReactDOM、Cordis 和 Wework Plugin SDK 在所有前端插件中只有一个宿主实例;required 插件必须随客户端 profile 锁定到完全一致的 `clientVersion`;可选插件同时满足本地已安装且启用、设备存在有效清单、内容哈希匹配才会加载;本地安装、禁用、卸载或更新必须触发串行重新扫描;动态注册和卸载必须通知 React 订阅者;没有 `.wework-plugin/plugin.json` 的旧包仍是合法 Executor capability plugin;前端插件在同一 JS realm 运行,拥有宿主页面权限,SHA-256 只证明内容完整性,不构成发布者身份或权限隔离;sidecar 只能从受认可的本地插件目录和包根目录内的已校验文件启动,插件 ID 必须与清单一致;每个 JSON-RPC method 必须在清单 `capabilities` 中同名声明,宿主按插件串行请求、忽略通知和无关响应,并在 30 秒超时或进程退出后终止 sidecar;本流程不读取云端期望态,也不由 Backend 加载或执行插件代码。 diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e2ba5d1f91..fb3638473d7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -531,6 +531,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) @@ -1205,6 +1214,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: @@ -3506,6 +3559,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' @@ -5640,7 +5694,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==} @@ -9455,6 +9509,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 @@ -12657,7 +12750,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 @@ -13938,7 +14031,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)) @@ -13968,7 +14061,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 @@ -13983,7 +14076,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/hooks/check-alembic-heads.sh b/scripts/hooks/check-alembic-heads.sh index af68a953868..98dc028687a 100755 --- a/scripts/hooks/check-alembic-heads.sh +++ b/scripts/hooks/check-alembic-heads.sh @@ -193,10 +193,10 @@ HEAD_COUNT=$(echo "$HEADS" | grep -c .) NON_STANDARD_COUNT=$(echo "$NON_STANDARD_IDS" | grep -c . || true) if [ "$NON_STANDARD_COUNT" -gt 0 ]; then echo -e "${YELLOW}⚠️ Non-standard revision ID format detected:${NC}" - while IFS= read -r id; do + printf '%s' "$NON_STANDARD_IDS" | while IFS= read -r id; do [ -z "$id" ] && continue echo -e " ${YELLOW}• $id${NC}" - done < <(printf '%s' "$NON_STANDARD_IDS") + done echo "" echo -e "${YELLOW} Recommendation: Use 'alembic revision --autogenerate' to generate${NC}" echo -e "${YELLOW} standard 12-character hexadecimal revision IDs.${NC}" @@ -219,11 +219,11 @@ else echo -e "${RED}❌ Alembic Multi-Head Check: FAILED${NC}" echo "" echo -e "${RED}${BOLD} Multiple heads detected (${HEAD_COUNT}):${NC}" - for head in $HEADS; do + printf '%s' "$HEADS" | while IFS= read -r head; do [ -z "$head" ] && continue head_file=$(get_value "$REVISIONS" "$head") echo -e " ${RED}• ${head}${NC} (${head_file})" - done < <(printf '%s' "$HEADS") + done echo "" # Provide fix instructions diff --git a/wework/package.json b/wework/package.json index 61287c0e8ad..7ef3a1e2f14 100644 --- a/wework/package.json +++ b/wework/package.json @@ -58,6 +58,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..26c9fe6a939 --- /dev/null +++ b/wework/scripts/create-tauri-dev-config.py @@ -0,0 +1,47 @@ +#!/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) + 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", + ], + } + + 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 8f7d8d7b8bc..a694def4e2a 100755 --- a/wework/scripts/dev-mac-app.sh +++ b/wework/scripts/dev-mac-app.sh @@ -314,44 +314,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 -c ' -import json -import os - -config = { - "build": { - "devUrl": "http://localhost:{}".format(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") -' +uv run --no-project python "$SCRIPT_DIR/create-tauri-dev-config.py" echo "Starting WeWork mac app" echo " RELEASE_UI=$WEWORK_RELEASE_UI" diff --git a/wework/src-tauri/bundled-plugins/wework-plugin-example/.codex-plugin/plugin.json b/wework/src-tauri/bundled-plugins/wework-plugin-example/.codex-plugin/plugin.json index 80f8b824f94..0b63282c751 100644 --- a/wework/src-tauri/bundled-plugins/wework-plugin-example/.codex-plugin/plugin.json +++ b/wework/src-tauri/bundled-plugins/wework-plugin-example/.codex-plugin/plugin.json @@ -1,18 +1,18 @@ { "name": "wework-plugin-example", - "version": "0.1.0", - "description": "展示 Wework 标准插件包中的 Skill 与 MCP 组件。", + "version": "0.2.0", + "description": "展示 Wework 标准插件包中的工作台、Skill 与 MCP 组件。", "author": { "name": "Wework" }, "skills": "./skills/", "interface": { "displayName": "Wework 插件示例", - "shortDescription": "包含一个 Skill 和一个本地 MCP 工具的标准示例包", - "longDescription": "用于学习 Wework 插件目录、manifest、Skill 和 MCP 配置。可以直接导入验证,也可以复制后修改为自己的插件。", + "shortDescription": "包含工作台应用、Skill 和本地 MCP 工具的标准示例包", + "longDescription": "用于学习 Wework 插件目录、manifest、工作台前端、Skill 和 MCP 配置。可以直接导入验证,也可以复制后修改为自己的插件。", "developerName": "Wework", "category": "效率工具", - "capabilities": ["Read", "MCP"], + "capabilities": ["Workbench", "Read", "MCP"], "defaultPrompt": ["使用示例插件向我问好。"] }, "mcpServers": "./.mcp.json" diff --git a/wework/src-tauri/bundled-plugins/wework-plugin-example/.wework-plugin/plugin.json b/wework/src-tauri/bundled-plugins/wework-plugin-example/.wework-plugin/plugin.json new file mode 100644 index 00000000000..f96dcffcb34 --- /dev/null +++ b/wework/src-tauri/bundled-plugins/wework-plugin-example/.wework-plugin/plugin.json @@ -0,0 +1,12 @@ +{ + "name": "wework-plugin-example", + "version": "0.2.0", + "apiVersion": "1", + "required": false, + "pinnedToClientVersion": false, + "frontend": { + "entry": "workbench/frontend.js", + "export": "default", + "sha256": "409c1844e41c6a3a769cae15928a52b4e810a2c7f199ad1cb368bbae950a25fc" + } +} diff --git a/wework/src-tauri/bundled-plugins/wework-plugin-example/README.md b/wework/src-tauri/bundled-plugins/wework-plugin-example/README.md index e06be04979a..f95f1f1ebc4 100644 --- a/wework/src-tauri/bundled-plugins/wework-plugin-example/README.md +++ b/wework/src-tauri/bundled-plugins/wework-plugin-example/README.md @@ -1,19 +1,37 @@ # Wework 插件示例 / Wework Plugin Example -这是可直接导入 Wework 的标准插件包,目录格式与 `wework-plugins/plugins//` 一致。 +这是可直接导入 Wework 的标准插件包,目录格式与 +`wework-plugins/plugins//` 一致。导入并安装后,应用列表、设置页和右侧 +工作区会出现“插件示例”,用于验证工作台插件的动态加载;Skill 与 MCP 组件也会同时安装。 This is a directly importable Wework plugin package. Its layout matches -`wework-plugins/plugins//`. +`wework-plugins/plugins//`. After import and installation, +“插件示例” appears in the application list, Settings, and the right workspace +to verify dynamic Workbench loading. The Skill and MCP components are installed +at the same time. ## 必需结构 / Required layout - `.codex-plugin/plugin.json`: 插件名称、版本、说明和组件入口。 +- `.wework-plugin/plugin.json`: 工作台运行时入口及其完整性校验。 +- `workbench/frontend.js`: 注册应用、路由和设置页的前端模块。 - `skills//SKILL.md`: 可选 Skill;每个 Skill 必须包含 YAML frontmatter。 - `.mcp.json`: 可选 MCP 配置;使用 `mcpServers` 对象。 - `mcp/`, `scripts/`, `assets/`: 可选运行文件和资源。 -打包时请压缩本目录中的内容,确保 `.codex-plugin/plugin.json` 位于 ZIP 根目录, -不要把整个目录作为 ZIP 中额外的一层。 +工作台模块导出带有 `activate(api)` 的对象,通过宿主提供的 +`api.react.createElement` 渲染 React 节点,并通过 `api.routes`、`api.apps`、 +`api.settings`、`api.rightPanels` 或 `api.slots` 注册功能。修改模块后必须同步更新 +`.wework-plugin/plugin.json` 中的 SHA-256。 -When packaging, zip the contents of this directory so `.codex-plugin/plugin.json` -is located at the ZIP root. Do not add another wrapper directory. +打包时请压缩本目录中的内容,确保 `.codex-plugin/plugin.json` 和 +`.wework-plugin/plugin.json` 位于 ZIP 根目录,不要把整个目录作为 ZIP 中额外的一层。 + +The Workbench module exports an object with `activate(api)`, renders React nodes +with the host-provided `api.react.createElement`, and registers features through +`api.routes`, `api.apps`, `api.settings`, `api.rightPanels`, or `api.slots`. +Whenever the module changes, update the SHA-256 in +`.wework-plugin/plugin.json`. + +When packaging, zip the contents of this directory so both manifests are +located at the ZIP root. Do not add another wrapper directory. diff --git a/wework/src-tauri/bundled-plugins/wework-plugin-example/workbench/frontend.js b/wework/src-tauri/bundled-plugins/wework-plugin-example/workbench/frontend.js new file mode 100644 index 00000000000..b039cb042b4 --- /dev/null +++ b/wework/src-tauri/bundled-plugins/wework-plugin-example/workbench/frontend.js @@ -0,0 +1,128 @@ +const examplePlugin = { + activate(api) { + const h = api.react.createElement + const disposeRoute = api.routes.register({ + id: 'wework-plugin-example.home', + path: '/plugin-example', + telemetryFeature: 'plugins', + render: () => + h( + 'main', + { + className: + 'mx-auto flex h-full w-full max-w-3xl flex-col justify-center px-5 py-8 text-text-primary', + }, + h('p', { className: 'text-sm text-text-secondary' }, 'Workbench Plugin API v1'), + h('h1', { className: 'heading-large mt-2' }, '示例插件已加载'), + h( + 'p', + { className: 'mt-3 max-w-2xl text-base leading-6 text-text-secondary' }, + '这个页面、应用入口和设置入口都由本地插件动态注册,不属于 Wework 内置执行器。' + ), + h( + 'section', + { className: 'mt-8 grid gap-3 sm:grid-cols-3' }, + ['本地清单发现', 'SHA-256 完整性校验', '卸载时自动清理'].map((label, index) => + h( + 'div', + { + key: label, + className: 'rounded-xl border border-border bg-surface px-4 py-5', + }, + h('p', { className: 'text-xs text-text-secondary' }, `步骤 ${index + 1}`), + h('p', { className: 'mt-2 text-sm font-medium' }, label) + ) + ) + ) + ), + }) + const disposeApp = api.apps.register({ + key: 'plugin-example', + mode: 'native', + path: '/plugin-example', + labelKey: 'pluginExample.app', + label: '插件示例', + descriptionKey: 'pluginExample.description', + description: '验证外部 Wework 插件的加载、路由注册和卸载', + }) + const disposeSettings = api.settings.register({ + key: 'plugin-example', + path: '/settings/plugin-example', + icon: () => null, + labelKey: 'pluginExample.settings', + label: '插件示例', + category: 'plugins', + categoryLabelKey: 'pluginExample.category', + categoryLabel: '插件', + render: () => + h( + 'main', + { className: 'mx-auto w-full max-w-3xl px-5 py-8 text-text-primary' }, + h('h1', { className: 'heading-medium' }, '插件示例'), + h( + 'p', + { className: 'mt-2 text-sm leading-6 text-text-secondary' }, + '这个设置页由示例插件注册。卸载插件后,侧栏入口和页面会一起移除。' + ), + h( + 'div', + { className: 'mt-6 rounded-xl border border-border bg-surface px-4 py-4' }, + h('p', { className: 'text-sm font-medium' }, '运行状态'), + h( + 'p', + { className: 'mt-1 text-sm text-text-secondary' }, + '本地前端模块已加载,路由、应用和设置注册表均可用。' + ) + ) + ), + }) + const disposeRightPanel = api.rightPanels.register({ + key: 'example', + label: '插件示例', + icon: ({ className }) => + h( + 'span', + { + 'aria-hidden': 'true', + className: `${className ?? ''} flex items-center justify-center rounded bg-primary/15 text-[10px] font-semibold text-primary`, + }, + 'P' + ), + render: () => + h( + 'section', + { + 'data-testid': 'plugin-example-right-panel', + className: + 'flex min-h-0 flex-1 flex-col overflow-y-auto bg-background px-6 py-8 text-text-primary', + }, + h('p', { className: 'text-xs font-medium text-primary' }, 'PLUGIN CONTRIBUTION'), + h('h1', { className: 'heading-medium mt-2' }, '插件右侧面板'), + h( + 'p', + { className: 'mt-3 text-sm leading-6 text-text-secondary' }, + '这个选项和面板由示例插件动态注册。卸载插件后,入口、标签页和内容会从工作台移除。' + ), + h( + 'div', + { className: 'mt-6 rounded-xl border border-border bg-surface px-4 py-4' }, + h('p', { className: 'text-sm font-medium' }, '扩展点状态'), + h( + 'p', + { className: 'mt-1 text-sm text-text-secondary' }, + 'api.rightPanels 已连接到 Wework 右侧工作区。' + ) + ) + ), + }) + + return () => { + disposeRightPanel() + disposeSettings() + disposeApp() + disposeRoute() + } + }, +} + +export default examplePlugin 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 9b89ef4d78e..085ad7b7d23 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()) @@ -5104,6 +5109,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, @@ -5248,6 +5265,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..fb8590b3cff --- /dev/null +++ b/wework/src-tauri/src/workbench_plugins.rs @@ -0,0 +1,564 @@ +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, Command, Stdio}; +use std::sync::{mpsc, Arc, Mutex}; +use std::thread; +use std::time::{Duration, Instant}; +use tauri::State; + +const MANIFEST_PATH: &str = ".wework-plugin/plugin.json"; +const SIDECAR_RESPONSE_TIMEOUT: Duration = Duration::from_secs(30); + +#[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, + frontend_source: Option, + desktop_path: Option, +} + +struct RunningSidecar { + child: Child, + stdin: ChildStdin, + responses: mpsc::Receiver>, + next_request_id: u64, + capabilities: Vec, +} + +#[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_bytes(bytes: &[u8], 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 actual = format!("{:x}", Sha256::digest(bytes)); + if actual != expected { + return Err(format!("{field} SHA-256 mismatch")); + } + Ok(()) +} + +fn verify_sha256(path: &Path, expected: &str, field: &str) -> Result<(), String> { + let bytes = fs::read(path).map_err(|error| format!("Failed to read {field}: {error}"))?; + verify_sha256_bytes(&bytes, expected, field) +} + +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 = manifest + .frontend + .as_ref() + .map(|frontend| { + let path = resolve_package_file(&root, &frontend.entry, "frontend.entry")?; + let bytes = fs::read(&path) + .map_err(|error| format!("Failed to read frontend.entry: {error}"))?; + verify_sha256_bytes(&bytes, &frontend.sha256, "frontend.entry")?; + let source = String::from_utf8(bytes) + .map_err(|_| "frontend.entry must contain valid UTF-8 JavaScript".to_string())?; + Ok::<(String, String), String>((path.to_string_lossy().into_owned(), source)) + }) + .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: frontend.as_ref().map(|(path, _)| path.clone()), + frontend_source: frontend.map(|(_, source)| source), + 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 canonical_plugin_search_roots() -> Vec { + plugin_search_roots() + .into_iter() + .filter_map(|root| fs::canonicalize(root).ok()) + .collect() +} + +fn inspect_discovered_plugin(raw_root: &str) -> Result { + let root = canonical_plugin_root(raw_root)?; + if !canonical_plugin_search_roots() + .iter() + .any(|search_root| root.starts_with(search_root)) + { + return Err("Plugin root is outside the approved local plugin directories".to_string()); + } + inspect_plugin(root.to_string_lossy().as_ref()) +} + +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); + } + } +} + +fn list_workbench_plugins() -> 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 async fn workbench_plugin_list() -> Result, String> { + tauri::async_runtime::spawn_blocking(list_workbench_plugins) + .await + .map_err(|error| format!("Failed to join Workbench plugin scan: {error}")) +} + +#[tauri::command] +pub fn workbench_plugin_inspect(plugin_root: String) -> Result { + inspect_discovered_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_discovered_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())?; + let (responses_tx, responses) = mpsc::channel(); + thread::spawn(move || { + for line in BufReader::new(stdout).lines() { + let message = line + .map_err(|error| format!("Failed to read plugin response: {error}")) + .and_then(|line| { + serde_json::from_str(&line) + .map_err(|error| format!("Plugin returned invalid JSON-RPC: {error}")) + }); + if responses_tx.send(message).is_err() { + break; + } + } + }); + sidecars.insert( + plugin_id, + Arc::new(Mutex::new(RunningSidecar { + child, + stdin, + responses, + next_request_id: 1, + capabilities: desktop.capabilities, + })), + ); + Ok(()) +} + +fn stop_sidecar(state: &WorkbenchPluginState, plugin_id: &str) -> Result<(), String> { + let sidecar = state + .sidecars + .lock() + .map_err(|_| "Workbench plugin state lock is poisoned".to_string())? + .remove(plugin_id); + if let Some(sidecar) = sidecar { + let mut sidecar = sidecar + .lock() + .map_err(|_| "Workbench plugin sidecar lock is poisoned".to_string())?; + let _ = sidecar.child.kill(); + let _ = sidecar.child.wait(); + } + Ok(()) +} + +fn read_matching_response( + sidecar: &RunningSidecar, + plugin_id: &str, + request_id: u64, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(format!( + "Workbench plugin '{plugin_id}' timed out waiting for response" + )); + } + let response = + sidecar + .responses + .recv_timeout(remaining) + .map_err(|error| match error { + mpsc::RecvTimeoutError::Timeout => { + format!("Workbench plugin '{plugin_id}' timed out waiting for response") + } + mpsc::RecvTimeoutError::Disconnected => { + format!("Workbench plugin '{plugin_id}' exited before responding") + } + })??; + if response.get("id").and_then(Value::as_u64) != Some(request_id) { + continue; + } + if let Some(error) = response.get("error") { + return Err(format!("Plugin JSON-RPC error: {error}")); + } + return Ok(response.get("result").cloned().unwrap_or(Value::Null)); + } +} + +#[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 sidecar = state + .sidecars + .lock() + .map_err(|_| "Workbench plugin state lock is poisoned".to_string())? + .get(&plugin_id) + .cloned() + .ok_or_else(|| format!("Workbench plugin '{plugin_id}' is not running"))?; + let result = { + let mut sidecar = sidecar + .lock() + .map_err(|_| "Workbench plugin sidecar lock is poisoned".to_string())?; + if !sidecar + .capabilities + .iter() + .any(|capability| capability == method) + { + return Err(format!( + "Workbench plugin '{plugin_id}' is not authorized for capability '{method}'" + )); + } + 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}"))?; + read_matching_response(&sidecar, &plugin_id, request_id, SIDECAR_RESPONSE_TIMEOUT) + }; + if result + .as_ref() + .is_err_and(|error| error.contains("timed out") || error.contains("exited")) + { + let _ = stop_sidecar(&state, &plugin_id); + } + result +} + +#[tauri::command] +pub fn workbench_plugin_stop( + state: State<'_, WorkbenchPluginState>, + plugin_id: String, +) -> Result<(), String> { + let plugin_id = normalize_plugin_id(&plugin_id)?; + stop_sidecar(&state, &plugin_id) +} + +#[tauri::command] +pub fn workbench_plugin_authorize_capability( + plugin_root: String, + capability: String, +) -> Result { + let inspected = inspect_discovered_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 (_, sidecar) in sidecars.drain() { + let Ok(mut sidecar) = sidecar.lock() else { + continue; + }; + 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") + ); + } + + #[test] + fn plugin_ids_accept_periods() { + assert_eq!( + normalize_plugin_id("example.plugin").unwrap(), + "example.plugin" + ); + } + + #[test] + fn response_reader_skips_notifications_and_mismatched_ids() { + let (sender, responses) = mpsc::channel(); + sender + .send(Ok(serde_json::json!({"jsonrpc":"2.0","method":"status"}))) + .unwrap(); + sender + .send(Ok( + serde_json::json!({"jsonrpc":"2.0","id":4,"result":"old"}), + )) + .unwrap(); + sender + .send(Ok( + serde_json::json!({"jsonrpc":"2.0","id":5,"result":"ok"}), + )) + .unwrap(); + let sidecar = RunningSidecar { + child: Command::new("true").spawn().unwrap(), + stdin: Command::new("cat") + .stdin(Stdio::piped()) + .spawn() + .unwrap() + .stdin + .take() + .unwrap(), + responses, + next_request_id: 1, + capabilities: vec![], + }; + + assert_eq!( + read_matching_response(&sidecar, "example", 5, Duration::from_secs(1)).unwrap(), + Value::String("ok".to_string()) + ); + } +} diff --git a/wework/src-tauri/tauri.conf.json b/wework/src-tauri/tauri.conf.json index 3101df1dc51..66f2584be56 100644 --- a/wework/src-tauri/tauri.conf.json +++ b/wework/src-tauri/tauri.conf.json @@ -38,9 +38,7 @@ "enable": true, "scope": { "requireLiteralLeadingDot": false, - "allow": [ - "**/*" - ] + "allow": ["**/*"] } } } @@ -56,10 +54,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "externalBin": [ - "binaries/wegent-executor", - "binaries/dws" - ], + "externalBin": ["binaries/wegent-executor", "binaries/dws"], "resources": [ "binaries/codex/**/*", "bundled-hooks/**/*", @@ -67,6 +62,7 @@ "bundled-plugins/wework-personal/.agents/plugins/marketplace.json", "bundled-plugins/wework-personal/.claude-plugin/marketplace.json", "bundled-plugins/wework-plugin-example/.codex-plugin/plugin.json", + "bundled-plugins/wework-plugin-example/.wework-plugin/plugin.json", "bundled-plugins/wework-plugin-example/.mcp.json" ], "android": { diff --git a/wework/src/App.tsx b/wework/src/App.tsx index b33abed00aa..d23b7c41059 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 @@ -187,23 +180,17 @@ function workspaceTabPath(tab: WorkspaceTab): string { function workspaceTabIframe( tab: WorkspaceTab, wegentUrl: string | null | undefined -): { src: string; title: string } | null { +): { appKey: string; 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 + return src ? { appKey: app.key, 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) @@ -309,6 +298,7 @@ function WorkspaceTabSurface({
- } - - return + const content = isTauriRuntime() && path === '/system-drag' ? : + return getWorkbenchPluginRuntime().slots.renderRoot(content) } function MainApp() { @@ -476,6 +464,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/layout/DesktopWorkbenchLayout.test.tsx b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx index 94be54fb23f..af8576a5725 100644 --- a/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx +++ b/wework/src/components/layout/DesktopWorkbenchLayout.test.tsx @@ -24,6 +24,10 @@ import { } from '@/features/workspace-tabs/workspaceTabsContextValue' import { openExternalUrl } from '@/lib/external-links' import { requestEmbeddedBrowserOpen } from '@/lib/embedded-browser' +import { + setActiveWorkbenchRightPanelRegistry, + WorkbenchRightPanelRegistry, +} from '@/plugin-runtime/right-panels' import { archiveLocalHarnessSession, closeLocalTerminal, @@ -5661,6 +5665,38 @@ describe('DesktopWorkbenchLayout', () => { expect(screen.getByTestId('workspace-browser-frame')).toHaveClass('bg-background') }) + test('opens and removes a plugin-contributed right workspace panel', async () => { + const registry = new WorkbenchRightPanelRegistry() + const restoreRegistry = setActiveWorkbenchRightPanelRegistry(registry) + const disposeContribution = registry.register({ + key: 'example', + label: '插件示例', + icon: () => null, + render: () =>
插件右侧面板
, + }) + + try { + renderWorkspacePanelLayout() + + await userEvent.click(screen.getByTestId('toggle-right-workspace-panel-button')) + await userEvent.click(screen.getByTestId('right-workspace-plugin-example-option')) + + expect(screen.getByTestId('right-workspace-plugin-example-tab')).toHaveAttribute( + 'aria-selected', + 'true' + ) + expect(screen.getByTestId('plugin-example-right-panel')).toHaveTextContent('插件右侧面板') + + act(() => disposeContribution()) + + expect(screen.queryByTestId('right-workspace-plugin-example-tab')).not.toBeInTheDocument() + expect(screen.queryByTestId('plugin-example-right-panel')).not.toBeInTheDocument() + expect(screen.getByTestId('right-workspace-launcher')).toBeInTheDocument() + } finally { + restoreRegistry() + } + }) + test('uses separate browser shortcuts for the closed and open right panel', async () => { renderWorkspacePanelLayout() diff --git a/wework/src/components/layout/DesktopWorkbenchMain.tsx b/wework/src/components/layout/DesktopWorkbenchMain.tsx index d40f61b4fa3..be3a14e1de1 100644 --- a/wework/src/components/layout/DesktopWorkbenchMain.tsx +++ b/wework/src/components/layout/DesktopWorkbenchMain.tsx @@ -65,6 +65,7 @@ import { useOptionalAppearance, } from '@/features/appearance' import { track } from '@/telemetry/client' +import { useActiveWorkbenchRightPanels } from '@/plugin-runtime/right-panels' import { BottomWorkspacePanel } from './workspace-panels/BottomWorkspacePanel' import { RightWorkspacePanel, @@ -74,6 +75,7 @@ import { type RightWorkspaceHarnessTab, type RightWorkspacePanelTab, type RightWorkspacePanelView, + type RightWorkspacePluginTab, type RightWorkspaceTerminalTab, } from './workspace-panels/RightWorkspacePanel' import { WorkspacePanelActions } from './workspace-panels/WorkspacePanelActions' @@ -892,6 +894,7 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ } = useWorkbenchPaneContext() const { services, openRuntimeTask } = useWorkbench() const { t } = useTranslation('common') + const pluginRightPanels = useActiveWorkbenchRightPanels() const [harnessSessionPickerTarget, setHarnessSessionPickerTarget] = useState< 'main' | 'right' | null >(null) @@ -1548,6 +1551,10 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ ? `calc(${rightPanelWidth} - ${rightPanelTabBarRightOffset} - ${COLLAPSED_RIGHT_TITLEBAR_ACTIONS_CLEARANCE})` : '0px' const temporaryChatAvailable = !activeLocalHarnessSession + const pluginRightPanelKeys = useMemo( + () => new Set(pluginRightPanels.map(panel => panel.key)), + [pluginRightPanels] + ) const effectiveRightPanelTabs = useMemo(() => { const canBrowseFiles = Boolean(workspaceProject || openFileRequest?.target) const availableContextTabs = workItemContextAvailable @@ -1559,18 +1566,24 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ const permittedTabs = temporaryChatAvailable ? workspaceTabs : workspaceTabs.filter(tab => !tab.startsWith('chat:')) + const registeredTabs = permittedTabs.filter( + tab => !tab.startsWith('plugin:') || pluginRightPanelKeys.has(tab.slice('plugin:'.length)) + ) if ( rightPanelView === 'launcher' || (!canBrowseFiles && rightPanelView === 'files') || - (!temporaryChatAvailable && rightPanelView.startsWith('chat:')) + (!temporaryChatAvailable && rightPanelView.startsWith('chat:')) || + (rightPanelView.startsWith('plugin:') && + !pluginRightPanelKeys.has(rightPanelView.slice('plugin:'.length))) ) { - return permittedTabs + return registeredTabs } - return permittedTabs.includes(rightPanelView) - ? permittedTabs - : [...permittedTabs, rightPanelView] + return registeredTabs.includes(rightPanelView) + ? registeredTabs + : [...registeredTabs, rightPanelView] }, [ openFileRequest?.target, + pluginRightPanelKeys, rightPanelTabs, rightPanelView, temporaryChatAvailable, @@ -1579,7 +1592,9 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ ]) const effectiveRightPanelView = (!temporaryChatAvailable && rightPanelView.startsWith('chat:')) || - (!workItemContextAvailable && rightPanelView === 'work-item') + (!workItemContextAvailable && rightPanelView === 'work-item') || + (rightPanelView.startsWith('plugin:') && + !pluginRightPanelKeys.has(rightPanelView.slice('plugin:'.length))) ? 'launcher' : rightPanelView const temporaryChatExpanded = @@ -2641,6 +2656,10 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ }, [setRightPanelOpen, setRightPanelTabs, setRightPanelView] ) + const selectPluginRightPanel = useCallback( + (key: string) => openRightPanelTab(`plugin:${key}` as RightWorkspacePluginTab), + [openRightPanelTab] + ) const currentWorkItemGuideProject = boundCloudProject ?? pendingCloudProject ?? defaultProject ?? defaultWorkItemPreviewProject const availableWorkItemProjects = @@ -4402,6 +4421,8 @@ const DesktopWorkbenchPane = memo(function DesktopWorkbenchPane({ onSelectPlan={selectPlanView} onSelectTab={selectRightPanelTab} onCloseTab={closeRightPanelTab} + pluginPanels={pluginRightPanels} + onSelectPluginPanel={selectPluginRightPanel} onHarnessSessionTitleChange={onLocalHarnessSessionTitleChange} onHarnessSessionExit={onLocalHarnessSessionExit} onRefreshReview={reviewState.reloadDiff ? refreshReview : undefined} diff --git a/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx b/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx index af751a440d4..5cf409ee3af 100644 --- a/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx +++ b/wework/src/components/layout/workspace-panels/RightWorkspacePanel.tsx @@ -9,7 +9,6 @@ import { SquareTerminal, X, } from 'lucide-react' -import type { LucideIcon } from 'lucide-react' import { memo, useCallback, useEffect, useState } from 'react' import type { ComponentType, KeyboardEvent as ReactKeyboardEvent, ReactNode } from 'react' import { @@ -41,6 +40,7 @@ import { WorkspaceAddMenu, type WorkspaceAddMenuItem } from './WorkspaceAddMenu' import { WorkspaceBrowserPanel } from './WorkspaceBrowserPanelContainer' import { WorkspacePanelCards } from './WorkspacePanelCards' import { TemporaryChatPanel } from './TemporaryChatPanel' +import type { WorkbenchRightPanelContribution } from '@/plugin-runtime/right-panels' function getRightWorkspaceShortcuts(platform: ReturnType) { if (platform === 'win') { @@ -63,6 +63,7 @@ export type RightWorkspaceChatTab = `chat:${string}` export type RightWorkspaceBrowserTab = `browser:${string}` export type RightWorkspaceHarnessTab = `harness:${string}` export type RightWorkspaceTerminalTab = `terminal:${string}` +export type RightWorkspacePluginTab = `plugin:${string}` export type RightWorkspacePanelTab = | 'review' | 'files' @@ -72,6 +73,7 @@ export type RightWorkspacePanelTab = | RightWorkspaceBrowserTab | RightWorkspaceHarnessTab | RightWorkspaceTerminalTab + | RightWorkspacePluginTab export type RightWorkspacePanelView = 'launcher' | RightWorkspacePanelTab function isRightWorkspaceChatTab(tab: RightWorkspacePanelView): tab is RightWorkspaceChatTab { @@ -92,6 +94,14 @@ function isRightWorkspaceTerminalTab( return tab.startsWith('terminal:') } +function isRightWorkspacePluginTab(tab: RightWorkspacePanelView): tab is RightWorkspacePluginTab { + return tab.startsWith('plugin:') +} + +function getRightWorkspacePluginKey(tab: RightWorkspacePluginTab) { + return tab.slice('plugin:'.length) +} + function getRightWorkspaceHarnessSessionId(tab: RightWorkspaceHarnessTab) { return tab.slice('harness:'.length) } @@ -190,6 +200,8 @@ interface RightWorkspacePanelProps { getChatInitialInput?: (tab: RightWorkspaceChatTab) => string | undefined getChatInitialAddress?: (tab: RightWorkspaceChatTab) => RuntimeTaskAddress | null | undefined onChatAddressChange?: (tab: RightWorkspaceChatTab, address: RuntimeTaskAddress | null) => void + pluginPanels?: readonly WorkbenchRightPanelContribution[] + onSelectPluginPanel?: (key: string) => void } interface RightWorkspaceBrowserPanelSlotProps { @@ -314,6 +326,8 @@ export const RightWorkspacePanel = memo(function RightWorkspacePanel({ getChatInitialInput, getChatInitialAddress, onChatAddressChange, + pluginPanels = [], + onSelectPluginPanel, }: RightWorkspacePanelProps) { const { t } = useTranslation('common') const availableTabs = allowTemporaryChat @@ -327,6 +341,7 @@ export const RightWorkspacePanel = memo(function RightWorkspacePanel({ const harnessSessionsById = new Map( harnessSessions.map(session => [session.sessionId, session] as const) ) + const pluginPanelsByKey = new Map(pluginPanels.map(panel => [panel.key, panel] as const)) useEffect(() => { if (!visible) return @@ -431,6 +446,13 @@ export const RightWorkspacePanel = memo(function RightWorkspacePanel({ }, ] : []), + ...pluginPanels.map(panel => ({ + id: `plugin:${panel.key}`, + testId: `right-workspace-plugin-${panel.key}-option`, + icon: panel.icon, + label: panel.label, + onSelect: () => onSelectPluginPanel?.(panel.key), + })), ] const tabBar = showTabs ? ( @@ -453,8 +475,14 @@ export const RightWorkspacePanel = memo(function RightWorkspacePanel({ key={tab} tab={tab} active={activeView === tab} - label={getRightWorkspaceTabLabel(tab, t, browserStates, harnessSessionsById)} - icon={getRightWorkspaceTabIcon(tab)} + label={getRightWorkspaceTabLabel( + tab, + t, + browserStates, + harnessSessionsById, + pluginPanelsByKey + )} + icon={getRightWorkspaceTabIcon(tab, pluginPanelsByKey)} iconSrc={isRightWorkspaceBrowserTab(tab) ? browserStates[tab]?.faviconUrl : null} onSelect={getTabSelectHandler(tab)} onClose={() => closeTab(tab)} @@ -515,6 +543,8 @@ export const RightWorkspacePanel = memo(function RightWorkspacePanel({ onSelectBrowser={onSelectBrowser} onSelectFiles={onSelectFiles} onSelectChat={onSelectChat} + pluginPanels={pluginPanels} + onSelectPluginPanel={onSelectPluginPanel} /> ) : !isRightWorkspaceChatTab(activeView) && activeView === 'review' ? ( ) : !isRightWorkspaceChatTab(activeView) && activeView === 'work-item' ? ( workItemPanel + ) : isRightWorkspacePluginTab(activeView) ? ( + (pluginPanelsByKey.get(getRightWorkspacePluginKey(activeView))?.render() ?? null) ) : !isRightWorkspaceChatTab(activeView) && workspaceTargetError ? (
iconSrc?: string | null onSelect: () => void onClose: () => void @@ -787,6 +819,8 @@ function RightWorkspaceLauncher({ onSelectBrowser, onSelectFiles, onSelectChat, + pluginPanels, + onSelectPluginPanel, }: { canOpenReview: boolean canBrowseFiles: boolean @@ -797,6 +831,8 @@ function RightWorkspaceLauncher({ onSelectBrowser: () => void onSelectFiles: () => void onSelectChat: () => void + pluginPanels: readonly WorkbenchRightPanelContribution[] + onSelectPluginPanel?: (key: string) => void }) { const { t } = useTranslation('common') const platform = getPlatform() @@ -818,6 +854,15 @@ function RightWorkspaceLauncher({ disabled={action.disabled} /> ))} + {pluginPanels.map(panel => ( + onSelectPluginPanel?.(panel.key)} + /> + ))} ['t'], browserStates: Partial>, - harnessSessionsById: Map + harnessSessionsById: Map, + pluginPanelsByKey: Map ) { if (tab === 'review') return t('workbench.workspace_tab_review', '审查') if (isRightWorkspaceTerminalTab(tab)) { @@ -919,6 +965,9 @@ function getRightWorkspaceTabLabel( t('workbench.harness_session_picker_title', '新建编码会话') ) } + if (isRightWorkspacePluginTab(tab)) { + return pluginPanelsByKey.get(getRightWorkspacePluginKey(tab))?.label ?? 'Plugin' + } if (tab === 'plan') return t('workbench.workspace_tab_plan', '计划') if (tab === 'work-item') return t('workbench.work_item_detail', 'Issue 详情') return t('workbench.workspace_tab_files', '文件') @@ -941,15 +990,24 @@ function getRightWorkspaceTabTestId(tab: RightWorkspacePanelTab) { if (isRightWorkspaceHarnessTab(tab)) { return `right-workspace-harness-tab-${getRightWorkspaceHarnessSessionId(tab)}` } + if (isRightWorkspacePluginTab(tab)) { + return `right-workspace-plugin-${getRightWorkspacePluginKey(tab)}-tab` + } return `right-workspace-${tab}-tab` } -function getRightWorkspaceTabIcon(tab: RightWorkspacePanelTab) { +function getRightWorkspaceTabIcon( + tab: RightWorkspacePanelTab, + pluginPanelsByKey: Map +) { if (tab === 'review') return FileDiff if (isRightWorkspaceTerminalTab(tab)) return SquareTerminal if (isRightWorkspaceBrowserTab(tab)) return Globe2 if (isRightWorkspaceChatTab(tab)) return MessageCircle if (isRightWorkspaceHarnessTab(tab)) return SquareTerminal + if (isRightWorkspacePluginTab(tab)) { + return pluginPanelsByKey.get(getRightWorkspacePluginKey(tab))?.icon ?? LayoutDashboard + } if (tab === 'plan') return ListChecks if (tab === 'work-item') return LayoutDashboard return File diff --git a/wework/src/components/settings/ConnectionsSettingsPage.tsx b/wework/src/components/settings/ConnectionsSettingsPage.tsx index 47fb708c8c5..eea011a644c 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,46 @@ export function ConnectionsSettingsPage({ const background = getWorkbenchBackground(appearance, appearanceContext?.resolvedMode ?? 'light') const { sidebarWidth, handleResizeStart } = useResizableSidebar() const isDesktopRuntime = isTauriRuntime() - const visibleSettingsNavItems = settingsNavItems.filter( - item => - (!item.experimental || experimentalFeaturesEnabled) && - (!['keyboard-shortcuts', 'appshots'].includes(item.key) || isDesktopRuntime) + const registeredSettings = useActiveWorkbenchSettings() + const settingsContributions: readonly WorkbenchSettingsContribution[] = useMemo( + () => (registeredSettings.length > 0 ? registeredSettings : CORE_WORKBENCH_SETTINGS), + [registeredSettings] + ) + const visibleSettingsNavItems = useMemo( + () => + settingsContributions.filter( + item => + (!item.experimental || experimentalFeaturesEnabled) && + (!item.desktopOnly || isDesktopRuntime) + ), + [experimentalFeaturesEnabled, isDesktopRuntime, settingsContributions] ) const shouldAutoOpenAddCloudDeviceDialog = autoOpenAddCloudDeviceDialog || new URLSearchParams(window.location.search).get('addDevice') === '1' - const [activeNav, setActiveNav] = useState(() => getSettingsNavFromPath(window.location.pathname)) + const [, setActiveNav] = useState(() => + getSettingsNavFromPath(window.location.pathname, settingsContributions) + ) + const routeActiveNav = getSettingsNavFromPath(window.location.pathname, settingsContributions) const effectiveActiveNav = - activeNav === 'harnesses' && !experimentalFeaturesEnabled ? 'general' : activeNav + visibleSettingsNavItems.find(item => item.key === routeActiveNav)?.key ?? 'general' 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 = + visibleSettingsNavItems.find(item => item.key === effectiveActiveNav) ?? + visibleSettingsNavItems.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)}
)}
) diff --git a/wework/src/components/topnav/AppIframe.test.tsx b/wework/src/components/topnav/AppIframe.test.tsx index f2ec525e628..6d4395e2a3a 100644 --- a/wework/src/components/topnav/AppIframe.test.tsx +++ b/wework/src/components/topnav/AppIframe.test.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react' import { render, screen, fireEvent, waitFor } from '@testing-library/react' -import { beforeEach, describe, expect, test, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' import { AppIframe } from './AppIframe' const embeddedBrowserMocks = vi.hoisted(() => ({ @@ -21,6 +21,10 @@ vi.mock('@/lib/embedded-browser', () => embeddedBrowserMocks) vi.mock('@/lib/runtime-environment', () => runtimeMocks) describe('AppIframe', () => { + afterEach(() => { + vi.restoreAllMocks() + }) + beforeEach(() => { runtimeMocks.isTauriRuntime.mockReturnValue(false) embeddedBrowserMocks.closeEmbeddedBrowser.mockClear() @@ -30,7 +34,7 @@ describe('AppIframe', () => { }) test('renders iframe with src and title', () => { - render() + render() const iframe = screen.getByTitle('Wegent') expect(iframe).toBeInTheDocument() expect(iframe).toHaveAttribute('src', 'http://localhost:3000') @@ -38,25 +42,57 @@ describe('AppIframe', () => { }) test('shows loading spinner initially', () => { - render() + render() expect(screen.getByText('Loading Wegent...')).toBeInTheDocument() }) + test('keeps app identity stable when the display title is localized', async () => { + runtimeMocks.isTauriRuntime.mockReturnValue(true) + vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({ + bottom: 620, + height: 600, + left: 10, + right: 810, + top: 20, + width: 800, + x: 10, + y: 20, + toJSON: () => ({}), + }) + const { container } = render( + + ) + + expect(container.querySelector('[data-testid="app-iframe-wegent"]')).not.toBeNull() + await waitFor(() => + expect(embeddedBrowserMocks.openEmbeddedBrowser).toHaveBeenCalledWith( + 'http://localhost:3000', + { x: 10, y: 20, width: 800, height: 600 }, + 'app-wegent-agent-localized' + ) + ) + }) + test('hides loading on iframe load', () => { - render() + render() const iframe = screen.getByTitle('Wegent') fireEvent.load(iframe) expect(screen.queryByText('Loading Wegent...')).not.toBeInTheDocument() }) test('has sandbox attribute for security', () => { - render() + render() const iframe = screen.getByTitle('Wegent') expect(iframe).toHaveAttribute('sandbox') }) test('allows popup links to escape the iframe sandbox', () => { - render() + render() const iframe = screen.getByTitle('Wegent') expect(iframe).toHaveAttribute( 'sandbox', @@ -78,7 +114,12 @@ describe('AppIframe', () => { toJSON: () => ({}), }) const { container } = render( - + ) await waitFor(() => @@ -108,7 +149,12 @@ describe('AppIframe', () => { render( - + ) @@ -134,20 +180,33 @@ describe('AppIframe', () => { toJSON: () => ({}), }) const { rerender } = render( - + ) await waitFor(() => expect(embeddedBrowserMocks.openEmbeddedBrowser).toHaveBeenCalledTimes(1)) rerender( ) rerender( - + ) await waitFor(() => @@ -187,13 +246,20 @@ describe('AppIframe', () => { toJSON: () => ({}), }) const { rerender } = render( - + ) await waitFor(() => expect(embeddedBrowserMocks.openEmbeddedBrowser).toHaveBeenCalledTimes(1)) rerender( (null) const openedRef = useRef(false) @@ -50,7 +51,7 @@ export function AppIframe({ active = true, src, title, workspaceTabId }: AppIfra const [loading, setLoading] = useState(true) const [error, setError] = useState(false) const [retryGeneration, setRetryGeneration] = useState(0) - const label = nativeLabel(title, workspaceTabId) + const label = nativeLabel(appKey, workspaceTabId) useLayoutEffect(() => { activeRef.current = active @@ -203,7 +204,7 @@ export function AppIframe({ active = true, src, title, workspaceTabId }: AppIfra
diff --git a/wework/src/components/topnav/useChromeTabs.ts b/wework/src/components/topnav/useChromeTabs.ts index 0d26b6f6cca..314646957a0 100644 --- a/wework/src/components/topnav/useChromeTabs.ts +++ b/wework/src/components/topnav/useChromeTabs.ts @@ -1,32 +1,42 @@ import { useCallback, useMemo } from 'react' -import { APP_TABS, DEFAULT_APP_KEY } from '@/config/apps' import { stripAppBasePath } from '@/config/runtime' import { navigateTo } from '@/lib/navigation' +import { + getActiveWorkbenchAppRegistry, + type WorkbenchAppContribution, + useActiveWorkbenchApps, +} from '@/plugin-runtime/apps' +import { CORE_WORKBENCH_APPS } from '@/plugin-runtime/core-apps-data' + +const DEFAULT_APP_KEY = 'wework' export function useChromeTabs(currentPath: string) { const normalizedPath = stripAppBasePath(currentPath) + const registeredTabs = useActiveWorkbenchApps() + const tabs: readonly WorkbenchAppContribution[] = + registeredTabs.length > 0 ? registeredTabs : CORE_WORKBENCH_APPS const activeAppKey = useMemo(() => { const match = normalizedPath.match(/^\/app\/([^/]+)/) - if (match && APP_TABS.some(t => t.key === match[1])) return match[1] + if (match && tabs.some(t => t.key === match[1])) return match[1] - const nativeMatch = APP_TABS.find( + const nativeMatch = tabs.find( tab => tab.mode === 'native' && tab.path && tab.path !== '/' && normalizedPath === tab.path ) if (nativeMatch) return nativeMatch.key return DEFAULT_APP_KEY - }, [normalizedPath]) + }, [normalizedPath, tabs]) const activeTab = useMemo( - () => APP_TABS.find(t => t.key === activeAppKey) ?? null, - [activeAppKey] + () => tabs.find(t => t.key === activeAppKey) ?? null, + [activeAppKey, tabs] ) const isNativeApp = activeTab?.mode === 'native' const navigateToApp = useCallback((appKey: string) => { - const tab = APP_TABS.find(item => item.key === appKey) + const tab = getActiveWorkbenchAppRegistry().resolve(appKey) if (!tab) return if (tab.mode === 'native') { @@ -41,6 +51,6 @@ export function useChromeTabs(currentPath: string) { activeTab, isNativeApp, navigateToApp, - tabs: APP_TABS.filter(tab => !tab.hidden), + tabs: tabs.filter(tab => !tab.hidden), } } diff --git a/wework/src/config/apps.ts b/wework/src/config/apps.ts deleted file mode 100644 index 3d35906ed23..00000000000 --- a/wework/src/config/apps.ts +++ /dev/null @@ -1,22 +0,0 @@ -export interface AppTab { - key: string - label: string - mode: 'native' | 'iframe' - path?: string - url?: string - requiresAuth?: boolean - hidden?: boolean -} - -export const APP_TABS: AppTab[] = [ - { key: 'wework', label: 'WeWork', mode: 'native', path: '/', requiresAuth: true }, - { - key: 'wegent', - label: 'Wegent', - mode: 'iframe', - requiresAuth: true, - hidden: true, - }, -] - -export const DEFAULT_APP_KEY = 'wework' diff --git a/wework/src/features/workbench/WorkbenchProvider.test.tsx b/wework/src/features/workbench/WorkbenchProvider.test.tsx index eab32b4b50f..b5e6fad959d 100644 --- a/wework/src/features/workbench/WorkbenchProvider.test.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.test.tsx @@ -2195,6 +2195,160 @@ describe('WorkbenchProvider runtime tasks', () => { ).toBe('owned-turn') }) + test('finishes an owned lifecycle reconciliation after its provider becomes inactive', async () => { + const lifecycleStore = new RuntimeTaskLifecycleStore('test') + const address: RuntimeTaskAddress = { + deviceId: 'device-1', + taskId: 'shared-task', + workspacePath: '/workspace/project-alpha', + } + const initialRuntimeWork = createRuntimeWork({ + projects: [ + { + project: { id: 7, name: 'Wegent' }, + deviceWorkspaces: [ + { + deviceId: address.deviceId, + workspacePath: address.workspacePath, + available: true, + tasks: [ + { + taskId: address.taskId, + workspacePath: address.workspacePath, + title: 'Goal task', + runtime: 'codex', + running: false, + status: 'active', + }, + ], + }, + ], + }, + ], + }) + const completedRuntimeWork = createRuntimeWork({ + projects: [ + { + project: { id: 7, name: 'Wegent' }, + deviceWorkspaces: [ + { + deviceId: address.deviceId, + workspacePath: address.workspacePath, + available: true, + tasks: [ + { + taskId: address.taskId, + workspacePath: address.workspacePath, + title: 'Goal task', + runtime: 'codex', + running: false, + status: 'done', + turnStatus: 'completed', + completedAt: '2026-08-19T13:21:32.000Z', + }, + ], + }, + ], + }, + ], + }) + const completedRuntimeWorkDeferred = deferred() + const completedGoalDeferred = deferred() + let reconcileCompletion = false + let streamHandlers: ChatStreamHandlers = {} + const listRuntimeWork = vi.fn(() => + reconcileCompletion + ? completedRuntimeWorkDeferred.promise + : Promise.resolve(initialRuntimeWork) + ) + const getRuntimeGoal = vi.fn(() => + reconcileCompletion + ? completedGoalDeferred.promise + : Promise.resolve({ accepted: true, goal: null }) + ) + const runtimeWorkApi = createRuntimeWorkApiMock({ + listRuntimeWork, + getRuntimeGoal, + }) + const services = createWorkbenchServices({ + runtimeWorkApi: runtimeWorkApi as WorkbenchServices['runtimeWorkApi'], + chatStream: { + subscribe: vi.fn((handlers: ChatStreamHandlers) => { + if (hasRuntimeStreamHandler(handlers)) streamHandlers = handlers + return vi.fn() + }), + } as unknown as WorkbenchServices['chatStream'], + }) + const renderProvider = (syncRuntimeTaskLifecycle: boolean) => ( + + + + ) + + const { rerender } = render(renderProvider(true)) + + await waitFor(() => expect(streamHandlers.onChatStart).toBeDefined()) + await waitFor(() => expect(lifecycleStore.getTask(address)).not.toBeNull()) + const runtimeWorkCallsBeforeSettlement = listRuntimeWork.mock.calls.length + const goalCallsBeforeSettlement = getRuntimeGoal.mock.calls.length + act(() => { + streamHandlers.onRuntimeGoalUpdated?.({ + taskId: address.taskId, + subtaskId: 'goal-turn', + deviceId: address.deviceId, + goal: createRuntimeGoal({ + objective: 'Complete after switching tabs', + status: 'active', + }), + }) + streamHandlers.onChatStart?.({ + taskId: address.taskId, + subtaskId: 'goal-turn', + shellType: 'Codex', + deviceId: address.deviceId, + }) + }) + expect(lifecycleStore.getTask(address)?.derived.isRunning).toBe(true) + + reconcileCompletion = true + act(() => { + streamHandlers.onChatDone?.({ + taskId: address.taskId, + subtaskId: 'goal-turn', + deviceId: address.deviceId, + result: { value: 'done' }, + }) + }) + await waitFor(() => + expect(listRuntimeWork.mock.calls.length).toBeGreaterThan(runtimeWorkCallsBeforeSettlement) + ) + await waitFor(() => + expect(getRuntimeGoal.mock.calls.length).toBeGreaterThan(goalCallsBeforeSettlement) + ) + + act(() => rerender(renderProvider(false))) + await act(async () => { + completedRuntimeWorkDeferred.resolve(completedRuntimeWork) + completedGoalDeferred.resolve({ + accepted: true, + goal: createRuntimeGoal({ + objective: 'Complete after switching tabs', + status: 'complete', + }), + }) + await Promise.all([completedRuntimeWorkDeferred.promise, completedGoalDeferred.promise]) + }) + + await waitFor(() => expect(lifecycleStore.getTask(address)?.goalStatus).toBe('complete')) + expect(lifecycleStore.getTask(address)?.execution.phase).toBe('idle') + expect(lifecycleStore.getTask(address)?.derived.isRunning).toBe(false) + }) + test('keeps the runtime event subscription across connected user preference updates', async () => { setTauriRuntime() window.__WEWORK_RUNTIME_CONFIG__ = { diff --git a/wework/src/features/workbench/WorkbenchProvider.tsx b/wework/src/features/workbench/WorkbenchProvider.tsx index 3a81a961765..504841350f7 100644 --- a/wework/src/features/workbench/WorkbenchProvider.tsx +++ b/wework/src/features/workbench/WorkbenchProvider.tsx @@ -1700,10 +1700,11 @@ export function WorkbenchProvider({ ) const stableRefreshWorkLists = useStableEvent(refreshWorkLists) const syncRuntimeTaskSnapshot = useStableEvent((address: RuntimeTaskAddress) => { - const expectedLifecycle = lifecycleStore.getTask(address) + if (!canSyncRuntimeTaskLifecycle()) return + const expectedLifecycle = sharedLifecycleStore.getTask(address) void refreshRuntimeTask(address) .then(task => { - if (task && lifecycleStore.syncRuntimeTask(address, task, expectedLifecycle)) { + if (task && sharedLifecycleStore.syncRuntimeTask(address, task, expectedLifecycle)) { updateLocalRuntimeTaskSnapshot(address, task) } }) @@ -1716,7 +1717,8 @@ export function WorkbenchProvider({ }) }) const syncRuntimeGoalSnapshot = useStableEvent((address: RuntimeTaskAddress) => { - const expectedGoalStatus = lifecycleStore.getTask(address)?.goalStatus + if (!canSyncRuntimeTaskLifecycle()) return + const expectedGoalStatus = sharedLifecycleStore.getTask(address)?.goalStatus if (expectedGoalStatus === null || expectedGoalStatus === undefined) return void runtimeTasks @@ -1726,7 +1728,7 @@ export function WorkbenchProvider({ const goal = response.goal if (!goal) return setRuntimeConversationGoal(address, goal) - lifecycleStore.goalStatusReceived(address, goal.status) + sharedLifecycleStore.goalStatusReceived(address, goal.status) }) .catch(error => { console.warn('[Wework] Runtime Goal snapshot sync failed', { diff --git a/wework/src/lib/navigation.ts b/wework/src/lib/navigation.ts index bc635cf5195..9b79fd441ef 100644 --- a/wework/src/lib/navigation.ts +++ b/wework/src/lib/navigation.ts @@ -1,5 +1,7 @@ import { getRuntimeConfig } from '@/config/runtime' import type { DesktopAppKey } from '@/components/layout/DesktopAppSwitcher' +import { getActiveWorkbenchAppRegistry } from '@/plugin-runtime/apps' +import { CORE_WORKBENCH_APPS } from '@/plugin-runtime/core-apps-data' function joinBrowserPath(basePath: string | undefined, path: string): string { const normalizedBasePath = !basePath || basePath === '/' ? '' : basePath.replace(/\/+$/, '') @@ -24,16 +26,11 @@ export function navigateTo(path: string) { } export function resolveDesktopAppRoute(app: DesktopAppKey): string { - switch (app) { - case 'wework': - return '/' - case 'todo': - return '/todo' - case 'wegent': - return '/app/wegent' - default: - return '/' - } + const registry = getActiveWorkbenchAppRegistry() + const contribution = + registry.resolve(app) ?? CORE_WORKBENCH_APPS.find(candidate => candidate.key === app) + if (!contribution) return '/' + return contribution.mode === 'native' ? contribution.path || '/' : `/app/${contribution.key}` } export interface RuntimeTaskRoute { diff --git a/wework/src/main.tsx b/wework/src/main.tsx index 1612fb04a8f..49a71d8c637 100644 --- a/wework/src/main.tsx +++ b/wework/src/main.tsx @@ -15,6 +15,7 @@ import { installWeworkAutomationBridge } from './e2e/automation' import { installDesktopExtensions } from '@extensions/desktop' import { isTauriRuntime } from '@/lib/runtime-environment' import { installFrontendRecoveryBridge } from '@/lib/frontendRecovery' +import { initializeWorkbenchPluginRuntime } from '@/plugin-runtime/bootstrap' const isSystemDragPanel = isTauriRuntime() && window.location.pathname === '/system-drag' if (!isSystemDragPanel) { @@ -46,6 +47,17 @@ function renderApp(): void { } if (!isSystemDragPanel) { - await installWeworkAutomationBridge() + try { + await initializeWorkbenchPluginRuntime() + await installWeworkAutomationBridge() + } catch (error) { + const root = document.getElementById('root') + if (root) { + root.textContent = `Wework failed to initialize: ${ + error instanceof Error ? error.message : String(error) + }` + } + throw error + } } renderApp() diff --git a/wework/src/plugin-runtime/DynamicWorkbenchPluginHost.test.tsx b/wework/src/plugin-runtime/DynamicWorkbenchPluginHost.test.tsx new file mode 100644 index 00000000000..cf34c7884da --- /dev/null +++ b/wework/src/plugin-runtime/DynamicWorkbenchPluginHost.test.tsx @@ -0,0 +1,123 @@ +import { act, render, waitFor } from '@testing-library/react' +import { beforeEach, describe, expect, test, vi } from 'vitest' + +import { LOCAL_PLUGIN_SKILLS_CHANGED_EVENT } from '@/features/plugins/pluginTrial' + +import { DynamicWorkbenchPluginHost } from './DynamicWorkbenchPluginHost' + +const mocks = vi.hoisted(() => ({ + dispose: vi.fn(), + listDeviceWorkbenchPlugins: vi.fn(), + listInstalledPlugins: vi.fn(), + reconcile: vi.fn(), +})) + +vi.mock('@/api/local/codexPlugins', () => ({ + createLocalCodexPluginApi: () => ({ + listInstalledPlugins: mocks.listInstalledPlugins, + }), +})) + +vi.mock('./bootstrap', () => ({ + getWorkbenchPluginRuntime: () => ({}), +})) + +vi.mock('./external', () => ({ + ExternalWorkbenchPluginLoader: class { + reconcile = mocks.reconcile + dispose = mocks.dispose + }, + listDeviceWorkbenchPlugins: mocks.listDeviceWorkbenchPlugins, +})) + +function inspectedPlugin(name: string, required = false) { + return { + root: `/plugins/${name}`, + frontendPath: `/plugins/${name}/frontend.js`, + frontendSource: 'export default {}', + desktopPath: null, + manifest: { + name, + apiVersion: '1', + required, + pinnedToClientVersion: false, + frontend: { + entry: 'frontend.js', + export: 'default', + sha256: '0'.repeat(64), + }, + desktop: null, + }, + } +} + +function installedPlugin(name: string, enabled = true) { + return { + spec: { + enabled, + installState: 'installed', + source: { + pluginKey: name, + }, + }, + } +} + +describe('DynamicWorkbenchPluginHost', () => { + beforeEach(() => { + mocks.dispose.mockReset().mockResolvedValue(undefined) + mocks.listDeviceWorkbenchPlugins + .mockReset() + .mockResolvedValue([ + inspectedPlugin('installed'), + inspectedPlugin('disabled'), + inspectedPlugin('required', true), + ]) + mocks.listInstalledPlugins.mockReset().mockResolvedValue({ + items: [installedPlugin('installed'), installedPlugin('disabled', false)], + }) + mocks.reconcile.mockReset().mockResolvedValue(undefined) + }) + + test('loads only locally installed plugins and rescans after local plugin changes', async () => { + const view = render() + + await waitFor(() => { + expect(mocks.reconcile).toHaveBeenCalledWith([ + expect.objectContaining({ manifest: expect.objectContaining({ name: 'installed' }) }), + expect.objectContaining({ manifest: expect.objectContaining({ name: 'required' }) }), + ]) + }) + + mocks.listInstalledPlugins.mockResolvedValue({ + items: [installedPlugin('disabled')], + }) + act(() => { + window.dispatchEvent(new Event(LOCAL_PLUGIN_SKILLS_CHANGED_EVENT)) + }) + + await waitFor(() => { + expect(mocks.reconcile).toHaveBeenLastCalledWith([ + expect.objectContaining({ manifest: expect.objectContaining({ name: 'disabled' }) }), + expect.objectContaining({ manifest: expect.objectContaining({ name: 'required' }) }), + ]) + }) + + view.unmount() + await waitFor(() => expect(mocks.dispose).toHaveBeenCalledOnce()) + }) + + test('loads required plugins only when local installed state is unavailable', async () => { + mocks.listInstalledPlugins.mockRejectedValue(new Error('local state unavailable')) + + const view = render() + + await waitFor(() => { + expect(mocks.reconcile).toHaveBeenCalledWith([ + expect.objectContaining({ manifest: expect.objectContaining({ name: 'required' }) }), + ]) + }) + + view.unmount() + }) +}) diff --git a/wework/src/plugin-runtime/DynamicWorkbenchPluginHost.tsx b/wework/src/plugin-runtime/DynamicWorkbenchPluginHost.tsx new file mode 100644 index 00000000000..91dba1c7b63 --- /dev/null +++ b/wework/src/plugin-runtime/DynamicWorkbenchPluginHost.tsx @@ -0,0 +1,77 @@ +import { useEffect } from 'react' + +import { createLocalCodexPluginApi } from '@/api/local/codexPlugins' +import { LOCAL_PLUGIN_SKILLS_CHANGED_EVENT } from '@/features/plugins/pluginTrial' + +import { getWorkbenchPluginRuntime } from './bootstrap' +import { + ExternalWorkbenchPluginLoader, + listDeviceWorkbenchPlugins, + type InspectedWorkbenchPlugin, +} from './external' + +function filterDesiredPlugins( + localPlugins: InspectedWorkbenchPlugin[], + desiredNames: ReadonlySet | null +) { + if (!desiredNames) return localPlugins.filter(plugin => plugin.manifest.required) + return localPlugins.filter( + plugin => plugin.manifest.required || desiredNames.has(plugin.manifest.name) + ) +} + +export function DynamicWorkbenchPluginHost() { + useEffect(() => { + const loader = new ExternalWorkbenchPluginLoader(getWorkbenchPluginRuntime()) + const pluginApi = createLocalCodexPluginApi() + let cancelled = false + let generation = 0 + let reconcileChain = Promise.resolve() + + const load = async () => { + const currentGeneration = ++generation + const localPlugins = await listDeviceWorkbenchPlugins() + let desiredNames: ReadonlySet | null = null + try { + const installed = await pluginApi.listInstalledPlugins() + desiredNames = new Set( + installed.items + .filter(plugin => plugin.spec.enabled && plugin.spec.installState !== 'uninstalled') + .map(plugin => plugin.spec.source.pluginKey) + ) + } catch (error) { + console.warn( + '[Wework] Failed to read local plugin state; loading required plugins only:', + error + ) + } + if (!cancelled && currentGeneration === generation) { + reconcileChain = reconcileChain + .catch(() => undefined) + .then(async () => { + if (!cancelled && currentGeneration === generation) { + await loader.reconcile(filterDesiredPlugins(localPlugins, desiredNames)) + } + }) + await reconcileChain + } + } + + const refresh = () => { + void load().catch(error => { + console.error('[Wework] Failed to load workbench plugins:', error) + }) + } + + window.addEventListener(LOCAL_PLUGIN_SKILLS_CHANGED_EVENT, refresh) + refresh() + + return () => { + cancelled = true + window.removeEventListener(LOCAL_PLUGIN_SKILLS_CHANGED_EVENT, refresh) + void reconcileChain.finally(() => loader.dispose()) + } + }, []) + + return null +} diff --git a/wework/src/plugin-runtime/apps.test.tsx b/wework/src/plugin-runtime/apps.test.tsx new file mode 100644 index 00000000000..82e4b75bf09 --- /dev/null +++ b/wework/src/plugin-runtime/apps.test.tsx @@ -0,0 +1,43 @@ +import { act, renderHook } from '@testing-library/react' +import { describe, expect, test } from 'vitest' + +import { setActiveWorkbenchAppRegistry, useActiveWorkbenchApps, WorkbenchAppRegistry } from './apps' + +function app(key: string) { + return { + key, + mode: 'native' as const, + path: `/${key}`, + labelKey: `${key}.label`, + label: key, + descriptionKey: `${key}.description`, + description: key, + } +} + +describe('active Workbench app registry', () => { + test('keeps mounted consumers subscribed across registry replacement and restoration', () => { + const first = new WorkbenchAppRegistry() + const restoreFirst = setActiveWorkbenchAppRegistry(first) + const hook = renderHook(() => useActiveWorkbenchApps()) + + act(() => { + first.register(app('first')) + }) + expect(hook.result.current.map(item => item.key)).toEqual(['first']) + + const second = new WorkbenchAppRegistry() + let restoreSecond = () => undefined + act(() => { + restoreSecond = setActiveWorkbenchAppRegistry(second) + second.register(app('second')) + }) + expect(hook.result.current.map(item => item.key)).toEqual(['second']) + + act(() => restoreSecond()) + expect(hook.result.current.map(item => item.key)).toEqual(['first']) + + hook.unmount() + restoreFirst() + }) +}) diff --git a/wework/src/plugin-runtime/apps.ts b/wework/src/plugin-runtime/apps.ts new file mode 100644 index 00000000000..cde9fc7c465 --- /dev/null +++ b/wework/src/plugin-runtime/apps.ts @@ -0,0 +1,109 @@ +import { useSyncExternalStore } from 'react' + +export interface WorkbenchAppContribution { + key: string + mode: 'native' | 'iframe' + path?: string + url?: string + requiresAuth?: boolean + hidden?: boolean + experimental?: boolean + requiresCloud?: boolean + labelKey: string + label: string + descriptionKey: string + description: string +} + +export class WorkbenchAppRegistry { + private readonly apps = new Map() + private readonly listeners = new Set<() => void>() + private snapshot: readonly WorkbenchAppContribution[] = [] + private revision = 0 + + private emit(): void { + this.snapshot = Array.from(this.apps.values()) + this.revision += 1 + for (const listener of this.listeners) listener() + } + + register(app: WorkbenchAppContribution): () => void { + if (this.apps.has(app.key)) { + throw new Error(`Workbench app '${app.key}' is already registered`) + } + this.apps.set(app.key, app) + this.emit() + return () => { + if (this.apps.get(app.key) === app) { + this.apps.delete(app.key) + this.emit() + } + } + } + + resolve(key: string): WorkbenchAppContribution | null { + return this.apps.get(key) ?? null + } + + list(): readonly WorkbenchAppContribution[] { + return this.snapshot + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + version(): number { + return this.revision + } +} + +let activeRegistry = new WorkbenchAppRegistry() +const activeRegistryListeners = new Set<() => void>() +let activeRegistryRevision = 0 +let unsubscribeActiveRegistry = activeRegistry.subscribe(notifyActiveRegistryListeners) + +function notifyActiveRegistryListeners(): void { + for (const listener of activeRegistryListeners) listener() +} + +function replaceActiveRegistry(registry: WorkbenchAppRegistry): void { + unsubscribeActiveRegistry() + activeRegistry = registry + unsubscribeActiveRegistry = registry.subscribe(notifyActiveRegistryListeners) + activeRegistryRevision += 1 + notifyActiveRegistryListeners() +} + +function subscribeActiveRegistry(listener: () => void): () => void { + activeRegistryListeners.add(listener) + return () => activeRegistryListeners.delete(listener) +} + +function getActiveRegistrySnapshot(): string { + return `${activeRegistryRevision}:${activeRegistry.version()}` +} + +export function getActiveWorkbenchAppRegistry(): WorkbenchAppRegistry { + return activeRegistry +} + +export function useActiveWorkbenchApps(): readonly WorkbenchAppContribution[] { + useSyncExternalStore( + subscribeActiveRegistry, + getActiveRegistrySnapshot, + getActiveRegistrySnapshot + ) + return activeRegistry.list() +} + +export function setActiveWorkbenchAppRegistry(registry: WorkbenchAppRegistry): () => void { + const previous = activeRegistry + replaceActiveRegistry(registry) + return () => { + if (activeRegistry === registry) { + replaceActiveRegistry(previous) + } + } +} diff --git a/wework/src/plugin-runtime/bootstrap.ts b/wework/src/plugin-runtime/bootstrap.ts new file mode 100644 index 00000000000..a5534693c56 --- /dev/null +++ b/wework/src/plugin-runtime/bootstrap.ts @@ -0,0 +1,22 @@ +import { baseWorkbenchProfile } from '@/plugins/base-profile' + +import { WorkbenchPluginRuntime } from './runtime' + +let runtime = new WorkbenchPluginRuntime() +let initialization = runtime.initialize(baseWorkbenchProfile) + +export async function initializeWorkbenchPluginRuntime(): Promise { + await initialization + return runtime +} + +export function getWorkbenchPluginRuntime(): WorkbenchPluginRuntime { + return runtime +} + +export async function disposeWorkbenchPluginRuntime(): Promise { + const current = runtime + await current.dispose() + runtime = new WorkbenchPluginRuntime() + initialization = runtime.initialize(baseWorkbenchProfile) +} diff --git a/wework/src/plugin-runtime/core-apps-data.ts b/wework/src/plugin-runtime/core-apps-data.ts new file mode 100644 index 00000000000..55ba4089525 --- /dev/null +++ b/wework/src/plugin-runtime/core-apps-data.ts @@ -0,0 +1,37 @@ +import type { WorkbenchAppContribution } from './apps' + +export const CORE_WORKBENCH_APPS = [ + { + key: 'wework', + labelKey: 'workbench.app_wework_label', + label: '任务', + descriptionKey: 'workbench.app_wework_description', + description: '使用 AI 解决具体问题', + mode: 'native', + path: '/', + requiresAuth: true, + }, + { + key: 'todo', + labelKey: 'workbench.app_weloop_label', + label: '项目空间', + descriptionKey: 'workbench.app_weloop_description', + description: '用 AI 管理项目的规划、执行与反馈', + mode: 'native', + path: '/todo', + requiresAuth: true, + experimental: true, + hidden: true, + }, + { + key: 'wegent', + labelKey: 'workbench.app_wegent_label', + label: '智能体', + descriptionKey: 'workbench.app_wegent_description', + description: '构建并交付可嵌入业务的云端智能体', + mode: 'iframe', + requiresAuth: true, + requiresCloud: true, + hidden: true, + }, +] as const satisfies readonly WorkbenchAppContribution[] diff --git a/wework/src/plugin-runtime/core-settings-data.tsx b/wework/src/plugin-runtime/core-settings-data.tsx new file mode 100644 index 00000000000..a25ac726e64 --- /dev/null +++ b/wework/src/plugin-runtime/core-settings-data.tsx @@ -0,0 +1,233 @@ +import { + AppWindow, + Archive, + Code2, + FolderGit2, + GitPullRequest, + Globe2, + Info, + Keyboard, + MessageSquareText, + Network, + Package, + Palette, + ScanLine, + SlidersHorizontal, + Terminal, + UserRound, + Webhook, +} from 'lucide-react' + +import { AppearanceSettingsPage } from '@/features/appearance/AppearanceSettingsPage' +import { HooksSettingsPage } from '@/features/hooks/HooksSettingsPage' +import { AboutSettingsPage } from '@/components/settings/AboutSettingsPage' +import { AppshotsSettingsPage } from '@/components/settings/AppshotsSettingsPage' +import { ArchivedConversationsSettingsPage } from '@/components/settings/ArchivedConversationsSettingsPage' +import { BrowserSettingsPage } from '@/components/settings/BrowserSettingsPage' +import { ContextSettingsPage } from '@/components/settings/ContextSettingsPage' +import { GeneralSettingsPage } from '@/components/settings/GeneralSettingsPage' +import { GitHostingSettingsPage } from '@/components/settings/GitHostingSettingsPage' +import { HarnessSettingsPage } from '@/components/settings/HarnessSettingsPage' +import { KeyboardShortcutsSettingsPage } from '@/components/settings/KeyboardShortcutsSettingsPage' +import { ModelSettingsPage } from '@/components/settings/ModelSettingsPage' +import { PluginSettingsPage } from '@/components/settings/PluginSettingsPage' +import { ProxySettingsPage } from '@/components/settings/ProxySettingsPage' +import { QuickPhrasesSettingsPage } from '@/components/settings/QuickPhrasesSettingsPage' +import { WorktreesSettingsPage } from '@/components/settings/WorktreesSettingsPage' + +import type { WorkbenchSettingsContribution } from './settings' + +const personal = { + category: 'personal', + categoryLabelKey: 'settings_category_personal', + categoryLabel: '个人', +} +const integrations = { + category: 'integrations', + categoryLabelKey: 'settings_category_integrations', + categoryLabel: '集成', +} +const coding = { + category: 'coding', + categoryLabelKey: 'settings_category_coding', + categoryLabel: '编码', +} +const archived = { + category: 'archived', + categoryLabelKey: 'settings_category_archived', + categoryLabel: '已归档', +} + +export const CORE_WORKBENCH_SETTINGS = [ + { + key: 'general', + path: '/settings', + icon: SlidersHorizontal, + labelKey: 'settings_nav_general', + label: '通用', + ...personal, + render: () => , + }, + { + key: 'connections', + path: '/settings/connections', + icon: Globe2, + labelKey: 'settings_nav_connections', + label: '云端连接', + ...personal, + }, + { + key: 'appearance', + path: '/settings/appearance', + icon: Palette, + labelKey: 'settings_nav_appearance', + label: '外观', + ...personal, + render: () => , + }, + { + key: 'context', + path: '/settings/personal/context', + icon: Terminal, + labelKey: 'settings_nav_context', + label: '上下文', + ...personal, + render: () => , + }, + { + key: 'model-settings', + path: '/settings/personal/models', + aliases: ['/settings/personal'], + icon: UserRound, + labelKey: 'settings_nav_model_settings', + label: '模型', + ...personal, + render: context => , + }, + { + key: 'proxy', + path: '/settings/personal/proxy', + icon: Network, + labelKey: 'settings_nav_proxy', + label: '代理', + ...personal, + render: () => , + }, + { + key: 'keyboard-shortcuts', + path: '/settings/personal/keyboard-shortcuts', + icon: Keyboard, + labelKey: 'settings_nav_keyboard_shortcuts', + label: '快捷键', + desktopOnly: true, + ...personal, + render: () => , + }, + { + key: 'quick-phrases', + path: '/settings/personal/quick-phrases', + icon: MessageSquareText, + labelKey: 'settings_nav_quick_phrases', + label: '快捷短语', + ...personal, + render: () => , + }, + { + key: 'about', + path: '/settings/about', + icon: Info, + labelKey: 'settings_nav_about', + label: '关于', + ...personal, + render: () => , + }, + { + key: 'appshots', + path: '/settings/appshots', + icon: ScanLine, + labelKey: 'settings_nav_appshots', + label: '应用快照', + desktopOnly: true, + ...integrations, + render: () => , + }, + { + key: 'plugins', + path: '/settings/plugins', + icon: Package, + labelKey: 'settings_nav_plugins', + label: '插件', + ...integrations, + render: () => , + }, + { + key: 'browser', + path: '/settings/browser', + icon: AppWindow, + labelKey: 'settings_nav_browser', + label: '浏览器', + ...integrations, + render: () => , + }, + { + key: 'git-hosting', + path: '/settings/git-hosting', + icon: GitPullRequest, + labelKey: 'settings_nav_git_hosting', + label: '代码托管', + ...coding, + render: () => , + }, + { + key: 'harnesses', + path: '/settings/harnesses', + icon: Code2, + labelKey: 'settings_nav_harnesses', + label: '编码工具', + experimental: true, + ...coding, + render: () => , + }, + { + key: 'worktrees', + path: '/settings/worktrees', + icon: FolderGit2, + labelKey: 'settings_nav_worktrees', + label: '工作树', + ...coding, + render: context => ( + + ), + }, + { + key: 'hooks', + path: '/settings/hooks', + icon: Webhook, + labelKey: 'settings_nav_hooks', + label: 'Hooks', + ...coding, + render: () => , + }, + { + key: 'archived-conversations', + path: '/settings/archived-conversations', + icon: Archive, + labelKey: 'settings_nav_archived_conversations', + label: '已归档对话', + ...archived, + render: context => ( + + ), + }, +] as const satisfies readonly WorkbenchSettingsContribution[] diff --git a/wework/src/plugin-runtime/external.test.ts b/wework/src/plugin-runtime/external.test.ts new file mode 100644 index 00000000000..fcce238d1a4 --- /dev/null +++ b/wework/src/plugin-runtime/external.test.ts @@ -0,0 +1,200 @@ +import { invoke } from '@tauri-apps/api/core' +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +import { WorkbenchPluginRuntime } from './runtime' +import { ExternalWorkbenchPluginLoader, type InspectedWorkbenchPlugin } from './external' + +vi.mock('@tauri-apps/api/core', () => ({ + convertFileSrc: (path: string) => `asset://localhost/${path}`, + invoke: vi.fn(), + isTauri: () => true, +})) + +function inspectedPlugin(): InspectedWorkbenchPlugin { + return { + root: '/plugins/example', + frontendPath: '/plugins/example/frontend.js', + frontendSource: 'export default {}', + desktopPath: null, + manifest: { + name: 'example', + apiVersion: '1', + required: false, + pinnedToClientVersion: false, + frontend: { + entry: 'frontend.js', + export: 'default', + sha256: '0'.repeat(64), + }, + desktop: null, + }, + } +} + +describe('ExternalWorkbenchPluginLoader', () => { + const runtimes: WorkbenchPluginRuntime[] = [] + + beforeEach(() => { + vi.mocked(invoke).mockReset() + }) + + afterEach(async () => { + await Promise.all(runtimes.splice(0).map(runtime => runtime.dispose())) + }) + + async function createRuntime() { + const runtime = new WorkbenchPluginRuntime() + runtimes.push(runtime) + await runtime.initialize({ id: 'test', entries: [] }) + return runtime + } + + test('loads and unloads same-realm frontend contributions transactionally', async () => { + const runtime = await createRuntime() + const dispose = vi.fn() + const receivedReactFactory = vi.fn() + const importer = vi.fn(async () => ({ + default: { + activate: ({ + apps, + react, + rightPanels, + routes, + settings, + }: { + apps: typeof runtime.apps + react: { createElement: (...args: unknown[]) => unknown } + rightPanels: typeof runtime.rightPanels + routes: typeof runtime.routes + settings: typeof runtime.settings + }) => { + receivedReactFactory(react.createElement) + const disposeRoute = routes.register({ + id: 'external.route', + path: '/external', + telemetryFeature: 'test', + render: () => null, + }) + const disposeApp = apps.register({ + key: 'external', + mode: 'native', + path: '/external', + labelKey: 'external.label', + label: 'External', + descriptionKey: 'external.description', + description: 'External application', + }) + const disposeSettings = settings.register({ + key: 'external', + path: '/settings/external', + icon: () => null, + labelKey: 'external.settings', + label: 'External', + category: 'plugins', + categoryLabelKey: 'external.category', + categoryLabel: 'Plugins', + render: () => null, + }) + const disposeRightPanel = rightPanels.register({ + key: 'external', + label: 'External', + icon: () => null, + render: () => null, + }) + return () => { + disposeRightPanel() + disposeSettings() + disposeApp() + disposeRoute() + dispose() + } + }, + }, + })) + const loader = new ExternalWorkbenchPluginLoader(runtime, importer) + + await loader.load(inspectedPlugin()) + expect(runtime.routes.resolve('/external')?.id).toBe('external.route') + expect(runtime.apps.resolve('external')?.path).toBe('/external') + expect(runtime.settings.resolve('external')?.path).toBe('/settings/external') + expect(runtime.rightPanels.resolve('external')?.label).toBe('External') + expect(importer).toHaveBeenCalledWith( + 'export default {}', + 'asset://localhost//plugins/example/frontend.js' + ) + expect(receivedReactFactory).toHaveBeenCalledWith(expect.any(Function)) + + await loader.unload('example') + expect(runtime.routes.resolve('/external')).toBeNull() + expect(runtime.apps.resolve('external')).toBeNull() + expect(runtime.settings.resolve('external')).toBeNull() + expect(runtime.rightPanels.resolve('external')).toBeNull() + expect(dispose).toHaveBeenCalledOnce() + }) + + test('rejects a frontend plugin pinned to a different client version', async () => { + const runtime = await createRuntime() + const importer = vi.fn() + const loader = new ExternalWorkbenchPluginLoader(runtime, importer) + const plugin = inspectedPlugin() + plugin.manifest.pinnedToClientVersion = true + plugin.manifest.clientVersion = '0.0.0-invalid' + + await expect(loader.load(plugin)).rejects.toThrow( + `Wework plugin 'example' requires client 0.0.0-invalid` + ) + expect(importer).not.toHaveBeenCalled() + }) + + test('starts and stops desktop-only plugins', async () => { + const runtime = await createRuntime() + const loader = new ExternalWorkbenchPluginLoader(runtime, vi.fn()) + const plugin = inspectedPlugin() + plugin.frontendPath = null + plugin.frontendSource = null + plugin.manifest.frontend = null + plugin.desktopPath = '/plugins/example/sidecar' + plugin.manifest.desktop = { + command: 'sidecar', + args: [], + sha256: '0'.repeat(64), + capabilities: ['example.ping'], + } + vi.mocked(invoke).mockResolvedValue(undefined) + + await loader.load(plugin) + expect(invoke).toHaveBeenCalledWith('workbench_plugin_start', { + pluginId: 'example', + pluginRoot: '/plugins/example', + }) + + await loader.unload('example') + expect(invoke).toHaveBeenLastCalledWith('workbench_plugin_stop', { + pluginId: 'example', + }) + }) + + test('attempts every plugin and reports aggregate load failures', async () => { + const runtime = await createRuntime() + const importer = vi.fn(async (_source: string, sourcePath: string) => { + if (sourcePath.includes('/broken/')) throw new Error('broken module') + return { default: { activate: () => undefined } } + }) + const loader = new ExternalWorkbenchPluginLoader(runtime, importer) + const broken = inspectedPlugin() + broken.root = '/plugins/broken' + broken.frontendPath = '/plugins/broken/frontend.js' + broken.manifest.name = 'broken' + const working = inspectedPlugin() + working.root = '/plugins/working' + working.frontendPath = '/plugins/working/frontend.js' + working.manifest.name = 'working' + + await expect(loader.reconcile([broken, working])).rejects.toThrow( + 'Failed to load 1 Wework plugin' + ) + expect(importer).toHaveBeenCalledTimes(2) + + await loader.unload('working') + }) +}) diff --git a/wework/src/plugin-runtime/external.ts b/wework/src/plugin-runtime/external.ts new file mode 100644 index 00000000000..a927406b0f4 --- /dev/null +++ b/wework/src/plugin-runtime/external.ts @@ -0,0 +1,239 @@ +import { convertFileSrc, invoke, isTauri } from '@tauri-apps/api/core' +import type { Plugin } from '@deepseek-ai/cordis' +import { createElement } from 'react' + +import type { WorkbenchRouteRegistry } from './routes' +import type { WorkbenchRightPanelRegistry } from './right-panels' +import type { WorkbenchSlotRegistry } from './slots' +import type { WorkbenchSettingsRegistry } from './settings' +import type { WorkbenchAppRegistry } from './apps' +import type { WorkbenchPluginRuntime } from './runtime' + +export interface WorkbenchPluginManifest { + name: string + version?: string | null + apiVersion: '1' + required: boolean + pinnedToClientVersion: boolean + clientVersion?: string | null + frontend?: { + entry: string + export?: string | null + sha256: string + } | null + desktop?: { + command: string + args: string[] + sha256: string + capabilities: string[] + } | null +} + +export interface InspectedWorkbenchPlugin { + root: string + manifest: WorkbenchPluginManifest + frontendPath?: string | null + frontendSource?: string | null + desktopPath?: string | null +} + +export interface WorkbenchDesktopClient { + authorize(capability: string): Promise + request(method: string, params?: unknown): Promise + start(): Promise + stop(): Promise +} + +export interface WorkbenchPluginApi { + id: string + manifest: WorkbenchPluginManifest + react: { + createElement: typeof createElement + } + routes: WorkbenchRouteRegistry + slots: WorkbenchSlotRegistry + settings: WorkbenchSettingsRegistry + apps: WorkbenchAppRegistry + rightPanels: WorkbenchRightPanelRegistry + desktop: WorkbenchDesktopClient | null +} + +export interface WorkbenchFrontendPluginModule { + activate(api: WorkbenchPluginApi): void | (() => void) | Promise void)> +} + +type ModuleImporter = (source: string, sourcePath: string) => Promise> + +function createDesktopClient(plugin: InspectedWorkbenchPlugin): WorkbenchDesktopClient | null { + if (!plugin.manifest.desktop) return null + return { + authorize(capability) { + return invoke('workbench_plugin_authorize_capability', { + pluginRoot: plugin.root, + capability, + }) + }, + request(method: string, params: unknown = {}): Promise { + return invoke('workbench_plugin_request', { + pluginId: plugin.manifest.name, + method, + params, + }) + }, + start() { + return invoke('workbench_plugin_start', { + pluginId: plugin.manifest.name, + pluginRoot: plugin.root, + }) + }, + stop() { + return invoke('workbench_plugin_stop', { + pluginId: plugin.manifest.name, + }) + }, + } +} + +async function defaultModuleImporter(source: string): Promise> { + const url = `data:text/javascript;charset=utf-8,${encodeURIComponent(source)}` + return import(/* @vite-ignore */ url) as Promise> +} + +function resolveFrontendModule( + namespace: Record, + exportName: string +): WorkbenchFrontendPluginModule { + const candidate = namespace[exportName] + if ( + !candidate || + typeof candidate !== 'object' || + typeof (candidate as { activate?: unknown }).activate !== 'function' + ) { + throw new Error(`Wework plugin export '${exportName}' must provide activate(api)`) + } + return candidate as WorkbenchFrontendPluginModule +} + +export class ExternalWorkbenchPluginLoader { + private readonly loaded = new Map Promise>() + private readonly loading = new Map>() + private readonly runtime: WorkbenchPluginRuntime + private readonly importer: ModuleImporter + + constructor(runtime: WorkbenchPluginRuntime, importer: ModuleImporter = defaultModuleImporter) { + this.runtime = runtime + this.importer = importer + } + + async load(plugin: InspectedWorkbenchPlugin): Promise { + const id = plugin.manifest.name + if (this.loaded.has(id)) return + const pending = this.loading.get(id) + if (pending) return pending + const loading = this.loadPlugin(plugin).finally(() => { + if (this.loading.get(id) === loading) this.loading.delete(id) + }) + this.loading.set(id, loading) + return loading + } + + private async loadPlugin(plugin: InspectedWorkbenchPlugin): Promise { + const id = plugin.manifest.name + if (plugin.manifest.apiVersion !== '1') { + throw new Error(`Unsupported Wework plugin apiVersion '${plugin.manifest.apiVersion}'`) + } + if ( + plugin.manifest.pinnedToClientVersion && + plugin.manifest.clientVersion !== __WEWORK_APP_VERSION__ + ) { + throw new Error( + `Wework plugin '${id}' requires client ${plugin.manifest.clientVersion ?? 'unknown'}, ` + + `current client is ${__WEWORK_APP_VERSION__}` + ) + } + const desktop = createDesktopClient(plugin) + if (!plugin.frontendPath || !plugin.manifest.frontend) { + if (!desktop) { + throw new Error(`Wework plugin '${id}' does not declare a loadable component`) + } + await desktop.start() + this.loaded.set(id, async () => desktop.stop()) + return + } + if (plugin.frontendSource == null) { + throw new Error(`Wework plugin '${id}' frontend source was not verified by the host`) + } + + const namespace = await this.importer( + plugin.frontendSource, + convertFileSrc(plugin.frontendPath) + ) + const module = resolveFrontendModule( + namespace, + plugin.manifest.frontend.export?.trim() || 'default' + ) + const cordisPlugin: Plugin.Object = { + name: `external:${id}`, + inject: [ + 'workbenchRoutes', + 'workbenchSlots', + 'workbenchSettings', + 'workbenchApps', + 'workbenchRightPanels', + ], + async apply(ctx) { + const dispose = await module.activate({ + id, + manifest: plugin.manifest, + react: { createElement }, + routes: ctx.workbenchRoutes, + slots: ctx.workbenchSlots, + settings: ctx.workbenchSettings, + apps: ctx.workbenchApps, + rightPanels: ctx.workbenchRightPanels, + desktop, + }) + return async () => { + dispose?.() + await desktop?.stop() + } + }, + } + this.loaded.set(id, await this.runtime.install(cordisPlugin)) + } + + async unload(id: string): Promise { + const dispose = this.loaded.get(id) + if (!dispose) return + this.loaded.delete(id) + await dispose() + } + + async reconcile(plugins: readonly InspectedWorkbenchPlugin[]): Promise { + const desired = new Set(plugins.map(plugin => plugin.manifest.name)) + await Promise.all( + Array.from(this.loaded.keys()) + .filter(id => !desired.has(id)) + .map(id => this.unload(id)) + ) + const results = await Promise.allSettled(plugins.map(plugin => this.load(plugin))) + const failures = results.flatMap((result, index) => + result.status === 'rejected' + ? [`${plugins[index].manifest.name}: ${String(result.reason)}`] + : [] + ) + if (failures.length > 0) { + throw new AggregateError(failures, `Failed to load ${failures.length} Wework plugin(s)`) + } + } + + async dispose(): Promise { + await Promise.allSettled(this.loading.values()) + await Promise.all(Array.from(this.loaded.keys()).map(id => this.unload(id))) + } +} + +export async function listDeviceWorkbenchPlugins(): Promise { + if (!isTauri()) return [] + return invoke('workbench_plugin_list') +} diff --git a/wework/src/plugin-runtime/right-panels.test.tsx b/wework/src/plugin-runtime/right-panels.test.tsx new file mode 100644 index 00000000000..32fbdb05b21 --- /dev/null +++ b/wework/src/plugin-runtime/right-panels.test.tsx @@ -0,0 +1,71 @@ +import { act, renderHook } from '@testing-library/react' +import { LayoutDashboard } from 'lucide-react' +import { describe, expect, test, vi } from 'vitest' + +import { + setActiveWorkbenchRightPanelRegistry, + useActiveWorkbenchRightPanels, + WorkbenchRightPanelRegistry, +} from './right-panels' + +function contribution(key = 'example') { + return { + key, + label: 'Example', + icon: LayoutDashboard, + render: () => null, + } +} + +describe('WorkbenchRightPanelRegistry', () => { + test('registers, resolves, notifies, and disposes contributions', () => { + const registry = new WorkbenchRightPanelRegistry() + const listener = vi.fn() + registry.subscribe(listener) + const item = contribution() + + const dispose = registry.register(item) + + expect(registry.resolve('example')).toBe(item) + expect(registry.list()).toEqual([item]) + expect(listener).toHaveBeenCalledOnce() + + dispose() + + expect(registry.resolve('example')).toBeNull() + expect(registry.list()).toEqual([]) + expect(listener).toHaveBeenCalledTimes(2) + }) + + test('rejects duplicate keys', () => { + const registry = new WorkbenchRightPanelRegistry() + registry.register(contribution()) + + expect(() => registry.register(contribution())).toThrow('already registered') + }) + + test('keeps mounted consumers subscribed across registry replacement and restoration', () => { + const first = new WorkbenchRightPanelRegistry() + const restoreFirst = setActiveWorkbenchRightPanelRegistry(first) + const hook = renderHook(() => useActiveWorkbenchRightPanels()) + + act(() => { + first.register(contribution('first')) + }) + expect(hook.result.current.map(item => item.key)).toEqual(['first']) + + const second = new WorkbenchRightPanelRegistry() + let restoreSecond = () => undefined + act(() => { + restoreSecond = setActiveWorkbenchRightPanelRegistry(second) + second.register(contribution('second')) + }) + expect(hook.result.current.map(item => item.key)).toEqual(['second']) + + act(() => restoreSecond()) + expect(hook.result.current.map(item => item.key)).toEqual(['first']) + + hook.unmount() + restoreFirst() + }) +}) diff --git a/wework/src/plugin-runtime/right-panels.tsx b/wework/src/plugin-runtime/right-panels.tsx new file mode 100644 index 00000000000..43bc23864db --- /dev/null +++ b/wework/src/plugin-runtime/right-panels.tsx @@ -0,0 +1,99 @@ +import { useSyncExternalStore, type ComponentType, type ReactNode } from 'react' + +export interface WorkbenchRightPanelContribution { + key: string + label: string + icon: ComponentType<{ className?: string }> + render: () => ReactNode +} + +export class WorkbenchRightPanelRegistry { + private readonly contributions = new Map() + private readonly listeners = new Set<() => void>() + private snapshot: readonly WorkbenchRightPanelContribution[] = [] + private revision = 0 + + private emit(): void { + this.snapshot = Array.from(this.contributions.values()) + this.revision += 1 + for (const listener of this.listeners) listener() + } + + register(contribution: WorkbenchRightPanelContribution): () => void { + if (this.contributions.has(contribution.key)) { + throw new Error(`Workbench right panel '${contribution.key}' is already registered`) + } + this.contributions.set(contribution.key, contribution) + this.emit() + return () => { + if (this.contributions.get(contribution.key) === contribution) { + this.contributions.delete(contribution.key) + this.emit() + } + } + } + + resolve(key: string): WorkbenchRightPanelContribution | null { + return this.contributions.get(key) ?? null + } + + list(): readonly WorkbenchRightPanelContribution[] { + return this.snapshot + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + version(): number { + return this.revision + } +} + +let activeRegistry = new WorkbenchRightPanelRegistry() +const activeRegistryListeners = new Set<() => void>() +let activeRegistryRevision = 0 +let unsubscribeActiveRegistry = activeRegistry.subscribe(notifyActiveRegistryListeners) + +function notifyActiveRegistryListeners(): void { + for (const listener of activeRegistryListeners) listener() +} + +function replaceActiveRegistry(registry: WorkbenchRightPanelRegistry): void { + unsubscribeActiveRegistry() + activeRegistry = registry + unsubscribeActiveRegistry = registry.subscribe(notifyActiveRegistryListeners) + activeRegistryRevision += 1 + notifyActiveRegistryListeners() +} + +function subscribeActiveRegistry(listener: () => void): () => void { + activeRegistryListeners.add(listener) + return () => activeRegistryListeners.delete(listener) +} + +function getActiveRegistrySnapshot(): string { + return `${activeRegistryRevision}:${activeRegistry.version()}` +} + +export function useActiveWorkbenchRightPanels(): readonly WorkbenchRightPanelContribution[] { + useSyncExternalStore( + subscribeActiveRegistry, + getActiveRegistrySnapshot, + getActiveRegistrySnapshot + ) + return activeRegistry.list() +} + +export function setActiveWorkbenchRightPanelRegistry( + registry: WorkbenchRightPanelRegistry +): () => void { + const previous = activeRegistry + replaceActiveRegistry(registry) + return () => { + if (activeRegistry === registry) { + replaceActiveRegistry(previous) + } + } +} diff --git a/wework/src/plugin-runtime/routes.test.tsx b/wework/src/plugin-runtime/routes.test.tsx new file mode 100644 index 00000000000..14f183e1d96 --- /dev/null +++ b/wework/src/plugin-runtime/routes.test.tsx @@ -0,0 +1,42 @@ +import { describe, expect, test } from 'vitest' + +import { WorkbenchRouteRegistry } from './routes' + +describe('WorkbenchRouteRegistry', () => { + test('registers and removes the exact contribution', () => { + const routes = new WorkbenchRouteRegistry() + const contribution = { + id: 'plugins', + path: '/plugins', + telemetryFeature: 'plugins', + render: () => null, + } + + const dispose = routes.register(contribution) + + expect(routes.resolve('/plugins')).toBe(contribution) + expect(routes.list()).toEqual([contribution]) + + dispose() + expect(routes.resolve('/plugins')).toBeNull() + }) + + test('rejects duplicate paths', () => { + const routes = new WorkbenchRouteRegistry() + routes.register({ + id: 'first', + path: '/plugins', + telemetryFeature: 'plugins', + render: () => null, + }) + + expect(() => + routes.register({ + id: 'second', + path: '/plugins', + telemetryFeature: 'other', + render: () => null, + }) + ).toThrow("Workbench route '/plugins' is already registered") + }) +}) diff --git a/wework/src/plugin-runtime/routes.tsx b/wework/src/plugin-runtime/routes.tsx new file mode 100644 index 00000000000..f1e968c1648 --- /dev/null +++ b/wework/src/plugin-runtime/routes.tsx @@ -0,0 +1,69 @@ +import { useSyncExternalStore, type ReactNode } from 'react' + +import type { AnalyticsEventMap } from '@/telemetry/events' + +type WorkbenchTelemetryFeature = AnalyticsEventMap['feature_opened']['feature'] + +export interface WorkbenchRouteRenderContext { + search: string +} + +export interface WorkbenchRouteContribution { + id: string + path: string + telemetryFeature: WorkbenchTelemetryFeature + render: (context: WorkbenchRouteRenderContext) => ReactNode +} + +export class WorkbenchRouteRegistry { + private readonly routes = new Map() + private readonly listeners = new Set<() => void>() + private revision = 0 + + private emit(): void { + this.revision += 1 + for (const listener of this.listeners) listener() + } + + register(route: WorkbenchRouteContribution): () => void { + if (this.routes.has(route.path)) { + throw new Error(`Workbench route '${route.path}' is already registered`) + } + this.routes.set(route.path, route) + this.emit() + return () => { + if (this.routes.get(route.path) === route) { + this.routes.delete(route.path) + this.emit() + } + } + } + + resolve(path: string): WorkbenchRouteContribution | null { + return this.routes.get(path) ?? null + } + + list(): readonly WorkbenchRouteContribution[] { + return Array.from(this.routes.values()) + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + version(): number { + return this.revision + } +} + +export function useWorkbenchRouteRegistry( + registry: WorkbenchRouteRegistry +): WorkbenchRouteRegistry { + useSyncExternalStore( + listener => registry.subscribe(listener), + () => registry.version(), + () => registry.version() + ) + return registry +} diff --git a/wework/src/plugin-runtime/runtime.test.ts b/wework/src/plugin-runtime/runtime.test.ts new file mode 100644 index 00000000000..1889c58f6da --- /dev/null +++ b/wework/src/plugin-runtime/runtime.test.ts @@ -0,0 +1,76 @@ +import { Context, type Plugin } from '@deepseek-ai/cordis' +import { describe, expect, test, vi } from 'vitest' + +import { WorkbenchPluginRuntime } from './runtime' + +describe('WorkbenchPluginRuntime', () => { + test('loads dependent plugins and reverses route effects on disposal', async () => { + const runtime = new WorkbenchPluginRuntime() + const cleanup = vi.fn() + const plugin = Object.assign( + (ctx: Context) => { + const disposeRoute = ctx.workbenchRoutes.register({ + id: 'test.route', + path: '/test', + telemetryFeature: 'test', + render: () => null, + }) + return () => { + cleanup() + disposeRoute() + } + }, + { inject: ['workbenchRoutes'] } + ) as Plugin + + await runtime.initialize({ + id: 'test', + entries: [{ id: 'test', plugin, required: false }], + }) + + expect(runtime.routes.resolve('/test')?.id).toBe('test.route') + + await runtime.dispose() + + expect(cleanup).toHaveBeenCalledOnce() + expect(() => runtime.routes).toThrow('Workbench route service is not active') + }) + + test('rolls back an already loaded plugin when a later plugin fails', async () => { + const runtime = new WorkbenchPluginRuntime() + const cleanup = vi.fn() + const first = (() => cleanup) as Plugin + const failing = (() => { + throw new Error('broken plugin') + }) as Plugin + + await expect( + runtime.initialize({ + id: 'broken-profile', + entries: [ + { id: 'first', plugin: first, required: false }, + { id: 'failing', plugin: failing, required: false }, + ], + }) + ).rejects.toThrow("Failed to initialize Wework plugin profile 'broken-profile'") + expect(cleanup).toHaveBeenCalledOnce() + }) + + test('rejects a required plugin that is not pinned to the current client', async () => { + const runtime = new WorkbenchPluginRuntime() + + await expect( + runtime.initialize({ + id: 'invalid-required-profile', + entries: [ + { + id: 'required', + plugin: (() => undefined) as Plugin, + required: true, + clientVersion: '0.0.0-invalid', + }, + ], + }) + ).rejects.toThrow("Failed to initialize Wework plugin profile 'invalid-required-profile'") + }) +}) diff --git a/wework/src/plugin-runtime/runtime.ts b/wework/src/plugin-runtime/runtime.ts new file mode 100644 index 00000000000..541acb6ea4b --- /dev/null +++ b/wework/src/plugin-runtime/runtime.ts @@ -0,0 +1,181 @@ +import { Context, type Fiber, type Plugin } from '@deepseek-ai/cordis' + +import { WorkbenchRouteRegistry } from './routes' +import { setActiveWorkbenchRightPanelRegistry, WorkbenchRightPanelRegistry } from './right-panels' +import { WorkbenchSlotRegistry } from './slots' +import { setActiveWorkbenchAppRegistry, WorkbenchAppRegistry } from './apps' +import { setActiveWorkbenchSettingsRegistry, WorkbenchSettingsRegistry } from './settings' + +declare module '@deepseek-ai/cordis' { + interface Context { + workbenchRoutes: WorkbenchRouteRegistry + workbenchSlots: WorkbenchSlotRegistry + workbenchApps: WorkbenchAppRegistry + workbenchSettings: WorkbenchSettingsRegistry + workbenchRightPanels: WorkbenchRightPanelRegistry + } +} + +const routeServicePlugin: Plugin.Object = { + name: 'wework-route-service', + provide: 'workbenchRoutes', + apply(ctx: Context) { + ctx.provide('workbenchRoutes', new WorkbenchRouteRegistry()) + }, +} + +const slotServicePlugin: Plugin.Object = { + name: 'wework-slot-service', + provide: 'workbenchSlots', + apply(ctx: Context) { + ctx.provide('workbenchSlots', new WorkbenchSlotRegistry()) + }, +} + +const appServicePlugin: Plugin.Object = { + name: 'wework-app-service', + provide: 'workbenchApps', + apply(ctx: Context) { + const registry = new WorkbenchAppRegistry() + const restore = setActiveWorkbenchAppRegistry(registry) + ctx.provide('workbenchApps', registry) + return restore + }, +} + +const settingsServicePlugin: Plugin.Object = { + name: 'wework-settings-service', + provide: 'workbenchSettings', + apply(ctx: Context) { + const registry = new WorkbenchSettingsRegistry() + const restore = setActiveWorkbenchSettingsRegistry(registry) + ctx.provide('workbenchSettings', registry) + return restore + }, +} + +const rightPanelServicePlugin: Plugin.Object = { + name: 'wework-right-panel-service', + provide: 'workbenchRightPanels', + apply(ctx: Context) { + const registry = new WorkbenchRightPanelRegistry() + const restore = setActiveWorkbenchRightPanelRegistry(registry) + ctx.provide('workbenchRightPanels', registry) + return restore + }, +} + +export interface WorkbenchPluginProfile { + id: string + entries: readonly WorkbenchPluginProfileEntry[] +} + +export interface WorkbenchPluginProfileEntry { + id: string + plugin: Plugin + required: boolean + clientVersion?: string +} + +export class WorkbenchPluginRuntime { + readonly context = new Context() + private readonly fibers: Fiber[] = [] + private initialized = false + + get routes(): WorkbenchRouteRegistry { + const routes = this.context.get('workbenchRoutes') + if (!routes) { + throw new Error('Workbench route service is not active') + } + return routes + } + + get slots(): WorkbenchSlotRegistry { + const slots = this.context.get('workbenchSlots') + if (!slots) { + throw new Error('Workbench slot service is not active') + } + return slots + } + + get apps(): WorkbenchAppRegistry { + const apps = this.context.get('workbenchApps') + if (!apps) { + throw new Error('Workbench app service is not active') + } + return apps + } + + get settings(): WorkbenchSettingsRegistry { + const settings = this.context.get('workbenchSettings') + if (!settings) { + throw new Error('Workbench settings service is not active') + } + return settings + } + + get rightPanels(): WorkbenchRightPanelRegistry { + const rightPanels = this.context.get('workbenchRightPanels') + if (!rightPanels) { + throw new Error('Workbench right panel service is not active') + } + return rightPanels + } + + async initialize(profile: WorkbenchPluginProfile): Promise { + if (this.initialized) { + throw new Error('Workbench plugin runtime is already initialized') + } + this.initialized = true + + try { + this.fibers.push(this.context.plugin(routeServicePlugin)) + this.fibers.push(this.context.plugin(slotServicePlugin)) + this.fibers.push(this.context.plugin(appServicePlugin)) + this.fibers.push(this.context.plugin(settingsServicePlugin)) + this.fibers.push(this.context.plugin(rightPanelServicePlugin)) + for (const entry of profile.entries) { + if (entry.required && entry.clientVersion !== __WEWORK_APP_VERSION__) { + throw new Error( + `Required plugin '${entry.id}' is pinned to client ` + + `${entry.clientVersion ?? 'unknown'}, current client is ${__WEWORK_APP_VERSION__}` + ) + } + this.fibers.push(this.context.plugin(entry.plugin)) + } + await Promise.all(this.fibers.map(fiber => fiber.await())) + } catch (error) { + await this.dispose() + throw new Error(`Failed to initialize Wework plugin profile '${profile.id}'`, { + cause: error, + }) + } + } + + async install(plugin: Plugin): Promise<() => Promise> { + if (!this.initialized) { + throw new Error('Workbench plugin runtime is not initialized') + } + const fiber = this.context.plugin(plugin) + this.fibers.push(fiber) + try { + await fiber.await() + } catch (error) { + const index = this.fibers.indexOf(fiber) + if (index >= 0) this.fibers.splice(index, 1) + await fiber.dispose() + throw error + } + return async () => { + const index = this.fibers.indexOf(fiber) + if (index >= 0) this.fibers.splice(index, 1) + await fiber.dispose() + } + } + + async dispose(): Promise { + const fibers = this.fibers.splice(0).reverse() + await Promise.allSettled(fibers.map(fiber => fiber.dispose())) + this.initialized = false + } +} diff --git a/wework/src/plugin-runtime/settings.test.tsx b/wework/src/plugin-runtime/settings.test.tsx new file mode 100644 index 00000000000..fd0b22bf5b8 --- /dev/null +++ b/wework/src/plugin-runtime/settings.test.tsx @@ -0,0 +1,79 @@ +import { act, renderHook } from '@testing-library/react' +import { SlidersHorizontal } from 'lucide-react' +import { describe, expect, test, vi } from 'vitest' + +import { + setActiveWorkbenchSettingsRegistry, + useActiveWorkbenchSettings, + WorkbenchSettingsRegistry, +} from './settings' + +function contribution(key = 'example', path = '/settings/example') { + return { + key, + path, + icon: SlidersHorizontal, + labelKey: 'settings_nav_example', + label: 'Example', + category: 'plugins', + categoryLabelKey: 'settings_category_plugins', + categoryLabel: 'Plugins', + render: () => null, + } +} + +describe('WorkbenchSettingsRegistry', () => { + test('resolves aliases and notifies subscribers across registration lifecycle', () => { + const registry = new WorkbenchSettingsRegistry() + const listener = vi.fn() + registry.subscribe(listener) + const item = { + ...contribution(), + aliases: ['/settings/example-alias'], + } + + const dispose = registry.register(item) + + expect(registry.resolve('example')).toBe(item) + expect(registry.resolvePath('/settings/example-alias')).toBe(item) + expect(listener).toHaveBeenCalledOnce() + + dispose() + + expect(registry.resolve('example')).toBeNull() + expect(listener).toHaveBeenCalledTimes(2) + }) + + test('rejects duplicate keys and paths', () => { + const registry = new WorkbenchSettingsRegistry() + registry.register(contribution()) + + expect(() => registry.register(contribution())).toThrow('already registered') + expect(() => registry.register(contribution('other'))).toThrow('path') + }) + + test('keeps mounted consumers subscribed across registry replacement and restoration', () => { + const first = new WorkbenchSettingsRegistry() + const restoreFirst = setActiveWorkbenchSettingsRegistry(first) + const hook = renderHook(() => useActiveWorkbenchSettings()) + + act(() => { + first.register(contribution('first', '/settings/first')) + }) + expect(hook.result.current.map(item => item.key)).toEqual(['first']) + + const second = new WorkbenchSettingsRegistry() + let restoreSecond = () => undefined + act(() => { + restoreSecond = setActiveWorkbenchSettingsRegistry(second) + second.register(contribution('second', '/settings/second')) + }) + expect(hook.result.current.map(item => item.key)).toEqual(['second']) + + act(() => restoreSecond()) + expect(hook.result.current.map(item => item.key)).toEqual(['first']) + + hook.unmount() + restoreFirst() + }) +}) diff --git a/wework/src/plugin-runtime/settings.tsx b/wework/src/plugin-runtime/settings.tsx new file mode 100644 index 00000000000..a701f52775c --- /dev/null +++ b/wework/src/plugin-runtime/settings.tsx @@ -0,0 +1,139 @@ +import { useSyncExternalStore, type ComponentType, type ReactNode } from 'react' + +import type { WorkbenchServices } from '@/features/workbench/workbenchServices' +import type { DeviceInfo, RuntimeTaskAddress } from '@/types/api' + +export interface WorkbenchSettingsRenderContext { + services?: WorkbenchServices + devices: DeviceInfo[] + onBack: () => void + onOpenCloudSettings: () => void + onOpenRuntimeTask?: (address: RuntimeTaskAddress) => Promise + onRefreshWorkLists?: () => Promise +} + +export interface WorkbenchSettingsContribution { + key: string + path: string + aliases?: readonly string[] + icon: ComponentType<{ className?: string }> + labelKey: string + label: string + category: string + categoryLabelKey: string + categoryLabel: string + experimental?: boolean + desktopOnly?: boolean + render?: (context: WorkbenchSettingsRenderContext) => ReactNode +} + +export class WorkbenchSettingsRegistry { + private readonly contributions = new Map() + private readonly listeners = new Set<() => void>() + private snapshot: readonly WorkbenchSettingsContribution[] = [] + private revision = 0 + + private emit(): void { + this.snapshot = Array.from(this.contributions.values()) + this.revision += 1 + for (const listener of this.listeners) listener() + } + + register(contribution: WorkbenchSettingsContribution): () => void { + if (this.contributions.has(contribution.key)) { + throw new Error(`Workbench settings contribution '${contribution.key}' is already registered`) + } + const paths = new Set([contribution.path, ...(contribution.aliases ?? [])]) + if ( + this.list().some(existing => + [existing.path, ...(existing.aliases ?? [])].some(path => paths.has(path)) + ) + ) { + throw new Error(`Workbench settings path '${contribution.path}' is already registered`) + } + this.contributions.set(contribution.key, contribution) + this.emit() + return () => { + if (this.contributions.get(contribution.key) === contribution) { + this.contributions.delete(contribution.key) + this.emit() + } + } + } + + resolve(key: string): WorkbenchSettingsContribution | null { + return this.contributions.get(key) ?? null + } + + resolvePath(path: string): WorkbenchSettingsContribution | null { + return ( + this.list().find( + contribution => contribution.path === path || contribution.aliases?.includes(path) + ) ?? null + ) + } + + list(): readonly WorkbenchSettingsContribution[] { + return this.snapshot + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => this.listeners.delete(listener) + } + + version(): number { + return this.revision + } +} + +let activeRegistry = new WorkbenchSettingsRegistry() +const activeRegistryListeners = new Set<() => void>() +let activeRegistryRevision = 0 +let unsubscribeActiveRegistry = activeRegistry.subscribe(notifyActiveRegistryListeners) + +function notifyActiveRegistryListeners(): void { + for (const listener of activeRegistryListeners) listener() +} + +function replaceActiveRegistry(registry: WorkbenchSettingsRegistry): void { + unsubscribeActiveRegistry() + activeRegistry = registry + unsubscribeActiveRegistry = registry.subscribe(notifyActiveRegistryListeners) + activeRegistryRevision += 1 + notifyActiveRegistryListeners() +} + +function subscribeActiveRegistry(listener: () => void): () => void { + activeRegistryListeners.add(listener) + return () => activeRegistryListeners.delete(listener) +} + +function getActiveRegistrySnapshot(): string { + return `${activeRegistryRevision}:${activeRegistry.version()}` +} + +export function getActiveWorkbenchSettingsRegistry(): WorkbenchSettingsRegistry { + return activeRegistry +} + +export function useActiveWorkbenchSettings(): readonly WorkbenchSettingsContribution[] { + useSyncExternalStore( + subscribeActiveRegistry, + getActiveRegistrySnapshot, + getActiveRegistrySnapshot + ) + return activeRegistry.list() +} + +export function setActiveWorkbenchSettingsRegistry( + registry: WorkbenchSettingsRegistry +): () => void { + const previous = activeRegistry + replaceActiveRegistry(registry) + return () => { + if (activeRegistry === registry) { + replaceActiveRegistry(previous) + } + } +} diff --git a/wework/src/plugin-runtime/slots.test.tsx b/wework/src/plugin-runtime/slots.test.tsx new file mode 100644 index 00000000000..b9c388a8925 --- /dev/null +++ b/wework/src/plugin-runtime/slots.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, test } from 'vitest' + +import { WorkbenchRootFrame } from '@/plugins/WorkbenchRootFrame' + +import { WorkbenchSlotRegistry } from './slots' + +describe('WorkbenchSlotRegistry', () => { + test('renders contributed shell slots and removes them through the disposer', async () => { + const slots = new WorkbenchSlotRegistry() + slots.register({ + name: 'root', + component: WorkbenchRootFrame, + children: { + 'wework.shell.before': { kind: 'list', scope: 'root' }, + 'wework.shell.after': { kind: 'list', scope: 'root' }, + 'wework.shell.overlay': { kind: 'list', scope: 'root' }, + }, + }) + const dispose = slots.register({ + name: 'wework.shell.overlay', + id: 'test-overlay', + component: () =>
plugin overlay
, + }) + + const view = render(slots.renderRoot(
workbench
)) + expect(screen.getByText('workbench')).toBeInTheDocument() + expect(screen.getByText('plugin overlay')).toBeInTheDocument() + + dispose() + await Promise.resolve() + view.rerender(slots.renderRoot(
workbench
)) + expect(screen.queryByText('plugin overlay')).not.toBeInTheDocument() + }) + + test('rejects contributions to undeclared slots', () => { + const slots = new WorkbenchSlotRegistry() + expect(() => + slots.register({ + name: 'wework.shell.overlay', + id: 'orphan', + component: () => null, + }) + ).toThrow(/not declared/) + }) +}) diff --git a/wework/src/plugin-runtime/slots.tsx b/wework/src/plugin-runtime/slots.tsx new file mode 100644 index 00000000000..ea327fa9e84 --- /dev/null +++ b/wework/src/plugin-runtime/slots.tsx @@ -0,0 +1,219 @@ +import { Component, useSyncExternalStore, type ComponentType, type ReactNode } from 'react' +/* eslint-disable react-refresh/only-export-components -- The registry owns the renderer lifecycle. */ +import { + SlotCore, + StaleAuthorizationError, + SlotOwnershipError, + type RenderOpts, + type SlotEntryDef, + type SlotMap, + type SlotSpec, + type StoredEntry, +} from '@deepseek-ai/dsh-client-ui-slots' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface SlotMap { + root: { + kind: 'single' + scope: 'root' + owner: { children: ReactNode } + } + 'wework.shell.before': { + kind: 'list' + scope: 'root' + } + 'wework.shell.after': { + kind: 'list' + scope: 'root' + } + 'wework.shell.overlay': { + kind: 'list' + scope: 'root' + } + } +} + +type SlotKey = keyof SlotMap & string +type SlotComponentProps = Record +type SlotComponent = ComponentType + +export interface WorkbenchSlotRegistration { + name: SlotKey + component: unknown + children?: Readonly>> + id?: string + key?: string + order?: number + priority?: number + select?: (owner: SlotComponentProps) => unknown + inject?: () => SlotComponentProps + registrant?: string +} + +interface SlotOutletProps { + registry: WorkbenchSlotRegistry + slotKey: SlotKey + ownerProps: SlotComponentProps + options?: RenderOpts +} + +interface SlotEntryBoundaryProps { + children: ReactNode + entry: StoredEntry + registry: WorkbenchSlotRegistry + slotKey: SlotKey +} + +class SlotEntryBoundary extends Component { + override state = { failed: false } + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true } + } + + override componentDidCatch(error: unknown): void { + this.props.registry.reportEntryError(this.props.slotKey, this.props.entry, error) + } + + override render(): ReactNode { + if (this.state.failed) { + return
+ } + return this.props.children + } +} + +function SlotOutlet({ registry, slotKey, ownerProps, options }: SlotOutletProps) { + useSyncExternalStore( + listener => registry.subscribe(slotKey, listener), + () => registry.version(slotKey), + () => registry.version(slotKey) + ) + + const spec = registry.spec(slotKey) as SlotSpec | undefined + if (!spec) return options?.fallback ?? null + + let entries = registry.entries(slotKey) + if (spec.kind === 'keyed') { + entries = entries.filter(entry => entry.options.key === options?.entryKey) + } else if (spec.kind === 'list' && options?.only) { + entries = entries.filter(entry => entry.options.id === options.only) + } else if (spec.kind === 'chain') { + const winner = entries.find(entry => entry.select?.(ownerProps as never) != null) + entries = winner ? [winner] : [] + } + + if (entries.length === 0) return options?.fallback ?? null + + return ( + <> + {entries.map(entry => ( + + ))} + + ) +} + +function SlotEntry({ + entry, + ownerProps, + registry, + slotKey, +}: { + entry: StoredEntry + ownerProps: SlotComponentProps + registry: WorkbenchSlotRegistry + slotKey: SlotKey +}) { + const Component = entry.component as SlotComponent + const renderSlot = (key: SlotKey, owner: SlotComponentProps, options?: RenderOpts) => { + if (!registry.isLive(entry)) { + throw new StaleAuthorizationError(`renderSlot('${key}') from a disposed registration`) + } + if (!entry.children?.[key]) { + throw new SlotOwnershipError(`slot '${key}' is not declared by this entry`) + } + return + } + const injected = entry.inject?.() ?? {} + const matched = + (registry.spec(slotKey) as SlotSpec | undefined)?.kind === 'chain' + ? entry.select?.(ownerProps as never) + : undefined + + return ( + + + + ) +} + +let nextEntryIdentity = 0 + +export class WorkbenchSlotRegistry { + private readonly core = new SlotCore() + private readonly entryIdentities = new WeakMap() + + register(registration: WorkbenchSlotRegistration): () => void { + const { component, ...options } = registration + return this.core.register(options as never, component as never) + } + + renderRoot(children: ReactNode): ReactNode { + return ( + + ) + } + + subscribe(key: SlotKey, listener: () => void): () => void { + return this.core.subscribe(key, listener) + } + + version(key: SlotKey): number { + return this.core.getVersion(key) + } + + spec(key: SlotKey): SlotSpec | undefined { + return this.core.spec(key) as SlotSpec | undefined + } + + entries(key: SlotKey): readonly StoredEntry[] { + return this.core.entriesOfSlot(key) + } + + isLive(entry: StoredEntry): boolean { + return this.core.isLive(entry) + } + + reportEntryError(key: SlotKey, entry: StoredEntry, error: unknown): void { + const kind = this.spec(key)?.kind + this.core.reportEntryError(key, entry, error, { + abdicate: kind !== 'chain', + }) + } + + entryIdentity(entry: StoredEntry): number { + let identity = this.entryIdentities.get(entry) + if (identity === undefined) { + identity = nextEntryIdentity++ + this.entryIdentities.set(entry, identity) + } + return identity + } +} diff --git a/wework/src/plugins/WorkbenchRootFrame.tsx b/wework/src/plugins/WorkbenchRootFrame.tsx new file mode 100644 index 00000000000..27545f0015f --- /dev/null +++ b/wework/src/plugins/WorkbenchRootFrame.tsx @@ -0,0 +1,21 @@ +import { Fragment, type ReactNode } from 'react' + +export function WorkbenchRootFrame({ + children, + renderSlot, +}: { + children: ReactNode + renderSlot: ( + key: 'wework.shell.before' | 'wework.shell.after' | 'wework.shell.overlay', + owner: Record + ) => ReactNode +}) { + return ( + + {renderSlot('wework.shell.before', {})} + {children} + {renderSlot('wework.shell.after', {})} + {renderSlot('wework.shell.overlay', {})} + + ) +} diff --git a/wework/src/plugins/applications.tsx b/wework/src/plugins/applications.tsx new file mode 100644 index 00000000000..d43d6e24bca --- /dev/null +++ b/wework/src/plugins/applications.tsx @@ -0,0 +1,16 @@ +import type { Context, Plugin } from '@deepseek-ai/cordis' + +import { SitesPage } from '@/pages/SitesPage' + +export const applicationsPlugin: Plugin.Object = { + name: 'wework-applications', + inject: ['workbenchRoutes'], + apply(ctx: Context) { + return ctx.workbenchRoutes.register({ + id: 'applications.sites', + path: '/sites', + telemetryFeature: 'sites', + render: () => , + }) + }, +} diff --git a/wework/src/plugins/automations.tsx b/wework/src/plugins/automations.tsx new file mode 100644 index 00000000000..debe40ff83a --- /dev/null +++ b/wework/src/plugins/automations.tsx @@ -0,0 +1,16 @@ +import type { Context, Plugin } from '@deepseek-ai/cordis' + +import { AutomationsPage } from '@/pages/AutomationsPage' + +export const automationsPlugin: Plugin.Object = { + name: 'wework-automations', + inject: ['workbenchRoutes'], + apply(ctx: Context) { + return ctx.workbenchRoutes.register({ + id: 'automations.root', + path: '/automations', + telemetryFeature: 'automations', + render: () => , + }) + }, +} diff --git a/wework/src/plugins/base-profile.ts b/wework/src/plugins/base-profile.ts new file mode 100644 index 00000000000..87e06e21022 --- /dev/null +++ b/wework/src/plugins/base-profile.ts @@ -0,0 +1,32 @@ +import { applicationsPlugin } from './applications' +import { automationsPlugin } from './automations' +import { cloudWorkPlugin } from './cloud-work' +import { pluginCenterPlugin } from './plugin-center' +import { shellPlugin } from './shell' +import { coreAppsPlugin } from './core-apps' +import { coreSettingsPlugin } from './core-settings' + +import type { Plugin } from '@deepseek-ai/cordis' +import type { WorkbenchPluginProfile, WorkbenchPluginProfileEntry } from '@/plugin-runtime/runtime' + +function required(id: string, plugin: Plugin): WorkbenchPluginProfileEntry { + return { + id, + plugin, + required: true, + clientVersion: __WEWORK_APP_VERSION__, + } +} + +export const baseWorkbenchProfile: WorkbenchPluginProfile = { + id: 'wework-base', + entries: [ + required('shell', shellPlugin), + required('core-apps', coreAppsPlugin), + required('core-settings', coreSettingsPlugin), + required('plugin-center', pluginCenterPlugin), + required('applications', applicationsPlugin), + required('automations', automationsPlugin), + required('cloud-work', cloudWorkPlugin), + ], +} diff --git a/wework/src/plugins/cloud-work.tsx b/wework/src/plugins/cloud-work.tsx new file mode 100644 index 00000000000..3c2301a3851 --- /dev/null +++ b/wework/src/plugins/cloud-work.tsx @@ -0,0 +1,16 @@ +import type { Context, Plugin } from '@deepseek-ai/cordis' + +import { CloudWorkPage } from '@/pages/CloudWorkPage' + +export const cloudWorkPlugin: Plugin.Object = { + name: 'wework-cloud-work', + inject: ['workbenchRoutes'], + apply(ctx: Context) { + return ctx.workbenchRoutes.register({ + id: 'cloud-work.root', + path: '/cloud-work', + telemetryFeature: 'cloud_work', + render: () => , + }) + }, +} diff --git a/wework/src/plugins/core-apps.ts b/wework/src/plugins/core-apps.ts new file mode 100644 index 00000000000..3a1b4bfff65 --- /dev/null +++ b/wework/src/plugins/core-apps.ts @@ -0,0 +1,13 @@ +import type { Context, Plugin } from '@deepseek-ai/cordis' +import { CORE_WORKBENCH_APPS } from '@/plugin-runtime/core-apps-data' + +export const coreAppsPlugin: Plugin.Object = { + name: 'wework-core-apps', + inject: ['workbenchApps'], + apply(ctx: Context) { + const disposers = CORE_WORKBENCH_APPS.map(app => ctx.workbenchApps.register(app)) + return () => { + for (const dispose of disposers.reverse()) dispose() + } + }, +} diff --git a/wework/src/plugins/core-settings.ts b/wework/src/plugins/core-settings.ts new file mode 100644 index 00000000000..bb56085940d --- /dev/null +++ b/wework/src/plugins/core-settings.ts @@ -0,0 +1,16 @@ +import type { Context, Plugin } from '@deepseek-ai/cordis' + +import { CORE_WORKBENCH_SETTINGS } from '@/plugin-runtime/core-settings-data' + +export const coreSettingsPlugin: Plugin.Object = { + name: 'wework-core-settings', + inject: ['workbenchSettings'], + apply(ctx: Context) { + const disposers = CORE_WORKBENCH_SETTINGS.map(contribution => + ctx.workbenchSettings.register(contribution) + ) + return () => { + for (const dispose of disposers.reverse()) dispose() + } + }, +} diff --git a/wework/src/plugins/plugin-center.tsx b/wework/src/plugins/plugin-center.tsx new file mode 100644 index 00000000000..4657d597f8d --- /dev/null +++ b/wework/src/plugins/plugin-center.tsx @@ -0,0 +1,35 @@ +import type { Context, Plugin } from '@deepseek-ai/cordis' + +import { PluginCreatePage } from '@/pages/PluginCreatePage' +import { PluginManagementPage } from '@/pages/PluginManagementPage' +import { PluginsPage } from '@/pages/PluginsPage' + +export const pluginCenterPlugin: Plugin.Object = { + name: 'wework-plugin-center', + inject: ['workbenchRoutes'], + apply(ctx: Context) { + const disposers = [ + ctx.workbenchRoutes.register({ + id: 'plugin-center.catalog', + path: '/plugins', + telemetryFeature: 'plugins', + render: ({ search }) => , + }), + ctx.workbenchRoutes.register({ + id: 'plugin-center.create', + path: '/plugins/create', + telemetryFeature: 'plugin_create', + render: () => , + }), + ctx.workbenchRoutes.register({ + id: 'plugin-center.management', + path: '/plugins/manage', + telemetryFeature: 'plugin_management', + render: () => , + }), + ] + return () => { + for (const dispose of disposers.reverse()) dispose() + } + }, +} diff --git a/wework/src/plugins/shell.tsx b/wework/src/plugins/shell.tsx new file mode 100644 index 00000000000..0b7b1d820d7 --- /dev/null +++ b/wework/src/plugins/shell.tsx @@ -0,0 +1,20 @@ +import type { Context, Plugin } from '@deepseek-ai/cordis' + +import { WorkbenchRootFrame } from './WorkbenchRootFrame' + +export const shellPlugin: Plugin.Object = { + name: 'wework-shell', + inject: ['workbenchSlots'], + apply(ctx: Context) { + return ctx.workbenchSlots.register({ + name: 'root', + component: WorkbenchRootFrame, + children: { + 'wework.shell.before': { kind: 'list', scope: 'root' }, + 'wework.shell.after': { kind: 'list', scope: 'root' }, + 'wework.shell.overlay': { kind: 'list', scope: 'root' }, + }, + registrant: 'wework-shell', + }) + }, +} diff --git a/wework/src/test/bundled-plugin-resources.test.ts b/wework/src/test/bundled-plugin-resources.test.ts index 3134399026f..99ab3b5e559 100644 --- a/wework/src/test/bundled-plugin-resources.test.ts +++ b/wework/src/test/bundled-plugin-resources.test.ts @@ -1,4 +1,5 @@ import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { resolve } from 'node:path' @@ -11,6 +12,7 @@ const bundledMarketplaceManifests = [ const bundledPluginExampleManifests = [ 'bundled-plugins/wework-plugin-example/.codex-plugin/plugin.json', + 'bundled-plugins/wework-plugin-example/.wework-plugin/plugin.json', 'bundled-plugins/wework-plugin-example/.mcp.json', ] @@ -82,6 +84,24 @@ describe('bundled plugin resources', () => { } }) + test('ships a valid Workbench entry digest in the example plugin', () => { + const exampleRoot = resolve(process.cwd(), 'src-tauri/bundled-plugins/wework-plugin-example') + const manifest = JSON.parse( + readFileSync(resolve(exampleRoot, '.wework-plugin/plugin.json'), 'utf8') + ) as { + name: string + frontend: { + entry: string + sha256: string + } + } + const frontend = readFileSync(resolve(exampleRoot, manifest.frontend.entry)) + const digest = createHash('sha256').update(frontend).digest('hex') + + expect(manifest.name).toBe('wework-plugin-example') + expect(manifest.frontend.sha256).toBe(digest) + }) + test('uses the shared release config generator in GitHub macOS releases', () => { const workflow = readFileSync( resolve(process.cwd(), '../.github/workflows/wework-app.yml'),