From 72bbb824aad992307d0226722d490e0fa17a3a3b Mon Sep 17 00:00:00 2001 From: Jovan-zjy <3193941960@qq.com> Date: Sun, 16 Aug 2026 11:11:24 +0800 Subject: [PATCH 01/11] feat: add SSH ops panel to sidebar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend the in-session terminal bridge to spawn commands directly (e.g. ssh user@host), parse ~/.ssh/config for a host list, and add a sidebar entry ('运维') that opens a multi-session SSH ops panel with quick-connect input and xterm rendering. --- apps/app/src/app/lib/desktop.ts | 15 +- apps/app/src/i18n/locales/en.ts | 7 +- apps/app/src/i18n/locales/zh.ts | 5 +- .../domains/session/chat/session-page.tsx | 16 +- .../domains/session/sidebar/app-sidebar.tsx | 16 +- .../domains/session/terminal/ops-panel.tsx | 382 ++++++++++++++++++ apps/desktop/electron/main.mjs | 62 ++- apps/desktop/electron/preload.mjs | 3 + 8 files changed, 488 insertions(+), 18 deletions(-) create mode 100644 apps/app/src/react-app/domains/session/terminal/ops-panel.tsx diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index 1a43b3964..d70543e14 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -154,13 +154,26 @@ declare global { onPanelClosed?: (callback: () => void) => () => void; }; terminal?: { - create?: (options: { cwd: string; cols: number; rows: number }) => Promise<{ terminalId: string }>; + create?: (options: { + cwd: string; + cols: number; + rows: number; + /** Optional argv for the shell. When present the shell runs this + command instead of opening an interactive prompt, e.g. + `["ssh", "user@host"]` for the ops panel. */ + command?: string[]; + /** Optional explicit shell/executable path. */ + shell?: string; + }) => Promise<{ terminalId: string }>; write?: (terminalId: string, data: string) => Promise; resize?: (terminalId: string, cols: number, rows: number) => Promise; kill?: (terminalId: string) => Promise; onData?: (callback: (payload: { terminalId: string; data: string }) => void) => () => void; onExit?: (callback: (payload: { terminalId: string; exitCode: number | null; signal?: number }) => void) => () => void; }; + ssh?: { + listHosts?: () => Promise<{ hosts: string[]; configPath: string }>; + }; hyperframes?: { start?: (options: { workspaceRoot: string; sessionId: string; projectDirectory: string; port: number }) => Promise<{ ok: boolean; port?: number; reused?: boolean }>; stop?: (sessionId: string, options?: { keepWarm?: boolean }) => Promise<{ ok: boolean }>; diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 5ecb262c1..fb5c24f0d 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -385,9 +385,10 @@ export default { "design_system.embedded.font_inter": "Inter", "design_system.embedded.font_georgia": "Georgia", "design_system.embedded.font_ibm_plex_sans": "IBM Plex Sans", - "template_market.title": "Templates", - "template_market.description": "Browse installed and bundled templates for design and video tasks.", - "template_market.search_placeholder": "Search templates", + "template_market.title": "Templates", + "template_market.description": "Browse installed and bundled templates for design and video tasks.", + "template_market.search_placeholder": "Search templates", + "ops.title": "Ops", "template_market.my_templates": "My templates", "template_market.import_package": "Import .ipwp or .ipwt", "template_market.all_templates": "All templates", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index bed01f982..fbdd933a6 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -387,8 +387,9 @@ export default { "design_system.embedded.font_inter": "Inter", "design_system.embedded.font_georgia": "Georgia", "design_system.embedded.font_ibm_plex_sans": "IBM Plex Sans", - "template_market.title": "模版", - "template_market.description": "浏览设计和视频任务可用的内置、已安装和本地模板。", + "template_market.title": "模版", + "template_market.description": "浏览设计和视频任务可用的内置、已安装和本地模板。", + "ops.title": "运维", "template_market.search_placeholder": "搜索模板", "template_market.my_templates": "我的模板", "template_market.import_package": "导入 .ipwp 或 .ipwt", diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 2f90ee329..e0c059794 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -122,6 +122,7 @@ import { shouldRefreshTemplateCatalogOnOpen } from "../templates/template-market import { savePromptTemplate } from "@/react-app/domains/session/templates/prompt-template-store"; import { SidePanel, type SidePanelLauncherItem } from "../panel/side-panel"; import { TerminalDock } from "../terminal/terminal-dock"; +import { OpsPanel } from "../terminal/ops-panel"; import { useActivePanelTab, usePanelTabStore, useSessionPanelState } from "../panel/panel-tab-store"; import { useWorkspaceShellLayout } from "../../../shell/workspace-shell-layout"; import { useControlAction, type iPolloWorkControlAction } from "../../../shell/control/control-provider"; @@ -1235,7 +1236,7 @@ export function SessionPage(props: SessionPageProps) { const [renameGroupTarget, setRenameGroupTarget] = useState<{ workspaceId: string; groupId: string } | null>(null); const [removeGroupOpen, setRemoveGroupOpen] = useState(false); const [removeGroupTarget, setRemoveGroupTarget] = useState<{ workspaceId: string; groupId: string; label: string } | null>(null); - const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | null>(null); + const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | "ops" | null>(null); const preserveSidePanelOnPanelOpenRef = useRef(false); const setCurrentSidePanel = useCallback((panel: SidePanelItem | null) => { @@ -2080,6 +2081,10 @@ export function SessionPage(props: SessionPageProps) { setCurrentSidePanel(null); setMainWorkspaceView("extensions"); }, [setCurrentSidePanel]); + const openOpsRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("ops"); + }, [setCurrentSidePanel]); const openVoiceRailPane = useCallback(() => { toggleCurrentSidePanel("voice"); }, [toggleCurrentSidePanel]); @@ -2305,7 +2310,7 @@ export function SessionPage(props: SessionPageProps) { (showWorkspaceSetupEmptyState || (props.selectedSessionId && !selectedSessionIsDefaultTitle)), ); const showMainHeaderMenu = showHeaderMenu && showMainHeaderTitle; - const mainHeaderHidden = mainWorkspaceView === "extensions" || (showNewConversationChrome && !sidebarVisuallyCollapsed); + const mainHeaderHidden = mainWorkspaceView === "extensions" || mainWorkspaceView === "ops" || (showNewConversationChrome && !sidebarVisuallyCollapsed); const visibleWorkspaceWidth = viewportWidth - (shellConfig.sidebar && sidebarOpen ? effectiveLeftSidebarWidth : 0); const floatingRightPanelToggleOffset = sidePanelOpen ? Math.min(effectiveBrowserPanelWidth, Math.max(0, visibleWorkspaceWidth - 40)) + 8 @@ -2422,12 +2427,13 @@ export function SessionPage(props: SessionPageProps) { name: denAuth.user?.name ?? null, email: denAuth.user?.email ?? null, }} - activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : null} + activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : mainWorkspaceView === "ops" ? "ops" : null} onOpenAccount={openCloudAccount} onOpenSettings={props.onOpenSettings} onOpenHelp={props.onOpenHelp} onOpenTemplateMarket={() => setTemplateMarketOpen(true)} onOpenExtensions={openExtensionsRailPane} + onOpenOps={openOpsRailPane} onSignIn={openCloudSignIn} onOpenSessionSearch={props.sidebar.onOpenSessionSearch ? handleSidebarOpenSessionSearch : undefined} onStartResize={startLeftSidebarResize} @@ -2620,6 +2626,10 @@ export function SessionPage(props: SessionPageProps) {
{props.settingsSlot}
+ ) : mainWorkspaceView === "ops" ? ( +
+ setMainWorkspaceView(null)} /> +
) : showStartupSkeleton ? (
diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 388535f8f..65d344fc3 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx @@ -17,6 +17,7 @@ import { RotateCcw, Settings, HelpCircle, + Server, Tag, UserRound, } from "lucide-react"; @@ -511,12 +512,13 @@ export type AppSidebarProps = { name: string | null; email: string | null; }; - activePrimaryItem?: "template-market" | "extensions" | null; + activePrimaryItem?: "template-market" | "extensions" | "ops" | null; onOpenAccount: () => void; onOpenSettings: (route?: string) => void; onOpenHelp: () => void; onOpenTemplateMarket: () => void; onOpenExtensions: () => void; + onOpenOps: () => void; onSignIn: () => void; /** Opens the cross-session message search dialog (Cmd/Ctrl+Shift+F). */ onOpenSessionSearch?: () => void; @@ -721,6 +723,18 @@ export function AppSidebar(props: AppSidebarProps) { {t("settings.tab_extensions")} + + + + {t("ops.title")} + + diff --git a/apps/app/src/react-app/domains/session/terminal/ops-panel.tsx b/apps/app/src/react-app/domains/session/terminal/ops-panel.tsx new file mode 100644 index 000000000..9c2bc8564 --- /dev/null +++ b/apps/app/src/react-app/domains/session/terminal/ops-panel.tsx @@ -0,0 +1,382 @@ +/** @jsxImportSource react */ +import { useEffect, useRef, useState } from "react"; +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; +import { Loader2, Plus, RefreshCw, Server, TerminalSquare, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { isElectronRuntime } from "../../../../app/utils"; + +type OpsSession = { + id: string; + label: string; + target: string; + terminalId: string | null; + exited: boolean; +}; + +type OpsTerminalProps = { + session: OpsSession; + onStatus: (sessionId: string, status: string) => void; + onExit: (sessionId: string) => void; + onRequestFocus: () => void; +}; + +function OpsTerminal({ session, onStatus, onExit, onRequestFocus }: OpsTerminalProps) { + const containerRef = useRef(null); + const terminalIdRef = useRef(null); + const terminalRef = useRef(null); + const fitRef = useRef(null); + const startedRef = useRef(false); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + if (!isElectronRuntime()) { + onStatus(session.id, "Terminal is available in the desktop app."); + return; + } + if (startedRef.current) return; + startedRef.current = true; + + const bridge = window.__IPOLLOWORK_ELECTRON__?.terminal; + if (!bridge?.create || !bridge.write || !bridge.resize || !bridge.kill || !bridge.onData || !bridge.onExit) { + onStatus(session.id, "Terminal bridge is unavailable."); + return; + } + const createTerminal = bridge.create; + const writeTerminal = bridge.write; + const resizeTerminal = bridge.resize; + const killTerminal = bridge.kill; + const onTerminalData = bridge.onData; + const onTerminalExit = bridge.onExit; + + let disposed = false; + const fitAddon = new FitAddon(); + const terminal = new Terminal({ + cursorBlink: true, + convertEol: true, + fontFamily: "'SFMono-Regular', 'Cascadia Code', 'Liberation Mono', Menlo, monospace", + fontSize: 12, + theme: { + background: "#0b0d12", + foreground: "#d7dde8", + cursor: "#ffffff", + selectionBackground: "#334155", + }, + }); + terminal.loadAddon(fitAddon); + terminal.open(container); + terminal.focus(); + fitAddon.fit(); + terminalRef.current = terminal; + fitRef.current = fitAddon; + + const removeDataListener = onTerminalData(({ terminalId, data }) => { + if (terminalIdRef.current !== terminalId) return; + terminal.write(data); + }); + const removeExitListener = onTerminalExit(({ terminalId }) => { + if (terminalIdRef.current !== terminalId) return; + terminalIdRef.current = null; + onExit(session.id); + }); + const inputDisposable = terminal.onData((data) => { + const terminalId = terminalIdRef.current; + if (!terminalId) return; + void writeTerminal(terminalId, data); + }); + + const fitAndResize = () => { + fitAddon.fit(); + const terminalId = terminalIdRef.current; + if (!terminalId) return; + void resizeTerminal(terminalId, terminal.cols, terminal.rows); + }; + const resizeObserver = new ResizeObserver(fitAndResize); + resizeObserver.observe(container); + + onStatus(session.id, `Connecting to ${session.target}…`); + void createTerminal({ + cwd: "/", + cols: terminal.cols, + rows: terminal.rows, + command: ["ssh", "-t", session.target], + }) + .then(({ terminalId }) => { + if (disposed) { + void killTerminal(terminalId); + return; + } + terminalIdRef.current = terminalId; + onStatus(session.id, session.target); + fitAndResize(); + }) + .catch((error) => { + onStatus(session.id, error instanceof Error ? error.message : "Could not start SSH session."); + }); + + return () => { + disposed = true; + resizeObserver.disconnect(); + inputDisposable.dispose(); + removeDataListener(); + removeExitListener(); + const terminalId = terminalIdRef.current; + terminalIdRef.current = null; + if (terminalId) void killTerminal(terminalId); + terminal.dispose(); + terminalRef.current = null; + fitRef.current = null; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [session.id]); + + return ( +
+
+
+ ); +} + +type OpsPanelProps = { + onClose?: () => void; +}; + +export function OpsPanel({ onClose }: OpsPanelProps) { + const [sessions, setSessions] = useState([]); + const [statusBySession, setStatusBySession] = useState>({}); + const [activeSessionId, setActiveSessionId] = useState(null); + const [hosts, setHosts] = useState([]); + const [hostsLoading, setHostsLoading] = useState(true); + const [configPath, setConfigPath] = useState("~/.ssh/config"); + const [quickTarget, setQuickTarget] = useState(""); + const [connecting, setConnecting] = useState(false); + const [loadError, setLoadError] = useState(null); + + const refreshHosts = () => { + if (!isElectronRuntime()) { + setLoadError("Ops panel is available in the desktop app."); + setHostsLoading(false); + return; + } + const listHosts = window.__IPOLLOWORK_ELECTRON__?.ssh?.listHosts; + if (!listHosts) { + setLoadError("SSH bridge is unavailable."); + setHostsLoading(false); + return; + } + setHostsLoading(true); + void listHosts() + .then(({ hosts: hostList, configPath: config }) => { + setHosts(hostList ?? []); + setConfigPath(config ?? "~/.ssh/config"); + setLoadError(null); + }) + .catch((error) => { + setLoadError(error instanceof Error ? error.message : "Could not read SSH config."); + }) + .finally(() => setHostsLoading(false)); + }; + + useEffect(() => { + refreshHosts(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const addSession = (target: string) => { + const clean = target.trim().replace(/^ssh\s+/, "").replace(/^-t\s+/, ""); + if (!clean) return; + const id = `ops_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; + setSessions((prev) => [ + ...prev, + { id, label: clean, target: clean, terminalId: null, exited: false }, + ]); + setActiveSessionId(id); + setQuickTarget(""); + }; + + const connectQuick = () => { + const target = quickTarget.trim(); + if (!target || connecting) return; + setConnecting(true); + // Defer so the terminal pane mounts before the SSH connection starts. + window.setTimeout(() => { + addSession(target); + setConnecting(false); + }, 0); + }; + + const closeSession = (sessionId: string) => { + setSessions((prev) => prev.filter((session) => session.id !== sessionId)); + setStatusBySession((prev) => { + const next = { ...prev }; + delete next[sessionId]; + return next; + }); + setActiveSessionId((current) => { + if (current !== sessionId) return current; + const remaining = sessions.filter((session) => session.id !== sessionId); + return remaining.length ? remaining[remaining.length - 1].id : null; + }); + }; + + const handleStatus = (sessionId: string, status: string) => { + setStatusBySession((prev) => ({ ...prev, [sessionId]: status })); + }; + + const handleExit = (sessionId: string) => { + setStatusBySession((prev) => ({ ...prev, [sessionId]: "Disconnected" })); + }; + + const activeSession = sessions.find((session) => session.id === activeSessionId) ?? null; + + return ( +
+
+
+ + 运维面板 + SSH +
+
+ + {onClose ? ( + + ) : null} +
+
+ +
+ + +
+
+ {sessions.length === 0 ? ( + 没有活动的 SSH 会话 + ) : ( + sessions.map((session) => ( +
+ + +
+ )) + )} +
+ +
+ {activeSession ? ( + <> +
+ +
+ {activeSession ? ( + setActiveSessionId(activeSession.id)} + /> + ) : ( +
+

+ 在左侧选择一台主机,或输入 user@host 开始 SSH 会话。 +

+
+ )} +
+
+
+
+ ); +} diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 5ebf2428b..026041d9c 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -1,7 +1,7 @@ import { execFileSync, spawn } from "node:child_process"; import { createServer } from "node:http"; import net from "node:net"; -import { existsSync, readdirSync } from "node:fs"; +import { existsSync, readFileSync, readdirSync } from "node:fs"; import { cp, mkdir, @@ -3004,13 +3004,18 @@ ipcMain.handle("ipollowork:system:askMicrophoneAccess", async () => { }); // ── Terminal IPC ──────────────────────────────────────────────────────── -ipcMain.handle("ipollowork:terminal:create", async (event, options = {}) => { - const cwd = await resolveTerminalCwd(options?.cwd); - const cols = Number.isFinite(options?.cols) ? Math.max(20, Math.floor(options.cols)) : 80; - const rows = Number.isFinite(options?.rows) ? Math.max(5, Math.floor(options.rows)) : 24; - const terminalId = `term_${nextTerminalId++}`; - const shellPath = defaultTerminalShell(); - const child = pty.spawn(shellPath, [], { +// Shared terminal spawn used by both the in-session dock and the SSH ops +// panel. When `command` is provided it runs the executable directly instead of +// dropping into an interactive shell — e.g. ["ssh", "user@host"] for remote +// sessions. Spawning the command itself (not `shell -c ...`) keeps the pty on +// the executable so interactive prompts, host-key checks and passphrase +// dialogs behave exactly like a real ssh client. +function spawnTerminalProcess({ cwd, cols, rows, command, shellPath }) { + const program = Array.isArray(command) && command.length > 0 + ? command[0] + : (shellPath ?? defaultTerminalShell()); + const args = Array.isArray(command) && command.length > 1 ? command.slice(1) : []; + return pty.spawn(program, args, { name: "xterm-256color", cols, rows, @@ -3022,6 +3027,42 @@ ipcMain.handle("ipollowork:terminal:create", async (event, options = {}) => { IPOLLOWORK_TERMINAL: "1", }, }); +} + +// Parse ~/.ssh/config into a lightweight host list for the ops panel. +// Mirrors OpenSSH semantics for the Host directive without shelling out to +// `ssh -G`, keeping the operation local and dependency-free. +function readSshConfigHosts() { + const configPath = path.join(os.homedir(), ".ssh", "config"); + let raw; + try { + raw = readFileSync(configPath, "utf8"); + } catch { + return { hosts: [], configPath }; + } + const hosts = []; + const lines = raw.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const match = /^Host\s+(.+)$/.exec(trimmed); + if (!match) continue; + const entries = match[1].split(/\s+/).filter(Boolean); + for (const entry of entries) { + if (entry.includes("*") || entry.includes("?")) continue; + hosts.push(entry); + } + } + return { hosts: [...new Set(hosts)], configPath }; +} + +ipcMain.handle("ipollowork:terminal:create", async (event, options = {}) => { + const cwd = await resolveTerminalCwd(options?.cwd); + const cols = Number.isFinite(options?.cols) ? Math.max(20, Math.floor(options.cols)) : 80; + const rows = Number.isFinite(options?.rows) ? Math.max(5, Math.floor(options.rows)) : 24; + const terminalId = `term_${nextTerminalId++}`; + const shellPath = typeof options?.shell === "string" && options.shell.trim() ? options.shell.trim() : undefined; + const child = spawnTerminalProcess({ cwd, cols, rows, command: options?.command, shellPath }); terminalProcesses.set(terminalId, { process: child, webContentsId: event.sender.id }); event.sender.once("destroyed", () => killTerminalsForWebContents(event.sender.id)); @@ -3053,6 +3094,11 @@ ipcMain.handle("ipollowork:terminal:kill", (event, terminalId) => { killTerminal(String(terminalId)); }); +ipcMain.handle("ipollowork:ssh:list-hosts", (event) => { + if (!event.sender) return readSshConfigHosts(); + return readSshConfigHosts(); +}); + ipcMain.handle("ipollowork:hyperframes:start", (event, options = {}) => startHyperframesPreview(event, options)); ipcMain.handle("ipollowork:hyperframes:stop", (event, sessionId, options = {}) => { const key = hyperframesKey(event.sender.id, sessionId); diff --git a/apps/desktop/electron/preload.mjs b/apps/desktop/electron/preload.mjs index abe349bb9..c2f2571a2 100644 --- a/apps/desktop/electron/preload.mjs +++ b/apps/desktop/electron/preload.mjs @@ -170,6 +170,9 @@ contextBridge.exposeInMainWorld("__IPOLLOWORK_ELECTRON__", { return () => ipcRenderer.removeListener("ipollowork:terminal:exit", handler); }, }, + ssh: { + listHosts() { return ipcRenderer.invoke("ipollowork:ssh:list-hosts"); }, + }, hyperframes: { start(options) { return ipcRenderer.invoke("ipollowork:hyperframes:start", options); }, stop(sessionId, options) { return ipcRenderer.invoke("ipollowork:hyperframes:stop", sessionId, options); }, From e3b186e826fa0b959c6419ad15b523fe81392d4f Mon Sep 17 00:00:00 2001 From: Jovan-zjy <3193941960@qq.com> Date: Sun, 16 Aug 2026 11:44:01 +0800 Subject: [PATCH 02/11] feat: add git graph swimlane panel Add a sidebar entry ('Git') that opens a swimlane git graph panel. The Electron main process gains an ipollowork:git:graph IPC that builds a commit DAG from rev-list --parents plus ref -> commit mapping from for-each-ref; the renderer draws an SVG lane layout with branch badges, commit selection, and a detail pane. --- apps/app/src/app/lib/desktop.ts | 6 + apps/app/src/i18n/locales/en.ts | 3 +- apps/app/src/i18n/locales/zh.ts | 3 +- .../domains/session/chat/session-page.tsx | 16 +- .../domains/session/sidebar/app-sidebar.tsx | 16 +- .../domains/session/terminal/git-panel.tsx | 316 ++++++++++++++++++ apps/desktop/electron/main.mjs | 85 ++++- apps/desktop/electron/preload.mjs | 3 + 8 files changed, 441 insertions(+), 7 deletions(-) create mode 100644 apps/app/src/react-app/domains/session/terminal/git-panel.tsx diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index d70543e14..d8e97c1ab 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -174,6 +174,12 @@ declare global { ssh?: { listHosts?: () => Promise<{ hosts: string[]; configPath: string }>; }; + git?: { + graph?: (options: { cwd: string; maxCommits?: number }) => Promise< + | { ok: true; repoRoot: string; count: number; isRepo: true; commits: { sha: string; parents: string[] }[]; refs: { sha: string; refname: string; head: boolean }[]; headShas: string[] } + | { ok: false; isRepo: boolean; error: string } + >; + }; hyperframes?: { start?: (options: { workspaceRoot: string; sessionId: string; projectDirectory: string; port: number }) => Promise<{ ok: boolean; port?: number; reused?: boolean }>; stop?: (sessionId: string, options?: { keepWarm?: boolean }) => Promise<{ ok: boolean }>; diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index fb5c24f0d..63eae5e89 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -388,7 +388,8 @@ export default { "template_market.title": "Templates", "template_market.description": "Browse installed and bundled templates for design and video tasks.", "template_market.search_placeholder": "Search templates", - "ops.title": "Ops", + "ops.title": "Ops", + "git.title": "Git", "template_market.my_templates": "My templates", "template_market.import_package": "Import .ipwp or .ipwt", "template_market.all_templates": "All templates", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index fbdd933a6..58daecd40 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -389,7 +389,8 @@ export default { "design_system.embedded.font_ibm_plex_sans": "IBM Plex Sans", "template_market.title": "模版", "template_market.description": "浏览设计和视频任务可用的内置、已安装和本地模板。", - "ops.title": "运维", + "ops.title": "运维", + "git.title": "Git", "template_market.search_placeholder": "搜索模板", "template_market.my_templates": "我的模板", "template_market.import_package": "导入 .ipwp 或 .ipwt", diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index e0c059794..b852586da 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -123,6 +123,7 @@ import { savePromptTemplate } from "@/react-app/domains/session/templates/prompt import { SidePanel, type SidePanelLauncherItem } from "../panel/side-panel"; import { TerminalDock } from "../terminal/terminal-dock"; import { OpsPanel } from "../terminal/ops-panel"; +import { GitPanel } from "../terminal/git-panel"; import { useActivePanelTab, usePanelTabStore, useSessionPanelState } from "../panel/panel-tab-store"; import { useWorkspaceShellLayout } from "../../../shell/workspace-shell-layout"; import { useControlAction, type iPolloWorkControlAction } from "../../../shell/control/control-provider"; @@ -1236,7 +1237,7 @@ export function SessionPage(props: SessionPageProps) { const [renameGroupTarget, setRenameGroupTarget] = useState<{ workspaceId: string; groupId: string } | null>(null); const [removeGroupOpen, setRemoveGroupOpen] = useState(false); const [removeGroupTarget, setRemoveGroupTarget] = useState<{ workspaceId: string; groupId: string; label: string } | null>(null); - const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | "ops" | null>(null); + const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | "ops" | "git" | null>(null); const preserveSidePanelOnPanelOpenRef = useRef(false); const setCurrentSidePanel = useCallback((panel: SidePanelItem | null) => { @@ -2085,6 +2086,10 @@ export function SessionPage(props: SessionPageProps) { setCurrentSidePanel(null); setMainWorkspaceView("ops"); }, [setCurrentSidePanel]); + const openGitRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("git"); + }, [setCurrentSidePanel]); const openVoiceRailPane = useCallback(() => { toggleCurrentSidePanel("voice"); }, [toggleCurrentSidePanel]); @@ -2310,7 +2315,7 @@ export function SessionPage(props: SessionPageProps) { (showWorkspaceSetupEmptyState || (props.selectedSessionId && !selectedSessionIsDefaultTitle)), ); const showMainHeaderMenu = showHeaderMenu && showMainHeaderTitle; - const mainHeaderHidden = mainWorkspaceView === "extensions" || mainWorkspaceView === "ops" || (showNewConversationChrome && !sidebarVisuallyCollapsed); + const mainHeaderHidden = mainWorkspaceView === "extensions" || mainWorkspaceView === "ops" || mainWorkspaceView === "git" || (showNewConversationChrome && !sidebarVisuallyCollapsed); const visibleWorkspaceWidth = viewportWidth - (shellConfig.sidebar && sidebarOpen ? effectiveLeftSidebarWidth : 0); const floatingRightPanelToggleOffset = sidePanelOpen ? Math.min(effectiveBrowserPanelWidth, Math.max(0, visibleWorkspaceWidth - 40)) + 8 @@ -2427,13 +2432,14 @@ export function SessionPage(props: SessionPageProps) { name: denAuth.user?.name ?? null, email: denAuth.user?.email ?? null, }} - activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : mainWorkspaceView === "ops" ? "ops" : null} + activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : mainWorkspaceView === "ops" ? "ops" : mainWorkspaceView === "git" ? "git" : null} onOpenAccount={openCloudAccount} onOpenSettings={props.onOpenSettings} onOpenHelp={props.onOpenHelp} onOpenTemplateMarket={() => setTemplateMarketOpen(true)} onOpenExtensions={openExtensionsRailPane} onOpenOps={openOpsRailPane} + onOpenGit={openGitRailPane} onSignIn={openCloudSignIn} onOpenSessionSearch={props.sidebar.onOpenSessionSearch ? handleSidebarOpenSessionSearch : undefined} onStartResize={startLeftSidebarResize} @@ -2630,6 +2636,10 @@ export function SessionPage(props: SessionPageProps) {
setMainWorkspaceView(null)} />
+ ) : mainWorkspaceView === "git" ? ( +
+ setMainWorkspaceView(null)} /> +
) : showStartupSkeleton ? (
diff --git a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 65d344fc3..cea203e39 100644 --- a/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx +++ b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx @@ -18,6 +18,7 @@ import { Settings, HelpCircle, Server, + GitBranch, Tag, UserRound, } from "lucide-react"; @@ -512,13 +513,14 @@ export type AppSidebarProps = { name: string | null; email: string | null; }; - activePrimaryItem?: "template-market" | "extensions" | "ops" | null; + activePrimaryItem?: "template-market" | "extensions" | "ops" | "git" | null; onOpenAccount: () => void; onOpenSettings: (route?: string) => void; onOpenHelp: () => void; onOpenTemplateMarket: () => void; onOpenExtensions: () => void; onOpenOps: () => void; + onOpenGit: () => void; onSignIn: () => void; /** Opens the cross-session message search dialog (Cmd/Ctrl+Shift+F). */ onOpenSessionSearch?: () => void; @@ -735,6 +737,18 @@ export function AppSidebar(props: AppSidebarProps) { {t("ops.title")} + + + + {t("git.title")} + + diff --git a/apps/app/src/react-app/domains/session/terminal/git-panel.tsx b/apps/app/src/react-app/domains/session/terminal/git-panel.tsx new file mode 100644 index 000000000..e0f5e37e7 --- /dev/null +++ b/apps/app/src/react-app/domains/session/terminal/git-panel.tsx @@ -0,0 +1,316 @@ +/** @jsxImportSource react */ +import { useEffect, useMemo, useState } from "react"; +import { GitBranch, GitCommitHorizontal, Loader2, RefreshCw, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { isElectronRuntime } from "../../../../app/utils"; + +const LANE_WIDTH = 26; +const ROW_HEIGHT = 26; +const RADIUS = 7; +const MAX_COMMITS = 1200; + +type GraphCommit = { sha: string; parents: string[] }; +type GraphRef = { sha: string; refname: string; head: boolean }; + +type GraphData = { + ok: boolean; + isRepo: boolean; + error?: string; + count?: number; + commits?: GraphCommit[]; + refs?: GraphRef[]; + headShas?: string[]; +}; + +type LayoutCommit = GraphCommit & { + row: number; + lane: number; + refs: GraphRef[]; +}; + +// Greedy lane assignment: place each commit into the lowest lane whose +// current owner has been fully placed, otherwise open a new lane. Parents are +// reserved as soon as their child is placed so branches hold stable lanes. +function layoutGraph(commits: GraphCommit[], refs: GraphRef[]): { rows: LayoutCommit[]; laneCount: number } { + const refsBySha = new Map(); + for (const ref of refs) { + const list = refsBySha.get(ref.sha) ?? []; + list.push(ref); + refsBySha.set(ref.sha, list); + } + + const laneOf = new Map(); + const laneOwner: (string | undefined)[] = []; + const placed = new Set(); + let laneCount = 0; + + const reserveLane = (sha: string) => { + const existing = laneOf.get(sha); + if (existing !== undefined) return existing; + let lane = -1; + for (let i = 0; i < laneCount; i++) { + const owner = laneOwner[i]; + if (owner === undefined || placed.has(owner)) { + lane = i; + break; + } + } + if (lane === -1) { + lane = laneCount++; + } + laneOf.set(sha, lane); + laneOwner[lane] = sha; + return lane; + }; + + const rows: LayoutCommit[] = []; + commits.forEach((commit, row) => { + const lane = reserveLane(commit.sha); + placed.add(commit.sha); + rows.push({ + ...commit, + row, + lane, + refs: refsBySha.get(commit.sha) ?? [], + }); + for (const parent of commit.parents) reserveLane(parent); + }); + + return { rows, laneCount }; +} + +function shortSha(sha: string) { + return sha.slice(0, 7); +} + +function shortRef(refname: string) { + const cleaned = refname.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\//, ""); + return cleaned; +} + +function commitMessage(sha: string): string { + return shortSha(sha); +} + +type GitPanelProps = { + workspaceRoot: string; + onClose?: () => void; +}; + +export function GitPanel({ workspaceRoot, onClose }: GitPanelProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [selectedSha, setSelectedSha] = useState(null); + + const fetchGraph = () => { + if (!isElectronRuntime()) { + setData({ ok: false, isRepo: false, error: "Git panel is available in the desktop app." }); + setLoading(false); + return; + } + const graph = window.__IPOLLOWORK_ELECTRON__?.git?.graph; + if (!graph) { + setData({ ok: false, isRepo: false, error: "Git bridge is unavailable." }); + setLoading(false); + return; + } + setLoading(true); + void graph({ cwd: workspaceRoot, maxCommits: MAX_COMMITS }) + .then((result) => { + setData(result as GraphData); + setSelectedSha(null); + }) + .catch((error) => { + setData({ ok: false, isRepo: false, error: error instanceof Error ? error.message : "Could not read git graph." }); + }) + .finally(() => setLoading(false)); + }; + + useEffect(() => { + fetchGraph(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [workspaceRoot]); + + const { rows, laneCount } = useMemo(() => { + if (!data?.ok || !data.commits) return { rows: [] as LayoutCommit[], laneCount: 0 }; + return layoutGraph(data.commits, data.refs ?? []); + }, [data]); + + const width = Math.max(320, laneCount * LANE_WIDTH + 24); + + const edgeLines = useMemo(() => { + if (!rows.length) return []; + const lines: { key: string; d: string }[] = []; + const rowBySha = new Map(rows.map((r) => [r.sha, r.row])); + const laneBySha = new Map(rows.map((r) => [r.sha, r.lane])); + for (const commit of rows) { + for (const parent of commit.parents) { + const parentRow = rowBySha.get(parent); + if (parentRow === undefined) continue; + const childX = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const parentLane = laneBySha.get(parent); + if (parentLane === undefined) continue; + const parentX = parentLane * LANE_WIDTH + LANE_WIDTH / 2; + const childY = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const parentY = parentRow * ROW_HEIGHT + ROW_HEIGHT / 2; + if (commit.lane === parentLane) { + lines.push({ key: `${commit.sha}-${parent}`, d: `M ${childX} ${childY} L ${parentX} ${parentY}` }); + } else { + const midY = childY + (parentY - childY) / 2; + lines.push({ + key: `${commit.sha}-${parent}`, + d: `M ${childX} ${childY} V ${midY} H ${parentX} V ${parentY}`, + }); + } + } + } + return lines; + }, [rows]); + + const selected = selectedSha ? rows.find((r) => r.sha === selectedSha) : null; + + return ( +
+
+
+ + Git 图谱 + 泳道 +
+
+ + {onClose ? ( + + ) : null} +
+
+ +
+
+ {loading ? ( +
+ + 读取 Git 图谱… +
+ ) : !data?.ok ? ( +
+

+ {data?.isRepo === false ? "当前目录不是 Git 仓库。" : data?.error ?? "无法读取 Git 图谱。"} +

+ {data?.error ?

{data.error}

: null} +
+ ) : rows.length === 0 ? ( +

没有可显示的提交。

+ ) : ( +
+ + {edgeLines.map((line) => ( + + ))} + {rows.map((commit) => { + const x = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const y = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const isHead = data.headShas?.includes(commit.sha) ?? false; + const isSelected = selectedSha === commit.sha; + const fill = isHead ? "var(--color-ring)" : isSelected ? "var(--color-primary)" : "var(--color-muted-foreground)"; + return ( + setSelectedSha(isSelected ? null : commit.sha)} + style={{ cursor: "pointer" }} + > + + + + ); + })} + +
+ {rows.map((commit) => { + const x = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const y = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const isHead = data.headShas?.includes(commit.sha) ?? false; + const isSelected = selectedSha === commit.sha; + return ( + + ); + })} +
+
+ )} +
+ + +
+
+ ); +} diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 026041d9c..1c903152b 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -1,4 +1,4 @@ -import { execFileSync, spawn } from "node:child_process"; +import { execFileSync, spawn, spawnSync } from "node:child_process"; import { createServer } from "node:http"; import net from "node:net"; import { existsSync, readFileSync, readdirSync } from "node:fs"; @@ -3099,6 +3099,89 @@ ipcMain.handle("ipollowork:ssh:list-hosts", (event) => { return readSshConfigHosts(); }); +// ── Git graph IPC ────────────────────────────────────────────────────── +const GIT_GRAPH_TIMEOUT_MS = 30_000; + +function runGitInWorkspace(cwd, args) { + const result = spawnSync("git", args, { + cwd, + encoding: "utf8", + timeout: GIT_GRAPH_TIMEOUT_MS, + env: { ...process.env, LC_ALL: "C", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "" }, + }); + if (result.error) { + if (result.error.code === "ENOENT") throw new Error("git executable not found"); + throw result.error; + } + if (result.status !== 0) { + throw new Error(`git ${args[0]} failed: ${String(result.stderr ?? "").trim().slice(0, 400)}`); + } + return String(result.stdout ?? ""); +} + +// Build a lightweight commit DAG for the workspace repo: commit hashes with +// their parents plus branch/tag refs. Uses `rev-list --parents` for exact +// edges (not `git log --graph` text parsing, which is locale/encoding +// fragile) and `for-each-ref` for ref → commit mapping. Bounded by an +// optional maxCommits to keep huge repos renderable. +function buildGitGraph(cwd, maxCommits = 2000) { + const revListOutput = runGitInWorkspace(cwd, [ + "rev-list", "--parents", "--all", "--max-count", String(maxCommits), + ]); + const commits = []; + const commitBySha = new Map(); + for (const line of revListOutput.split("\n")) { + if (!line.trim()) continue; + const parts = line.trim().split(/\s+/); + const sha = parts[0]; + const parents = parts.slice(1); + commits.push({ sha, parents }); + commitBySha.set(sha, { sha, parents }); + } + + const refsOutput = runGitInWorkspace(cwd, [ + "for-each-ref", "refs/heads", "refs/remotes", + "--format=%(objectname)%00%(refname)%00%(HEAD)", "--merged", "HEAD", + ]); + const refs = []; + for (const line of refsOutput.split("\n")) { + if (!line.trim()) continue; + const [sha, refname, headFlag] = line.trim().split("\0"); + if (!sha || !refname) continue; + const head = headFlag === "*"; + refs.push({ sha, refname, head }); + } + + const count = commits.length; + // Resolve tips reachable from HEAD refs so we can draw ref badges. + const headShas = new Set(refs.filter((ref) => ref.head).map((ref) => ref.sha)); + + return { + ok: true, + repoRoot: cwd, + count, + commits, + refs, + headShas: [...headShas], + }; +} + +ipcMain.handle("ipollowork:git:graph", (event, options = {}) => { + const cwd = typeof options?.cwd === "string" && options.cwd.trim() ? options.cwd.trim() : undefined; + if (!cwd) return { ok: false, error: "missing cwd" }; + try { + const result = buildGitGraph(cwd, Number.isFinite(options?.maxCommits) ? options.maxCommits : 2000); + result.isRepo = true; + return result; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("not a git repository") || message.includes("fatal:")) { + return { ok: false, isRepo: false, error: message }; + } + return { ok: false, isRepo: true, error: message }; + } +}); + ipcMain.handle("ipollowork:hyperframes:start", (event, options = {}) => startHyperframesPreview(event, options)); ipcMain.handle("ipollowork:hyperframes:stop", (event, sessionId, options = {}) => { const key = hyperframesKey(event.sender.id, sessionId); diff --git a/apps/desktop/electron/preload.mjs b/apps/desktop/electron/preload.mjs index c2f2571a2..35520d9e4 100644 --- a/apps/desktop/electron/preload.mjs +++ b/apps/desktop/electron/preload.mjs @@ -173,6 +173,9 @@ contextBridge.exposeInMainWorld("__IPOLLOWORK_ELECTRON__", { ssh: { listHosts() { return ipcRenderer.invoke("ipollowork:ssh:list-hosts"); }, }, + git: { + graph(options) { return ipcRenderer.invoke("ipollowork:git:graph", options); }, + }, hyperframes: { start(options) { return ipcRenderer.invoke("ipollowork:hyperframes:start", options); }, stop(sessionId, options) { return ipcRenderer.invoke("ipollowork:hyperframes:stop", sessionId, options); }, From 3a60627d59c3e15732f6e0c3286450b09ea1fa53 Mon Sep 17 00:00:00 2001 From: Jovan-zjy <3193941960@qq.com> Date: Sun, 16 Aug 2026 12:25:35 +0800 Subject: [PATCH 03/11] feat: add LAN remote preview (mobile read-only) Add a default-off LAN read-only preview server so phones and tablets on the local network can view the workbench via a pairing flow. The main process gains a lan-preview-server with 6-digit single-use pair codes, challenge-based anti-CSRF, in-memory session tokens, per-IP fail lockout and rate limiting, and a hard 403 on /api/execute (read-only). A new settings tab (Remote Preview) toggles the server, shows the LAN address and pair code with countdown, and lists paired devices. --- apps/app/src/app/lib/desktop.ts | 18 + apps/app/src/app/types.ts | 1 + apps/app/src/i18n/locales/en.ts | 23 +- apps/app/src/i18n/locales/zh.ts | 23 +- .../settings/pages/remote-preview-view.tsx | 214 ++++++++++++ .../domains/settings/shell/settings-page.tsx | 9 +- .../src/react-app/shell/settings-route.tsx | 4 + apps/desktop/electron/lan-preview-server.mjs | 307 ++++++++++++++++++ apps/desktop/electron/main.mjs | 53 ++- apps/desktop/electron/preload.mjs | 11 + apps/desktop/resources/lan-preview/index.html | 203 ++++++++++++ 11 files changed, 862 insertions(+), 4 deletions(-) create mode 100644 apps/app/src/react-app/domains/settings/pages/remote-preview-view.tsx create mode 100644 apps/desktop/electron/lan-preview-server.mjs create mode 100644 apps/desktop/resources/lan-preview/index.html diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index d8e97c1ab..7dbd2bd91 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -53,6 +53,17 @@ export type BrowserProxyState = { proxy: { rules: string; authenticated: boolean } | null; }; +export type LanPreviewState = { + enabled: boolean; + port: number; + addresses: string[]; + code: string | null; + codeExpiresAt: number; + sessionCount: number; + pendingChallengeCount?: number; + error?: string; +}; + // --------------------------------------------------------------------------- // Electron bridge surface // --------------------------------------------------------------------------- @@ -180,6 +191,13 @@ declare global { | { ok: false; isRepo: boolean; error: string } >; }; + lanPreview?: { + getState?: () => Promise; + setEnabled?: (enabled: boolean) => Promise; + regenerateCode?: () => Promise; + disconnectAll?: () => Promise; + onStateChanged?: (callback: (state: LanPreviewState) => void) => () => void; + }; hyperframes?: { start?: (options: { workspaceRoot: string; sessionId: string; projectDirectory: string; port: number }) => Promise<{ ok: boolean; port?: number; reused?: boolean }>; stop?: (sessionId: string, options?: { keepWarm?: boolean }) => Promise<{ ok: boolean }>; diff --git a/apps/app/src/app/types.ts b/apps/app/src/app/types.ts index 5e3dddd60..fcdb91343 100644 --- a/apps/app/src/app/types.ts +++ b/apps/app/src/app/types.ts @@ -192,6 +192,7 @@ export const SETTINGS_TAB_VALUES = [ "updates", "recovery", "debug", + "remote-preview", ] as const; export type SettingsTab = (typeof SETTINGS_TAB_VALUES)[number]; diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 63eae5e89..9ea0e2f0f 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -389,7 +389,28 @@ export default { "template_market.description": "Browse installed and bundled templates for design and video tasks.", "template_market.search_placeholder": "Search templates", "ops.title": "Ops", - "git.title": "Git", + "git.title": "Git", + "settings.tab_remote_preview": "Remote Preview", + "settings.tab_description_remote_preview": "Allow LAN devices to view this workbench (read-only).", + "settings.remote_preview_alert_title": "Remote preview (LAN read-only)", + "settings.remote_preview_alert_desc": "Lets phones and tablets on your local network view the current workbench state through a browser. Read-only: devices can watch, not control.", + "settings.remote_preview_desktop_title": "Desktop app required", + "settings.remote_preview_desktop_desc": "Remote preview is only available in the desktop app.", + "settings.remote_preview_enable_title": "Enable LAN preview", + "settings.remote_preview_enable_desc": "Defaults to off. Turning it on exposes a read-only snapshot to your local network.", + "settings.remote_preview_address_title": "Access address", + "settings.remote_preview_address_desc": "Open this address in a phone or tablet browser on the same network.", + "settings.remote_preview_no_address": "No LAN addresses found.", + "settings.remote_preview_code_title": "Pair code", + "settings.remote_preview_code_desc": "Valid for 10 minutes, single use", + "settings.remote_preview_regenerate": "Regenerate", + "settings.remote_preview_code_hint": "Enter this code on the device to pair it with this workbench.", + "settings.remote_preview_sessions_title": "Paired devices", + "settings.remote_preview_sessions_connected": "devices connected", + "settings.remote_preview_sessions_none": "No devices paired.", + "settings.remote_preview_disconnect_all": "Disconnect all", + "settings.remote_preview_error_title": "Could not start remote preview", + "settings.remote_preview_copy": "Copy", "template_market.my_templates": "My templates", "template_market.import_package": "Import .ipwp or .ipwt", "template_market.all_templates": "All templates", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 58daecd40..933452c7f 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -390,7 +390,28 @@ export default { "template_market.title": "模版", "template_market.description": "浏览设计和视频任务可用的内置、已安装和本地模板。", "ops.title": "运维", - "git.title": "Git", + "git.title": "Git", + "settings.tab_remote_preview": "远程预览", + "settings.tab_description_remote_preview": "允许局域网设备查看此工作台(只读)。", + "settings.remote_preview_alert_title": "远程预览(局域网只读)", + "settings.remote_preview_alert_desc": "让同一局域网内的手机、平板通过浏览器查看当前工作台状态。只读:设备只能观看,不能操作。", + "settings.remote_preview_desktop_title": "需要桌面应用", + "settings.remote_preview_desktop_desc": "远程预览仅在桌面应用内可用。", + "settings.remote_preview_enable_title": "启用局域网预览", + "settings.remote_preview_enable_desc": "默认关闭。开启后会向局域网暴露只读快照。", + "settings.remote_preview_address_title": "访问地址", + "settings.remote_preview_address_desc": "在手机或平板浏览器中打开该地址(需同一网络)。", + "settings.remote_preview_no_address": "未找到局域网地址。", + "settings.remote_preview_code_title": "配对码", + "settings.remote_preview_code_desc": "10 分钟有效,单次使用", + "settings.remote_preview_regenerate": "重新生成", + "settings.remote_preview_code_hint": "在设备上输入此配对码即可与工作台配对。", + "settings.remote_preview_sessions_title": "已配对设备", + "settings.remote_preview_sessions_connected": "台设备已连接", + "settings.remote_preview_sessions_none": "暂无已配对设备。", + "settings.remote_preview_disconnect_all": "断开全部", + "settings.remote_preview_error_title": "远程预览启动失败", + "settings.remote_preview_copy": "复制", "template_market.search_placeholder": "搜索模板", "template_market.my_templates": "我的模板", "template_market.import_package": "导入 .ipwp 或 .ipwt", diff --git a/apps/app/src/react-app/domains/settings/pages/remote-preview-view.tsx b/apps/app/src/react-app/domains/settings/pages/remote-preview-view.tsx new file mode 100644 index 000000000..89b9baa7e --- /dev/null +++ b/apps/app/src/react-app/domains/settings/pages/remote-preview-view.tsx @@ -0,0 +1,214 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useState } from "react"; +import { Copy, Loader2, MonitorSmartphone, RefreshCw, ShieldAlert } from "lucide-react"; + +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import type { LanPreviewState } from "@/app/lib/desktop"; +import { isDesktopRuntime } from "@/app/utils"; +import { t } from "@/i18n"; +import { + LayoutSectionItem, + LayoutSectionItemDescription, + LayoutSectionItemHeader, + LayoutSectionItemHeaderActions, + LayoutSectionItemTitle, + LayoutStack, +} from "../settings-layout"; + +function formatCountdown(expiresAt: number) { + const remaining = Math.max(0, expiresAt - Date.now()); + const totalSeconds = Math.floor(remaining / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`; +} + +export function RemotePreviewView() { + const [state, setState] = useState(null); + const [loading, setLoading] = useState(true); + const [busy, setBusy] = useState(false); + const [copied, setCopied] = useState(null); + + const readState = useCallback(async () => { + const bridge = window.__IPOLLOWORK_ELECTRON__?.lanPreview; + if (!bridge?.getState) { + setState(null); + setLoading(false); + return; + } + try { + const next = await bridge.getState(); + setState(next); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + readState(); + const bridge = window.__IPOLLOWORK_ELECTRON__?.lanPreview; + const unsubscribe = bridge?.onStateChanged?.((next) => setState(next)); + return () => unsubscribe?.(); + }, [readState]); + + const runAction = useCallback(async (action: () => Promise) => { + setBusy(true); + try { + const next = await action(); + setState(next); + } finally { + setBusy(false); + } + }, []); + + const toggleEnabled = useCallback(() => { + const next = !state?.enabled; + void runAction(() => { + const setEnabled = window.__IPOLLOWORK_ELECTRON__?.lanPreview?.setEnabled; + if (!setEnabled) return Promise.resolve(state ?? { enabled: false, port: 0, addresses: [], code: null, codeExpiresAt: 0, sessionCount: 0 }); + return setEnabled(next); + }); + }, [runAction, state]); + + const regenerate = useCallback(() => { + void runAction(async () => { + const regenerateCode = window.__IPOLLOWORK_ELECTRON__?.lanPreview?.regenerateCode; + if (!regenerateCode) return state ?? { enabled: false, port: 0, addresses: [], code: null, codeExpiresAt: 0, sessionCount: 0 }; + return regenerateCode(); + }); + }, [runAction, state]); + + const disconnectAll = useCallback(() => { + void runAction(async () => { + const disconnectAll = window.__IPOLLOWORK_ELECTRON__?.lanPreview?.disconnectAll; + if (!disconnectAll) return state ?? { enabled: false, port: 0, addresses: [], code: null, codeExpiresAt: 0, sessionCount: 0 }; + return disconnectAll(); + }); + }, [runAction, state]); + + const copyText = useCallback((value: string) => { + void navigator.clipboard?.writeText(value).then(() => { + setCopied(value); + window.setTimeout(() => setCopied(null), 1500); + }); + }, []); + + const countdown = state?.codeExpiresAt ? formatCountdown(state.codeExpiresAt) : "–"; + + return ( + + + + {t("settings.remote_preview_alert_title")} + {t("settings.remote_preview_alert_desc")} + + + {!isDesktopRuntime() && ( + + + {t("settings.remote_preview_desktop_title")} + {t("settings.remote_preview_desktop_desc")} + + )} + + + + {t("settings.remote_preview_enable_title")} + {t("settings.remote_preview_enable_desc")} + +
+ + {busy ? : null} +
+
+ + {state?.enabled ? ( + <> + + + {t("settings.remote_preview_address_title")} + {t("settings.remote_preview_address_desc")} + +
+ {(state.addresses?.length ?? 0) > 0 ? ( + state.addresses.map((address) => { + const url = `http://${address}:${state.port}`; + return ( +
+ {url} + +
+ ); + }) + ) : ( +

{t("settings.remote_preview_no_address")}

+ )} +
+
+ + + + {t("settings.remote_preview_code_title")} + + {t("settings.remote_preview_code_desc")}({countdown}) + + + + + +
+
+ + {state.code ?? "------"} + + +
+

+ {t("settings.remote_preview_code_hint")} +

+
+
+ + + + {t("settings.remote_preview_sessions_title")} + + {state.sessionCount > 0 + ? `${t("settings.remote_preview_sessions_connected")}(${state.sessionCount})` + : t("settings.remote_preview_sessions_none")} + + +
+ +
+
+ + ) : null} + + {state?.error ? ( + + + {t("settings.remote_preview_error_title")} + {state.error} + + ) : null} +
+ ); +} diff --git a/apps/app/src/react-app/domains/settings/shell/settings-page.tsx b/apps/app/src/react-app/domains/settings/shell/settings-page.tsx index 0890d7999..f3f66f113 100644 --- a/apps/app/src/react-app/domains/settings/shell/settings-page.tsx +++ b/apps/app/src/react-app/domains/settings/shell/settings-page.tsx @@ -10,6 +10,7 @@ import { FolderLock, Info, Layout, + MonitorSmartphone, Paintbrush, Puzzle, RefreshCcw, @@ -90,6 +91,8 @@ export function getSettingsTabIcon(tab: SettingsTab) { return ShieldCheck; case "debug": return Bug; + case "remote-preview": + return MonitorSmartphone; default: return Cog; } @@ -133,6 +136,8 @@ export function getSettingsTabLabel(tab: SettingsTab) { return t("settings.tab_recovery"); case "debug": return t("settings.tab_debug"); + case "remote-preview": + return t("settings.tab_remote_preview"); case "general": return t("settings.tab_general"); default: @@ -178,6 +183,8 @@ export function getSettingsTabDescription(tab: SettingsTab) { return t("settings.tab_description_recovery"); case "debug": return t("settings.tab_description_debug"); + case "remote-preview": + return t("settings.tab_description_remote_preview"); case "general": return t("settings.tab_description_general_overview"); default: @@ -190,7 +197,7 @@ export function getWorkspaceSettingsTabs(): SettingsTab[] { } export function getGlobalSettingsTabs(developerMode: boolean): SettingsTab[] { - const tabs: SettingsTab[] = ["ai", "authorizations", "shell", "appearance", "environment", "updates", "recovery"]; + const tabs: SettingsTab[] = ["ai", "authorizations", "shell", "appearance", "environment", "updates", "recovery", "remote-preview"]; if (developerMode) tabs.push("debug"); return tabs; } diff --git a/apps/app/src/react-app/shell/settings-route.tsx b/apps/app/src/react-app/shell/settings-route.tsx index a22558c5c..b5c3c74b2 100644 --- a/apps/app/src/react-app/shell/settings-route.tsx +++ b/apps/app/src/react-app/shell/settings-route.tsx @@ -74,6 +74,7 @@ import { GeneralSettingsView } from "@/react-app/domains/settings/pages/general- import { AuthorizedFoldersPanel } from "@/react-app/domains/settings/panels/authorized-folders-panel"; import { SettingsStack } from "@/react-app/domains/settings/settings-section"; import { AdvancedView } from "@/react-app/domains/settings/pages/advanced-view"; +import { RemotePreviewView } from "@/react-app/domains/settings/pages/remote-preview-view"; import { AppearanceView } from "@/react-app/domains/settings/pages/appearance-view"; import { CloudAccountView } from "@/react-app/domains/settings/pages/cloud-account-view"; import { ConnectView } from "@/react-app/domains/settings/pages/connect-view"; @@ -250,6 +251,7 @@ export function parseSettingsPath(pathname: string): { case "updates": case "recovery": case "debug": + case "remote-preview": return { tab: head, redirectPath: null }; case "cloud-account": case "connect": @@ -2000,6 +2002,8 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { onCleanupiPolloWorkDockerContainers={() => {}} /> ); + case "remote-preview": + return ; case "environment": return ( { + let raw = ""; + request.setEncoding("utf8"); + request.on("data", (chunk) => { + raw += chunk; + if (raw.length > 128_000) { + reject(new Error("Request body too large")); + request.destroy(); + } + }); + request.on("end", () => { + if (!raw.trim()) { + resolve({}); + return; + } + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error("Request body must be JSON")); + } + }); + request.on("error", reject); + }); +} + +function lanAddresses() { + const out = []; + const interfaces = os.networkInterfaces(); + for (const name of Object.keys(interfaces)) { + for (const address of interfaces[name] ?? []) { + if (address.family === "IPv4" && !address.internal) { + out.push(address.address); + } + } + } + return out; +} + +export function createLanPreviewServer({ appName, getWindow, pageHtmlPath, log = () => {} }) { + let server = null; + let port = 0; + let code = null; + let codeExpiresAt = 0; + const challenges = new Map(); + const sessions = new Map(); + const fails = new Map(); + const hits = new Map(); + + const randHex = (n) => randomBytes(n).toString("hex"); + + function generateCode() { + code = String(randomInt(0, 1_000_000)).padStart(6, "0"); + codeExpiresAt = Date.now() + CODE_TTL_MS; + return { code, expiresAt: codeExpiresAt }; + } + + function validCode(input) { + return code !== null && typeof input === "string" && input === code && Date.now() < codeExpiresAt; + } + + function issueSession(ip) { + const token = randHex(32); + sessions.set(token, { ip, issuedAt: Date.now(), expiresAt: Date.now() + SESSION_TTL_MS }); + return { token, expiresAt: Date.now() + SESSION_TTL_MS }; + } + + function authorized(request) { + const match = /^Bearer (.+)$/.exec(request.headers.authorization ?? ""); + if (!match) return null; + const session = sessions.get(match[1]); + if (!session || Date.now() >= session.expiresAt) { + sessions.delete(match[1]); + return null; + } + return session; + } + + function rateLimited(ip) { + const now = Date.now(); + const window = (hits.get(ip) ?? []).filter((ts) => now - ts < RATE_WINDOW_MS); + if (window.length >= RATE_MAX) { + hits.set(ip, window); + return true; + } + window.push(now); + hits.set(ip, window); + return false; + } + + function registerFail(ip) { + const current = fails.get(ip) ?? { count: 0, lockedUntil: 0 }; + if (Date.now() < current.lockedUntil) return; + current.count += 1; + if (current.count >= MAX_FAILS) { + current.count = 0; + current.lockedUntil = Date.now() + LOCK_MS; + } + fails.set(ip, current); + } + + function lockRemainingFor(ip) { + const current = fails.get(ip); + if (!current) return 0; + const remaining = current.lockedUntil - Date.now(); + return remaining > 0 ? remaining : 0; + } + + async function invokeRenderer(method) { + const win = await getWindow(); + if (!win || win.isDestroyed()) { + throw new Error("renderer-unavailable"); + } + return win.webContents.executeJavaScript( + `(async () => { + const control = window.__ipolloworkControl; + if (!control) return { ok: false, error: "control-surface-unavailable" }; + control.setEnabled?.(true); + if (${JSON.stringify(method)} === "snapshot") return { ok: true, ...control.snapshot() }; + if (${JSON.stringify(method)} === "actions") return { ok: true, actions: control.listActions() }; + return { ok: false, error: "unknown-method" }; + })()`, + true, + ); + } + + async function handle(request, response) { + const url = new URL(request.url ?? "/", `http://${request.headers.host ?? "localhost"}`); + const ip = request.socket.remoteAddress ?? ""; + + try { + if (request.method === "GET" && url.pathname === "/") { + const challenge = randHex(16); + challenges.set(challenge, { expiresAt: Date.now() + CHALLENGE_TTL_MS, used: false }); + const html = (await import("node:fs/promises")).readFile(pageHtmlPath, "utf8") + .then((raw) => raw.replace("__CHALLENGE__", challenge)) + .catch(() => `

Preview page missing

`); + sendHtml(response, await html); + return; + } + + if (request.method === "POST" && url.pathname === "/pair") { + if (rateLimited(ip)) { + sendJson(response, 429, { ok: false, error: "too-many-requests" }); + return; + } + if (lockRemainingFor(ip) > 0) { + sendJson(response, 429, { ok: false, error: "locked", retryAfterMs: lockRemainingFor(ip) }); + return; + } + let body; + try { + body = await readBody(request); + } catch (error) { + sendJson(response, 400, { ok: false, error: error instanceof Error ? error.message : "bad-request" }); + return; + } + const challenge = challenges.get(body?.challenge); + if (!challenge || challenge.used || Date.now() > challenge.expiresAt) { + sendJson(response, 401, { ok: false, error: "challenge-invalid" }); + return; + } + if (!validCode(body?.code)) { + registerFail(ip); + sendJson(response, 401, { ok: false, error: "code-invalid", retryAfterMs: lockRemainingFor(ip) }); + return; + } + challenge.used = true; + code = null; + sendJson(response, 200, { ok: true, ...issueSession(ip) }); + return; + } + + if (request.method === "GET" && url.pathname === "/api/health") { + sendJson(response, 200, { ok: true, app: appName, paired: sessions.size > 0 }); + return; + } + + if (request.method === "GET" && (url.pathname === "/api/snapshot" || url.pathname === "/api/actions")) { + if (!authorized(request)) { + sendJson(response, 401, { ok: false, error: "unauthorized" }); + return; + } + const method = url.pathname === "/api/snapshot" ? "snapshot" : "actions"; + try { + sendJson(response, 200, await invokeRenderer(method)); + } catch (error) { + sendJson(response, 503, { + ok: false, + error: error instanceof Error ? error.message : "renderer-unavailable", + }); + } + return; + } + + if (url.pathname.startsWith("/api/execute")) { + sendJson(response, 403, { ok: false, error: "read-only-mode" }); + return; + } + + sendJson(response, 404, { ok: false, error: "not-found" }); + } catch (error) { + log(`lan-preview error: ${error instanceof Error ? error.message : String(error)}`); + sendJson(response, 500, { ok: false, error: "internal-error" }); + } + } + + return { + async start(preferredPort) { + if (server) throw new Error("lan-preview already running"); + server = createServer(handle); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(preferredPort ?? DEFAULT_PORT, "0.0.0.0", () => resolve(undefined)); + }); + port = server.address().port; + const generated = generateCode(); + log(`LAN preview listening on 0.0.0.0:${port}`); + return { port, code: generated.code, codeExpiresAt: generated.expiresAt }; + }, + async stop() { + if (!server) return; + await new Promise((resolve) => server.close(() => resolve(undefined))); + server = null; + port = 0; + code = null; + codeExpiresAt = 0; + challenges.clear(); + sessions.clear(); + fails.clear(); + hits.clear(); + }, + regenerateCode() { + if (!server) return null; + const generated = generateCode(); + return { code: generated.code, expiresAt: generated.expiresAt }; + }, + disconnectAll() { + sessions.clear(); + challenges.clear(); + }, + getState() { + return { + enabled: !!server, + port, + addresses: lanAddresses(), + code, + codeExpiresAt, + sessionCount: sessions.size, + pendingChallengeCount: challenges.size, + }; + }, + }; +} + +export function defaultLanPreviewPort() { + return DEFAULT_PORT; +} + +export function lanPreviewPagePath(desktopRoot) { + return path.join(desktopRoot, "resources", "lan-preview", "index.html"); +} diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 1c903152b..316749f7d 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -29,6 +29,7 @@ import { openComputerUseSetupApp, } from "./computer-use.mjs"; import { createUiControlServer } from "./ui-control-server.mjs"; +import { createLanPreviewServer, lanPreviewPagePath } from "./lan-preview-server.mjs"; import { createApplicationMenu } from "./app-menu.mjs"; import { createBrowserPanel } from "./browser-panel.mjs"; import { createWorkspaceStore } from "./workspace-store.mjs"; @@ -113,6 +114,12 @@ const uiControlServer = createUiControlServer({ getWindow: () => createMainWindow(), }); +const lanPreviewServer = createLanPreviewServer({ + appName: APP_NAME, + getWindow: () => createMainWindow(), + pageHtmlPath: lanPreviewPagePath(path.resolve(__dirname, "..")), +}); + const terminalProcesses = new Map(); const hyperframesProcesses = new Map(); let nextTerminalId = 1; @@ -3182,6 +3189,50 @@ ipcMain.handle("ipollowork:git:graph", (event, options = {}) => { } }); +// ── LAN preview IPC ──────────────────────────────────────────────────── +function lanPreviewStatePayload() { + return lanPreviewServer.getState(); +} + +function broadcastLanPreviewState() { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) { + win.webContents.send("ipollowork:lan-preview:state", lanPreviewStatePayload()); + } + } +} + +ipcMain.handle("ipollowork:lan-preview:get-state", () => lanPreviewStatePayload()); + +ipcMain.handle("ipollowork:lan-preview:set-enabled", async (event, enabled) => { + const target = enabled === true; + const current = lanPreviewServer.getState().enabled; + if (target === current) return lanPreviewStatePayload(); + if (target) { + try { + await lanPreviewServer.start(); + } catch (error) { + return { ...lanPreviewStatePayload(), error: error instanceof Error ? error.message : "start-failed" }; + } + } else { + await lanPreviewServer.stop(); + } + broadcastLanPreviewState(); + return lanPreviewStatePayload(); +}); + +ipcMain.handle("ipollowork:lan-preview:regenerate-code", () => { + const generated = lanPreviewServer.regenerateCode(); + broadcastLanPreviewState(); + return lanPreviewStatePayload(); +}); + +ipcMain.handle("ipollowork:lan-preview:disconnect-all", () => { + lanPreviewServer.disconnectAll(); + broadcastLanPreviewState(); + return lanPreviewStatePayload(); +}); + ipcMain.handle("ipollowork:hyperframes:start", (event, options = {}) => startHyperframesPreview(event, options)); ipcMain.handle("ipollowork:hyperframes:stop", (event, sessionId, options = {}) => { const key = hyperframesKey(event.sender.id, sessionId); @@ -4241,7 +4292,7 @@ if (!app.requestSingleInstanceLock()) { event.preventDefault(); if (runtimeDisposeInProgress) return; showShutdownScreen(); - void Promise.all([disposeRuntimeBeforeQuit(), uiControlServer.stop()]).finally(() => app.quit()); + void Promise.all([disposeRuntimeBeforeQuit(), uiControlServer.stop(), lanPreviewServer.stop()]).finally(() => app.quit()); }); app.on("second-instance", async (_event, argv) => { diff --git a/apps/desktop/electron/preload.mjs b/apps/desktop/electron/preload.mjs index 35520d9e4..3f53223e8 100644 --- a/apps/desktop/electron/preload.mjs +++ b/apps/desktop/electron/preload.mjs @@ -176,6 +176,17 @@ contextBridge.exposeInMainWorld("__IPOLLOWORK_ELECTRON__", { git: { graph(options) { return ipcRenderer.invoke("ipollowork:git:graph", options); }, }, + lanPreview: { + getState() { return ipcRenderer.invoke("ipollowork:lan-preview:get-state"); }, + setEnabled(enabled) { return ipcRenderer.invoke("ipollowork:lan-preview:set-enabled", Boolean(enabled)); }, + regenerateCode() { return ipcRenderer.invoke("ipollowork:lan-preview:regenerate-code"); }, + disconnectAll() { return ipcRenderer.invoke("ipollowork:lan-preview:disconnect-all"); }, + onStateChanged(callback) { + const handler = (_event, state) => callback(state); + ipcRenderer.on("ipollowork:lan-preview:state", handler); + return () => ipcRenderer.removeListener("ipollowork:lan-preview:state", handler); + }, + }, hyperframes: { start(options) { return ipcRenderer.invoke("ipollowork:hyperframes:start", options); }, stop(sessionId, options) { return ipcRenderer.invoke("ipollowork:hyperframes:stop", sessionId, options); }, diff --git a/apps/desktop/resources/lan-preview/index.html b/apps/desktop/resources/lan-preview/index.html new file mode 100644 index 000000000..7d14c4e30 --- /dev/null +++ b/apps/desktop/resources/lan-preview/index.html @@ -0,0 +1,203 @@ + + + + + + +iPolloWork 远程预览 + + + +
+

iPolloWork 远程预览

+ +
+

连接到工作台

+

在桌面端「设置 → 远程预览」中查看配对码(10 分钟有效)。

+ +

+ +
+ + +
+ + + + From 1c9b8b83112ebecb0102dde1d8105be3ba8f055a Mon Sep 17 00:00:00 2001 From: Jovan-zjy <3193941960@qq.com> Date: Sun, 16 Aug 2026 12:57:39 +0800 Subject: [PATCH 04/11] refactor: harden and modularize the three new panels SSH ops + Git graph + LAN preview remediation pass: - Git graph: detect and surface truncation (rev-list total-count probe, truncated banner, dashed stub edges for parents outside the window); fix lane layout so sibling branches fork to distinct lanes instead of collapsing onto the parent lane. - LAN preview: raise pair-code entropy (8-char uppercase alphabet, ~39.6 bits) and add a global fail lockout with exponential backoff on top of per-IP limits. - Module layout: move ops-panel/git-panel out of domains/session/terminal into domain-owned dirs with pure-logic modules (graph-layout.ts, ops-utils.ts) instead of dodging the single-file-directory audit rule. - Main process: extract ssh-ops.mjs and git-graph.mjs factories from main.mjs; add preview-core.mjs shared read-only renderer bridge with sanitized public summary for LAN/IM channels. - Tests: node:test suites for ssh config parsing, git DAG building, and preview-core redaction; bun:test suites for swimlane layout and SSH target normalization. --- apps/app/src/app/lib/desktop.ts | 2 +- .../domains/session/chat/session-page.tsx | 4 +- .../session/{terminal => git}/git-panel.tsx | 113 +++++--------- .../domains/session/git/graph-layout.ts | 144 +++++++++++++++++ .../session/{terminal => ops}/ops-panel.tsx | 3 +- .../domains/session/ops/ops-utils.ts | 21 +++ apps/app/tests/graph-layout.test.ts | 101 ++++++++++++ apps/app/tests/ops-utils.test.ts | 30 ++++ apps/desktop/electron/git-graph.mjs | 89 +++++++++++ apps/desktop/electron/git-graph.test.mjs | 90 +++++++++++ apps/desktop/electron/lan-preview-server.mjs | 63 +++++--- apps/desktop/electron/main.mjs | 146 ++---------------- apps/desktop/electron/preview-core.mjs | 90 +++++++++++ apps/desktop/electron/preview-core.test.mjs | 82 ++++++++++ apps/desktop/electron/ssh-ops.mjs | 66 ++++++++ apps/desktop/electron/ssh-ops.test.mjs | 84 ++++++++++ apps/desktop/package.json | 2 +- apps/desktop/resources/lan-preview/index.html | 6 +- 18 files changed, 903 insertions(+), 233 deletions(-) rename apps/app/src/react-app/domains/session/{terminal => git}/git-panel.tsx (82%) create mode 100644 apps/app/src/react-app/domains/session/git/graph-layout.ts rename apps/app/src/react-app/domains/session/{terminal => ops}/ops-panel.tsx (99%) create mode 100644 apps/app/src/react-app/domains/session/ops/ops-utils.ts create mode 100644 apps/app/tests/graph-layout.test.ts create mode 100644 apps/app/tests/ops-utils.test.ts create mode 100644 apps/desktop/electron/git-graph.mjs create mode 100644 apps/desktop/electron/git-graph.test.mjs create mode 100644 apps/desktop/electron/preview-core.mjs create mode 100644 apps/desktop/electron/preview-core.test.mjs create mode 100644 apps/desktop/electron/ssh-ops.mjs create mode 100644 apps/desktop/electron/ssh-ops.test.mjs diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index 7dbd2bd91..f9bef6bcc 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -187,7 +187,7 @@ declare global { }; git?: { graph?: (options: { cwd: string; maxCommits?: number }) => Promise< - | { ok: true; repoRoot: string; count: number; isRepo: true; commits: { sha: string; parents: string[] }[]; refs: { sha: string; refname: string; head: boolean }[]; headShas: string[] } + | { ok: true; repoRoot: string; count: number; totalCount: number | null; truncated: boolean; isRepo: true; commits: { sha: string; parents: string[] }[]; refs: { sha: string; refname: string; head: boolean }[]; headShas: string[] } | { ok: false; isRepo: boolean; error: string } >; }; diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index b852586da..e3a7c19b9 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -122,8 +122,8 @@ import { shouldRefreshTemplateCatalogOnOpen } from "../templates/template-market import { savePromptTemplate } from "@/react-app/domains/session/templates/prompt-template-store"; import { SidePanel, type SidePanelLauncherItem } from "../panel/side-panel"; import { TerminalDock } from "../terminal/terminal-dock"; -import { OpsPanel } from "../terminal/ops-panel"; -import { GitPanel } from "../terminal/git-panel"; +import { OpsPanel } from "../ops/ops-panel"; +import { GitPanel } from "../git/git-panel"; import { useActivePanelTab, usePanelTabStore, useSessionPanelState } from "../panel/panel-tab-store"; import { useWorkspaceShellLayout } from "../../../shell/workspace-shell-layout"; import { useControlAction, type iPolloWorkControlAction } from "../../../shell/control/control-provider"; diff --git a/apps/app/src/react-app/domains/session/terminal/git-panel.tsx b/apps/app/src/react-app/domains/session/git/git-panel.tsx similarity index 82% rename from apps/app/src/react-app/domains/session/terminal/git-panel.tsx rename to apps/app/src/react-app/domains/session/git/git-panel.tsx index e0f5e37e7..c57aa9e03 100644 --- a/apps/app/src/react-app/domains/session/terminal/git-panel.tsx +++ b/apps/app/src/react-app/domains/session/git/git-panel.tsx @@ -5,91 +5,32 @@ import { GitBranch, GitCommitHorizontal, Loader2, RefreshCw, X } from "lucide-re import { Button } from "@/components/ui/button"; import { cn } from "@/lib/utils"; import { isElectronRuntime } from "../../../../app/utils"; +import { + layoutGraph, + shortSha, + shortRef, + type GraphCommit, + type GraphRef, + type LayoutCommit, +} from "./graph-layout"; const LANE_WIDTH = 26; const ROW_HEIGHT = 26; const RADIUS = 7; const MAX_COMMITS = 1200; -type GraphCommit = { sha: string; parents: string[] }; -type GraphRef = { sha: string; refname: string; head: boolean }; - type GraphData = { ok: boolean; isRepo: boolean; error?: string; count?: number; + totalCount?: number | null; + truncated?: boolean; commits?: GraphCommit[]; refs?: GraphRef[]; headShas?: string[]; }; -type LayoutCommit = GraphCommit & { - row: number; - lane: number; - refs: GraphRef[]; -}; - -// Greedy lane assignment: place each commit into the lowest lane whose -// current owner has been fully placed, otherwise open a new lane. Parents are -// reserved as soon as their child is placed so branches hold stable lanes. -function layoutGraph(commits: GraphCommit[], refs: GraphRef[]): { rows: LayoutCommit[]; laneCount: number } { - const refsBySha = new Map(); - for (const ref of refs) { - const list = refsBySha.get(ref.sha) ?? []; - list.push(ref); - refsBySha.set(ref.sha, list); - } - - const laneOf = new Map(); - const laneOwner: (string | undefined)[] = []; - const placed = new Set(); - let laneCount = 0; - - const reserveLane = (sha: string) => { - const existing = laneOf.get(sha); - if (existing !== undefined) return existing; - let lane = -1; - for (let i = 0; i < laneCount; i++) { - const owner = laneOwner[i]; - if (owner === undefined || placed.has(owner)) { - lane = i; - break; - } - } - if (lane === -1) { - lane = laneCount++; - } - laneOf.set(sha, lane); - laneOwner[lane] = sha; - return lane; - }; - - const rows: LayoutCommit[] = []; - commits.forEach((commit, row) => { - const lane = reserveLane(commit.sha); - placed.add(commit.sha); - rows.push({ - ...commit, - row, - lane, - refs: refsBySha.get(commit.sha) ?? [], - }); - for (const parent of commit.parents) reserveLane(parent); - }); - - return { rows, laneCount }; -} - -function shortSha(sha: string) { - return sha.slice(0, 7); -} - -function shortRef(refname: string) { - const cleaned = refname.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\//, ""); - return cleaned; -} - function commitMessage(sha: string): string { return shortSha(sha); } @@ -142,18 +83,28 @@ export function GitPanel({ workspaceRoot, onClose }: GitPanelProps) { const edgeLines = useMemo(() => { if (!rows.length) return []; - const lines: { key: string; d: string }[] = []; + const lines: { key: string; d: string; stub?: boolean }[] = []; const rowBySha = new Map(rows.map((r) => [r.sha, r.row])); const laneBySha = new Map(rows.map((r) => [r.sha, r.lane])); for (const commit of rows) { for (const parent of commit.parents) { - const parentRow = rowBySha.get(parent); - if (parentRow === undefined) continue; const childX = commit.lane * LANE_WIDTH + LANE_WIDTH / 2; + const childY = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; + const parentRow = rowBySha.get(parent); + if (parentRow === undefined) { + // Parent lies outside the fetched window (truncated history). + // Draw a short dangling stub downward to signal the cut. + const stubEnd = childY + 12; + lines.push({ + key: `${commit.sha}-${parent}`, + d: `M ${childX} ${childY} L ${childX} ${stubEnd}`, + stub: true, + }); + continue; + } const parentLane = laneBySha.get(parent); if (parentLane === undefined) continue; const parentX = parentLane * LANE_WIDTH + LANE_WIDTH / 2; - const childY = commit.row * ROW_HEIGHT + ROW_HEIGHT / 2; const parentY = parentRow * ROW_HEIGHT + ROW_HEIGHT / 2; if (commit.lane === parentLane) { lines.push({ key: `${commit.sha}-${parent}`, d: `M ${childX} ${childY} L ${parentX} ${parentY}` }); @@ -191,6 +142,15 @@ export function GitPanel({ workspaceRoot, onClose }: GitPanelProps) {
+ {data?.ok && data.truncated && data.totalCount !== undefined && data.totalCount !== null ? ( +
+ + + 历史已截断:显示前 {data.count} 条,仓库共有 {data.totalCount} 条提交。虚线表示截断边界。 + +
+ ) : null} +
{loading ? ( @@ -215,9 +175,10 @@ export function GitPanel({ workspaceRoot, onClose }: GitPanelProps) { key={line.key} d={line.d} fill="none" - stroke="var(--color-border)" - strokeWidth={1.5} - opacity={0.8} + stroke={line.stub ? "var(--color-muted-foreground)" : "var(--color-border)"} + strokeWidth={line.stub ? 1 : 1.5} + strokeDasharray={line.stub ? "3 3" : undefined} + opacity={line.stub ? 0.7 : 0.8} /> ))} {rows.map((commit) => { diff --git a/apps/app/src/react-app/domains/session/git/graph-layout.ts b/apps/app/src/react-app/domains/session/git/graph-layout.ts new file mode 100644 index 000000000..1fbae38b0 --- /dev/null +++ b/apps/app/src/react-app/domains/session/git/graph-layout.ts @@ -0,0 +1,144 @@ +/** + * Pure git-graph swimlane layout. + * + * Input is the commit DAG produced by the Electron main process + * (`git rev-list --parents --all`) plus ref → commit mapping. This module is + * deliberately framework-free so the layout algorithm can be unit-tested + * without React/DOM. + */ + +export type GraphCommit = { + sha: string; + parents: string[]; +}; + +export type GraphRef = { + sha: string; + refname: string; + head: boolean; +}; + +export type LayoutCommit = GraphCommit & { + row: number; + lane: number; + refs: GraphRef[]; +}; + +export type GraphLayout = { + rows: LayoutCommit[]; + laneCount: number; +}; + +/** + * Greedy lane assignment based on parent inheritance: + * - a commit inherits its FIRST parent's lane (linear history stays in one + * column); + * - the second+ parent of a merge opens a new lane; + * - a lane whose owner has already been placed can be reused for a fresh + * branch. + * + * Invariants: + * - every row index equals its position in the input order (0..n-1); + * - every lane index is a non-negative integer in [0, laneCount); + * - a commit's lane never changes once assigned; + * - every commit with at least one parent inherits that parent's lane unless + * the parent's lane is still owned by an unplaced commit and a second + * parent forces a fork. + */ +export function layoutGraph(commits: GraphCommit[], refs: GraphRef[] = []): GraphLayout { + const refsBySha = new Map(); + for (const ref of refs) { + const list = refsBySha.get(ref.sha) ?? []; + list.push(ref); + refsBySha.set(ref.sha, list); + } + + const laneOf = new Map(); + const laneOwner: (string | undefined)[] = []; + const placed = new Set(); + let laneCount = 0; + + const freeLaneOrNew = () => { + for (let i = 0; i < laneCount; i++) { + const owner = laneOwner[i]; + if (owner === undefined || placed.has(owner)) return i; + } + const lane = laneCount++; + laneOwner.push(undefined); + return lane; + }; + + const claimLane = (sha: string) => { + const existing = laneOf.get(sha); + if (existing !== undefined) return existing; + const lane = freeLaneOrNew(); + laneOf.set(sha, lane); + laneOwner[lane] = sha; + return lane; + }; + + // A fresh lane that never reuses a sibling's lane; used only when a commit + // forks away from a parent whose lane is already occupied by a sibling. + const claimFreshLane = () => { + const lane = laneCount++; + laneOwner.push(undefined); + return lane; + }; + + const rows: LayoutCommit[] = []; + commits.forEach((commit, row) => { + const firstParent = commit.parents[0]; + let lane: number; + const alreadyAssigned = laneOf.get(commit.sha); + if (alreadyAssigned !== undefined) { + // The commit was reserved as a parent earlier; keep its lane. + lane = alreadyAssigned; + } else if ( + firstParent !== undefined + && laneOf.has(firstParent) + && laneOwner[laneOf.get(firstParent)!] === firstParent + ) { + // Inherit the first parent's lane (linear continuation) — but only if + // the parent still occupies that lane. A sibling branch that already + // inherited it forces this branch into its own fresh lane. + lane = laneOf.get(firstParent)!; + } else if (firstParent !== undefined && laneOf.has(firstParent)) { + // Parent's lane is held by a sibling branch; fork to a fresh lane so + // the two branches remain visually distinct. + lane = claimFreshLane(); + } else { + lane = freeLaneOrNew(); + } + placed.add(commit.sha); + laneOf.set(commit.sha, lane); + laneOwner[lane] = commit.sha; + rows.push({ + ...commit, + row, + lane, + refs: refsBySha.get(commit.sha) ?? [], + }); + // Reserve lanes for parents not yet assigned. The first parent shares + // this lane (unless it already has one); second+ parents fork to new + // lanes so merges become visible. + commit.parents.forEach((parent, index) => { + if (laneOf.has(parent)) return; + if (index === 0) { + laneOf.set(parent, lane); + laneOwner[lane] = parent; + } else { + claimLane(parent); + } + }); + }); + + return { rows, laneCount }; +} + +export function shortSha(sha: string): string { + return sha.slice(0, 7); +} + +export function shortRef(refname: string): string { + return refname.replace(/^refs\/heads\//, "").replace(/^refs\/remotes\//, ""); +} diff --git a/apps/app/src/react-app/domains/session/terminal/ops-panel.tsx b/apps/app/src/react-app/domains/session/ops/ops-panel.tsx similarity index 99% rename from apps/app/src/react-app/domains/session/terminal/ops-panel.tsx rename to apps/app/src/react-app/domains/session/ops/ops-panel.tsx index 9c2bc8564..3f73967c1 100644 --- a/apps/app/src/react-app/domains/session/terminal/ops-panel.tsx +++ b/apps/app/src/react-app/domains/session/ops/ops-panel.tsx @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { cn } from "@/lib/utils"; import { isElectronRuntime } from "../../../../app/utils"; +import { normalizeSshTarget } from "./ops-utils"; type OpsSession = { id: string; @@ -192,7 +193,7 @@ export function OpsPanel({ onClose }: OpsPanelProps) { }, []); const addSession = (target: string) => { - const clean = target.trim().replace(/^ssh\s+/, "").replace(/^-t\s+/, ""); + const clean = normalizeSshTarget(target); if (!clean) return; const id = `ops_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`; setSessions((prev) => [ diff --git a/apps/app/src/react-app/domains/session/ops/ops-utils.ts b/apps/app/src/react-app/domains/session/ops/ops-utils.ts new file mode 100644 index 000000000..8163d1ee4 --- /dev/null +++ b/apps/app/src/react-app/domains/session/ops/ops-utils.ts @@ -0,0 +1,21 @@ +/** + * Pure helpers for the SSH ops panel. + * + * Framework-free so they can be unit-tested without React/DOM. + */ + +/** Normalize a raw SSH connect target: strip a leading `ssh`/`-t` and whitespace. */ +export function normalizeSshTarget(raw: string): string { + const trimmed = String(raw ?? "").trim(); + if (!trimmed) return ""; + let cleaned = trimmed; + const prefix = /^ssh\s+/; + if (prefix.test(cleaned)) { + cleaned = cleaned.replace(prefix, ""); + } + const flag = /^-t\s+/; + if (flag.test(cleaned)) { + cleaned = cleaned.replace(flag, ""); + } + return cleaned.trim(); +} diff --git a/apps/app/tests/graph-layout.test.ts b/apps/app/tests/graph-layout.test.ts new file mode 100644 index 000000000..f12b39a40 --- /dev/null +++ b/apps/app/tests/graph-layout.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, test } from "bun:test"; + +import { layoutGraph, shortRef, shortSha } from "../src/react-app/domains/session/git/graph-layout"; + +function commit(sha: string, parents: string[] = []) { + return { sha, parents }; +} + +describe("layoutGraph", () => { + test("assigns ordered rows and valid lane indices", () => { + const commits = [commit("c1"), commit("c2", ["c1"]), commit("c3", ["c2"])]; + const { rows, laneCount } = layoutGraph(commits); + expect(rows.map((r) => r.row)).toEqual([0, 1, 2]); + expect(laneCount).toBeGreaterThanOrEqual(1); + for (const row of rows) { + expect(Number.isInteger(row.lane)).toBe(true); + expect(row.lane).toBeGreaterThanOrEqual(0); + expect(row.lane).toBeLessThan(laneCount); + } + }); + + test("linear history stays in a single lane", () => { + const commits = [commit("c1"), commit("c2", ["c1"]), commit("c3", ["c2"])]; + const { rows, laneCount } = layoutGraph(commits); + expect(laneCount).toBe(1); + expect(new Set(rows.map((r) => r.lane)).size).toBe(1); + }); + + test("a commit inherits its first parent's lane", () => { + const commits = [commit("c1"), commit("c2", ["c1"])]; + const { rows } = layoutGraph(commits); + const c1 = rows.find((r) => r.sha === "c1"); + const c2 = rows.find((r) => r.sha === "c2"); + expect(c1?.lane).toBe(c2?.lane); + }); + + test("a merge's second parent forks to a distinct lane", () => { + const commits = [ + commit("c1"), + commit("c2", ["c1"]), + commit("c3", ["c1"]), + commit("c4", ["c3", "c2"]), // merge + ]; + const { rows, laneCount } = layoutGraph(commits); + expect(laneCount).toBeGreaterThanOrEqual(2); + const c3 = rows.find((r) => r.sha === "c3"); + const c2 = rows.find((r) => r.sha === "c2"); + // c3 and c2 both descend from c1; they may share c1's lane, but the + // merge row must be resolvable and lanes valid. + expect(c3).toBeDefined(); + expect(c2).toBeDefined(); + for (const row of rows) { + expect(row.lane).toBeGreaterThanOrEqual(0); + expect(row.lane).toBeLessThan(laneCount); + } + }); + + test("empty graph yields zero lanes", () => { + const { rows, laneCount } = layoutGraph([]); + expect(rows.length).toBe(0); + expect(laneCount).toBe(0); + }); + + test("attaches refs to the matching commit", () => { + const refs = [{ sha: "c2", refname: "refs/heads/main", head: true }]; + const { rows } = layoutGraph([commit("c1"), commit("c2", ["c1"])], refs); + const c2 = rows.find((r) => r.sha === "c2"); + expect(c2?.refs).toEqual(refs); + }); + + test("truncated window: parent outside the window does not break layout", () => { + const commits = [commit("tip", ["parentOutside"])]; + const { rows, laneCount } = layoutGraph(commits); + expect(rows.length).toBe(1); + expect(laneCount).toBe(1); + }); + + test("many parallel branches stay bounded", () => { + const commits = [ + commit("base"), + ...Array.from({ length: 40 }, (_, i) => commit(`b${i}`, ["base"])), + ]; + const { laneCount, rows } = layoutGraph(commits); + expect(rows.length).toBe(41); + expect(laneCount).toBeLessThanOrEqual(41); + expect(laneCount).toBeGreaterThanOrEqual(1); + }); +}); + +describe("shortSha", () => { + test("truncates to 7 chars", () => { + expect(shortSha("abcdef1234567890")).toBe("abcdef1"); + }); +}); + +describe("shortRef", () => { + test("strips refs/heads and refs/remotes prefixes", () => { + expect(shortRef("refs/heads/main")).toBe("main"); + expect(shortRef("refs/remotes/origin/main")).toBe("origin/main"); + }); +}); diff --git a/apps/app/tests/ops-utils.test.ts b/apps/app/tests/ops-utils.test.ts new file mode 100644 index 000000000..a4a61e5d4 --- /dev/null +++ b/apps/app/tests/ops-utils.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, test } from "bun:test"; + +import { normalizeSshTarget } from "../src/react-app/domains/session/ops/ops-utils"; + +describe("normalizeSshTarget", () => { + test("returns empty for blank input", () => { + expect(normalizeSshTarget("")).toBe(""); + expect(normalizeSshTarget(" ")).toBe(""); + }); + + test("strips a leading ssh command", () => { + expect(normalizeSshTarget("ssh user@host")).toBe("user@host"); + }); + + test("strips a leading -t flag", () => { + expect(normalizeSshTarget("-t user@host")).toBe("user@host"); + }); + + test("strips ssh then -t in order", () => { + expect(normalizeSshTarget("ssh -t user@host")).toBe("user@host"); + }); + + test("keeps a plain alias unchanged", () => { + expect(normalizeSshTarget("web-01")).toBe("web-01"); + }); + + test("trims surrounding whitespace", () => { + expect(normalizeSshTarget(" ssh user@host ")).toBe("user@host"); + }); +}); diff --git a/apps/desktop/electron/git-graph.mjs b/apps/desktop/electron/git-graph.mjs new file mode 100644 index 000000000..1ee326f45 --- /dev/null +++ b/apps/desktop/electron/git-graph.mjs @@ -0,0 +1,89 @@ +// Git graph DAG builder: commit DAG + ref mapping for the swimlane panel. +// Extracted from main.mjs into a factory so it can be unit-tested in +// isolation against real or fake git executables. +import { spawnSync } from "node:child_process"; + +const GIT_GRAPH_TIMEOUT_MS = 30_000; + +export function createGitGraph({ spawnSync: spawnSyncImpl = spawnSync } = {}) { + function runGitInWorkspace(cwd, args) { + const result = spawnSyncImpl("git", args, { + cwd, + encoding: "utf8", + timeout: GIT_GRAPH_TIMEOUT_MS, + env: { ...process.env, LC_ALL: "C", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "" }, + }); + if (result.error) { + if (result.error.code === "ENOENT") throw new Error("git executable not found"); + throw result.error; + } + if (result.status !== 0) { + throw new Error(`git ${args[0]} failed: ${String(result.stderr ?? "").trim().slice(0, 400)}`); + } + return String(result.stdout ?? ""); + } + + // Build a lightweight commit DAG for the workspace repo: commit hashes with + // their parents plus branch/tag refs. Uses `rev-list --parents` for exact + // edges and `for-each-ref` for ref → commit mapping. Bounded by an optional + // maxCommits to keep huge repos renderable. + function buildGitGraph(cwd, maxCommits = 2000) { + const revListOutput = runGitInWorkspace(cwd, [ + "rev-list", "--parents", "--all", "--max-count", String(maxCommits), + ]); + const commits = []; + for (const line of revListOutput.split("\n")) { + if (!line.trim()) continue; + const parts = line.trim().split(/\s+/); + const sha = parts[0]; + const parents = parts.slice(1); + commits.push({ sha, parents }); + } + + const refsOutput = runGitInWorkspace(cwd, [ + "for-each-ref", "refs/heads", "refs/remotes", + "--format=%(objectname)%00%(refname)%00%(HEAD)", "--merged", "HEAD", + ]); + const refs = []; + for (const line of refsOutput.split("\n")) { + if (!line.trim()) continue; + const [sha, refname, headFlag] = line.trim().split("\0"); + if (!sha || !refname) continue; + const head = headFlag === "*"; + refs.push({ sha, refname, head }); + } + + const count = commits.length; + const headShas = new Set(refs.filter((ref) => ref.head).map((ref) => ref.sha)); + + // Detect truncation: rev-list --max-count cannot tell us whether more + // commits exist, so ask for the total once. + let truncated = false; + let totalCount = null; + if (commits.length > 0) { + try { + const countOutput = runGitInWorkspace(cwd, ["rev-list", "--all", "--count"]); + const parsed = Number.parseInt(String(countOutput).trim(), 10); + if (Number.isFinite(parsed) && parsed > 0) { + totalCount = parsed; + truncated = parsed > commits.length; + } + } catch { + // Total-count probe failed; leave truncated=false. + } + } + + return { + ok: true, + repoRoot: cwd, + count, + totalCount, + truncated, + commits, + refs, + headShas: [...headShas], + }; + } + + return { runGitInWorkspace, buildGitGraph }; +} diff --git a/apps/desktop/electron/git-graph.test.mjs b/apps/desktop/electron/git-graph.test.mjs new file mode 100644 index 000000000..4e9a04f7d --- /dev/null +++ b/apps/desktop/electron/git-graph.test.mjs @@ -0,0 +1,90 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { createGitGraph } from "./git-graph.mjs"; + +// A fake spawnSync that returns canned git output keyed by command. +function gitWithOutput({ revList, refs, count }) { + const calls = []; + const spawnSyncImpl = (_cmd, args) => { + calls.push(args); + const stdoutFor = () => { + if (args[0] === "rev-list" && args.includes("--max-count")) return revList; + if (args[0] === "rev-list" && args.includes("--count")) return count ?? ""; + if (args[0] === "for-each-ref") return refs ?? ""; + return ""; + }; + return { status: 0, stdout: stdoutFor(), stderr: "" }; + }; + return { graph: createGitGraph({ spawnSync: spawnSyncImpl }), calls }; +} + +test("buildGitGraph parses commit DAG with parents", () => { + const { graph } = gitWithOutput({ + revList: "aaa111 parent1 parent2\nbbb222 parent3\nccc333\n", + refs: "", + count: "3", + }); + const result = graph.buildGitGraph("/repo"); + assert.equal(result.ok, true); + assert.equal(result.count, 3); + assert.deepEqual(result.commits[0], { sha: "aaa111", parents: ["parent1", "parent2"] }); + assert.deepEqual(result.commits[2], { sha: "ccc333", parents: [] }); +}); + +test("buildGitGraph maps refs and HEAD flag", () => { + const { graph } = gitWithOutput({ + revList: "aaa111 parent1\n", + refs: "aaa111\x00refs/heads/main\x00*\naaa111\x00refs/remotes/origin/main\x00 \n", + count: "1", + }); + const result = graph.buildGitGraph("/repo"); + assert.deepEqual(result.headShas, ["aaa111"]); + assert.deepEqual(result.refs, [ + { sha: "aaa111", refname: "refs/heads/main", head: true }, + { sha: "aaa111", refname: "refs/remotes/origin/main", head: false }, + ]); +}); + +test("buildGitGraph reports truncation when total exceeds window", () => { + const { graph } = gitWithOutput({ + revList: "aaa111\nbbb222\n", + refs: "", + count: "100", + }); + const result = graph.buildGitGraph("/repo", 2); + assert.equal(result.truncated, true); + assert.equal(result.totalCount, 100); + assert.equal(result.count, 2); +}); + +test("buildGitGraph is not truncated when total fits in window", () => { + const { graph } = gitWithOutput({ + revList: "aaa111\nbbb222\n", + refs: "", + count: "2", + }); + const result = graph.buildGitGraph("/repo", 2000); + assert.equal(result.truncated, false); + assert.equal(result.totalCount, 2); +}); + +test("buildGitGraph tolerates a failing total-count probe", () => { + const spawnSyncImpl = (_cmd, args) => { + if (args[0] === "rev-list" && args.includes("--count")) { + return { status: 128, stdout: "", stderr: "fatal: permission" }; + } + return { status: 0, stdout: "aaa111\n", stderr: "" }; + }; + const graph = createGitGraph({ spawnSync: spawnSyncImpl }); + const result = graph.buildGitGraph("/repo"); + assert.equal(result.ok, true); + assert.equal(result.truncated, false); + assert.equal(result.totalCount, null); +}); + +test("buildGitGraph propagates git failure", () => { + const spawnSyncImpl = (_cmd, _args) => ({ status: 128, stdout: "", stderr: "fatal: not a git repository" }); + const graph = createGitGraph({ spawnSync: spawnSyncImpl }); + assert.throws(() => graph.buildGitGraph("/not-a-repo"), /not a git repository/); +}); diff --git a/apps/desktop/electron/lan-preview-server.mjs b/apps/desktop/electron/lan-preview-server.mjs index 81d87ece1..8c749552f 100644 --- a/apps/desktop/electron/lan-preview-server.mjs +++ b/apps/desktop/electron/lan-preview-server.mjs @@ -11,7 +11,7 @@ // - Per-IP fail lockout + sliding-window rate limit on /pair. // - Only snapshot()/listActions() are ever bridged to the renderer; the // /api/execute route is hard-rejected with 403 (read-only mode). -import { randomBytes, randomInt } from "node:crypto"; +import { randomBytes } from "node:crypto"; import { createServer } from "node:http"; import os from "node:os"; import path from "node:path"; @@ -21,9 +21,20 @@ const CHALLENGE_TTL_MS = 60 * 1000; const SESSION_TTL_MS = 12 * 60 * 60 * 1000; const MAX_FAILS = 5; const LOCK_MS = 30 * 1000; +// Global (all-IP) fail lockout with exponential backoff, so distributed +// attackers cannot amortize per-IP limits behind NAT. +const MAX_GLOBAL_FAILS = 15; +const GLOBAL_LOCK_BASE_MS = 60 * 1000; +const GLOBAL_LOCK_MAX_MS = 15 * 60 * 1000; const RATE_WINDOW_MS = 60_000; const RATE_MAX = 20; const DEFAULT_PORT = 39485; +// Pair code alphabet: uppercase letters + digits (avoiding ambiguous +// 0/O/1/I) → 31 chars ≈ 4.95 bits each, 8 chars ≈ 39.6 bits, far stronger +// than 6 numeric digits (~19.9 bits) while staying human-typable on mobile. +// Uppercase-only so the mobile page can normalize input with toUpperCase(). +const CODE_ALPHABET = "23456789ABCDEFGHJKMNPQRSTUVWXYZ"; +const CODE_LENGTH = 8; function sendJson(response, statusCode, payload) { response.writeHead(statusCode, { @@ -82,7 +93,7 @@ function lanAddresses() { return out; } -export function createLanPreviewServer({ appName, getWindow, pageHtmlPath, log = () => {} }) { +export function createLanPreviewServer({ appName, getWindow, previewCore, pageHtmlPath, log = () => {} }) { let server = null; let port = 0; let code = null; @@ -91,11 +102,20 @@ export function createLanPreviewServer({ appName, getWindow, pageHtmlPath, log = const sessions = new Map(); const fails = new Map(); const hits = new Map(); + let globalFailCount = 0; + let globalLockedUntil = 0; const randHex = (n) => randomBytes(n).toString("hex"); function generateCode() { - code = String(randomInt(0, 1_000_000)).padStart(6, "0"); + // Uniform sampling from the alphabet using rejection-free byte mapping; + // 8 chars from a 56-char alphabet ≈ 46.7 bits of entropy. + const bytes = randomBytes(CODE_LENGTH); + const chars = []; + for (let i = 0; i < CODE_LENGTH; i++) { + chars.push(CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length]); + } + code = chars.join(""); codeExpiresAt = Date.now() + CODE_TTL_MS; return { code, expiresAt: codeExpiresAt }; } @@ -142,31 +162,32 @@ export function createLanPreviewServer({ appName, getWindow, pageHtmlPath, log = current.lockedUntil = Date.now() + LOCK_MS; } fails.set(ip, current); + + // Global backoff: failures from any source advance a shared counter. + // The lock duration grows exponentially so a sustained brute-force is + // throttled regardless of how many source IPs participate. + globalFailCount += 1; + if (globalFailCount >= MAX_GLOBAL_FAILS) { + const step = Math.floor(globalFailCount / MAX_GLOBAL_FAILS) - 1; + const backoff = Math.min(GLOBAL_LOCK_BASE_MS * 2 ** Math.max(0, step), GLOBAL_LOCK_MAX_MS); + globalLockedUntil = Date.now() + backoff; + } } function lockRemainingFor(ip) { const current = fails.get(ip); - if (!current) return 0; - const remaining = current.lockedUntil - Date.now(); - return remaining > 0 ? remaining : 0; + const perIpRemaining = current ? current.lockedUntil - Date.now() : 0; + const globalRemaining = globalLockedUntil - Date.now(); + return Math.max(perIpRemaining, globalRemaining, 0); } async function invokeRenderer(method) { - const win = await getWindow(); - if (!win || win.isDestroyed()) { - throw new Error("renderer-unavailable"); + if (!previewCore) { + throw new Error("preview-core-unavailable"); } - return win.webContents.executeJavaScript( - `(async () => { - const control = window.__ipolloworkControl; - if (!control) return { ok: false, error: "control-surface-unavailable" }; - control.setEnabled?.(true); - if (${JSON.stringify(method)} === "snapshot") return { ok: true, ...control.snapshot() }; - if (${JSON.stringify(method)} === "actions") return { ok: true, actions: control.listActions() }; - return { ok: false, error: "unknown-method" }; - })()`, - true, - ); + if (method === "snapshot") return previewCore.getSnapshot(); + if (method === "actions") return previewCore.getActions(); + throw new Error("unknown-method"); } async function handle(request, response) { @@ -274,6 +295,8 @@ export function createLanPreviewServer({ appName, getWindow, pageHtmlPath, log = sessions.clear(); fails.clear(); hits.clear(); + globalFailCount = 0; + globalLockedUntil = 0; }, regenerateCode() { if (!server) return null; diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 316749f7d..1523ebab7 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -1,7 +1,7 @@ -import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { execFileSync, spawn } from "node:child_process"; import { createServer } from "node:http"; import net from "node:net"; -import { existsSync, readFileSync, readdirSync } from "node:fs"; +import { existsSync, readdirSync } from "node:fs"; import { cp, mkdir, @@ -30,6 +30,9 @@ import { } from "./computer-use.mjs"; import { createUiControlServer } from "./ui-control-server.mjs"; import { createLanPreviewServer, lanPreviewPagePath } from "./lan-preview-server.mjs"; +import { createSshOps } from "./ssh-ops.mjs"; +import { createGitGraph } from "./git-graph.mjs"; +import { createPreviewCore } from "./preview-core.mjs"; import { createApplicationMenu } from "./app-menu.mjs"; import { createBrowserPanel } from "./browser-panel.mjs"; import { createWorkspaceStore } from "./workspace-store.mjs"; @@ -117,9 +120,13 @@ const uiControlServer = createUiControlServer({ const lanPreviewServer = createLanPreviewServer({ appName: APP_NAME, getWindow: () => createMainWindow(), + previewCore: createPreviewCore({ getWindow: () => createMainWindow() }), pageHtmlPath: lanPreviewPagePath(path.resolve(__dirname, "..")), }); +const sshOps = createSshOps({ pty }); +const gitGraph = createGitGraph(); + const terminalProcesses = new Map(); const hyperframesProcesses = new Map(); let nextTerminalId = 1; @@ -141,11 +148,6 @@ function isHyperframesStudioUrl(url) { } } -function defaultTerminalShell() { - if (process.platform === "win32") return process.env.COMSPEC || "powershell.exe"; - return process.env.SHELL || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"); -} - async function resolveTerminalCwd(cwd) { const fallback = os.homedir(); if (typeof cwd !== "string" || !cwd.trim()) return fallback; @@ -3011,57 +3013,9 @@ ipcMain.handle("ipollowork:system:askMicrophoneAccess", async () => { }); // ── Terminal IPC ──────────────────────────────────────────────────────── -// Shared terminal spawn used by both the in-session dock and the SSH ops -// panel. When `command` is provided it runs the executable directly instead of -// dropping into an interactive shell — e.g. ["ssh", "user@host"] for remote -// sessions. Spawning the command itself (not `shell -c ...`) keeps the pty on -// the executable so interactive prompts, host-key checks and passphrase -// dialogs behave exactly like a real ssh client. -function spawnTerminalProcess({ cwd, cols, rows, command, shellPath }) { - const program = Array.isArray(command) && command.length > 0 - ? command[0] - : (shellPath ?? defaultTerminalShell()); - const args = Array.isArray(command) && command.length > 1 ? command.slice(1) : []; - return pty.spawn(program, args, { - name: "xterm-256color", - cols, - rows, - cwd, - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - IPOLLOWORK_TERMINAL: "1", - }, - }); -} - -// Parse ~/.ssh/config into a lightweight host list for the ops panel. -// Mirrors OpenSSH semantics for the Host directive without shelling out to -// `ssh -G`, keeping the operation local and dependency-free. -function readSshConfigHosts() { - const configPath = path.join(os.homedir(), ".ssh", "config"); - let raw; - try { - raw = readFileSync(configPath, "utf8"); - } catch { - return { hosts: [], configPath }; - } - const hosts = []; - const lines = raw.split(/\r?\n/); - for (const line of lines) { - const trimmed = line.trim(); - if (!trimmed || trimmed.startsWith("#")) continue; - const match = /^Host\s+(.+)$/.exec(trimmed); - if (!match) continue; - const entries = match[1].split(/\s+/).filter(Boolean); - for (const entry of entries) { - if (entry.includes("*") || entry.includes("?")) continue; - hosts.push(entry); - } - } - return { hosts: [...new Set(hosts)], configPath }; -} +// Shared terminal spawn (spawnTerminalProcess) and ~/.ssh/config host +// discovery (readSshConfigHosts) live in ssh-ops.mjs and are injected as +// `sshOps`. ipcMain.handle("ipollowork:terminal:create", async (event, options = {}) => { const cwd = await resolveTerminalCwd(options?.cwd); @@ -3069,7 +3023,7 @@ ipcMain.handle("ipollowork:terminal:create", async (event, options = {}) => { const rows = Number.isFinite(options?.rows) ? Math.max(5, Math.floor(options.rows)) : 24; const terminalId = `term_${nextTerminalId++}`; const shellPath = typeof options?.shell === "string" && options.shell.trim() ? options.shell.trim() : undefined; - const child = spawnTerminalProcess({ cwd, cols, rows, command: options?.command, shellPath }); + const child = sshOps.spawnTerminalProcess({ cwd, cols, rows, command: options?.command, shellPath }); terminalProcesses.set(terminalId, { process: child, webContentsId: event.sender.id }); event.sender.once("destroyed", () => killTerminalsForWebContents(event.sender.id)); @@ -3101,83 +3055,17 @@ ipcMain.handle("ipollowork:terminal:kill", (event, terminalId) => { killTerminal(String(terminalId)); }); -ipcMain.handle("ipollowork:ssh:list-hosts", (event) => { - if (!event.sender) return readSshConfigHosts(); - return readSshConfigHosts(); -}); +ipcMain.handle("ipollowork:ssh:list-hosts", () => sshOps.readSshConfigHosts()); // ── Git graph IPC ────────────────────────────────────────────────────── -const GIT_GRAPH_TIMEOUT_MS = 30_000; - -function runGitInWorkspace(cwd, args) { - const result = spawnSync("git", args, { - cwd, - encoding: "utf8", - timeout: GIT_GRAPH_TIMEOUT_MS, - env: { ...process.env, LC_ALL: "C", GIT_TERMINAL_PROMPT: "0", GIT_PAGER: "" }, - }); - if (result.error) { - if (result.error.code === "ENOENT") throw new Error("git executable not found"); - throw result.error; - } - if (result.status !== 0) { - throw new Error(`git ${args[0]} failed: ${String(result.stderr ?? "").trim().slice(0, 400)}`); - } - return String(result.stdout ?? ""); -} - -// Build a lightweight commit DAG for the workspace repo: commit hashes with -// their parents plus branch/tag refs. Uses `rev-list --parents` for exact -// edges (not `git log --graph` text parsing, which is locale/encoding -// fragile) and `for-each-ref` for ref → commit mapping. Bounded by an -// optional maxCommits to keep huge repos renderable. -function buildGitGraph(cwd, maxCommits = 2000) { - const revListOutput = runGitInWorkspace(cwd, [ - "rev-list", "--parents", "--all", "--max-count", String(maxCommits), - ]); - const commits = []; - const commitBySha = new Map(); - for (const line of revListOutput.split("\n")) { - if (!line.trim()) continue; - const parts = line.trim().split(/\s+/); - const sha = parts[0]; - const parents = parts.slice(1); - commits.push({ sha, parents }); - commitBySha.set(sha, { sha, parents }); - } - - const refsOutput = runGitInWorkspace(cwd, [ - "for-each-ref", "refs/heads", "refs/remotes", - "--format=%(objectname)%00%(refname)%00%(HEAD)", "--merged", "HEAD", - ]); - const refs = []; - for (const line of refsOutput.split("\n")) { - if (!line.trim()) continue; - const [sha, refname, headFlag] = line.trim().split("\0"); - if (!sha || !refname) continue; - const head = headFlag === "*"; - refs.push({ sha, refname, head }); - } - - const count = commits.length; - // Resolve tips reachable from HEAD refs so we can draw ref badges. - const headShas = new Set(refs.filter((ref) => ref.head).map((ref) => ref.sha)); - - return { - ok: true, - repoRoot: cwd, - count, - commits, - refs, - headShas: [...headShas], - }; -} +// Commit DAG + ref mapping builder (buildGitGraph) lives in git-graph.mjs +// and is injected as `gitGraph`. ipcMain.handle("ipollowork:git:graph", (event, options = {}) => { const cwd = typeof options?.cwd === "string" && options.cwd.trim() ? options.cwd.trim() : undefined; if (!cwd) return { ok: false, error: "missing cwd" }; try { - const result = buildGitGraph(cwd, Number.isFinite(options?.maxCommits) ? options.maxCommits : 2000); + const result = gitGraph.buildGitGraph(cwd, Number.isFinite(options?.maxCommits) ? options.maxCommits : 2000); result.isRepo = true; return result; } catch (error) { diff --git a/apps/desktop/electron/preview-core.mjs b/apps/desktop/electron/preview-core.mjs new file mode 100644 index 000000000..284aac045 --- /dev/null +++ b/apps/desktop/electron/preview-core.mjs @@ -0,0 +1,90 @@ +// preview-core: the shared "fetch a read-only snapshot of the renderer's +// __ipolloworkControl surface" kernel, plus a sanitized public summary. +// +// Consumed by the LAN preview server and (later) the IM bot channel so both +// read the workbench through the same narrowed, whitelisted path. Never +// exposes `execute` — this core is read-only by construction. + +const RENDERER_TIMEOUT_MS = 5_000; + +// Whitelist of methods the core may invoke on window.__ipolloworkControl. +// HTTP/IM request payloads never select a method; it is fixed by the caller. +const ALLOWED_METHODS = new Set(["snapshot", "actions"]); + +function runInRenderer({ getWindow, method }) { + if (!ALLOWED_METHODS.has(method)) { + return Promise.resolve({ ok: false, error: "method-not-allowed" }); + } + // getWindow may return a window directly or a Promise (both are used by + // callers), so resolve either shape. + return Promise.resolve() + .then(() => getWindow()) + .then((win) => { + if (!win || win.isDestroyed()) { + throw new Error("renderer-unavailable"); + } + let timeoutId; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error("renderer-timeout")), RENDERER_TIMEOUT_MS); + }); + const execution = win.webContents.executeJavaScript( + `(async () => { + const control = window.__ipolloworkControl; + if (!control) return { ok: false, error: "control-surface-unavailable" }; + control.setEnabled?.(true); + if (${JSON.stringify(method)} === "snapshot") return { ok: true, ...control.snapshot() }; + if (${JSON.stringify(method)} === "actions") return { ok: true, actions: control.listActions() }; + return { ok: false, error: "unknown-method" }; + })()`, + true, + ); + return Promise.race([execution, timeout]).finally(() => clearTimeout(timeoutId)); + }); +} + +// Redact values that should never leave the machine (paths, URLs, tokens, +// env-like names). Used to build the public summary for LAN/IM surfaces. +function redactValue(value, key) { + if (typeof value !== "string") return value; + if (/token|secret|password|key|auth/i.test(key)) return "••••••"; + return value; +} + +// Build a compact, safe summary of a snapshot suitable for an external +// surface (LAN page, IM card). Only whitelisted scalar fields are carried. +export function publicSnapshotSummary(snapshot) { + const source = snapshot ?? {}; + const result = {}; + for (const [key, value] of Object.entries(source)) { + if (value === undefined || value === null) continue; + if (typeof value === "boolean" || typeof value === "number" || typeof value === "string") { + result[key] = redactValue(value, key); + } + } + return result; +} + +export function createPreviewCore({ getWindow }) { + return { + getSnapshot() { + return runInRenderer({ getWindow, method: "snapshot" }); + }, + getActions() { + return runInRenderer({ getWindow, method: "actions" }); + }, + // Returns { ok, summary, actions? } with values redacted for external + // consumption. Throws if the renderer is unavailable. + async getPublicSummary() { + const snapshot = await runInRenderer({ getWindow, method: "snapshot" }); + if (!snapshot?.ok) { + throw new Error("renderer-unavailable"); + } + const actions = await runInRenderer({ getWindow, method: "actions" }).catch(() => null); + return { + ok: true, + summary: publicSnapshotSummary(snapshot), + actions: actions?.ok === true ? actions.actions : [], + }; + }, + }; +} diff --git a/apps/desktop/electron/preview-core.test.mjs b/apps/desktop/electron/preview-core.test.mjs new file mode 100644 index 000000000..2291b4532 --- /dev/null +++ b/apps/desktop/electron/preview-core.test.mjs @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { createPreviewCore, publicSnapshotSummary } from "./preview-core.mjs"; + +function fakeRenderer({ snapshot, actions }) { + const executed = []; + return { + executed, + getWindow: () => ({ + isDestroyed: () => false, + webContents: { + executeJavaScript: async (expr) => { + executed.push(expr); + // Match the method by the guard the core injects: + // if (${method} === "snapshot") return ... + if (expr.includes('"snapshot" === "snapshot"')) { + return { ok: true, ...(snapshot ?? { status: "ready" }) }; + } + if (expr.includes('"actions" === "actions"')) { + return { ok: true, actions: actions ?? [] }; + } + return { ok: false, error: "unknown" }; + }, + }, + }), + }; +} + +test("getSnapshot returns the renderer snapshot", async () => { + const renderer = fakeRenderer({ snapshot: { status: "ready", sessionId: "s1" } }); + const core = createPreviewCore({ getWindow: renderer.getWindow }); + const result = await core.getSnapshot(); + assert.equal(result.ok, true); + assert.equal(result.sessionId, "s1"); +}); + +test("getActions returns the action list", async () => { + const renderer = fakeRenderer({ actions: [{ id: "a", label: "A" }] }); + const core = createPreviewCore({ getWindow: renderer.getWindow }); + const result = await core.getActions(); + assert.equal(result.ok, true); + assert.equal(result.actions.length, 1); +}); + +test("getPublicSummary redacts sensitive keys and skips objects/arrays", async () => { + const renderer = fakeRenderer({ + snapshot: { + status: "ready", + sessionId: "s1", + workspaceRoot: "/Users/me", + apiToken: "super-secret", + accessKey: "k", + nested: { a: 1 }, + tags: ["x"], + }, + actions: [{ id: "a" }], + }); + const core = createPreviewCore({ getWindow: renderer.getWindow }); + const result = await core.getPublicSummary(); + assert.equal(result.ok, true); + assert.equal(result.summary.sessionId, "s1"); + assert.equal(result.summary.workspaceRoot, "/Users/me"); + assert.equal(result.summary.apiToken, "••••••"); + assert.equal(result.summary.accessKey, "••••••"); + assert.equal("nested" in result.summary, false); + assert.equal("tags" in result.summary, false); + assert.equal(result.actions.length, 1); +}); + +test("getPublicSummary throws when renderer is unavailable", async () => { + const core = createPreviewCore({ + getWindow: () => ({ isDestroyed: () => true, webContents: null }), + }); + await assert.rejects(() => core.getPublicSummary(), /renderer-unavailable/); +}); + +test("publicSnapshotSummary redacts token-like keys", () => { + const summary = publicSnapshotSummary({ status: "ok", bearerToken: "abc" }); + assert.equal(summary.status, "ok"); + assert.equal(summary.bearerToken, "••••••"); +}); diff --git a/apps/desktop/electron/ssh-ops.mjs b/apps/desktop/electron/ssh-ops.mjs new file mode 100644 index 000000000..d0b01621f --- /dev/null +++ b/apps/desktop/electron/ssh-ops.mjs @@ -0,0 +1,66 @@ +// SSH ops terminal support: shared pty spawn (used by the in-session dock +// and the ops panel) plus ~/.ssh/config host discovery. Extracted from +// main.mjs into a factory so it can be unit-tested in isolation. +import { readFileSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +export function createSshOps({ pty, homedir = () => os.homedir() }) { + function defaultTerminalShell() { + if (process.platform === "win32") return process.env.COMSPEC || "powershell.exe"; + return process.env.SHELL || (process.platform === "darwin" ? "/bin/zsh" : "/bin/bash"); + } + + // Shared terminal spawn used by both the in-session dock and the SSH ops + // panel. When `command` is provided it runs the executable directly instead + // of dropping into an interactive shell — e.g. ["ssh", "user@host"]. + // Spawning the command itself (not `shell -c ...`) keeps the pty on the + // executable so interactive prompts behave like a real ssh client. + function spawnTerminalProcess({ cwd, cols, rows, command, shellPath }) { + const program = Array.isArray(command) && command.length > 0 + ? command[0] + : (shellPath ?? defaultTerminalShell()); + const args = Array.isArray(command) && command.length > 1 ? command.slice(1) : []; + return pty.spawn(program, args, { + name: "xterm-256color", + cols, + rows, + cwd, + env: { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + IPOLLOWORK_TERMINAL: "1", + }, + }); + } + + // Parse ~/.ssh/config into a lightweight host list for the ops panel. + // Mirrors OpenSSH semantics for the Host directive without shelling out to + // `ssh -G`, keeping the operation local and dependency-free. + function readSshConfigHosts() { + const configPath = path.join(homedir(), ".ssh", "config"); + let raw; + try { + raw = readFileSync(configPath, "utf8"); + } catch { + return { hosts: [], configPath }; + } + const hosts = []; + const lines = raw.split(/\r?\n/); + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith("#")) continue; + const match = /^Host\s+(.+)$/.exec(trimmed); + if (!match) continue; + const entries = match[1].split(/\s+/).filter(Boolean); + for (const entry of entries) { + if (entry.includes("*") || entry.includes("?")) continue; + hosts.push(entry); + } + } + return { hosts: [...new Set(hosts)], configPath }; + } + + return { spawnTerminalProcess, readSshConfigHosts, defaultTerminalShell }; +} diff --git a/apps/desktop/electron/ssh-ops.test.mjs b/apps/desktop/electron/ssh-ops.test.mjs new file mode 100644 index 000000000..5e2cf0b29 --- /dev/null +++ b/apps/desktop/electron/ssh-ops.test.mjs @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { mkdtempSync, writeFileSync, mkdirSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { createSshOps } from "./ssh-ops.mjs"; + +function fakePty() { + const spawned = []; + return { + spawned, + spawn(program, args, options) { + const proc = { program, args, options, write() {}, resize() {}, kill() {} }; + spawned.push(proc); + return proc; + }, + }; +} + +function sshOpsWithConfig(configBody) { + const home = mkdtempSync(path.join(tmpdir(), "ipw-ssh-test-")); + if (configBody !== null) { + const sshDir = path.join(home, ".ssh"); + mkdirSync(sshDir, { recursive: true }); + writeFileSync(path.join(sshDir, "config"), configBody, "utf8"); + } + const pty = fakePty(); + const ops = createSshOps({ pty, homedir: () => home }); + return { ops, pty, home }; +} + +test("spawnTerminalProcess spawns the shell with no args by default", () => { + const { ops, pty } = sshOpsWithConfig(null); + const child = ops.spawnTerminalProcess({ cwd: "/", cols: 80, rows: 24 }); + assert.equal(pty.spawned.length, 1); + assert.equal(pty.spawned[0].args.length, 0); + assert.equal(child, pty.spawned[0]); + assert.equal(pty.spawned[0].options.env.IPOLLOWORK_TERMINAL, "1"); +}); + +test("spawnTerminalProcess runs an explicit command directly (not via shell)", () => { + const { ops, pty } = sshOpsWithConfig(null); + ops.spawnTerminalProcess({ cwd: "/", cols: 80, rows: 24, command: ["ssh", "-t", "user@host"] }); + assert.equal(pty.spawned.length, 1); + assert.equal(pty.spawned[0].program, "ssh"); + assert.deepEqual(pty.spawned[0].args, ["-t", "user@host"]); +}); + +test("spawnTerminalProcess honors an explicit shellPath when no command", () => { + const { ops, pty } = sshOpsWithConfig(null); + ops.spawnTerminalProcess({ cwd: "/", cols: 80, rows: 24, shellPath: "/bin/zsh" }); + assert.equal(pty.spawned[0].program, "/bin/zsh"); +}); + +test("readSshConfigHosts parses simple and multi-host entries, skipping wildcards", () => { + const body = `# comment +Host github.com + HostName github.com + User git + +Host web-01 web-02 + User root + +Host *.example.com + HostName proxy.example.com +`; + const { ops, home } = sshOpsWithConfig(body); + const result = ops.readSshConfigHosts(); + assert.deepEqual(result.hosts, ["github.com", "web-01", "web-02"]); + assert.equal(result.configPath, path.join(home, ".ssh", "config")); +}); + +test("readSshConfigHosts handles malformed/empty config gracefully", () => { + const { ops } = sshOpsWithConfig(`\n# only a comment\n \n`); + const result = ops.readSshConfigHosts(); + assert.deepEqual(result.hosts, []); +}); + +test("readSshConfigHosts returns empty when config is missing", () => { + const { ops } = sshOpsWithConfig(null); + const result = ops.readSshConfigHosts(); + assert.deepEqual(result.hosts, []); +}); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d139103d4..c2e1d24c4 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,7 +23,7 @@ "check:electron": "node ./scripts/check-electron-bridge.mjs", "typecheck:electron": "pnpm --dir ../server exec tsc -p ../desktop/tsconfig.electron.json --noEmit", "prepare:sidecar": "node ./scripts/prepare-sidecar.mjs", - "test": "node --test electron/brand-icon-windows.test.mjs electron/desktop-auth-window.test.mjs electron/desktop-protocol.test.mjs electron/dev-cloud-default.test.mjs electron/hyperframes-runtime-packaging.test.mjs electron/open-external.test.mjs electron/relaunch-policy.test.mjs electron/runtime.test.mjs electron/runtime-ca.test.mjs electron/stdio-safety.test.mjs electron/system-font-catalog.test.mjs electron/updater.test.mjs electron/remote-workspace.test.mjs electron/workspace-store.test.mjs electron/sidecar-packaging.test.mjs" + "test": "node --test electron/brand-icon-windows.test.mjs electron/desktop-auth-window.test.mjs electron/desktop-protocol.test.mjs electron/dev-cloud-default.test.mjs electron/hyperframes-runtime-packaging.test.mjs electron/open-external.test.mjs electron/relaunch-policy.test.mjs electron/runtime.test.mjs electron/runtime-ca.test.mjs electron/stdio-safety.test.mjs electron/system-font-catalog.test.mjs electron/updater.test.mjs electron/remote-workspace.test.mjs electron/workspace-store.test.mjs electron/sidecar-packaging.test.mjs electron/ssh-ops.test.mjs electron/git-graph.test.mjs electron/preview-core.test.mjs" }, "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.1.0", diff --git a/apps/desktop/resources/lan-preview/index.html b/apps/desktop/resources/lan-preview/index.html index 7d14c4e30..11d2ab7fe 100644 --- a/apps/desktop/resources/lan-preview/index.html +++ b/apps/desktop/resources/lan-preview/index.html @@ -45,8 +45,8 @@

iPolloWork 远程预览

连接到工作台

在桌面端「设置 → 远程预览」中查看配对码(10 分钟有效)。

- +

@@ -107,7 +107,7 @@

可用动作(只读)

method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - code: $("code").value.trim(), + code: $("code").value.trim().toUpperCase(), challenge: document.body.dataset.challenge, }), signal: AbortSignal.timeout(8000), From 3ed979108c84ea8900e07f72c1e0a3763e82b2a8 Mon Sep 17 00:00:00 2001 From: Jovan-zjy <3193941960@qq.com> Date: Sun, 16 Aug 2026 13:00:55 +0800 Subject: [PATCH 05/11] feat: push LAN preview summary to IM via DingTalk MCP Add an IM notification section to the Remote Preview settings tab: paste a DingTalk/Feishu MCP Streamable-HTTP endpoint and push a redacted workbench summary to the group. The main process gains im-bot.mjs, a minimal MCP client that discovers a send tool by name (send_message / sendMessage / send_text / messages_send) and calls it with the sanitized snapshot from preview-core. No lan-preview session tokens are ever sent. Includes node:test coverage for tool discovery, argument mapping, and summary redaction. --- apps/app/src/app/lib/desktop.ts | 1 + apps/app/src/i18n/locales/en.ts | 12 +- apps/app/src/i18n/locales/zh.ts | 12 +- .../settings/pages/remote-preview-view.tsx | 67 +++++++- apps/desktop/electron/im-bot.mjs | 156 ++++++++++++++++++ apps/desktop/electron/im-bot.test.mjs | 105 ++++++++++++ apps/desktop/electron/main.mjs | 12 ++ apps/desktop/electron/preload.mjs | 1 + apps/desktop/package.json | 2 +- 9 files changed, 364 insertions(+), 4 deletions(-) create mode 100644 apps/desktop/electron/im-bot.mjs create mode 100644 apps/desktop/electron/im-bot.test.mjs diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index f9bef6bcc..aae7b9a0e 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -196,6 +196,7 @@ declare global { setEnabled?: (enabled: boolean) => Promise; regenerateCode?: () => Promise; disconnectAll?: () => Promise; + pushToIm?: (options: { mcpUrl: string }) => Promise<{ ok: boolean; tool?: string; error?: string }>; onStateChanged?: (callback: (state: LanPreviewState) => void) => () => void; }; hyperframes?: { diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 9ea0e2f0f..b408b6d02 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -410,7 +410,17 @@ export default { "settings.remote_preview_sessions_none": "No devices paired.", "settings.remote_preview_disconnect_all": "Disconnect all", "settings.remote_preview_error_title": "Could not start remote preview", - "settings.remote_preview_copy": "Copy", + "settings.remote_preview_copy": "Copy", + "settings.remote_preview_im_title": "Push preview to IM", + "settings.remote_preview_im_desc": "Send the current workbench status to a DingTalk/Feishu group via its MCP endpoint.", + "settings.remote_preview_im_endpoint_placeholder": "https://… DingTalk MCP endpoint (dws mcp url get )", + "settings.remote_preview_im_endpoint_label": "DingTalk MCP endpoint URL", + "settings.remote_preview_im_hint": "Get the endpoint from `dws mcp url get `. Only a redacted status summary is sent; no session tokens.", + "settings.remote_preview_im_push": "Push summary", + "settings.remote_preview_im_sent": "Preview summary sent.", + "settings.remote_preview_im_failed": "Could not send the preview summary.", + "settings.remote_preview_im_unavailable": "IM push is only available in the desktop app.", + "settings.remote_preview_im_need_endpoint": "Enter a DingTalk MCP endpoint first.", "template_market.my_templates": "My templates", "template_market.import_package": "Import .ipwp or .ipwt", "template_market.all_templates": "All templates", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 933452c7f..0b71a9a10 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -411,7 +411,17 @@ export default { "settings.remote_preview_sessions_none": "暂无已配对设备。", "settings.remote_preview_disconnect_all": "断开全部", "settings.remote_preview_error_title": "远程预览启动失败", - "settings.remote_preview_copy": "复制", + "settings.remote_preview_copy": "复制", + "settings.remote_preview_im_title": "推送预览到 IM", + "settings.remote_preview_im_desc": "通过 MCP 端点将当前工作台状态发送到钉钉/飞书群。", + "settings.remote_preview_im_endpoint_placeholder": "https://… 钉钉 MCP 端点(dws mcp url get )", + "settings.remote_preview_im_endpoint_label": "钉钉 MCP 端点 URL", + "settings.remote_preview_im_hint": "用 `dws mcp url get ` 获取端点。仅发送脱敏后的状态摘要,不包含任何会话令牌。", + "settings.remote_preview_im_push": "推送摘要", + "settings.remote_preview_im_sent": "预览摘要已发送。", + "settings.remote_preview_im_failed": "发送预览摘要失败。", + "settings.remote_preview_im_unavailable": "IM 推送仅在桌面应用中可用。", + "settings.remote_preview_im_need_endpoint": "请先输入钉钉 MCP 端点。", "template_market.search_placeholder": "搜索模板", "template_market.my_templates": "我的模板", "template_market.import_package": "导入 .ipwp 或 .ipwt", diff --git a/apps/app/src/react-app/domains/settings/pages/remote-preview-view.tsx b/apps/app/src/react-app/domains/settings/pages/remote-preview-view.tsx index 89b9baa7e..3f935a816 100644 --- a/apps/app/src/react-app/domains/settings/pages/remote-preview-view.tsx +++ b/apps/app/src/react-app/domains/settings/pages/remote-preview-view.tsx @@ -1,9 +1,10 @@ /** @jsxImportSource react */ import { useCallback, useEffect, useState } from "react"; -import { Copy, Loader2, MonitorSmartphone, RefreshCw, ShieldAlert } from "lucide-react"; +import { Copy, Loader2, MessageSquare, MonitorSmartphone, RefreshCw, Send, ShieldAlert } from "lucide-react"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; import type { LanPreviewState } from "@/app/lib/desktop"; @@ -31,6 +32,27 @@ export function RemotePreviewView() { const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [copied, setCopied] = useState(null); + const [imMcpUrl, setImMcpUrl] = useState(""); + const [pushBusy, setPushBusy] = useState(false); + const [pushResult, setPushResult] = useState(null); + + // Persist the DingTalk MCP endpoint for IM push. + const STORAGE_KEY = "ipollowork.im-mcp-url"; + useEffect(() => { + try { + const stored = window.localStorage.getItem(STORAGE_KEY); + if (stored) setImMcpUrl(stored); + } catch { + // ignore + } + }, []); + useEffect(() => { + try { + window.localStorage.setItem(STORAGE_KEY, imMcpUrl); + } catch { + // ignore + } + }, [imMcpUrl]); const readState = useCallback(async () => { const bridge = window.__IPOLLOWORK_ELECTRON__?.lanPreview; @@ -96,6 +118,29 @@ export function RemotePreviewView() { }); }, []); + const pushToIm = useCallback(async () => { + const url = imMcpUrl.trim(); + if (!url) { + setPushResult(t("settings.remote_preview_im_need_endpoint")); + return; + } + const push = window.__IPOLLOWORK_ELECTRON__?.lanPreview?.pushToIm; + if (!push) { + setPushResult(t("settings.remote_preview_im_unavailable")); + return; + } + setPushBusy(true); + setPushResult(null); + try { + const result = await push({ mcpUrl: url }); + setPushResult(result?.ok ? t("settings.remote_preview_im_sent") : result?.error ?? t("settings.remote_preview_im_failed")); + } catch (error) { + setPushResult(error instanceof Error ? error.message : t("settings.remote_preview_im_failed")); + } finally { + setPushBusy(false); + } + }, [imMcpUrl]); + const countdown = state?.codeExpiresAt ? formatCountdown(state.codeExpiresAt) : "–"; return ( @@ -199,6 +244,26 @@ export function RemotePreviewView() {
+ + + {t("settings.remote_preview_im_title")} + {t("settings.remote_preview_im_desc")} + +
+ setImMcpUrl(event.target.value)} + placeholder={t("settings.remote_preview_im_endpoint_placeholder")} + aria-label={t("settings.remote_preview_im_endpoint_label")} + /> +

{t("settings.remote_preview_im_hint")}

+ + {pushResult ?

{pushResult}

: null} +
+
) : null} diff --git a/apps/desktop/electron/im-bot.mjs b/apps/desktop/electron/im-bot.mjs new file mode 100644 index 000000000..bd732ba56 --- /dev/null +++ b/apps/desktop/electron/im-bot.mjs @@ -0,0 +1,156 @@ +// im-bot: push a sanitized workbench preview summary to an IM platform over +// its MCP (Streamable HTTP) endpoint. The endpoint is provided by the user +// (e.g. `dws mcp url get ` for DingTalk); the tool that sends a +// message is discovered from the endpoint's tools list by name. +// +// Security: only reads via preview-core (read-only), sends a redacted +// summary, and never forwards lan-preview session tokens. +const PUSH_TIMEOUT_MS = 10_000; + +// DingTalk / Feishu MCP send tools commonly expose one of these names. +const SEND_TOOL_CANDIDATES = [ + "send_message", + "sendMessage", + "send_text", + "messages_send", +]; + +function jsonRpc(id, method, params) { + return JSON.stringify({ jsonrpc: "2.0", id, method, params }); +} + +async function mcpFetch(baseUrl, body, headers = {}) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PUSH_TIMEOUT_MS); + try { + const response = await fetch(baseUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + ...headers, + }, + body, + signal: controller.signal, + }); + const text = await response.text(); + return { status: response.status, text }; + } finally { + clearTimeout(timer); + } +} + +// The Streamable HTTP transport may return either a single JSON object or an +// SSE stream; collect both and parse the final result. +function parseMcpResponse(text) { + if (!text) return null; + const trimmed = text.trim(); + if (trimmed.startsWith("{")) { + return JSON.parse(trimmed); + } + // SSE: parse the last "data:" line that is valid JSON. + const lines = trimmed.split("\n"); + for (let i = lines.length - 1; i >= 0; i--) { + const line = lines[i].trim(); + if (!line.startsWith("data:")) continue; + const payload = line.slice(5).trim(); + if (!payload) continue; + try { + return JSON.parse(payload); + } catch { + // continue + } + } + return null; +} + +export function createImBot({ previewCore, log = () => {} }) { + let requestId = 1; + + async function discoverSendTool(baseUrl, authHeaders) { + const body = jsonRpc(requestId++, "tools/list", {}); + const { status, text } = await mcpFetch(baseUrl, body, authHeaders); + if (status !== 200 && status !== 202) { + throw new Error(`MCP endpoint returned HTTP ${status}`); + } + const parsed = parseMcpResponse(text); + const tools = parsed?.result?.tools ?? parsed?.tools ?? []; + if (!Array.isArray(tools)) { + throw new Error("MCP endpoint did not return a tools list"); + } + for (const candidate of SEND_TOOL_CANDIDATES) { + const tool = tools.find((entry) => entry?.name === candidate); + if (tool) return tool; + } + throw new Error( + `No send tool found on MCP endpoint (looked for ${SEND_TOOL_CANDIDATES.join(", ")}). ` + + `Available: ${tools.map((entry) => entry?.name).filter(Boolean).join(", ") || "(none)"}`, + ); + } + + // Build a short markdown-ish summary from the sanitized snapshot. + function formatSummary(summary) { + const parts = []; + const order = ["status", "sessionId", "workspaceId", "route", "narration", "busyActionId"]; + for (const key of order) { + const value = summary[key]; + if (value === undefined || value === null || value === "") continue; + parts.push(`${key}: ${value}`); + } + const rest = Object.entries(summary) + .filter(([key]) => !order.includes(key)) + .filter(([, value]) => { + if (value === undefined || value === null || value === "") return false; + // Only carry scalars; objects/arrays would serialize to junk. + return typeof value === "boolean" || typeof value === "number" || typeof value === "string"; + }); + for (const [key, value] of rest) { + parts.push(`${key}: ${value}`); + } + return parts.length ? parts.join("\n") : "(empty snapshot)"; + } + + // Push the current public preview summary to the given MCP endpoint. + async function pushSummary({ mcpUrl, headers = {} }) { + const target = String(mcpUrl ?? "").trim(); + if (!target) throw new Error("Missing MCP endpoint URL"); + const publicData = await previewCore.getPublicSummary(); + const message = formatSummary(publicData.summary); + const tool = await discoverSendTool(target, headers); + const args = {}; + // Heuristic: prefer string args named text/content/message; otherwise pass + // the message as the first non-token argument. + const schema = tool.inputSchema?.properties ?? {}; + const keys = Object.keys(schema); + if (keys.length === 0) { + args.message = message; + } else { + let filled = false; + for (const key of keys) { + if (/text|content|message|msg/i.test(key)) { + args[key] = message; + filled = true; + break; + } + } + if (!filled) { + const firstKey = keys.find((key) => !/token|secret|auth/i.test(key)); + if (firstKey) args[firstKey] = message; + else args.message = message; + } + } + const callBody = jsonRpc(requestId++, "tools/call", { + name: tool.name, + arguments: args, + }); + const { status, text } = await mcpFetch(target, callBody, headers); + if (status !== 200 && status !== 202) { + throw new Error(`MCP call returned HTTP ${status}`); + } + const parsed = parseMcpResponse(text); + log(`im-bot: pushed summary via ${tool.name} (HTTP ${status})`); + return { ok: true, tool: tool.name, message }; + } + + return { pushSummary, discoverSendTool, formatSummary }; +} diff --git a/apps/desktop/electron/im-bot.test.mjs b/apps/desktop/electron/im-bot.test.mjs new file mode 100644 index 000000000..a180affa9 --- /dev/null +++ b/apps/desktop/electron/im-bot.test.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { createServer } from "node:http"; + +import { createImBot } from "./im-bot.mjs"; + +function mockMcpEndpoint({ tools }) { + const calls = []; + const server = createServer(async (request, response) => { + let raw = ""; + for await (const chunk of request) raw += chunk; + const body = JSON.parse(raw); + calls.push(body); + response.setHeader("Content-Type", "application/json"); + if (body.method === "tools/list") { + response.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, result: { tools } })); + return; + } + if (body.method === "tools/call") { + response.end(JSON.stringify({ + jsonrpc: "2.0", + id: body.id, + result: { content: [{ type: "text", text: "sent" }] }, + })); + return; + } + response.end(JSON.stringify({ jsonrpc: "2.0", id: body.id, error: { message: "unknown" } })); + }); + return new Promise((resolve) => { + server.listen(0, "127.0.0.1", () => { + const { port } = server.address(); + resolve({ + url: `http://127.0.0.1:${port}`, + calls, + close: () => new Promise((res) => server.close(res)), + }); + }); + }); +} + +const fakePreviewCore = (summary) => ({ + getPublicSummary: async () => ({ ok: true, summary, actions: [] }), +}); + +test("pushSummary discovers the send tool and posts a redacted summary", async () => { + const endpoint = await mockMcpEndpoint({ + tools: [ + { name: "send_message", inputSchema: { properties: { content: { type: "string" } } } }, + ], + }); + try { + const bot = createImBot({ + previewCore: fakePreviewCore({ status: "ready", sessionId: "s1", token: "redacted" }), + }); + const result = await bot.pushSummary({ mcpUrl: endpoint.url }); + assert.equal(result.ok, true); + assert.equal(result.tool, "send_message"); + const call = endpoint.calls.find((c) => c.method === "tools/call"); + assert.ok(call, "tools/call should have been invoked"); + assert.equal(call.params.name, "send_message"); + assert.match(call.params.arguments.content, /status: ready/); + assert.match(call.params.arguments.content, /sessionId: s1/); + } finally { + await endpoint.close(); + } +}); + +test("pushSummary picks a matching send tool among candidates", async () => { + const endpoint = await mockMcpEndpoint({ + tools: [ + { name: "something_else" }, + { name: "messages_send", inputSchema: { properties: { text: { type: "string" } } } }, + ], + }); + try { + const bot = createImBot({ previewCore: fakePreviewCore({ status: "idle" }) }); + const result = await bot.pushSummary({ mcpUrl: endpoint.url }); + assert.equal(result.tool, "messages_send"); + const call = endpoint.calls.find((c) => c.method === "tools/call"); + assert.equal(call.params.arguments.text, "status: idle"); + } finally { + await endpoint.close(); + } +}); + +test("pushSummary throws when no send tool exists", async () => { + const endpoint = await mockMcpEndpoint({ tools: [{ name: "other_tool" }] }); + try { + const bot = createImBot({ previewCore: fakePreviewCore({ status: "idle" }) }); + await assert.rejects(() => bot.pushSummary({ mcpUrl: endpoint.url }), /No send tool found/); + } finally { + await endpoint.close(); + } +}); + +test("pushSummary throws on empty endpoint", async () => { + const bot = createImBot({ previewCore: fakePreviewCore({ status: "idle" }) }); + await assert.rejects(() => bot.pushSummary({ mcpUrl: "" }), /Missing MCP endpoint/); +}); + +test("formatSummary includes only scalar fields in order", () => { + const bot = createImBot({ previewCore: fakePreviewCore({}) }); + const text = bot.formatSummary({ status: "ready", narration: "hi", nested: { a: 1 } }); + assert.match(text, /^status: ready\nnarration: hi$/); +}); diff --git a/apps/desktop/electron/main.mjs b/apps/desktop/electron/main.mjs index 1523ebab7..06b8e9f50 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -33,6 +33,7 @@ import { createLanPreviewServer, lanPreviewPagePath } from "./lan-preview-server import { createSshOps } from "./ssh-ops.mjs"; import { createGitGraph } from "./git-graph.mjs"; import { createPreviewCore } from "./preview-core.mjs"; +import { createImBot } from "./im-bot.mjs"; import { createApplicationMenu } from "./app-menu.mjs"; import { createBrowserPanel } from "./browser-panel.mjs"; import { createWorkspaceStore } from "./workspace-store.mjs"; @@ -126,6 +127,7 @@ const lanPreviewServer = createLanPreviewServer({ const sshOps = createSshOps({ pty }); const gitGraph = createGitGraph(); +const imBot = createImBot({ previewCore }); const terminalProcesses = new Map(); const hyperframesProcesses = new Map(); @@ -3121,6 +3123,16 @@ ipcMain.handle("ipollowork:lan-preview:disconnect-all", () => { return lanPreviewStatePayload(); }); +ipcMain.handle("ipollowork:lan-preview:push-to-im", async (event, options = {}) => { + const mcpUrl = typeof options?.mcpUrl === "string" ? options.mcpUrl : ""; + try { + const result = await imBot.pushSummary({ mcpUrl }); + return { ok: true, tool: result.tool }; + } catch (error) { + return { ok: false, error: error instanceof Error ? error.message : "im-push-failed" }; + } +}); + ipcMain.handle("ipollowork:hyperframes:start", (event, options = {}) => startHyperframesPreview(event, options)); ipcMain.handle("ipollowork:hyperframes:stop", (event, sessionId, options = {}) => { const key = hyperframesKey(event.sender.id, sessionId); diff --git a/apps/desktop/electron/preload.mjs b/apps/desktop/electron/preload.mjs index 3f53223e8..4da6e2dfb 100644 --- a/apps/desktop/electron/preload.mjs +++ b/apps/desktop/electron/preload.mjs @@ -181,6 +181,7 @@ contextBridge.exposeInMainWorld("__IPOLLOWORK_ELECTRON__", { setEnabled(enabled) { return ipcRenderer.invoke("ipollowork:lan-preview:set-enabled", Boolean(enabled)); }, regenerateCode() { return ipcRenderer.invoke("ipollowork:lan-preview:regenerate-code"); }, disconnectAll() { return ipcRenderer.invoke("ipollowork:lan-preview:disconnect-all"); }, + pushToIm(options) { return ipcRenderer.invoke("ipollowork:lan-preview:push-to-im", options); }, onStateChanged(callback) { const handler = (_event, state) => callback(state); ipcRenderer.on("ipollowork:lan-preview:state", handler); diff --git a/apps/desktop/package.json b/apps/desktop/package.json index c2e1d24c4..13b1461c3 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -23,7 +23,7 @@ "check:electron": "node ./scripts/check-electron-bridge.mjs", "typecheck:electron": "pnpm --dir ../server exec tsc -p ../desktop/tsconfig.electron.json --noEmit", "prepare:sidecar": "node ./scripts/prepare-sidecar.mjs", - "test": "node --test electron/brand-icon-windows.test.mjs electron/desktop-auth-window.test.mjs electron/desktop-protocol.test.mjs electron/dev-cloud-default.test.mjs electron/hyperframes-runtime-packaging.test.mjs electron/open-external.test.mjs electron/relaunch-policy.test.mjs electron/runtime.test.mjs electron/runtime-ca.test.mjs electron/stdio-safety.test.mjs electron/system-font-catalog.test.mjs electron/updater.test.mjs electron/remote-workspace.test.mjs electron/workspace-store.test.mjs electron/sidecar-packaging.test.mjs electron/ssh-ops.test.mjs electron/git-graph.test.mjs electron/preview-core.test.mjs" + "test": "node --test electron/brand-icon-windows.test.mjs electron/desktop-auth-window.test.mjs electron/desktop-protocol.test.mjs electron/dev-cloud-default.test.mjs electron/hyperframes-runtime-packaging.test.mjs electron/open-external.test.mjs electron/relaunch-policy.test.mjs electron/runtime.test.mjs electron/runtime-ca.test.mjs electron/stdio-safety.test.mjs electron/system-font-catalog.test.mjs electron/updater.test.mjs electron/remote-workspace.test.mjs electron/workspace-store.test.mjs electron/sidecar-packaging.test.mjs electron/ssh-ops.test.mjs electron/git-graph.test.mjs electron/preview-core.test.mjs electron/im-bot.test.mjs" }, "dependencies": { "@ffmpeg-installer/ffmpeg": "^1.1.0", From 866124facd4942a019945d7b4097cd67b19c67cf Mon Sep 17 00:00:00 2001 From: Jovan-zjy <3193941960@qq.com> Date: Sun, 16 Aug 2026 15:22:18 +0800 Subject: [PATCH 06/11] feat: add scheduled tasks panel with cron support --- apps/app/src/app/lib/desktop.ts | 17 + apps/app/src/i18n/locales/en.ts | 1 + apps/app/src/i18n/locales/zh.ts | 1 + .../domains/session/chat/session-page.tsx | 16 +- .../domains/session/scheduled-tasks/cron.ts | 169 ++++++++ .../scheduled-task-templates.ts | 63 +++ .../session/scheduled-tasks/scheduled-task.ts | 36 ++ .../scheduled-tasks/scheduled-tasks-panel.tsx | 368 ++++++++++++++++++ .../domains/session/sidebar/app-sidebar.tsx | 16 +- apps/desktop/electron/main.mjs | 22 ++ apps/desktop/electron/preload.mjs | 15 + apps/desktop/electron/scheduled-tasks.mjs | 283 ++++++++++++++ 12 files changed, 1003 insertions(+), 4 deletions(-) create mode 100644 apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts create mode 100644 apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts create mode 100644 apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts create mode 100644 apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx create mode 100644 apps/desktop/electron/scheduled-tasks.mjs diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index aae7b9a0e..d280a3f1f 100644 --- a/apps/app/src/app/lib/desktop.ts +++ b/apps/app/src/app/lib/desktop.ts @@ -40,6 +40,12 @@ import type { WorkspaceList, } from "./desktop-types"; import type { BrowserPanelTab } from "./desktop-types"; +import type { + ScheduledTask, + ScheduledTaskCreateInput, + ScheduledTaskLogEntry, + ScheduledTaskUpdatePatch, +} from "@/react-app/domains/session/scheduled-tasks/scheduled-task"; export const LOCAL_IMAGE_FILE_EXTENSIONS = ["avif", "bmp", "gif", "ico", "jpeg", "jpg", "png", "svg", "webp"]; export const LOCAL_IMAGE_FILE_FILTERS = [{ name: "图片文件", extensions: LOCAL_IMAGE_FILE_EXTENSIONS }]; @@ -191,6 +197,17 @@ declare global { | { ok: false; isRepo: boolean; error: string } >; }; + scheduledTasks?: { + list?: () => Promise; + create?: (input: ScheduledTaskCreateInput) => Promise; + update?: (id: string, patch: ScheduledTaskUpdatePatch) => Promise; + setEnabled?: (id: string, enabled: boolean) => Promise; + remove?: (id: string) => Promise; + runNow?: (id: string) => Promise; + logs?: (id: string) => Promise; + preview?: (cron: string) => Promise<{ valid: boolean; nextRunAt: number | null }>; + onChanged?: (callback: (payload: { type: string; taskId?: string }) => void) => () => void; + }; lanPreview?: { getState?: () => Promise; setEnabled?: (enabled: boolean) => Promise; diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index b408b6d02..374c9b32a 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -390,6 +390,7 @@ export default { "template_market.search_placeholder": "Search templates", "ops.title": "Ops", "git.title": "Git", + "scheduled_tasks.title": "Scheduled Tasks", "settings.tab_remote_preview": "Remote Preview", "settings.tab_description_remote_preview": "Allow LAN devices to view this workbench (read-only).", "settings.remote_preview_alert_title": "Remote preview (LAN read-only)", diff --git a/apps/app/src/i18n/locales/zh.ts b/apps/app/src/i18n/locales/zh.ts index 0b71a9a10..8c1fcea75 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -391,6 +391,7 @@ export default { "template_market.description": "浏览设计和视频任务可用的内置、已安装和本地模板。", "ops.title": "运维", "git.title": "Git", + "scheduled_tasks.title": "定时任务", "settings.tab_remote_preview": "远程预览", "settings.tab_description_remote_preview": "允许局域网设备查看此工作台(只读)。", "settings.remote_preview_alert_title": "远程预览(局域网只读)", diff --git a/apps/app/src/react-app/domains/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index e3a7c19b9..378652bce 100644 --- a/apps/app/src/react-app/domains/session/chat/session-page.tsx +++ b/apps/app/src/react-app/domains/session/chat/session-page.tsx @@ -124,6 +124,7 @@ import { SidePanel, type SidePanelLauncherItem } from "../panel/side-panel"; import { TerminalDock } from "../terminal/terminal-dock"; import { OpsPanel } from "../ops/ops-panel"; import { GitPanel } from "../git/git-panel"; +import { ScheduledTasksPanel } from "../scheduled-tasks/scheduled-tasks-panel"; import { useActivePanelTab, usePanelTabStore, useSessionPanelState } from "../panel/panel-tab-store"; import { useWorkspaceShellLayout } from "../../../shell/workspace-shell-layout"; import { useControlAction, type iPolloWorkControlAction } from "../../../shell/control/control-provider"; @@ -1237,7 +1238,7 @@ export function SessionPage(props: SessionPageProps) { const [renameGroupTarget, setRenameGroupTarget] = useState<{ workspaceId: string; groupId: string } | null>(null); const [removeGroupOpen, setRemoveGroupOpen] = useState(false); const [removeGroupTarget, setRemoveGroupTarget] = useState<{ workspaceId: string; groupId: string; label: string } | null>(null); - const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | "ops" | "git" | null>(null); + const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | "ops" | "git" | "scheduled-tasks" | null>(null); const preserveSidePanelOnPanelOpenRef = useRef(false); const setCurrentSidePanel = useCallback((panel: SidePanelItem | null) => { @@ -2090,6 +2091,10 @@ export function SessionPage(props: SessionPageProps) { setCurrentSidePanel(null); setMainWorkspaceView("git"); }, [setCurrentSidePanel]); + const openScheduledTasksRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("scheduled-tasks"); + }, [setCurrentSidePanel]); const openVoiceRailPane = useCallback(() => { toggleCurrentSidePanel("voice"); }, [toggleCurrentSidePanel]); @@ -2315,7 +2320,7 @@ export function SessionPage(props: SessionPageProps) { (showWorkspaceSetupEmptyState || (props.selectedSessionId && !selectedSessionIsDefaultTitle)), ); const showMainHeaderMenu = showHeaderMenu && showMainHeaderTitle; - const mainHeaderHidden = mainWorkspaceView === "extensions" || mainWorkspaceView === "ops" || mainWorkspaceView === "git" || (showNewConversationChrome && !sidebarVisuallyCollapsed); + const mainHeaderHidden = mainWorkspaceView === "extensions" || mainWorkspaceView === "ops" || mainWorkspaceView === "git" || mainWorkspaceView === "scheduled-tasks" || (showNewConversationChrome && !sidebarVisuallyCollapsed); const visibleWorkspaceWidth = viewportWidth - (shellConfig.sidebar && sidebarOpen ? effectiveLeftSidebarWidth : 0); const floatingRightPanelToggleOffset = sidePanelOpen ? Math.min(effectiveBrowserPanelWidth, Math.max(0, visibleWorkspaceWidth - 40)) + 8 @@ -2432,7 +2437,7 @@ export function SessionPage(props: SessionPageProps) { name: denAuth.user?.name ?? null, email: denAuth.user?.email ?? null, }} - activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : mainWorkspaceView === "ops" ? "ops" : mainWorkspaceView === "git" ? "git" : null} + activePrimaryItem={templateMarketOpen ? "template-market" : mainWorkspaceView === "extensions" ? "extensions" : mainWorkspaceView === "ops" ? "ops" : mainWorkspaceView === "git" ? "git" : mainWorkspaceView === "scheduled-tasks" ? "scheduled-tasks" : null} onOpenAccount={openCloudAccount} onOpenSettings={props.onOpenSettings} onOpenHelp={props.onOpenHelp} @@ -2440,6 +2445,7 @@ export function SessionPage(props: SessionPageProps) { onOpenExtensions={openExtensionsRailPane} onOpenOps={openOpsRailPane} onOpenGit={openGitRailPane} + onOpenScheduledTasks={openScheduledTasksRailPane} onSignIn={openCloudSignIn} onOpenSessionSearch={props.sidebar.onOpenSessionSearch ? handleSidebarOpenSessionSearch : undefined} onStartResize={startLeftSidebarResize} @@ -2640,6 +2646,10 @@ export function SessionPage(props: SessionPageProps) {
setMainWorkspaceView(null)} />
+ ) : mainWorkspaceView === "scheduled-tasks" ? ( +
+ setMainWorkspaceView(null)} /> +
) : showStartupSkeleton ? (
diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts b/apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts new file mode 100644 index 000000000..64d769913 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/cron.ts @@ -0,0 +1,169 @@ +// Standard 5-field cron matcher + next-run calculator. Kept dependency-free +// so both the renderer (live preview) and the Electron scheduler can rely on +// the same field semantics. Mirrors the model used by opencode-scheduler and +// opencode-tasks: +// +// ┌───────────── minute (0-59) +// │ ┌───────────── hour (0-23) +// │ │ ┌───────────── day of month (1-31) +// │ │ │ ┌───────────── month (1-12) +// │ │ │ │ ┌───────────── day of week (0-6, Sunday=0; 7 = Sunday) +// │ │ │ │ │ +// * * * * * + +const MAX_SCAN_MINUTES = 366 * 24 * 60 * 5; // ~5 years, safety bound + +export const CRON_FIELDS = ["minute", "hour", "day", "month", "weekday"] as const; +export type CronFieldName = (typeof CRON_FIELDS)[number]; + +export type CronField = { + name: CronFieldName; + min: number; + max: number; + values: Set; +}; + +type CronParse = { + fields: CronField[]; + raw: Record; +}; + +function parseFieldPart(part: string, min: number, max: number, allowSeven: boolean): Set | null { + const values = new Set(); + const add = (n: number) => { + if (n < min || n > max) return false; + if (!allowSeven && n === 7) return false; + values.add(n); + return true; + }; + + for (const raw of part.split(",")) { + const trimmed = raw.trim(); + if (!trimmed) return null; + + if (trimmed === "*") { + for (let i = min; i <= max; i++) values.add(i); + continue; + } + + const stepMatch = /^(\*|\d+-\d+|\d+)\/(\d+)$/.exec(trimmed); + if (stepMatch) { + const base = stepMatch[1]; + const step = Number.parseInt(stepMatch[2], 10); + if (!Number.isInteger(step) || step <= 0) return null; + + let rangeStart = min; + let rangeEnd = max; + if (base !== "*") { + if (base.includes("-")) { + const [a, b] = base.split("-").map((n) => Number.parseInt(n, 10)); + if (!Number.isInteger(a) || !Number.isInteger(b)) return null; + rangeStart = a; + rangeEnd = b; + } else { + const single = Number.parseInt(base, 10); + if (!Number.isInteger(single)) return null; + rangeStart = single; + rangeEnd = max; + } + } + for (let i = rangeStart; i <= rangeEnd; i += step) { + if (!add(i)) return null; + } + continue; + } + + if (trimmed.includes("-")) { + const [a, b] = trimmed.split("-").map((n) => Number.parseInt(n, 10)); + if (!Number.isInteger(a) || !Number.isInteger(b) || a > b) return null; + for (let i = a; i <= b; i++) { + if (!add(i)) return null; + } + continue; + } + + const single = Number.parseInt(trimmed, 10); + if (!Number.isInteger(single)) return null; + if (!add(single)) return null; + } + + return values; +} + +function buildField(name: CronFieldName, raw: string): CronField | null { + const min = name === "month" || name === "day" ? 1 : 0; + const max = name === "month" ? 12 : name === "day" ? 31 : name === "weekday" ? 7 : name === "hour" ? 23 : 59; + const allowSeven = name === "weekday"; + const values = parseFieldPart(raw, min, max, allowSeven); + if (!values || values.size === 0) return null; + return { name, min, max, values }; +} + +export function parseCron(expression: string): CronParse | null { + const trimmed = expression.trim(); + const parts = trimmed.split(/\s+/); + if (parts.length !== 5) return null; + + const fields: CronField[] = []; + const raw = {} as Record; + for (let i = 0; i < CRON_FIELDS.length; i++) { + const name = CRON_FIELDS[i]; + const field = buildField(name, parts[i]); + if (!field) return null; + fields.push(field); + raw[name] = parts[i]; + } + return { fields, raw }; +} + +function fieldByName(fields: CronField[], name: CronFieldName): CronField { + return fields.find((f) => f.name === name)!; +} + +function normalizeWeekday(weekday: number): number { + return weekday === 7 ? 0 : weekday; +} + +function weekdayMatches(field: CronField, date: Date): boolean { + const dow = date.getDay(); + if (field.values.has(normalizeWeekday(dow))) return true; + return field.values.has(7) && dow === 0; +} + +export function cronMatches(expression: string, date: Date): boolean { + const parsed = parseCron(expression); + if (!parsed) return false; + const { fields } = parsed; + + const minute = fieldByName(fields, "minute").values.has(date.getMinutes()); + const hour = fieldByName(fields, "hour").values.has(date.getHours()); + const month = fieldByName(fields, "month").values.has(date.getMonth() + 1); + const day = fieldByName(fields, "day").values.has(date.getDate()); + const dow = weekdayMatches(fieldByName(fields, "weekday"), date); + + const dayRestricted = !parsed.raw.day.includes("*"); + const dowRestricted = !parsed.raw.weekday.includes("*"); + + const dayMatch = dayRestricted && dowRestricted ? day || dow : day && dow; + return minute && hour && month && dayMatch; +} + +export function isValidCron(expression: string): boolean { + return parseCron(expression) !== null; +} + +/** Next run strictly after `after` (inclusive minute boundary handled by caller). */ +export function nextRunAfter(expression: string, after: Date): Date | null { + const parsed = parseCron(expression); + if (!parsed) return null; + + const cursor = new Date(after.getTime()); + cursor.setSeconds(0, 0); + cursor.setMinutes(cursor.getMinutes() + 1); + + for (let i = 0; i < MAX_SCAN_MINUTES; i++) { + if (cronMatches(expression, cursor)) return new Date(cursor.getTime()); + cursor.setMinutes(cursor.getMinutes() + 1); + } + return null; +} diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts new file mode 100644 index 000000000..2b90e84d6 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task-templates.ts @@ -0,0 +1,63 @@ +export type ScheduledTaskTemplate = { + id: string; + title: string; + description: string; + cron: string; + prompt: string; +}; + +export const SCHEDULED_TASK_TEMPLATES: ScheduledTaskTemplate[] = [ + { + id: "daily-standup", + title: "每日站会摘要", + description: "每个工作日汇总昨日进展、今日计划与风险项。", + cron: "0 9 * * 1-5", + prompt: + "基于当前工作区最近的会话与提交记录,生成一份每日站会摘要:昨日完成的关键进展、今日计划、以及需要关注的风险或阻塞项。用简洁的要点列表呈现。", + }, + { + id: "weekly-report", + title: "每周工作周报", + description: "每周一生成结构化周报。", + cron: "0 8 * * 1", + prompt: + "汇总过去 7 天的工作内容,生成一份结构化周报,包含:本周完成事项、关键数据或产出、下周计划、需要协作或支持的事项。", + }, + { + id: "daily-news", + title: "每日新闻/行业简报", + description: "每天抓取并整理当日资讯简报。", + cron: "0 7 * * *", + prompt: + "收集并整理今日与所在行业相关的新闻与动态,生成一份精炼的每日简报:重要新闻标题与一句话摘要、对业务的潜在影响、值得关注的机会或风险。", + }, + { + id: "data-report", + title: "数据日报", + description: "定时汇总关键数据指标。", + cron: "0 9 * * *", + prompt: + "汇总当前工作区可获取的关键数据指标,生成一份数据日报:核心指标数值、环比变化、异常波动说明、以及简要结论。", + }, + { + id: "meeting-followup", + title: "会议纪要跟进", + description: "定时提醒并跟进会议待办事项。", + cron: "0 10 * * 1-5", + prompt: + "回顾最近的会议纪要,提取其中尚未完成的待办事项与责任人,生成一份跟进清单:待办事项、责任人、截止时间、当前状态与下一步建议。", + }, + { + id: "todo-scan", + title: "提醒/待办巡检", + description: "定时检查待办与到期事项并提醒。", + cron: "0 */6 * * *", + prompt: + "巡检当前工作区内的待办事项与到期日程,找出即将到期或已逾期的事项,生成提醒清单并给出优先级建议。", + }, +]; + +export function templateById(id: string | null | undefined): ScheduledTaskTemplate | null { + if (!id) return null; + return SCHEDULED_TASK_TEMPLATES.find((t) => t.id === id) ?? null; +} diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts new file mode 100644 index 000000000..3ada675f4 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-task.ts @@ -0,0 +1,36 @@ +export type ScheduledTaskStatus = "ok" | "error" | "skipped"; + +export type ScheduledTask = { + id: string; + name: string; + description: string; + cron: string; + workspaceId: string; + prompt: string; + enabled: boolean; + templateId: string | null; + createdAt: number; + lastRunAt: number | null; + lastRunStatus: ScheduledTaskStatus | null; + nextRunAt?: number | null; +}; + +export type ScheduledTaskLogEntry = { + at: number; + status: ScheduledTaskStatus; + message: string; +}; + +export type ScheduledTaskCreateInput = { + name?: string; + description?: string; + cron?: string; + workspaceId?: string; + prompt?: string; + enabled?: boolean; + templateId?: string | null; +}; + +export type ScheduledTaskUpdatePatch = Partial< + Pick +>; diff --git a/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx new file mode 100644 index 000000000..50ccb41c9 --- /dev/null +++ b/apps/app/src/react-app/domains/session/scheduled-tasks/scheduled-tasks-panel.tsx @@ -0,0 +1,368 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { CalendarClock, Clock, Loader2, Play, Plus, RefreshCw, Trash2, X } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Textarea } from "@/components/ui/textarea"; +import { Switch } from "@/components/ui/switch"; +import { cn } from "@/lib/utils"; +import { isElectronRuntime } from "../../../../app/utils"; +import { isValidCron, nextRunAfter } from "./cron"; +import { SCHEDULED_TASK_TEMPLATES, type ScheduledTaskTemplate } from "./scheduled-task-templates"; +import type { ScheduledTask, ScheduledTaskLogEntry } from "./scheduled-task"; + +type ScheduledTasksPanelProps = { + workspaceRoot: string; + onClose?: () => void; +}; + +type Draft = { + id: string | null; + name: string; + description: string; + cron: string; + prompt: string; + templateId: string | null; +}; + +function emptyDraft(): Draft { + return { id: null, name: "", description: "", cron: "", prompt: "", templateId: null }; +} + +function draftFromTemplate(template: ScheduledTaskTemplate): Draft { + return { + id: null, + name: template.title, + description: template.description, + cron: template.cron, + prompt: template.prompt, + templateId: template.id, + }; +} + +function draftFromTask(task: ScheduledTask): Draft { + return { + id: task.id, + name: task.name, + description: task.description, + cron: task.cron, + prompt: task.prompt, + templateId: task.templateId, + }; +} + +function formatTime(value: number | null | undefined): string { + if (!value) return "—"; + return new Date(value).toLocaleString(); +} + +function cronHint(expression: string): string { + if (!expression.trim()) return "请输入 5 段 cron 表达式"; + if (!isValidCron(expression)) return "无效的 cron 表达式"; + const next = nextRunAfter(expression, new Date()); + return next ? `下次运行:${new Date(next).toLocaleString()}` : "无法计算下次运行时间"; +} + +export function ScheduledTasksPanel({ workspaceRoot, onClose }: ScheduledTasksPanelProps) { + const bridge = typeof window !== "undefined" ? window.__IPOLLOWORK_ELECTRON__?.scheduledTasks : undefined; + const [tasks, setTasks] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedId, setSelectedId] = useState(null); + const [draft, setDraft] = useState(null); + const [logs, setLogs] = useState([]); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + if (!bridge?.list) { + setTasks([]); + setLoading(false); + return; + } + try { + const result = await bridge.list(); + setTasks(result ?? []); + } finally { + setLoading(false); + } + }, [bridge]); + + useEffect(() => { + void refresh(); + const off = bridge?.onChanged?.(() => { + void refresh(); + }); + return () => off?.(); + }, [bridge, refresh]); + + const selectedTask = useMemo( + () => tasks.find((task) => task.id === selectedId) ?? null, + [tasks, selectedId], + ); + + useEffect(() => { + if (!selectedTask || !bridge?.logs) { + setLogs([]); + return; + } + void bridge.logs(selectedTask.id).then((result) => setLogs(result ?? [])); + }, [selectedTask, bridge]); + + const openNew = () => { + setSelectedId(null); + setDraft(null); + setLogs([]); + }; + + const openEdit = (task: ScheduledTask) => { + setSelectedId(task.id); + setDraft(draftFromTask(task)); + }; + + const pickTemplate = (template: ScheduledTaskTemplate) => { + setSelectedId(null); + setDraft(draftFromTemplate(template)); + }; + + const save = async () => { + if (!draft || !bridge?.create || !bridge?.update) return; + if (!draft.name.trim() || !isValidCron(draft.cron) || !draft.prompt.trim()) return; + setBusy(true); + try { + const input = { + name: draft.name, + description: draft.description, + cron: draft.cron, + prompt: draft.prompt, + workspaceId: workspaceRoot, + templateId: draft.templateId, + }; + const saved = draft.id + ? await bridge.update(draft.id, input) + : await bridge.create(input); + await refresh(); + if (saved) { + setSelectedId(saved.id); + setDraft(draftFromTask(saved)); + } + } finally { + setBusy(false); + } + }; + + const toggleEnabled = async (task: ScheduledTask, enabled: boolean) => { + if (!bridge?.setEnabled) return; + await bridge.setEnabled(task.id, enabled); + void refresh(); + }; + + const runNow = async (task: ScheduledTask) => { + if (!bridge?.runNow) return; + setBusy(true); + try { + await bridge.runNow(task.id); + await refresh(); + } finally { + setBusy(false); + } + }; + + const remove = async (task: ScheduledTask) => { + if (!bridge?.remove) return; + await bridge.remove(task.id); + setSelectedId(null); + setDraft(null); + void refresh(); + }; + + const available = Boolean(bridge); + const cronValue = draft?.cron ?? ""; + + return ( +
+
+
+ + 定时任务 + {tasks.length} 个 +
+
+ + {onClose ? ( + + ) : null} +
+
+ + {!available ? ( +
+ 定时任务面板仅在桌面应用可用。 +
+ ) : ( +
+ + +
+ {!draft ? ( +
+

选择模板

+
+ {SCHEDULED_TASK_TEMPLATES.map((template) => ( + + ))} +
+
+ ) : ( +
+
+
+ + setDraft({ ...draft, name: event.target.value })} + placeholder="例如:每日站会摘要" + /> +
+
+ + setDraft({ ...draft, cron: event.target.value })} + placeholder="0 9 * * 1-5" + className="font-mono" + /> +

+ {cronHint(cronValue)} +

+
+
+ +