diff --git a/README.de.md b/README.de.md index 3670a00b2..32b658e92 100644 --- a/README.de.md +++ b/README.de.md @@ -12,7 +12,7 @@ > **Hinweis:** Diese Übersetzung wurde automatisch erstellt und kann Ungenauigkeiten enthalten.

- Missionskontrolle für Ihre Coding-Agenten: eine unendliche Arbeitsfläche für Terminals, Editoren, Browser und Dokumente. + Eine IDE auf unendlicher Arbeitsfläche für parallele Coding-Agenten.

@@ -45,15 +45,13 @@ Laden Sie eine vorgefertigte Version herunter. Bauen Sie für den täglichen Geb ## Was drinsteckt -- **Agenten-bewusste Terminals:** Cate erkennt Coding-Agenten (Claude Code, Codex und andere), die in einem beliebigen Terminal laufen. Tabs zeigen den Agentenzustand live: läuft, fertig oder wartet auf Eingabe, mit einer Systembenachrichtigung, wenn ein Agent Sie braucht. Terminals überstehen Neustarts und Fensterwechsel mit intaktem Verlauf, Farben und Vollbild-TUIs. -- **Paralleles Arbeiten:** Beschreiben Sie, woran Sie arbeiten, und Cate erstellt einen Git-Worktree mit eigenem Branch, eigener Farbe und eigenem Territorium auf der Fläche. Checken Sie eine PR direkt in einen Worktree aus, und lassen Sie `.env` oder `node_modules` automatisch in jeden neuen verlinken. -- **Agenten-steuerbarer Browser:** eingebaute Browser-Panels, die Agenten über die `cate`-CLI aus der Shell steuern können: Seiten öffnen, Screenshots aufnehmen, Accessibility-Snapshots lesen, klicken und tippen. -- **Integrierter Agent-Chat:** ein eingebetteter Coding-Agent (Pi) mit Chat-Threads und Modellgedächtnis pro Thread. Verbinden Sie Anthropic, OpenAI Codex, GitHub Copilot, Gemini, OpenRouter, Groq, Mistral, DeepSeek und weitere per OAuth oder API-Key. -- **Arbeitsfläche & Layout:** unendliches Zoomen und Verschieben, Andocken als Tabs und Splits in vier Zonen, ablösbare Fenster, gespeicherte Layouts und Sitzungswiederherstellung über mehrere Projekte. -- **Editoren & Dokumente:** Monaco-Editoren mit Syntaxhervorhebung, Multi-Cursor, Diffs und Markdown-Vorschau; Dokument-Panels für PDFs, DOCX und Bilder. -- **Git:** git-bewusster Dateibaum mit Live-Überwachung, dazu eine Versionsverwaltungs-Seitenleiste für Staging, Branches, Worktrees, Verlauf und Inline-Diffs. Volltextsuche. -- **Remote-Arbeitsbereiche:** Verbinden Sie sich per SSH mit einer Maschine und arbeiten Sie wie in einem lokalen Ordner. Terminals, Agenten und Suche laufen remote über einen leichtgewichtigen Runtime-Daemon. -- **Navigation:** flächenweite Suche über Dateien, Terminal-Verlauf und Panel-Titel; Befehlspalette; Tastaturnavigation von Panel zu Panel. +- **Agenten-bewusste Terminals:** Cate klinkt sich per Hooks in die unterstützten Agenten-CLIs ein (Claude Code, Codex, Cursor, OpenCode, Pi), sodass der Agent selbst Turn-Beginn, Turn-Ende und Berechtigungsabfragen meldet. Das steuert den Panel-Zustand (läuft, wartet, fertig) und die Benachrichtigung, wenn einer eine Antwort braucht. Ein Agent, der keine Hooks sendet, zeigt keinen Status. +- **Agenten-Sitzungen überstehen Neustarts:** Der Hook-Strom trägt die Sitzungs-ID jeder CLI. Öffnen Sie das Projekt erneut, kommen die Terminals mit ihrem Verlauf zurück und der Agent wird mit seinem eigenen Resume-Befehl wieder angehängt. Eine veraltete ID fällt auf eine einfache Shell zurück, statt die falsche Unterhaltung fortzusetzen. +- **Worktrees für parallele Branches:** Beschreiben Sie, woran Sie arbeiten, und Cate legt Worktree und Branch an, ausgehend von einem lokalen oder entfernten Branch oder einer offenen PR. Jeder bekommt eine Farbe, die ihn durch Seitenleiste und Dock-Tabs begleitet, samt Territorium hinter seinen Panels auf der Arbeitsfläche. +- **Panels auf der Fläche oder im Dock:** Terminals, Monaco-Editoren, Browser, PDF-/Bild-/DOCX-Anzeigen, Erweiterungs-Webviews, verschachtelte Flächen. Lassen Sie sie schweben, docken Sie sie als Tabs und Splits an oder ziehen Sie sie in ein eigenes Fenster. Das Layout bleibt pro Projekt erhalten. +- **Git und Suche:** Versionsverwaltungs-Seitenleiste für Staging, Commits, Branches, Stash und Verlauf über mehrere Repos; Git-Markierungen im Dateibaum; Diffs nebeneinander. Ripgrep-Suche über den Arbeitsbereich, und `Cmd+K` für Befehle, Panels und Dateien. +- **Eine CLI, die Agenten aufrufen können:** In einem Cate-Terminal steuert `cate` ein Browser-Panel (`open`, `screenshot`, `snapshot`, `click`, `type`), liest ein anderes Terminal, öffnet Dateien, verwaltet Panels. Einstellungen → CLI gibt jede Fläche getrennt für Lesen und Steuern frei. +- **Lokal und remote gehen denselben Weg:** Ein einziger Runtime-Daemon bedient jeden Arbeitsbereich. Zeigen Sie Cate per SSH oder WSL auf einen Host: Terminals, Git, Suche und Agenten laufen dort; Editoren, Browser und Fläche bleiben lokal. ## Erweiterungen diff --git a/README.fr.md b/README.fr.md index 5878dedc6..42a5d5716 100644 --- a/README.fr.md +++ b/README.fr.md @@ -12,7 +12,7 @@ > **Note :** Cette traduction a été générée automatiquement et peut contenir des inexactitudes.

- Le centre de contrôle de vos agents de code : un canevas infini pour vos terminaux, éditeurs, navigateurs et documents. + Un IDE à canevas infini pour agents de code en parallèle.

@@ -45,15 +45,13 @@ Téléchargez une version précompilée. Ne compilez pas depuis les sources pour ## Ce qu'il contient -- **Terminaux conscients des agents :** Cate détecte les agents de code (Claude Code, Codex et d'autres) qui tournent dans n'importe quel terminal. Les onglets affichent l'état de l'agent en direct : en cours, terminé ou en attente d'une réponse, avec une notification système quand un agent a besoin de vous. Les terminaux survivent aux redémarrages et aux déplacements de fenêtre avec leur historique, leurs couleurs et leurs TUI plein écran intacts. -- **Travail parallèle :** décrivez ce sur quoi vous travaillez et Cate crée un worktree git avec sa propre branche, sa couleur et son territoire sur le canevas. Récupérez une PR directement dans un worktree, et liez automatiquement `.env` ou `node_modules` dans chaque nouveau worktree. -- **Navigateur pilotable par agent :** des panneaux navigateur intégrés que les agents peuvent contrôler depuis le shell via la CLI `cate` : ouvrir des pages, prendre des captures d'écran, lire des instantanés d'accessibilité, cliquer et saisir du texte. -- **Chat d'agent intégré :** un agent de code embarqué (Pi) avec fils de discussion et mémoire de modèle par fil. Connectez Anthropic, OpenAI Codex, GitHub Copilot, Gemini, OpenRouter, Groq, Mistral, DeepSeek et d'autres via OAuth ou clé API. -- **Canevas et disposition :** zoom et déplacement infinis, ancrage en onglets et divisions sur quatre zones, fenêtres détachables, dispositions enregistrées et restauration de session multi-projets. -- **Éditeurs et documents :** éditeurs Monaco avec coloration syntaxique, multi-curseur, diffs et aperçu Markdown ; panneaux de document pour PDF, DOCX et images. -- **Git :** arborescence de fichiers consciente de git avec suivi en direct, plus une barre latérale de contrôle de source pour l'index, les branches, les worktrees, l'historique et les diffs en ligne. Recherche plein texte. -- **Espaces de travail distants :** connectez-vous à une machine via SSH et travaillez comme sur un dossier local. Terminaux, agents et recherche s'exécutent à distance via un démon runtime léger. -- **Navigation :** recherche sur tout le canevas dans les fichiers, l'historique des terminaux et les titres de panneaux ; palette de commandes ; navigation clavier de panneau en panneau. +- **Terminaux conscients des agents :** Cate installe ses hooks dans les CLI d'agents prises en charge (Claude Code, Codex, Cursor, OpenCode, Pi) : l'agent signale lui-même le début et la fin d'un tour ainsi que les demandes d'autorisation. C'est ce qui alimente l'état du panneau (en cours, en attente, terminé) et la notification quand un agent attend votre réponse. Un agent qui n'envoie aucun hook n'affiche aucun état. +- **Les sessions d'agent survivent aux redémarrages :** le flux de hooks transporte l'identifiant de session de chaque CLI. Rouvrez le projet : les terminaux reviennent avec leur historique et l'agent est rattaché via sa propre commande de reprise. Un identifiant périmé retombe sur un simple shell plutôt que de reprendre la mauvaise conversation. +- **Worktrees pour branches parallèles :** décrivez ce sur quoi vous travaillez et Cate crée un worktree et une branche, à partir d'une branche locale ou distante ou d'une PR ouverte. Chacun reçoit une couleur qui le suit dans la barre latérale, les onglets du dock et un territoire dessiné derrière ses panneaux sur le canevas. +- **Des panneaux sur le canevas ou dans le dock :** terminaux, éditeurs Monaco, navigateurs, visionneuses PDF/image/DOCX, webviews d'extensions, canevas imbriqués. Faites-les flotter sur le canevas, ancrez-les en onglets et divisions, ou glissez-les dans leur propre fenêtre. La disposition est conservée par projet. +- **Git et recherche :** barre latérale de contrôle de source pour l'index, les commits, les branches, le stash et l'historique, sur plusieurs dépôts ; badges git dans l'arborescence ; diffs côte à côte. Recherche ripgrep sur l'espace de travail, et `Cmd+K` pour les commandes, les panneaux et les fichiers. +- **Une CLI que les agents peuvent appeler :** dans un terminal Cate, `cate` pilote un panneau navigateur (`open`, `screenshot`, `snapshot`, `click`, `type`), lit un autre terminal, ouvre des fichiers, gère les panneaux. Réglages → CLI autorise chaque surface séparément en lecture et en contrôle. +- **Local et distant suivent le même chemin :** un seul démon runtime sert tous les espaces de travail. Pointez Cate vers une machine en SSH ou WSL : terminaux, git, recherche et agents s'y exécutent ; éditeurs, navigateur et canevas restent en local. ## Extensions diff --git a/README.zh-CN.md b/README.zh-CN.md index a3dc2cf37..89affe440 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -12,7 +12,7 @@ > **注意:** 本翻译由机器自动生成,可能存在不准确之处。

- 编码智能体的任务控制中心:一块容纳终端、编辑器、浏览器和文档的无限画布。 + 为并行编码智能体打造的无限画布 IDE。

@@ -45,15 +45,13 @@ Cate 是一款基于无限画布的桌面 IDE,为同时运行大量终端和 ## 包含什么 -- **感知智能体的终端:** Cate 能检测在任意终端中运行的编码智能体(Claude Code、Codex 等)。标签页实时显示智能体状态:运行中、已完成或等待输入,当智能体需要你时还会发出系统通知。终端在重启和跨窗口移动后保留回滚缓冲、颜色和全屏 TUI。 -- **并行工作:** 描述你要做什么,Cate 就会创建一个 git worktree,带有自己的分支、颜色和画布领地。可以把 PR 直接检出到 worktree,还能自动把 `.env` 或 `node_modules` 符号链接到每个新 worktree。 -- **智能体可驱动的浏览器:** 内置浏览器面板,智能体可通过 `cate` CLI 在 shell 中控制:打开页面、截图、读取无障碍快照、点击和输入。 -- **内置智能体聊天:** 内嵌编码智能体(Pi),支持聊天线程和按线程记忆模型。通过 OAuth 或 API key 接入 Anthropic、OpenAI Codex、GitHub Copilot、Gemini、OpenRouter、Groq、Mistral、DeepSeek 等。 -- **画布与布局:** 无限缩放和平移,四个区域的标签和分屏停靠,可拆分窗口,保存布局,多项目会话还原。 -- **编辑器与文档:** Monaco 编辑器,支持语法高亮、多光标、差异对比和 Markdown 预览;文档面板可渲染 PDF、DOCX 和图片。 -- **Git:** 感知 git 的文件树,带实时监听;外加版本控制侧边栏,处理暂存、分支、worktree、历史和行内差异。全文搜索。 -- **远程工作区:** 通过 SSH 连接远程机器,像本地文件夹一样工作。终端、智能体和搜索通过轻量级运行时守护进程在远端执行。 -- **导航:** 跨画布搜索文件、终端回滚和面板标题;命令面板;面板间键盘导航。 +- **感知智能体的终端:** Cate 会为支持的智能体 CLI(Claude Code、Codex、Cursor、OpenCode、Pi)安装钩子,由智能体自己上报一轮对话的开始、结束以及权限询问。面板的运行中/等待/已完成状态,以及智能体需要你回应时的通知,都由此驱动。不发送钩子的智能体不会显示任何状态。 +- **智能体会话在重启后延续:** 钩子流会带上每个 CLI 的会话 ID。重新打开项目,终端会带着回滚缓冲回来,并用智能体自己的恢复命令重新接上会话。ID 已失效时会退回普通 shell,而不是恢复错误的会话。 +- **为并行分支准备的 worktree:** 描述你要做什么,Cate 就会基于本地分支、远程分支或一个开放的 PR 创建 worktree 和分支。每个 worktree 都有专属颜色,贯穿侧边栏、停靠标签,以及画布上绘制在其面板背后的领地。 +- **画布上或停靠区里的面板:** 终端、Monaco 编辑器、浏览器、PDF/图片/DOCX 查看器、扩展 webview、嵌套画布。可以浮在画布上、停靠成标签和分屏,或拖进独立窗口。布局按项目保存。 +- **Git 与搜索:** 版本控制侧边栏支持暂存、提交、分支、stash 和历史,可跨多个仓库;文件树带 git 状态标记;并排差异对比。工作区内使用 ripgrep 搜索,`Cmd+K` 查找命令、面板和文件。 +- **智能体可调用的 CLI:** 在 Cate 终端里,`cate` 可以驱动浏览器面板(`open`、`screenshot`、`snapshot`、`click`、`type`)、读取其他终端、打开文件、管理面板。设置 → CLI 中可分别授予每个能力的读取与控制权限。 +- **本地与远程走同一条路:** 同一个运行时守护进程服务所有工作区。通过 SSH 或 WSL 指向一台主机,终端、git、搜索和智能体都在那边运行;编辑器、浏览器和画布留在本地。 ## 扩展 diff --git a/docs/extensions.md b/docs/extensions.md index 0eb40ec8e..bfc19d4da 100644 --- a/docs/extensions.md +++ b/docs/extensions.md @@ -2,10 +2,11 @@ ## Overview -An extension adds panels to Cate by shipping a **web frontend**, and optionally a **local server process** for backend work. Each panel renders on the canvas like any built-in panel (zooms, clips, composites). Extensions come in two shapes: +An extension adds panels to Cate by shipping a **web frontend**, and optionally a **local server process** for backend work. Each panel renders on the canvas like any built-in panel (zooms, clips, composites). Extensions come in three shapes: - **Frontend-only** (default) — just static web assets. Cate serves them and the panel talks to Cate solely through the `cateHost` bridge. No process, port, token, or lifecycle to manage. Best for tools that only need the Cate API (viewers, formatters, pickers, dashboards over `cate.storage`). - **Server-backed** — also ships a local server for full OS access (filesystem, processes, sockets, network) without a capability broker. Cate spawns **one server per extension per workspace** and points every panel's webview at it. The relationship is always **n:1** — many panels, one server — and the server handles concurrent panels: routing state and events per panel id, isolating panel-local data, and tolerating panels opening and closing independently. +- **URL** — no assets and no process: the manifest names a remote `https://` page and the panel is pointed straight at it. The panel webview is a top-level browsing context, so pages that refuse to be framed (`X-Frame-Options` / `frame-ancestors`) still load, and each extension keeps its own persistent session partition so a login survives restarts. Best for wrapping a hosted web app (chat, CRM, dashboards) as a panel. A URL extension gets **no** `cateHost` bridge — see Security Hygiene. Cate only standardizes how it serves/launches an extension and a small reverse API back into Cate. The built-in agent panel is server-backed and is the canonical reference. @@ -29,11 +30,16 @@ Cate only standardizes how it serves/launches an extension and a small reverse A ], "frontend": "dist/index.html", "server": { "command": "node dist/server.js", "readyPath": "/health", "portEnv": "PORT" }, + "url": "https://example.com/app", "cateApi": ["workspace.read", "editor.write", "storage"] } ``` -`server` is **optional** — omit it for a frontend-only extension, where Cate serves the `frontend` entry statically and injects only the `cateHost` bridge. When `server` is present it serves the frontend itself at `PORT` and `frontend` is ignored. +`server`, `url` and `frontend` are all **optional**, and a manifest normally declares exactly one. Mode precedence when several are present is **`server` > `url` > `frontend`** (a mixed manifest still loads; the extra fields are simply ignored): + +- frontend-only: Cate serves the `frontend` entry statically and injects the `cateHost` bridge. +- server-backed: the server serves the frontend itself at `PORT`; `frontend` is ignored. +- url: `url` must be an absolute **`https://`** URL. Anything else (`http:` including `localhost`, `file:`, `javascript:`, `data:`, garbage) is dropped at manifest validation and the extension falls back to its other modes. Use `server` for a local dev server; `url` is for hosted pages only. ## Lifecycle @@ -50,6 +56,7 @@ Applies only to **server-backed** extensions. Frontend-only panels are plain web - Servers bind `127.0.0.1` only. Cate injects `HOST=127.0.0.1` into the server's environment and the server is expected to bind that host; a server that ignores `HOST` and binds `0.0.0.0` would expose itself on the network, defeating the token gate. Honoring `HOST` (alongside `PORT`) is part of the server contract. - Per-server random port + shared token (`CATE_TOKEN`); the server requires the token on every panel connection so other local processes/tabs can't drive it. Panels authenticate with the token and identify themselves by `cate.panel.id`. - Tight CSP on the webview. +- **URL extensions get no Cate API.** A guest's identity (which extension, which workspace) is derived from the opaque route token in the local proxy's own origin, so a remote page can never prove one — handing it the `cateHost` preload would only create a bridge whose every call is rejected. Cate therefore attaches no preload to a `url` panel, and the main process independently strips the preload from any guest whose URL isn't the proxy origin. `cateApi` scopes in a url-mode manifest are inert. The page still runs in the extension's own persistent partition, so its cookies/logins are isolated from other extensions and from browser panels. ## Reverse API diff --git a/src/main/extensions/proxyServer.ts b/src/main/extensions/proxyServer.ts index c245fa41a..e35c2ce4a 100644 --- a/src/main/extensions/proxyServer.ts +++ b/src/main/extensions/proxyServer.ts @@ -477,6 +477,23 @@ export async function getProxyUrlFor(args: { const manifest = extensionManager.getManifest(extensionId) if (!manifest) return null + // URL-BACKED (manifest.url, no server): the panel points straight at a remote + // https page — no proxy server, no route token, no spawned process. Mode + // precedence is server > url > frontend, so this is only taken when the + // manifest declares no server. + // + // Security: such a guest gets NO cate host API. Guest identity is derived from + // the proxy's own origin + opaque routeToken (identityForGuestUrl), which a + // remote origin can never satisfy; handing it the cateHost preload would only + // create a bridge whose every call is rejected, while widening the surface a + // third-party page can poke at. So we return an empty preloadPath (the panel + // then omits the attribute) — and webSecurity.ts independently strips the + // preload from any guest whose URL isn't the proxy origin, so this holds even + // if the renderer asked for one. + if (!manifest.server && manifest.url) { + return { url: manifest.url, preloadPath: '' } + } + const port = await ensureProxyServer() const routeToken = registerRoute(extensionId, workspaceId) diff --git a/src/main/extensions/proxyServerUrlMode.test.ts b/src/main/extensions/proxyServerUrlMode.test.ts new file mode 100644 index 000000000..8066afbd9 --- /dev/null +++ b/src/main/extensions/proxyServerUrlMode.test.ts @@ -0,0 +1,70 @@ +// ============================================================================= +// proxyServer.getProxyUrlFor — url-mode extensions resolve to the remote page +// directly: no proxy server started, no route token, and NO cateHost preload +// (a remote origin can never satisfy identityForGuestUrl, so it gets no host +// API). A manifest that declares both server and url still takes the server +// path — precedence is server > url > frontend. +// ============================================================================= + +import { describe, it, expect, vi, beforeEach } from 'vitest' +import type { ExtensionManifest } from '../../shared/extensions' + +const state = vi.hoisted(() => ({ + manifest: null as ExtensionManifest | null, + createServerCalls: 0, +})) + +vi.mock('http', () => { + const createServer = vi.fn(() => { + state.createServerCalls++ + const handlers: Record void> = {} + const fake = { + on(ev: string, cb: (arg?: unknown) => void) { handlers[ev] = cb; return fake }, + listen(_port: number, _host: string, cb: () => void) { queueMicrotask(() => cb()) }, + address() { return { port: 4321 } }, + } + return fake + }) + return { default: { createServer } } +}) + +vi.mock('./ExtensionManager', () => ({ + extensionManager: { + isKnown: () => true, + isEnabled: () => true, + getManifest: () => state.manifest, + }, +})) +const joinPanel = vi.hoisted(() => vi.fn(async () => undefined)) +vi.mock('./ExtensionServerManager', () => ({ extensionServerManager: { joinPanel } })) +vi.mock('./serverTunnel', () => ({ openTunnelDuplex: vi.fn() })) +vi.mock('../logger', () => ({ default: { info: vi.fn(), warn: vi.fn(), error: vi.fn() } })) +vi.mock('electron', () => ({})) +vi.mock('../workspaceManager', () => ({ getWorkspaceInfo: vi.fn() })) +vi.mock('../runtime/runtimeManager', () => ({ runtimes: { resolve: vi.fn() } })) + +import { getProxyUrlFor } from './proxyServer' + +const ARGS = { extensionId: 'cate.discord', workspaceId: 'ws1', panelId: 'p1' } + +function base(extra: Partial): ExtensionManifest { + return { id: 'cate.discord', name: 'Discord', panels: [{ id: 'main', label: 'Discord' }], ...extra } +} + +describe('getProxyUrlFor — url mode', () => { + beforeEach(() => { state.createServerCalls = 0; joinPanel.mockClear() }) + + it('returns the remote url with no preload and starts no proxy', async () => { + state.manifest = base({ url: 'https://discord.com/app' }) + const res = await getProxyUrlFor(ARGS) + expect(res).toEqual({ url: 'https://discord.com/app', preloadPath: '' }) + expect(state.createServerCalls).toBe(0) + }) + + it('prefers server over url when a manifest declares both', async () => { + state.manifest = base({ url: 'https://discord.com/app', server: { command: 'node s.js' } }) + const res = await getProxyUrlFor({ ...ARGS, sender: {} as Electron.WebContents }) + expect(res && 'url' in res ? res.url : '').toMatch(/^http:\/\/127\.0\.0\.1:\d+\/ext\//) + expect(joinPanel).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/panels/CanvasPanel.tsx b/src/renderer/panels/CanvasPanel.tsx index 8c1ff1bda..fe7dee5f4 100644 --- a/src/renderer/panels/CanvasPanel.tsx +++ b/src/renderer/panels/CanvasPanel.tsx @@ -18,9 +18,8 @@ import { EmptyCanvasOverlay } from './EmptyCanvasOverlay' import type { PanelType, Point, DockLayoutNode, WindowDockState } from '../../shared/types' import { useAppStore, useSelectedWorkspace, type PanelPlacement } from '../stores/appStore' import { useCateAgentStore } from '../cateAgent/cateAgentStore' -import { useStoreWithEqualityFn } from 'zustand/traditional' import type { StoreApi } from 'zustand' -import { keepsMountedOffscreen } from '../../shared/panels' +import { useKeepMountedPanelIds } from './keepMountedPanels' import { ensureWorkspaceFolder } from '../hooks/useShortcuts' import { setActivePanel } from '../lib/activePanel' import { createDockStore, type DockStore } from '../stores/dockStore' @@ -40,39 +39,6 @@ import { activeDockPanelId } from '../../shared/collectPanelIds' // directly from './nodeDockRegistry' to skip the heavy CanvasPanel module. export { findNodeDockStore, findNodeIdForDockStore } -// Same-membership equality for the keep-mounted set, so the selector below hands -// back the SAME Set object whenever the ids are unchanged. -function setEqual(a: ReadonlySet, b: ReadonlySet): boolean { - if (a === b) return true - if (a.size !== b.size) return false - for (const v of a) if (!b.has(v)) return false - return true -} - -// The set of panel ids whose type must stay mounted off-screen (webview-backed -// extensions). Derived from the workspace's panels with an equality-checked -// selector so pure panel-state churn (a title edit, dirty flag, etc.) does NOT -// produce a new Set — the cull's keep-alive cache is keyed on this set's identity, -// so a stable identity means no re-render here and no per-frame recompute there. -// Panel `type` is immutable after creation, so this only changes when a -// keep-mounted panel is actually added or removed. -function useKeepMountedPanelIds(workspaceId: string): ReadonlySet { - return useStoreWithEqualityFn( - useAppStore, - (s) => { - const panels = s.workspaces.find((w) => w.id === workspaceId)?.panels - const ids = new Set() - if (panels) { - for (const p of Object.values(panels)) { - if (keepsMountedOffscreen(p.type)) ids.add(p.id) - } - } - return ids - }, - setEqual, - ) -} - interface CanvasPanelProps { panelId: string workspaceId: string @@ -238,10 +204,13 @@ export default function CanvasPanel({ panelId, workspaceId, renderPanelContent } // `visibleNodeIds` is viewport-culled: we only mount CanvasNodeWrapper for // nodes whose bbox overlaps the visible canvas rect (plus a 1-screen margin), // so off-screen terminals/editors don't hold live xterm/Monaco instances. - // `keepMountedPanelIds` lets the cull exempt webview-backed nodes (extensions) - // so panning them off-screen doesn't unmount the guest and reset its session - // state. It's a stable, membership-keyed set (see useKeepMountedPanelIds) so - // unrelated panel churn (titles, dirty flags) never re-runs the cull. + // `keepMountedPanelIds` lets the cull exempt webview-backed nodes (local + // extensions) so panning them off-screen doesn't unmount the guest and reset its + // session state. url-mode extensions are deliberately NOT exempt: they're remote + // SaaS pages whose login survives in the persistent session partition, so they + // reload rather than lose state. It's a stable, membership-keyed set (see + // useKeepMountedPanelIds) so unrelated panel churn (titles, dirty flags) never + // re-runs the cull. const nodeIds = useNodeIds(store) const keepMountedPanelIds = useKeepMountedPanelIds(workspaceId) // Terminals the Cate Agent is driving must also stay mounted off-view: they're diff --git a/src/renderer/panels/ExtensionPanel.test.tsx b/src/renderer/panels/ExtensionPanel.test.tsx index 43854a5c4..240faccdb 100644 --- a/src/renderer/panels/ExtensionPanel.test.tsx +++ b/src/renderer/panels/ExtensionPanel.test.tsx @@ -104,6 +104,22 @@ describe('ExtensionPanel', () => { expect(insertCSS).toHaveBeenCalledTimes(1) expect(insertCSS.mock.calls[0][0]).toContain('::-webkit-scrollbar') }) + + it('sets the preload for a proxied guest', async () => { + mount({ workspaceId: 'ws-real-123' }) + await act(async () => { await Promise.resolve() }) + const webview = container.querySelector('webview') as HTMLElement + expect(webview.getAttribute('preload')).toBe('file:///p/cateHost.js') + }) + + it('omits the preload for a url-mode guest (no host API on a remote origin)', async () => { + proxyUrl.mockResolvedValueOnce({ url: 'https://discord.com/app', preloadPath: '' }) + mount({ workspaceId: 'ws-real-123' }) + await act(async () => { await Promise.resolve() }) + const webview = container.querySelector('webview') as HTMLElement + expect(webview.getAttribute('src')).toBe('https://discord.com/app') + expect(webview.hasAttribute('preload')).toBe(false) + }) }) describe('guestScrollbarCss', () => { diff --git a/src/renderer/panels/ExtensionPanel.tsx b/src/renderer/panels/ExtensionPanel.tsx index 0135fcad0..f0ce95269 100644 --- a/src/renderer/panels/ExtensionPanel.tsx +++ b/src/renderer/panels/ExtensionPanel.tsx @@ -318,6 +318,8 @@ export default function ExtensionPanel({ // data-filedrop on the wrapper (not an overlay) lets the shared drag tracker // find this target; the drop effect above toggles the webview to // pointer-events:none during a Cate drag so hit-testing reaches the wrapper. + // A url-mode extension resolves an empty preloadPath (remote origins get no + // cate host API) — omit the attribute entirely rather than pass "file://". return (

diff --git a/src/renderer/panels/keepMountedPanels.test.tsx b/src/renderer/panels/keepMountedPanels.test.tsx new file mode 100644 index 000000000..2c7fc0bc1 --- /dev/null +++ b/src/renderer/panels/keepMountedPanels.test.tsx @@ -0,0 +1,185 @@ +// ============================================================================= +// keepMountedPanels — which panel instances survive the canvas viewport cull. +// +// Local (frontend/server) extensions must stay mounted off-screen: unmounting +// destroys the guest and its in-page state unrecoverably. url-mode +// extensions are remote SaaS pages whose login lives in the persistent +// `persist:ext-` session partition, so a remount just reloads the page — +// they participate in the cull like any other node. +// +// The second block covers the referential-stability contract: the keep-mounted +// set is the cache key of the cull's keep-alive memo, so it MUST keep its +// identity across unrelated store churn. +// ============================================================================= + +import React from 'react' +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { createRoot, type Root } from 'react-dom/client' +import { act } from 'react' +import type { ExtensionListEntry } from '../../shared/extensions' +import type { PanelState } from '../../shared/types' +import { + urlModeExtensionIds, + keepMountedOffscreenPanelIds, + useKeepMountedPanelIds, +} from './keepMountedPanels' +import { useExtensionsStore } from '../stores/extensionsStore' +import { useAppStore } from '../stores/appStore' +import { createCanvasStore, selectVisibleNodeIds } from '../stores/canvasStore' + +;(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true + +const entry = (id: string, extra: Partial): ExtensionListEntry => ({ + manifest: { id, name: id, panels: [{ id: 'main', label: 'Main' }], ...extra }, + enabled: true, + source: 'catalog', + rootDir: `/ext/${id}`, + installed: true, +}) + +const panel = (id: string, extensionId?: string): PanelState => ({ + id, + type: extensionId ? 'extension' : 'editor', + title: id, + isDirty: false, + ...(extensionId ? { extensionId, extensionPanelId: 'main' } : {}), +}) + +describe('urlModeExtensionIds', () => { + it('picks manifests with a url and no server (server > url precedence)', () => { + const ids = urlModeExtensionIds([ + entry('acme.remote', { url: 'https://jira.example.com' }), + entry('acme.local', { frontend: 'index.html' }), + entry('acme.served', { server: { command: 'node s.js' } }), + // A mixed manifest resolves as server mode, so it is NOT url mode. + entry('acme.mixed', { url: 'https://x.example.com', server: { command: 'node s.js' } }), + ]) + expect([...ids]).toEqual(['acme.remote']) + }) +}) + +describe('keepMountedOffscreenPanelIds', () => { + const panels: Record = { + 'p-editor': panel('p-editor'), + 'p-url': panel('p-url', 'acme.remote'), + 'p-local': panel('p-local', 'acme.local'), + 'p-unknown': panel('p-unknown', 'acme.notyetloaded'), + } + const urlMode = new Set(['acme.remote']) + + it('exempts local extensions but not url-mode ones', () => { + const ids = keepMountedOffscreenPanelIds(panels, urlMode) + expect(ids.has('p-local')).toBe(true) + expect(ids.has('p-url')).toBe(false) + }) + + it('exempts an extension whose manifest is not loaded yet (safe default)', () => { + expect(keepMountedOffscreenPanelIds(panels, urlMode).has('p-unknown')).toBe(true) + // Registry not loaded at all → every extension panel stays mounted. + expect(keepMountedOffscreenPanelIds(panels, new Set()).has('p-url')).toBe(true) + }) + + it('leaves non-webview panel types cullable', () => { + expect(keepMountedOffscreenPanelIds(panels, urlMode).has('p-editor')).toBe(false) + }) +}) + +// End-to-end through the actual cull core: an off-screen node hosting a url-mode +// extension is unmounted, a local one is not. +describe('viewport cull with url-mode extensions', () => { + it('culls the url-mode extension node and keeps the local one', () => { + const store = createCanvasStore() + const urlNode = store.getState().addNode('p-url', 'extension', { x: 5000, y: 5000 }, { width: 100, height: 80 }) + const localNode = store.getState().addNode('p-local', 'extension', { x: 5000, y: 6000 }, { width: 100, height: 80 }) + store.getState().setContainerSize({ width: 800, height: 600 }) + store.setState({ zoomLevel: 1, viewportOffset: { x: 0, y: 0 }, selection: [], selectionActive: false }) + + const panels: Record = { + 'p-url': panel('p-url', 'acme.remote'), + 'p-local': panel('p-local', 'acme.local'), + } + const keepMounted = keepMountedOffscreenPanelIds(panels, new Set(['acme.remote'])) + const visible = selectVisibleNodeIds(store.getState(), keepMounted) + + expect(visible).not.toContain(urlNode) + expect(visible).toContain(localNode) + }) +}) + +// --------------------------------------------------------------------------- +// Referential stability — the cull's keep-alive cache is keyed on this set's +// identity, so unrelated store updates must NOT mint a new Set. +// --------------------------------------------------------------------------- +describe('useKeepMountedPanelIds — referential stability', () => { + const wsId = 'ws-1' + + beforeEach(() => { + ;(window as unknown as { electronAPI: unknown }).electronAPI = { + extensionList: vi.fn(async () => []), + onExtensionsChanged: vi.fn(), + } + useExtensionsStore.setState({ + entries: [entry('acme.remote', { url: 'https://jira.example.com' }), entry('acme.local', { frontend: 'i.html' })], + }) + useAppStore.setState({ + workspaces: [ + { + ...(useAppStore.getState().workspaces[0] ?? {}), + id: wsId, + name: 'ws', + rootPath: '/tmp/ws', + panels: { + 'p-url': panel('p-url', 'acme.remote'), + 'p-local': panel('p-local', 'acme.local'), + }, + }, + ] as never, + }) + }) + + it('returns the same Set object across unrelated panel churn and re-renders', () => { + const seen: ReadonlySet[] = [] + function Probe() { + seen.push(useKeepMountedPanelIds(wsId)) + return null + } + const container = document.createElement('div') + document.body.appendChild(container) + let root!: Root + act(() => { + root = createRoot(container) + root.render() + }) + + expect([...seen[0]]).toEqual(['p-local']) + + // Unrelated panel churn: a title edit swaps the panels record, re-running the + // selector. Equal membership → zustand hands back the SAME object. + act(() => { + useAppStore.setState((s) => ({ + workspaces: s.workspaces.map((w) => + w.id === wsId + ? { ...w, panels: { ...w.panels, 'p-local': { ...w.panels['p-local'], title: 'renamed' } } } + : w, + ), + })) + }) + // A forced re-render (new selector closure) must not mint a new set either. + act(() => { root.render() }) + + // Initial render + the forced one. (The title edit itself re-runs the + // selector but produces an equal set, so it doesn't even re-render.) + expect(seen.length).toBe(2) + for (const s of seen) expect(s).toBe(seen[0]) + + // A real membership change (the url-mode extension is now unknown) DOES + // produce a new set — the local-extension default takes over. + act(() => { useExtensionsStore.setState({ entries: [] }) }) + const latest = seen[seen.length - 1] + expect(latest).not.toBe(seen[0]) + expect([...latest].sort()).toEqual(['p-local', 'p-url']) + + act(() => { root.unmount() }) + container.remove() + }) +}) diff --git a/src/renderer/panels/keepMountedPanels.ts b/src/renderer/panels/keepMountedPanels.ts new file mode 100644 index 000000000..ad008b27e --- /dev/null +++ b/src/renderer/panels/keepMountedPanels.ts @@ -0,0 +1,112 @@ +// ============================================================================= +// keepMountedPanels — which panel INSTANCES are exempt from the canvas viewport +// cull. +// +// `keepsMountedOffscreen()` in shared/panels.ts answers this per panel TYPE, and +// it can't do better: it has no access to the extension registry. Extensions are +// the one type where the answer is per-instance: +// +// • local extensions (frontend/server mode) render a guest whose live state +// only exists in-page; unmounting destroys it unrecoverably → keep mounted. +// • url-mode extensions (manifest.url, no server) point straight at a remote +// SaaS SPA (Jira, Discord, …). Each one is a full Chromium renderer with +// websockets and timers that would otherwise live forever once opened. Their +// login lives in the persistent `persist:ext-` partition, so a remount +// reloads the page with the user still signed in → safe to cull. +// +// Only the geometric/viewport cull is affected. `keepsMountedWhenTabHidden()` is +// untouched: a dock tab switch is a fast deliberate toggle, and reloading a SaaS +// page on every tab switch would cost more than the memory it saves. +// ============================================================================= + +import { useEffect, useMemo } from 'react' +import { useStoreWithEqualityFn } from 'zustand/traditional' +import type { ExtensionListEntry } from '../../shared/extensions' +import type { PanelState } from '../../shared/types' +import { keepsMountedOffscreen } from '../../shared/panels' +import { useAppStore, type AppStore } from '../stores/appStore' +import { useExtensionsStore, ensureExtensionsStarted } from '../stores/extensionsStore' + +/** Extension ids that render a remote page rather than a locally served guest. + * Mode precedence is server > url > frontend (see getProxyUrlFor), so a + * manifest that declares both is NOT url mode. */ +export function urlModeExtensionIds(entries: ExtensionListEntry[]): Set { + const ids = new Set() + for (const e of entries) { + if (e.manifest.url && !e.manifest.server) ids.add(e.manifest.id) + } + return ids +} + +/** Panel ids that must stay mounted when their canvas node scrolls off-screen. + * + * `urlModeExtIds` is the set from {@link urlModeExtensionIds}. It is empty until + * the renderer's extension registry mirror loads, which is deliberately the safe + * direction: an unknown manifest keeps the panel mounted (the pre-existing + * behaviour), so a not-yet-loaded registry can never surprise-unmount a local + * extension. When the registry loads, the set legitimately changes once. */ +export function keepMountedOffscreenPanelIds( + panels: Record | undefined, + urlModeExtIds: ReadonlySet, +): Set { + const ids = new Set() + if (!panels) return ids + for (const p of Object.values(panels)) { + if (!keepsMountedOffscreen(p.type)) continue + if (p.type === 'extension' && p.extensionId && urlModeExtIds.has(p.extensionId)) continue + ids.add(p.id) + } + return ids +} + +// ----------------------------------------------------------------------------- +// Hooks +// +// Both sets below are handed to the cull's keep-alive cache, which is keyed on +// SET IDENTITY. The cull selector runs on every store update including every +// pan/zoom frame, so a set that is a fresh object each time would defeat the +// cache and re-walk every node's dock layout 60×/s. Hence the equality-checked +// selectors: `setEqual` makes zustand hand back the SAME Set object whenever the +// membership is unchanged. +// ----------------------------------------------------------------------------- + +/** Same-membership equality, so the selectors below return a stable identity. */ +export function setEqual(a: ReadonlySet, b: ReadonlySet): boolean { + if (a === b) return true + if (a.size !== b.size) return false + for (const v of a) if (!b.has(v)) return false + return true +} + +/** Extension ids that render a remote SaaS page instead of a locally served + * guest. The identity only changes when the registry's url-mode membership + * changes — in practice once, when the lazily started mirror first loads. */ +export function useUrlModeExtensionIds(): ReadonlySet { + useEffect(() => { ensureExtensionsStarted() }, []) + return useStoreWithEqualityFn( + useExtensionsStore, + (s) => urlModeExtensionIds(s.entries), + setEqual, + ) +} + +/** The workspace's panel ids that are exempt from the viewport cull. + * + * Pure panel-state churn (a title edit, dirty flag, …) re-runs the selector but + * produces an equal set, so the identity — and therefore the cull's keep-alive + * cache — survives. Panel `type`/`extensionId` are immutable after creation, so + * the set only really changes when a keep-mounted panel is added or removed, or + * when the extension registry first loads. The selector itself is memoized on + * its inputs (both identity-stable) so it isn't rebuilt on every render. */ +export function useKeepMountedPanelIds(workspaceId: string): ReadonlySet { + const urlModeExtIds = useUrlModeExtensionIds() + const selector = useMemo( + () => (s: AppStore) => + keepMountedOffscreenPanelIds( + s.workspaces.find((w) => w.id === workspaceId)?.panels, + urlModeExtIds, + ), + [workspaceId, urlModeExtIds], + ) + return useStoreWithEqualityFn(useAppStore, selector, setEqual) +} diff --git a/src/shared/extensions.test.ts b/src/shared/extensions.test.ts index d8aa36870..9713742ef 100644 Binary files a/src/shared/extensions.test.ts and b/src/shared/extensions.test.ts differ diff --git a/src/shared/extensions.ts b/src/shared/extensions.ts index 6872bd324..42cb563c7 100644 --- a/src/shared/extensions.ts +++ b/src/shared/extensions.ts @@ -37,8 +37,9 @@ export interface ExtensionManifest { name: string version?: string panels: ExtensionPanelDef[] - frontend?: string // entry html for frontend-only (ignored when server present) + frontend?: string // entry html for frontend-only (ignored when server/url present) server?: ExtensionServerSpec + url?: string // remote https page the panel points at (see normalizeUrl) cateApi?: string[] // declared cate.* scopes } @@ -138,6 +139,28 @@ function normalizeServer(parsed: unknown): ExtensionServerSpec | undefined { } } +/** + * Normalize the optional remote-page URL (url mode), or undefined if unusable. + * + * Only `https:` is accepted. A manifest is untrusted input that ends up as a + * top-level webview `src`, so anything else is a foot-gun or an escalation: + * `file:`/`javascript:`/`data:` would run attacker-chosen content in the + * extension's persistent session partition, and plain `http:` (localhost + * included — a url extension is meant for hosted SaaS, and a local dev server is + * what `server` mode is for) would be a cleartext page inside the app. + */ +function normalizeUrl(value: unknown): string | undefined { + if (!nonEmptyString(value)) return undefined + let parsed: URL + try { + parsed = new URL(value) + } catch { + return undefined + } + if (parsed.protocol !== 'https:') return undefined + return value +} + /** * Validate untrusted parsed JSON into a manifest, or null if unusable * (missing id, missing/empty panels, panel without id/label). Never throws. @@ -171,6 +194,13 @@ export function normalizeManifest(parsed: unknown): ExtensionManifest | null { const server = normalizeServer(parsed.server) if (server) manifest.server = server + // Mode precedence when a manifest declares more than one backend: + // `server` > `url` > `frontend`. A mixed manifest is kept (rather than + // rejected) so a badly-written one still loads; the resolver in + // main/extensions/proxyServer.ts picks the winner by this order. + const url = normalizeUrl(parsed.url) + if (url) manifest.url = url + if (Array.isArray(parsed.cateApi)) { const scopes = parsed.cateApi.filter((s): s is string => typeof s === 'string') if (scopes.length > 0) manifest.cateApi = scopes diff --git a/src/shared/panels.ts b/src/shared/panels.ts index 65204e44b..a7262f0dd 100644 --- a/src/shared/panels.ts +++ b/src/shared/panels.ts @@ -172,7 +172,11 @@ export function getSharedPanelDef(type: PanelType | string): SharedPanelDefiniti } /** True when a canvas node hosting this panel type must stay mounted even when - * scrolled off-screen (its live `` state can't survive a remount). */ + * scrolled off-screen (its live `` state can't survive a remount). + * + * Per-TYPE answer only. Extension panels are refined per INSTANCE in + * `renderer/panels/keepMountedPanels.ts`: url-mode extensions (remote SaaS + * pages) are cullable because their session lives in a persistent partition. */ export function keepsMountedOffscreen(type: PanelType | string | undefined): boolean { return !!type && getSharedPanelDef(type).keepMountedOffscreen }