Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 7 additions & 11 deletions src/main/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppSettings>) => ipcRenderer.invoke(IPC.settingsUpdate, patch)
Expand Down
29 changes: 28 additions & 1 deletion src/renderer/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,13 +33,15 @@ 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";
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";
Expand Down Expand Up @@ -188,6 +190,7 @@ export function App(): React.JSX.Element {
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [browserSelected, setBrowserSelected] = useState(false);
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
const [pendingTerminalUrl, setPendingTerminalUrl] = useState<string | null>(null);
const [toast, setToast] = useState<string | null>(null);
const [ready, setReady] = useState(false);
const [windowState, setWindowState] = useState<WindowState>({
Expand Down Expand Up @@ -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}
Expand All @@ -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}
Expand Down Expand Up @@ -1043,6 +1053,23 @@ export function App(): React.JSX.Element {
onAcknowledge={acknowledgeDanger}
onLaunch={launchAgent}
/>
<TerminalLinkDialog
locale={settings.locale}
url={pendingTerminalUrl}
onClose={() => 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"));
});
}}
/>
<SettingsPanel
open={settingsOpen}
settings={settings}
Expand Down
25 changes: 23 additions & 2 deletions src/renderer/src/features/terminal/TerminalCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { FitAddon } from "@xterm/addon-fit";
import { WebLinksAddon } from "@xterm/addon-web-links";
import { Terminal } from "@xterm/xterm";
import {
INITIAL_TERMINAL_COLS,
Expand Down Expand Up @@ -58,6 +59,7 @@ interface TerminalCardProps {
onBoundsChange(id: string, bounds: SessionBounds): void;
onRestart(id: string): Promise<void>;
onDispose(id: string): void;
onOpenUrl(url: string): void;
}

interface DragState {
Expand Down Expand Up @@ -95,10 +97,13 @@ export function TerminalCard({
onRenameEnd,
onBoundsChange,
onRestart,
onDispose
onDispose,
onOpenUrl
}: TerminalCardProps): React.JSX.Element {
const terminalHost = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
const onOpenUrlRef = useRef(onOpenUrl);
onOpenUrlRef.current = onOpenUrl;
const renameInput = useRef<HTMLInputElement>(null);
const renameInFlight = useRef(false);
const suppressFocusReport = useRef(false);
Expand Down Expand Up @@ -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 => {
Expand Down
62 changes: 62 additions & 0 deletions src/renderer/src/features/terminal/TerminalLinkDialog.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLButtonElement>(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 (
<div className="dialog-backdrop terminal-link-dialog__backdrop" role="presentation" onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}>
<section className="terminal-link-dialog" role="dialog" aria-modal="true" aria-labelledby="terminal-link-dialog-title">
<button className="terminal-link-dialog__close" type="button" onClick={onClose} aria-label={t(locale, "close")}>
<UiIcon name="close" size={18} />
</button>
<div className="terminal-link-dialog__icon"><UiIcon name="browser" size={28} /></div>
<div className="terminal-link-dialog__copy">
<h2 id="terminal-link-dialog-title">{t(locale, "terminalLinkTitle")}</h2>
<p>{t(locale, "terminalLinkDescription")}</p>
<code title={url}>{url}</code>
</div>
<div className="terminal-link-dialog__actions">
<button ref={canvasButton} type="button" onClick={() => onOpenCanvas(url)}>
<UiIcon name="app-window" size={19} />
{t(locale, "terminalLinkCanvas")}
</button>
<button type="button" onClick={() => onOpenExternal(url)}>
<UiIcon name="browser" size={19} />
{t(locale, "terminalLinkExternal")}
</button>
</div>
</section>
</div>
);
}
4 changes: 3 additions & 1 deletion src/renderer/src/features/workspace/WorkspaceCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -534,6 +535,7 @@ export function WorkspaceCanvas(props: WorkspaceCanvasProps): React.JSX.Element
onBoundsChange={onSessionBoundsChange}
onRestart={onRestartSession}
onDispose={onDisposeSession}
onOpenUrl={onOpenTerminalUrl}
/>
))}
{renderedPluginCanvas.map((instance) => {
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/src/lib/i18n.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@ const ru = {
browserNavigation: "Навигация браузера",
browserAddress: "Адрес или поисковый запрос",
browserActionFailed: "Действие браузера не выполнено",
terminalLinkTitle: "Открыть ссылку",
terminalLinkDescription: "Где открыть эту ссылку?",
terminalLinkCanvas: "В браузере Canvas",
terminalLinkExternal: "В системном браузере",
browserRestartRequired: "Перезапусти CanvasTTY, чтобы включить встроенный браузер",
browserAgentAccess: "Доступ агентов",
browserAgentAccessDescription: "Разрешает локальным агентам работать с открытыми вкладками. Куки и сохранённые пароли им не передаются.",
Expand Down Expand Up @@ -459,6 +463,10 @@ const en: Record<keyof typeof ru, string> = {
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.",
Expand Down
14 changes: 14 additions & 0 deletions src/renderer/src/styles/app.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/shared/contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -858,6 +858,9 @@ export interface CanvasTTYApi {
readText(): Promise<string>;
writeText(text: string): void;
};
external: {
openUrl(url: string): Promise<void>;
};
settings: {
get(): Promise<AppSettings>;
update(patch: Partial<AppSettings>): Promise<AppSettings>;
Expand Down Expand Up @@ -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",
Expand Down
19 changes: 19 additions & 0 deletions src/shared/externalUrl.ts
Original file line number Diff line number Diff line change
@@ -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();
}
Loading