diff --git a/package-lock.json b/package-lock.json index 1235b2b..772726d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,6 +11,7 @@ "hasInstallScript": true, "dependencies": { "@xterm/addon-fit": "0.11.0", + "@xterm/addon-web-links": "0.12.0", "@xterm/xterm": "6.0.0", "node-pty": "1.1.0", "react": "19.2.8", @@ -2239,6 +2240,12 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-web-links": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.12.0.tgz", + "integrity": "sha512-4Smom3RPyVp7ZMYOYDoC/9eGJJJqYhnPLGGqJ6wOBfB8VxPViJNSKdgRYb8NpaM6YSelEKbA2SStD7lGyqaobw==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/package.json b/package.json index 8e9cb7b..6ad1f88 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ }, "dependencies": { "@xterm/addon-fit": "0.11.0", + "@xterm/addon-web-links": "0.12.0", "@xterm/xterm": "6.0.0", "node-pty": "1.1.0", "react": "19.2.8", diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 6ad36af..18dc0a6 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -26,6 +26,7 @@ import { normalizePluginBrowserUrl } from "../services/browser/PluginBrowserOpen import { PluginBrowserOpenBroker } from "./PluginBrowserOpenBroker"; import type { GithubAuthService } from "../services/GithubAuthService"; import type { HermesHudService } from "../services/HermesHudService"; +import { normalizeExternalUrl } from "../../shared/externalUrl"; const MAX_MEDIA_BYTES = 25 * 1024 * 1024; const MEDIA_MIME: Record = { @@ -87,6 +88,10 @@ export function registerIpc({ ipcMain.on(IPC.clipboardWrite, (_event, text: string) => { if (typeof text === "string" && text.length > 0) clipboard.writeText(text); }); + ipcMain.handle(IPC.externalOpenUrl, (event, value: unknown) => { + assertMainRenderer(event, getMainWindow); + return shell.openExternal(normalizeExternalUrl(value)); + }); ipcMain.handle(IPC.appVersion, (event) => { assertMainRenderer(event, getMainWindow); @@ -263,7 +268,7 @@ export function registerIpc({ )); ipcMain.handle(IPC.pluginsOpenExternal, async (_event, pluginId: string, value: string) => { plugins.assertPermission(pluginId, "external:open"); - const url = safeExternalUrl(value); + const url = normalizeExternalUrl(value); await shell.openExternal(url); }); ipcMain.handle(IPC.pluginsOpenBrowser, async (event, pluginId: string, value: unknown) => { @@ -411,7 +416,7 @@ export function registerIpc({ } if (method === "external.open") { plugins.assertPermission(pluginId, "external:open"); - await shell.openExternal(safeExternalUrl(values.url)); + await shell.openExternal(normalizeExternalUrl(values.url)); return null; } if (method === "browser.open") { @@ -629,15 +634,6 @@ function assertMainRenderer( } } -function safeExternalUrl(value: unknown): string { - if (typeof value !== "string" || value.length > 2_048) throw new Error("External URL is invalid."); - const url = new URL(value); - if (url.protocol !== "https:" && url.protocol !== "http:") { - throw new Error("Plugins may open only HTTP(S) URLs."); - } - return url.toString(); -} - function safeGithubUrl(value: unknown): string { if (typeof value !== "string" || value.length > 2_048) throw new Error("GitHub URL is invalid."); const url = new URL(value); diff --git a/src/preload/index.ts b/src/preload/index.ts index 5b4df45..7418aa9 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -38,6 +38,9 @@ const api: CanvasTTYApi = { readText: () => ipcRenderer.invoke(IPC.clipboardRead), writeText: (text: string) => ipcRenderer.send(IPC.clipboardWrite, text) }, + external: { + openUrl: (url: string) => ipcRenderer.invoke(IPC.externalOpenUrl, url) + }, settings: { get: () => ipcRenderer.invoke(IPC.settingsGet), update: (patch: Partial) => ipcRenderer.invoke(IPC.settingsUpdate, patch) diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5a8fd75..b9397ab 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -33,6 +33,7 @@ import { DEFAULT_UI_SCALE, DEFAULT_SHORTCUTS } from "../../shared/contracts"; +import { normalizeExternalUrl } from "../../shared/externalUrl"; import { TitleBar } from "./components/TitleBar"; import { Toast } from "./components/Toast"; import { AgentLaunchDialog } from "./features/launcher/AgentLaunchDialog"; @@ -40,6 +41,7 @@ import { SettingsPanel } from "./features/settings/SettingsPanel"; import { resolveAppearanceSettings } from "./features/settings/appearanceSettings"; import { persistSettingsUpdate } from "./features/settings/persistSettings"; import { PluginBrowserOpenQueue } from "./features/plugins/PluginBrowserOpenQueue"; +import { TerminalLinkDialog } from "./features/terminal/TerminalLinkDialog"; import { WorkspaceCanvas } from "./features/workspace/WorkspaceCanvas"; import type { LimitsLoadState } from "./features/home/homeModel"; import { t } from "./lib/i18n"; @@ -188,6 +190,7 @@ export function App(): React.JSX.Element { const [activeSessionId, setActiveSessionId] = useState(null); const [browserSelected, setBrowserSelected] = useState(false); const [renamingSessionId, setRenamingSessionId] = useState(null); + const [pendingTerminalUrl, setPendingTerminalUrl] = useState(null); const [toast, setToast] = useState(null); const [ready, setReady] = useState(false); const [windowState, setWindowState] = useState({ @@ -979,7 +982,7 @@ export function App(): React.JSX.Element { limitsLoadState={limitsLoadState} plugins={plugins} browser={browser} - browserViewVisible={!settingsOpen && launchProvider === null} + browserViewVisible={!settingsOpen && launchProvider === null && pendingTerminalUrl === null} homeEditing={homeEditDraft !== null} camera={camera} onCameraChange={changeCamera} @@ -988,6 +991,13 @@ export function App(): React.JSX.Element { onOpenAgent={openAgent} onOpenTerminal={(position) => void openTerminal(position)} onOpenBrowser={openBrowserFromUi} + onOpenTerminalUrl={(url) => { + try { + setPendingTerminalUrl(normalizeExternalUrl(url)); + } catch (error) { + showToast(error instanceof Error ? error.message : t(settings.locale, "browserActionFailed")); + } + }} onRequestMedia={requestMedia} onRemoveMedia={removeMedia} onHomeLayoutChange={changeHomeLayout} @@ -1043,6 +1053,23 @@ export function App(): React.JSX.Element { onAcknowledge={acknowledgeDanger} onLaunch={launchAgent} /> + setPendingTerminalUrl(null)} + onOpenCanvas={(url) => { + setPendingTerminalUrl(null); + void openBrowser(url).catch((error: unknown) => { + showToast(error instanceof Error ? error.message : t(settings.locale, "browserActionFailed")); + }); + }} + onOpenExternal={(url) => { + setPendingTerminalUrl(null); + void window.canvasTTY.external.openUrl(url).catch((error: unknown) => { + showToast(error instanceof Error ? error.message : t(settings.locale, "browserActionFailed")); + }); + }} + /> ; onDispose(id: string): void; + onOpenUrl(url: string): void; } interface DragState { @@ -95,10 +97,13 @@ export function TerminalCard({ onRenameEnd, onBoundsChange, onRestart, - onDispose + onDispose, + onOpenUrl }: TerminalCardProps): React.JSX.Element { const terminalHost = useRef(null); const terminalRef = useRef(null); + const onOpenUrlRef = useRef(onOpenUrl); + onOpenUrlRef.current = onOpenUrl; const renameInput = useRef(null); const renameInFlight = useRef(false); const suppressFocusReport = useRef(false); @@ -152,10 +157,26 @@ export function TerminalCard({ lineHeight: 1.2, scrollback: 5_000, allowTransparency: true, - theme: terminalTheme(palette) + theme: terminalTheme(palette), + // OSC 8 hyperlinks are handled by xterm itself rather than WebLinksAddon. + // Without an explicit handler, xterm shows its own confirm() prompt and + // attempts window.open(), bypassing CanvasTTY's link destination chooser. + linkHandler: { + activate: (event, uri) => { + event.preventDefault(); + event.stopPropagation(); + onOpenUrlRef.current(uri); + } + } }); const fitAddon = new FitAddon(); + const webLinksAddon = new WebLinksAddon((event, uri) => { + event.preventDefault(); + event.stopPropagation(); + onOpenUrlRef.current(uri); + }); terminal.loadAddon(fitAddon); + terminal.loadAddon(webLinksAddon); terminal.open(host); let lastReportedGrid = ""; const reportGrid = (cols: number, rows: number): void => { diff --git a/src/renderer/src/features/terminal/TerminalLinkDialog.tsx b/src/renderer/src/features/terminal/TerminalLinkDialog.tsx new file mode 100644 index 0000000..21337c3 --- /dev/null +++ b/src/renderer/src/features/terminal/TerminalLinkDialog.tsx @@ -0,0 +1,62 @@ +import { useEffect, useRef } from "react"; +import type { LocaleId } from "../../../../shared/contracts"; +import { UiIcon } from "../../components/UiIcon"; +import { t } from "../../lib/i18n"; + +interface TerminalLinkDialogProps { + locale: LocaleId; + url: string | null; + onClose(): void; + onOpenCanvas(url: string): void; + onOpenExternal(url: string): void; +} + +export function TerminalLinkDialog({ + locale, + url, + onClose, + onOpenCanvas, + onOpenExternal +}: TerminalLinkDialogProps): React.JSX.Element | null { + const canvasButton = useRef(null); + + useEffect(() => { + if (!url) return; + canvasButton.current?.focus({ preventScroll: true }); + const closeOnEscape = (event: KeyboardEvent): void => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", closeOnEscape); + return () => window.removeEventListener("keydown", closeOnEscape); + }, [onClose, url]); + + if (!url) return null; + + return ( +
{ + if (event.target === event.currentTarget) onClose(); + }}> +
+ +
+
+ +

{t(locale, "terminalLinkDescription")}

+ {url} +
+
+ + +
+
+
+ ); +} diff --git a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx index 6bbaf61..ede03e2 100644 --- a/src/renderer/src/features/workspace/WorkspaceCanvas.tsx +++ b/src/renderer/src/features/workspace/WorkspaceCanvas.tsx @@ -114,6 +114,7 @@ interface WorkspaceCanvasProps { onOpenAgent(provider: AgentProviderId, position?: Point): void; onOpenTerminal(position?: Point): void; onOpenBrowser(position?: Point): void; + onOpenTerminalUrl(url: string): void; onFocusSession(session: SessionSnapshot): void; activeSessionId: string | null; browserSelected: boolean; @@ -153,7 +154,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element const { settings, mediaData, sessions, limits, limitsLoadState, plugins, browser, browserViewVisible, homeEditing, camera, onCameraChange, onGoHome, - onOpenSettings, onOpenAgent, onOpenTerminal, onOpenBrowser, onFocusSession, + onOpenSettings, onOpenAgent, onOpenTerminal, onOpenBrowser, onOpenTerminalUrl, onFocusSession, activeSessionId, browserSelected, renamingSessionId, onSelectSession, onSelectBrowser, onClearCanvasSelection, onRenameSession, onRenameEnd, onRequestMedia, onRemoveMedia, onHomeLayoutChange, onHomeGridSizeChange, @@ -534,6 +535,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element onBoundsChange={onSessionBoundsChange} onRestart={onRestartSession} onDispose={onDisposeSession} + onOpenUrl={onOpenTerminalUrl} /> ))} {renderedPluginCanvas.map((instance) => { diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index 9687e5a..b7c0aad 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -42,6 +42,10 @@ const ru = { browserNavigation: "Навигация браузера", browserAddress: "Адрес или поисковый запрос", browserActionFailed: "Действие браузера не выполнено", + terminalLinkTitle: "Открыть ссылку", + terminalLinkDescription: "Где открыть эту ссылку?", + terminalLinkCanvas: "В браузере Canvas", + terminalLinkExternal: "В системном браузере", browserRestartRequired: "Перезапусти CanvasTTY, чтобы включить встроенный браузер", browserAgentAccess: "Доступ агентов", browserAgentAccessDescription: "Разрешает локальным агентам работать с открытыми вкладками. Куки и сохранённые пароли им не передаются.", @@ -459,6 +463,10 @@ const en: Record = { browserNavigation: "Browser navigation", browserAddress: "Address or search query", browserActionFailed: "Browser action failed", + terminalLinkTitle: "Open link", + terminalLinkDescription: "Where would you like to open this link?", + terminalLinkCanvas: "Canvas browser", + terminalLinkExternal: "System browser", browserRestartRequired: "Restart CanvasTTY to enable the built-in browser", browserAgentAccess: "Agent access", browserAgentAccessDescription: "Lets local agents work with open tabs. Cookies and saved passwords are never shared.", diff --git a/src/renderer/src/styles/app.css b/src/renderer/src/styles/app.css index bd65b33..095744f 100644 --- a/src/renderer/src/styles/app.css +++ b/src/renderer/src/styles/app.css @@ -428,6 +428,20 @@ button { border: 0; } .dialog-backdrop, .settings-backdrop { position: fixed; inset: var(--titlebar-height) 0 0; z-index: 6000; background: rgba(29,30,39,.56); backdrop-filter: blur(10px); } .dialog-backdrop { display: grid; place-items: center; padding: 24px; animation: fade-in .16s ease both; } @keyframes fade-in { from { opacity: 0; } } +.terminal-link-dialog__backdrop { z-index: 6100; } +.terminal-link-dialog { position: relative; width: min(520px, 100%); padding: 28px; display: grid; grid-template-columns: 52px minmax(0, 1fr); gap: 16px; border: 1px solid color-mix(in srgb, var(--text) 11%, transparent); border-radius: var(--radius-xl); color: var(--text); background: var(--surface); box-shadow: var(--shadow-lg); animation: dialog-in .2s cubic-bezier(.2,.8,.2,1) both; } +.terminal-link-dialog__close { position: absolute; top: 14px; right: 14px; width: 34px; height: 34px; display: grid; place-items: center; border-radius: 10px; color: var(--text-muted-on-dark); background: var(--surface-soft); cursor: pointer; } +.terminal-link-dialog__close:hover, .terminal-link-dialog__close:focus-visible { color: var(--text); box-shadow: inset 0 0 0 2px var(--secondary); } +.terminal-link-dialog__icon { width: 52px; height: 52px; display: grid; place-items: center; border-radius: 15px; color: var(--text-dark); background: var(--primary); } +.terminal-link-dialog__copy { min-width: 0; padding-right: 34px; } +.terminal-link-dialog__copy h2 { margin: 1px 0 5px; font-size: 21px; } +.terminal-link-dialog__copy p { margin: 0 0 12px; color: var(--text-muted-on-dark); font-size: 12px; } +.terminal-link-dialog__copy code { display: block; padding: 10px 12px; overflow: hidden; border-radius: 9px; color: var(--text); background: var(--surface-soft); font: 11px/1.4 var(--font-mono); text-overflow: ellipsis; white-space: nowrap; user-select: text; } +.terminal-link-dialog__actions { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } +.terminal-link-dialog__actions button { min-height: 54px; padding: 0 14px; display: flex; align-items: center; justify-content: center; gap: 9px; border-radius: 13px; outline: 0; color: var(--text); background: var(--surface-soft); cursor: pointer; font-weight: 800; } +.terminal-link-dialog__actions button:first-child { color: var(--text-dark); background: var(--primary); } +.terminal-link-dialog__actions button:hover, .terminal-link-dialog__actions button:focus-visible { box-shadow: inset 0 0 0 2px var(--secondary); transform: translateY(-1px); } +@media (max-width: 560px) { .terminal-link-dialog__actions { grid-template-columns: 1fr; } } .launch-dialog { position: relative; width: min(780px, 100%); padding: 28px; border-radius: var(--radius-xl); color: var(--text-dark); background: var(--primary); box-shadow: var(--shadow-lg); animation: dialog-in .2s cubic-bezier(.2,.8,.2,1) both; } @keyframes dialog-in { from { opacity: 0; transform: translateY(10px) scale(.98); } } .dialog-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin-bottom: 17px; } diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index a154a43..5ff02c9 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -858,6 +858,9 @@ export interface CanvasTTYApi { readText(): Promise; writeText(text: string): void; }; + external: { + openUrl(url: string): Promise; + }; settings: { get(): Promise; update(patch: Partial): Promise; @@ -976,6 +979,7 @@ export interface CanvasTTYApi { export const IPC = { clipboardRead: "clipboard:read", clipboardWrite: "clipboard:write", + externalOpenUrl: "external:open-url", settingsGet: "settings:get", settingsUpdate: "settings:update", dialogPickDirectory: "dialog:pick-directory", diff --git a/src/shared/externalUrl.ts b/src/shared/externalUrl.ts new file mode 100644 index 0000000..273b663 --- /dev/null +++ b/src/shared/externalUrl.ts @@ -0,0 +1,19 @@ +const MAX_EXTERNAL_URL_LENGTH = 2_048; + +export function normalizeExternalUrl(value: unknown): string { + if (typeof value !== "string" || value.length > MAX_EXTERNAL_URL_LENGTH) { + throw new Error("External URL is invalid."); + } + + let url: URL; + try { + url = new URL(value); + } catch { + throw new Error("External URL is invalid."); + } + + if ((url.protocol !== "https:" && url.protocol !== "http:") || url.username || url.password) { + throw new Error("Only non-credentialed HTTP(S) URLs may be opened externally."); + } + return url.toString(); +} diff --git a/tests/terminal-links.test.mjs b/tests/terminal-links.test.mjs new file mode 100644 index 0000000..95a1aea --- /dev/null +++ b/tests/terminal-links.test.mjs @@ -0,0 +1,59 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; +import { normalizeExternalUrl } from "../src/shared/externalUrl.ts"; + +const terminalCardPath = new URL("../src/renderer/src/features/terminal/TerminalCard.tsx", import.meta.url); +const dialogPath = new URL("../src/renderer/src/features/terminal/TerminalLinkDialog.tsx", import.meta.url); +const workspacePath = new URL("../src/renderer/src/features/workspace/WorkspaceCanvas.tsx", import.meta.url); +const appPath = new URL("../src/renderer/src/App.tsx", import.meta.url); +const contractsPath = new URL("../src/shared/contracts.ts", import.meta.url); +const preloadPath = new URL("../src/preload/index.ts", import.meta.url); +const ipcPath = new URL("../src/main/ipc/registerIpc.ts", import.meta.url); + +test("terminal HTTP(S) links open a Canvas or system-browser chooser", async () => { + const [terminal, dialog, workspace, app] = await Promise.all([ + readFile(terminalCardPath, "utf8"), + readFile(dialogPath, "utf8"), + readFile(workspacePath, "utf8"), + readFile(appPath, "utf8") + ]); + + assert.match(terminal, /new WebLinksAddon/); + assert.match(terminal, /onOpenUrlRef\.current\(uri\)/); + assert.match(terminal, /linkHandler:\s*\{[\s\S]*?activate:\s*\(event, uri\)[\s\S]*?onOpenUrlRef\.current\(uri\)/); + assert.match(workspace, /onOpenUrl=\{onOpenTerminalUrl\}/); + assert.match(app, /onOpenTerminalUrl=\{\(url\) => \{[\s\S]*?normalizeExternalUrl\(url\)[\s\S]*?showToast/); + assert.match(dialog, /onOpenCanvas\(url\)/); + assert.match(dialog, /onOpenExternal\(url\)/); + assert.match(app, / { + const [contracts, preload, ipc] = await Promise.all([ + readFile(contractsPath, "utf8"), + readFile(preloadPath, "utf8"), + readFile(ipcPath, "utf8") + ]); + + assert.match(contracts, /externalOpenUrl: "external:open-url"/); + assert.match(preload, /openUrl: \(url: string\) => ipcRenderer\.invoke\(IPC\.externalOpenUrl, url\)/); + const handler = ipc.slice(ipc.indexOf("IPC.externalOpenUrl"), ipc.indexOf("IPC.externalOpenUrl") + 260); + assert.match(handler, /assertMainRenderer\(event, getMainWindow\)/); + assert.match(handler, /normalizeExternalUrl\(value\)/); + + assert.equal(normalizeExternalUrl("http://localhost:5173"), "http://localhost:5173/"); + assert.equal(normalizeExternalUrl("https://example.com/path?q=1"), "https://example.com/path?q=1"); + for (const value of [ + "file:///tmp/index.html", + "javascript:alert(1)", + "https://user:secret@example.com", + "not a url", + `https://example.com/${"x".repeat(2_100)}` + ]) { + assert.throws(() => normalizeExternalUrl(value), /External URL|HTTP\(S\)/); + } +});