diff --git a/apps/app/src/app/lib/desktop.ts b/apps/app/src/app/lib/desktop.ts index 1a43b3964..aae7b9a0e 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 // --------------------------------------------------------------------------- @@ -154,13 +165,40 @@ 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 }>; + }; + git?: { + graph?: (options: { cwd: string; maxCommits?: number }) => Promise< + | { 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 } + >; + }; + lanPreview?: { + getState?: () => Promise; + 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?: { 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 5c56ed0ce..9da11634e 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -408,9 +408,42 @@ 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", + "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", + "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 7b1d2a743..5b04feb6b 100644 --- a/apps/app/src/i18n/locales/zh.ts +++ b/apps/app/src/i18n/locales/zh.ts @@ -410,8 +410,41 @@ 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": "运维", + "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": "复制", + "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/session/chat/session-page.tsx b/apps/app/src/react-app/domains/session/chat/session-page.tsx index 394479d4f..9ec827123 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,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 "../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"; @@ -1235,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" | null>(null); + const [mainWorkspaceView, setMainWorkspaceView] = useState<"extensions" | "ops" | "git" | null>(null); const preserveSidePanelOnPanelOpenRef = useRef(false); const setCurrentSidePanel = useCallback((panel: SidePanelItem | null) => { @@ -2080,6 +2082,14 @@ export function SessionPage(props: SessionPageProps) { setCurrentSidePanel(null); setMainWorkspaceView("extensions"); }, [setCurrentSidePanel]); + const openOpsRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("ops"); + }, [setCurrentSidePanel]); + const openGitRailPane = useCallback(() => { + setCurrentSidePanel(null); + setMainWorkspaceView("git"); + }, [setCurrentSidePanel]); const openVoiceRailPane = useCallback(() => { toggleCurrentSidePanel("voice"); }, [toggleCurrentSidePanel]); @@ -2305,7 +2315,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" || mainWorkspaceView === "git" || (showNewConversationChrome && !sidebarVisuallyCollapsed); const visibleWorkspaceWidth = viewportWidth - (shellConfig.sidebar && sidebarOpen ? effectiveLeftSidebarWidth : 0); const floatingRightPanelToggleOffset = sidePanelOpen ? Math.min(effectiveBrowserPanelWidth, Math.max(0, visibleWorkspaceWidth - 40)) + 8 @@ -2422,12 +2432,14 @@ 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" : 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} @@ -2620,6 +2632,14 @@ export function SessionPage(props: SessionPageProps) {
{props.settingsSlot}
+ ) : mainWorkspaceView === "ops" ? ( +
+ setMainWorkspaceView(null)} /> +
+ ) : mainWorkspaceView === "git" ? ( +
+ setMainWorkspaceView(null)} /> +
) : showStartupSkeleton ? (
diff --git a/apps/app/src/react-app/domains/session/git/git-panel.tsx b/apps/app/src/react-app/domains/session/git/git-panel.tsx new file mode 100644 index 000000000..c57aa9e03 --- /dev/null +++ b/apps/app/src/react-app/domains/session/git/git-panel.tsx @@ -0,0 +1,277 @@ +/** @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"; +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 GraphData = { + ok: boolean; + isRepo: boolean; + error?: string; + count?: number; + totalCount?: number | null; + truncated?: boolean; + commits?: GraphCommit[]; + refs?: GraphRef[]; + headShas?: string[]; +}; + +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; 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 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 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} +
+
+ + {data?.ok && data.truncated && data.totalCount !== undefined && data.totalCount !== null ? ( +
+ + + 历史已截断:显示前 {data.count} 条,仓库共有 {data.totalCount} 条提交。虚线表示截断边界。 + +
+ ) : 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/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/ops/ops-panel.tsx b/apps/app/src/react-app/domains/session/ops/ops-panel.tsx new file mode 100644 index 000000000..3f73967c1 --- /dev/null +++ b/apps/app/src/react-app/domains/session/ops/ops-panel.tsx @@ -0,0 +1,383 @@ +/** @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"; +import { normalizeSshTarget } from "./ops-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 = normalizeSshTarget(target); + 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/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/src/react-app/domains/session/sidebar/app-sidebar.tsx b/apps/app/src/react-app/domains/session/sidebar/app-sidebar.tsx index 388535f8f..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 @@ -17,6 +17,8 @@ import { RotateCcw, Settings, HelpCircle, + Server, + GitBranch, Tag, UserRound, } from "lucide-react"; @@ -511,12 +513,14 @@ export type AppSidebarProps = { name: string | null; email: string | null; }; - activePrimaryItem?: "template-market" | "extensions" | 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; @@ -721,6 +725,30 @@ export function AppSidebar(props: AppSidebarProps) { {t("settings.tab_extensions")} + + + + {t("ops.title")} + + + + + + {t("git.title")} + + 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..3f935a816 --- /dev/null +++ b/apps/app/src/react-app/domains/settings/pages/remote-preview-view.tsx @@ -0,0 +1,279 @@ +/** @jsxImportSource react */ +import { useCallback, useEffect, useState } from "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"; +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 [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; + 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 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 ( + + + + {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")} + + +
+ +
+
+ + + {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} + + {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 eb2092a83..9a6fda2cc 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": @@ -1912,6 +1914,8 @@ function SettingsRouteContent(props: SettingsSurfaceProps = {}) { onCleanupiPolloWorkDockerContainers={() => {}} /> ); + case "remote-preview": + return ; case "environment": return ( { + 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/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/lan-preview-server.mjs b/apps/desktop/electron/lan-preview-server.mjs new file mode 100644 index 000000000..8c749552f --- /dev/null +++ b/apps/desktop/electron/lan-preview-server.mjs @@ -0,0 +1,330 @@ +// LAN read-only preview server: pair-code → session token → read-only +// snapshot of the renderer's __ipolloworkControl surface, served to +// mobile devices on the local network. Zero third-party dependencies. +// +// Security posture (read-only by construction): +// - Default OFF; only started when the user enables it in settings. +// - 6-digit pair code (10 min TTL, single-use) exchanged for an in-memory +// session token (12 h TTL, cleared on disable/quit). +// - One-time challenge issued with the pairing page to blunt CSRF / drive-by +// browser brute-forcing of the pair endpoint. +// - 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 } from "node:crypto"; +import { createServer } from "node:http"; +import os from "node:os"; +import path from "node:path"; + +const CODE_TTL_MS = 10 * 60 * 1000; +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, { + "Content-Type": "application/json; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + response.end(JSON.stringify(payload)); +} + +function sendHtml(response, html) { + response.writeHead(200, { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + }); + response.end(html); +} + +function readBody(request) { + return new Promise((resolve, reject) => { + 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, previewCore, 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(); + let globalFailCount = 0; + let globalLockedUntil = 0; + + const randHex = (n) => randomBytes(n).toString("hex"); + + function generateCode() { + // 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 }; + } + + 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); + + // 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); + const perIpRemaining = current ? current.lockedUntil - Date.now() : 0; + const globalRemaining = globalLockedUntil - Date.now(); + return Math.max(perIpRemaining, globalRemaining, 0); + } + + async function invokeRenderer(method) { + if (!previewCore) { + throw new Error("preview-core-unavailable"); + } + if (method === "snapshot") return previewCore.getSnapshot(); + if (method === "actions") return previewCore.getActions(); + throw new Error("unknown-method"); + } + + 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(); + globalFailCount = 0; + globalLockedUntil = 0; + }, + 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 5ebf2428b..06b8e9f50 100644 --- a/apps/desktop/electron/main.mjs +++ b/apps/desktop/electron/main.mjs @@ -29,6 +29,11 @@ import { openComputerUseSetupApp, } 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 { createImBot } from "./im-bot.mjs"; import { createApplicationMenu } from "./app-menu.mjs"; import { createBrowserPanel } from "./browser-panel.mjs"; import { createWorkspaceStore } from "./workspace-store.mjs"; @@ -113,6 +118,17 @@ const uiControlServer = createUiControlServer({ getWindow: () => createMainWindow(), }); +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 imBot = createImBot({ previewCore }); + const terminalProcesses = new Map(); const hyperframesProcesses = new Map(); let nextTerminalId = 1; @@ -134,11 +150,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; @@ -3004,24 +3015,17 @@ ipcMain.handle("ipollowork:system:askMicrophoneAccess", async () => { }); // ── Terminal IPC ──────────────────────────────────────────────────────── +// 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); 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, [], { - name: "xterm-256color", - cols, - rows, - cwd, - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - IPOLLOWORK_TERMINAL: "1", - }, - }); + const shellPath = typeof options?.shell === "string" && options.shell.trim() ? options.shell.trim() : undefined; + 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)); @@ -3053,6 +3057,82 @@ ipcMain.handle("ipollowork:terminal:kill", (event, terminalId) => { killTerminal(String(terminalId)); }); +ipcMain.handle("ipollowork:ssh:list-hosts", () => sshOps.readSshConfigHosts()); + +// ── Git graph IPC ────────────────────────────────────────────────────── +// 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 = gitGraph.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 }; + } +}); + +// ── 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: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); @@ -4112,7 +4192,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 abe349bb9..4da6e2dfb 100644 --- a/apps/desktop/electron/preload.mjs +++ b/apps/desktop/electron/preload.mjs @@ -170,6 +170,24 @@ contextBridge.exposeInMainWorld("__IPOLLOWORK_ELECTRON__", { return () => ipcRenderer.removeListener("ipollowork:terminal:exit", handler); }, }, + ssh: { + listHosts() { return ipcRenderer.invoke("ipollowork:ssh:list-hosts"); }, + }, + 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"); }, + 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); + 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/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..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" + "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", diff --git a/apps/desktop/resources/lan-preview/index.html b/apps/desktop/resources/lan-preview/index.html new file mode 100644 index 000000000..11d2ab7fe --- /dev/null +++ b/apps/desktop/resources/lan-preview/index.html @@ -0,0 +1,203 @@ + + + + + + +iPolloWork 远程预览 + + + +
+

iPolloWork 远程预览

+ +
+

连接到工作台

+

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

+ +

+ +
+ + +
+ + + +