Compare commits

...
Author SHA1 Message Date
bincxz 31700d6847 style(browser): encode glyphs in ASCII-safe source 2026-10-17 02:04:40 +08:00
bincxz 7d3cd14e0b fix(browser): skip unsupported sessions in open requests 2026-10-17 01:55:33 +08:00
bincxz a1d0397ab7 fix(browser): enforce WebRTC routing on page contents 2026-10-17 01:35:02 +08:00
bincxz 1252ec3835 fix(browser): release contexts after workspace detach 2026-10-17 01:26:03 +08:00
bincxz 5fbfdc749f fix(browser): keep vault browser requests on visible sessions 2026-10-17 01:18:13 +08:00
bincxz b0e6cdbeb9 fix(browser): share download controls across host contexts 2026-10-17 01:16:27 +08:00
bincxz ed94b4173c fix(browser): align vault actions with supported hosts 2026-10-17 01:10:20 +08:00
bincxz 7e50cc3218 fix(browser): retain open requests before the panel mounts 2026-10-17 01:04:43 +08:00
bincxz ca37599be7 fix(browser): expose actions for supported terminal types 2026-10-17 00:56:17 +08:00
bincxz 294a9aca69 fix(browser): preserve empty-vault recovery with bookmarks 2026-10-17 00:48:54 +08:00
bincxz 252d2026e0 fix(browser): mount browser content on terminal auto-open 2026-10-17 00:43:18 +08:00
bincxz e8b7a032e9 fix(browser): ignore late downloads after panel closure 2026-10-17 00:37:51 +08:00
bincxz eca4577dbd style(browser): remove the downloads panel shadow 2026-10-17 00:30:07 +08:00
bincxz 259c1970f0 fix(browser): release contexts when their split pane closes 2026-10-17 00:27:26 +08:00
bincxz 660d8483f5 fix(browser): retain the first terminal link during panel mount replay 2026-10-17 00:15:00 +08:00
bincxz ee46f36088 feat(browser): open terminal links internally with a system browser preference 2026-10-16 23:56:50 +08:00
bincxz 249211d893 style(browser): blend compact browser chrome into terminal panels 2026-10-16 23:43:58 +08:00
bincxz aedf99794c feat(browser): embed host browsing in terminal side panels 2026-10-16 23:36:43 +08:00
bincxz 059586c840 fix(browser): close malformed proxy connections without a reply 2026-10-16 23:09:12 +08:00
bincxzandCodex 05cffda7be Codex worktree snapshot: startup-cleanup
Co-authored-by: Codex
2026-10-16 23:03:35 +08:00
90 changed files with 4225 additions and 62 deletions

No files matched your search

+3
View File
@@ -52,6 +52,9 @@ jobs:
- name: Test
run: npm test
- name: Test browser first-link lifecycle
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
- name: Test terminal background rendering
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox --use-angle=swiftshader --enable-unsafe-swiftshader scripts/terminal-background-rendering.live.test.cjs
+78
View File
@@ -0,0 +1,78 @@
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../domain/models';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { startPortForward } from '../infrastructure/services/portForwardingService';
import { requestHostBrowserPanel } from './state/hostBrowserPanelRequests';
import { isValidHslToken } from './state/settingsStateDefaults';
interface OpenHostBrowserOptions {
host: Host;
sessionId?: string;
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts?: KnownHost[];
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>;
}
const pendingOpens = new Map<string, Promise<void>>();
const browserThemeTokens = ['background', 'foreground', 'secondary', 'muted', 'muted-foreground', 'border', 'primary', 'primary-foreground'] as const;
export function currentBrowserThemeTokens(element: Element = document.documentElement): Record<string, string> {
const style = getComputedStyle(element);
return Object.fromEntries(browserThemeTokens.flatMap((token) => {
const value = style.getPropertyValue(`--${token}`).trim();
return isValidHslToken(value) ? [[token, value]] : [];
}));
}
export async function openHostBrowser(options: OpenHostBrowserOptions): Promise<void> {
requestHostBrowserPanel({ host: options.host, sessionId: options.sessionId });
}
export async function openHostBrowserPanelUrl(browserId: string, url: string): Promise<void> {
const bridge = netcattyBridge.get();
if (!bridge?.openHostBrowserUrl) throw new Error('The host browser is unavailable in this build');
await bridge.openHostBrowserUrl({ browserId, url });
}
export function mountHostBrowser(options: OpenHostBrowserOptions & { browserId: string }): Promise<void> {
const { host, hosts, keys, identities, knownHosts, terminalSettings } = options;
const existing = pendingOpens.get(options.browserId);
if (existing) return existing;
const opening = (async () => {
const bridge = netcattyBridge.get();
if (!bridge?.setHostBrowserPanelLayout || !bridge.openHostBrowser || !bridge.startPortForward) {
throw new Error('The host browser is unavailable in this build');
}
const local = host.protocol === 'local';
const result = local ? { success: true, connectionOptions: undefined } : await startPortForward({
id: `host-browser-${crypto.randomUUID()}`,
label: `Browser: ${host.label || host.hostname}`,
type: 'dynamic',
hostId: host.id,
localPort: 0,
bindAddress: '127.0.0.1',
status: 'inactive',
createdAt: Date.now(),
}, host, hosts, keys, identities, () => {}, false, terminalSettings,
knownHosts, { dedicatedTransport: true, prepareOnly: true });
if (!result.success || (!local && !result.connectionOptions)) {
throw new Error(result.error || 'Could not prepare the browser connection');
}
await bridge.openHostBrowser({
browserId: options.browserId,
embedded: true,
local,
hostId: host.id,
label: host.label || host.hostname,
theme: document.documentElement.classList.contains('dark') ? 'dark' : 'light',
themeTokens: currentBrowserThemeTokens(),
connectionOptions: result.connectionOptions,
});
})();
pendingOpens.set(options.browserId, opening);
void opening.finally(() => {
if (pendingOpens.get(options.browserId) === opening) pendingOpens.delete(options.browserId);
}).catch(() => {});
return opening;
}
+23
View File
@@ -0,0 +1,23 @@
import type { BrowserBookmark } from '../domain/browserBookmark';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
let cachedBookmarks: BrowserBookmark[] | undefined;
export function getCachedHostBrowserBookmarks(): BrowserBookmark[] | undefined {
return cachedBookmarks?.map((item) => ({ ...item }));
}
export async function collectHostBrowserBookmarks(): Promise<BrowserBookmark[] | undefined> {
const reader = netcattyBridge.get()?.getHostBrowserBookmarks;
if (!reader) return cachedBookmarks;
const items = await reader();
cachedBookmarks = items;
return getCachedHostBrowserBookmarks();
}
export async function applyHostBrowserBookmarks(items: BrowserBookmark[]): Promise<void> {
const writer = netcattyBridge.get()?.applyHostBrowserBookmarks;
if (!writer) return;
await writer(items);
cachedBookmarks = items;
}
+5
View File
@@ -767,6 +767,11 @@ Highlight the focused split pane:
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
'settings.terminal.behavior.smoothScrolling.desc':
'Animate terminal viewport scrolling instead of jumping instantly',
'settings.terminal.sidePanel.pane.browser': 'Browser',
'settings.terminal.behavior.linkBrowser': 'Open terminal links in',
'settings.terminal.behavior.linkBrowser.desc': 'SSH links use the connected host; local terminal links use this computer.',
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty browser (default)',
'settings.terminal.behavior.linkBrowser.system': 'System default browser',
'settings.terminal.behavior.linkModifier': 'Link modifier key',
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
+4
View File
@@ -20,6 +20,9 @@ export const enTerminalMessages: Messages = {
'terminal.networkDevice.tip.enabled': 'Network Device Mode enabled for {host}',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Open SFTP',
'terminal.browser.title': 'Browser',
'terminal.browser.sshOnly': 'Focus an SSH terminal to open its browser.',
'terminal.browser.open': 'Open Browser',
'terminal.toolbar.availableAfterConnect': 'Available after connect',
'terminal.toolbar.sendYmodem': 'Send with YMODEM',
'terminal.toolbar.receiveYmodem': 'Receive with YMODEM',
@@ -531,6 +534,7 @@ export const enTerminalMessages: Messages = {
'cloudSync.conflict.entity.customGroups': 'Groups',
'cloudSync.conflict.entity.snippetPackages': 'Snippet packages',
'cloudSync.conflict.entity.portForwardingRules': 'Port forwarding',
'cloudSync.conflict.entity.browserBookmarks': 'Browser bookmarks',
'cloudSync.conflict.entity.groupConfigs': 'Group settings',
'cloudSync.conflict.entity.settings': 'Settings',
'cloudSync.conflict.keepLocal': 'Overwrite cloud (keep local)',
+5
View File
@@ -682,6 +682,11 @@ Resalta el panel dividido enfocado:
'settings.terminal.behavior.smoothScrolling': 'Desplazamiento suave',
'settings.terminal.behavior.smoothScrolling.desc':
'Anima el desplazamiento de la vista de la terminal en lugar de saltar instantáneamente',
'settings.terminal.sidePanel.pane.browser': 'Navegador',
'settings.terminal.behavior.linkBrowser': 'Abrir enlaces de terminal en',
'settings.terminal.behavior.linkBrowser.desc': 'Los enlaces SSH usan el host conectado; los enlaces locales usan este equipo.',
'settings.terminal.behavior.linkBrowser.netcatty': 'Navegador Netcatty (predeterminado)',
'settings.terminal.behavior.linkBrowser.system': 'Navegador del sistema',
'settings.terminal.behavior.linkModifier': 'Tecla modificadora de enlaces',
'settings.terminal.behavior.linkModifier.desc': 'Mantén presionada esta tecla para hacer clic en enlaces de la terminal',
'settings.terminal.behavior.linkModifier.none': 'Ninguna (clic directo)',
+4
View File
@@ -20,6 +20,9 @@ export const esTerminalMessages: Messages = {
'terminal.networkDevice.tip.enabled': 'Modo de Dispositivo de Red activado para {host}',
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Abrir SFTP',
'terminal.browser.title': 'Navegador',
'terminal.browser.sshOnly': 'Selecciona una terminal SSH para abrir su navegador.',
'terminal.browser.open': 'Abrir navegador',
'terminal.toolbar.availableAfterConnect': 'Disponible después de conectar',
'terminal.toolbar.sendYmodem': 'Enviar con YMODEM',
'terminal.toolbar.receiveYmodem': 'Recibir con YMODEM',
@@ -530,6 +533,7 @@ export const esTerminalMessages: Messages = {
'cloudSync.conflict.entity.customGroups': 'Grupos',
'cloudSync.conflict.entity.snippetPackages': 'Paquetes de snippets',
'cloudSync.conflict.entity.portForwardingRules': 'Reenvío de puertos',
'cloudSync.conflict.entity.browserBookmarks': 'Marcadores del navegador',
'cloudSync.conflict.entity.groupConfigs': 'Configuración de grupos',
'cloudSync.conflict.entity.settings': 'Configuración',
'cloudSync.conflict.keepLocal': 'Sobrescribir la nube (conservar local)',
+5
View File
@@ -718,6 +718,11 @@ export const ruCoreMessages: Messages = {
'settings.terminal.behavior.smoothScrolling': 'Плавная прокрутка',
'settings.terminal.behavior.smoothScrolling.desc':
'Анимировать прокрутку области терминала вместо мгновенного перехода',
'settings.terminal.sidePanel.pane.browser': 'Браузер',
'settings.terminal.behavior.linkBrowser': 'Открывать ссылки терминала в',
'settings.terminal.behavior.linkBrowser.desc': 'SSH-ссылки используют подключённый хост; локальные ссылки — этот компьютер.',
'settings.terminal.behavior.linkBrowser.netcatty': 'Браузер Netcatty (по умолчанию)',
'settings.terminal.behavior.linkBrowser.system': 'Системный браузер',
'settings.terminal.behavior.linkModifier': 'Клавиша-модификатор для ссылок',
'settings.terminal.behavior.linkModifier.desc': 'Удерживайте эту клавишу, чтобы нажимать на ссылки в терминале',
'settings.terminal.behavior.linkModifier.none': 'Нет (нажимать напрямую)',
+4
View File
@@ -41,6 +41,9 @@ export const ruTerminalMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': 'Открыть SFTP',
'terminal.browser.title': 'Браузер',
'terminal.browser.sshOnly': 'Выберите SSH-терминал, чтобы открыть его браузер.',
'terminal.browser.open': 'Открыть браузер',
'terminal.toolbar.availableAfterConnect': 'Доступно после подключения',
'terminal.toolbar.sendYmodem': 'Отправить через YMODEM',
'terminal.toolbar.receiveYmodem': 'Получить через YMODEM',
@@ -549,6 +552,7 @@ export const ruTerminalMessages: Messages = {
'cloudSync.conflict.entity.customGroups': 'Группы',
'cloudSync.conflict.entity.snippetPackages': 'Пакеты сниппетов',
'cloudSync.conflict.entity.portForwardingRules': 'Проброс портов',
'cloudSync.conflict.entity.browserBookmarks': 'Закладки браузера',
'cloudSync.conflict.entity.groupConfigs': 'Настройки групп',
'cloudSync.conflict.entity.settings': 'Настройки',
'cloudSync.conflict.keepLocal': 'Перезаписать облако (сохранить локальную)',
@@ -403,6 +403,11 @@ export const zhCNTerminalMessages: Messages = {
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
'settings.terminal.sidePanel.pane.browser': '浏览器',
'settings.terminal.behavior.linkBrowser': '终端链接打开方式',
'settings.terminal.behavior.linkBrowser.desc': 'SSH 终端通过对应主机访问,本地终端通过本机访问。',
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 内置浏览器(默认)',
'settings.terminal.behavior.linkBrowser.system': '系统默认浏览器',
'settings.terminal.behavior.linkModifier': '链接修饰键',
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
+4
View File
@@ -468,6 +468,9 @@ export const zhCNVaultMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '打开 SFTP',
'terminal.browser.title': '浏览器',
'terminal.browser.sshOnly': '请聚焦 SSH 终端以打开浏览器。',
'terminal.browser.open': '打开浏览器',
'terminal.toolbar.availableAfterConnect': '连接后可用',
'terminal.toolbar.sendYmodem': 'YMODEM 发送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
@@ -933,6 +936,7 @@ export const zhCNVaultMessages: Messages = {
'cloudSync.conflict.entity.customGroups': '分组',
'cloudSync.conflict.entity.snippetPackages': '片段包',
'cloudSync.conflict.entity.portForwardingRules': '端口转发',
'cloudSync.conflict.entity.browserBookmarks': '浏览器书签',
'cloudSync.conflict.entity.groupConfigs': '分组设置',
'cloudSync.conflict.entity.settings': '设置',
'cloudSync.conflict.keepLocal': '覆盖云端(保留本地)',
@@ -403,6 +403,11 @@ export const zhTWTerminalMessages: Messages = {
'settings.terminal.behavior.scrollOnPaste.desc': '貼上文字時將終端捲動到底部',
'settings.terminal.behavior.smoothScrolling': '平滑捲動',
'settings.terminal.behavior.smoothScrolling.desc': '捲動終端可視區域時使用平滑動畫',
'settings.terminal.sidePanel.pane.browser': '瀏覽器',
'settings.terminal.behavior.linkBrowser': '終端連結開啟方式',
'settings.terminal.behavior.linkBrowser.desc': 'SSH 終端透過對應主機存取,本機終端透過本機存取。',
'settings.terminal.behavior.linkBrowser.netcatty': 'Netcatty 內建瀏覽器(預設)',
'settings.terminal.behavior.linkBrowser.system': '系統預設瀏覽器',
'settings.terminal.behavior.linkModifier': '連結修飾鍵',
'settings.terminal.behavior.linkModifier.desc': '按住此鍵再點選終端中的連結',
'settings.terminal.behavior.linkModifier.none': '無(直接點選)',
+4
View File
@@ -468,6 +468,9 @@ export const zhTWVaultMessages: Messages = {
// Terminal toolbar / search / context menu / auth
'terminal.toolbar.openSftp': '開啟 SFTP',
'terminal.browser.title': '瀏覽器',
'terminal.browser.sshOnly': '請聚焦 SSH 終端以開啟瀏覽器。',
'terminal.browser.open': '開啟瀏覽器',
'terminal.toolbar.availableAfterConnect': '連線後可用',
'terminal.toolbar.sendYmodem': 'YMODEM 傳送',
'terminal.toolbar.receiveYmodem': 'YMODEM 接收',
@@ -933,6 +936,7 @@ export const zhTWVaultMessages: Messages = {
'cloudSync.conflict.entity.customGroups': '群組',
'cloudSync.conflict.entity.snippetPackages': '片段包',
'cloudSync.conflict.entity.portForwardingRules': '埠轉發',
'cloudSync.conflict.entity.browserBookmarks': '瀏覽器書籤',
'cloudSync.conflict.entity.groupConfigs': '群組設定',
'cloudSync.conflict.entity.settings': '設定',
'cloudSync.conflict.keepLocal': '覆蓋雲端(保留本機)',
@@ -0,0 +1,36 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { requestHostBrowserPanel, subscribeHostBrowserPanelRequests, type HostBrowserPanelRequest } from './hostBrowserPanelRequests';
test('detached terminal requests reach the main window instead of being dropped', async () => {
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
const forwarded: HostBrowserPanelRequest[] = [];
const delivered: HostBrowserPanelRequest[] = [];
let nativeListener: ((request: HostBrowserPanelRequest) => void) | undefined;
let removed = false;
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
requestHostBrowserPanel: async (request: HostBrowserPanelRequest) => { forwarded.push(request); return true; },
onHostBrowserPanelRequest: (listener: typeof nativeListener) => {
nativeListener = listener;
return () => { removed = true; nativeListener = undefined; };
},
} } });
let unsubscribe: (() => void) | undefined;
try {
const request = { host: { id: 'host', hostname: 'host', label: 'Host', username: 'test' }, url: 'http://localhost:3000/' } as HostBrowserPanelRequest;
requestHostBrowserPanel(request);
await Promise.resolve();
assert.deepEqual(forwarded, [request]);
unsubscribe = subscribeHostBrowserPanelRequests((value) => delivered.push(value));
assert.ok(nativeListener);
nativeListener(request);
assert.deepEqual(delivered, [request]);
assert.equal(forwarded.length, 1, 'main-window consumption must not forward the request again');
unsubscribe(); unsubscribe = undefined;
assert.equal(removed, true);
} finally {
unsubscribe?.();
if (previous) Object.defineProperty(globalThis, 'window', previous);
else Reflect.deleteProperty(globalThis, 'window');
}
});
@@ -0,0 +1,35 @@
import type { Host } from '../../domain/models';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export type HostBrowserPanelRequest = { host: Host; sessionId?: string; url?: string };
type Listener = (request: HostBrowserPanelRequest) => void;
const listeners = new Set<Listener>();
let pending: HostBrowserPanelRequest | null = null;
let unsubscribeNative: (() => void) | undefined;
export function requestHostBrowserPanel(request: HostBrowserPanelRequest): void {
if (!listeners.size) {
const forward = netcattyBridge.get()?.requestHostBrowserPanel;
if (forward) {
void forward(request).catch(() => { pending = request; });
} else pending = request;
return;
}
for (const listener of listeners) listener(request);
}
export function subscribeHostBrowserPanelRequests(listener: Listener): () => void {
listeners.add(listener);
if (!unsubscribeNative) unsubscribeNative = netcattyBridge.get()?.onHostBrowserPanelRequest?.((request) => {
for (const current of listeners) current(request);
});
if (pending) {
const request = pending;
pending = null;
listener(request);
}
return () => {
listeners.delete(listener);
if (!listeners.size) { unsubscribeNative?.(); unsubscribeNative = undefined; }
};
}
@@ -5,6 +5,8 @@ import vm from 'node:vm';
import React, { useCallback, useRef, useSyncExternalStore } from 'react';
import { act, create } from 'react-test-renderer';
import ts from 'typescript';
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
import { collectSessionIds } from '../../domain/workspace';
import { getSidePanelLiveSnapshot, subscribeSidePanelLiveSnapshot, sidePanelLiveStore, SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT, type SidePanelLiveSnapshot } from './sidePanelLiveStore';
// Exercise the production hook without importing the unrelated panel UI modules.
@@ -20,6 +22,60 @@ const useSnapshot = vm.runInNewContext(`${code}; useSidePanelLiveSnapshotForTab`
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
test('workspace browser follows focus without replacing the earlier terminal context', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
const browserSlot = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === 'SidePanelBrowserSlot');
assert.ok(browserSlot);
const slotCode = ts.transpile(browserSlot.getText(ast), { target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React });
const Slot = vm.runInNewContext(`${slotCode}; SidePanelBrowserSlot`, {
React, useRef, useSidePanelLiveSnapshotForTab: useSnapshot, resolveSystemSidebarSession, collectSessionIds,
sidePanelHiddenPanelClassName: (hidden: boolean) => hidden ? 'hidden' : 'visible',
HostBrowserSidePanel: (props: object) => React.createElement('div', { ...props, 'data-browser-context': true }),
}) as React.ComponentType<{ tabId: string; ctx: object; isVisible: boolean }>;
const sessionA = { id: 'session-a', hostId: 'host-a', protocol: 'ssh' };
const sessionB = { id: 'session-b', hostId: 'host-b', protocol: 'ssh' };
const workspace = { id: 'workspace', focusedSessionId: sessionA.id, root: {
id: 'split', type: 'split', direction: 'vertical', sizes: [0.5, 0.5], children: [
{ id: 'a', type: 'pane', sessionId: sessionA.id }, { id: 'b', type: 'pane', sessionId: sessionB.id },
],
} } as NonNullable<SidePanelLiveSnapshot['activeWorkspace']>;
const ctx = { sessions: [sessionA, sessionB], workspaceById: new Map([['workspace', workspace]]),
sessionHostsMap: new Map([['session-a', { id: 'host-a', hostname: 'a' }], ['session-b', { id: 'host-b', hostname: 'b' }]]),
sftpAuthHostBySessionId: new Map(), effectiveHosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {}, t: (key: string) => key };
const publish = (session: typeof sessionA) => sidePanelLiveStore.update({ ...SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT,
activeWorkspace: workspace, focusedSessionId: session.id,
activeTerminalSessionForSystem: session as SidePanelLiveSnapshot['activeTerminalSessionForSystem'] });
let renderer: ReturnType<typeof create> | undefined;
try {
publish(sessionA);
await act(async () => { renderer = create(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: true })); });
const contexts = () => renderer!.root.findAll((node) => node.type === 'div' && node.props['data-browser-context'] === true);
const first = contexts()[0];
assert.equal(first.props.host.id, 'host-a');
await act(async () => publish(sessionB));
assert.equal(contexts().length, 2);
assert.equal(contexts()[0], first, 'the first terminal keeps the same browser mount');
assert.equal(first.props.isVisible, false);
assert.equal(contexts()[1].props.host.id, 'host-b');
assert.equal(contexts()[1].props.isVisible, true);
await act(async () => publish(sessionA));
assert.equal(first.props.isVisible, true);
assert.equal(contexts()[1].props.isVisible, false);
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
assert.ok(contexts().every((context) => !context.props.isVisible), 'hidden tabs hide every native browser context');
ctx.workspaceById.set('workspace', { ...workspace, focusedSessionId: sessionB.id,
root: { id: 'b', type: 'pane', sessionId: sessionB.id } });
await act(async () => renderer!.update(React.createElement(Slot, { tabId: 'workspace', ctx, isVisible: false })));
assert.equal(contexts().length, 1, 'a detached session must leave the old workspace even while it still exists globally');
assert.equal(contexts()[0].props.host.id, 'host-b', 'the remaining workspace browser stays mounted');
} finally {
await act(async () => renderer?.unmount());
sidePanelLiveStore.update(SIDE_PANEL_INACTIVE_LIVE_SNAPSHOT);
env.IS_REACT_ACT_ENVIRONMENT = previous;
}
});
test('returning to a tab never exposes the previous tab snapshot before its publisher catches up', async () => {
const previous = env.IS_REACT_ACT_ENVIRONMENT;
env.IS_REACT_ACT_ENVIRONMENT = true;
@@ -19,6 +19,7 @@ export type TerminalSidePanelTabId = SidePanelTool;
export const TERMINAL_SIDE_PANEL_TAB_DEFAULT_ORDER: TerminalSidePanelTabId[] = [
'sftp',
'browser',
'scripts',
'history',
'theme',
+13
View File
@@ -55,6 +55,7 @@ import {
} from './autoSyncRemoteSchedule';
import { resolveAutoSyncHashDecision } from './autoSyncHashDecision';
import { getNotesSnapshot, subscribeNotes } from './notesStore';
import { collectHostBrowserBookmarks } from '../hostBrowserBookmarks';
/** Prefer dedicated SYNC_PREFERENCES; fall back to legacy SYNC_CONFIG fields. */
function isPersistedAutoSyncEnabled(fallback: boolean): boolean {
@@ -160,6 +161,7 @@ const getSyncPayloadDataHash = (payload: SyncPayload): string => {
notes: payload.notes,
noteGroups: payload.noteGroups,
portForwardingRules: sanitizePortForwardingRulesForSync(payload.portForwardingRules),
browserBookmarks: payload.browserBookmarks,
groupConfigs: payload.groupConfigs,
settings: payload.settings,
pluginSidecars: payload.pluginSidecars,
@@ -224,6 +226,13 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// Listen for SFTP bookmark changes to trigger auto-sync
const [bookmarksVersion, setBookmarksVersion] = useState(0);
const [hostBrowserBookmarksVersion, setHostBrowserBookmarksVersion] = useState(0);
useEffect(() => {
const unsubscribe = netcattyBridge.get()?.onHostBrowserBookmarksChanged?.(() => {
setHostBrowserBookmarksVersion((version) => version + 1);
});
return () => { unsubscribe?.(); };
}, []);
useEffect(() => {
const handler = () => setBookmarksVersion((v) => v + 1);
window.addEventListener('sftp-bookmarks-changed', handler);
@@ -309,9 +318,11 @@ export const useAutoSync = (config: AutoSyncConfig) => {
const { withPluginSyncSidecars } = await import('../syncPayload');
const { collectPluginSyncSidecarsFromHost } = await import('../pluginSyncSidecarBridge');
const aiSessions = collectAiSessionsForSync();
const browserBookmarks = await collectHostBrowserBookmarks();
const base: SyncPayload = {
...getSyncSnapshot(),
...(aiSessions !== undefined ? { aiSessions } : {}),
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
@@ -1125,6 +1136,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.settingsVersion,
enabled,
bookmarksVersion,
hostBrowserBookmarksVersion,
pluginSidecarsVersion,
convergentSyncPaused,
syncableSettingsStorageVersion,
@@ -1142,6 +1154,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
config.settingsVersion,
enabled,
bookmarksVersion,
hostBrowserBookmarksVersion,
pluginSidecarsVersion,
syncableSettingsStorageVersion,
]);
+84
View File
@@ -0,0 +1,84 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { currentBrowserThemeTokens, mountHostBrowser } from '../hostBrowser';
import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from '../../domain/models';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
interface BrowserPanelOptions {
browserId: string;
host: Host;
hosts: Host[];
keys: SSHKey[];
identities: Identity[];
knownHosts: KnownHost[];
terminalSettings: TerminalSettings;
isVisible: boolean;
}
export function useHostBrowserPanel(options: BrowserPanelOptions) {
const elementRef = useRef<HTMLDivElement>(null);
const optionsRef = useRef(options);
optionsRef.current = options;
const opened = useRef(false);
const mountedBrowserId = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const { browserId, isVisible } = options;
useLayoutEffect(() => {
const bridge = netcattyBridge.get();
let frame = 0;
let previous = '';
const update = () => {
const element = elementRef.current;
const rect = element?.getBoundingClientRect();
// Native page views must leave the screen with their owning pane and
// must not cover a renderer dialog or a toolbar's open menu.
const covered = !!document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper]');
const visible = isVisible && !!element?.isConnected && !!rect
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
const next = JSON.stringify({ browserId, visible, bounds, theme, themeTokens });
if (next !== previous) {
previous = next;
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
}
if (isVisible) frame = requestAnimationFrame(update);
};
update();
return () => {
cancelAnimationFrame(frame);
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
};
}, [browserId, isVisible]);
useEffect(() => {
if (!isVisible || opened.current) return;
opened.current = true;
setError(null);
let cancelled = false;
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
opened.current = false;
if (cancelled) return;
setError(reason instanceof Error ? reason.message : String(reason));
});
return () => { cancelled = true; };
}, [attempt, browserId, isVisible]);
useEffect(() => {
mountedBrowserId.current = browserId;
return () => {
opened.current = false;
mountedBrowserId.current = null;
// StrictMode replays setup immediately. Do not destroy its pending URL
// or native browser unless the owning panel actually remains unmounted.
queueMicrotask(() => {
if (mountedBrowserId.current === browserId) return;
void netcattyBridge.get()?.closeHostBrowserPanel?.(browserId).catch(() => {});
});
};
}, [browserId]);
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
}
@@ -86,6 +86,13 @@ test('saved default resolves per session with fresh ids and protocol-aware pruni
const again = state.resolveDefaultLayoutForSession({ protocol: 'ssh' });
assert.ok(again);
assert.notEqual(again.layout.root.id, sshResolved.layout.root.id);
await act(async () => {
state?.saveWorkspaceLayoutAsDefault(createSidePanelLayout('browser', 'browser-pane'));
});
assert.equal(state.resolveDefaultLayoutForSession({ protocol: 'telnet' }), null);
for (const protocol of ['ssh', 'mosh', 'local']) {
assert.equal(state.resolveDefaultLayoutForSession({ protocol })?.focusedTool, 'browser');
}
} finally {
if (previousLocalStorage) {
Object.defineProperty(globalThis, 'localStorage', previousLocalStorage);
@@ -35,7 +35,7 @@ export type ResolvedWorkspaceLayoutPreset = {
export function useWorkspaceLayoutPresetState() {
/**
* Resolve the saved default preset into a fresh layout for a newly
* connected session. Panes the session cannot serve (SFTP on non-file
* connected session. Panes the session cannot serve (SFTP/Browser on non-file
* protocols) are pruned, and ids are rekeyed so several workspaces can
* run the same preset in parallel. Returns null when there is no preset
* or nothing survives availability filtering.
@@ -48,7 +48,7 @@ export function useWorkspaceLayoutPresetState() {
const proto = session.protocol ?? 'ssh';
const usable = withoutUnavailableSidePanelTools(
preset.layout,
(tool) => tool !== 'sftp' || canServeSftp(proto),
(tool) => (tool !== 'sftp' && tool !== 'browser') || canServeSftp(proto),
);
if (!usable) return null;
+20
View File
@@ -1420,6 +1420,26 @@ test("hasCloudSyncEntityData ignores settings-only payloads for empty-vault reco
);
});
test("surviving browser bookmarks do not suppress empty-vault recovery", () => {
const local: SyncPayload = {
...vault([]), syncedAt: 1,
browserBookmarks: [{
id: "app", title: "App", url: "https://app.example.com/", scope: "all-hosts",
hostId: null, icon: "A", color: "#247969", order: 0, createdAt: 1, updatedAt: 1,
}],
};
assert.equal(hasMeaningfulCloudSyncData(local), true, "bookmarks must remain syncable");
assert.equal(hasCloudSyncEntityData(local), false);
const host = {
id: "remote", label: "Remote", hostname: "remote.example.com", username: "test",
port: 22, protocol: "ssh" as const, tags: [], os: "linux" as const,
};
for (const hosts of [[host], [host, { ...host, id: "second" }]]) {
assert.equal(shouldPromptCloudVaultRecovery(local, { ...vault([]), hosts, syncedAt: 2 }), true);
}
assert.equal(shouldPromptCloudVaultRecovery({ ...vault([]), syncedAt: 1 }, local), false);
});
test("shouldPromptCloudVaultRecovery ignores settings-only remote payloads", () => {
const settingsOnlyPayload: SyncPayload = {
hosts: [],
+12 -2
View File
@@ -59,6 +59,7 @@ import {
readCommandBlocklistSetting,
} from './state/commandBlocklistSettings';
import { rehydrateGlobalSftpBookmarks } from './state/sftp/globalSftpBookmarks';
import { applyHostBrowserBookmarks, collectHostBrowserBookmarks, getCachedHostBrowserBookmarks } from './hostBrowserBookmarks';
import {
clampTerminalFontSizeValue,
nextTerminalFontSizeSyncVersion,
@@ -213,11 +214,11 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
/**
* Returns true only when the payload contains synced vault entities.
* Settings and chat history are ignored: neither proves that the vault's
* Settings, chat history, and browser bookmarks are ignored: none proves that the vault's
* hosts and keys survived local data loss.
*/
export function hasCloudSyncEntityData(payload: SyncPayload): boolean {
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS.filter((key) => key !== 'aiSessions'));
return hasSyncPayloadEntityData(payload, CLOUD_SYNC_PAYLOAD_ENTITY_KEYS.filter((key) => key !== 'aiSessions' && key !== 'browserBookmarks'));
}
export function shouldPromptCloudVaultRecovery(
@@ -1077,6 +1078,7 @@ function buildVaultSyncPayload(
vault: SyncableVaultData,
portForwardingRules?: PortForwardingRule[],
): SyncPayload {
const browserBookmarks = getCachedHostBrowserBookmarks();
return {
hosts: sanitizeHostsForSync(vault.hosts),
keys: vault.keys,
@@ -1089,6 +1091,7 @@ function buildVaultSyncPayload(
noteGroups: vault.noteGroups,
groupConfigs: vault.groupConfigs,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
settings: collectSyncableSettings(),
syncedAt: Date.now(),
};
@@ -1163,6 +1166,7 @@ export async function buildCloudSyncPayload(
},
): Promise<SyncPayload> {
const aiSessions = collectAiSessionsForSync();
const browserBookmarks = await collectHostBrowserBookmarks();
const base: SyncPayload = {
hosts: sanitizeHostsForSync(vault.hosts),
keys: vault.keys,
@@ -1176,6 +1180,7 @@ export async function buildCloudSyncPayload(
groupConfigs: vault.groupConfigs,
portForwardingRules: sanitizePortForwardingRulesForSync(portForwardingRules),
...(aiSessions !== undefined ? { aiSessions } : {}),
...(browserBookmarks !== undefined ? { browserBookmarks } : {}),
settings: await collectCloudSyncableSettings(),
syncedAt: Date.now(),
};
@@ -1226,6 +1231,8 @@ export async function buildLocalVaultPayloadAsync(
portForwardingRules?: PortForwardingRule[],
): Promise<SyncPayload> {
const base = buildLocalVaultPayload(vault, portForwardingRules);
const browserBookmarks = await collectHostBrowserBookmarks();
if (browserBookmarks !== undefined) base.browserBookmarks = browserBookmarks;
const {
collectPluginSyncSidecarsFromHost,
isPluginSidecarHostUnavailableError,
@@ -1400,6 +1407,9 @@ async function preparePayloadApply(
if (sanitizedPayload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
importers.importPortForwardingRules(sanitizedPayload.portForwardingRules);
}
if (sanitizedPayload.browserBookmarks !== undefined) {
await applyHostBrowserBookmarks(sanitizedPayload.browserBookmarks);
}
// AI chat sessions merge append-only: a cloud snapshot may be missing
// turns that only exist on this device, so union instead of replace.
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
import { useHostBrowserPanel } from '../application/state/useHostBrowserPanel';
import { useI18n } from '../application/i18n/I18nProvider';
export function HostBrowserSidePanel(props: Parameters<typeof useHostBrowserPanel>[0]) {
const { elementRef, error, retry } = useHostBrowserPanel(props);
const { t } = useI18n();
return (
<div ref={elementRef} className="relative h-full min-h-0 w-full overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
{error && <div className="p-4 text-sm text-muted-foreground">
<p>{error}</p>
<button type="button" className="mt-3 text-primary" onClick={retry}>{t('common.retry')}</button>
</div>}
</div>
);
}
+12
View File
@@ -282,6 +282,7 @@ interface HostTreeViewProps {
onExpandAll?: (paths: string[]) => void;
onCollapseAll?: () => void;
onConnect: (host: Host) => void;
onOpenBrowser?: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
@@ -322,6 +323,7 @@ interface TreeNodeProps {
expandedPaths: Set<string>;
onToggle: (path: string) => void;
onConnect: (host: Host) => void;
onOpenBrowser?: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
@@ -368,6 +370,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
expandedPaths,
onToggle,
onConnect,
onOpenBrowser,
onEditHost,
onDuplicateHost,
onDeleteHost,
@@ -584,6 +587,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
expandedPaths={expandedPaths}
onToggle={onToggle}
onConnect={onConnect}
onOpenBrowser={onOpenBrowser}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
@@ -627,6 +631,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
host={host}
depth={depth + 1}
onConnect={onConnect}
onOpenBrowser={onOpenBrowser}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
@@ -660,6 +665,7 @@ interface HostTreeItemProps {
host: Host;
depth: number;
onConnect: (host: Host) => void;
onOpenBrowser?: (host: Host) => void;
onEditHost: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
onDeleteHost: (host: Host) => void;
@@ -706,6 +712,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
host,
depth,
onConnect,
onOpenBrowser,
onEditHost,
onDuplicateHost,
onDeleteHost,
@@ -871,6 +878,7 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
<HostTreeHostContextMenuContent
host={host}
onConnect={onConnect}
onOpenBrowser={onOpenBrowser}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onCopyHostname={onCopyHostname}
@@ -890,6 +898,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
onExpandAll: externalOnExpandAll,
onCollapseAll: externalOnCollapseAll,
onConnect,
onOpenBrowser,
onEditHost,
onDuplicateHost,
onDeleteHost,
@@ -1199,6 +1208,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
expandedPaths={expandedPaths}
onToggle={togglePath}
onConnect={onConnect}
onOpenBrowser={onOpenBrowser}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
@@ -1240,6 +1250,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
host={item.host}
depth={item.depth}
onConnect={onConnect}
onOpenBrowser={onOpenBrowser}
onEditHost={onEditHost}
onDuplicateHost={onDuplicateHost}
onDeleteHost={onDeleteHost}
@@ -1289,6 +1300,7 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
&& prev.onExpandAll === next.onExpandAll
&& prev.onCollapseAll === next.onCollapseAll
&& prev.onConnect === next.onConnect
&& prev.onOpenBrowser === next.onOpenBrowser
&& prev.onEditHost === next.onEditHost
&& prev.onDuplicateHost === next.onDuplicateHost
&& prev.onDeleteHost === next.onDeleteHost
+20 -1
View File
@@ -130,6 +130,7 @@ import {
import { isVaultInitialized } from "@/application/state/vaultInitStore.ts";
import { useVaultSnapshotField } from "@/application/state/vaultSnapshotStore.ts";
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
import { openHostBrowser } from "../application/hostBrowser";
import { handleTerminalOscNotification } from "@/application/state/oscDesktopNotifications.ts";
import { OscNotificationStreamScanner } from "@/domain/terminalOscNotifications.ts";
import { ScriptExecutionOverlay } from "./terminal/ScriptExecutionOverlay";
@@ -4187,6 +4188,22 @@ const TerminalComponent: React.FC<TerminalProps> = ({
window.dispatchEvent(new CustomEvent('netcatty:script:recording:start', { detail: { sessionId } }));
}, [sessionId, t]);
const handleOpenHostBrowser = useCallback(async () => {
try {
await openHostBrowser({
sessionId,
host,
hosts: [host, ...resolvedChainHosts],
keys,
identities,
terminalSettings,
knownHosts,
});
} catch (error) {
toast.error(error instanceof Error ? error.message : 'Could not open the host browser');
}
}, [sessionId, host, resolvedChainHosts, keys, identities, terminalSettings, knownHosts]);
const renderControls = useCallback((opts?: { showClose?: boolean; restorePaneLayout?: boolean }) => (
<TerminalToolbar
sessionId={sessionId}
@@ -4199,6 +4216,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onSnippetClick={(snippet) => { void executeSnippet(snippet); }}
onDeleteSnippets={onDeleteSnippets}
onOpenSFTP={handleOpenSFTP}
onOpenBrowser={(!host.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol)) ? () => { void handleOpenHostBrowser(); } : undefined}
onSendYmodem={isSerialConnection ? handleSendYmodem : undefined}
onReceiveYmodem={isSerialConnection ? handleReceiveYmodem : undefined}
onOpenScripts={onOpenScripts ?? (() => {})}
@@ -4252,6 +4270,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
compactToolbar,
executeSnippet,
handleOpenSFTP,
handleOpenHostBrowser,
handleReceiveYmodem,
handleSendYmodem,
handleSetTerminalEncoding,
@@ -4629,7 +4648,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
return (
<>
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<TerminalView ctx={{ Activity, ArrowDownToLine, ArrowUpFromLine, Button, Clock3, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, RefreshCcw, Sparkles, SquareArrowOutUpRight, Unplug, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, canUpdateHost: !!onUpdateHost, chainProgress, cn, compactToolbar, lineTimestampsAvailable, containerRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippet, executeSnippetCommand, handleAddSelectionToAI: onAddSelectionToAI ? handleAddSelectionToAI : undefined, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDisconnect: (attachExistingSession || compactToolbar) ? undefined : handleDisconnect, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOpenHostBrowser, handleOsc52ReadResponse, handleOsc7SetupConfirm, handleOsc7SetupOpenChange, handleReceiveYmodem, handleRetry, handleSearch, handleSendYmodem, handleTopOverlayMouseDownCapture, hasMouseTracking, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isDraggingOver, isFocusMode, isFocusedPane, isLocalConnection, remoteDragDropUsesZmodem, isPluginTerminalProviderAvailable, isReconnectActive, isSerialConnection, isSearchOpen, isSupportedOs, isSystemSidebarEligible, isVisible, keyBindings, keys, identities, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onDetach, onDetachDragEnd, onDetachDragStart, onDetachPointerDown, onEndSessionDrag, onExpandToFocus, onTogglePaneMagnification, onOpenSystem, onRename, onSplitHorizontal, onSplitVertical, onStartSessionDrag, onToggleBroadcast, onUpdateHost: handleUpdateHostFromTerminal, osc52ReadPromptVisible, osc7SetupOpen, osc7SetupRunning, passwordPromptActiveRef, pendingHostKeyInfo, progressLogs, progressValue, renderControls, resolvedFontFamily, restoreState, scrollToBottomAfterProgrammaticInput, searchMatchCount, searchFocusToken, scriptExecutionOverlay: activeScriptRun ? (
<ScriptExecutionOverlay
run={activeScriptRun}
onPause={() => { void pauseScriptRun(activeScriptRun.runId); }}
+50
View File
@@ -1,3 +1,5 @@
import { subscribeHostBrowserPanelRequests } from '../application/state/hostBrowserPanelRequests';
import { openHostBrowserPanelUrl } from '../application/hostBrowser';
import { captureTerminalBroadcastInput, isTerminalBroadcastInputCurrent, markTerminalBroadcastUserInput } from "./terminal/runtime/terminalPacedBroadcast";
import { pruneComposeBarHistory } from '../application/state/composeBarHistoryStore';
import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X, Zap } from 'lucide-react';
@@ -488,6 +490,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setThemeMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'system') {
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'browser') {
setBrowserMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'notes') {
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (pane.tool === 'sftp') {
@@ -659,6 +663,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (targetPanel === 'notes') {
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
} else if (targetPanel === 'browser') {
setBrowserMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
}
setSidePanelOpenTabs(prev => {
@@ -770,6 +776,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const [scriptsMountedTabIds, setScriptsMountedTabIds] = useState<string[]>([]);
const [systemMountedTabIds, setSystemMountedTabIds] = useState<string[]>([]);
const [themeMountedTabIds, setThemeMountedTabIds] = useState<string[]>([]);
const [browserMountedTabIds, setBrowserMountedTabIds] = useState<string[]>([]);
const [notesMountedTabIds, setNotesMountedTabIds] = useState<string[]>([]);
const [sidePanelWidth, setSidePanelWidth, persistSidePanelWidth] = useStoredNumber(
STORAGE_KEY_SIDE_PANEL_WIDTH,
@@ -1009,6 +1016,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setScriptsMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setThemeMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setSystemMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setBrowserMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setNotesMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
setNotesOpenNoteByTab((previous) => {
const next = new Map(previous);
@@ -1549,6 +1557,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
setSystemMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
return;
}
if (panel === 'browser') {
setBrowserMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
return;
}
if (panel === 'notes') {
setNotesMountedTabIds((prev) => addMountedSidePanelTabId(prev, tabId));
}
@@ -1888,6 +1900,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleCloseSidePanel();
return;
}
if (closingPane?.tool === 'browser') {
setBrowserMountedTabIds((previous) => removeMountedSidePanelTabId(previous, tabId));
}
if (shouldMarkSftpPaneClosed({
closingPaneTool: closingPane?.tool,
closesWholePanel: closesWholePanel,
@@ -1974,6 +1989,39 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleSwitchSidePanelTab('system');
}, [handleSwitchSidePanelTab]);
const handleOpenBrowser = useCallback(() => {
handleSwitchSidePanelTab('browser');
}, [handleSwitchSidePanelTab]);
useEffect(() => subscribeHostBrowserPanelRequests(({ host, sessionId, url }) => {
const session = sessionsRef.current.find((candidate) => sessionId
? candidate.id === sessionId : !candidate.hiddenFromTabs && candidate.hostId === host.id
&& (!candidate.protocol || ['ssh', 'mosh', 'local'].includes(candidate.protocol)));
let tabId: string | null = null;
if (session) {
tabId = session.workspaceId ?? session.id;
if (session.workspaceId) onSetWorkspaceFocusedSessionRef.current?.(session.workspaceId, session.id);
activeTabStore.setActiveTabId(tabId);
} else {
const opened = onConnectToHost(host);
if (typeof opened === 'string') tabId = opened;
}
if (!tabId) return;
if (url) {
void openHostBrowserPanelUrl(`${tabId}:${session?.id ?? tabId}`, url)
.catch((error: unknown) => toast.error(error instanceof Error ? error.message : String(error)));
}
setBrowserMountedTabIds((previous) => addMountedSidePanelTabId(previous, tabId));
setSidePanelOpenTabs((previous) => new Map(previous).set(tabId, 'browser'));
}), [onConnectToHost, setSidePanelOpenTabs]);
useEffect(() => {
setBrowserMountedTabIds((previous) => {
const next = previous.filter((tabId) => validAIScopeTargetIds.has(tabId));
return next.length === previous.length ? previous : next;
});
}, [validAIScopeTargetIds]);
const handleOpenNotes = useCallback(() => {
const tabId = activeTabIdRef.current;
if (!tabId) return;
@@ -2546,6 +2594,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
handleOpenAI,
handleOpenSystem,
handleOpenNotes,
handleOpenBrowser,
handleFocusSidePanelPane,
handleMagnifySidePanelPane,
handleRestoreMagnifiedPane,
@@ -2612,6 +2661,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
mountedSftpTabIds,
magnifiedPane,
notesMountedTabIds,
browserMountedTabIds,
notesOpenNoteByTab,
scriptsMountedTabIds,
systemMountedTabIds,
@@ -575,6 +575,7 @@ const CONFLICT_ENTITY_LABEL_KEYS: Record<SyncChangeEntityKey, string> = {
customGroups: 'cloudSync.conflict.entity.customGroups',
snippetPackages: 'cloudSync.conflict.entity.snippetPackages',
portForwardingRules: 'cloudSync.conflict.entity.portForwardingRules',
browserBookmarks: 'cloudSync.conflict.entity.browserBookmarks',
groupConfigs: 'cloudSync.conflict.entity.groupConfigs',
aiSessions: 'cloudSync.aiSessions.title',
settings: 'cloudSync.conflict.entity.settings',
+10 -1
View File
@@ -1,4 +1,4 @@
import { Copy, FileSymlink, Files, Folder, FolderOpen, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
import { Copy, FileSymlink, Files, Folder, FolderOpen, Globe2, Monitor, Pencil, Plus, Server, Settings2 } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
@@ -14,6 +14,7 @@ import { PluginContributionIcon } from '../plugins/PluginContributionIcon';
export interface HostTreeHostContextMenuHandlers {
onConnect: (host: Host) => void;
onOpenBrowser?: (host: Host) => void;
onEditHost?: (host: Host) => void;
onRenameHost?: (host: Host) => void;
onDuplicateHost: (host: Host) => void;
@@ -33,11 +34,18 @@ export const OpenDualPaneSftpMenuItem: React.FC<{ host: Host }> = ({ host }) =>
);
};
export const OpenHostBrowserMenuItem: React.FC<{ host: Host; onOpenBrowser?: (host: Host) => void }> = ({ host, onOpenBrowser }) => {
const { t } = useI18n();
if (!onOpenBrowser || (host.protocol && !['ssh', 'mosh', 'local'].includes(host.protocol))) return null;
return <ContextMenuItem onClick={() => onOpenBrowser(host)}><Globe2 className="mr-2 h-4 w-4" /> {t('terminal.browser.open')}</ContextMenuItem>;
};
export const HostTreeHostContextMenuContent: React.FC<
HostTreeHostContextMenuHandlers & { host: Host }
> = ({
host,
onConnect,
onOpenBrowser,
onEditHost,
onRenameHost,
onDuplicateHost,
@@ -65,6 +73,7 @@ export const HostTreeHostContextMenuContent: React.FC<
<Monitor className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={safeHost} />
<OpenHostBrowserMenuItem host={safeHost} onOpenBrowser={onOpenBrowser} />
{onEditHost && (
<ContextMenuItem onClick={() => onEditHost(host)}>
<Settings2 className="mr-2 h-4 w-4" /> {t('terminal.layer.hostTree.editHost')}
@@ -367,6 +367,22 @@ export const TerminalBehaviorSettings: React.FC<TerminalBehaviorSettingsProps> =
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
</SettingRow>
<SettingRow
anchorId="terminal-link-browser"
label={t("settings.terminal.behavior.linkBrowser")}
description={t("settings.terminal.behavior.linkBrowser.desc")}
>
<Select
value={terminalSettings.linkBrowser ?? 'netcatty'}
options={[
{ value: 'netcatty', label: t('settings.terminal.behavior.linkBrowser.netcatty') },
{ value: 'system', label: t('settings.terminal.behavior.linkBrowser.system') },
]}
onChange={(v) => updateTerminalSetting('linkBrowser', v as 'netcatty' | 'system')}
className="w-48"
/>
</SettingRow>
<SettingRow
label={t("settings.terminal.behavior.linkModifier")}
description={t("settings.terminal.behavior.linkModifier.desc")}
@@ -6,6 +6,7 @@ import {
ClipboardPaste,
Copy,
Download,
Globe2,
Pencil,
RefreshCcw,
Sparkles,
@@ -65,6 +66,7 @@ export interface TerminalContextMenuProps {
isReconnectable?: boolean;
onReconnect?: () => void;
onClose?: () => void;
onOpenBrowser?: () => void;
onSelectWord?: () => void;
onAddSelectionToAI?: () => void;
onRename?: () => void;
@@ -224,6 +226,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
isReconnectable,
onReconnect,
onClose,
onOpenBrowser,
onSelectWord,
onAddSelectionToAI,
onRename,
@@ -410,6 +413,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
{t('terminal.menu.selectAll')}
<ContextMenuShortcut>{selectAllShortcut}</ContextMenuShortcut>
</ContextMenuItem>
{onOpenBrowser && (
<>
<ContextMenuSeparator />
<ContextMenuItem onClick={onOpenBrowser}>
<Globe2 size={14} className="mr-2" /> {t('terminal.browser.open')}
</ContextMenuItem>
</>
)}
{showReconnectAction && (
<>
@@ -68,6 +68,21 @@ test("keeps SFTP visible before the terminal overflow menu for SSH sessions", ()
assert.ok(sftpIndex < moreIndex);
});
test("browser action remains available when an SSH terminal is disconnected", () => {
const disconnectedSsh = renderToolbar(sshHost, "disconnected", { onOpenBrowser: () => {} });
const serial = renderToolbar(serialHost, "connected", { onOpenBrowser: () => {} });
assert.match(disconnectedSsh, /aria-label="Open Browser"/);
assert.doesNotMatch(disconnectedSsh, /aria-label="Open Browser"[^>]*disabled/);
assert.doesNotMatch(serial, /aria-label="Open Browser"/);
});
test("browser action is available for supported local and mosh terminals", () => {
for (const protocol of ['local', 'mosh'] as const) {
const markup = renderToolbar({ ...sshHost, protocol }, "connected", { onOpenBrowser: () => {} });
assert.match(markup, /aria-label="Open Browser"/);
}
});
test("keeps Scripts visible before the terminal overflow menu", () => {
const markup = renderToolbar(sshHost);
+32 -1
View File
@@ -11,6 +11,7 @@ import {
FileText,
FolderInput,
FolderSync,
Globe2,
Highlighter,
History,
Languages,
@@ -44,6 +45,7 @@ import { isPluginHostProtocol } from '../../domain/pluginConnection';
export const TERMINAL_TOOLBAR_ITEM_IDS = [
'highlight',
'sftp',
'browser',
'ymodemSend',
'ymodemReceive',
'compose',
@@ -65,6 +67,7 @@ export const TERMINAL_TOOLBAR_LAYOUT_DEFAULTS: ToolbarItemLayoutDefaults = {
placement: {
highlight: 'show',
sftp: 'show',
browser: 'show',
ymodemSend: 'show',
ymodemReceive: 'show',
compose: 'show',
@@ -94,6 +97,7 @@ export interface TerminalToolbarProps {
/** Popup vault mutation path — compact toolbar has no AppSideEffects listener. */
onDeleteSnippets?: (ids: ReadonlySet<string>) => void;
onOpenSFTP: () => void;
onOpenBrowser?: () => void;
onSendYmodem?: () => void;
onReceiveYmodem?: () => void;
onOpenScripts: () => void;
@@ -133,6 +137,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
onSnippetClick,
onDeleteSnippets,
onOpenSFTP,
onOpenBrowser,
onSendYmodem,
onReceiveYmodem,
onOpenScripts,
@@ -238,6 +243,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
'terminalSettings',
];
if (!hidesSftp) ids.push('sftp');
if (onOpenBrowser && (!host?.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol))) ids.push('browser');
if (isSerialTerminal) {
ids.push('ymodemSend', 'ymodemReceive');
}
@@ -253,6 +259,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
historySupported,
isSerialTerminal,
onConfigureOsc7,
onOpenBrowser,
host?.protocol,
onSetTerminalEncoding,
onStartRecording,
showLogButton,
@@ -262,6 +270,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
(): Record<TerminalToolbarItemId, string> => ({
highlight: t('terminal.toolbar.hostHighlight.title'),
sftp: t('terminal.toolbar.openSftp'),
browser: t('terminal.browser.open'),
ymodemSend: t('terminal.toolbar.sendYmodem'),
ymodemReceive: t('terminal.toolbar.receiveYmodem'),
compose: t('terminal.toolbar.composeBar'),
@@ -283,6 +292,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
(): Record<TerminalToolbarItemId, React.ReactNode> => ({
highlight: <Highlighter size={14} />,
sftp: <FolderInput size={14} />,
browser: <Globe2 size={14} />,
ymodemSend: <Upload size={14} />,
ymodemReceive: <Download size={14} />,
compose: <TextCursorInput size={14} />,
@@ -316,7 +326,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
);
const { shown, collapsed } = useMemo(() => {
const available = new Set(availableIds);
const available = new Set<string>(availableIds);
const shownIds: string[] = [];
const collapsedIds: string[] = [];
for (const id of toolbarLayout.layout.order) {
@@ -519,6 +529,18 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</TooltipContent>
</Tooltip>
);
case 'browser':
return (
<Tooltip key={id}>
<TooltipTrigger asChild>
<Button variant="secondary" size="icon" className={buttonBase}
aria-label={t('terminal.browser.open')} onClick={onOpenBrowser}>
<Globe2 size={12} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom">{t('terminal.browser.open')}</TooltipContent>
</Tooltip>
);
case 'ymodemSend':
return (
<Tooltip key={id}>
@@ -818,6 +840,15 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
</button>
</PopoverClose>
);
case 'browser':
return (
<PopoverClose asChild key={id}>
<button type="button" className={menuItemClass} onClick={onOpenBrowser}>
<Globe2 size={12} className="shrink-0" />
<span className="flex-1 text-left truncate">{t('terminal.browser.open')}</span>
</button>
</PopoverClose>
);
case 'ymodemSend':
return (
<PopoverClose asChild key={id}>
+1
View File
@@ -586,6 +586,7 @@ function TerminalViewInner({ ctx, isPaneMagnified = false }: { ctx: TerminalView
showContextMenuOverFullscreenApps={terminalSettings?.showContextMenuOverFullscreenApps}
onSaveScreen={terminalContextActions.onSaveScreen}
onCopy={terminalContextActions.onCopy}
onOpenBrowser={(!host?.protocol || ['ssh', 'mosh', 'local'].includes(host.protocol)) ? () => { void ctx.handleOpenHostBrowser(); } : undefined}
onPaste={terminalContextActions.onPaste}
onUploadClipboardImage={status === "connected" ? terminalContextActions.onUploadClipboardImage : undefined}
onPasteSelection={terminalContextActions.onPasteSelection}
@@ -198,6 +198,7 @@ import {
} from "./terminalUserPaste";
import { pasteTextWithMultilineConfirm } from "../terminalClipboardPaste";
import { requestMultilinePasteConfirm } from "../../../application/state/multilinePasteConfirmStore";
import { requestHostBrowserPanel } from "../../../application/state/hostBrowserPanelRequests";
import {
consumeOsc133CommandCompletion,
type PromptLineBreakState,
@@ -576,6 +577,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
};
const terminalLinkHandler = createTerminalLinkHandler({
canActivate: canActivateTerminalLink,
useInternal: () => ctx.terminalSettingsRef.current?.linkBrowser !== 'system',
openInternal: ['ssh', 'mosh', 'local'].includes(ctx.host.protocol ?? 'ssh')
? (url) => requestHostBrowserPanel({ host: ctx.host, sessionId: ctx.sessionId, url })
: undefined,
openExternalAvailable: ctx.terminalBackend.openExternalAvailable,
openExternal: ctx.terminalBackend.openExternal,
confirmOscLink: (uri) => window.confirm(
@@ -11,6 +11,42 @@ const click = {
shiftKey: false,
} as MouseEvent;
test("terminal links default to the internal browser and apply settings immediately", async () => {
const internal: string[] = [];
const external: string[] = [];
let systemBrowser = false;
const handler = createTerminalLinkHandler({
canActivate: () => true,
openExternalAvailable: () => true,
openExternal: async (url) => { external.push(url); },
openInternal: (url) => { internal.push(url); },
useInternal: () => !systemBrowser,
confirmOscLink: () => true,
});
await handler.open('http://localhost:3000/');
systemBrowser = true;
await handler.open('https://example.com/');
assert.deepEqual(internal, ['http://localhost:3000/']);
assert.deepEqual(external, ['https://example.com/']);
});
test("internal browser failures never fall back to the local system browser", async () => {
const errors: unknown[] = [];
let external = 0;
const handler = createTerminalLinkHandler({
canActivate: () => true,
openExternalAvailable: () => true,
openExternal: async () => { external += 1; },
openInternal: () => { throw new Error('Disconnected'); },
confirmOscLink: () => true,
onError: (error) => errors.push(error),
warn: () => {},
});
await handler.open('http://localhost:3000/');
assert.equal(external, 0);
assert.equal(errors.length, 1);
});
test("OSC 8 links use the Netcatty external browser bridge", async () => {
const opened: string[] = [];
const confirmed: string[] = [];
@@ -7,6 +7,8 @@ export type TerminalLinkHandlerOptions = {
canActivate: (event: TerminalLinkClickEvent) => boolean;
openExternalAvailable: () => boolean;
openExternal: (uri: string) => Promise<void>;
openInternal?: (uri: string) => Promise<void> | void;
useInternal?: () => boolean;
confirmOscLink: (uri: string) => boolean;
openWindow?: (uri: string) => unknown;
onError?: (error: unknown) => void;
@@ -31,6 +33,10 @@ export function createTerminalLinkHandler(
}
try {
if (options.openInternal && (options.useInternal?.() ?? true)) {
await options.openInternal(uri);
return;
}
if (options.openExternalAvailable()) {
await options.openExternal(uri);
return;
@@ -84,6 +84,7 @@ test('side panel tab order falls back to the default order', () => {
assert.deepEqual(normalizeTerminalSidePanelTabOrder(['scripts', 'bad-tab']), [
'scripts',
'sftp',
'browser',
'history',
'theme',
'system',
@@ -93,7 +94,7 @@ test('side panel tab order falls back to the default order', () => {
});
test('side panel tab order accepts a stored permutation', () => {
const stored = ['scripts', 'sftp', 'history', 'theme', 'system', 'notes', 'ai'];
const stored = ['scripts', 'sftp', 'browser', 'history', 'theme', 'system', 'notes', 'ai'];
assert.deepEqual(normalizeTerminalSidePanelTabOrder(stored), stored);
});
@@ -105,7 +106,7 @@ test('side panel tab order moves the dragged tab before the target tab', () => {
'notes',
'scripts',
),
['sftp', 'notes', 'scripts', 'history', 'theme', 'system', 'ai'],
['sftp', 'browser', 'notes', 'scripts', 'history', 'theme', 'system', 'ai'],
);
});
@@ -117,7 +118,7 @@ test('side panel tab order can move the dragged tab after the target tab', () =>
'ai',
'after',
),
['sftp', 'history', 'theme', 'system', 'notes', 'ai', 'scripts'],
['sftp', 'browser', 'history', 'theme', 'system', 'notes', 'ai', 'scripts'],
);
});
@@ -130,7 +131,7 @@ test('narrow side panels keep the active tool visible and move extra tools into
});
assert.deepEqual(fitted.shown, ['sftp', 'ai']);
assert.deepEqual(fitted.collapsed, ['scripts', 'history', 'theme', 'system', 'notes']);
assert.deepEqual(fitted.collapsed, ['browser', 'scripts', 'history', 'theme', 'system', 'notes']);
});
test('notes side panel forwards repeated open-note requests', () => {
@@ -1,5 +1,5 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Activity, FolderTree, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelsTopLeft, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
import { Activity, FolderTree, Globe2, History, Maximize2, MessageSquare, Minimize2, NotebookText, Palette, PanelsTopLeft, Play, Save, SplitSquareHorizontal, SplitSquareVertical, X } from 'lucide-react';
import {
buildSidePanelChromeThemeFromTerminalTheme,
buildTerminalSidePanelCssVars,
@@ -555,6 +555,7 @@ export function getTerminalSidePanelShellWidth({
function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
const {
mountedAiTabIds,
browserMountedTabIds = [],
mountedSftpTabIds,
notesMountedTabIds,
scriptsMountedTabIds,
@@ -569,7 +570,8 @@ function hasMountedSidePanelContent(ctx: SidePanelContext): boolean {
&& Array.from((sidePanelOpenTabs as Map<string, SidePanelTab>).values()).includes('notes');
return !(
mountedSftpTabIds.length === 0
browserMountedTabIds.length === 0
&& mountedSftpTabIds.length === 0
&& mountedAiTabIds.length === 0
&& notesMountedTabIds.length === 0
&& scriptsMountedTabIds.length === 0
@@ -628,6 +630,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
handleOpenAI,
handleOpenHistory,
handleOpenNotes,
handleOpenBrowser,
handleOpenScripts,
handleOpenSystem,
handleOpenTheme,
@@ -1036,6 +1039,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
const sidePanelTabItems = useMemo<SidePanelTabItem[]>(() => [
{ id: 'sftp' as const, label: t('terminal.layer.sftp'), icon: <FolderTree size={15} />, onClick: handleToggleSftpFromBar },
{ id: 'browser' as const, label: t('terminal.browser.title'), icon: <Globe2 size={15} />, onClick: handleOpenBrowser },
{ id: 'scripts' as const, label: t('terminal.layer.scripts'), icon: <Play size={15} />, onClick: handleOpenScripts },
{ id: 'history' as const, label: t('terminal.layer.history'), icon: <History size={15} />, onClick: handleOpenHistory },
{ id: 'theme' as const, label: t('terminal.layer.theme'), icon: <Palette size={15} />, onClick: handleOpenTheme },
@@ -1046,6 +1050,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
handleOpenAI,
handleOpenHistory,
handleOpenNotes,
handleOpenBrowser,
handleOpenScripts,
handleOpenSystem,
handleOpenTheme,
@@ -1297,6 +1302,7 @@ function TerminalLayerSidePanelInner({ ctx }: { ctx: SidePanelContext }) {
variant="ghost"
size="icon"
draggable
aria-label={item.label}
data-tab-id={item.id}
data-tab-type="sidepanel"
data-state={isActive ? 'active' : 'inactive'}
@@ -489,6 +489,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
handleFontWeightResetForFocusedSession: themeState.handleFontWeightResetForFocusedSession,
handleOpenAI: s.handleOpenAI,
handleOpenNotes: s.handleOpenNotes,
handleOpenBrowser: s.handleOpenBrowser,
handleOpenSystem: s.handleOpenSystem,
handleOpenHistory: s.handleOpenHistory,
handleOpenScripts: s.handleOpenScripts,
@@ -558,6 +559,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
mountedAiTabIds: s.mountedAiTabIds,
mountedSftpTabIds: s.mountedSftpTabIds,
notesMountedTabIds: s.notesMountedTabIds,
browserMountedTabIds: s.browserMountedTabIds,
notesOpenNoteByTab: s.notesOpenNoteByTab,
NotesManager: s.NotesManager,
scriptsMountedTabIds: s.scriptsMountedTabIds,
@@ -602,6 +604,7 @@ export function TerminalLayerTabBridge({ stableRef }: { stableRef: StableRef })
ScriptsSidePanel: s.ScriptsSidePanel,
sessionChainHostsMap: s.sessionChainHostsMap,
sessionHostsMap,
sftpAuthHostBySessionId,
resolvedSessionHostIds: s.resolvedSessionHostIds,
sessionLogConfig: s.sessionLogConfig,
sessionSudoAutofillPasswordsMap: s.sessionSudoAutofillPasswordsMap,
@@ -0,0 +1,120 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import vm from 'node:vm';
import ts from 'typescript';
import { collectSidePanelPanes, closeSidePanelPane, type SidePanelLayout } from '../../domain/sidePanelLayout';
import { resolveSessionSidePanelAutoOpen } from '../../domain/terminalSidePanelAutoOpen';
const source = readFileSync(new URL('../TerminalLayer.tsx', import.meta.url), 'utf8');
const start = source.indexOf('const handleCloseSidePanelPane = useCallback(');
const end = source.indexOf('const handleResizeSidePanelSplit =', start);
assert.ok(start > 0 && end > start);
const ast = ts.createSourceFile('TerminalLayer.tsx', source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
const helper = ast.statements.find((node) => ts.isVariableStatement(node)
&& node.declarationList.declarations.some((declaration) => declaration.name.getText(ast) === 'removeMountedSidePanelTabId'));
assert.ok(helper);
const code = ts.transpile(`${helper.getText(ast)};${source.slice(start, end)};handleCloseSidePanelPane;`, { target: ts.ScriptTarget.ES2022 });
function closePane(tool: 'browser' | 'notes') {
const layout: SidePanelLayout = { focusedPaneId: 'browser-pane', root: {
type: 'split', id: 'split', direction: 'horizontal', sizes: [0.5, 0.5], children: [
{ type: 'pane', id: 'browser-pane', tool: 'browser' },
{ type: 'pane', id: 'notes-pane', tool: 'notes' },
],
} };
const layouts = new Map([['terminal', layout]]);
let mounted = ['terminal', 'other-terminal'];
const close = vm.runInNewContext(code, {
useCallback: (callback: unknown) => callback,
activeTabIdRef: { current: 'terminal' }, sidePanelLayoutsRef: { current: layouts },
collectSidePanelPanes,
setMagnifiedPane: () => {},
closeSidePanelPaneForTab: (tabId: string, paneId: string) => {
const next = closeSidePanelPane(layouts.get(tabId)!, paneId);
assert.ok(next);
layouts.set(tabId, next);
return false;
},
handleCloseSidePanel: () => { throw new Error('the sibling pane must remain open'); },
setBrowserMountedTabIds: (update: (previous: string[]) => string[]) => { mounted = update(mounted); },
shouldMarkSftpPaneClosed: () => false,
}) as (paneId: string) => void;
close(`${tool}-pane`);
return { mounted, tools: collectSidePanelPanes(layouts.get('terminal')!.root).map((pane) => pane.tool) };
}
test('closing a browser split pane releases only that terminal browser', () => {
const result = closePane('browser');
assert.deepEqual(result.mounted, ['other-terminal']);
assert.deepEqual(result.tools, ['notes']);
});
test('closing a sibling pane keeps the browser mounted', () => {
const result = closePane('notes');
assert.deepEqual(result.mounted, ['terminal', 'other-terminal']);
assert.deepEqual(result.tools, ['browser']);
});
for (const protocol of ['local', 'ssh']) {
test(`browser auto-open mounts content for a ${protocol} terminal`, () => {
const statusStart = source.indexOf('const handleStatusChange = useCallback(');
const statusEnd = source.indexOf('const handleSessionExit =', statusStart);
const addHelper = ast.statements.find((node) => ts.isVariableStatement(node)
&& node.declarationList.declarations.some((declaration) => declaration.name.getText(ast) === 'addMountedSidePanelTabId'));
assert.ok(addHelper && statusStart > 0 && statusEnd > statusStart);
let mounted: string[] = [];
let opened = new Map<string, string>();
const statusCode = ts.transpile(`${addHelper.getText(ast)};${source.slice(statusStart, statusEnd)};handleStatusChange;`, { target: ts.ScriptTarget.ES2022 });
const statusChange = vm.runInNewContext(statusCode, {
useCallback: (callback: unknown) => callback,
onUpdateSessionStatus: () => {},
setSftpAuthHostBySessionId: () => {},
sessionsRef: { current: [{ id: 'terminal', protocol }] },
sidePanelOpenTabsRef: { current: opened },
sftpHostSourceSessionForTabRef: { current: new Map() },
lastSidePanelTabRef: { current: new Map() },
terminalSidePanelAutoOpenRef: { current: true },
terminalSidePanelAutoOpenTabRef: { current: 'browser' },
localShellSidePanelAutoOpenRef: { current: true },
localShellSidePanelAutoOpenTabRef: { current: 'browser' },
sftpAutoOpenSidebarRef: { current: false },
resolveSessionSidePanelAutoOpen,
applyWorkspaceLayoutPresetForSession: () => false,
setBrowserMountedTabIds: (update: (previous: string[]) => string[]) => { mounted = update(mounted); },
setSidePanelOpenTabs: (update: (previous: Map<string, string>) => Map<string, string>) => { opened = update(opened); },
}) as (sessionId: string, status: string) => void;
statusChange('terminal', 'connected');
assert.equal(opened.get('terminal'), 'browser');
assert.deepEqual(Array.from(mounted), ['terminal']);
});
}
test('host-only browser requests skip hidden and unsupported sessions', () => {
const requestStart = source.indexOf('useEffect(() => subscribeHostBrowserPanelRequests(');
const requestEnd = source.indexOf('useEffect(() => {', requestStart);
assert.ok(requestStart > 0 && requestEnd > requestStart);
const requestCode = ts.transpile(source.slice(requestStart, requestEnd), { target: ts.ScriptTarget.ES2022 });
const hidden = { id: 'hidden', hostId: 'host', hiddenFromTabs: true };
const unsupported = { id: 'telnet', hostId: 'host', protocol: 'telnet' };
const visible = { id: 'visible', hostId: 'host' };
for (const sessions of [[hidden, unsupported, visible], [hidden, unsupported]]) {
let active: string | undefined;
let mounted: string[] = [];
let opened = new Map<string, string>();
vm.runInNewContext(requestCode, {
useEffect: (callback: () => void) => callback(),
subscribeHostBrowserPanelRequests: (callback: (request: unknown) => void) => callback({ host: { id: 'host' } }),
sessionsRef: { current: sessions },
activeTabStore: { setActiveTabId: (tabId: string) => { active = tabId; } },
onConnectToHost: () => 'new-visible',
addMountedSidePanelTabId: (previous: string[], tabId: string) => [...previous, tabId],
setBrowserMountedTabIds: (update: (previous: string[]) => string[]) => { mounted = update(mounted); },
setSidePanelOpenTabs: (update: (previous: Map<string, string>) => Map<string, string>) => { opened = update(opened); },
});
const expected = sessions.includes(visible) ? 'visible' : 'new-visible';
assert.deepEqual(mounted, [expected]);
assert.equal(opened.get(expected), 'browser');
assert.notEqual(active, 'hidden');
}
});
@@ -23,9 +23,11 @@ import {
import { useScriptExecution } from '../../application/state/useScriptExecution';
import { useRemoteHistoryState } from '../../application/state/useRemoteHistoryState';
import { resolveSystemSidebarSession } from '../../domain/systemManager/resolveSystemSession';
import { collectSessionIds } from '../../domain/workspace';
import { shouldKeepTerminalBackgroundWorkActive } from '../../domain/terminalHibernate';
import { resolveTerminalFontFamilyId } from '../../infrastructure/config/fonts';
import type { Host, TerminalSession, Workspace } from '../../types';
import { HostBrowserSidePanel } from '../HostBrowserSidePanel';
import { SystemManagerSidePanel } from '../systemManager/SystemManagerSidePanel';
import { resolveSftpFollowTerminalCwdTargetHost } from '../../domain/sftpFollowTerminalCwd';
import { AI_PANEL_FORCE_HIDE_SHELL } from '../ai/aiPanelDiagnostics';
@@ -243,6 +245,57 @@ function SidePanelSftpSlotInner({
export const SidePanelSftpSlot = memo(SidePanelSftpSlotInner);
SidePanelSftpSlot.displayName = 'SidePanelSftpSlot';
function SidePanelBrowserSlot({ tabId, ctx, isVisible }: {
tabId: string;
ctx: SidePanelStableContext;
isVisible: boolean;
}) {
const live = useSidePanelLiveSnapshotForTab(tabId, isVisible);
const sessions = ctx.sessions as TerminalSession[];
const workspace = (ctx.workspaceById as Map<string, Workspace>).get(tabId);
const current = (isVisible ? live.activeTerminalSessionForSystem : null)
?? resolveSystemSidebarSession(sessions, workspace, workspace?.focusedSessionId,
sessions.find((session) => session.id === tabId));
const retainedSessionIds = useRef(new Set<string>());
if (isVisible && current) retainedSessionIds.current.add(current.id);
const workspaceSessionIds = workspace ? new Set(collectSessionIds(workspace.root)) : null;
for (const id of retainedSessionIds.current) {
if (!sessions.some((session) => session.id === id && (workspaceSessionIds
? workspaceSessionIds.has(id) : session.id === tabId && !session.workspaceId))) retainedSessionIds.current.delete(id);
}
const sessionHosts = ctx.sessionHostsMap as Map<string, Host>;
const authHosts = ctx.sftpAuthHostBySessionId as Map<string, Host>;
const currentHost = current ? authHosts.get(current.id) ?? sessionHosts.get(current.id) : null;
const supportsBrowser = currentHost && ['ssh', 'mosh', 'local'].includes(currentHost.protocol ?? 'ssh');
return (
<div className={sidePanelHiddenPanelClassName(!isVisible)} data-section="terminal-browser-panel">
{workspace && supportsBrowser && <div className="h-8 shrink-0 border-b border-border/50 px-3 flex items-center text-xs text-muted-foreground truncate">
{currentHost.label || currentHost.hostname}
</div>}
<div className={workspace && supportsBrowser ? 'relative h-[calc(100%-2rem)] min-h-0 overflow-hidden' : 'relative h-full min-h-0 overflow-hidden'}>
{!supportsBrowser && <p className="p-4 text-sm text-muted-foreground">{ctx.t('terminal.browser.sshOnly')}</p>}
{[...retainedSessionIds.current].map((sessionId) => {
const host = authHosts.get(sessionId) ?? sessionHosts.get(sessionId);
if (!host || !['ssh', 'mosh', 'local'].includes(host.protocol ?? 'ssh')) return null;
const visible = isVisible && current?.id === sessionId;
return <div key={sessionId} className={visible ? 'absolute inset-0 overflow-hidden' : 'hidden'}>
<HostBrowserSidePanel
browserId={`${tabId}:${sessionId}`}
host={host}
hosts={ctx.effectiveHosts}
keys={ctx.keys}
identities={ctx.identities}
knownHosts={ctx.knownHosts}
terminalSettings={ctx.terminalSettings}
isVisible={visible}
/>
</div>;
})}
</div>
</div>
);
}
function SidePanelSystemSlotInner({
tabId,
ctx,
@@ -690,6 +743,7 @@ export function SidePanelMountedContent({
}) {
const {
mountedSftpTabIds,
browserMountedTabIds = [],
systemMountedTabIds,
scriptsMountedTabIds,
themeMountedTabIds,
@@ -723,6 +777,11 @@ export function SidePanelMountedContent({
return (
<>
{browserMountedTabIds.map((tabId: string) => (
<PersistentSidePanelPortal key={`browser-${tabId}`} portalKey={`browser-${tabId}`} target={portalTarget(tabId, 'browser')}>
<SidePanelBrowserSlot tabId={tabId} ctx={ctx} isVisible={isToolVisible(tabId, 'browser')} />
</PersistentSidePanelPortal>
))}
{mountedSftpTabIds.map((tabId: string) => (
<PersistentSidePanelPortal
key={`sftp-${tabId}`}
@@ -228,6 +228,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'mountedSftpTabIds',
'mountedAiTabIds',
'notesMountedTabIds',
'browserMountedTabIds',
'notesOpenNoteByTab',
'scriptsMountedTabIds',
'systemMountedTabIds',
@@ -253,6 +254,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
// SFTP Connected picker reads live terminal sessions from stable ctx.
'sessions',
'sessionHostsMap',
'sftpAuthHostBySessionId',
'workspaceById',
'keys',
'identities',
@@ -314,6 +316,7 @@ const SIDE_PANEL_STABLE_CTX_KEYS = [
'handleOpenTheme',
'handleOpenAI',
'handleOpenNotes',
'handleOpenBrowser',
'handleBackFromNotes',
'handleOpenHostFromNotes',
'handleOpenSystem',
@@ -352,6 +355,7 @@ const WORKSPACE_CTX_KEYS = [
'handleWorkspaceDrop',
'sessions',
'sessionHostsMap',
'sftpAuthHostBySessionId',
'sessionChainHostsMap',
'sessionSudoAutofillPasswordsMap',
'sessionSudoAutofillCandidatesMap',
+7 -2
View File
@@ -26,7 +26,7 @@ import {
} from "../../domain/hostClickBehavior";
import type { GroupNode, Host } from "../../domain/models";
import { isPluginHostProtocol } from "../../domain/pluginConnection";
import { OpenDualPaneSftpMenuItem } from "../host/HostTreeContextMenus";
import { OpenDualPaneSftpMenuItem, OpenHostBrowserMenuItem } from "../host/HostTreeContextMenus";
import { getVaultHostRowSubtitle } from "../../domain/localShellHost";
type VaultHostListSectionContext = Record<string, any>;
@@ -56,7 +56,7 @@ const isRelatedTargetInside = (
const EMPTY_GROUP_PATH_SET = new Set<string>();
export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext }) {
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
const { Badge, Boolean, Button, cancelInlineGroupEdit, CheckSquare, ClipboardCopy, Clock, cn, commitInlineGroupRename, ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger, Copy, displayedGroups, displayedHosts, DistroAvatar, Edit2, FileSymlink, FolderPlus, FolderTree, getDropTargetClasses, getEffectiveHostDistro, groupConfigs, groupedDisplayHosts, handleCopyCredentials, handleCopyHostname, handleDuplicateHost, handleEditGroupConfig, handleEditHost, handleHostConnect, handleOpenHostBrowser, hostClickBehavior: hostClickBehaviorProp, handleUnmanageGroup, hasHostsSidePanel, hostListScrollRef, HostTreeView, isHostsSectionActive, isMultiSelectMode, lastPinnedId, LayoutGrid, managedGroupPaths, moveGroup, moveHostToGroup, onDeleteHost, Pin, pinnedHosts, Plug, recentHosts, reorderGroup, reorderHost, sanitizeHost, search, selectedGroupPath, selectedGroupPaths, selectedHostIds, selectedTags, sessionCount, setDeleteTargetPath, setDragOverDropTarget, setGroupDragOverDropTarget, setIsDeleteGroupOpen, setIsNewFolderOpen, setLastPinnedId, setNewFolderName, setSelectedGroupPath, setTargetParentPath, shouldHideEmptyRootHostsSection, showRecentHosts, sortMode, Square, Star, startInlineDeleteGroup, startInlineNewGroup, startInlineRenameGroup, t, toggleGroupSelection, toggleHostPinned, toggleHostSelection, Trash2, treeExpandedState, treeViewGroupTree, treeViewHosts, viewMode, visibleDisplayedHosts } = ctx;
const hostClickBehavior: HostClickBehavior = hostClickBehaviorProp === 'select' ? 'select' : 'connect';
const multiSelectedGroupPaths: Set<string> = selectedGroupPaths ?? EMPTY_GROUP_PATH_SET;
const [draggingHostId, setDraggingHostId] = React.useState<string | null>(null);
@@ -611,6 +611,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={host} />
<OpenHostBrowserMenuItem host={host} onOpenBrowser={handleOpenHostBrowser} />
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
@@ -731,6 +732,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={host} />
<OpenHostBrowserMenuItem host={host} onOpenBrowser={handleOpenHostBrowser} />
<ContextMenuItem onClick={() => handleEditHost(host)}>
<Edit2 className="mr-2 h-4 w-4" /> {t('action.edit')}
</ContextMenuItem>
@@ -967,6 +969,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
onExpandAll={treeExpandedState.expandAll}
onCollapseAll={treeExpandedState.collapseAll}
onConnect={handleHostConnect}
onOpenBrowser={handleOpenHostBrowser}
onEditHost={handleEditHost}
onDuplicateHost={handleDuplicateHost}
onDeleteHost={handleTreeDeleteHost}
@@ -1112,6 +1115,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={host} />
<OpenHostBrowserMenuItem host={host} onOpenBrowser={handleOpenHostBrowser} />
<ContextMenuItem
onClick={() => handleEditHost(host)}
>
@@ -1262,6 +1266,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
<Plug className="mr-2 h-4 w-4" /> {t('vault.hosts.connect')}
</ContextMenuItem>
<OpenDualPaneSftpMenuItem host={host} />
<OpenHostBrowserMenuItem host={host} onOpenBrowser={handleOpenHostBrowser} />
<ContextMenuItem
onClick={() => handleEditHost(host)}
>
+6
View File
@@ -26,6 +26,7 @@ import { useNotesStore } from "../../application/state/notesStore";
import { LazyLoadBoundary } from "../ui/lazy-load-boundary";
import { toast } from "../ui/toast";
import { AppWordmark } from "../AppWordmark";
import { openHostBrowser } from "../../application/hostBrowser";
type VaultViewLayoutContext = Record<string, any>;
@@ -1215,6 +1216,11 @@ export function VaultViewLayout({ ctx }: { ctx: VaultViewLayoutContext }) {
handleEditGroupConfig,
handleEditHost,
handleHostConnect,
handleOpenHostBrowser: (host: any) => {
if (host.protocol && !['ssh', 'mosh', 'local'].includes(host.protocol)) return;
void openHostBrowser({ host, hosts, keys, identities, knownHosts, terminalSettings })
.catch((error) => toast.error(error instanceof Error ? error.message : 'Could not open the host browser'));
},
handleUnmanageGroup,
hasHostsSidePanel,
hostListScrollRef,
+174
View File
@@ -0,0 +1,174 @@
# Host-routed browser for issue #2972
Status: terminal sidebar integration in `codex/issue-2972-host-browser`, 2026-10-16.
The macOS sidebar and workspace focus switching have been exercised in the real
app. Electron fixtures verify two independent SSH routes, retained pages,
panel resizing, bookmarks, downloads, and closing without closing the terminal.
Windows/Linux and the remaining network acceptance cases below are not yet
runtime-verified.
## Goal and completion criteria
From a connected SSH terminal, the user opens the Browser tool in the terminal sidebar, alongside SFTP
and Notes. Its context follows the focused terminal in a workspace. All supported page traffic, including access to that host's
`127.0.0.1`, must use the selected host's network path. Loss of that path
stops browsing without switching to a local direct connection. Bookmarks sync
through the existing encrypted cloud-sync payload.
The feature is complete only after a packaged desktop build demonstrates:
1. Remote `127.0.0.1`, remote-only LAN DNS, a public exit-IP endpoint, and
HTTPS all behave as seen from the selected SSH host.
2. HTTP, HTTPS, WebSocket, redirects, popups, downloads, and background tab
requests remain on the same route. Requests using an unsupported transport
are denied rather than sent directly.
3. Disconnect, proxy setup failure, reconnection, host deletion, browser close,
and app quit leave no direct fallback or orphaned listener.
4. Tabs on two different hosts cannot share network state, cookies, local
storage, service workers, or `127.0.0.1` data.
5. Bookmarks survive restart and encrypted multi-device sync, including
simultaneous additions, edits, and deletions on two devices. Browsing
history, cookies, passwords, open tabs, and local tunnel ports do not sync.
6. The real UI is checked on macOS, Windows, and Linux: opening from a terminal,
navigation, tabs, focus, resizing, loading/errors, reconnect, and bookmarks.
## Product behavior
### Entry and terminal ownership
- Browser is a normal terminal side-panel tool, alongside SFTP and Notes. It
participates in the existing nested split layout, position chooser, and
saved layout presets. Terminal toolbar and host-menu actions open this tool.
- Each terminal keeps its own browser tabs and route. Within a workspace,
focusing another terminal reveals that terminal's browser context. Returning
restores its earlier tabs and pages without retargeting their network route.
- Do not show a permanent `Via <host>` badge in the address toolbar. A workspace
uses the same focused-host heading pattern as other side-panel tools.
- Switching tools or terminal tabs hides the native views and retains pages.
Closing the whole side panel or its terminal releases its browser views and
dedicated SSH connections. Renderer menus, dialogs, and magnified panes must
never be covered by a hidden browser view.
### Terminal links
HTTP(S) links in SSH/Mosh and local terminals open in the Netcatty sidebar by
default. Settings > Terminal > Behavior > Open terminal links in can select the
system default browser instead; the choice applies immediately to existing
terminals. SSH contexts keep the host route; local terminal contexts use an
explicit direct session. A failed SSH route never becomes a local route.
Detached terminal windows forward browser requests to the main window's sidebar.
Other terminal protocols keep their existing system-browser behavior.
### Browsing surface
- The sidebar can be resized or magnified. The trusted top chrome has a tab
strip, new/close/reorder tab controls, back, forward, reload/stop, one address
bar and a bookmarks menu. Page title and favicon appear on
tabs. The start tab is a plain app-icon grid with no duplicate address field.
It follows the main window's flat tab styling and normal desktop shortcuts.
- Website favicons are fetched through the host route and stored locally by
host and website. Users can right-click a tab to set an icon image or glyph;
that override persists until they restore the website icon. Bookmark app
icons can also use a custom image, which is included in bookmark sync.
- `127.0.0.1:80` or `localhost:80` defaults to HTTP and refers to the SSH host.
External names default to HTTPS. The address field displays the real page URL
and the focused terminal determines its host route.
- All page-created tabs and permitted window-open requests stay in this host's
browser context/profile. External protocols and "open in system browser" need
an explicit warning that the destination will use the local computer's route.
- While disconnected, show a full-panel error state and a Retry action. Do not
load cached pages as though the route were connected; stale content, if ever
shown, needs a separate offline label.
### Bookmarks
- Each item has an id, title, URL, scope (`host` or `all-hosts`), optional
hostId, display order, and timestamps. The default scope is the current host,
especially for `localhost` and private addresses. Users can explicitly make
a public bookmark available from every host.
- The menu has `This host` and `All hosts`, add/edit/delete, and a quick search.
Bookmark opening always uses the current terminal's host route. A missing host
reference is shown as unavailable; never silently attach it to another host.
- Sync bookmark metadata as its own id-based payload entity with the same
merge, deletion, backup/restore, shrink guard, and encrypted provider handling
as other vault entities. Browser cookies, history, passwords, page content,
and per-device settings remain local. Host-scoped bookmarks retain the synced
host id. Old sync payloads treat a missing bookmarks array as legacy absence,
not as a delete-all operation.
## Network and security model
- The browser chrome is trusted local UI. Each untrusted website is rendered in
a sandboxed `WebContentsView` with no Node integration, no Netcatty preload,
and no access to terminal, vault, or plugin bridges. Use an Electron session
partition separate from the application and separate for each host profile.
Tabs for one host may share website cookies; different hosts must not.
- The main process owns a `HostBrowserManager` with a small interface:
openForHost(hostId), create/close/navigate tab, bookmark operations, and
close host browser. The UI sends intent and receives state. The manager owns
panel views, tabs, session partitions, route state, and cleanup.
- Use a dedicated, temporary SSH connection for the browser, honoring
Netcatty's existing host authentication, known-host policy, jump hosts, and
connection settings. A dynamic SOCKS5 listener binds only to `127.0.0.1` on
an OS-assigned port. It is not a saved port-forward rule. Expose the actual
assigned port only inside the main process; close it with the owning browser panel.
- A loopback SOCKS listener is not exclusive to this browser: another local
process could use it while open, and Chromium does not support SOCKS5 proxy
authentication. Keep its lifetime short, never bind a public interface,
and describe this limitation honestly. If exclusive access becomes a product
requirement, the local listener design must be revisited.
- Before creating any page view, create its isolated session with a dead-end
proxy and the loopback bypass disabled. After SSH/SOCKS is ready, install a
single SOCKS5 proxy with no `DIRECT` alternative. Chromium routes names to
the SOCKS5 side and can proxy `localhost` only when its implicit bypass is
explicitly removed. On route loss, first prevent new navigation and requests,
switch to the dead-end proxy, close pooled connections, then close/replace
the SSH route. Resume only after the new route and proxy are verified.
- The existing dynamic SOCKS implementation is a starting point, not a ready
browser transport: it currently assumes caller-chosen local ports, parses
SOCKS messages from single socket chunks, and rejects IPv6 targets. Extract a
bounded, incremental SOCKS parser/listener shared with saved forwarding;
support IPv4, hostnames, and IPv6, timeouts, cancellation, and cleanup.
- Allow HTTP(S) and WS(S). Restrict or explicitly deny page capabilities that
can use a direct route outside the SOCKS TCP path (notably WebRTC/UDP), along
with unexpected URL schemes and unrestricted device permissions. Downloads
must use the isolated browser session and a reviewed destination flow.
Browser-started operations must not call the app's normal Node `fetch`, system
browser, DNS resolver, or default Electron session.
- SSH encrypts local-computer-to-host transport. Traffic from the SSH host to
the destination follows that URL's HTTP or HTTPS security; SSH alone does not
make a remote-to-website HTTP leg encrypted. The product copy must say this
accurately and must not promise anonymity or unrestricted internet access.
## Existing code seams
- Toolbar: `components/terminal/TerminalToolbar.tsx` and its caller in
`components/Terminal.tsx`.
- SSH forwarding: `electron/bridges/portForwardingBridge.cjs` and
`infrastructure/services/portForwardingService.ts`. The saved rule's fixed
port and persistence model should not own temporary browser routes.
- Isolated web content and fail-closed proxy patterns already exist in
`electron/plugins/pluginViewHost.cjs`; reuse the principles, not the plugin
permissions or protocol.
- Sync entity/payload: `domain/sync.ts`, `domain/syncMerge.ts`,
`application/syncPayload.ts`, and the auto/cloud sync application hooks.
Existing SFTP global bookmarks live inside settings, but browser bookmarks
need host scope and per-item conflict/deletion handling.
## Delivery order
1. Network proof in the actual Electron runtime: isolated session, SSH SOCKS5,
`localhost` through the host, remote DNS, public exit IP, WS(S), disconnect
kill switch, and packet-capture checks for direct DNS/WebRTC/UDP leakage.
This is a release gate, not a mock-only test.
2. Browser manager and safe window/tab lifecycle, including page isolation,
navigation, popups, permissions, downloads, and multi-host separation.
3. Toolbar entry and full browsing UI, with real click-through QA.
4. Bookmark storage and encrypted sync migration/merge; cross-device QA.
5. Packaged smoke tests on all supported desktop platforms and resource cleanup.
Primary references: [Netcatty issue #2972](https://github.com/binaricat/Netcatty/issues/2972),
[Electron web embeds](https://www.electronjs.org/docs/latest/tutorial/web-embeds),
[Electron session proxy](https://www.electronjs.org/docs/latest/api/session),
[Chromium proxy behavior](https://chromium.googlesource.com/chromium/src/+/main/net/docs/proxy.md),
[Electron security guidance](https://www.electronjs.org/docs/latest/tutorial/security).
@@ -0,0 +1,32 @@
# WaveTerm embedded browser and remote localhost
Checked 2026-09-29. Scope: WaveTerm's own website/docs, GitHub discussions/issues, and the 2026-09-25 source commit `c58bf7f346d0a638f3e5d7c77b2688122d36fede`. No runtime verification.
## Confirmed user workflow
- Wave has an **inline web browser** for sites, dashboards, and apps. Its website says web views can be arranged alongside terminals and editors. The docs say the right-side widget bar adds a web widget to the **current tab**; widgets can be moved, resized, and magnified. [Website](https://www.waveterm.dev/) · [Widgets](https://docs.waveterm.dev/widgets)
- A user can enter a URL in the web widget, or run `wsh web open <url>` from a Wave terminal. `wsh web open` creates a web block; `-m` magnifies it and `-r <blockid>` replaces an existing block. The docs' local development example starts a server and then opens `http://localhost:3000` in a web block. [wsh overview](https://docs.waveterm.dev/wsh) · [wsh reference](https://docs.waveterm.dev/wsh-reference)
- Web navigation has a URL field, back/forward, reload, in-page find, and bookmarks; the documented shortcuts include `Alt+L`, `Alt+Left/Right`, `Alt+R`, `Alt+F`, and `Alt+O`. A home page can be set globally or per widget. `web:openlinksinternally=false` sends web links to an external browser. [Key bindings](https://docs.waveterm.dev/keybindings) · [Custom widgets](https://docs.waveterm.dev/customwidgets) · [Configuration](https://docs.waveterm.dev/config)
## Remote `127.0.0.1`: not established as automatic
- **Confirmed:** Wave's SSH connection picker opens a remote terminal. With `wsh` installed, the remote can also back file-preview and system-info widgets, and a remote shell can control Wave widgets. The [Connections](https://docs.waveterm.dev/connections) page does **not** list the web widget as a connection-backed widget. The [wsh overview](https://docs.waveterm.dev/wsh) puts `wsh web open http://localhost:3000` under *Development Workflow* and lists SSH under a separate *Remote Development* example; it does not say the first command retargets localhost to an SSH host.
- **Best reading, still an inference:** entering `http://127.0.0.1:<port>` in Wave's web widget addresses the machine running the desktop app unless the user has separately made the remote port reachable. Do **not** describe SSH remote loopback as auto-forwarded from these docs. A [May 2026 feature request](https://github.com/wavetermdev/waveterm/issues/3276) explicitly asks for a port-forwarding widget to avoid typing `ssh -L 3000:localhost:3000 user@server`; it was still open with no linked closing PR when checked on 2026-09-29. That request supports the gap but is not, by itself, proof of current behavior.
- **Candidate manual workflow, inferred from that request:** create a local SSH tunnel such as `ssh -L 3000:localhost:3000 user@server`, then open `http://127.0.0.1:3000` in Wave's web block. This requires a working tunnel and an available local port; the reviewed docs do not present it as an integrated Wave flow. [Port-forwarding request](https://github.com/wavetermdev/waveterm/issues/3276) · [wsh web command](https://docs.waveterm.dev/wsh-reference)
- **Different forwarding path:** the [Connections troubleshooting guide](https://docs.waveterm.dev/connections) says SSH server TCP forwarding settings can affect `wsh`; a [GitHub discussion](https://github.com/wavetermdev/waveterm/discussions/2757) reports `wsh` failing where stream-local forwarding is denied. Neither source establishes automatic forwarding of arbitrary HTTP ports to the browser.
## Source-code check at the pinned commit
- [`wsh web open`](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/cmd/wsh/cmd/wshcmd-web.go#L100-L139) creates a web block containing the supplied URL. It does not resolve the current SSH host or request a forward in this command path.
- The [web block](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/frontend/app/view/webview/webview.tsx#L1106-L1121) uses Electron's `<webview>` to load its URL; its [navigation path](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/frontend/app/view/webview/webview.tsx#L393-L470) normalizes the scheme and passes the resulting URL directly to the webview. **Inference from these paths:** `localhost` resolves on the computer running Wave, even when `wsh web open` is invoked in an SSH terminal. This is not a live acceptance test.
- The web block implements [address, back/forward, reload, external-open](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/frontend/app/view/webview/webview.tsx#L127-L213) and [load-error feedback](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/frontend/app/view/webview/webview.tsx#L1011-L1050). Popups are intercepted by the [host tab](https://github.com/wavetermdev/waveterm/blob/c58bf7f346d0a638f3e5d7c77b2688122d36fede/emain/emain-tabview.ts#L315-L324) and converted into Wave's link-opening flow.
- Wave uses an Electron `<webview>` tag. [Electron's current guidance](https://www.electronjs.org/docs/latest/api/webview-tag) advises considering alternatives such as `WebContentsView`; Netcatty should copy the workspace interaction, not assume the exact embedding mechanism is the best current choice.
## Security and limits of the evidence
- The website says Wave stores user data locally and needs no account; that is a general product statement, **not** a browser-specific guarantee about cookies, site isolation, permissions, or proxying. The browser docs found here do not specify those protections or a remote-URL trust policy. [Website](https://www.waveterm.dev/) · [Configuration](https://docs.waveterm.dev/config)
- Web links can be opened externally, but an [older GitHub report](https://github.com/wavetermdev/waveterm/issues/1733) said the external-browser button picked Chrome instead of the system default on Ubuntu; it is version-specific and should not be generalized. A [v0.14.5 macOS report](https://github.com/wavetermdev/waveterm/issues/3428) describes unresponsiveness when switching between two web blocks; it is a report, not a confirmed universal limitation.
**Practical conclusion:** the documented product is a browser block beside terminal blocks. The reviewed first-party material does not confirm automatic SSH forwarding for a service bound to remote `127.0.0.1`; treat that behavior as unverified until tested in the app or confirmed by a direct WaveTerm statement.
For Netcatty issue [#2972](https://github.com/binaricat/Netcatty/issues/2972), the reusable product idea is a resizable web pane beside the terminal, with address, navigation, expand, and clear load errors. The distinct requirement is automatically connecting that pane to the selected SSH host's private web service, showing which host `127.0.0.1` refers to, and releasing the temporary access path when the pane closes. These are design recommendations, not claims about Wave's behavior.
+14
View File
@@ -0,0 +1,14 @@
export interface BrowserBookmark {
id: string;
title: string;
url: string;
scope: 'host' | 'all-hosts';
hostId: string | null;
icon: string;
iconMode?: 'auto' | 'custom';
iconDataUrl?: string | null;
color: string;
order: number;
createdAt: number;
updatedAt: number;
}
+2
View File
@@ -50,6 +50,7 @@ export const CONVERGENT_ENTITY_COLLECTIONS = [
'snippets',
'notes',
'portForwardingRules',
'browserBookmarks',
'groupConfigs',
] as const satisfies readonly CloudSyncPayloadEntityKey[];
@@ -230,6 +231,7 @@ export function materializeSyncPayloadFromConvergentState(
notes: typedCollection<import('../models').VaultNote>(materialized.collections, 'notes'),
noteGroups: materialized.stringCollections.noteGroups ?? [],
portForwardingRules: typedCollection<import('../models').PortForwardingRule>(materialized.collections, 'portForwardingRules'),
browserBookmarks: typedCollection<import('../browserBookmark').BrowserBookmark>(materialized.collections, 'browserBookmarks'),
groupConfigs,
settings,
syncedAt: options.syncedAt,
@@ -444,6 +444,22 @@ test('trusted legacy diff treats own undefined optional fields as omitted', () =
assert.equal(materialized.settings?.theme, 'dark');
});
test('legacy browser clients cannot erase host browser bookmarks they do not know about', () => {
const baseline = payload();
baseline.browserBookmarks = [{
id: 'bookmark-1', title: 'Dashboard', url: 'https://dashboard.example/',
scope: 'host', hostId: 'host-1', icon: 'D', color: '#247969',
order: 0, createdAt: NOW, updatedAt: NOW,
}];
const legacy = { ...baseline };
delete legacy.browserBookmarks;
assert.deepEqual(diffLegacySyncPayload(baseline, legacy), []);
const state = createConvergentSyncStateFromPayload(baseline, 'seed', NOW);
const next = applyLegacySyncPayload(state, baseline, legacy, 'legacy-device', NOW + 1);
assert.deepEqual(materializeSyncPayloadFromConvergentState(next, { syncedAt: NOW + 1 }).browserBookmarks, baseline.browserBookmarks);
});
test('payload conversion still rejects entities that JSON cannot serialize', () => {
const invalid = payload();
const circular: Record<string, unknown> = {};
+3
View File
@@ -126,6 +126,7 @@ export interface TerminalSettings {
middleClickPaste: boolean; // Legacy mirror for older settings payloads
wordSeparators: string; // Characters for word selection
linkModifier: LinkModifier; // Modifier key to click links
linkBrowser?: 'netcatty' | 'system';
autoCloseOnExit: boolean; // Automatically close terminal UI after eligible session exits
disconnectedNoticeMode: DisconnectedNoticeMode; // Non-blocking terminal line or legacy dialog after disconnect
/** Action when double-clicking a terminal session tab. */
@@ -428,6 +429,7 @@ export const normalizeTerminalSettings = (
middleClickBehavior,
middleClickPaste: middleClickBehavior === 'paste',
wordSeparators,
linkBrowser: settings?.linkBrowser === 'system' ? 'system' as const : 'netcatty' as const,
shiftEnterNewlineText,
cursorBarWidth: normalizeCursorBarWidth(settings?.cursorBarWidth),
dynamicTabTitleMode: isDynamicTabTitleMode(settings?.dynamicTabTitleMode)
@@ -537,6 +539,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
middleClickPaste: true,
wordSeparators: DEFAULT_TERMINAL_WORD_SEPARATORS,
linkModifier: 'none',
linkBrowser: 'netcatty',
// Off by default: remote graceful channel closes can be reported as a clean
// exit (code 0), which would auto-close tabs on network drops. Users who
// want close-on-exit can enable it in Settings -> Terminal -> Behavior.
+1
View File
@@ -1,5 +1,6 @@
export type SidePanelTool =
| 'sftp'
| 'browser'
| 'scripts'
| 'history'
| 'theme'
+3
View File
@@ -296,6 +296,7 @@ export interface SyncPayload {
* are stripped — only their metadata travels.
*/
aiSessions?: import('../infrastructure/ai/types').AISession[];
browserBookmarks?: import('./browserBookmark').BrowserBookmark[];
// Known hosts
knownHosts?: import('./models').KnownHost[];
@@ -479,6 +480,7 @@ export const SYNC_PAYLOAD_ENTITY_KEYS = [
'notes',
'noteGroups',
'portForwardingRules',
'browserBookmarks',
'knownHosts',
'groupConfigs',
] as const;
@@ -494,6 +496,7 @@ export const CLOUD_SYNC_PAYLOAD_ENTITY_KEYS = [
'notes',
'noteGroups',
'portForwardingRules',
'browserBookmarks',
'groupConfigs',
'aiSessions',
] as const;
+25
View File
@@ -5,6 +5,31 @@ import { mergeSyncPayloads } from "./syncMerge.ts";
import { withSyncReliabilityMeta } from "./syncReliability.ts";
import type { SyncPayload } from "./sync.ts";
const bookmark = (id: string, title = id) => ({
id, title, url: `https://${id}.example.com/`, scope: 'host' as const,
hostId: 'host-1', icon: 'A', color: '#247969', order: 0,
createdAt: 1, updatedAt: 1,
});
test('browser bookmark additions from two devices merge by ID', () => {
const result = mergeSyncPayloads(
payload({ browserBookmarks: [] }),
payload({ browserBookmarks: [bookmark('local')] }),
payload({ browserBookmarks: [bookmark('remote')] }),
);
assert.deepEqual(result.payload.browserBookmarks?.map((item) => item.id).sort(), ['local', 'remote']);
});
test('browser bookmark deletion survives an unchanged remote and legacy omission', () => {
const base = payload({ browserBookmarks: [bookmark('app')] });
const local = payload({ browserBookmarks: [] });
const unchanged = payload({ browserBookmarks: [bookmark('app')] });
assert.deepEqual(mergeSyncPayloads(base, local, unchanged).payload.browserBookmarks, []);
const legacy = payload();
delete legacy.browserBookmarks;
assert.deepEqual(mergeSyncPayloads(base, unchanged, legacy).payload.browserBookmarks, [bookmark('app')]);
});
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
return {
hosts: [],
+12 -1
View File
@@ -80,6 +80,7 @@ const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
'notes',
'noteGroups',
'portForwardingRules',
'browserBookmarks',
'groupConfigs',
'aiSessions',
]);
@@ -796,6 +797,7 @@ export function mergeSyncPayloads(
noteGroups: [],
portForwardingRules: [],
aiSessions: [],
browserBookmarks: [],
settings: undefined,
syncedAt: 0,
};
@@ -858,6 +860,14 @@ export function mergeSyncPayloads(
tombstones('portForwardingRules'),
mergeOptions,
);
const baseBrowserBookmarks = b.browserBookmarks ?? [];
const browserBookmarks = mergeEntityArrays(
baseBrowserBookmarks,
entityArray(local, 'browserBookmarks', baseBrowserBookmarks),
entityArray(remote, 'browserBookmarks', baseBrowserBookmarks),
tombstones('browserBookmarks'),
mergeOptions,
);
// Merge group configs (keyed by path — wrap with virtual id for entity merge)
type GCWithId = import('./models').GroupConfig & { id: string };
@@ -885,7 +895,7 @@ export function mergeSyncPayloads(
// Aggregate stats
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
[hosts, keys, identities, proxyProfiles, snippets, notes, portForwardingRules, groupConfigsResult, aiSessions];
[hosts, keys, identities, proxyProfiles, snippets, notes, portForwardingRules, browserBookmarks, groupConfigsResult, aiSessions];
for (const r of entityResults) {
summary.added.local += r.added.local;
summary.added.remote += r.added.remote;
@@ -981,6 +991,7 @@ export function mergeSyncPayloads(
notes: notes.merged,
noteGroups,
portForwardingRules: portForwardingRules.merged,
browserBookmarks: browserBookmarks.merged,
groupConfigs,
...(base?.aiSessions !== undefined || local.aiSessions !== undefined || remote.aiSessions !== undefined
? { aiSessions: aiSessions.merged }
+1
View File
@@ -24,6 +24,7 @@ const OPTIONAL_ENTITY_KEYS = new Set<CloudSyncPayloadEntityKey>([
'proxyProfiles',
'snippetPackages',
'portForwardingRules',
'browserBookmarks',
'groupConfigs',
'aiSessions',
]);
+5
View File
@@ -320,6 +320,11 @@ test("normalizeTerminalSettings preserves intentional custom autocompleteMaxSugg
);
});
test("terminal links default to Netcatty and preserve the system browser choice", () => {
assert.equal(normalizeTerminalSettings().linkBrowser, 'netcatty');
assert.equal(normalizeTerminalSettings({ linkBrowser: 'system' }).linkBrowser, 'system');
});
test("normalizeTerminalSettings keeps multi-line paste confirmation opt-in", () => {
assert.equal(normalizeTerminalSettings().confirmBeforeMultilinePaste, false);
assert.equal(
+9
View File
@@ -93,3 +93,12 @@ test("explicit per-session SFTP requests take priority on supported transports",
assert.equal(resolveSessionSidePanelAutoOpen({ ...preferences, terminalTab: "sftp", legacySftpEnabled: true, session: { protocol, autoOpenSidePanel: "sftp" } }), null);
}
});
test("Browser auto-open skips unsupported terminal protocols", () => {
for (const protocol of ['ssh', 'mosh', 'local', undefined] as const) {
assert.equal(resolveSessionSidePanelAutoOpen({ ...preferences, terminalTab: 'browser', localTab: 'browser', session: { protocol } }), 'browser');
}
for (const protocol of ['telnet', 'serial', 'et', 'plugin:example'] as const) {
assert.equal(resolveSessionSidePanelAutoOpen({ ...preferences, terminalTab: 'browser', session: { protocol } }), null);
}
});
+2 -1
View File
@@ -2,6 +2,7 @@ import type { TerminalSession } from "./models";
export const TERMINAL_SIDE_PANEL_AUTO_OPEN_TABS = [
"sftp",
"browser",
"scripts",
"history",
"theme",
@@ -32,7 +33,7 @@ export function resolveTerminalSidePanelAutoOpen({
sftpAvailable: boolean;
}): TerminalSidePanelAutoOpenTab | null {
if (!enabled) return null;
if (selectedTab === "sftp" && !sftpAvailable) return null;
if ((selectedTab === "sftp" || selectedTab === "browser") && !sftpAvailable) return null;
return selectedTab;
}
+1
View File
@@ -22,6 +22,7 @@ export type WorkspaceLayoutPreset = {
const SIDE_PANEL_TOOLS: ReadonlySet<SidePanelTool> = new Set([
'sftp',
'browser',
'scripts',
'history',
'theme',
+36 -40
View File
@@ -4,6 +4,7 @@
*/
const net = require("node:net");
const { createSocks5RequestParser } = require("./socks5Request.cjs");
require("./boringSslDhCompat.cjs").installBoringSslDhCompat();
const { Client: SSHClient } = require("ssh2");
const { NetcattyAgent } = require("./netcattyAgent.cjs");
@@ -46,6 +47,12 @@ const {
// Active port forwarding tunnels
const portForwardingTunnels = new Map();
const tunnelStatusListeners = new Set();
function onTunnelStatus(listener) {
tunnelStatusListeners.add(listener);
return () => tunnelStatusListeners.delete(listener);
}
// Process-scoped authority metadata for renderer projections (#2288).
// Epoch changes whenever this module (main or terminal worker) boots.
@@ -791,44 +798,17 @@ function bindPortForwardChannels({
if (type === "dynamic") {
const server = net.createServer((socket) => {
const pipeEntry = trackTunnelPipe(tunnelState, socket);
socket.once("data", (data) => {
if (data[0] !== 0x05) {
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
let greetingSent = false;
const parser = createSocks5RequestParser({
onFailure: (code) => {
if (!greetingSent && code === 0xff) socket.write(Buffer.from([0x05, 0xff]));
else if (greetingSent) socket.write(Buffer.from([0x05, code, 0, 1, 0, 0, 0, 0, 0, 0]));
socket.end();
return;
}
socket.write(Buffer.from([0x05, 0x00]));
socket.once("data", (request) => {
if (request[0] !== 0x05 || request[1] !== 0x01) {
socket.write(Buffer.from([0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
let targetHost;
let targetPort;
const addressType = request[3];
if (addressType === 0x01) {
targetHost = `${request[4]}.${request[5]}.${request[6]}.${request[7]}`;
targetPort = request.readUInt16BE(8);
} else if (addressType === 0x03) {
const domainLength = request[4];
targetHost = request.slice(5, 5 + domainLength).toString();
targetPort = request.readUInt16BE(5 + domainLength);
} else if (addressType === 0x04) {
socket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
} else {
socket.write(Buffer.from([0x05, 0x08, 0x00, 0x01, 0, 0, 0, 0, 0, 0]));
socket.end();
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
return;
}
destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false });
},
onConnect: (targetHost, targetPort, remaining) => {
socket.pause();
socket.setTimeout(0);
pipeEntry.openStarted = true;
openBoundedForwardOutCallback(
conn,
@@ -851,14 +831,23 @@ function bindPortForwardChannels({
reply[3] = 0x01;
reply.writeUInt16BE(0, 8);
socket.write(reply);
if (remaining.length) stream.write(remaining);
socket.pipe(stream).pipe(socket);
socket.resume();
socket.on("error", () => stream.end());
stream.on("error", () => socket.end());
},
{ signal: pipeEntry?.openAbortController?.signal },
);
});
},
});
parser.onGreeting = () => {
greetingSent = true;
socket.write(Buffer.from([0x05, 0x00]));
};
socket.setTimeout(15_000, () => socket.destroy());
socket.on("data", (data) => parser.push(data));
socket.on("error", () => destroyTunnelPipeEntry(tunnelState, pipeEntry, { abortOpen: false }));
});
server.on("error", (err) => {
@@ -867,7 +856,8 @@ function bindPortForwardChannels({
});
server.listen(localPort, bindAddress, () => {
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
const assignedPort = server.address()?.port;
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${assignedPort}`);
try {
if (isTunnelCancelled(tunnelState)) {
try { server.close(); } catch { /* ignore */ }
@@ -890,7 +880,7 @@ function bindPortForwardChannels({
portForwardingTunnels.set(tunnelId, tunnelState);
sendStatus?.("active");
settled = true;
resolve({ tunnelId, success: true });
resolve({ tunnelId, success: true, localPort: assignedPort });
} catch (regErr) {
try { server.close(); } catch { /* ignore */ }
fail(regErr);
@@ -922,6 +912,11 @@ function publishTunnelStatus(tunnelId, tunnel, status, error = null) {
if (!tunnel) return;
tunnel.status = status;
tunnel.error = error || undefined;
for (const listener of tunnelStatusListeners) {
try { listener({ tunnelId, status, error }); } catch (listenerError) {
console.warn("[PortForward] Tunnel status listener failed:", listenerError);
}
}
tunnel.updatedAt = Date.now();
const runtimeEvent = publishRuntimeUpsert(tunnelId, tunnel);
const subscribers = tunnel.subscribers instanceof Map
@@ -2119,6 +2114,7 @@ function registerHandlers(ipcMain, options = {}) {
}
module.exports = {
onTunnelStatus,
registerHandlers,
startPortForward,
stopPortForward,
+69
View File
@@ -0,0 +1,69 @@
"use strict";
const MAX_HANDSHAKE_BYTES = 1024;
function parseIpv6(bytes) {
const groups = [];
for (let index = 0; index < 16; index += 2) {
groups.push(bytes.readUInt16BE(index).toString(16));
}
return groups.join(":");
}
/** Incremental SOCKS5 CONNECT handshake; leaves post-handshake bytes untouched. */
function createSocks5RequestParser({ onConnect, onFailure }) {
let state = "greeting";
let buffer = Buffer.alloc(0);
const fail = (code = 0x01) => {
if (state === "failed" || state === "connected") return;
state = "failed";
onFailure(code);
};
return {
push(chunk) {
if (state === "failed" || state === "connected") return;
if (!Buffer.isBuffer(chunk)) { fail(); return; }
buffer = Buffer.concat([buffer, chunk]);
if (buffer.length > MAX_HANDSHAKE_BYTES) { fail(); return; }
while (true) {
if (state === "greeting") {
if (buffer.length < 2) return;
const count = buffer[1];
if (buffer[0] !== 5 || count < 1) { fail(); return; }
if (buffer.length < 2 + count) return;
const acceptsNoAuth = buffer.subarray(2, 2 + count).includes(0);
buffer = buffer.subarray(2 + count);
if (!acceptsNoAuth) { fail(0xff); return; }
state = "request";
this.onGreeting?.();
}
if (state !== "request") return;
if (buffer.length < 4) return;
if (buffer[0] !== 5 || buffer[2] !== 0) { fail(); return; }
if (buffer[1] !== 1) { fail(0x07); return; }
const addressType = buffer[3];
const addressBytes = addressType === 1 ? 4 : addressType === 4 ? 16 : addressType === 3 ? (buffer.length < 5 ? -1 : buffer[4] + 1) : 0;
if (addressBytes === -1) return;
if (!addressBytes || (addressType === 3 && addressBytes === 1)) { fail(0x08); return; }
const addressStart = 4 + (addressType === 3 ? 1 : 0);
const addressEnd = addressStart + addressBytes - (addressType === 3 ? 1 : 0);
if (buffer.length < addressEnd + 2) return;
const address = buffer.subarray(addressStart, addressEnd);
const host = addressType === 1 ? Array.from(address).join(".")
: addressType === 4 ? parseIpv6(address)
: address.toString("utf8");
const port = buffer.readUInt16BE(addressEnd);
if (!host || !port) { fail(0x08); return; }
const remaining = buffer.subarray(addressEnd + 2);
buffer = Buffer.alloc(0);
state = "connected";
onConnect(host, port, remaining);
return;
}
},
fail,
onGreeting: null,
};
}
module.exports = { createSocks5RequestParser };
+46
View File
@@ -0,0 +1,46 @@
"use strict";
const { test } = require("node:test");
const assert = require("node:assert/strict");
const { createSocks5RequestParser } = require("./socks5Request.cjs");
function harness() {
const events = [];
const parser = createSocks5RequestParser({
onConnect: (host, port, remainder) => events.push({ host, port, remainder: [...remainder] }),
onFailure: (code) => events.push({ failure: code }),
});
parser.onGreeting = () => events.push({ greeting: true });
return { parser, events };
}
test("SOCKS5 parses split greeting and domain request with retained page bytes", () => {
const { parser, events } = harness();
parser.push(Buffer.from([5]));
parser.push(Buffer.from([1, 0, 5, 1, 0, 3, 9, 108, 111]));
parser.push(Buffer.from("calhost"));
parser.push(Buffer.from([0, 80, 71, 69, 84]));
assert.deepEqual(events, [
{ greeting: true },
{ host: "localhost", port: 80, remainder: [71, 69, 84] },
]);
});
test("SOCKS5 accepts IPv4 and IPv6 CONNECT targets", () => {
const ipv4 = harness();
ipv4.parser.push(Buffer.from([5, 1, 0, 5, 1, 0, 1, 127, 0, 0, 1, 0, 80]));
assert.equal(ipv4.events[1].host, "127.0.0.1");
const ipv6 = harness();
ipv6.parser.push(Buffer.from([5, 1, 0, 5, 1, 0, 4, ...Array(15).fill(0), 1, 1, 187]));
assert.equal(ipv6.events[1].host, "0:0:0:0:0:0:0:1");
assert.equal(ipv6.events[1].port, 443);
});
test("SOCKS5 refuses unsupported commands and oversized handshakes", () => {
const command = harness();
command.parser.push(Buffer.from([5, 1, 0, 5, 3, 0, 1, 127, 0, 0, 1, 0, 80]));
assert.deepEqual(command.events.at(-1), { failure: 7 });
const oversized = harness();
oversized.parser.push(Buffer.alloc(1025, 5));
assert.deepEqual(oversized.events, [{ failure: 1 }]);
});
+91
View File
@@ -0,0 +1,91 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const { validImageDataUrl } = require("./siteIconStore.cjs");
const MAX_BOOKMARKS = 1000;
function normalizeBookmark(raw, previous = null) {
if (!raw || typeof raw !== "object") throw new Error("Invalid bookmark");
const url = new URL(String(raw.url || ""));
if (!["http:", "https:"].includes(url.protocol) || !url.hostname || url.username || url.password) {
throw new Error("Bookmarks require an HTTP or HTTPS address without credentials");
}
const scope = raw.scope === "all-hosts" ? "all-hosts" : "host";
const hostId = scope === "host" ? String(raw.hostId || "").slice(0, 256) : null;
if (scope === "host" && !hostId) throw new Error("Host bookmark is missing its host");
const title = String(raw.title || url.hostname).trim().slice(0, 120);
const icon = String(raw.icon || title[0] || "\u25c7").trim().slice(0, 4);
const color = /^#[0-9a-f]{6}$/i.test(raw.color) ? raw.color : "#247969";
const now = Date.now();
return {
id: previous?.id || (typeof raw.id === "string" && raw.id.trim() && raw.id.length <= 128 ? raw.id : randomUUID()),
title: title || url.hostname,
url: url.href,
scope,
hostId,
icon: icon || "\u25c7",
iconMode: raw.iconMode === "custom" ? "custom" : "auto",
iconDataUrl: raw.iconDataUrl === null ? null
: validImageDataUrl(raw.iconDataUrl) ? raw.iconDataUrl
: previous?.iconDataUrl || null,
color,
order: Number.isSafeInteger(raw.order) ? raw.order : (previous?.order ?? 0),
createdAt: previous?.createdAt || (Number.isSafeInteger(raw.createdAt) && raw.createdAt > 0 ? raw.createdAt : now),
updatedAt: previous ? now : (Number.isSafeInteger(raw.updatedAt) && raw.updatedAt > 0 ? raw.updatedAt : now),
};
}
function createBookmarkStore(app, onChange = () => {}) {
const file = path.join(app.getPath("userData"), "host-browser-bookmarks.json");
let items = [];
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (Array.isArray(parsed) && parsed.length <= MAX_BOOKMARKS) {
items = parsed.flatMap((item) => {
try { return [normalizeBookmark(item)]; } catch { return []; }
});
}
} catch (error) {
if (error?.code !== "ENOENT") console.warn("[HostBrowser] Could not read bookmarks:", error?.message);
}
const persist = () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(items), { mode: 0o600 });
fs.renameSync(temporary, file);
onChange(items);
};
return {
list: () => items.map((item) => ({ ...item })),
upsert(input) {
const index = items.findIndex((item) => item.id === input?.id);
if (index < 0 && items.length >= MAX_BOOKMARKS) throw new Error("Bookmark limit reached");
const next = normalizeBookmark(input, index >= 0 ? items[index] : null);
if (index >= 0) items[index] = next;
else items.push(next);
persist();
return { ...next };
},
remove(id) {
const next = items.filter((item) => item.id !== id);
if (next.length !== items.length) { items = next; persist(); }
},
apply(input) {
if (!Array.isArray(input) || input.length > MAX_BOOKMARKS) throw new Error("Invalid bookmark list");
const ids = new Set();
const next = input.map((item) => {
const normalized = normalizeBookmark(item);
if (ids.has(normalized.id)) throw new Error("Duplicate bookmark ID");
ids.add(normalized.id);
return normalized;
});
items = next;
persist();
},
};
}
module.exports = { createBookmarkStore, normalizeBookmark };
@@ -0,0 +1,25 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { createBookmarkStore } = require("./bookmarkStore.cjs");
const iconDataUrl = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLytQAAAABJRU5ErkJggg==";
test("custom bookmark icons persist and survive cloud apply", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-browser-bookmarks-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const app = { getPath: () => dir };
const first = createBookmarkStore(app);
const saved = first.upsert({ title: "App", url: "https://example.com/", scope: "host", hostId: "host-a", icon: "A", iconMode: "custom", iconDataUrl });
assert.equal(saved.iconDataUrl, iconDataUrl);
assert.equal(saved.iconMode, "custom");
const reopened = createBookmarkStore(app);
assert.equal(reopened.list()[0].iconDataUrl, iconDataUrl);
reopened.apply([{ ...saved, scope: "all-hosts", hostId: null }]);
assert.equal(createBookmarkStore(app).list()[0].iconDataUrl, iconDataUrl);
assert.equal(createBookmarkStore(app).list()[0].iconMode, "custom");
});
+170
View File
@@ -0,0 +1,170 @@
:root {
color-scheme: light;
--browser-chrome-height: 80px;
--background: 216 33% 96%;
--foreground: 222 47% 12%;
--secondary: 220 16% 90%;
--muted: 220 16% 90%;
--muted-foreground: 220 10% 45%;
--border: 220 16% 82%;
--primary: 208 100% 50%;
--primary-foreground: 0 0% 100%;
--font-sans: "Mona Sans", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei UI", system-ui, sans-serif;
}
.dark {
color-scheme: dark;
--background: 220 28% 8%;
--foreground: 210 40% 95%;
--secondary: 220 16% 16%;
--muted: 220 16% 16%;
--muted-foreground: 220 10% 70%;
--border: 220 22% 18%;
--primary: 200 100% 61%;
--primary-foreground: 220 28% 8%;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 0; background: hsl(var(--background)); color: hsl(var(--foreground)); font-family: var(--font-sans); }
button, input, select { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: default; opacity: .45; }
[hidden] { display: none !important; }
.chrome { height: var(--browser-chrome-height); position: relative; z-index: 2; background: hsl(var(--background)); }
.tab-bar { height: 36px; display: flex; align-items: end; background: hsl(var(--secondary)); padding-left: 12px; -webkit-app-region: drag; user-select: none; }
[data-platform="darwin"] .tab-bar { padding-left: 76px; }
.tabs { display: flex; align-items: end; gap: 0; flex: 0 1 auto; min-width: 0; overflow-x: auto; scrollbar-width: none; -webkit-app-region: no-drag; }
.tabs::-webkit-scrollbar { display: none; }
.tab { display: flex; align-items: center; flex: 0 0 200px; min-width: 140px; max-width: 200px; height: 28px; gap: 8px; padding: 0 8px 0 12px; border-radius: 6px 6px 0 0; background: transparent; color: hsl(var(--muted-foreground)); font-size: 12px; font-weight: 600; cursor: pointer; outline: none; }
.tab:hover { background: hsl(var(--background) / .4); color: hsl(var(--foreground)); }
.tab.active, .tab.active:hover { background: hsl(var(--background)); color: hsl(var(--foreground)); }
.tab.dragging { opacity: .5; }
.tab:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: -2px; }
.tab-glyph { flex: none; color: hsl(var(--primary)); font-size: 14px; font-weight: 400; }
.tab-glyph:empty { display: none; }
.tab-favicon { flex: none; width: 15px; height: 15px; object-fit: contain; }
.tab-title { min-width: 0; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: left; }
.tab-close { display: grid; place-items: center; flex: none; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 4px; background: transparent; color: hsl(var(--muted-foreground)); font-size: 16px; line-height: 1; }
.tab-close:hover { background: hsl(var(--foreground) / .1); color: hsl(var(--foreground)); }
.drag-space { flex: 1; min-width: 8px; }
.icon-button { display: grid; place-items: center; flex: none; width: 30px; height: 30px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: hsl(var(--muted-foreground)); font-size: 18px; line-height: 1; -webkit-app-region: no-drag; }
.icon-button:hover:not(:disabled) { background: hsl(var(--foreground) / .08); color: hsl(var(--foreground)); }
.icon-button svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.new-tab { width: 28px; height: 28px; margin: 0 2px 0 4px; font-size: 20px; }
.window-controls { display: flex; align-self: stretch; -webkit-app-region: no-drag; }
[data-platform="darwin"] .window-controls { display: none; }
.window-control { width: 40px; border: 0; background: transparent; color: hsl(var(--muted-foreground)); font-size: 18px; }
.window-control:hover { background: hsl(var(--foreground) / .1); color: hsl(var(--foreground)); }
.window-control.close:hover { background: #ef4444; color: white; }
.nav-bar { height: 44px; display: flex; align-items: center; gap: 4px; padding: 0 12px; border-bottom: 1px solid hsl(var(--border) / .5); background: hsl(var(--background)); }
.address-form { display: flex; align-items: center; flex: 1; min-width: 0; height: 30px; margin: 0 8px; padding: 0 11px; border: 1px solid hsl(var(--border) / .65); border-radius: 6px; background: hsl(var(--secondary) / .5); }
.address-form:focus-within { border-color: hsl(var(--primary) / .7); }
.address-form input { flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; color: hsl(var(--foreground)); font-size: 12px; }
.downloads-button { position: relative; }
.downloads-button[aria-expanded="true"] { background: hsl(var(--foreground) / .08); color: hsl(var(--foreground)); }
.download-indicator { display: none; position: absolute; right: 3px; top: 3px; width: 5px; height: 5px; border-radius: 50%; background: hsl(var(--primary)); }
.downloads-button.has-active .download-indicator { display: block; }
.home { width: 100%; height: calc(100vh - var(--browser-chrome-height)); padding: 54px 28px 45px; overflow: auto; background: hsl(var(--background)); }
.bookmark-panel-open .home { width: calc(100% - 360px); }
.home-content { width: min(680px, 100%); margin: 0 auto; }
h1 { margin: 0; font-size: 21px; font-weight: 650; letter-spacing: -.02em; }
p { margin: 5px 0 0; color: hsl(var(--muted-foreground)); font-size: 12px; line-height: 1.5; }
.retry-button { padding: 0 14px; border: 0; border-radius: 5px; background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); font-size: 12px; font-weight: 650; }
.retry-button { height: 34px; margin-top: 25px; }
.apps-section { margin-top: 0; }
.apps-heading { display: flex; align-items: center; justify-content: space-between; font-size: 13px; font-weight: 650; }
.apps-heading button { border: 0; background: transparent; color: hsl(var(--primary)); font-size: 12px; }
.apps-heading button:hover { text-decoration: underline; }
.app-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(92px, 1fr)); gap: 8px; margin-top: 12px; }
.apps-empty { padding: 22px 4px; font-size: 12px; }
.app-tile { display: flex; align-items: center; flex-direction: column; justify-content: center; gap: 9px; min-width: 0; height: 88px; padding: 7px; border: 0; border-radius: 7px; background: transparent; color: hsl(var(--foreground)); }
.app-tile:hover { background: hsl(var(--muted)); }
.app-icon { display: grid; place-items: center; flex: none; width: 38px; height: 38px; border-radius: 10px; color: white; font-size: 18px; font-weight: 700; }
.image-icon { background: transparent; object-fit: contain; }
.app-name { max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.bookmark-panel { position: fixed; z-index: 5; top: var(--browser-chrome-height); right: 0; bottom: 0; width: min(360px, 100%); padding: 18px 14px; border-left: 1px solid hsl(var(--border) / .6); background: hsl(var(--background)); }
.panel-head { display: flex; align-items: center; justify-content: space-between; padding: 0 2px; font-size: 13px; }
.add-bookmark { width: 100%; height: 34px; margin: 17px 0 13px; border: 0; border-radius: 6px; background: hsl(var(--secondary)); color: hsl(var(--foreground)); font-size: 12px; }
.add-bookmark:hover { background: hsl(var(--muted)); }
.bookmark-list { display: flex; flex-direction: column; gap: 2px; max-height: calc(100vh - 190px); overflow: auto; }
.bookmark-row { display: flex; align-items: center; gap: 8px; min-width: 0; padding: 7px 5px; border-radius: 6px; }
.bookmark-row:hover { background: hsl(var(--muted)); }
.bookmark-row .app-icon { width: 30px; height: 30px; border-radius: 7px; font-size: 14px; }
.bookmark-open { flex: 1; min-width: 0; padding: 0; border: 0; background: transparent; color: hsl(var(--foreground)); text-align: left; }
.bookmark-open span, .bookmark-open small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.bookmark-open span { font-size: 12px; }
.bookmark-open small { margin-top: 2px; color: hsl(var(--muted-foreground)); font-size: 10px; }
.bookmark-row .icon-button { width: 22px; height: 22px; font-size: 14px; }
.bookmark-form { position: absolute; inset: 0; display: flex; flex-direction: column; padding: 0; background: hsl(var(--background)); }
.bookmark-form-head { display: flex; align-items: center; gap: 7px; height: 52px; flex: none; padding: 0 12px; border-bottom: 1px solid hsl(var(--border) / .5); font-size: 13px; }
.bookmark-form-head strong { flex: 1; font-weight: 650; }
.bookmark-form-body { flex: 1; min-height: 0; overflow: auto; display: flex; flex-direction: column; gap: 21px; padding: 23px 20px; }
.form-field { display: flex; flex-direction: column; gap: 8px; color: hsl(var(--foreground)); font-size: 12px; font-weight: 550; }
.bookmark-form input:not([type="file"]):not([type="color"]) { width: 100%; height: 38px; padding: 0 11px; border: 1px solid hsl(var(--border)); border-radius: 7px; outline: 0; background: hsl(var(--background)); color: hsl(var(--foreground)); font-size: 12px; font-weight: 400; }
.bookmark-form input:focus-visible { border-color: hsl(var(--primary) / .65) !important; box-shadow: 0 0 0 2px hsl(var(--primary) / .15); }
.bookmark-form input::placeholder { color: hsl(var(--muted-foreground) / .8); }
.icon-setting { display: flex; align-items: center; gap: 11px; padding: 3px 0 5px; }
.icon-setting .app-icon { width: 42px; height: 42px; border-radius: 9px; }
.icon-setting-copy { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.icon-setting-copy strong { font-size: 12px; font-weight: 550; }
.icon-setting-copy small, .image-options small { color: hsl(var(--muted-foreground)); font-size: 11px; font-weight: 400; line-height: 1.4; }
.choice-group { display: flex; gap: 3px; padding: 3px; border-radius: 8px; background: hsl(var(--secondary) / .8); }
.choice-group button { flex: 1; height: 30px; border: 0; border-radius: 6px; background: transparent; color: hsl(var(--muted-foreground)); font-size: 11px; font-weight: 550; }
.choice-group button:hover { color: hsl(var(--foreground)); }
.choice-group button[aria-pressed="true"] { background: hsl(var(--background)); color: hsl(var(--foreground)); box-shadow: 0 1px 3px #0002; }
.icon-options { display: flex; align-items: center; gap: 8px; }
.icon-options > input { flex: 1; min-width: 0; }
.color-control { display: flex; align-items: center; gap: 7px; color: hsl(var(--muted-foreground)); font-size: 11px; font-weight: 400; }
.color-control button { display: grid; place-items: center; width: 38px; height: 38px; padding: 0; border: 1px solid hsl(var(--border)); border-radius: 7px; background: hsl(var(--background)); }
.color-control button:hover { border-color: hsl(var(--primary) / .65); }
.color-control button span { width: 22px; height: 22px; border-radius: 5px; }
#bookmark-color, #bookmark-image { display: none; }
.image-options { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; }
.image-options button { width: 100%; }
.form-actions { display: flex; gap: 8px; margin-top: auto; }
.form-actions button { flex: 1; height: 34px; border: 0; border-radius: 6px; background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); font-size: 12px; }
.form-actions button:first-child { background: hsl(var(--secondary)); color: hsl(var(--foreground)); }
.bookmark-actions { flex: none; margin: 0; padding: 14px 20px; border-top: 1px solid hsl(var(--border) / .5); }
.bookmark-actions button { height: 36px; font-weight: 550; }
.bookmark-actions button:focus-visible, .choice-group button:focus-visible, .icon-button:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 1px; }
.secondary-button { border: 1px solid hsl(var(--border)) !important; background: hsl(var(--background)) !important; color: hsl(var(--foreground)) !important; border-radius: 7px; }
.secondary-button:hover { background: hsl(var(--secondary) / .7) !important; }
.text-action { align-self: flex-start; padding: 0; border: 0; background: transparent; color: hsl(var(--primary)); font-size: 11px; }
.connection-message { text-align: center; padding: 20vh 0 0; }
.tab-icon-menu { position: fixed; z-index: 9; width: 160px; padding: 4px; border: 1px solid hsl(var(--border)); border-radius: 7px; background: hsl(var(--background)); box-shadow: 0 7px 20px #0003; }
.tab-icon-menu button { display: block; width: 100%; padding: 8px; border: 0; border-radius: 4px; background: transparent; color: hsl(var(--foreground)); text-align: left; font-size: 12px; }
.tab-icon-menu button:hover { background: hsl(var(--muted)); }
.tab-icon-editor { position: fixed; z-index: 10; top: 50%; left: 50%; transform: translate(-50%,-50%); display: flex; flex-direction: column; gap: 14px; width: min(320px, 90vw); padding: 19px; border: 1px solid hsl(var(--border)); border-radius: 10px; background: hsl(var(--background)); box-shadow: 0 15px 45px #0005; }
.tab-icon-editor p { margin: -6px 0 0; }
.tab-icon-editor label { display: flex; flex-direction: column; gap: 5px; font-size: 11px; color: hsl(var(--muted-foreground)); }
.tab-icon-editor input { width: 100%; height: 34px; padding: 6px 8px; border: 1px solid hsl(var(--border)); border-radius: 6px; background: hsl(var(--background)); color: hsl(var(--foreground)); }
.notice { position: fixed; z-index: 7; bottom: 18px; left: 50%; transform: translateX(-50%); max-width: 80%; padding: 9px 13px; border-radius: 6px; background: hsl(var(--foreground)); color: hsl(var(--background)); font-size: 12px; }
[data-embedded="true"] { --browser-chrome-height: 64px; }
[data-embedded="true"] .tab-bar { height: 28px; align-items: center; padding: 0 6px; background: transparent; -webkit-app-region: no-drag; }
[data-embedded="true"] .window-controls { display: none; }
[data-embedded="true"] .tabs { flex: 0 1 auto; align-items: center; gap: 2px; }
[data-embedded="true"] .tab { flex: 0 1 144px; min-width: 64px; max-width: 180px; height: 28px; padding: 0 6px 0 8px; gap: 6px; border-radius: 0; border-bottom: 2px solid transparent; font-size: 11px; font-weight: 400; }
[data-embedded="true"] .tab:hover { background: hsl(var(--muted) / .5); }
[data-embedded="true"] .tab.active { background: transparent; border-bottom-color: hsl(var(--foreground) / .45); color: hsl(var(--foreground)); }
[data-embedded="true"] .tab-close { width: 16px; height: 16px; font-size: 14px; }
[data-embedded="true"] .tab-favicon { width: 12px; height: 12px; }
[data-embedded="true"] .new-tab { width: 24px; height: 24px; margin: 0 0 0 3px; font-size: 18px; }
[data-embedded="true"] .drag-space { display: none; }
[data-embedded="true"] .nav-bar { height: 36px; padding: 0 6px; gap: 0; }
[data-embedded="true"] .nav-bar .icon-button { width: 24px; height: 24px; border-radius: 4px; }
[data-embedded="true"] .nav-bar .icon-button svg { width: 14px; height: 14px; stroke-width: 1.6; }
[data-embedded="true"] .address-form { height: 26px; margin: 0 5px; padding: 0 7px; border-color: transparent; border-radius: 4px; background: hsl(var(--muted) / .6); }
[data-embedded="true"] .address-form:focus-within { border-color: hsl(var(--foreground) / .3); background: hsl(var(--background)); }
[data-embedded="true"] .address-form input { font-size: 11px; }
[data-embedded="true"] .address-form input::placeholder { color: hsl(var(--muted-foreground)); }
[data-embedded="true"] .home { padding: 24px 16px; }
@media (max-width: 540px) {
.bookmark-panel-open .home { visibility: hidden; }
.bookmark-panel { width: 100%; }
.tab-icon-editor { max-width: calc(100vw - 16px); }
}
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'">
<title>Netcatty Browser</title>
<link rel="stylesheet" href="browser.css">
<script defer src="browser.js"></script>
</head>
<body class="dark" data-platform="darwin">
<header class="chrome">
<div class="tab-bar" id="window-drag-region">
<div class="tabs" id="tabs" role="tablist"></div>
<button id="new-tab" class="icon-button new-tab" title="New tab" aria-label="New tab">+</button>
<div class="drag-space"></div>
<div class="window-controls">
<button id="window-minimize" class="window-control" title="Minimize" aria-label="Minimize">&#8722;</button>
<button id="window-maximize" class="window-control" title="Maximize" aria-label="Maximize">&#9633;</button>
<button id="window-close" class="window-control close" title="Close" aria-label="Close">&#215;</button>
</div>
</div>
<div class="nav-bar">
<button id="back" class="icon-button" title="Back" aria-label="Back"><svg viewBox="0 0 24 24"><path d="m12 19-7-7 7-7M5 12h14"/></svg></button>
<button id="forward" class="icon-button" title="Forward" aria-label="Forward"><svg viewBox="0 0 24 24"><path d="m12 5 7 7-7 7M19 12H5"/></svg></button>
<button id="reload" class="icon-button" title="Reload" aria-label="Reload"><svg viewBox="0 0 24 24"><path d="M20 11a8 8 0 1 0-2.3 6M20 4v7h-7"/></svg></button>
<form id="address-form" class="address-form"><input id="address" autocomplete="off" spellcheck="false" aria-label="Web address" placeholder="Enter a URL"></form>
<button id="bookmarks-menu" class="icon-button" title="Apps and bookmarks" aria-label="Apps and bookmarks"><svg viewBox="0 0 24 24"><path d="M19 21l-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/></svg></button>
<button id="downloads-menu" class="icon-button downloads-button" title="Downloads" aria-label="Downloads" aria-expanded="false"><svg viewBox="0 0 24 24"><path d="M12 3v12m0 0 4-4m-4 4-4-4M4 17v3h16v-3"/></svg><span class="download-indicator"></span></button>
</div>
</header>
<main id="home" class="home">
<div class="home-content">
<div id="connection-message" class="connection-message" hidden><h1 id="home-title">Connection unavailable</h1><p id="home-description"></p><button id="retry" class="retry-button" type="button" hidden>Retry connection</button></div>
<section id="apps-section" class="apps-section"><div class="apps-heading"><span>Apps</span><button id="add-app" type="button">+ Add app</button></div><div id="app-grid" class="app-grid"></div><p id="apps-empty" class="apps-empty">Your apps for this host will appear here.</p></section>
</div>
</main>
<aside id="bookmark-panel" class="bookmark-panel" hidden>
<div class="panel-head"><strong>Apps &amp; bookmarks</strong><button id="close-bookmarks" class="icon-button" aria-label="Close bookmarks">&#215;</button></div>
<button id="add-bookmark" class="add-bookmark" type="button">+ Add bookmark</button>
<div id="bookmark-list" class="bookmark-list"></div>
<form id="bookmark-form" class="bookmark-form" hidden>
<input id="bookmark-id" type="hidden">
<div class="bookmark-form-head"><button id="bookmark-back" class="icon-button" type="button" title="Back" aria-label="Back"><svg viewBox="0 0 24 24"><path d="m15 18-6-6 6-6"/></svg></button><strong id="bookmark-form-heading">Add app</strong><button id="bookmark-close" class="icon-button" type="button" title="Close" aria-label="Close"><svg viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg></button></div>
<div class="bookmark-form-body">
<label class="form-field">Title<input id="bookmark-title" required maxlength="120" placeholder="App name"></label>
<label class="form-field">Address<input id="bookmark-url" required type="url" placeholder="https://example.com"></label>
<div class="form-field"><span>Icon</span><div class="icon-setting"><span id="bookmark-icon-preview" class="app-icon"></span><div class="icon-setting-copy"><strong>App icon</strong><small>Use the website icon or choose your own.</small></div></div>
<div class="choice-group" role="group" aria-label="Icon type"><button id="bookmark-use-site-icon" data-icon-mode="website" type="button">Website</button><button data-icon-mode="text" type="button">Text</button><button data-icon-mode="image" type="button">Image</button></div>
<div id="bookmark-text-options" class="icon-options" hidden><input id="bookmark-icon" maxlength="4" placeholder="Icon text" aria-label="Icon text"><div class="color-control"><span>Color</span><button id="bookmark-color-button" type="button" title="Choose icon color" aria-label="Choose icon color"><span id="bookmark-color-swatch"></span></button><input id="bookmark-color" type="color" value="#247969" aria-label="Icon color"></div></div>
<div id="bookmark-image-options" class="image-options" hidden><input id="bookmark-image" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon,image/vnd.microsoft.icon"><button id="bookmark-choose-image" class="secondary-button" type="button">Choose image</button><small id="bookmark-image-name">PNG, JPEG, WebP, GIF or ICO &#183; max 64 KB</small></div>
</div>
<div class="form-field"><span>Show on</span><div class="choice-group scope-choices" role="group" aria-label="Show on"><button data-scope="host" type="button">This host</button><button data-scope="all-hosts" type="button">All hosts</button></div><select id="bookmark-scope" hidden><option value="host">This host</option><option value="all-hosts">All hosts</option></select></div>
</div>
<div class="form-actions bookmark-actions"><button id="cancel-bookmark" class="secondary-button" type="button">Cancel</button><button type="submit">Save app</button></div>
</form>
</aside>
<div id="tab-icon-menu" class="tab-icon-menu" hidden><button id="tab-icon-change" type="button">Change icon&#8230;</button><button id="tab-icon-reset" type="button">Use website icon</button></div>
<div id="tab-icon-editor" class="tab-icon-editor" hidden><div class="panel-head"><strong>Tab icon</strong><button id="tab-icon-editor-close" class="icon-button" type="button">&#215;</button></div><p>Choose an icon for this website on this host.</p><label>Icon text<input id="tab-icon-text" maxlength="4" placeholder="&#9671;"></label><label>Color<input id="tab-icon-color" type="color" value="#247969"></label><label>Custom image<input id="tab-icon-image" type="file" accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon,image/vnd.microsoft.icon"></label><div class="form-actions"><button id="tab-icon-cancel" type="button">Cancel</button><button id="tab-icon-save" type="button">Save</button></div></div>
<div id="notice" class="notice" hidden></div>
</body>
</html>
+410
View File
@@ -0,0 +1,410 @@
"use strict";
const api = window.hostBrowser;
const $ = (id) => document.getElementById(id);
let state = null;
let bookmarks = [];
let noticeTimer = null;
let editingTabId = null;
let editingBookmarkImageDataUrl = null;
let editingBookmarkMode = "website";
let editingBookmarkSiteIcon = null;
function showNotice(message) {
const notice = $("notice");
notice.textContent = message;
notice.hidden = false;
clearTimeout(noticeTimer);
noticeTimer = setTimeout(() => { notice.hidden = true; }, 5000);
}
async function command(type, extra = {}) {
try {
const next = await api.command({ type, ...extra });
if (next) render(next);
return true;
} catch (error) { showNotice(error?.message || "Browser action failed"); }
return false;
}
function syncChromeOverlay() {
void command("set-overlay", { open: !$("tab-icon-menu").hidden || !$("tab-icon-editor").hidden });
}
function tabIcon(tab) {
if (tab.iconDataUrl) {
const image = document.createElement("img");
image.className = "tab-favicon";
image.src = tab.iconDataUrl;
image.alt = "";
return image;
}
const glyph = document.createElement("span");
glyph.className = "tab-glyph";
glyph.textContent = tab.iconGlyph || (tab.url ? "\u25cc" : "");
if (tab.iconGlyph && tab.iconColor) glyph.style.color = tab.iconColor;
return glyph;
}
function render(next) {
const activeTabChanged = state?.activeTabId !== next.activeTabId;
state = next;
document.title = "Browser";
document.body.dataset.embedded = String(Boolean(next.embedded));
document.body.classList.toggle("dark", next.theme !== "light");
for (const name of ["background", "foreground", "secondary", "muted", "muted-foreground", "border", "primary", "primary-foreground"]) {
const value = next.themeTokens?.[name];
if (typeof value === "string") document.body.style.setProperty(`--${name}`, value);
else document.body.style.removeProperty(`--${name}`);
}
document.body.dataset.platform = next.platform;
$("window-maximize").textContent = next.isMaximized ? "\u2750" : "\u25a1";
$("window-maximize").title = next.isMaximized ? "Restore" : "Maximize";
const connected = next.route === "connected";
$("downloads-menu").setAttribute("aria-expanded", String(Boolean(next.downloadsPanelOpen)));
const tabs = $("tabs");
tabs.replaceChildren();
for (const tab of next.tabs) {
const item = document.createElement("div");
item.className = `tab${tab.id === next.activeTabId ? " active" : ""}`;
item.tabIndex = 0;
item.setAttribute("role", "tab");
item.setAttribute("aria-selected", String(tab.id === next.activeTabId));
item.draggable = true;
item.addEventListener("dragstart", (event) => {
event.dataTransfer.setData("text/plain", tab.id);
event.dataTransfer.effectAllowed = "move";
item.classList.add("dragging");
});
item.addEventListener("dragend", () => item.classList.remove("dragging"));
item.addEventListener("dragover", (event) => { event.preventDefault(); event.dataTransfer.dropEffect = "move"; });
item.addEventListener("drop", (event) => {
event.preventDefault();
const draggedId = event.dataTransfer.getData("text/plain");
if (draggedId && draggedId !== tab.id) void command("reorder", { tabId: draggedId, beforeTabId: tab.id });
});
const icon = tabIcon(tab);
if (tab.url) icon.title = "Right-click the tab to change its icon";
item.append(icon);
const title = document.createElement("span");
title.className = "tab-title";
title.textContent = tab.title || "New tab";
const close = document.createElement("button");
close.className = "tab-close";
close.type = "button";
close.title = "Close tab";
close.textContent = "\u00d7";
close.addEventListener("click", (event) => { event.stopPropagation(); void command("close-tab", { tabId: tab.id }); });
item.addEventListener("click", () => void command("activate", { tabId: tab.id }));
item.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); void command("activate", { tabId: tab.id }); }
});
item.addEventListener("contextmenu", (event) => {
if (!tab.url) return;
event.preventDefault();
editingTabId = tab.id;
const menu = $("tab-icon-menu");
menu.style.left = `${Math.min(event.clientX, window.innerWidth - 170)}px`;
menu.style.top = `${Math.min(event.clientY, window.innerHeight - 90)}px`;
menu.hidden = false;
syncChromeOverlay();
});
item.append(title, close);
tabs.append(item);
}
const active = next.tabs.find((tab) => tab.id === next.activeTabId);
if (activeTabChanged || document.activeElement !== $("address")) $("address").value = active?.url || "";
$("back").disabled = !connected || !active?.canGoBack;
$("forward").disabled = !connected || !active?.canGoForward;
$("reload").disabled = !connected || !active?.url;
$("reload").title = active?.loading ? "Stop" : "Reload";
$("reload").querySelector("path").setAttribute("d", active?.loading ? "M18 6 6 18M6 6l12 12" : "M20 11a8 8 0 1 0-2.3 6M20 4v7h-7");
$("home").hidden = connected && Boolean(active?.url);
$("connection-message").hidden = connected;
$("apps-section").hidden = !connected;
if (!connected) {
$("home-description").textContent = next.error || "Reconnect to this host to continue browsing.";
$("retry").hidden = next.route !== "disconnected";
}
renderBookmarks();
}
function bookmarkIcon(item) {
const dataUrl = item.iconDataUrl || (item.iconMode === "custom" ? null : item.siteIconDataUrl);
if (dataUrl) {
const image = document.createElement("img");
image.className = "app-icon image-icon";
image.src = dataUrl;
image.alt = "";
return image;
}
const icon = document.createElement("span");
icon.className = "app-icon";
const custom = item.iconMode === "custom";
icon.style.backgroundColor = !custom && item.siteIconGlyph ? item.siteIconColor : item.color;
icon.textContent = !custom && item.siteIconGlyph ? item.siteIconGlyph : item.icon;
return icon;
}
function renderBookmarks() {
const appGrid = $("app-grid");
const list = $("bookmark-list");
appGrid.replaceChildren();
list.replaceChildren();
$("apps-empty").hidden = bookmarks.length > 0;
for (const item of [...bookmarks].sort((a, b) => a.order - b.order || a.title.localeCompare(b.title))) {
const tile = document.createElement("button");
tile.className = "app-tile";
tile.type = "button";
tile.append(bookmarkIcon(item));
const label = document.createElement("span");
label.className = "app-name";
label.textContent = item.title;
tile.append(label);
tile.title = item.url;
tile.addEventListener("click", () => void command("navigate", { url: item.url }));
appGrid.append(tile);
const row = document.createElement("div");
row.className = "bookmark-row";
row.append(bookmarkIcon(item));
const open = document.createElement("button");
open.type = "button";
open.className = "bookmark-open";
const title = document.createElement("span");
title.textContent = item.title;
const address = document.createElement("small");
address.textContent = item.url;
open.append(title, address);
open.addEventListener("click", () => void command("navigate", { url: item.url }));
row.append(open);
const edit = document.createElement("button");
edit.className = "icon-button";
edit.title = "Edit";
edit.textContent = "\u270e";
edit.addEventListener("click", () => editBookmark(item));
const remove = document.createElement("button");
remove.className = "icon-button";
remove.title = "Delete";
remove.textContent = "\u00d7";
remove.addEventListener("click", async () => {
try { await api.removeBookmark(item.id); await refreshBookmarks(); }
catch (error) { showNotice(error?.message || "Could not delete bookmark"); }
});
row.append(edit, remove);
list.append(row);
}
}
async function refreshBookmarks() {
try {
bookmarks = await api.listBookmarks();
renderBookmarks();
} catch (error) { showNotice(error?.message || "Could not load bookmarks"); }
}
function showBookmarkPanel(open) {
$("bookmark-panel").hidden = !open;
document.body.classList.toggle("bookmark-panel-open", open);
if (!open) $("bookmark-form").hidden = true;
void command("set-panel", { open });
}
function editBookmark(item = null) {
showBookmarkPanel(true);
$("bookmark-form").hidden = false;
$("bookmark-id").value = item?.id || "";
$("bookmark-title").value = item?.title || "";
$("bookmark-url").value = item?.url || "";
$("bookmark-icon").value = item?.iconMode === "custom" ? item.icon : "";
$("bookmark-color").value = item?.color || "#247969";
$("bookmark-scope").value = item?.scope || "host";
$("bookmark-image").value = "";
editingBookmarkImageDataUrl = item?.iconDataUrl || null;
editingBookmarkSiteIcon = item?.siteIconDataUrl || null;
$("bookmark-form-heading").textContent = item ? "Edit app" : "Add app";
editingBookmarkMode = editingBookmarkImageDataUrl ? "image" : item?.iconMode === "custom" ? "text" : "website";
$("bookmark-image-name").textContent = editingBookmarkImageDataUrl ? "Custom image saved" : "PNG, JPEG, WebP, GIF or ICO \u00b7 max 64 KB";
renderBookmarkFormOptions();
$("bookmark-title").focus();
}
function renderBookmarkFormOptions() {
for (const button of document.querySelectorAll("[data-icon-mode]")) button.setAttribute("aria-pressed", String(button.dataset.iconMode === editingBookmarkMode));
for (const button of document.querySelectorAll("[data-scope]")) button.setAttribute("aria-pressed", String(button.dataset.scope === $("bookmark-scope").value));
$("bookmark-text-options").hidden = editingBookmarkMode !== "text";
$("bookmark-image-options").hidden = editingBookmarkMode !== "image";
const preview = $("bookmark-icon-preview");
preview.replaceChildren();
preview.classList.remove("image-icon");
preview.style.backgroundColor = "";
if (editingBookmarkMode === "image" && editingBookmarkImageDataUrl || editingBookmarkMode === "website" && editingBookmarkSiteIcon) {
const image = document.createElement("img");
image.src = editingBookmarkMode === "image" ? editingBookmarkImageDataUrl : editingBookmarkSiteIcon;
image.alt = "";
image.style.cssText = "width:100%;height:100%;object-fit:contain";
preview.classList.add("image-icon");
preview.append(image);
} else {
preview.style.backgroundColor = editingBookmarkMode === "text" ? $("bookmark-color").value : "hsl(var(--secondary))";
preview.style.color = editingBookmarkMode === "text" ? "white" : "hsl(var(--muted-foreground))";
preview.textContent = editingBookmarkMode === "text" ? ($("bookmark-icon").value || "A") : "\u25cc";
}
$("bookmark-color-swatch").style.backgroundColor = $("bookmark-color").value;
}
const allowedImageTypes = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/x-icon", "image/vnd.microsoft.icon"]);
function readImageFile(file) {
if (!file) return Promise.resolve(null);
if (!allowedImageTypes.has(file.type) || file.size > 64 * 1024) throw new Error("Choose a PNG, JPEG, WebP, GIF or ICO image under 64 KB");
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(new Error("Could not read image"));
reader.readAsDataURL(file);
});
}
$("new-tab").addEventListener("click", () => void command("new-tab"));
$("window-minimize").addEventListener("click", () => void command("window-minimize"));
$("window-maximize").addEventListener("click", () => void command("window-maximize"));
$("window-close").addEventListener("click", () => void command("window-close"));
$("window-drag-region").addEventListener("dblclick", (event) => {
if (!state?.embedded && state?.platform !== "darwin" && !event.target.closest("button, .tab")) void command("window-maximize");
});
$("retry").addEventListener("click", () => void command("retry"));
$("bookmarks-menu").addEventListener("click", () => {
if (state?.downloadsPanelOpen) void command("set-download-panel", { open: false });
showBookmarkPanel($("bookmark-panel").hidden);
});
$("downloads-menu").addEventListener("click", () => {
if (!state?.downloadsPanelOpen && !$("bookmark-panel").hidden) showBookmarkPanel(false);
void command("set-download-panel", { open: !state?.downloadsPanelOpen });
});
$("close-bookmarks").addEventListener("click", () => showBookmarkPanel(false));
$("add-bookmark").addEventListener("click", () => {
const active = state?.tabs.find((tab) => tab.id === state.activeTabId);
editBookmark();
if (active?.url) {
$("bookmark-title").value = active.title || new URL(active.url).hostname;
$("bookmark-url").value = active.url;
}
});
$("add-app").addEventListener("click", () => editBookmark());
$("bookmark-use-site-icon").addEventListener("click", () => {
editingBookmarkMode = "website";
editingBookmarkImageDataUrl = null;
$("bookmark-image").value = "";
$("bookmark-icon").value = "";
renderBookmarkFormOptions();
});
for (const button of document.querySelectorAll("[data-icon-mode]")) button.addEventListener("click", () => {
editingBookmarkMode = button.dataset.iconMode;
renderBookmarkFormOptions();
});
for (const button of document.querySelectorAll("[data-scope]")) button.addEventListener("click", () => {
$("bookmark-scope").value = button.dataset.scope;
renderBookmarkFormOptions();
});
$("bookmark-icon").addEventListener("input", renderBookmarkFormOptions);
$("bookmark-color-button").addEventListener("click", () => $("bookmark-color").click());
$("bookmark-color").addEventListener("input", renderBookmarkFormOptions);
$("bookmark-choose-image").addEventListener("click", () => $("bookmark-image").click());
$("bookmark-image").addEventListener("change", async () => {
const file = $("bookmark-image").files[0];
if (!file) return;
try {
editingBookmarkImageDataUrl = await readImageFile(file);
$("bookmark-image-name").textContent = file.name;
renderBookmarkFormOptions();
} catch (error) { $("bookmark-image").value = ""; showNotice(error?.message || "Could not read image"); }
});
$("bookmark-back").addEventListener("click", () => { $("bookmark-form").hidden = true; });
$("bookmark-close").addEventListener("click", () => showBookmarkPanel(false));
$("cancel-bookmark").addEventListener("click", () => { $("bookmark-form").hidden = true; });
$("bookmark-form").addEventListener("submit", async (event) => {
event.preventDefault();
try {
const selectedImage = editingBookmarkMode === "image" ? await readImageFile($("bookmark-image").files[0]) : null;
if (editingBookmarkMode === "image" && !selectedImage && !editingBookmarkImageDataUrl) throw new Error("Choose an image for this app");
await api.saveBookmark({
id: $("bookmark-id").value || undefined,
title: $("bookmark-title").value,
url: $("bookmark-url").value,
icon: editingBookmarkMode === "text" ? $("bookmark-icon").value : "",
iconMode: editingBookmarkMode === "website" ? "auto" : "custom",
iconDataUrl: editingBookmarkMode === "image" ? selectedImage || editingBookmarkImageDataUrl : null,
color: $("bookmark-color").value,
scope: $("bookmark-scope").value,
order: bookmarks.length,
});
$("bookmark-form").hidden = true;
await refreshBookmarks();
} catch (error) { showNotice(error?.message || "Could not save bookmark"); }
});
$("tab-icon-change").addEventListener("click", () => {
$("tab-icon-menu").hidden = true;
$("tab-icon-editor").hidden = false;
const tab = state?.tabs.find((item) => item.id === editingTabId);
$("tab-icon-text").value = tab?.iconGlyph || "";
$("tab-icon-color").value = tab?.iconColor || "#247969";
$("tab-icon-image").value = "";
$("tab-icon-text").focus();
syncChromeOverlay();
});
$("tab-icon-reset").addEventListener("click", () => {
$("tab-icon-menu").hidden = true;
syncChromeOverlay();
void command("reset-icon", { tabId: editingTabId });
});
$("tab-icon-cancel").addEventListener("click", () => { $("tab-icon-editor").hidden = true; syncChromeOverlay(); });
$("tab-icon-editor-close").addEventListener("click", () => { $("tab-icon-editor").hidden = true; syncChromeOverlay(); });
$("tab-icon-save").addEventListener("click", async () => {
try {
const dataUrl = await readImageFile($("tab-icon-image").files[0]);
const glyph = $("tab-icon-text").value.trim();
if (!dataUrl && !glyph) throw new Error("Enter icon text or choose an image");
if (await command("set-icon", { tabId: editingTabId, dataUrl, glyph, color: $("tab-icon-color").value })) {
$("tab-icon-editor").hidden = true;
syncChromeOverlay();
}
} catch (error) { showNotice(error?.message || "Could not save icon"); }
});
document.addEventListener("click", (event) => {
if (!$("tab-icon-menu").hidden && !$("tab-icon-menu").contains(event.target)) {
$("tab-icon-menu").hidden = true;
syncChromeOverlay();
}
});
$("back").addEventListener("click", () => void command("back"));
$("forward").addEventListener("click", () => void command("forward"));
$("reload").addEventListener("click", () => {
const active = state?.tabs.find((tab) => tab.id === state.activeTabId);
void command(active?.loading ? "stop" : "reload");
});
$("address-form").addEventListener("submit", (event) => {
event.preventDefault();
void command("navigate", { url: $("address").value });
$("address").blur();
});
document.addEventListener("keydown", (event) => {
const modifier = navigator.platform.includes("Mac") ? event.metaKey : event.ctrlKey;
if (!modifier) return;
if (event.key.toLowerCase() === "l") { event.preventDefault(); $("address").focus(); $("address").select(); }
if (event.key.toLowerCase() === "t") { event.preventDefault(); void command("new-tab"); }
if (event.key.toLowerCase() === "w") { event.preventDefault(); void command("close-tab"); }
if (event.key.toLowerCase() === "r") { event.preventDefault(); void command("reload"); }
});
api.onState(render);
api.onBookmarksChanged(() => void refreshBookmarks());
api.onSiteIconsChanged(() => void refreshBookmarks());
api.onDownloadsChanged((downloads) => $("downloads-menu").classList.toggle("has-active", downloads.some((item) => ["waiting", "progressing"].includes(item.status))));
api.onShortcut(({ type, tabId }) => {
if (type === "focus-address") { $("address").focus(); $("address").select(); }
else void command(type, { tabId });
});
void api.snapshot().then(render).catch((error) => showNotice(error?.message || "Browser unavailable"));
void refreshBookmarks();
void api.listDownloads().then((downloads) => $("downloads-menu").classList.toggle("has-active", downloads.some((item) => ["waiting", "progressing"].includes(item.status)))).catch(() => {});
+37
View File
@@ -0,0 +1,37 @@
"use strict";
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("hostBrowser", Object.freeze({
snapshot: () => ipcRenderer.invoke("netcatty:host-browser:snapshot"),
command: (command) => ipcRenderer.invoke("netcatty:host-browser:command", command),
listBookmarks: () => ipcRenderer.invoke("netcatty:host-browser:bookmarks:list"),
saveBookmark: (bookmark) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:upsert", bookmark),
removeBookmark: (id) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:remove", id),
listDownloads: () => ipcRenderer.invoke("netcatty:host-browser:downloads:list"),
onDownloadsChanged: (callback) => {
const listener = (_event, downloads) => callback(downloads);
ipcRenderer.on("netcatty:host-browser:downloads-changed", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:downloads-changed", listener);
},
onBookmarksChanged: (callback) => {
const listener = () => callback();
ipcRenderer.on("netcatty:host-browser:bookmarks-changed", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:bookmarks-changed", listener);
},
onSiteIconsChanged: (callback) => {
const listener = () => callback();
ipcRenderer.on("netcatty:host-browser:site-icons-changed", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:site-icons-changed", listener);
},
onShortcut: (callback) => {
const listener = (_event, shortcut) => callback(shortcut);
ipcRenderer.on("netcatty:host-browser:shortcut", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:shortcut", listener);
},
onState: (callback) => {
const listener = (_event, state) => callback(state);
ipcRenderer.on("netcatty:host-browser:state", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:state", listener);
},
}));
+81
View File
@@ -0,0 +1,81 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const MAX_DOWNLOADS = 500;
const STATUSES = new Set(["waiting", "progressing", "completed", "cancelled", "interrupted"]);
function normalizeDownload(raw) {
if (!raw || typeof raw !== "object" || typeof raw.id !== "string" || !raw.id || raw.id.length > 128
|| typeof raw.hostId !== "string" || !raw.hostId || raw.hostId.length > 256) throw new Error("Invalid download");
return {
id: raw.id,
hostId: raw.hostId,
filename: String(raw.filename || "download").slice(0, 255),
url: String(raw.url || "").slice(0, 4096),
savePath: typeof raw.savePath === "string" ? raw.savePath.slice(0, 4096) : "",
status: STATUSES.has(raw.status) ? raw.status : "interrupted",
received: Number.isSafeInteger(raw.received) && raw.received >= 0 ? raw.received : 0,
total: Number.isSafeInteger(raw.total) && raw.total >= 0 ? raw.total : 0,
startedAt: Number.isSafeInteger(raw.startedAt) && raw.startedAt > 0 ? raw.startedAt : Date.now(),
endedAt: Number.isSafeInteger(raw.endedAt) && raw.endedAt > 0 ? raw.endedAt : null,
};
}
function createDownloadStore(app) {
const file = path.join(app.getPath("userData"), "host-browser-downloads.json");
let items = [];
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (Array.isArray(parsed)) {
items = parsed.slice(0, MAX_DOWNLOADS).flatMap((raw) => {
try {
const item = normalizeDownload(raw);
if (item.status === "progressing" || item.status === "waiting") item.status = "interrupted";
return [item];
} catch { return []; }
});
}
} catch (error) {
if (error?.code !== "ENOENT") console.warn("[HostBrowser] Could not read downloads:", error?.message);
}
const persist = () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(items), { mode: 0o600 });
fs.renameSync(temporary, file);
};
const isMissing = (item) => item.status === "completed" && (!item.savePath || !fs.existsSync(item.savePath));
return {
list(hostId) {
return items.filter((item) => item.hostId === hostId)
.sort((a, b) => b.startedAt - a.startedAt)
.map((item) => ({ ...item, missing: isMissing(item) }));
},
get(hostId, id) { return items.find((item) => item.hostId === hostId && item.id === id) || null; },
upsert(raw) {
const next = normalizeDownload(raw);
const index = items.findIndex((item) => item.id === next.id);
if (index >= 0 && items[index].hostId !== next.hostId) throw new Error("Download belongs to another host");
if (index >= 0) items[index] = next;
else items.unshift(next);
items = items.sort((a, b) => b.startedAt - a.startedAt).slice(0, MAX_DOWNLOADS);
persist();
return next;
},
remove(hostId, id) {
const next = items.filter((item) => item.hostId !== hostId || item.id !== id || ["waiting", "progressing"].includes(item.status));
if (next.length === items.length) return false;
items = next;
persist();
return true;
},
clearFinished(hostId) {
const next = items.filter((item) => item.hostId !== hostId || ["waiting", "progressing"].includes(item.status));
if (next.length !== items.length) { items = next; persist(); }
},
};
}
module.exports = { createDownloadStore };
@@ -0,0 +1,30 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { test } = require("node:test");
const { createDownloadStore } = require("./downloadStore.cjs");
test("download history stays local to its host and survives restart", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "host-browser-downloads-"));
try {
const app = { getPath: () => directory };
const file = path.join(directory, "download.bin");
fs.writeFileSync(file, "downloaded");
const first = createDownloadStore(app);
first.upsert({ id: "one", hostId: "host-a", filename: "download.bin", status: "completed",
savePath: file, received: 10, total: 10, startedAt: 100 });
first.upsert({ id: "two", hostId: "host-b", filename: "other.bin", status: "waiting", startedAt: 200 });
assert.equal(first.list("host-b").length, 1);
const restored = createDownloadStore(app);
assert.equal(restored.list("host-a")[0].status, "completed");
assert.equal(restored.list("host-b")[0].status, "interrupted");
fs.unlinkSync(file);
assert.equal(restored.list("host-a")[0].missing, true);
restored.clearFinished("host-a");
assert.equal(restored.list("host-a").length, 0);
assert.equal(restored.list("host-b").length, 1);
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
});
+38
View File
@@ -0,0 +1,38 @@
:root { color-scheme: light; --background: 216 33% 96%; --foreground: 222 47% 12%; --secondary: 220 16% 90%; --muted: 220 16% 90%; --muted-foreground: 220 10% 45%; --border: 220 16% 82%; --primary: 208 100% 50%; --font-sans: "Mona Sans", "PingFang SC", system-ui, sans-serif; }
.dark { color-scheme: dark; --background: 220 28% 8%; --foreground: 210 40% 95%; --secondary: 220 16% 16%; --muted: 220 16% 16%; --muted-foreground: 220 10% 70%; --border: 220 22% 18%; --primary: 200 100% 61%; }
* { box-sizing: border-box; }
body { margin: 0; padding: 6px; overflow: hidden; background: transparent; color: hsl(var(--foreground)); font-family: var(--font-sans); font-size: 12px; }
button { font: inherit; cursor: pointer; }
button:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 1px; }
[hidden] { display: none !important; }
.panel { max-height: 458px; display: flex; flex-direction: column; overflow: hidden; border: 1px solid hsl(var(--border) / .65); border-radius: 10px; background: hsl(var(--background)); }
.panel-header { height: 52px; flex: none; display: flex; justify-content: space-between; align-items: center; padding: 0 15px 0 18px; }
h1 { margin: 0; font-size: 14px; font-weight: 650; }
.header-actions { display: flex; gap: 2px; }
.icon-button { flex: none; display: grid; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: hsl(var(--muted-foreground)); }
.icon-button:hover { background: hsl(var(--secondary)); color: hsl(var(--foreground)); }
svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.download-list { min-height: 0; max-height: 348px; overflow-y: auto; padding: 0 8px 8px; }
.download-row { position: relative; display: flex; align-items: center; gap: 10px; min-height: 66px; padding: 8px; border-radius: 7px; }
.download-row:hover { background: hsl(var(--secondary) / .55); }
.file-icon { flex: none; display: grid; place-items: center; width: 36px; height: 36px; border-radius: 8px; background: hsl(var(--secondary) / .8); color: hsl(var(--muted-foreground)); }
.file-icon svg { width: 19px; height: 19px; }
.download-detail { flex: 1; min-width: 0; }
.filename { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 12px; font-weight: 570; }
.download-status { display: block; overflow: hidden; margin-top: 4px; color: hsl(var(--muted-foreground)); text-overflow: ellipsis; white-space: nowrap; font-size: 11px; }
.download-status.missing, .download-status.interrupted { color: hsl(var(--muted-foreground)); }
.row-actions { flex: none; display: flex; gap: 1px; }
.row-actions .icon-button { width: 26px; height: 26px; }
.progress-track { height: 3px; overflow: hidden; margin-top: 7px; border-radius: 10px; background: hsl(var(--secondary)); }
.progress-fill { height: 100%; border-radius: inherit; background: hsl(var(--primary)); transition: width .2s ease; }
.row-menu { position: absolute; z-index: 3; top: 45px; right: 7px; min-width: 134px; padding: 4px; border: 1px solid hsl(var(--border)); border-radius: 7px; background: hsl(var(--background)); box-shadow: 0 6px 18px #0004; }
.row-menu button { display: block; width: 100%; padding: 7px 9px; border: 0; border-radius: 4px; background: transparent; color: hsl(var(--foreground)); text-align: left; font-size: 11px; }
.row-menu button:hover { background: hsl(var(--secondary)); }
.empty { display: flex; flex-direction: column; align-items: center; gap: 6px; padding: 25px 12px 34px; color: hsl(var(--muted-foreground)); text-align: center; }
.empty-icon { display: grid; place-items: center; width: 34px; height: 34px; margin-bottom: 5px; border-radius: 8px; background: hsl(var(--secondary)); }
.empty strong { color: hsl(var(--foreground)); font-size: 12px; font-weight: 600; }
.empty svg { width: 18px; height: 18px; }
.panel-footer { flex: none; padding: 8px 15px; border-top: 1px solid hsl(var(--border) / .5); text-align: right; }
.panel-footer button { border: 0; background: transparent; color: hsl(var(--muted-foreground)); font-size: 11px; }
.panel-footer button:hover { color: hsl(var(--foreground)); }
.notice { position: fixed; right: 12px; bottom: 12px; max-width: calc(100% - 24px); padding: 7px 10px; border-radius: 6px; background: hsl(var(--foreground)); color: hsl(var(--background)); box-shadow: 0 3px 12px #0004; }
+25
View File
@@ -0,0 +1,25 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'none'; object-src 'none'; base-uri 'none'">
<link rel="stylesheet" href="downloads.css">
<script defer src="downloads.js"></script>
</head>
<body class="dark">
<section class="panel" aria-label="Downloads">
<header class="panel-header">
<h1>Downloads</h1>
<div class="header-actions">
<button id="open-folder" class="icon-button" type="button" title="Open Downloads folder" aria-label="Open Downloads folder"><svg viewBox="0 0 24 24"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10H3zM3 11h18"/></svg></button>
<button id="close" class="icon-button" type="button" title="Close downloads" aria-label="Close downloads"><svg viewBox="0 0 24 24"><path d="M18 6 6 18M6 6l12 12"/></svg></button>
</div>
</header>
<div id="download-list" class="download-list"></div>
<div id="empty" class="empty" hidden><span class="empty-icon"><svg viewBox="0 0 24 24"><path d="M12 3v12m0 0 4-4m-4 4-4-4M4 17v3h16v-3"/></svg></span><strong>No downloads yet</strong><span>Files from this host will appear here.</span></div>
<footer id="footer" class="panel-footer" hidden><button id="clear" type="button">Clear finished</button></footer>
</section>
<div id="notice" class="notice" hidden></div>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
"use strict";
const api = window.hostBrowserDownloads;
const $ = (id) => document.getElementById(id);
const icons = {
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6"/>',
folder: '<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v10H3zM3 11h18"/>',
more: '<circle cx="5" cy="12" r="1"/><circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/>',
cancel: '<path d="M18 6 6 18M6 6l12 12"/>',
};
let items = [];
let noticeTimer = null;
function icon(name) {
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.innerHTML = icons[name];
return svg;
}
function showNotice(message) {
$("notice").textContent = message;
$("notice").hidden = false;
clearTimeout(noticeTimer);
noticeTimer = setTimeout(() => { $("notice").hidden = true; }, 4000);
}
async function action(type, id) {
try { await api.command(type, id); }
catch (error) { showNotice(error?.message || "Download action failed"); }
}
function size(bytes) {
if (!bytes) return "0 B";
const units = ["B", "KB", "MB", "GB"];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
function statusText(item) {
if (item.missing) return "File moved or deleted";
if (item.status === "waiting") return "Choose where to save\u2026";
if (item.status === "progressing") return item.total
? `${size(item.received)} of ${size(item.total)} \u00b7 ${Math.min(100, Math.round(item.received * 100 / item.total))}%`
: `${size(item.received)} \u00b7 Downloading\u2026`;
if (item.status === "completed") return `${size(item.total || item.received)} \u00b7 Completed`;
if (item.status === "cancelled") return "Cancelled";
return "Interrupted";
}
function button(name, title, onClick) {
const element = document.createElement("button");
element.type = "button";
element.className = "icon-button";
element.title = title;
element.setAttribute("aria-label", title);
element.append(icon(name));
element.addEventListener("click", onClick);
return element;
}
function render(downloads) {
items = Array.isArray(downloads) ? downloads : [];
$("download-list").replaceChildren();
$("empty").hidden = items.length > 0;
$("footer").hidden = !items.some((item) => !["waiting", "progressing"].includes(item.status));
for (const item of items) {
const row = document.createElement("div");
row.className = "download-row";
const file = document.createElement("span");
file.className = "file-icon";
file.append(icon("file"));
const detail = document.createElement("div");
detail.className = "download-detail";
const filename = document.createElement("span");
filename.className = "filename";
filename.textContent = item.filename;
filename.title = item.filename;
const status = document.createElement("span");
status.className = `download-status${item.missing ? " missing" : item.status === "interrupted" ? " interrupted" : ""}`;
status.textContent = statusText(item);
detail.append(filename, status);
if (item.status === "progressing") {
const track = document.createElement("div");
track.className = "progress-track";
const fill = document.createElement("div");
fill.className = "progress-fill";
fill.style.width = `${item.total ? Math.min(100, item.received * 100 / item.total) : 12}%`;
track.append(fill);
detail.append(track);
}
const actions = document.createElement("div");
actions.className = "row-actions";
if (item.status === "completed" && !item.missing) {
actions.append(button("folder", "Show in folder", () => void action("reveal", item.id)));
} else if (["waiting", "progressing"].includes(item.status)) {
actions.append(button("cancel", "Cancel download", () => void action("cancel", item.id)));
}
const more = button("more", "More actions", (event) => {
event.stopPropagation();
document.querySelectorAll(".row-menu").forEach((menu) => { if (menu !== actionMenu) menu.hidden = true; });
actionMenu.hidden = !actionMenu.hidden;
});
const actionMenu = document.createElement("div");
actionMenu.className = "row-menu";
actionMenu.hidden = true;
const addMenuAction = (label, type) => {
const choice = document.createElement("button");
choice.type = "button";
choice.textContent = label;
choice.addEventListener("click", () => { actionMenu.hidden = true; void action(type, item.id); });
actionMenu.append(choice);
};
if (item.status === "completed" && !item.missing) addMenuAction("Open file", "open");
if (!["waiting", "progressing"].includes(item.status)) addMenuAction("Remove from list", "remove");
if (actionMenu.childElementCount) actions.append(more);
row.append(file, detail, actions, actionMenu);
$("download-list").append(row);
}
}
function applyTheme(next) {
document.body.classList.toggle("dark", next?.theme !== "light");
for (const name of ["background", "foreground", "secondary", "muted", "muted-foreground", "border", "primary"]) {
const value = next?.themeTokens?.[name];
if (typeof value === "string") document.body.style.setProperty(`--${name}`, value);
else document.body.style.removeProperty(`--${name}`);
}
}
$("open-folder").addEventListener("click", () => void action("open-folder"));
$("close").addEventListener("click", () => void action("close"));
$("clear").addEventListener("click", () => void action("clear"));
document.addEventListener("click", (event) => {
if (!event.target.closest(".row-menu, .row-actions")) document.querySelectorAll(".row-menu").forEach((menu) => { menu.hidden = true; });
});
document.addEventListener("keydown", (event) => { if (event.key === "Escape") void action("close"); });
api.onChanged(render);
api.onTheme(applyTheme);
void api.theme().then(applyTheme).catch(() => {});
void api.list().then(render).catch((error) => showNotice(error?.message || "Could not load downloads"));
+19
View File
@@ -0,0 +1,19 @@
"use strict";
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("hostBrowserDownloads", Object.freeze({
list: () => ipcRenderer.invoke("netcatty:host-browser:downloads:list"),
theme: () => ipcRenderer.invoke("netcatty:host-browser:downloads:theme"),
command: (type, id) => ipcRenderer.invoke("netcatty:host-browser:downloads:command", { type, id }),
onChanged: (callback) => {
const listener = (_event, items) => callback(items);
ipcRenderer.on("netcatty:host-browser:downloads-changed", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:downloads-changed", listener);
},
onTheme: (callback) => {
const listener = (_event, theme) => callback(theme);
ipcRenderer.on("netcatty:host-browser:theme", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:theme", listener);
},
}));
@@ -0,0 +1,117 @@
"use strict";
// Exercise the production React lifecycle and native manager together, including
// the StrictMode mount replay used by the desktop development app.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const os = require("node:os");
const path = require("node:path");
const vm = require("node:vm");
const dgram = require("node:dgram");
const ts = require("typescript");
const React = require("react");
const { create, act } = require("react-test-renderer");
const electron = require("electron");
const { app, BrowserWindow, WebContentsView } = electron;
const portForwardingBridge = require("../bridges/portForwardingBridge.cjs");
const { createHostBrowserManager } = require("./hostBrowserManager.cjs");
function loadModule(file, globals) {
const source = fs.readFileSync(path.join(__dirname, "../..", file), "utf8").replace(/^import .*;\n/gm, "");
const context = { exports: {}, ...globals };
vm.runInNewContext(ts.transpile(source, { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 }), context);
return context.exports;
}
async function probeWebRtcUdp(contents) {
const socket = dgram.createSocket('udp4');
let packets = 0;
socket.on('message', () => { packets += 1; });
await new Promise((resolve) => socket.bind(0, '127.0.0.1', resolve));
try {
await contents.executeJavaScript(`(async () => {
const peer = new RTCPeerConnection({ iceServers: [{ urls: 'stun:127.0.0.1:${socket.address().port}' }] });
try {
peer.createDataChannel('route-check');
await peer.setLocalDescription(await peer.createOffer());
await new Promise(resolve => setTimeout(resolve, 1500));
} finally { peer.close(); }
})()`);
return packets;
} finally { socket.close(); }
}
async function main() {
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
app.setPath("userData", path.join(os.tmpdir(), `netcatty-browser-first-link-${process.pid}`));
const site = http.createServer((_request, response) => response.end("<title>First click opened</title>"));
await new Promise((resolve) => site.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${site.address().port}/`;
const owner = new BrowserWindow({ width: 900, height: 760, show: false });
const event = { sender: owner.webContents };
const manager = createHostBrowserManager({ electron, app, portForwardingBridge });
const opens = [];
const bridge = {
startPortForward: () => { throw new Error("Local browser must not create an SSH tunnel"); },
setHostBrowserPanelLayout: async (input) => manager.updatePanelLayout(event, input),
closeHostBrowserPanel: async (id) => manager.closePanel(event, id),
openHostBrowser: (input) => { const operation = manager.open(event, input); opens.push(operation); return operation; },
};
const style = { colorScheme: "light", getPropertyValue: () => "" };
const globals = { ...React, Map, Promise, Object, crypto: require("node:crypto").webcrypto,
setTimeout, clearTimeout, queueMicrotask,
requestAnimationFrame: () => 1, cancelAnimationFrame: () => {},
document: { querySelector: () => null, documentElement: { classList: { contains: () => false } } },
getComputedStyle: () => style, netcattyBridge: { get: () => bridge }, isValidHslToken: () => false,
};
const { mountHostBrowser, currentBrowserThemeTokens } = loadModule("application/hostBrowser.ts", globals);
const { useHostBrowserPanel } = loadModule("application/state/useHostBrowserPanel.ts", { ...globals, mountHostBrowser, currentBrowserThemeTokens });
const browserId = "local:first-link";
const element = { isConnected: true, closest: () => null,
getBoundingClientRect: () => ({ x: 200, y: 50, width: 420, height: 600 }) };
function Panel() {
const panel = useHostBrowserPanel({ browserId, host: { id: "local", protocol: "local", label: "Local terminal" },
hosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {}, isVisible: true });
return React.createElement("div", { ref: panel.elementRef });
}
let renderer;
try {
// A terminal click queues its URL before opening the previously closed pane.
manager.openPanelUrl(event, { browserId, url });
await act(async () => {
renderer = create(React.createElement(React.StrictMode, null, React.createElement(Panel)), { createNodeMock: () => element });
});
await Promise.all(opens);
const root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL().endsWith("browser.html")));
assert.ok(root, "the first click must open the browser pane");
const chrome = root.children[0].webContents;
const snapshot = await chrome.executeJavaScript("window.hostBrowser.snapshot()");
assert.equal(snapshot.tabs[0].url, url, "the first click must also open its page without a second terminal click");
let loadedTitle = "";
for (let attempt = 0; attempt < 60; attempt += 1) {
loadedTitle = (await chrome.executeJavaScript("window.hostBrowser.snapshot()")).tabs[0].title;
if (loadedTitle === "First click opened") break;
await new Promise((resolve) => setTimeout(resolve, 20));
}
assert.equal(loadedTitle, "First click opened", "the first-click page must actually finish loading");
const page = root.children.find((view) => view instanceof WebContentsView && view.webContents.getURL() === url).webContents;
assert.equal(page.getWebRTCIPHandlingPolicy(), 'disable_non_proxied_udp');
page.setWebRTCIPHandlingPolicy('default');
assert.ok(await probeWebRtcUdp(page) > 0, 'the control must demonstrate direct UDP traffic');
page.setWebRTCIPHandlingPolicy('disable_non_proxied_udp');
assert.equal(await probeWebRtcUdp(page), 0, 'browser pages must not bypass their route with direct WebRTC UDP');
await act(async () => renderer.unmount());
renderer = null;
await new Promise((resolve) => setTimeout(resolve, 20));
assert.equal(owner.contentView.children.includes(root), false, "a real unmount must still release the native browser");
console.log(JSON.stringify({ ok: true, firstClickOpenedUrl: true, strictMode: true, webRtcUdpBlocked: true, cleanup: true }));
} finally {
if (renderer) await act(async () => renderer.unmount());
manager.closeAll(); owner.destroy(); site.closeAllConnections(); site.close();
}
app.quit();
}
app.whenReady().then(main).catch((error) => { console.error(error); app.exit(1); });
@@ -0,0 +1,301 @@
"use strict";
// Run with: electron electron/hostBrowser/hostBrowser.live.test.cjs
const assert = require("node:assert/strict");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const { app, BrowserWindow, WebContentsView, session, ipcMain } = require("electron");
const { createHostBrowserManager } = require("./hostBrowserManager.cjs");
const { createSiteIconStore } = require("./siteIconStore.cjs");
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const received = [];
const listeners = new Set();
const favicon = require("node:fs").readFileSync(path.join(__dirname, "../../public/icon.png"));
async function captureView(view, file) {
try {
fs.writeFileSync(file, (await view.webContents.capturePage()).toPNG());
return file;
} catch (error) {
if (error?.message === "UnknownVizError") return null;
throw error;
}
}
function reply(socket, request) {
if (request.toString().startsWith("GET /slow.bin ")) {
socket.write(Buffer.from("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename=\"slow.bin\"\r\nContent-Length: 1048576\r\nConnection: close\r\n\r\n"));
socket.write(Buffer.alloc(16384, 42));
return;
}
if (request.toString().startsWith("GET /download.bin ")) {
const body = Buffer.from("Netcatty host browser download through SOCKS5\n");
socket.write(Buffer.from(`HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Disposition: attachment; filename="host-browser-test.bin"\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`));
socket.end(body);
return;
}
const icon = request.toString().startsWith("GET /favicon.ico ");
const body = icon ? favicon : Buffer.from('<link rel="icon" href="/favicon.ico"><title>Host route verified</title><h1>Via host</h1>');
socket.write(Buffer.from(`HTTP/1.1 200 OK\r\nContent-Type: ${icon ? "image/png" : "text/html"}\r\nContent-Length: ${body.length}\r\nConnection: close\r\n\r\n`));
socket.end(body);
}
function makeSocksServer() {
return net.createServer((socket) => {
let stage = "greeting";
let buffer = Buffer.alloc(0);
socket.on("data", (chunk) => {
if (stage === "done") {
reply(socket, chunk);
stage = "replied";
return;
}
if (stage === "replied") return;
buffer = Buffer.concat([buffer, chunk]);
if (stage === "greeting" && buffer.length >= 2 + buffer[1]) {
buffer = buffer.subarray(2 + buffer[1]);
socket.write(Buffer.from([5, 0]));
stage = "request";
}
if (stage === "request" && buffer.length >= 5) {
const len = buffer[3] === 3 ? 1 + buffer[4] : buffer[3] === 4 ? 16 : 4;
if (buffer.length < 4 + len + 2) return;
received.push(buffer[3] === 3 ? buffer.subarray(5, 4 + len).toString() : "ip");
buffer = buffer.subarray(4 + len + 2);
socket.write(Buffer.from([5, 0, 0, 1, 127, 0, 0, 1, 0, 80]));
stage = "done";
if (buffer.length) {
reply(socket, buffer);
stage = "replied";
}
}
});
});
}
async function main() {
app.setPath("userData", path.join(os.tmpdir(), `netcatty-host-browser-live-${process.pid}`));
const socks = makeSocksServer();
await new Promise((resolve) => socks.listen(0, "127.0.0.1", resolve));
const port = socks.address().port;
const source = new BrowserWindow({ show: false });
const bridge = {
failNext: false,
stopped: 0,
async startPortForward(_event, options) {
assert.equal(options.reuseTransport, false);
assert.equal(options.localPort, 0);
bridge.lastTunnelId = options.tunnelId;
if (bridge.failNext) { bridge.failNext = false; return { success: false, error: "SSH unavailable" }; }
return { success: true, localPort: port };
},
async stopPortForward() { bridge.stopped += 1; return { success: true }; },
onTunnelStatus(listener) { listeners.add(listener); return () => listeners.delete(listener); },
};
const shellActions = [];
const shell = {
async openPath(file) { shellActions.push(["open", file]); return ""; },
showItemInFolder(file) { shellActions.push(["reveal", file]); },
};
const manager = createHostBrowserManager({ electron: { BrowserWindow, WebContentsView, session, ipcMain, shell }, app, portForwardingBridge: bridge });
await manager.open({ sender: source.webContents }, {
hostId: "live-host", label: "Live Test Host", theme: "dark",
themeTokens: { primary: "170 80% 40%", background: "220 28% 8%" },
connectionOptions: { hostId: "live-host", type: "dynamic", hostname: "fixture", username: "test" },
});
const browser = BrowserWindow.getAllWindows().find((win) => win !== source);
assert.ok(browser);
const page = browser.webContents;
const snapshot = () => page.executeJavaScript("window.hostBrowser.snapshot()");
assert.equal((await snapshot()).route, "connected");
assert.equal(await page.executeJavaScript("getComputedStyle(document.body).getPropertyValue('--primary').trim()"), "170 80% 40%");
assert.equal((await snapshot()).tabs.length, 1);
await page.executeJavaScript("document.getElementById('add-app').click()");
await page.executeJavaScript("document.querySelector('[data-icon-mode=text]').click();document.getElementById('bookmark-title').value='My app';document.getElementById('bookmark-url').value='http://example.invalid:3000';document.getElementById('bookmark-icon').value='\u2726';document.getElementById('bookmark-form').requestSubmit()");
await wait(300);
assert.equal(await page.executeJavaScript("document.querySelectorAll('.app-tile').length"), 1);
await page.executeJavaScript("document.querySelector('.bookmark-row button[title=Edit]').click()");
assert.equal(await page.executeJavaScript("document.getElementById('bookmark-form-heading').textContent"), "Edit app");
assert.equal(await page.executeJavaScript("document.querySelector('[data-icon-mode=text]').getAttribute('aria-pressed')"), "true");
await page.executeJavaScript("document.querySelector('[data-icon-mode=website]').click()");
assert.equal(await page.executeJavaScript("document.getElementById('bookmark-text-options').hidden"), true);
await page.executeJavaScript("document.querySelector('[data-icon-mode=image]').click()");
assert.equal(await page.executeJavaScript("document.getElementById('bookmark-image-options').hidden"), false);
await page.executeJavaScript(`{
const bytes = Uint8Array.from(atob("${favicon.toString("base64")}"), (char) => char.charCodeAt(0));
const transfer = new DataTransfer();
transfer.items.add(new File([bytes], "icon.png", { type: "image/png" }));
const input = document.getElementById('bookmark-image');
input.files = transfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}`);
for (let i = 0; i < 20 && !(await page.executeJavaScript("document.querySelector('#bookmark-icon-preview img')?.src.startsWith('data:image/png;base64,')")); i += 1) await wait(100);
assert.equal(await page.executeJavaScript("document.querySelector('#bookmark-icon-preview img')?.src.startsWith('data:image/png;base64,')"), true);
await page.executeJavaScript("document.querySelector('[data-icon-mode=text]').click();document.querySelector('[data-scope=all-hosts]').click()");
assert.equal(await page.executeJavaScript("document.getElementById('bookmark-scope').value"), "all-hosts");
await page.executeJavaScript("document.querySelector('[data-scope=host]').click()");
const formScreenshot = path.join(os.tmpdir(), "netcatty-host-browser-app-form.png");
fs.writeFileSync(formScreenshot, (await page.capturePage()).toPNG());
await page.executeJavaScript("document.getElementById('bookmark-back').click()");
await page.executeJavaScript("document.querySelector('.app-tile').click()");
for (let i = 0; i < 50 && !(await snapshot()).tabs[0].title.includes("Host route verified"); i += 1) await wait(100);
assert.ok(received.includes("example.invalid"), `SOCKS requests: ${received.join(",")}`);
assert.equal((await snapshot()).tabs[0].title, "Host route verified");
for (let i = 0; i < 50 && !(await snapshot()).tabs[0].iconDataUrl; i += 1) await wait(100);
assert.match((await snapshot()).tabs[0].iconDataUrl || "", /^data:image\/png;base64,/);
assert.equal(await page.executeJavaScript("document.querySelector('.app-tile .app-icon').textContent"), "\u2726");
assert.match(createSiteIconStore(app).get("live-host", "http://example.invalid:3000/")?.dataUrl || "", /^data:image\/png;base64,/);
await page.executeJavaScript("document.getElementById('downloads-menu').click()");
for (let i = 0; i < 30 && !(await snapshot()).downloadsPanelOpen; i += 1) await wait(100);
assert.equal((await snapshot()).downloadsPanelOpen, true);
assert.equal(browser.contentView.children.length, 2);
const downloadsView = browser.contentView.children[1];
assert.equal(await downloadsView.webContents.executeJavaScript("document.getElementById('empty').hidden"), false);
assert.ok(downloadsView.getBounds().height < 300);
const downloadsScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "netcatty-host-browser-downloads.png"));
const downloadPath = path.join(app.getPath("userData"), "host-browser-test.bin");
const slowPath = path.join(app.getPath("userData"), "slow.bin");
const websiteView = browser.contentView.children[0];
websiteView.webContents.session.on("will-download", (_event, item) => item.setSavePath(item.getFilename() === "slow.bin" ? slowPath : downloadPath));
browser.contentView.children[0].webContents.downloadURL("http://example.invalid:3000/download.bin");
for (let i = 0; i < 50 && !(await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.status === "completed"); i += 1) await wait(100);
const downloaded = (await page.executeJavaScript("window.hostBrowser.listDownloads()"))[0];
assert.equal(downloaded.status, "completed");
assert.equal(downloaded.filename, "host-browser-test.bin");
assert.equal(fs.readFileSync(downloadPath, "utf8"), "Netcatty host browser download through SOCKS5\n");
assert.equal(await downloadsView.webContents.executeJavaScript("document.querySelectorAll('.download-row').length"), 1);
await downloadsView.webContents.executeJavaScript("document.querySelector('.row-actions button[title=\"Show in folder\"]').click()");
for (let i = 0; i < 20 && shellActions.length < 1; i += 1) await wait(50);
assert.deepEqual(shellActions[0], ["reveal", downloadPath]);
await downloadsView.webContents.executeJavaScript("document.querySelector('.row-actions button[title=\"More actions\"]').click();document.querySelector('.row-menu button').click()");
for (let i = 0; i < 20 && shellActions.length < 2; i += 1) await wait(50);
assert.deepEqual(shellActions[1], ["open", downloadPath]);
await downloadsView.webContents.executeJavaScript("document.getElementById('open-folder').click()");
for (let i = 0; i < 20 && shellActions.length < 3; i += 1) await wait(50);
assert.deepEqual(shellActions[2], ["open", app.getPath("downloads")]);
websiteView.webContents.downloadURL("http://example.invalid:3000/slow.bin");
for (let i = 0; i < 40 && !(await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.filename === "slow.bin" && item.status === "progressing"); i += 1) await wait(100);
assert.equal((await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.filename === "slow.bin" && item.status === "progressing"), true);
await downloadsView.webContents.executeJavaScript("document.querySelector('button[title=\"Cancel download\"]').click()");
for (let i = 0; i < 40 && !(await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.filename === "slow.bin" && item.status === "cancelled"); i += 1) await wait(100);
assert.equal((await page.executeJavaScript("window.hostBrowser.listDownloads()")).some((item) => item.filename === "slow.bin" && item.status === "cancelled"), true);
const downloadedScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "netcatty-host-browser-downloaded.png"));
assert.equal(downloaded.missing, false);
await downloadsView.webContents.executeJavaScript("document.getElementById('clear').click()");
for (let i = 0; i < 20 && (await page.executeJavaScript("window.hostBrowser.listDownloads()")).length; i += 1) await wait(100);
assert.equal((await page.executeJavaScript("window.hostBrowser.listDownloads()")).length, 0);
assert.equal(fs.existsSync(downloadPath), true);
fs.unlinkSync(downloadPath);
await downloadsView.webContents.executeJavaScript("document.getElementById('close').click()");
for (let i = 0; i < 20 && (await snapshot()).downloadsPanelOpen; i += 1) await wait(100);
assert.equal((await snapshot()).downloadsPanelOpen, false);
await page.executeJavaScript("document.querySelector('.tab').dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:80,clientY:25}))");
await page.executeJavaScript("document.getElementById('tab-icon-change').click();document.getElementById('tab-icon-text').value='\u2726';document.getElementById('tab-icon-save').click()");
for (let i = 0; i < 30 && (await snapshot()).tabs[0].iconGlyph !== "\u2726"; i += 1) await wait(100);
assert.equal((await snapshot()).tabs[0].iconGlyph, "\u2726");
assert.equal(createSiteIconStore(app).get("live-host", "http://example.invalid:3000/")?.mode, "custom");
await page.executeJavaScript("document.querySelector('.tab').dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:80,clientY:25}));document.getElementById('tab-icon-reset').click()");
for (let i = 0; i < 50 && !(await snapshot()).tabs[0].iconDataUrl; i += 1) await wait(100);
assert.equal(createSiteIconStore(app).get("live-host", "http://example.invalid:3000/")?.mode, "auto");
assert.equal(await page.executeJavaScript("Boolean(document.querySelector('.tab .tab-favicon'))"), true);
websiteView.webContents.focus();
websiteView.webContents.sendInputEvent({ type: "keyDown", keyCode: "L", modifiers: ["meta"] });
for (let i = 0; i < 20 && !(await page.executeJavaScript("document.activeElement === document.getElementById('address')")); i += 1) await wait(100);
assert.equal(await page.executeJavaScript("document.activeElement === document.getElementById('address')"), true);
await page.executeJavaScript("document.getElementById('new-tab').click()");
await wait(100);
assert.equal((await snapshot()).tabs.length, 2);
assert.equal(await page.executeJavaScript("document.getElementById('address').value"), "");
const [websiteTab, newTab] = (await snapshot()).tabs;
await page.executeJavaScript(`window.hostBrowser.command({type:'reorder',tabId:'${newTab.id}',beforeTabId:'${websiteTab.id}'})`);
assert.equal((await snapshot()).tabs[0].id, newTab.id);
await page.executeJavaScript(`window.hostBrowser.command({type:'reorder',tabId:'${websiteTab.id}',beforeTabId:'${newTab.id}'})`);
assert.equal(await page.executeJavaScript("!document.getElementById('home').hidden"), true);
await page.executeJavaScript("document.getElementById('bookmarks-menu').click()");
assert.equal(await page.executeJavaScript("!document.getElementById('bookmark-panel').hidden"), true);
await page.executeJavaScript("document.getElementById('bookmarks-menu').click()");
await wait(100);
assert.equal(await page.executeJavaScript("document.getElementById('bookmark-panel').hidden"), true);
await page.executeJavaScript("document.getElementById('bookmarks-menu').click()");
await wait(100);
assert.equal(await page.executeJavaScript("document.getElementById('add-app').getBoundingClientRect().right <= document.getElementById('bookmark-panel').getBoundingClientRect().left"), true);
const screenshot = path.join(os.tmpdir(), "netcatty-host-browser-live.png");
require("node:fs").writeFileSync(screenshot, (await page.capturePage()).toPNG());
browser.setSize(720, 500);
await wait(100);
assert.equal(await page.executeJavaScript("document.getElementById('add-app').getBoundingClientRect().right <= document.getElementById('bookmark-panel').getBoundingClientRect().left"), true);
assert.equal(await page.executeJavaScript("document.getElementById('address').getBoundingClientRect().width > 80"), true);
browser.setSize(1100, 760);
await manager.open({ sender: source.webContents }, {
hostId: "live-host", label: "Live Test Host", theme: "light",
connectionOptions: { hostId: "live-host", type: "dynamic", hostname: "fixture", username: "test" },
});
assert.equal((await snapshot()).theme, "light");
const lightScreenshot = path.join(os.tmpdir(), "netcatty-host-browser-live-light.png");
require("node:fs").writeFileSync(lightScreenshot, (await page.capturePage()).toPNG());
await page.executeJavaScript("document.getElementById('downloads-menu').click()");
for (let i = 0; i < 20 && !(await snapshot()).downloadsPanelOpen; i += 1) await wait(100);
assert.equal(await downloadsView.webContents.executeJavaScript("document.body.classList.contains('dark')"), false);
const lightDownloadsScreenshot = await captureView(downloadsView, path.join(os.tmpdir(), "netcatty-host-browser-downloads-light.png"));
await downloadsView.webContents.executeJavaScript("document.getElementById('close').click()");
assert.equal(browser.isMinimizable(), true);
assert.equal(browser.isMaximizable(), true);
await page.executeJavaScript("window.hostBrowser.command({type:'window-maximize'})");
for (let i = 0; i < 20 && !browser.isMaximized(); i += 1) await wait(100);
assert.equal(browser.isMaximized(), true);
await page.executeJavaScript("window.hostBrowser.command({type:'window-maximize'})");
for (let i = 0; i < 20 && browser.isMaximized(); i += 1) await wait(100);
assert.equal(browser.isMaximized(), false);
await page.executeJavaScript("window.hostBrowser.command({type:'window-minimize'})");
for (let i = 0; i < 20 && !browser.isMinimized(); i += 1) await wait(100);
assert.equal(browser.isMinimized(), true);
browser.restore();
await page.executeJavaScript("document.querySelector('.tab').dispatchEvent(new MouseEvent('contextmenu',{bubbles:true,clientX:80,clientY:25}));document.getElementById('tab-icon-change').click()");
await page.executeJavaScript(`{
const bytes = Uint8Array.from(atob("${favicon.toString("base64")}"), (char) => char.charCodeAt(0));
const file = new File([bytes], "icon.png", { type: "image/png" });
const transfer = new DataTransfer();
transfer.items.add(file);
document.getElementById('tab-icon-image').files = transfer.files;
document.getElementById('tab-icon-text').value = '';
document.getElementById('tab-icon-save').click();
}`);
for (let i = 0; i < 30 && (await snapshot()).tabs[0].iconGlyph; i += 1) await wait(100);
assert.match((await snapshot()).tabs[0].iconDataUrl || "", /^data:image\/png;base64,/);
assert.equal(createSiteIconStore(app).get("live-host", "http://example.invalid:3000/")?.mode, "custom");
const route = await snapshot();
for (const listener of listeners) listener({ tunnelId: bridge.lastTunnelId, status: "inactive" });
for (let i = 0; i < 30 && (await snapshot()).route !== "disconnected"; i += 1) await wait(100);
assert.equal((await snapshot()).route, "disconnected");
assert.equal(await page.executeJavaScript("!document.getElementById('retry').hidden"), true);
await page.executeJavaScript("document.getElementById('retry').click()");
for (let i = 0; i < 30 && (await snapshot()).route !== "connected"; i += 1) await wait(100);
assert.equal((await snapshot()).route, "connected");
bridge.failNext = true;
await manager.open({ sender: source.webContents }, {
hostId: "failure-host", label: "Failure Host", theme: "dark",
connectionOptions: { hostId: "failure-host", type: "dynamic", hostname: "fixture", username: "test" },
});
const failedWindow = BrowserWindow.getAllWindows().find((win) => win !== source && win !== browser);
assert.ok(failedWindow);
const failedSnapshot = () => failedWindow.webContents.executeJavaScript("window.hostBrowser.snapshot()");
assert.equal((await failedSnapshot()).route, "disconnected");
await failedWindow.webContents.executeJavaScript("document.getElementById('retry').click()");
for (let i = 0; i < 30 && (await failedSnapshot()).route !== "connected"; i += 1) await wait(100);
assert.equal((await failedSnapshot()).route, "connected");
const stoppedBeforeClose = bridge.stopped;
failedWindow.close();
for (let i = 0; i < 20 && bridge.stopped === stoppedBeforeClose; i += 1) await wait(100);
assert.equal(failedWindow.isDestroyed(), true);
assert.ok(bridge.stopped > stoppedBeforeClose);
console.log(JSON.stringify({ ok: true, screenshot, lightScreenshot, formScreenshot, downloadsScreenshot, lightDownloadsScreenshot, downloadedScreenshot, received, tabs: route.tabs.length }));
manager.closeAll();
source.destroy();
socks.close();
app.quit();
}
app.whenReady().then(main).catch((error) => { console.error(error); app.exit(1); });
@@ -0,0 +1,202 @@
"use strict";
// Run with: electron electron/hostBrowser/hostBrowser.panel.live.test.cjs
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const http = require("node:http");
const net = require("node:net");
const path = require("node:path");
const os = require("node:os");
const { Server } = require("ssh2");
const electron = require("electron");
const { app, BrowserWindow, WebContentsView } = electron;
const portForwardingBridge = require("../bridges/portForwardingBridge.cjs");
const { createHostBrowserManager } = require("./hostBrowserManager.cjs");
async function waitFor(read, expected) {
for (let index = 0; index < 100; index += 1) {
if (await read() === expected) return;
await new Promise((resolve) => setTimeout(resolve, 50));
}
assert.equal(await read(), expected);
}
async function fixture(label) {
const sockets = new Set();
const allowed = new Set();
let connections = 0;
const site = http.createServer((request, response) => {
assert.ok(allowed.has(request.socket.remotePort), "page must arrive through its SSH host");
response.writeHead(200, { "content-type": "text/html" });
response.end(`<title>${label}</title><h1>${label}</h1>`);
});
await new Promise((resolve) => site.listen(0, "127.0.0.1", resolve));
const { privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
const server = new Server({ hostKeys: [privateKey] }, (client) => {
sockets.add(client);
client.on("close", () => sockets.delete(client));
client.on("authentication", (context) => context.method === "password" ? context.accept() : context.reject());
client.on("ready", () => client.on("tcpip", (accept, reject, info) => {
if (info.destPort !== site.address().port) return reject();
connections += 1;
const channel = accept();
const target = net.connect(site.address().port, "127.0.0.1");
target.once("connect", () => { allowed.add(target.localPort); channel.pipe(target).pipe(channel); });
target.on("close", () => allowed.delete(target.localPort));
target.on("error", () => channel.destroy());
channel.on("error", () => target.destroy());
}));
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
return {
options: { hostId: label, type: "dynamic", hostname: "127.0.0.1", port: server.address().port,
username: "test", authMethod: "password", password: "fixture", verifyHostKeys: false, knownHosts: [] },
url: `http://localhost:${site.address().port}/`,
connections: () => connections,
close() { for (const socket of sockets) socket.end(); server.close(); site.closeAllConnections(); site.close(); },
};
}
async function main() {
app.setPath("userData", path.join(os.tmpdir(), `netcatty-browser-panel-${process.pid}`));
const a = await fixture("Host A");
const b = await fixture("Host B");
const owner = new BrowserWindow({ width: 1100, height: 760, show: true });
await owner.loadURL("data:text/html,<h1>Terminal workspace</h1>");
const ownerEvents = ["blur", "resize", "maximize", "unmaximize", "closed"];
const beforeListeners = ownerEvents.map((name) => owner.listenerCount(name));
const manager = createHostBrowserManager({ electron, app, portForwardingBridge });
const event = { sender: owner.webContents };
const bounds = { x: 680, y: 50, width: 400, height: 650 };
const layout = (browserId, visible, nextBounds = bounds) => manager.updatePanelLayout(event, { browserId, visible, bounds: nextBounds });
const open = async (id, host) => {
layout(id, true);
await manager.open(event, { browserId: id, embedded: true, hostId: host.options.hostId,
label: host.options.hostId, theme: "light", connectionOptions: host.options });
return owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL().endsWith("browser.html")));
};
manager.openPanelUrl(event, { browserId: "workspace:session-a", url: a.url });
const rootA = await open("workspace:session-a", a);
const chromeA = rootA.children[0].webContents;
const snapshotA = () => chromeA.executeJavaScript("window.hostBrowser.snapshot()");
assert.equal((await snapshotA()).embedded, true);
assert.equal(await chromeA.executeJavaScript("document.querySelector('#route') === null"), true);
assert.equal(await chromeA.executeJavaScript("document.documentElement.scrollWidth <= innerWidth"), true);
await waitFor(async () => (await snapshotA()).tabs[0].title, "Host A");
const chromeBottom = await chromeA.executeJavaScript("document.querySelector('.chrome').getBoundingClientRect().bottom");
const pageA = rootA.children.find((child) => child instanceof WebContentsView && child.webContents.getURL() === a.url);
assert.equal(pageA.getBounds().y, chromeBottom, "page must meet the compact toolbar without a gap or overlap");
await chromeA.executeJavaScript("document.querySelector('.tab').dispatchEvent(new MouseEvent('contextmenu', {bubbles:true,clientX:30,clientY:20}))");
await waitFor(() => rootA.children.includes(pageA), false);
await chromeA.executeJavaScript("document.querySelector('#tab-icon-change').click()");
assert.equal(rootA.children.includes(pageA), false, "web content must not cover the icon editor");
assert.equal(await chromeA.executeJavaScript("document.querySelector('#tab-icon-editor').hidden"), false);
await chromeA.executeJavaScript("document.querySelector('#tab-icon-cancel').click()");
await waitFor(() => rootA.children.includes(pageA), true);
layout("workspace:session-a", false);
assert.equal(owner.contentView.children.includes(rootA), false);
// A locked desktop cannot grant focus; keep the focus check enabled by default.
if (!process.env.NETCATTY_BROWSER_SMOKE_SKIP_FOCUS) await waitFor(() => owner.webContents.isFocused(), true);
const rootB = await open("workspace:session-b", b);
const chromeB = rootB.children[0].webContents;
await chromeB.executeJavaScript(`window.hostBrowser.command({type:'navigate',url:${JSON.stringify(b.url)}})`);
await waitFor(async () => (await chromeB.executeJavaScript("window.hostBrowser.snapshot()")).tabs[0].title, "Host B");
assert.equal(BrowserWindow.getAllWindows().length, 1, "panels must not create popup windows");
layout("workspace:session-b", false);
layout("workspace:session-a", true, { x: 20, y: 80, width: 350, height: 500 });
assert.deepEqual(rootA.getBounds(), { x: 20, y: 80, width: 350, height: 500 });
assert.equal((await snapshotA()).tabs[0].title, "Host A", "focus switch preserves the earlier page");
assert.equal(owner.contentView.children.includes(rootB), false, "old context must leave the screen");
for (let index = 0; index < 6; index += 1) {
await chromeA.executeJavaScript("window.hostBrowser.command({type:'new-tab'})");
}
assert.equal(await chromeA.executeJavaScript(`(() => {
const button = document.querySelector('#new-tab').getBoundingClientRect();
return document.documentElement.scrollWidth <= innerWidth && button.left >= 0 && button.right <= innerWidth;
})()`), true, "tabs must scroll within a narrow pane and leave the add button reachable");
await chromeA.executeJavaScript("document.querySelector('#add-app').click()");
assert.equal(await chromeA.executeJavaScript("document.querySelector('#bookmark-panel').getBoundingClientRect().width <= innerWidth"), true);
await chromeA.executeJavaScript("document.querySelector('#bookmark-close').click()");
await chromeA.executeJavaScript("window.hostBrowser.command({type:'set-download-panel',open:true})");
const downloads = rootA.children.find((child) => child instanceof WebContentsView && child.webContents.getURL().endsWith("downloads.html"));
assert.ok(downloads);
assert.ok(downloads.getBounds().width <= rootA.getBounds().width);
assert.equal(downloads.getBounds().y, chromeBottom, "downloads must start below the compact toolbar");
const otherOwner = new BrowserWindow({ show: false });
assert.throws(() => manager.updatePanelLayout({ sender: otherOwner.webContents }, { browserId: "workspace:session-a", visible: false }), /another terminal window/);
assert.throws(() => manager.openPanelUrl({ sender: otherOwner.webContents }, { browserId: "workspace:session-a", url: a.url }), /another terminal window/);
assert.throws(() => manager.openPanelUrl(event, { browserId: "workspace:session-a", url: "file:///etc/passwd" }), /HTTP and HTTPS/);
otherOwner.destroy();
assert.ok(a.connections() > 0 && b.connections() > 0);
manager.closePanel(event, "workspace:session-a");
await waitFor(() => chromeA.isDestroyed(), true);
assert.equal(owner.isDestroyed(), false, "closing browser must keep the terminal window alive");
const localSite = http.createServer((request, response) => {
if (request.url === '/slow.bin') {
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': 1048576 });
response.write(Buffer.alloc(16384));
return;
}
response.end('<title>Local browser</title><h1>Local browser</h1>');
});
await new Promise((resolve) => localSite.listen(0, "127.0.0.1", resolve));
manager.openPanelUrl(event, { browserId: "local:session", url: `http://127.0.0.1:${localSite.address().port}/` });
layout("local:session", true);
await manager.open(event, { browserId: "local:session", embedded: true, local: true,
hostId: "local", label: "Local terminal", theme: "light" });
const localRoot = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL().endsWith("browser.html")));
const localChrome = localRoot.children[0].webContents;
await waitFor(async () => (await localChrome.executeJavaScript("window.hostBrowser.snapshot()")).tabs[0].title, "Local browser");
assert.equal((await localChrome.executeJavaScript("window.hostBrowser.snapshot()")).tabs.length, 1, "first terminal link reuses the blank tab");
const localPage = localRoot.children.find((child) => child instanceof WebContentsView && child !== localRoot.children[0]);
layout('local:session', false);
layout('local:peer', true);
await manager.open(event, { browserId: 'local:peer', embedded: true, local: true,
hostId: 'local', label: 'Same host peer', theme: 'light' });
const peerRoot = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL().endsWith('browser.html')));
await peerRoot.children[0].webContents.executeJavaScript("window.hostBrowser.command({type:'set-download-panel',open:true})");
const peerDownloads = peerRoot.children.find((child) => child instanceof WebContentsView && child.webContents.getURL().endsWith('downloads.html')).webContents;
let activeDownload;
const startDownload = async () => {
activeDownload = undefined;
localPage.webContents.session.once('will-download', (_downloadEvent, item) => {
activeDownload = item;
item.setSavePath(path.join(app.getPath('userData'), 'closing-download.bin'));
});
localPage.webContents.downloadURL(`http://127.0.0.1:${localSite.address().port}/slow.bin`);
await waitFor(() => Boolean(activeDownload), true);
};
await startDownload();
let cancelled = false;
activeDownload.once('done', (_downloadEvent, status) => { cancelled = status === 'cancelled'; });
await waitFor(() => peerDownloads.executeJavaScript("Boolean(document.querySelector('button[title=\"Cancel download\"]'))"), true);
await peerDownloads.executeJavaScript("window.hostBrowserDownloads.list().then(items => window.hostBrowserDownloads.command('cancel', items[0].id))");
await waitFor(() => cancelled, true);
await waitFor(() => peerDownloads.executeJavaScript("Boolean(document.querySelector('button[title=\"Cancel download\"]'))"), false);
manager.closePanel(event, 'local:peer');
await startDownload();
cancelled = false;
activeDownload.once('done', (_downloadEvent, status) => { cancelled = status === 'cancelled'; });
manager.closePanel(event, 'local:session');
await waitFor(() => cancelled, true);
await waitFor(() => localChrome.isDestroyed(), true);
assert.equal(owner.isDestroyed(), false, 'closing during a download must keep the terminal alive');
manager.closeAll();
localSite.closeAllConnections();
localSite.close();
assert.equal(owner.isDestroyed(), false);
assert.deepEqual(ownerEvents.map((name) => owner.listenerCount(name)), beforeListeners);
owner.destroy();
a.close(); b.close();
console.log(JSON.stringify({ ok: true, embedded: true, hostSwitching: true, pageRetention: true, popups: 0 }));
app.quit();
}
app.whenReady().then(main).catch((error) => { console.error(error); app.exit(1); });
@@ -0,0 +1,87 @@
"use strict";
// Run with: electron electron/hostBrowser/hostBrowser.ssh.live.test.cjs
const assert = require("node:assert/strict");
const crypto = require("node:crypto");
const http = require("node:http");
const net = require("node:net");
const os = require("node:os");
const path = require("node:path");
const { Server } = require("ssh2");
const { app, BrowserWindow, WebContentsView, session, ipcMain } = require("electron");
const portForwardingBridge = require("../bridges/portForwardingBridge.cjs");
const { createHostBrowserManager } = require("./hostBrowserManager.cjs");
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function main() {
app.setPath("userData", path.join(os.tmpdir(), `netcatty-browser-ssh-live-${process.pid}`));
const allowedPorts = new Set();
let forwardedConnections = 0;
const site = http.createServer((request, response) => {
if (!allowedPorts.has(request.socket.remotePort)) {
response.writeHead(403);
response.end("Direct connection denied");
return;
}
response.writeHead(200, { "content-type": "text/html" });
response.end("<title>SSH route reached</title><h1>SSH route reached</h1>");
});
await new Promise((resolve) => site.listen(0, "127.0.0.1", resolve));
const sitePort = site.address().port;
const { privateKey } = crypto.generateKeyPairSync("rsa", {
modulusLength: 2048,
privateKeyEncoding: { type: "pkcs1", format: "pem" },
publicKeyEncoding: { type: "spki", format: "pem" },
});
const ssh = new Server({ hostKeys: [privateKey] }, (client) => {
client.on("authentication", (context) => {
if (context.method === "password" && context.username === "test" && context.password === "secret") context.accept();
else context.reject();
});
client.on("ready", () => {
client.on("tcpip", (accept, reject, info) => {
if (Number(info.destPort) !== sitePort || !["localhost", "127.0.0.1"].includes(info.destIP)) return reject();
forwardedConnections += 1;
const channel = accept();
const target = net.connect(sitePort, "127.0.0.1");
target.once("connect", () => {
allowedPorts.add(target.localPort);
channel.pipe(target).pipe(channel);
});
target.on("close", () => allowedPorts.delete(target.localPort));
channel.on("error", () => target.destroy());
target.on("error", () => channel.destroy());
});
});
});
await new Promise((resolve) => ssh.listen(0, "127.0.0.1", resolve));
const source = new BrowserWindow({ show: false });
const manager = createHostBrowserManager({ electron: { BrowserWindow, WebContentsView, session, ipcMain }, app, portForwardingBridge });
await manager.open({ sender: source.webContents }, {
hostId: "ssh-host", label: "SSH Fixture", theme: "dark",
connectionOptions: {
hostId: "ssh-host", type: "dynamic", hostname: "127.0.0.1", port: ssh.address().port,
username: "test", authMethod: "password", password: "secret", verifyHostKeys: false,
knownHosts: [], sshTcpConnectTimeoutMs: 5000, sshAuthReadyTimeoutMs: 5000,
},
});
const browser = BrowserWindow.getAllWindows().find((win) => win !== source);
assert.ok(browser);
const snapshot = () => browser.webContents.executeJavaScript("window.hostBrowser.snapshot()");
assert.equal((await snapshot()).route, "connected", JSON.stringify(await snapshot()));
await browser.webContents.executeJavaScript(`window.hostBrowser.command({type:'navigate',url:'http://localhost:${sitePort}/'})`);
for (let i = 0; i < 60 && (await snapshot()).tabs[0].title !== "SSH route reached"; i += 1) await wait(100);
assert.equal((await snapshot()).tabs[0].title, "SSH route reached");
assert.ok(forwardedConnections > 0, "the page must pass through an SSH tcpip channel");
browser.close();
await wait(100);
manager.closeAll();
source.destroy();
ssh.close();
site.close();
console.log(JSON.stringify({ ok: true, forwardedConnections }));
app.quit();
}
app.whenReady().then(main).catch((error) => { console.error(error); app.exit(1); });
+826
View File
@@ -0,0 +1,826 @@
"use strict";
const path = require("node:path");
const fs = require("node:fs");
const { createHash, randomUUID } = require("node:crypto");
const { createBookmarkStore } = require("./bookmarkStore.cjs");
const { createDownloadStore } = require("./downloadStore.cjs");
const { createSiteIconStore, iconOrigin, validImageDataUrl } = require("./siteIconStore.cjs");
const { windowsFramelessContentChromeOptions } = require("../bridges/windowManager/windowsWindowChrome.cjs");
const DEAD_PROXY = "socks5://127.0.0.1:9";
const CHROME_HEIGHT = 80;
const EMBEDDED_CHROME_HEIGHT = 64;
const chromeHeight = (record) => Math.round((record.embedded ? EMBEDDED_CHROME_HEIGHT : CHROME_HEIGHT) * (record.zoom || 1));
const MAX_TABS = 32;
const DOWNLOAD_PANEL_WIDTH = 372;
const ALLOWED_PAGE_SCHEMES = new Set(["http:", "https:", "ws:", "wss:", "blob:", "data:"]);
const THEME_TOKEN_NAMES = ["background", "foreground", "secondary", "muted", "muted-foreground", "border", "primary", "primary-foreground"];
function validThemeTokens(input) {
const result = {};
for (const name of THEME_TOKEN_NAMES) {
const value = input?.[name];
if (typeof value === "string" && /^\s*\d+(\.\d+)?\s+\d+(\.\d+)?%\s+\d+(\.\d+)?%\s*$/.test(value)) result[name] = value.trim();
}
return result;
}
function imageMime(bytes) {
if (bytes.length >= 8 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return "image/jpeg";
if (bytes.length >= 6 && ["GIF87a", "GIF89a"].includes(bytes.subarray(0, 6).toString())) return "image/gif";
if (bytes.length >= 12 && bytes.toString("ascii", 0, 4) === "RIFF" && bytes.toString("ascii", 8, 12) === "WEBP") return "image/webp";
if (bytes.length >= 4 && bytes[0] === 0 && bytes[1] === 0 && bytes[2] === 1 && bytes[3] === 0) return "image/x-icon";
return null;
}
async function fetchPageIcon(pageSession, url) {
if (validImageDataUrl(url)) return url;
const parsed = new URL(url);
if (!["http:", "https:"].includes(parsed.protocol) || parsed.username || parsed.password) return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 5000);
try {
const response = await pageSession.fetch(parsed.href, { signal: controller.signal });
if (!response.ok || !response.body) return null;
const reader = response.body.getReader();
const chunks = [];
let size = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > 64 * 1024) { await reader.cancel(); return null; }
chunks.push(Buffer.from(value));
}
const bytes = Buffer.concat(chunks);
const mime = imageMime(bytes);
return mime ? `data:${mime};base64,${bytes.toString("base64")}` : null;
} catch { return null; }
finally { clearTimeout(timer); }
}
function normalizeBrowserUrl(input) {
const raw = String(input || "").trim();
if (!raw || raw.length > 2048) throw new Error("Enter a valid web address");
const isLocal = /^(localhost|127\.\d+\.\d+\.\d+|\[[0-9a-f:]+\])(?::\d+)?(?:\/|$)/i.test(raw);
const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(raw) && !/^[^/:]+:\d+(?:[/?#]|$)/.test(raw);
const candidate = hasScheme ? raw : `${isLocal ? "http" : "https"}://${raw}`;
const url = new URL(candidate);
if (url.protocol !== "http:" && url.protocol !== "https:") throw new Error("Only HTTP and HTTPS pages can open here");
if (!url.hostname || url.username || url.password) throw new Error("Enter a valid web address without credentials");
return url.href;
}
function createHostBrowserManager({ electron, app, portForwardingBridge }) {
const { BrowserWindow, WebContentsView, session, ipcMain, shell } = electron;
const windows = new Map();
const panelLayouts = new Map();
const byChromeId = new Map();
const byDownloadsId = new Map();
const siteIconStore = createSiteIconStore(app);
const downloadStore = createDownloadStore(app);
const bookmarkStore = createBookmarkStore(app, () => {
for (const record of windows.values()) {
if (record.embedded && !record.chromeContents.isDestroyed()) record.chromeContents.send("netcatty:host-browser:bookmarks-changed");
}
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
win.webContents.send("netcatty:host-browser:bookmarks-changed");
}
}
});
const send = (record) => {
if (record.win.isDestroyed() || record.chromeContents.isDestroyed()) return;
record.chromeContents.send("netcatty:host-browser:state", snapshot(record));
};
const notifyDownloads = (record) => {
const downloads = downloadStore.list(record.hostId);
for (const target of windows.values()) {
if (target.hostId !== record.hostId || target.win.isDestroyed() || target.chromeContents.isDestroyed()) continue;
if (target.downloadsPanelOpen) positionDownloadsView(target);
target.chromeContents.send("netcatty:host-browser:downloads-changed", downloads);
if (target.downloadsView && !target.downloadsView.webContents.isDestroyed()) {
target.downloadsView.webContents.send("netcatty:host-browser:downloads-changed", downloads);
}
}
};
const snapshot = (record) => ({
hostId: record.hostId,
label: record.label,
theme: record.theme,
themeTokens: record.themeTokens,
platform: process.platform,
isMaximized: !record.embedded && record.win.isMaximized(),
embedded: record.embedded,
downloadsPanelOpen: record.downloadsPanelOpen,
route: record.route,
error: record.error,
activeTabId: record.activeTabId,
tabs: [...record.tabs.values()].map(({ id, url, title, loading, canGoBack, canGoForward }) => {
const icon = siteIconStore.get(record.hostId, url);
return { id, url, title, loading, canGoBack, canGoForward,
iconDataUrl: icon?.dataUrl || null, iconGlyph: icon?.glyph || null, iconColor: icon?.color || null };
}),
});
const notifySiteIcon = (record) => {
send(record);
if (!record.win.isDestroyed()) record.chromeContents.send("netcatty:host-browser:site-icons-changed");
};
const updateFavicon = async (record, tab, urls) => {
if (!tab.url || record.route !== "connected") return;
const origin = iconOrigin(tab.url);
if (!origin || siteIconStore.get(record.hostId, tab.url)?.mode === "custom") return;
const generation = record.routeGeneration;
for (const url of urls) {
let dataUrl = null;
try { dataUrl = await fetchPageIcon(record.pageSession, url); } catch {}
if (!dataUrl) continue;
if (record.route !== "connected" || record.routeGeneration !== generation || iconOrigin(tab.url) !== origin) return;
siteIconStore.set(record.hostId, tab.url, { mode: "auto", dataUrl, glyph: "", color: "#247969" });
notifySiteIcon(record);
return;
}
};
const activeView = (record) => record.tabs.get(record.activeTabId)?.view;
const positionDownloadsView = (record) => {
if (!record.downloadsView || record.win.isDestroyed()) return;
const [width, height] = record.embedded
? [record.bounds.width, record.bounds.height] : record.win.getContentSize();
const downloads = downloadStore.list(record.hostId);
const rowsHeight = Math.min(348, downloads.length * 66 + 8);
const panelHeight = downloads.length
? 72 + rowsHeight + (downloads.some((item) => !["waiting", "progressing"].includes(item.status)) ? 38 : 0)
: 230;
const zoom = record.zoom || 1;
record.downloadsView.webContents.setZoomFactor(zoom);
record.downloadsView.setBounds({
x: Math.max(0, width - Math.round((DOWNLOAD_PANEL_WIDTH + 8) * zoom)), y: chromeHeight(record),
width: Math.min(Math.round(DOWNLOAD_PANEL_WIDTH * zoom), width), height: Math.min(Math.round(panelHeight * zoom), Math.max(1, height - chromeHeight(record))),
});
};
const closeDownloadsPanel = (record) => {
if (!record.downloadsPanelOpen) return;
record.downloadsPanelOpen = false;
try { record.container.removeChildView(record.downloadsView); } catch {}
send(record);
};
const setDownloadsPanel = async (record, open) => {
if (!open) { closeDownloadsPanel(record); return; }
if (!record.downloadsView) {
const view = new WebContentsView({ webPreferences: {
preload: path.join(__dirname, "downloadsPreload.cjs"),
sandbox: true, contextIsolation: true, nodeIntegration: false,
} });
view.setBackgroundColor("#00000000");
view.webContents.setWindowOpenHandler(() => ({ action: "deny" }));
view.webContents.on("will-navigate", (event) => event.preventDefault());
record.downloadsView = view;
byDownloadsId.set(view.webContents.id, record);
await view.webContents.loadFile(path.join(__dirname, "downloads.html"));
}
record.downloadsPanelOpen = true;
try { record.container.removeChildView(record.downloadsView); } catch {}
record.container.addChildView(record.downloadsView);
positionDownloadsView(record);
record.downloadsView.webContents.send("netcatty:host-browser:theme", { theme: record.theme, themeTokens: record.themeTokens });
notifyDownloads(record);
send(record);
};
const resizeView = (record) => {
const [width, height] = record.embedded
? [record.bounds.width, record.bounds.height] : record.win.getContentSize();
for (const tab of record.tabs.values()) {
tab.view.setBounds({
x: 0,
y: chromeHeight(record),
width: Math.max(1, width - (record.panelOpen ? Math.min(width, Math.round(360 * (record.zoom || 1))) : 0)),
height: Math.max(1, height - chromeHeight(record)),
});
}
positionDownloadsView(record);
};
const displayTab = (record) => {
for (const tab of record.tabs.values()) {
try { record.container.removeChildView(tab.view); } catch {}
}
const tab = record.tabs.get(record.activeTabId);
if (record.embedded && !record.visible) return;
const panelWidth = record.embedded ? record.bounds.width / (record.zoom || 1) : record.win.getContentSize()[0];
if (tab && tab.url && record.route === "connected" && !record.overlayOpen && !(record.panelOpen && panelWidth <= 540)) {
record.container.addChildView(tab.view);
resizeView(record);
}
if (record.downloadsPanelOpen && record.downloadsView) {
try { record.container.removeChildView(record.downloadsView); } catch {}
record.container.addChildView(record.downloadsView);
}
send(record);
};
const refreshTab = (record, tab) => {
const contents = tab.view.webContents;
if (contents.isDestroyed()) return;
tab.url = contents.getURL() || tab.url;
tab.title = contents.getTitle() || tab.url || "New tab";
tab.canGoBack = contents.navigationHistory?.canGoBack?.() || false;
tab.canGoForward = contents.navigationHistory?.canGoForward?.() || false;
send(record);
};
const addTab = (record, initialUrl = null) => {
if (record.tabs.size >= MAX_TABS) throw new Error("Too many open tabs");
const view = new WebContentsView({ webPreferences: {
session: record.pageSession,
sandbox: true,
nodeIntegration: false,
nodeIntegrationInSubFrames: false,
nodeIntegrationInWorker: false,
contextIsolation: true,
webSecurity: true,
devTools: false,
navigateOnDragDrop: false,
} });
const tab = { id: randomUUID(), view, url: initialUrl || "", title: "New tab", loading: false, canGoBack: false, canGoForward: false, faviconUrls: [] };
record.tabs.set(tab.id, tab);
const contents = view.webContents;
contents.setWebRTCIPHandlingPolicy("disable_non_proxied_udp");
contents.on("before-input-event", (event, input) => {
const modifier = process.platform === "darwin" ? input.meta : input.control;
if (input.type !== "keyDown" || !modifier) return;
const key = String(input.key || "").toLowerCase();
const type = { t: "new-tab", w: "close-tab", r: "reload", l: "focus-address" }[key];
if (!type) return;
event.preventDefault();
if (!record.win.isDestroyed()) record.chromeContents.send("netcatty:host-browser:shortcut", { type, tabId: tab.id });
});
contents.setWindowOpenHandler(({ url }) => {
try { addTab(record, normalizeBrowserUrl(url)); } catch {}
return { action: "deny" };
});
contents.on("will-attach-webview", (event) => event.preventDefault());
contents.on("will-navigate", (event, url) => {
try { normalizeBrowserUrl(url); } catch { event.preventDefault(); }
if (record.route !== "connected") event.preventDefault();
});
contents.on("did-start-loading", () => { tab.loading = true; refreshTab(record, tab); displayTab(record); });
contents.on("did-stop-loading", () => { tab.loading = false; refreshTab(record, tab); });
contents.on("did-navigate", () => refreshTab(record, tab));
contents.on("did-navigate-in-page", () => refreshTab(record, tab));
contents.on("page-title-updated", () => refreshTab(record, tab));
contents.on("page-favicon-updated", (_event, urls) => {
tab.faviconUrls = urls;
void updateFavicon(record, tab, urls);
});
contents.on("did-finish-load", () => {
if (tab.url && !siteIconStore.get(record.hostId, tab.url)) {
void updateFavicon(record, tab, [`${iconOrigin(tab.url)}/favicon.ico`]);
}
});
contents.on("render-process-gone", () => { tab.loading = false; tab.title = "Page stopped"; send(record); });
record.activeTabId = tab.id;
displayTab(record);
if (initialUrl && record.route === "connected") void contents.loadURL(initialUrl).catch(() => {});
return tab;
};
const stopTunnel = (record) => {
if (!record.tunnelId) return;
const tunnelId = record.tunnelId;
record.tunnelId = null;
void portForwardingBridge.stopPortForward(null, { tunnelId }).catch(() => {});
};
const flushPanelUrls = (record) => {
const layout = panelLayouts.get(record.id);
if (record.route !== "connected" || !layout?.pendingUrls?.length) return;
const urls = layout.pendingUrls.splice(0);
for (const url of urls) {
const tab = record.tabs.get(record.activeTabId);
if (tab && !tab.url) {
tab.url = url;
displayTab(record);
void tab.view.webContents.loadURL(url).catch(() => {});
} else if (record.tabs.size < MAX_TABS) addTab(record, url);
}
};
const closeRecord = (record) => {
if (windows.get(record.id) !== record) return;
windows.delete(record.id);
panelLayouts.delete(record.id);
record.win.off("closed", record.onOwnerClosed);
record.win.off("resize", record.onResize);
record.win.off("blur", record.onBlur);
record.win.off("maximize", record.onMaximize);
record.win.off("unmaximize", record.onMaximize);
record.sourceWebContents.off("destroyed", record.onOwnerClosed);
if (record.embedded) {
try { record.win.contentView.removeChildView(record.container); } catch {}
try { record.chromeContents.close(); } catch {}
}
byChromeId.delete(record.chromeId);
if (record.downloadsView) {
byDownloadsId.delete(record.downloadsView.webContents.id);
try { record.container.removeChildView(record.downloadsView); } catch {}
try { record.downloadsView.webContents.close(); } catch {}
record.downloadsView = null;
}
record.pageSession.off("will-download", record.downloadHandler);
record.routeGeneration += 1;
record.route = "disconnected";
stopTunnel(record);
for (const item of record.downloads.values()) { try { item.cancel(); } catch {} }
record.downloads.clear();
for (const tab of record.tabs.values()) {
try { record.container.removeChildView(tab.view); } catch {}
try { tab.view.webContents.close(); } catch {}
}
record.tabs.clear();
void record.proxyQueue.then(async () => {
await record.pageSession.setProxy({ mode: "fixed_servers", proxyRules: DEAD_PROXY, proxyBypassRules: "<-loopback>" });
await record.pageSession.closeAllConnections();
}).catch(() => {});
};
const setRouteProxy = (record, generation, proxyRules) => {
const operation = record.proxyQueue.catch(() => {}).then(async () => {
if (windows.get(record.id) !== record || record.routeGeneration !== generation) return false;
await record.pageSession.setProxy({ mode: "fixed_servers", proxyRules, proxyBypassRules: "<-loopback>" });
await record.pageSession.closeAllConnections();
return record.routeGeneration === generation;
});
record.proxyQueue = operation;
return operation;
};
const demote = async (record, error = "Connection to the host was lost") => {
if (record.route === "disconnected") return;
const generation = ++record.routeGeneration;
record.route = "disconnected";
record.error = error;
displayTab(record);
stopTunnel(record);
for (const item of record.downloads.values()) { try { item.cancel(); } catch {} }
try {
await setRouteProxy(record, generation, DEAD_PROXY);
} catch {}
for (const tab of record.tabs.values()) tab.view.webContents.stop();
send(record);
};
const configureProxy = async (record, generation, port) => {
if (!Number.isSafeInteger(port) || port < 1 || port > 65535) throw new Error("Browser route is unavailable");
if (!await setRouteProxy(record, generation, `socks5://127.0.0.1:${port}`)) return;
record.route = "connected";
record.error = null;
displayTab(record);
flushPanelUrls(record);
};
const startRoute = async (record) => {
const generation = ++record.routeGeneration;
record.route = "connecting";
displayTab(record);
if (!await setRouteProxy(record, generation, DEAD_PROXY)) return;
stopTunnel(record);
if (record.local) {
await record.pageSession.setProxy({ mode: "direct" });
if (windows.get(record.id) !== record || record.routeGeneration !== generation) return;
record.route = "connected";
record.error = null;
displayTab(record);
flushPanelUrls(record);
return;
}
const tunnelId = `host-browser-${randomUUID()}`;
record.tunnelId = tunnelId;
const sender = record.sourceWebContents && !record.sourceWebContents.isDestroyed()
? record.sourceWebContents : record.chromeContents;
try {
const result = await portForwardingBridge.startPortForward({ sender }, {
...record.connectionOptions,
ruleId: undefined,
tunnelId,
type: "dynamic",
localPort: 0,
bindAddress: "127.0.0.1",
reuseTransport: false,
});
if (windows.get(record.id) !== record || record.routeGeneration !== generation || record.win.isDestroyed()) {
stopTunnel(record);
return;
}
if (!result.success || !result.localPort) throw new Error(result.error || "Could not connect to host");
await configureProxy(record, generation, result.localPort);
} catch (error) {
if (record.routeGeneration === generation) {
await demote(record, error?.message || String(error));
throw error;
}
}
};
const applyPanelLayout = (record) => {
if (!record.embedded || record.win.isDestroyed()) return;
const layout = panelLayouts.get(record.id);
if (layout?.theme) {
record.theme = layout.theme;
record.themeTokens = validThemeTokens(layout.themeTokens);
record.downloadsView?.webContents.send("netcatty:host-browser:theme", { theme: record.theme, themeTokens: record.themeTokens });
}
const visible = layout?.sender === record.sourceWebContents && layout.visible === true;
const attached = record.win.contentView.children.includes(record.container);
if (!visible) {
if (attached) {
record.sourceWebContents.focus();
record.win.contentView.removeChildView(record.container);
}
record.visible = false;
return;
}
record.visible = true;
const zoom = record.sourceWebContents.getZoomFactor();
const [ownerWidth, ownerHeight] = record.win.getContentSize();
const x = Math.max(0, Math.min(ownerWidth, Math.round(layout.bounds.x * zoom)));
const y = Math.max(0, Math.min(ownerHeight, Math.round(layout.bounds.y * zoom)));
const width = Math.max(0, Math.min(ownerWidth, Math.round((layout.bounds.x + layout.bounds.width) * zoom)) - x);
const height = Math.max(0, Math.min(ownerHeight, Math.round((layout.bounds.y + layout.bounds.height) * zoom)) - y);
record.zoom = zoom;
record.bounds = { x, y, width, height };
if (width < 1 || height < 1) {
if (attached) record.win.contentView.removeChildView(record.container);
record.visible = false;
return;
}
record.container.setBounds(record.bounds);
record.chromeView.setBounds({ x: 0, y: 0, width, height });
record.chromeContents.setZoomFactor(zoom);
for (const tab of record.tabs.values()) tab.view.webContents.setZoomFactor(zoom);
if (!attached) record.win.contentView.addChildView(record.container);
resizeView(record);
displayTab(record);
};
const updatePanelLayout = (event, input) => {
const owner = BrowserWindow.fromWebContents(event.sender);
if (!owner || owner.webContents !== event.sender) throw new Error("Untrusted browser panel owner");
const id = input?.browserId;
if (typeof id !== "string" || !id || id.length > 512) throw new Error("Invalid browser identity");
const record = windows.get(id);
const previous = panelLayouts.get(id);
if ((record && record.sourceWebContents !== event.sender) || (previous && previous.sender !== event.sender)) {
throw new Error("Browser belongs to another terminal window");
}
const bounds = input.bounds;
if (input.visible && (!bounds || ![bounds.x, bounds.y, bounds.width, bounds.height].every(Number.isFinite)
|| bounds.width < 0 || bounds.height < 0)) throw new Error("Invalid browser panel bounds");
panelLayouts.set(id, { sender: event.sender, visible: input.visible === true, bounds, pendingUrls: previous?.pendingUrls ?? [],
theme: input.theme === "light" || input.theme === "dark" ? input.theme : undefined, themeTokens: validThemeTokens(input.themeTokens) });
if (record) applyPanelLayout(record);
return true;
};
const openPanelUrl = (event, input) => {
const owner = BrowserWindow.fromWebContents(event.sender);
if (!owner || owner.webContents !== event.sender) throw new Error("Untrusted browser panel owner");
const id = input?.browserId;
if (typeof id !== "string" || !id || id.length > 512) throw new Error("Invalid browser identity");
const url = normalizeBrowserUrl(input.url);
const record = windows.get(id);
const layout = panelLayouts.get(id);
if ((record && record.sourceWebContents !== event.sender) || (layout && layout.sender !== event.sender)) {
throw new Error("Browser belongs to another terminal window");
}
const next = layout ?? { sender: event.sender, visible: false, pendingUrls: [] };
if ((record?.tabs.size ?? 0) + next.pendingUrls.length >= MAX_TABS) throw new Error("Too many open tabs");
next.pendingUrls.push(url);
panelLayouts.set(id, next);
if (record) flushPanelUrls(record);
return true;
};
const closePanel = (event, id) => {
const record = windows.get(id);
const layout = panelLayouts.get(id);
if ((record && record.sourceWebContents !== event.sender) || (layout && layout.sender !== event.sender)) {
throw new Error("Browser belongs to another terminal window");
}
if (record?.embedded) closeRecord(record);
panelLayouts.delete(id);
return true;
};
const open = async (event, input) => {
if (!BrowserWindow.fromWebContents(event.sender)) throw new Error("Browser opener is unavailable");
const hostId = typeof input?.hostId === "string" ? input.hostId : "";
const label = typeof input?.label === "string" ? input.label.slice(0, 120) : "";
const local = input?.local === true;
if (!hostId || hostId.length > 256 || !label || (!local && (input?.connectionOptions?.hostId !== hostId
|| input.connectionOptions?.type !== "dynamic"))) throw new Error("Invalid host browser request");
const embedded = input?.embedded === true;
const id = embedded ? input.browserId : hostId;
if (typeof id !== "string" || !id || id.length > 512) throw new Error("Invalid browser identity");
const owner = BrowserWindow.fromWebContents(event.sender);
if (embedded && owner.webContents !== event.sender) throw new Error("Untrusted browser panel owner");
const existing = windows.get(id);
if (existing && (existing.sourceWebContents !== event.sender || existing.hostId !== hostId)) {
throw new Error("Browser belongs to another terminal window");
}
if (existing) {
existing.theme = input?.theme === "light" ? "light" : "dark";
existing.themeTokens = validThemeTokens(input?.themeTokens);
if (existing.downloadsView && !existing.downloadsView.webContents.isDestroyed()) {
existing.downloadsView.webContents.send("netcatty:host-browser:theme", {
theme: existing.theme, themeTokens: existing.themeTokens,
});
}
send(existing);
if (existing.route !== "connected") {
existing.connectionOptions = input.connectionOptions;
existing.sourceWebContents = event.sender;
await startRoute(existing);
}
if (!embedded) { existing.win.show(); existing.win.focus(); }
return { reused: true };
}
const partition = `host-browser-${createHash("sha256").update(id).digest("hex")}${embedded ? `-${randomUUID()}` : ""}`;
const pageSession = session.fromPartition(partition, { cache: false });
await pageSession.setProxy({ mode: "fixed_servers", proxyRules: DEAD_PROXY, proxyBypassRules: "<-loopback>" });
pageSession.setPermissionCheckHandler?.(() => false);
pageSession.setPermissionRequestHandler?.((_contents, _permission, callback) => callback(false));
pageSession.setDevicePermissionHandler?.(() => false);
if (embedded && !panelLayouts.has(id)) return { reused: false };
const record = { id, hostId, embedded, local, visible: false, bounds: { x: 0, y: 0, width: 1, height: 1 },
label, theme: input?.theme === "light" ? "light" : "dark",
themeTokens: validThemeTokens(input?.themeTokens),
pageSession, route: "connecting", error: null, panelOpen: false, overlayOpen: false,
tunnelId: null, connectionOptions: input.connectionOptions, sourceWebContents: event.sender,
routeGeneration: 0, proxyQueue: Promise.resolve(), tabs: new Map(), downloads: new Map(),
downloadsView: null, downloadsPanelOpen: false, activeTabId: null, win: null };
record.downloadHandler = (event, item, contents) => {
const ownedPage = [...record.tabs.values()].some((tab) => tab.view.webContents === contents);
if (record.route !== "connected" || !ownedPage) { event.preventDefault(); return; }
const id = randomUUID();
const filename = path.basename(String(item.getFilename() || "download").replace(/\\/g, "/")) || "download";
item.setSaveDialogOptions({ defaultPath: path.join(app.getPath("downloads"), filename) });
record.downloads.set(id, item);
downloadStore.upsert({ id, hostId, filename, url: item.getURL(), savePath: "",
status: "waiting", received: 0, total: item.getTotalBytes(), startedAt: Date.now() });
notifyDownloads(record);
let lastProgressAt = 0;
item.on("updated", (_updateEvent, status) => {
const now = Date.now();
if (status === "progressing" && now - lastProgressAt < 200) return;
lastProgressAt = now;
const previous = downloadStore.get(hostId, id);
if (!previous) return;
const savePath = item.getSavePath() || previous.savePath;
downloadStore.upsert({ ...previous, filename: savePath ? path.basename(savePath) : filename,
savePath, status, received: item.getReceivedBytes(), total: item.getTotalBytes() });
notifyDownloads(record);
});
item.once("done", (_doneEvent, status) => {
record.downloads.delete(id);
const previous = downloadStore.get(hostId, id);
if (!previous) return;
const savePath = item.getSavePath() || previous.savePath;
downloadStore.upsert({ ...previous, filename: savePath ? path.basename(savePath) : filename,
savePath, status, received: item.getReceivedBytes(), total: item.getTotalBytes(), endedAt: Date.now() });
notifyDownloads(record);
});
};
pageSession.on("will-download", record.downloadHandler);
pageSession.webRequest.onBeforeRequest((details, callback) => {
let allowed = false;
try { allowed = record.route === "connected" && ALLOWED_PAGE_SCHEMES.has(new URL(details.url).protocol); } catch {}
callback({ cancel: !allowed });
});
const chromeOptions = { webPreferences: {
preload: path.join(__dirname, "browserPreload.cjs"),
sandbox: true, contextIsolation: true, nodeIntegration: false,
} };
const win = embedded ? owner : new BrowserWindow({
title: "Browser",
width: 1100, height: 760, minWidth: 720, minHeight: 480,
show: false, backgroundColor: input?.theme === "light" ? "#f1f4f8" : "#0f121a",
frame: process.platform === "darwin",
titleBarStyle: process.platform === "darwin" ? "hiddenInset" : undefined,
trafficLightPosition: process.platform === "darwin" ? { x: 12, y: 12 } : undefined,
...windowsFramelessContentChromeOptions(),
webPreferences: {
preload: path.join(__dirname, "browserPreload.cjs"),
sandbox: true, contextIsolation: true, nodeIntegration: false,
},
});
record.win = win;
record.container = embedded ? new electron.View() : win.contentView;
record.chromeView = embedded ? new WebContentsView(chromeOptions) : null;
record.chromeContents = record.chromeView?.webContents || win.webContents;
if (embedded) record.container.addChildView(record.chromeView);
record.chromeId = record.chromeContents.id;
windows.set(id, record);
byChromeId.set(record.chromeId, record);
record.onResize = () => resizeView(record);
record.onBlur = () => closeDownloadsPanel(record);
record.onMaximize = () => send(record);
if (!embedded) win.on("resize", record.onResize);
win.on("blur", record.onBlur);
win.on("maximize", record.onMaximize);
win.on("unmaximize", record.onMaximize);
record.onOwnerClosed = () => closeRecord(record);
win.on("closed", record.onOwnerClosed);
event.sender.once("destroyed", record.onOwnerClosed);
record.chromeContents.setWindowOpenHandler(() => ({ action: "deny" }));
record.chromeContents.on("will-navigate", (navigationEvent) => navigationEvent.preventDefault());
try {
await record.chromeContents.loadFile(path.join(__dirname, "browser.html"));
if (win.isDestroyed() || windows.get(id) !== record) throw new Error("Browser was closed before it opened");
addTab(record);
if (embedded) applyPanelLayout(record);
else { record.visible = true; win.show(); }
try { await startRoute(record); } catch { /* Keep the browser open for an in-window retry. */ }
return { reused: false };
} catch (error) {
closeRecord(record);
if (!embedded && !win.isDestroyed()) win.destroy();
throw error;
}
};
const focus = (_event, hostId) => {
const record = windows.get(hostId);
if (!record || record.win.isDestroyed()) return false;
record.win.show();
record.win.focus();
return record.route === "connected";
};
const getOwned = (event) => {
const record = byChromeId.get(event.sender.id);
if (!record || record.chromeContents !== event.sender) throw new Error("Untrusted browser control");
return record;
};
const getBrowserOwned = (event) => {
const chrome = byChromeId.get(event.sender.id);
if (chrome && chrome.chromeContents === event.sender) return chrome;
const downloads = byDownloadsId.get(event.sender.id);
if (downloads && downloads.downloadsView?.webContents === event.sender) return downloads;
throw new Error("Untrusted browser download control");
};
ipcMain.handle("netcatty:host-browser:panel-layout", updatePanelLayout);
ipcMain.handle("netcatty:host-browser:panel-close", closePanel);
ipcMain.handle("netcatty:host-browser:panel-url", openPanelUrl);
ipcMain.handle("netcatty:host-browser:open", open);
ipcMain.handle("netcatty:host-browser:focus", focus);
ipcMain.handle("netcatty:host-browser:snapshot", (event) => snapshot(getOwned(event)));
ipcMain.handle("netcatty:host-browser:downloads:list", (event) => {
const record = getBrowserOwned(event);
return downloadStore.list(record.hostId);
});
ipcMain.handle("netcatty:host-browser:downloads:theme", (event) => {
const record = getBrowserOwned(event);
return { theme: record.theme, themeTokens: record.themeTokens };
});
ipcMain.handle("netcatty:host-browser:downloads:command", async (event, input) => {
const record = getBrowserOwned(event);
const item = downloadStore.get(record.hostId, input?.id);
switch (input?.type) {
case "close": closeDownloadsPanel(record); return true;
case "open-folder": {
const error = await (shell || require("electron").shell).openPath(app.getPath("downloads"));
if (error) throw new Error(error);
return true;
}
case "clear": downloadStore.clearFinished(record.hostId); notifyDownloads(record); return true;
case "cancel": {
const owner = item && [...windows.values()].find((candidate) => candidate.hostId === record.hostId && candidate.downloads.has(item.id));
if (!owner) throw new Error("Download is no longer active");
owner.downloads.get(item.id).cancel();
return true;
}
case "remove":
if (!item || !downloadStore.remove(record.hostId, item.id)) return false;
notifyDownloads(record);
return true;
case "open":
case "reveal": {
if (!item || item.status !== "completed" || !item.savePath || !fs.existsSync(item.savePath)) {
throw new Error("File moved or deleted");
}
if (input.type === "reveal") (shell || require("electron").shell).showItemInFolder(item.savePath);
else {
const error = await (shell || require("electron").shell).openPath(item.savePath);
if (error) throw new Error(error);
}
return true;
}
default: throw new Error("Unknown download action");
}
});
ipcMain.handle("netcatty:host-browser:bookmarks:list", (event) => {
const record = getOwned(event);
return bookmarkStore.list().filter((item) => item.scope === "all-hosts" || item.hostId === record.hostId)
.map((item) => {
const siteIcon = siteIconStore.get(record.hostId, item.url);
return { ...item, siteIconDataUrl: siteIcon?.dataUrl || null,
siteIconGlyph: siteIcon?.glyph || null, siteIconColor: siteIcon?.color || null };
});
});
ipcMain.handle("netcatty:host-browser:bookmarks:upsert", (event, input) => {
const record = getOwned(event);
if (input?.id) {
const previous = bookmarkStore.list().find((item) => item.id === input.id);
if (previous && previous.scope === "host" && previous.hostId !== record.hostId) throw new Error("Bookmark belongs to another host");
}
return bookmarkStore.upsert({ ...input, hostId: input?.scope === "all-hosts" ? null : record.hostId });
});
ipcMain.handle("netcatty:host-browser:bookmarks:remove", (event, id) => {
const record = getOwned(event);
const previous = bookmarkStore.list().find((item) => item.id === id);
if (!previous || (previous.scope === "host" && previous.hostId !== record.hostId)) return false;
bookmarkStore.remove(id);
return true;
});
ipcMain.handle("netcatty:host-browser:bookmarks:get-all", (event) => {
if (!BrowserWindow.fromWebContents(event.sender) || byChromeId.has(event.sender.id)) throw new Error("Untrusted bookmark reader");
return bookmarkStore.list();
});
ipcMain.handle("netcatty:host-browser:bookmarks:apply", (event, items) => {
if (!BrowserWindow.fromWebContents(event.sender) || byChromeId.has(event.sender.id)) throw new Error("Untrusted bookmark writer");
bookmarkStore.apply(items);
return true;
});
ipcMain.handle("netcatty:host-browser:command", async (event, command) => {
const record = getOwned(event);
if (command?.type?.startsWith("window-") && record.embedded) return snapshot(record);
if (command?.type === "window-close") { record.win.close(); return null; }
const tab = record.tabs.get(command?.tabId || record.activeTabId);
switch (command?.type) {
case "set-overlay":
record.overlayOpen = command.open === true;
if (record.overlayOpen) closeDownloadsPanel(record);
displayTab(record);
break;
case "window-minimize": record.win.minimize(); break;
case "window-maximize":
if (record.win.isMaximized()) record.win.unmaximize();
else record.win.maximize();
break;
case "set-icon":
if (!tab?.url) throw new Error("Open a page before changing its icon");
siteIconStore.set(record.hostId, tab.url, {
mode: "custom", dataUrl: command.dataUrl, glyph: command.glyph, color: command.color,
});
notifySiteIcon(record);
break;
case "reset-icon":
if (!tab?.url) throw new Error("Open a page before changing its icon");
siteIconStore.reset(record.hostId, tab.url);
notifySiteIcon(record);
void updateFavicon(record, tab, tab.faviconUrls.length ? tab.faviconUrls : [`${iconOrigin(tab.url)}/favicon.ico`]);
break;
case "set-panel": record.panelOpen = command.open === true; resizeView(record); displayTab(record); break;
case "set-download-panel": await setDownloadsPanel(record, command.open === true); break;
case "retry": if (record.route === "disconnected") await startRoute(record); break;
case "new-tab": addTab(record, command.url ? normalizeBrowserUrl(command.url) : null); break;
case "reorder": {
const ids = [...record.tabs.keys()];
const from = ids.indexOf(command.tabId);
const to = ids.indexOf(command.beforeTabId);
if (from >= 0 && to >= 0 && from !== to) {
ids.splice(from, 1);
ids.splice(ids.indexOf(command.beforeTabId), 0, command.tabId);
record.tabs = new Map(ids.map((id) => [id, record.tabs.get(id)]));
send(record);
}
break;
}
case "activate": if (tab) { record.activeTabId = tab.id; displayTab(record); } break;
case "close-tab":
if (tab) {
const tabs = [...record.tabs.values()];
const next = tabs[tabs.indexOf(tab) + 1] || tabs[tabs.indexOf(tab) - 1];
record.container.removeChildView(tab.view);
record.tabs.delete(tab.id);
tab.view.webContents.close();
if (record.activeTabId === tab.id) record.activeTabId = next?.id || null;
if (!record.tabs.size) addTab(record);
else displayTab(record);
}
break;
case "navigate":
if (record.route !== "connected") throw new Error("Reconnect to the host before opening a page");
if (tab) {
tab.url = normalizeBrowserUrl(command.url);
displayTab(record);
await tab.view.webContents.loadURL(tab.url);
}
break;
case "back": if (tab && record.route === "connected") tab.view.webContents.navigationHistory?.goBack(); break;
case "forward": if (tab && record.route === "connected") tab.view.webContents.navigationHistory?.goForward(); break;
case "reload": if (tab && record.route === "connected") tab.view.webContents.reload(); break;
case "stop": tab?.view.webContents.stop(); break;
default: throw new Error("Unknown browser command");
}
return snapshot(record);
});
const unsubscribeTunnelStatus = portForwardingBridge.onTunnelStatus((event) => {
for (const record of windows.values()) {
if (record.tunnelId === event.tunnelId && ["inactive", "error"].includes(event.status)) {
void demote(record, event.error || undefined);
}
}
});
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, closeAll: () => {
unsubscribeTunnelStatus();
for (const record of [...windows.values()]) { closeRecord(record); if (!record.embedded && !record.win.isDestroyed()) record.win.destroy(); }
} };
}
module.exports = { createHostBrowserManager, normalizeBrowserUrl };
@@ -0,0 +1,42 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { readFileSync } = require("node:fs");
const vm = require("node:vm");
const { normalizeBrowserUrl } = require("./hostBrowserManager.cjs");
test("browser addresses accept bare localhost and domain ports", () => {
assert.equal(normalizeBrowserUrl("localhost:3000"), "http://localhost:3000/");
assert.equal(normalizeBrowserUrl("localhost:3000/app"), "http://localhost:3000/app");
assert.equal(normalizeBrowserUrl("127.0.0.1:8080"), "http://127.0.0.1:8080/");
assert.equal(normalizeBrowserUrl("example.com:8080"), "https://example.com:8080/");
assert.equal(normalizeBrowserUrl("https://example.com/app"), "https://example.com/app");
});
test("browser addresses keep rejecting external protocols and credentials", () => {
for (const url of ["file:///etc/passwd", "javascript:alert(1)", "mailto:test@example.com", "https://user:password@example.com/"]) {
assert.throws(() => normalizeBrowserUrl(url));
}
});
test("late download notifications ignore closed browser chrome in a live terminal", () => {
const source = readFileSync(require.resolve("./hostBrowserManager.cjs"), "utf8");
const start = source.indexOf("const notifyDownloads =");
const end = source.indexOf("const snapshot =", start);
assert.ok(start > 0 && end > start);
const record = {
hostId: "host",
win: { isDestroyed: () => false },
chromeContents: {
isDestroyed: () => true,
send: () => { throw new Error("Object has been destroyed"); },
},
downloadsPanelOpen: false,
};
const notify = vm.runInNewContext(`${source.slice(start, end)};notifyDownloads;`, {
downloadStore: { list: () => [] },
windows: new Map([["closed", record]]),
});
assert.doesNotThrow(() => notify(record));
});
+80
View File
@@ -0,0 +1,80 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
const MAX_ICONS = 1000;
const MAX_ICON_BYTES = 64 * 1024;
const IMAGE_MIMES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif", "image/x-icon", "image/vnd.microsoft.icon"]);
function iconOrigin(url) {
try {
const parsed = new URL(url);
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
} catch { return null; }
}
function validImageDataUrl(value) {
if (typeof value !== "string" || value.length > MAX_ICON_BYTES * 1.4) return false;
const match = /^data:([^;,]+);base64,([A-Za-z0-9+/]+={0,2})$/.exec(value);
if (!match || !IMAGE_MIMES.has(match[1])) return false;
return Buffer.byteLength(match[2], "base64") <= MAX_ICON_BYTES;
}
function normalizeIcon(raw) {
if (!raw || typeof raw !== "object") throw new Error("Invalid website icon");
const hostId = typeof raw.hostId === "string" ? raw.hostId.slice(0, 256) : "";
const origin = iconOrigin(raw.origin);
if (!hostId || !origin) throw new Error("Invalid website icon target");
const mode = raw.mode === "custom" ? "custom" : "auto";
const dataUrl = validImageDataUrl(raw.dataUrl) ? raw.dataUrl : null;
const glyph = typeof raw.glyph === "string" ? raw.glyph.trim().slice(0, 4) : "";
const color = /^#[0-9a-f]{6}$/i.test(raw.color) ? raw.color : "#247969";
if (!dataUrl && !glyph) throw new Error("Website icon is empty");
return { hostId, origin, mode, dataUrl, glyph, color };
}
function createSiteIconStore(app) {
const file = path.join(app.getPath("userData"), "host-browser-site-icons.json");
const icons = new Map();
try {
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
if (Array.isArray(parsed)) {
for (const raw of parsed.slice(0, MAX_ICONS)) {
try { const icon = normalizeIcon(raw); icons.set(`${icon.hostId}\n${icon.origin}`, icon); } catch {}
}
}
} catch (error) {
if (error?.code !== "ENOENT") console.warn("[HostBrowser] Could not read website icons:", error?.message);
}
const persist = () => {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temporary = `${file}.${process.pid}.tmp`;
fs.writeFileSync(temporary, JSON.stringify([...icons.values()]), { mode: 0o600 });
fs.renameSync(temporary, file);
};
const keyFor = (hostId, url) => {
const origin = iconOrigin(url);
return origin && typeof hostId === "string" ? `${hostId}\n${origin}` : null;
};
return {
get(hostId, url) { const key = keyFor(hostId, url); return key ? icons.get(key) || null : null; },
set(hostId, url, input) {
const key = keyFor(hostId, url);
if (!key) throw new Error("Invalid website address");
const previous = icons.get(key);
if (input.mode === "auto" && previous?.mode === "custom") return previous;
if (!previous && icons.size >= MAX_ICONS) throw new Error("Website icon limit reached");
const next = normalizeIcon({ ...input, hostId, origin: iconOrigin(url) });
icons.set(key, next);
persist();
return next;
},
reset(hostId, url) {
const key = keyFor(hostId, url);
if (key && icons.delete(key)) persist();
},
};
}
module.exports = { createSiteIconStore, iconOrigin, validImageDataUrl };
@@ -0,0 +1,35 @@
"use strict";
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const test = require("node:test");
const { createSiteIconStore, validImageDataUrl } = require("./siteIconStore.cjs");
const favicon = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIHWP4z8DwHwAFgAI/ScLytQAAAABJRU5ErkJggg==";
test("website icons persist per host and user overrides survive automatic updates", (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-site-icons-"));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const app = { getPath: () => dir };
const first = createSiteIconStore(app);
first.set("host-a", "https://example.com/page", { mode: "auto", dataUrl: favicon });
assert.equal(first.get("host-a", "https://example.com/other").dataUrl, favicon);
assert.equal(first.get("host-b", "https://example.com/page"), null);
first.set("host-a", "https://example.com/page", { mode: "custom", glyph: "\u2726", color: "#123456" });
first.set("host-a", "https://example.com/page", { mode: "auto", dataUrl: favicon });
const reopened = createSiteIconStore(app);
assert.deepEqual(reopened.get("host-a", "https://example.com/page"), {
hostId: "host-a", origin: "https://example.com", mode: "custom",
dataUrl: null, glyph: "\u2726", color: "#123456",
});
reopened.reset("host-a", "https://example.com/page");
assert.equal(createSiteIconStore(app).get("host-a", "https://example.com/page"), null);
});
test("website icon images accept only bounded raster data", () => {
assert.equal(validImageDataUrl(favicon), true);
assert.equal(validImageDataUrl("data:image/svg+xml;base64,PHN2Zz4="), false);
assert.equal(validImageDataUrl(`data:image/png;base64,${Buffer.alloc(65 * 1024).toString("base64")}`), false);
});
+19
View File
@@ -466,6 +466,25 @@ function createBridgeRegistrar(context) {
});
transferBridge.registerHandlers(ipcMain, { terminalWorkerManager });
portForwardingBridge.registerHandlers(ipcMain, { terminalWorkerManager });
if (electronModule.WebContentsView && electronModule.session?.fromPartition) {
const { createHostBrowserManager } = require("../hostBrowser/hostBrowserManager.cjs");
const hostBrowserManager = createHostBrowserManager({
electron: electronModule,
app,
portForwardingBridge,
});
ipcMain.handle("netcatty:host-browser:request-panel", (event, request) => {
const senderWindow = BrowserWindow.fromWebContents(event.sender);
const mainWindow = getWindowManager().getMainWindow?.();
if (!senderWindow || senderWindow.webContents !== event.sender || !mainWindow || mainWindow.isDestroyed()
|| typeof request?.host?.id !== "string") throw new Error("Browser opener is unavailable");
mainWindow.webContents.send("netcatty:host-browser:request-panel", request);
mainWindow.show();
mainWindow.focus();
return true;
});
app.once?.("will-quit", () => hostBrowserManager.closeAll());
}
terminalBridge.registerHandlers(ipcMain, { terminalWorkerManager });
const scriptBridge = require("../bridges/scriptBridge.cjs");
+28
View File
@@ -13,6 +13,16 @@ function createPreloadApi(ctx) {
const globalSftpTransferListeners = ctx.globalSftpTransferListeners || new Set();
const pluginContributionsChangedListeners = ctx.pluginContributionsChangedListeners || new Set();
const uploadStageControllers = new Map();
const hostBrowserPanelListeners = new Set();
const pendingHostBrowserPanels = [];
// TerminalLayer mounts lazily; retain incoming requests until it subscribes.
ctx.ipcRenderer?.on?.("netcatty:host-browser:request-panel", (_event, request) => {
if (!hostBrowserPanelListeners.size) {
pendingHostBrowserPanels.push(request);
return;
}
for (const listener of hostBrowserPanelListeners) listener(request);
});
// Lightweight test contexts may omit this map; default so closeSession never throws.
if (!ctx.moshSessionReadyListeners) {
ctx.moshSessionReadyListeners = new Map();
@@ -1304,6 +1314,24 @@ function createPreloadApi(ctx) {
// Open URL in default browser
openExternal: (url) => ipcRenderer.invoke("netcatty:openExternal", url),
focusHostBrowser: (hostId) => ipcRenderer.invoke("netcatty:host-browser:focus", hostId),
setHostBrowserPanelLayout: (options) => ipcRenderer.invoke("netcatty:host-browser:panel-layout", options),
closeHostBrowserPanel: (browserId) => ipcRenderer.invoke("netcatty:host-browser:panel-close", browserId),
openHostBrowserUrl: (options) => ipcRenderer.invoke("netcatty:host-browser:panel-url", options),
requestHostBrowserPanel: (request) => ipcRenderer.invoke("netcatty:host-browser:request-panel", request),
onHostBrowserPanelRequest: (callback) => {
hostBrowserPanelListeners.add(callback);
for (const request of pendingHostBrowserPanels.splice(0)) callback(request);
return () => hostBrowserPanelListeners.delete(callback);
},
openHostBrowser: (options) => ipcRenderer.invoke("netcatty:host-browser:open", options),
getHostBrowserBookmarks: () => ipcRenderer.invoke("netcatty:host-browser:bookmarks:get-all"),
applyHostBrowserBookmarks: (items) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:apply", items),
onHostBrowserBookmarksChanged: (callback) => {
const listener = () => callback();
ipcRenderer.on("netcatty:host-browser:bookmarks-changed", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:bookmarks-changed", listener);
},
openPath: (path) => ipcRenderer.invoke("netcatty:openPath", path),
// App info
+24
View File
@@ -0,0 +1,24 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { EventEmitter } = require('node:events');
const { createPreloadApi } = require('./api.cjs');
test('browser requests survive before the lazy terminal receiver subscribes', () => {
const ipcRenderer = new EventEmitter();
const api = createPreloadApi({ ipcRenderer, webUtils: {} });
const first = { host: { id: 'first' } };
const second = { host: { id: 'second' }, url: 'http://localhost:3000/' };
const delivered = [];
ipcRenderer.emit('netcatty:host-browser:request-panel', {}, first);
ipcRenderer.emit('netcatty:host-browser:request-panel', {}, second);
const off = api.onHostBrowserPanelRequest((request) => delivered.push(request));
assert.deepEqual(delivered, [first, second]);
off();
const third = { host: { id: 'third' } };
ipcRenderer.emit('netcatty:host-browser:request-panel', {}, third);
const offAgain = api.onHostBrowserPanelRequest((request) => delivered.push(request));
assert.deepEqual(delivered, [first, second, third], 'queued requests must be consumed once');
ipcRenderer.emit('netcatty:host-browser:request-panel', {}, first);
assert.deepEqual(delivered, [first, second, third, first]);
offAgain();
});
Vendored
+2
View File
@@ -244,11 +244,13 @@ declare global {
keepaliveCountMax?: number;
sshTcpConnectTimeoutMs?: number;
sshAuthReadyTimeoutMs?: number;
reuseTransport?: boolean;
}
interface PortForwardResult {
tunnelId: string;
success: boolean;
localPort?: number;
cancelled?: boolean;
blockedByCleanup?: boolean;
reused?: boolean;
@@ -883,7 +883,8 @@ export const startPortForward = async (
enableReconnect = false,
terminalSettings?: Pick<TerminalSettings, 'verifyHostKeys' | 'keepaliveInterval' | 'keepaliveCountMax'>,
knownHosts?: KnownHost[],
): Promise<{ success: boolean; error?: string }> => {
options?: { dedicatedTransport?: boolean; prepareOnly?: boolean },
): Promise<{ success: boolean; error?: string; localPort?: number; tunnelId?: string; connectionOptions?: PortForwardOptions }> => {
const globalTerminalSettings = { ...FALLBACK_TERMINAL_SETTINGS, ...(terminalSettings ?? {}) };
const bridge = netcattyBridge.get();
if (rulesPendingCleanup.has(rule.id)) {
@@ -1117,7 +1118,7 @@ export const startPortForward = async (
// Start the tunnel
const connectionTimeouts = resolveHostSshConnectionTimeouts(host);
const result = await bridge.startPortForward({
const connectionOptions: PortForwardOptions = {
ruleId: rule.id,
tunnelId,
type: rule.type,
@@ -1149,7 +1150,15 @@ export const startPortForward = async (
keepaliveCountMax: resolveHostKeepalive(host, globalTerminalSettings).countMax,
sshTcpConnectTimeoutMs: connectionTimeouts.tcpConnectTimeoutSeconds * 1000,
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
});
reuseTransport: !options?.dedicatedTransport,
};
if (options?.prepareOnly) {
activeConnections.delete(rule.id);
unsubscribe?.();
onStatusChange('inactive');
return { success: true, connectionOptions };
}
const result = await bridge.startPortForward(connectionOptions);
if (!result.success) {
// Intentional cancellation (rule deleted/replaced during handshake).
@@ -1236,7 +1245,7 @@ export const startPortForward = async (
}
exhaustedReconnectRules.delete(rule.id);
return { success: true };
return { success: true, localPort: result.localPort, tunnelId: result.tunnelId };
} catch (err) {
const error = err instanceof Error ? err.message : 'Unknown error';
+1 -1
View File
@@ -54,7 +54,7 @@
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"pretest": "npm run build:plugin-runtime-deps && npm run check:codebuddy-sdk-contract",
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test": "node --test --import tsx electron/*.test.cjs electron/preload/*.test.cjs electron/mcp/*.test.cjs electron/scripts/*.test.cjs electron/terminalWorker/*.test.cjs electron/capabilities/*.test.cjs electron/capabilities/*/*.test.cjs electron/capabilities/*/*/*.test.cjs electron/cli/*.test.cjs electron/hostBrowser/*Store.test.cjs electron/hostBrowser/hostBrowserManager.test.cjs electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs electron/bridges/aiBridge/codexAppServer/*.test.cjs electron/bridges/aiBridge/sdk/*.test.cjs scripts/*.test.cjs application/*.test.ts application/app/*.test.ts application/i18n/locales/*.test.ts application/state/*.test.ts application/state/*/*.test.ts components/*.test.ts components/*.test.tsx components/editor/*.test.ts components/editor/*.test.tsx components/notes/*.test.ts components/notes/*.test.tsx components/port-forwarding/*.test.ts components/scripts/*.test.tsx components/systemManager/*.test.ts components/terminalLayer/*.test.ts components/settings/*.test.tsx components/settings/tabs/*.test.ts components/settings/tabs/ai/*.test.ts components/settings/tabs/ai/*.test.tsx components/ai/*.test.ts components/ai/*.test.tsx components/ai/toolArtifacts/*.test.ts components/ai/toolArtifacts/*.test.tsx components/ai-elements/*.test.tsx components/sftp/*.test.ts components/terminal/*.test.ts components/terminal/*.test.tsx components/terminal/runtime/*.test.ts components/vault/*.test.ts components/vault/*.test.tsx domain/*.test.ts domain/*/*.test.ts infrastructure/ai/*.test.ts infrastructure/ai/*/*.test.ts infrastructure/ai/sdk/*.test.ts infrastructure/config/*.test.ts infrastructure/monaco/*.test.ts infrastructure/persistence/*.test.ts infrastructure/services/*.test.ts infrastructure/services/*/*.test.ts lib/*.test.ts packages/*/src/*.test.ts",
"test:plugin-contract": "node --test --import tsx packages/*/src/*.test.ts",
"pretest:plugin-runtime": "npm run build:plugin-runtime-deps",
"test:plugin-runtime": "node --test electron/plugins/*.test.cjs",
+10
View File
@@ -102,6 +102,16 @@ declare global {
// Settings window
openSettingsWindow?(): Promise<boolean>;
setHostBrowserPanelLayout?(options: { browserId: string; visible: boolean; theme?: 'dark' | 'light'; themeTokens?: Record<string, string>; bounds?: { x: number; y: number; width: number; height: number } }): Promise<boolean>;
closeHostBrowserPanel?(browserId: string): Promise<boolean>;
openHostBrowserUrl?(options: { browserId: string; url: string }): Promise<boolean>;
requestHostBrowserPanel?(request: { host: import('../../domain/models').Host; sessionId?: string; url?: string }): Promise<boolean>;
onHostBrowserPanelRequest?(callback: (request: { host: import('../../domain/models').Host; sessionId?: string; url?: string }) => void): () => void;
focusHostBrowser?(hostId: string): Promise<boolean>;
openHostBrowser?(options: { browserId?: string; embedded?: boolean; local?: boolean; hostId: string; label: string; theme: 'dark' | 'light'; themeTokens?: Record<string, string>; connectionOptions?: PortForwardOptions }): Promise<{ reused: boolean }>;
getHostBrowserBookmarks?(): Promise<import('../../domain/browserBookmark').BrowserBookmark[]>;
applyHostBrowserBookmarks?(items: import('../../domain/browserBookmark').BrowserBookmark[]): Promise<boolean>;
onHostBrowserBookmarksChanged?(callback: () => void): () => void;
closeSettingsWindow?(): Promise<void>;
// Cross-window settings sync