Compare commits

...
30 changed files with 1965 additions and 199 deletions

No files matched your search

+65
View File
@@ -942,4 +942,69 @@ export const enTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Save and close',
'sftp.editor.quitBlockedByDirty': 'Unsaved editors — please save or discard before quitting',
'terminal.browser.newTab': 'New tab',
'terminal.browser.address': 'Web address',
'terminal.browser.back': 'Back',
'terminal.browser.forward': 'Forward',
'terminal.browser.reload': 'Reload',
'terminal.browser.stop': 'Stop',
'terminal.browser.bookmarks': 'Apps & bookmarks',
'terminal.browser.downloads': 'Downloads',
'terminal.browser.empty': 'Save an app or enter an address to get started.',
'terminal.browser.connecting': 'Connecting…',
'terminal.browser.disconnected': 'Connection unavailable',
'terminal.browser.retry': 'Reconnect',
'terminal.browser.addBookmark': 'Add app',
'terminal.browser.editBookmark': 'Edit app',
'terminal.browser.bookmarkTitle': 'Title',
'terminal.browser.icon': 'Icon',
'terminal.browser.website': 'Website',
'terminal.browser.text': 'Text',
'terminal.browser.image': 'Image',
'terminal.browser.chooseImage': 'Choose image',
'terminal.browser.color': 'Color',
'terminal.browser.scope': 'Show on',
'terminal.browser.hostScope': 'This host',
'terminal.browser.allScope': 'All hosts',
'terminal.browser.changeIcon': 'Change icon',
'terminal.browser.resetIcon': 'Use website icon',
'terminal.browser.downloadEmpty': 'No downloads yet',
'terminal.browser.openFolder': 'Open downloads folder',
'terminal.browser.clearDownloads': 'Clear finished downloads',
'terminal.browser.cancelDownload': 'Cancel download',
'terminal.browser.reveal': 'Show in folder',
'terminal.browser.openFile': 'Open file',
'terminal.browser.removeDownload': 'Remove from list',
'terminal.browser.waiting': 'Preparing download…',
'terminal.browser.progressing': 'Downloading',
'terminal.browser.completed': 'Completed',
'terminal.browser.cancelled': 'Cancelled',
'terminal.browser.interrupted': 'Interrupted',
'terminal.browser.missing': 'File moved or deleted',
'terminal.browser.closeTab': 'Close tab',
'terminal.browser.more': 'More actions',
'terminal.browser.websiteIconHint': 'The website icon appears automatically after you visit it.',
'terminal.browser.iconText': 'Icon text',
'terminal.browser.iconTextHint': 'Use a letter, short name or emoji.',
'terminal.browser.selectColor': 'Use {color}',
'terminal.browser.customColor': 'Custom color',
'terminal.browser.iconPreview': 'Icon preview',
'terminal.browser.savedImage': 'Saved image',
'terminal.browser.processingImage': 'Preparing image…',
'terminal.browser.replaceImage': 'Replace image',
'terminal.browser.removeImage': 'Remove image',
'terminal.browser.uploadImage': 'Choose an image',
'terminal.browser.dropImage': 'or drag and drop it here',
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF or ICO · up to 5 MB. Resized automatically.',
'terminal.browser.iconError.file-type': 'Choose a PNG, JPG, WebP, GIF or ICO image.',
'terminal.browser.iconError.file-size': 'This image is too large. Choose a file under 5 MB.',
'terminal.browser.iconError.decode': 'This image could not be read. Try another file.',
'terminal.browser.bookmarkPreviewHint': 'Shown on your browser start page',
'terminal.browser.untitledBookmark': 'Your app',
'terminal.browser.bookmarkTitlePlaceholder': 'e.g. Team docs',
'terminal.browser.websiteAddress': 'Website address',
'terminal.browser.hostScopeHint': 'Show only in this host’s browser.',
'terminal.browser.allScopeHint': 'Show in every host’s browser.',
'terminal.browser.downloadLocation': 'Saved to your Downloads folder',
'terminal.browser.saveAs': 'Save as…',
};
+65
View File
@@ -904,4 +904,69 @@ export const esTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Guardar y cerrar',
'sftp.editor.quitBlockedByDirty': 'Editores sin guardar: guarda o descarta antes de salir',
'terminal.browser.newTab': 'Nueva pestaña',
'terminal.browser.address': 'Dirección web',
'terminal.browser.back': 'Atrás',
'terminal.browser.forward': 'Adelante',
'terminal.browser.reload': 'Recargar',
'terminal.browser.stop': 'Detener',
'terminal.browser.bookmarks': 'Aplicaciones y marcadores',
'terminal.browser.downloads': 'Descargas',
'terminal.browser.empty': 'Añade una aplicación o introduce una dirección.',
'terminal.browser.connecting': 'Conectando…',
'terminal.browser.disconnected': 'Conexión no disponible',
'terminal.browser.retry': 'Reconectar',
'terminal.browser.addBookmark': 'Añadir aplicación',
'terminal.browser.editBookmark': 'Editar aplicación',
'terminal.browser.bookmarkTitle': 'Título',
'terminal.browser.icon': 'Icono',
'terminal.browser.website': 'Sitio web',
'terminal.browser.text': 'Texto',
'terminal.browser.image': 'Imagen',
'terminal.browser.chooseImage': 'Elegir imagen',
'terminal.browser.color': 'Color',
'terminal.browser.scope': 'Mostrar en',
'terminal.browser.hostScope': 'Este equipo',
'terminal.browser.allScope': 'Todos los equipos',
'terminal.browser.changeIcon': 'Cambiar icono',
'terminal.browser.resetIcon': 'Usar icono del sitio',
'terminal.browser.downloadEmpty': 'No hay descargas',
'terminal.browser.openFolder': 'Abrir carpeta de descargas',
'terminal.browser.clearDownloads': 'Borrar descargas finalizadas',
'terminal.browser.cancelDownload': 'Cancelar descarga',
'terminal.browser.reveal': 'Mostrar en carpeta',
'terminal.browser.openFile': 'Abrir archivo',
'terminal.browser.removeDownload': 'Quitar de la lista',
'terminal.browser.waiting': 'Preparando descarga…',
'terminal.browser.progressing': 'Descargando',
'terminal.browser.completed': 'Completada',
'terminal.browser.cancelled': 'Cancelada',
'terminal.browser.interrupted': 'Interrumpida',
'terminal.browser.missing': 'Archivo movido o eliminado',
'terminal.browser.closeTab': 'Cerrar pestaña',
'terminal.browser.more': 'Más acciones',
'terminal.browser.websiteIconHint': 'El icono se obtiene automáticamente al visitar el sitio.',
'terminal.browser.iconText': 'Texto del icono',
'terminal.browser.iconTextHint': 'Usa una letra, abreviatura o emoji.',
'terminal.browser.selectColor': 'Usar {color}',
'terminal.browser.customColor': 'Color personalizado',
'terminal.browser.iconPreview': 'Vista previa del icono',
'terminal.browser.savedImage': 'Imagen guardada',
'terminal.browser.processingImage': 'Preparando imagen…',
'terminal.browser.replaceImage': 'Cambiar imagen',
'terminal.browser.removeImage': 'Quitar imagen',
'terminal.browser.uploadImage': 'Elegir imagen',
'terminal.browser.dropImage': 'o arrástrala aquí',
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF o ICO · hasta 5 MB. Se ajusta automáticamente.',
'terminal.browser.iconError.file-type': 'Elige una imagen PNG, JPG, WebP, GIF o ICO.',
'terminal.browser.iconError.file-size': 'La imagen es demasiado grande. Elige un archivo de hasta 5 MB.',
'terminal.browser.iconError.decode': 'No se pudo leer la imagen. Prueba con otro archivo.',
'terminal.browser.bookmarkPreviewHint': 'Aparece en la página de inicio del navegador',
'terminal.browser.untitledBookmark': 'Tu aplicación',
'terminal.browser.bookmarkTitlePlaceholder': 'p. ej., Documentos del equipo',
'terminal.browser.websiteAddress': 'Dirección del sitio',
'terminal.browser.hostScopeHint': 'Mostrar solo en el navegador de este host.',
'terminal.browser.allScopeHint': 'Mostrar en el navegador de todos los hosts.',
'terminal.browser.downloadLocation': 'Guardado en la carpeta Descargas',
'terminal.browser.saveAs': 'Guardar como…',
};
+65
View File
@@ -949,4 +949,69 @@ export const ruTerminalMessages: Messages = {
'sftp.editor.saveAndClose': 'Сохранить и закрыть',
'sftp.editor.quitBlockedByDirty': 'Есть несохранённые редакторы — перед выходом сохраните изменения или отбросьте их',
'terminal.browser.newTab': 'Новая вкладка',
'terminal.browser.address': 'Веб-адрес',
'terminal.browser.back': 'Назад',
'terminal.browser.forward': 'Вперёд',
'terminal.browser.reload': 'Обновить',
'terminal.browser.stop': 'Остановить',
'terminal.browser.bookmarks': 'Приложения и закладки',
'terminal.browser.downloads': 'Загрузки',
'terminal.browser.empty': 'Добавьте приложение или введите адрес.',
'terminal.browser.connecting': 'Подключение…',
'terminal.browser.disconnected': 'Соединение недоступно',
'terminal.browser.retry': 'Подключиться снова',
'terminal.browser.addBookmark': 'Добавить приложение',
'terminal.browser.editBookmark': 'Изменить приложение',
'terminal.browser.bookmarkTitle': 'Название',
'terminal.browser.icon': 'Значок',
'terminal.browser.website': 'Значок сайта',
'terminal.browser.text': 'Текст',
'terminal.browser.image': 'Изображение',
'terminal.browser.chooseImage': 'Выбрать изображение',
'terminal.browser.color': 'Цвет',
'terminal.browser.scope': 'Показывать на',
'terminal.browser.hostScope': 'Этот хост',
'terminal.browser.allScope': 'Все хосты',
'terminal.browser.changeIcon': 'Изменить значок',
'terminal.browser.resetIcon': 'Использовать значок сайта',
'terminal.browser.downloadEmpty': 'Загрузок пока нет',
'terminal.browser.openFolder': 'Открыть папку загрузок',
'terminal.browser.clearDownloads': 'Очистить завершённые загрузки',
'terminal.browser.cancelDownload': 'Отменить загрузку',
'terminal.browser.reveal': 'Показать в папке',
'terminal.browser.openFile': 'Открыть файл',
'terminal.browser.removeDownload': 'Удалить из списка',
'terminal.browser.waiting': 'Подготовка загрузки…',
'terminal.browser.progressing': 'Загрузка',
'terminal.browser.completed': 'Завершена',
'terminal.browser.cancelled': 'Отменена',
'terminal.browser.interrupted': 'Прервана',
'terminal.browser.missing': 'Файл перемещён или удалён',
'terminal.browser.closeTab': 'Закрыть вкладку',
'terminal.browser.more': 'Другие действия',
'terminal.browser.websiteIconHint': 'Значок загрузится автоматически после посещения сайта.',
'terminal.browser.iconText': 'Текст значка',
'terminal.browser.iconTextHint': 'Буква, сокращение или эмодзи.',
'terminal.browser.selectColor': 'Выбрать {color}',
'terminal.browser.customColor': 'Свой цвет',
'terminal.browser.iconPreview': 'Предпросмотр значка',
'terminal.browser.savedImage': 'Сохранённое изображение',
'terminal.browser.processingImage': 'Обработка изображения…',
'terminal.browser.replaceImage': 'Заменить',
'terminal.browser.removeImage': 'Удалить изображение',
'terminal.browser.uploadImage': 'Выбрать изображение',
'terminal.browser.dropImage': 'или перетащить его сюда',
'terminal.browser.imageHint': 'PNG, JPG, WebP, GIF или ICO · до 5 МБ. Размер подбирается автоматически.',
'terminal.browser.iconError.file-type': 'Выберите PNG, JPG, WebP, GIF или ICO.',
'terminal.browser.iconError.file-size': 'Изображение слишком большое. Выберите файл до 5 МБ.',
'terminal.browser.iconError.decode': 'Не удалось прочитать изображение. Выберите другой файл.',
'terminal.browser.bookmarkPreviewHint': 'Отображается на начальной странице браузера',
'terminal.browser.untitledBookmark': 'Ваше приложение',
'terminal.browser.bookmarkTitlePlaceholder': 'Например, Документы команды',
'terminal.browser.websiteAddress': 'Адрес сайта',
'terminal.browser.hostScopeHint': 'Только в браузере текущего хоста.',
'terminal.browser.allScopeHint': 'В браузерах всех хостов.',
'terminal.browser.downloadLocation': 'Файлы сохраняются в папку «Загрузки»',
'terminal.browser.saveAs': 'Сохранить как…',
};
@@ -1028,4 +1028,69 @@ export const zhCNTerminalMessages: Messages = {
'sftp.editor.saveAndClose': '保存并关闭',
'sftp.editor.quitBlockedByDirty': '存在未保存的编辑器,请先处理后再退出',
'terminal.browser.newTab': '新标签页',
'terminal.browser.address': '输入网址',
'terminal.browser.back': '后退',
'terminal.browser.forward': '前进',
'terminal.browser.reload': '刷新',
'terminal.browser.stop': '停止',
'terminal.browser.bookmarks': '应用与书签',
'terminal.browser.downloads': '下载',
'terminal.browser.empty': '添加应用或输入网址即可开始浏览。',
'terminal.browser.connecting': '正在连接…',
'terminal.browser.disconnected': '连接不可用',
'terminal.browser.retry': '重新连接',
'terminal.browser.addBookmark': '添加应用',
'terminal.browser.editBookmark': '编辑应用',
'terminal.browser.bookmarkTitle': '名称',
'terminal.browser.icon': '图标',
'terminal.browser.website': '网站图标',
'terminal.browser.text': '文字',
'terminal.browser.image': '图片',
'terminal.browser.chooseImage': '选择图片',
'terminal.browser.color': '颜色',
'terminal.browser.scope': '显示范围',
'terminal.browser.hostScope': '此主机',
'terminal.browser.allScope': '所有主机',
'terminal.browser.changeIcon': '更改图标',
'terminal.browser.resetIcon': '使用网站图标',
'terminal.browser.downloadEmpty': '暂无下载',
'terminal.browser.openFolder': '打开下载文件夹',
'terminal.browser.clearDownloads': '清除已结束的下载',
'terminal.browser.cancelDownload': '取消下载',
'terminal.browser.reveal': '在文件夹中显示',
'terminal.browser.openFile': '打开文件',
'terminal.browser.removeDownload': '从列表移除',
'terminal.browser.waiting': '正在准备下载…',
'terminal.browser.progressing': '正在下载',
'terminal.browser.completed': '已完成',
'terminal.browser.cancelled': '已取消',
'terminal.browser.interrupted': '已中断',
'terminal.browser.missing': '文件已移动或删除',
'terminal.browser.closeTab': '关闭标签页',
'terminal.browser.more': '更多操作',
'terminal.browser.websiteIconHint': '访问网站后,自动获取并显示网站图标。',
'terminal.browser.iconText': '图标文字',
'terminal.browser.iconTextHint': '可以使用字母、简称或表情。',
'terminal.browser.selectColor': '使用 {color}',
'terminal.browser.customColor': '自定义颜色',
'terminal.browser.iconPreview': '图标预览',
'terminal.browser.savedImage': '已保存的图片',
'terminal.browser.processingImage': '正在处理图片…',
'terminal.browser.replaceImage': '替换图片',
'terminal.browser.removeImage': '移除图片',
'terminal.browser.uploadImage': '选择图片',
'terminal.browser.dropImage': '也可以将图片拖到这里',
'terminal.browser.imageHint': '支持 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自动适配图标大小。',
'terminal.browser.iconError.file-type': '请选择 PNG、JPG、WebP、GIF 或 ICO 图片。',
'terminal.browser.iconError.file-size': '图片太大,请选择不超过 5 MB 的文件。',
'terminal.browser.iconError.decode': '无法读取这张图片,请换一个文件。',
'terminal.browser.bookmarkPreviewHint': '显示在浏览器开始页',
'terminal.browser.untitledBookmark': '应用预览',
'terminal.browser.bookmarkTitlePlaceholder': '例如,团队文档',
'terminal.browser.websiteAddress': '网址',
'terminal.browser.hostScopeHint': '仅在当前主机的浏览器中显示。',
'terminal.browser.allScopeHint': '在每台主机的浏览器中显示。',
'terminal.browser.downloadLocation': '文件保存在系统“下载”文件夹',
'terminal.browser.saveAs': '另存为…',
};
@@ -1028,4 +1028,69 @@ export const zhTWTerminalMessages: Messages = {
'sftp.editor.saveAndClose': '儲存並關閉',
'sftp.editor.quitBlockedByDirty': '存在未儲存的編輯器,請先處理後再離開',
'terminal.browser.newTab': '新增分頁',
'terminal.browser.address': '輸入網址',
'terminal.browser.back': '上一頁',
'terminal.browser.forward': '下一頁',
'terminal.browser.reload': '重新整理',
'terminal.browser.stop': '停止',
'terminal.browser.bookmarks': '應用程式與書籤',
'terminal.browser.downloads': '下載',
'terminal.browser.empty': '新增應用程式或輸入網址即可開始瀏覽。',
'terminal.browser.connecting': '正在連線…',
'terminal.browser.disconnected': '連線無法使用',
'terminal.browser.retry': '重新連線',
'terminal.browser.addBookmark': '新增應用程式',
'terminal.browser.editBookmark': '編輯應用程式',
'terminal.browser.bookmarkTitle': '名稱',
'terminal.browser.icon': '圖示',
'terminal.browser.website': '網站圖示',
'terminal.browser.text': '文字',
'terminal.browser.image': '圖片',
'terminal.browser.chooseImage': '選擇圖片',
'terminal.browser.color': '顏色',
'terminal.browser.scope': '顯示範圍',
'terminal.browser.hostScope': '此主機',
'terminal.browser.allScope': '所有主機',
'terminal.browser.changeIcon': '變更圖示',
'terminal.browser.resetIcon': '使用網站圖示',
'terminal.browser.downloadEmpty': '尚無下載',
'terminal.browser.openFolder': '開啟下載資料夾',
'terminal.browser.clearDownloads': '清除已結束的下載',
'terminal.browser.cancelDownload': '取消下載',
'terminal.browser.reveal': '在資料夾中顯示',
'terminal.browser.openFile': '開啟檔案',
'terminal.browser.removeDownload': '從清單移除',
'terminal.browser.waiting': '正在準備下載…',
'terminal.browser.progressing': '正在下載',
'terminal.browser.completed': '已完成',
'terminal.browser.cancelled': '已取消',
'terminal.browser.interrupted': '已中斷',
'terminal.browser.missing': '檔案已移動或刪除',
'terminal.browser.closeTab': '關閉分頁',
'terminal.browser.more': '更多操作',
'terminal.browser.websiteIconHint': '造訪網站後,自動取得並顯示網站圖示。',
'terminal.browser.iconText': '圖示文字',
'terminal.browser.iconTextHint': '可以使用字母、簡稱或表情符號。',
'terminal.browser.selectColor': '使用 {color}',
'terminal.browser.customColor': '自訂顏色',
'terminal.browser.iconPreview': '圖示預覽',
'terminal.browser.savedImage': '已儲存的圖片',
'terminal.browser.processingImage': '正在處理圖片…',
'terminal.browser.replaceImage': '替換圖片',
'terminal.browser.removeImage': '移除圖片',
'terminal.browser.uploadImage': '選擇圖片',
'terminal.browser.dropImage': '也可以將圖片拖到這裡',
'terminal.browser.imageHint': '支援 PNG、JPG、WebP、GIF、ICO,最大 5 MB,自動調整圖示大小。',
'terminal.browser.iconError.file-type': '請選擇 PNG、JPG、WebP、GIF 或 ICO 圖片。',
'terminal.browser.iconError.file-size': '圖片太大,請選擇不超過 5 MB 的檔案。',
'terminal.browser.iconError.decode': '無法讀取這張圖片,請更換檔案。',
'terminal.browser.bookmarkPreviewHint': '顯示在瀏覽器起始頁',
'terminal.browser.untitledBookmark': '應用程式預覽',
'terminal.browser.bookmarkTitlePlaceholder': '例如,團隊文件',
'terminal.browser.websiteAddress': '網址',
'terminal.browser.hostScopeHint': '僅在目前主機的瀏覽器中顯示。',
'terminal.browser.allScopeHint': '在每台主機的瀏覽器中顯示。',
'terminal.browser.downloadLocation': '檔案儲存在系統「下載項目」資料夾',
'terminal.browser.saveAs': '另存新檔…',
};
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { HostBrowserPanelEvent, HostBrowserState } from '../../domain/hostBrowser';
import { useHostBrowserControls } from './useHostBrowserControls';
const state = (label: string): HostBrowserState => ({ hostId: label, label, route: 'connected', error: null, activeTabId: null, tabs: [] });
test('browser events stay scoped and a late initial read cannot overwrite a newer page', async (t) => {
const env = installDomEnvironment();
let resolveInitial: (value: HostBrowserState) => void = () => assert.fail('initial read missing');
let emit: (event: HostBrowserPanelEvent) => void = () => {};
let unsubscribed = false;
t.mock.method(netcattyBridge, 'require', () => ({
getHostBrowserPanelState: () => new Promise<HostBrowserState>((resolve) => { resolveInitial = resolve; }),
commandHostBrowserPanel: async () => state('command'),
listHostBrowserPanelBookmarks: async () => [],
listHostBrowserPanelDownloads: async () => [],
onHostBrowserPanelEvent: (callback: typeof emit) => { emit = callback; return () => { unsubscribed = true; }; },
}));
let controls: ReturnType<typeof useHostBrowserControls>;
function Panel() { controls = useHostBrowserControls('browser-a', true); return null; }
let renderer: ReturnType<typeof create>;
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
await act(async () => { renderer = create(React.createElement(Panel)); });
await act(async () => emit({ browserId: 'browser-b', type: 'state', state: state('wrong host') }));
assert.equal(controls.state, null);
await act(async () => emit({ browserId: 'browser-a', type: 'state', state: state('new page') }));
await act(async () => resolveInitial(state('old page')));
assert.equal(controls.state?.label, 'new page');
await act(async () => renderer.unmount());
assert.equal(unsubscribed, true);
});
test('switching browser contexts invalidates an outstanding navigation response', async (t) => {
const env = installDomEnvironment();
let finish: (value: HostBrowserState) => void = () => assert.fail('command missing');
const commands: string[] = [];
t.mock.method(netcattyBridge, 'require', () => ({
getHostBrowserPanelState: async (id: string) => state(id),
commandHostBrowserPanel: (id: string) => { commands.push(id); return new Promise<HostBrowserState>((resolve) => { finish = resolve; }); },
listHostBrowserPanelBookmarks: async () => [], listHostBrowserPanelDownloads: async () => [],
onHostBrowserPanelEvent: () => () => {},
}));
let controls: ReturnType<typeof useHostBrowserControls>;
function Panel({ id }: { id: string }) { controls = useHostBrowserControls(id, true); return null; }
let renderer: ReturnType<typeof create>;
t.after(async () => { if (renderer) await act(async () => renderer.unmount()); env.cleanup(); });
await act(async () => { renderer = create(React.createElement(Panel, { id: 'browser-a' })); });
let pending: Promise<boolean>;
await act(async () => { pending = controls.command({ type: 'navigate', url: 'http://localhost/' }); });
await act(async () => renderer.update(React.createElement(Panel, { id: 'browser-b' })));
await act(async () => finish(state('late browser-a')));
assert.equal(await pending, false);
assert.equal(controls.state?.label, 'browser-b');
assert.deepEqual(commands, ['browser-a']);
});
@@ -0,0 +1,95 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { BrowserBookmark } from '../../domain/browserBookmark';
import type { HostBrowserBookmark, HostBrowserCommand, HostBrowserDownload, HostBrowserState } from '../../domain/hostBrowser';
import { hostBrowserClient, readHostBrowserIcon } from '../../infrastructure/services/hostBrowserService';
export function useHostBrowserControls(browserId: string, ready: boolean) {
const [state, setState] = useState<HostBrowserState | null>(null);
const [bookmarks, setBookmarks] = useState<HostBrowserBookmark[]>([]);
const [downloads, setDownloads] = useState<HostBrowserDownload[]>([]);
const [error, setError] = useState<string | null>(null);
const [focusAddress, setFocusAddress] = useState(0);
const [downloadStarted, setDownloadStarted] = useState(0);
const generation = useRef(0);
const client = useRef<ReturnType<typeof hostBrowserClient> | null>(null);
const report = useCallback((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)), []);
const command = useCallback(async (input: HostBrowserCommand) => {
const current = client.current;
const revision = generation.current;
if (!current) return false;
setError(null);
try {
const next = await current.command(input);
if (revision !== generation.current) return false;
setState(next);
if (input.type === 'new-tab' || (input.type === 'close-tab' && !next.tabs.find((tab) => tab.id === next.activeTabId)?.url)) setFocusAddress((value) => value + 1);
return true;
} catch (reason) { if (revision === generation.current) report(reason); return false; }
}, [report]);
useEffect(() => {
const revision = ++generation.current;
setState(null); setBookmarks([]); setDownloads([]); setError(null); setDownloadStarted(0);
if (!ready) return;
let current: ReturnType<typeof hostBrowserClient>;
try { current = hostBrowserClient(browserId); } catch (reason) { report(reason); return; }
client.current = current;
let stateRevision = 0;
let bookmarkRevision = 0;
let downloadRevision = 0;
const active = () => revision === generation.current;
const refreshBookmarks = async () => {
const request = ++bookmarkRevision;
try { const items = await current.bookmarks(); if (active() && request === bookmarkRevision) setBookmarks(items); }
catch (reason) { if (active()) report(reason); }
};
const off = current.subscribe((event) => {
if (!active()) return;
if (event.type === 'state') { stateRevision++; setState(event.state); }
if (event.type === 'bookmarks') void refreshBookmarks();
if (event.type === 'downloads') {
downloadRevision++; setDownloads(event.downloads);
if (event.startedId) setDownloadStarted((value) => value + 1);
}
if (event.type === 'shortcut') {
if (event.shortcut.type === 'focus-address') setFocusAddress((value) => value + 1);
else void command(event.shortcut.type === 'new-tab' ? { type: 'new-tab' } : { type: event.shortcut.type, tabId: event.shortcut.tabId });
}
});
const requestState = stateRevision;
void current.state().then((next) => { if (active() && stateRevision === requestState) setState(next); }).catch((reason) => { if (active()) report(reason); });
void refreshBookmarks();
const requestDownloads = downloadRevision;
void current.downloads().then((items) => { if (active() && requestDownloads === downloadRevision) setDownloads(items); }).catch((reason) => { if (active()) report(reason); });
return () => { generation.current = revision + 1; client.current = null; off(); };
}, [browserId, ready, command, report]);
const mutateBookmark = useCallback(async (input: Partial<BrowserBookmark> | string) => {
const current = client.current;
const revision = generation.current;
if (!current) return false;
try {
if (typeof input === 'string') await current.removeBookmark(input);
else await current.saveBookmark(input);
const items = await current.bookmarks();
if (revision === generation.current) { setBookmarks(items); setError(null); }
return true;
} catch (reason) { if (revision === generation.current) report(reason); return false; }
}, [report]);
const downloadCommand = useCallback(async (type: Parameters<ReturnType<typeof hostBrowserClient>['downloadCommand']>[0]['type'], id?: string) => {
const current = client.current;
const revision = generation.current;
if (!current) return;
try {
await current.downloadCommand({ type, id });
const items = await current.downloads();
if (revision === generation.current) setDownloads(items);
} catch (reason) { if (revision === generation.current) report(reason); }
}, [report]);
const readIcon = useCallback((file: File) => readHostBrowserIcon(file), []);
return { state, bookmarks, downloads, error, command, mutateBookmark, downloadCommand, focusAddress, downloadStarted,
requestAddressFocus: () => setFocusAddress((value) => value + 1), readIcon, dismissError: () => setError(null) };
}
@@ -0,0 +1,130 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { TerminalSettings } from '../../domain/models';
import { useHostBrowserPanel } from './useHostBrowserPanel';
type Layout = Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0];
type Result = Awaited<ReturnType<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>>;
const layers = [{ image: 'data:image/png;base64,fixture', bounds: { x: 0, y: 0, width: 500, height: 600 } }];
async function fixture(t: test.TestContext) {
const env = installDomEnvironment();
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
const originals = new Map(['requestAnimationFrame', 'cancelAnimationFrame', 'getComputedStyle']
.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));
Object.defineProperty(globalThis, 'requestAnimationFrame', { configurable: true,
value: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; } });
Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true,
value: (id: number) => frames.delete(id) });
const style = env.document.createElement('div').style;
style.colorScheme = 'dark';
globalThis.getComputedStyle = () => style;
const layouts: Layout[] = [];
let respond: (layout: Layout) => Promise<Result> = async (layout) => layout.occluded ? { layers } : true;
t.mock.method(netcattyBridge, 'get', () => ({
setHostBrowserPanelLayout: async (layout: Layout) => { layouts.push(layout); return respond(layout); },
openHostBrowser: async () => {},
startPortForward: async () => assert.fail('a local browser needs no SSH transport'),
closeHostBrowserPanel: async () => {},
}));
let panel: ReturnType<typeof useHostBrowserPanel>;
function Panel({ visible = true, browserId = 'local:overlay-test' }) {
panel = useHostBrowserPanel({ browserId,
host: { id: 'local', protocol: 'local', label: 'Local terminal', hostname: '', username: '', tags: [], os: 'macos' },
hosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {} as TerminalSettings,
isVisible: visible });
return React.createElement('div', { ref: panel.elementRef });
}
const element = { isConnected: true, closest: () => null,
getBoundingClientRect: () => new env.window.DOMRect(200, 100, 500, 600) };
let renderer: ReturnType<typeof create>;
t.after(async () => {
if (renderer) await act(async () => renderer.unmount());
for (const [name, previous] of originals) {
if (previous) Object.defineProperty(globalThis, name, previous);
else Reflect.deleteProperty(globalThis, name);
}
env.cleanup();
});
await act(async () => { renderer = create(React.createElement(Panel), { createNodeMock: () => element }); });
const overlay = (html: string, x = 220, y = 110) => {
env.document.body.insertAdjacentHTML('beforeend', html);
const node = env.document.body.lastElementChild!;
for (const target of [node, ...node.children]) {
target.getBoundingClientRect = () => new env.window.DOMRect(x, y, 150, 40);
}
return node;
};
const tick = async () => {
await act(async () => {
const pending = [...frames.values()]; frames.clear();
for (const callback of pending) callback(0);
});
};
return { layouts, overlay, tick, panel: () => panel,
respond: (callback: typeof respond) => { respond = callback; },
hide: async () => { await act(async () => renderer.update(React.createElement(Panel, { visible: false }))); } };
}
const tooltip = '<div data-radix-popper-content-wrapper><div data-state="delayed-open"><span role="tooltip">Split vertically</span></div></div>';
test('a split-button tooltip keeps the browser picture, then restores its live view', async (t) => {
const f = await fixture(t);
assert.equal(f.layouts.at(-1)?.visible, true);
const hint = f.overlay(tooltip);
await f.tick();
assert.equal(f.layouts.at(-1)?.visible, true, 'the owning panel remains open');
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.panel().snapshot, layers, 'the native page is replaced by its picture, not a blank pane');
const count = f.layouts.length;
await f.tick();
assert.equal(f.layouts.length, count, 'a stationary tooltip captures only once');
hint.remove(); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false);
assert.deepEqual(f.panel().snapshot, []);
});
test('unrelated and closed poppers leave the browser live', async (t) => {
const f = await fixture(t);
f.overlay(tooltip, 10, 10);
f.overlay('<div data-radix-popper-content-wrapper><div role="menu" data-state="closed"></div></div>');
await f.tick();
assert.equal(f.layouts.length, 1);
assert.equal(f.layouts.at(-1)?.occluded, false);
});
test('overlapping nonmodal menus yield with a picture; modal dialogs yield even beside the pane', async (t) => {
const f = await fixture(t);
const menu = f.overlay('<div data-radix-popper-content-wrapper><div role="dialog" data-state="open"></div></div>');
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
menu.remove(); await f.tick();
f.overlay('<div role="dialog" aria-modal="true"></div>', 0, 0);
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
});
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
const f = await fixture(t);
let complete: (result: Result) => void = () => assert.fail('capture was not started');
f.respond((layout) => layout.occluded ? new Promise((resolve) => { complete = resolve; }) : Promise.resolve(true));
const menu = f.overlay(tooltip); await f.tick();
menu.remove(); await f.tick();
await act(async () => complete({ layers }));
assert.deepEqual(f.panel().snapshot, []);
});
test('hiding or locking the pane clears pictures without taking another capture', async (t) => {
const f = await fixture(t);
f.overlay(tooltip); await f.tick();
assert.deepEqual(f.panel().snapshot, layers);
f.overlay('<div data-app-lock-overlay role="dialog"></div>'); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, false);
assert.equal(f.layouts.at(-1)?.occluded, false);
assert.deepEqual(f.panel().snapshot, []);
await f.hide();
assert.deepEqual(f.panel().snapshot, []);
});
+36 -8
View File
@@ -2,6 +2,16 @@ 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';
import type { HostBrowserPanelSnapshotLayer } from '../../types/global/netcatty-bridge-sync';
function overlapsBrowser(rect: DOMRect, element: Element): boolean {
const style = getComputedStyle(element);
if (style.display === 'none' || style.visibility === 'hidden' || element.closest('[hidden], [inert], [data-state="closed"]')) return false;
const overlay = element.getBoundingClientRect();
return overlay.width > 0 && overlay.height > 0
&& overlay.x < rect.right && overlay.right > rect.x
&& overlay.y < rect.bottom && overlay.bottom > rect.y;
}
interface BrowserPanelOptions {
browserId: string;
@@ -22,32 +32,47 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const mountedBrowserId = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const [readyId, setReadyId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<{ browserId: string; layers: HostBrowserPanelSnapshotLayer[] } | null>(null);
const { browserId, isVisible } = options;
useLayoutEffect(() => {
const bridge = netcattyBridge.get();
let frame = 0;
let previous = '';
let disposed = false;
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]');
// Native views sit above renderer portals. Only overlapping poppers need
// to yield; dialogs are modal even when their content is beside the pane.
const locked = !!document.querySelector('[data-app-lock-overlay]');
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
const covered = modal || (!!rect && Array.from(document.querySelectorAll(
'[role="dialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
)).some((overlay) => overlapsBrowser(rect, overlay)));
const visible = isVisible && !!element?.isConnected && !!rect
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
if (!visible) setSnapshot(null);
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 });
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
browserId, visible, occluded: visible && covered, bounds, theme, themeTokens,
};
const next = JSON.stringify(layout);
if (next !== previous) {
previous = next;
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
if (disposed || previous !== next) return;
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
}).catch(() => {});
}
if (isVisible) frame = requestAnimationFrame(update);
};
update();
return () => {
disposed = true;
cancelAnimationFrame(frame);
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
};
@@ -58,7 +83,9 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
opened.current = true;
setError(null);
let cancelled = false;
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
void mountHostBrowser({ ...optionsRef.current, browserId }).then(() => {
if (mountedBrowserId.current === browserId) setReadyId(browserId);
}).catch((reason: unknown) => {
opened.current = false;
if (cancelled) return;
setError(reason instanceof Error ? reason.message : String(reason));
@@ -80,5 +107,6 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
};
}, [browserId]);
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
return { elementRef, error, ready: readyId === browserId, snapshot: isVisible && snapshot?.browserId === browserId ? snapshot.layers : [],
retry: () => setAttempt((value) => value + 1) };
}
+130 -9
View File
@@ -1,16 +1,137 @@
import React from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { ArrowLeft, ArrowRight, Bookmark, Download, Edit2, Loader2, Plus, RotateCw, Square, Trash2, X } from 'lucide-react';
import { useHostBrowserPanel } from '../application/state/useHostBrowserPanel';
import { useHostBrowserControls } from '../application/state/useHostBrowserControls';
import { useI18n } from '../application/i18n/I18nProvider';
import type { BrowserBookmark } from '../domain/browserBookmark';
import type { HostBrowserTab } from '../domain/hostBrowser';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Input } from './ui/input';
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from './ui/context-menu';
import { Dialog, DialogContent, DialogTitle } from './ui/dialog';
import { BrowserIcon } from './host-browser/BrowserIcon';
import { BrowserBookmarkEditor } from './host-browser/BrowserBookmarkEditor';
import { BrowserDownloads } from './host-browser/BrowserDownloads';
import { BrowserIconEditor, type BrowserIconSelection } from './host-browser/BrowserIconEditor';
function ToolbarButton({ label, children, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { label: string }) {
return <Tooltip><TooltipTrigger asChild><button type="button" aria-label={label} {...props} className={cn('grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground disabled:pointer-events-none disabled:opacity-35', props.className)}>{children}</button></TooltipTrigger><TooltipContent side="top">{label}</TooltipContent></Tooltip>;
}
export function HostBrowserSidePanel(props: Parameters<typeof useHostBrowserPanel>[0]) {
const { elementRef, error, retry } = useHostBrowserPanel(props);
const { elementRef, error: mountError, snapshot, retry, ready } = useHostBrowserPanel(props);
const browser = useHostBrowserControls(props.browserId, ready && props.isVisible);
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>}
const active = browser.state?.tabs.find((tab) => tab.id === browser.state?.activeTabId);
const connected = browser.state?.route === 'connected';
const [address, setAddress] = useState('');
const addressRef = useRef<HTMLInputElement>(null);
const lastActive = useRef<string | undefined>(undefined);
const [bookmarksOpen, setBookmarksOpen] = useState(false);
const [downloadsOpen, setDownloadsOpen] = useState(false);
const [editingBookmark, setEditingBookmark] = useState<Partial<BrowserBookmark> | null>(null);
const [editingIcon, setEditingIcon] = useState<HostBrowserTab | null>(null);
useEffect(() => {
if (lastActive.current !== active?.id || document.activeElement !== addressRef.current) setAddress(active?.url || '');
lastActive.current = active?.id;
}, [active?.id, active?.url]);
useEffect(() => {
if (!browser.focusAddress) return;
addressRef.current?.focus(); addressRef.current?.select();
}, [browser.focusAddress]);
useEffect(() => { setEditingBookmark(null); setEditingIcon(null); setBookmarksOpen(false); setDownloadsOpen(false); }, [props.browserId]);
useEffect(() => {
if (browser.downloadStarted) { setDownloadsOpen(true); setBookmarksOpen(false); }
}, [browser.downloadStarted]);
const newBookmark = () => {
setBookmarksOpen(true);
setEditingBookmark({ title: active?.url ? active.title || '' : '', url: active?.url || '', scope: 'host', color: '#247969', order: browser.bookmarks.length });
};
const navigate = (url: string) => { void browser.command({ type: 'navigate', url }); };
const error = mountError || browser.error;
return <div className="relative flex h-full min-h-0 min-w-0 w-full flex-col overflow-hidden bg-background text-foreground" data-section="host-browser-root" onKeyDownCapture={(event) => {
if (!(event.metaKey || event.ctrlKey) || event.altKey) return;
const key = event.key.toLowerCase();
if (!['l', 't', 'w', 'r'].includes(key)) return;
event.preventDefault(); event.stopPropagation();
if (key === 'l') browser.requestAddressFocus();
if (key === 't') void browser.command({ type: 'new-tab' });
if (key === 'w') void browser.command({ type: 'close-tab' });
if (key === 'r') void browser.command({ type: 'reload' });
}}>
<div className="shrink-0 border-b border-border/50" data-section="host-browser-controls">
<div className="flex min-w-0 items-center gap-0.5 px-1.5 pt-1">
<div role="tablist" aria-label={t('terminal.browser.title')} className="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto">
{browser.state?.tabs.map((tab) => <ContextMenu key={tab.id}><ContextMenuTrigger asChild><div role="tab" tabIndex={tab.id === active?.id ? 0 : -1} aria-selected={tab.id === active?.id} draggable
className={cn('group flex h-7 min-w-[90px] max-w-[200px] flex-1 cursor-default items-center gap-1.5 rounded px-2 text-xs outline-none focus-visible:ring-1 focus-visible:ring-ring', tab.id === active?.id ? 'bg-secondary/65 text-foreground' : 'text-muted-foreground hover:bg-secondary/30')}
onClick={() => void browser.command({ type: 'activate', tabId: tab.id })}
onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); void browser.command({ type: 'activate', tabId: tab.id }); } }}
onDragStart={(event) => { event.dataTransfer.setData('text/plain', tab.id); event.dataTransfer.effectAllowed = 'move'; }}
onDragOver={(event) => { event.preventDefault(); event.dataTransfer.dropEffect = 'move'; }}
onDrop={(event) => { event.preventDefault(); const id = event.dataTransfer.getData('text/plain'); if (browser.state?.tabs.some((item) => item.id === id)) void browser.command({ type: 'reorder', tabId: id, beforeTabId: tab.id }); }}>
{tab.loading ? <Loader2 size={13} className="shrink-0 animate-spin" /> : <BrowserIcon image={tab.iconDataUrl} glyph={tab.iconGlyph} color={tab.iconColor} className="h-3.5 w-3.5" />}
<span className="min-w-0 flex-1 truncate" title={tab.title || tab.url}>{tab.url ? tab.title || tab.url : t('terminal.browser.newTab')}</span>
<button type="button" aria-label={t('terminal.browser.closeTab')} className="grid h-4 w-4 shrink-0 place-items-center rounded opacity-60 hover:bg-muted hover:opacity-100" onClick={(event) => { event.stopPropagation(); void browser.command({ type: 'close-tab', tabId: tab.id }); }}><X size={11} /></button>
</div></ContextMenuTrigger><ContextMenuContent>
<ContextMenuItem disabled={!tab.url} onClick={() => setEditingIcon(tab)}>{t('terminal.browser.changeIcon')}</ContextMenuItem>
<ContextMenuItem disabled={!tab.url} onClick={() => void browser.command({ type: 'reset-icon', tabId: tab.id })}>{t('terminal.browser.resetIcon')}</ContextMenuItem>
</ContextMenuContent></ContextMenu>)}
</div>
<ToolbarButton label={t('terminal.browser.newTab')} disabled={!ready} onClick={() => void browser.command({ type: 'new-tab' })}><Plus size={14} /></ToolbarButton>
</div>
<div className="flex min-w-0 items-center gap-0.5 px-1.5 py-1">
<ToolbarButton label={t('terminal.browser.back')} disabled={!connected || !active?.canGoBack} onClick={() => void browser.command({ type: 'back' })}><ArrowLeft size={14} /></ToolbarButton>
<ToolbarButton label={t('terminal.browser.forward')} disabled={!connected || !active?.canGoForward} onClick={() => void browser.command({ type: 'forward' })}><ArrowRight size={14} /></ToolbarButton>
<ToolbarButton label={t(active?.loading ? 'terminal.browser.stop' : 'terminal.browser.reload')} disabled={!connected || !active?.url} onClick={() => void browser.command({ type: active?.loading ? 'stop' : 'reload' })}>{active?.loading ? <Square size={12} /> : <RotateCw size={14} />}</ToolbarButton>
<form className="mx-1 min-w-0 flex-1" onSubmit={(event) => { event.preventDefault(); navigate(address); addressRef.current?.blur(); }}><Input ref={addressRef} aria-label={t('terminal.browser.address')} placeholder={t('terminal.browser.address')} value={address} onChange={(event) => setAddress(event.target.value)} autoComplete="off" spellCheck={false} className="h-7 min-w-0 rounded-md border-border/50 bg-secondary/35 px-2 text-xs" /></form>
<ToolbarButton label={t('terminal.browser.bookmarks')} aria-pressed={bookmarksOpen} className={bookmarksOpen ? 'bg-secondary/70 text-foreground' : ''} onClick={() => { setBookmarksOpen((value) => !value); setDownloadsOpen(false); }}><Bookmark size={14} /></ToolbarButton>
<Popover open={downloadsOpen} onOpenChange={setDownloadsOpen}><Tooltip><TooltipTrigger asChild><PopoverTrigger asChild><button type="button" aria-label={t('terminal.browser.downloads')} className="relative grid h-7 w-7 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-secondary/70 hover:text-foreground"><Download size={14} />{browser.downloads.some((item) => ['waiting', 'progressing'].includes(item.status)) && <span className="absolute right-1 top-1 h-1.5 w-1.5 rounded-full bg-primary" />}</button></PopoverTrigger></TooltipTrigger><TooltipContent side="top">{t('terminal.browser.downloads')}</TooltipContent></Tooltip><PopoverContent align="end" className="w-[min(380px,calc(100vw-24px))] p-0"><BrowserDownloads items={browser.downloads} command={browser.downloadCommand} /></PopoverContent></Popover>
</div>
</div>
);
{error && <div role="status" className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-2 text-xs text-muted-foreground"><span className="min-w-0 flex-1">{error}</span>{mountError ? <Button size="sm" variant="ghost" onClick={retry}>{t('terminal.browser.retry')}</Button> : <button type="button" aria-label={t('common.close')} onClick={browser.dismissError}><X size={13} /></button>}</div>}
<div className="flex min-h-0 flex-1 overflow-hidden">
<div ref={elementRef} className="relative min-h-0 min-w-0 flex-1 overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
{snapshot.map((layer, index) => <img key={index} src={layer.image} alt="" aria-hidden="true" draggable={false} className="absolute pointer-events-none select-none" style={{ left: layer.bounds.x, top: layer.bounds.y, width: layer.bounds.width, height: layer.bounds.height }} />)}
{!connected ? <div className="flex h-full flex-col items-center justify-center gap-3 p-6 text-center text-xs text-muted-foreground"><p>{t(browser.state?.route === 'disconnected' ? 'terminal.browser.disconnected' : 'terminal.browser.connecting')}</p>{browser.state?.error && <p>{browser.state.error}</p>}{browser.state?.route === 'disconnected' && <Button size="sm" variant="secondary" onClick={() => void browser.command({ type: 'retry' })}>{t('terminal.browser.retry')}</Button>}</div>
: !active?.url && <div className="h-full overflow-y-auto p-4"><div className="mb-3 flex items-center justify-between gap-2 text-xs text-muted-foreground"><span>{t('terminal.browser.bookmarks')}</span><Button size="sm" variant="ghost" className="h-7 text-xs" onClick={newBookmark}><Plus size={12} className="mr-1" />{t('terminal.browser.addBookmark')}</Button></div>{!browser.bookmarks.length ? <p className="py-8 text-center text-xs text-muted-foreground">{t('terminal.browser.empty')}</p> : <div className="grid grid-cols-[repeat(auto-fill,minmax(110px,1fr))] gap-1">{browser.bookmarks.map((item) => <button type="button" key={item.id} title={item.url} className="flex min-w-0 flex-col items-center gap-2 rounded-lg p-3 hover:bg-secondary/50" onClick={() => navigate(item.url)}><BrowserIcon className="h-9 w-9 rounded-lg bg-secondary/60 p-1.5" image={item.iconMode === 'custom' ? item.iconDataUrl : item.siteIconDataUrl} glyph={item.iconMode === 'custom' ? item.icon : item.siteIconGlyph || item.icon} color={item.color} /><span className="w-full truncate text-xs">{item.title}</span></button>)}</div>}</div>}
</div>
{bookmarksOpen && <aside className="flex w-[380px] max-w-full shrink-0 flex-col border-l border-border/50 bg-background" aria-label={t('terminal.browser.bookmarks')}>
<div className="flex min-h-12 shrink-0 items-center justify-between gap-2 border-b border-border/60 px-4 py-3"><span className="truncate text-sm font-semibold">{t(editingBookmark ? editingBookmark.id ? 'terminal.browser.editBookmark' : 'terminal.browser.addBookmark' : 'terminal.browser.bookmarks')}</span><div className="flex items-center gap-1">{!editingBookmark && <ToolbarButton label={t('terminal.browser.addBookmark')} onClick={newBookmark}><Plus size={13} /></ToolbarButton>}<ToolbarButton label={t('common.close')} onClick={() => setBookmarksOpen(false)}><X size={13} /></ToolbarButton></div></div>
{editingBookmark ? <BrowserBookmarkEditor key={editingBookmark.id || 'new'} initial={editingBookmark} onSave={browser.mutateBookmark} onCancel={() => setEditingBookmark(null)} readIcon={browser.readIcon} /> : <div className="min-h-0 flex-1 overflow-y-auto p-1">{browser.bookmarks.map((item) => <div key={item.id} className="group flex min-w-0 items-center gap-2 rounded-md px-2 py-2 hover:bg-secondary/40"><BrowserIcon className="h-6 w-6 rounded" image={item.iconMode === 'custom' ? item.iconDataUrl : item.siteIconDataUrl} glyph={item.iconMode === 'custom' ? item.icon : item.siteIconGlyph || item.icon} color={item.color} /><button type="button" className="min-w-0 flex-1 text-left" onClick={() => navigate(item.url)}><p className="truncate text-xs">{item.title}</p><p className="truncate text-[11px] text-muted-foreground">{item.url}</p></button><ToolbarButton label={t('common.edit')} onClick={() => setEditingBookmark(item)}><Edit2 size={12} /></ToolbarButton><ToolbarButton label={t('common.delete')} onClick={() => void browser.mutateBookmark(item.id)}><Trash2 size={12} /></ToolbarButton></div>)}</div>}
</aside>}
</div>
{editingIcon && <BrowserTabIconEditor key={editingIcon.id} tab={editingIcon} readIcon={browser.readIcon} onClose={() => setEditingIcon(null)} onSave={async (values) => { const saved = await browser.command({ type: 'set-icon', tabId: editingIcon.id, ...values }); if (saved) setEditingIcon(null); }} />}
</div>;
}
function BrowserTabIconEditor({ tab, readIcon, onClose, onSave }: { tab: HostBrowserTab; readIcon: (file: File) => Promise<string | null>; onClose: () => void; onSave: (values: { glyph: string; color: string; dataUrl?: string | null }) => Promise<void> }) {
const { t } = useI18n();
const [icon, setIcon] = useState<BrowserIconSelection>({ mode: tab.iconDataUrl ? 'image' : 'text', glyph: tab.iconGlyph || '', color: tab.iconColor || '#247969', image: tab.iconDataUrl });
const [saving, setSaving] = useState(false);
const [reading, setReading] = useState(false);
return <Dialog open onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent className="max-w-sm">
<DialogTitle>{t('terminal.browser.changeIcon')}</DialogTitle>
<form className="space-y-4" onSubmit={async (event) => {
event.preventDefault(); setSaving(true);
try { await onSave({ glyph: icon.mode === 'text' ? icon.glyph : '', color: icon.color, dataUrl: icon.mode === 'image' ? icon.image : null }); }
finally { setSaving(false); }
}}>
<div className="flex min-w-0 items-center gap-3">
<BrowserIcon className="h-12 w-12 rounded-xl border border-border/50 bg-muted/40 p-2.5 [&>span]:text-lg" image={icon.mode === 'image' ? icon.image : null} glyph={icon.mode === 'text' ? icon.glyph : null} color={icon.color} />
<p className="min-w-0 truncate text-sm font-medium">{tab.title || tab.url}</p>
</div>
<BrowserIconEditor value={icon} onChange={(changes) => setIcon((current) => ({ ...current, ...changes }))} readIcon={readIcon} onBusyChange={setReading} allowWebsite={false} />
<div className="flex justify-end gap-2 border-t border-border/50 pt-3">
<Button type="button" variant="ghost" size="sm" onClick={onClose}>{t('common.cancel')}</Button>
<Button type="submit" size="sm" disabled={saving || reading || (icon.mode === 'image' ? !icon.image : !icon.glyph.trim())}>{t('common.save')}</Button>
</div>
</form>
</DialogContent>
</Dialog>;
}
@@ -0,0 +1,82 @@
import React, { useId, useState } from 'react';
import { Check, Globe, Loader2, Monitor } from 'lucide-react';
import type { BrowserBookmark } from '../../domain/browserBookmark';
import type { HostBrowserBookmark } from '../../domain/hostBrowser';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { AsidePanelContent, AsidePanelFooter } from '../ui/aside-panel';
import { BrowserIcon } from './BrowserIcon';
import { BrowserIconEditor, type BrowserIconSelection } from './BrowserIconEditor';
export function BrowserBookmarkEditor({ initial, onSave, onCancel, readIcon }: {
initial: Partial<HostBrowserBookmark>;
onSave: (bookmark: Partial<BrowserBookmark>) => Promise<boolean>;
onCancel: () => void;
readIcon: (file: File) => Promise<string | null>;
}) {
const { t } = useI18n();
const id = useId();
const [draft, setDraft] = useState(initial);
const [icon, setIcon] = useState<BrowserIconSelection>({
mode: initial.iconDataUrl ? 'image' : initial.iconMode === 'custom' ? 'text' : 'website',
glyph: initial.icon || '', color: initial.color || '#247969', image: initial.iconDataUrl || null,
});
const [reading, setReading] = useState(false);
const [saving, setSaving] = useState(false);
const invalidIcon = reading || (icon.mode === 'image' && !icon.image);
const previewImage = icon.mode === 'image' ? icon.image : icon.mode === 'website' ? initial.siteIconDataUrl : null;
const previewGlyph = icon.mode === 'text' ? icon.glyph || draft.title?.trim().slice(0, 1) : icon.mode === 'website' ? initial.siteIconGlyph : null;
let addressHint = t('terminal.browser.bookmarkPreviewHint');
try { if (draft.url) addressHint = new URL(draft.url).host; } catch { /* Keep the hint while an address is being typed. */ }
return <form className="flex min-h-0 flex-1 flex-col" data-section="browser-bookmark-editor" onSubmit={async (event) => {
event.preventDefault();
if (invalidIcon || saving) return;
setSaving(true);
try {
const saved = await onSave({ ...draft, title: draft.title?.trim(), url: draft.url?.trim(),
color: icon.color, iconMode: icon.mode === 'website' ? 'auto' : 'custom',
iconDataUrl: icon.mode === 'image' ? icon.image : null, icon: icon.mode === 'text' ? icon.glyph : '' });
if (saved) onCancel();
} finally { setSaving(false); }
}}>
<AsidePanelContent className="min-h-0">
<div className="flex min-w-0 items-center gap-3 border-b border-border/40 pb-4" data-section="bookmark-preview">
<BrowserIcon className="h-12 w-12 rounded-xl border border-border/50 bg-muted/40 p-2.5 [&>span]:text-lg [&>svg]:h-5 [&>svg]:w-5" image={previewImage} glyph={previewGlyph} color={icon.mode === 'website' ? initial.siteIconColor : icon.color} />
<div className="min-w-0 flex-1"><p className="truncate text-sm font-medium">{draft.title?.trim() || t('terminal.browser.untitledBookmark')}</p><p className="mt-1 truncate text-xs text-muted-foreground">{addressHint}</p></div>
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-title`} className="text-xs text-muted-foreground">{t('terminal.browser.bookmarkTitle')}</Label>
<Input id={`${id}-title`} autoFocus required maxLength={120} className="h-9" placeholder={t('terminal.browser.bookmarkTitlePlaceholder')} value={draft.title || ''} onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} />
</div>
<div className="space-y-2">
<Label htmlFor={`${id}-url`} className="text-xs text-muted-foreground">{t('terminal.browser.websiteAddress')}</Label>
<Input id={`${id}-url`} required type="url" className="h-9" placeholder="https://example.com" spellCheck={false} autoComplete="off" value={draft.url || ''} onChange={(event) => setDraft((current) => ({ ...current, url: event.target.value }))} />
</div>
<div className="space-y-2.5">
<p className="text-xs font-medium text-muted-foreground">{t('terminal.browser.icon')}</p>
<BrowserIconEditor value={icon} onChange={(changes) => setIcon((current) => ({ ...current, ...changes }))} readIcon={readIcon} onBusyChange={setReading} />
</div>
<fieldset className="min-w-0 space-y-2.5">
<legend className="mb-2.5 text-xs font-medium text-muted-foreground">{t('terminal.browser.scope')}</legend>
{(['host', 'all-hosts'] as const).map((scope) => {
const selected = (draft.scope || 'host') === scope;
const Icon = scope === 'host' ? Monitor : Globe;
return <label key={scope} className={cn('relative flex cursor-pointer items-center gap-3 rounded-lg border px-3 py-2.5 transition-colors focus-within:ring-1 focus-within:ring-ring', selected ? 'border-primary/35 bg-primary/5' : 'border-border/50 hover:bg-muted/30')}>
<input type="radio" name={`${id}-scope`} value={scope} checked={selected} onChange={() => setDraft((current) => ({ ...current, scope }))} className="sr-only" />
<Icon size={15} className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground')} />
<span className="min-w-0 flex-1"><span className="block text-xs font-medium">{t(scope === 'host' ? 'terminal.browser.hostScope' : 'terminal.browser.allScope')}</span><span className="mt-1 block text-[11px] leading-relaxed text-muted-foreground">{t(scope === 'host' ? 'terminal.browser.hostScopeHint' : 'terminal.browser.allScopeHint')}</span></span>
<span className={cn('grid h-4 w-4 shrink-0 place-items-center rounded-full border', selected ? 'border-primary bg-primary text-primary-foreground' : 'border-muted-foreground/40')}>{selected && <Check size={10} />}</span>
</label>;
})}
</fieldset>
</AsidePanelContent>
<AsidePanelFooter className="flex justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={onCancel}>{t('common.cancel')}</Button>
<Button type="submit" size="sm" className="min-w-24" disabled={saving || invalidIcon}>{saving && <Loader2 size={13} className="mr-1.5 animate-spin" />}{t(initial.id ? 'common.save' : 'terminal.browser.addBookmark')}</Button>
</AsidePanelFooter>
</form>;
}
@@ -0,0 +1,41 @@
import React from 'react';
import { File, FolderOpen, MoreHorizontal, X } from 'lucide-react';
import type { HostBrowserDownload } from '../../domain/hostBrowser';
import type { useHostBrowserControls } from '../../application/state/useHostBrowserControls';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
function size(bytes: number) {
const units = ['B', 'KB', 'MB', 'GB'];
const index = bytes > 0 ? Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024))) : 0;
return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
}
export function BrowserDownloads({ items, command }: { items: HostBrowserDownload[]; command: ReturnType<typeof useHostBrowserControls>['downloadCommand'] }) {
const { t } = useI18n();
return <div className="min-w-0">
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2"><div className="min-w-0"><p className="text-xs font-medium">{t('terminal.browser.downloads')}</p><p className="mt-1 text-[11px] text-muted-foreground">{t('terminal.browser.downloadLocation')}</p></div><Button size="icon" variant="ghost" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.openFolder')} title={t('terminal.browser.openFolder')} onClick={() => void command('open-folder')}><FolderOpen size={14} /></Button></div>
<div className="max-h-80 overflow-y-auto p-1">
{!items.length && <p className="px-3 py-8 text-center text-xs text-muted-foreground">{t('terminal.browser.downloadEmpty')}</p>}
{items.map((item) => {
const running = item.status === 'waiting' || item.status === 'progressing';
const canOpen = item.status === 'completed' && !item.missing;
return <div key={item.id} data-download-id={item.id} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-2 hover:bg-secondary/50">
<File size={16} className="shrink-0 text-muted-foreground" /><div className="min-w-0 flex-1"><p className="truncate text-xs" title={item.filename}>{item.filename}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{t(`terminal.browser.${item.missing ? 'missing' : item.status}`)}{item.status === 'progressing' ? ` · ${size(item.received)}${item.total ? ` / ${size(item.total)}` : ''}` : item.status === 'completed' ? ` · ${size(item.total || item.received)}` : ''}</p>
{item.status === 'progressing' && <div className="mt-1 h-1 overflow-hidden rounded bg-secondary"><div className={`h-full bg-primary${item.total ? '' : ' animate-pulse'}`} style={{ width: `${item.total ? Math.min(100, item.received * 100 / item.total) : 20}%` }} /></div>}</div>
{running ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.cancelDownload')} title={t('terminal.browser.cancelDownload')} onClick={() => void command('cancel', item.id)}><X size={13} /></Button>
: canOpen ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" title={t('terminal.browser.reveal')} onClick={() => void command('reveal', item.id)}><FolderOpen size={13} /></Button> : null}
<Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.more')}><MoreHorizontal size={13} /></Button></PopoverTrigger><PopoverContent align="end" className="w-40 p-1">
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('open', item.id)}>{t('terminal.browser.openFile')}</Button>}
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('save-as', item.id)}>{t('terminal.browser.saveAs')}</Button>}
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('reveal', item.id)}>{t('terminal.browser.reveal')}</Button>}
{!running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('remove', item.id)}>{t('terminal.browser.removeDownload')}</Button>}
{running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('cancel', item.id)}>{t('terminal.browser.cancelDownload')}</Button>}
</PopoverContent></Popover>
</div>;
})}
</div>
{items.some((item) => !['waiting', 'progressing'].includes(item.status)) && <div className="border-t border-border/50 p-1"><Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => void command('clear')}>{t('terminal.browser.clearDownloads')}</Button></div>}
</div>;
}
+12
View File
@@ -0,0 +1,12 @@
import React from 'react';
import { Globe } from 'lucide-react';
export function BrowserIcon({ image, glyph, color, className = '' }: {
image?: string | null; glyph?: string | null; color?: string | null; className?: string;
}) {
return <span className={`inline-flex shrink-0 items-center justify-center overflow-hidden ${className}`}>
{image ? <img src={image} alt="" className="h-full w-full rounded-[20%] object-contain" draggable={false} />
: glyph ? <span className="truncate text-xs" style={{ color: color || undefined }}>{glyph}</span>
: <Globe size={14} className="text-muted-foreground" />}
</span>;
}
@@ -0,0 +1,103 @@
import React, { useEffect, useId, useRef, useState } from 'react';
import { Check, Globe, ImagePlus, Loader2, Pipette, Type, Upload, X } from 'lucide-react';
import { HostBrowserIconError } from '../../domain/hostBrowser';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
export interface BrowserIconSelection {
mode: 'website' | 'text' | 'image';
glyph: string;
color: string;
image: string | null;
}
const COLORS = ['#247969', '#2563eb', '#7c3aed', '#c026d3', '#e11d48', '#ea580c', '#ca8a04', '#64748b'];
const MODES = [{ value: 'website', Icon: Globe }, { value: 'text', Icon: Type }, { value: 'image', Icon: ImagePlus }] as const;
export function BrowserIconEditor({ value, onChange, readIcon, onBusyChange, allowWebsite = true }: {
value: BrowserIconSelection;
onChange: (changes: Partial<BrowserIconSelection>) => void;
readIcon: (file: File) => Promise<string | null>;
onBusyChange: (busy: boolean) => void;
allowWebsite?: boolean;
}) {
const { t } = useI18n();
const inputId = useId();
const fileInput = useRef<HTMLInputElement>(null);
const readRevision = useRef(0);
const [busy, setBusy] = useState(false);
const [dragging, setDragging] = useState(false);
const [filename, setFilename] = useState('');
const [error, setError] = useState<string | null>(null);
useEffect(() => () => { readRevision.current++; }, []);
const selectFile = async (file?: File) => {
if (!file) return;
const revision = ++readRevision.current;
setError(null); setBusy(true); onBusyChange(true);
try {
const image = await readIcon(file);
if (revision !== readRevision.current) return;
if (!image) throw new HostBrowserIconError('decode');
onChange({ image }); setFilename(file.name);
} catch (reason) {
if (revision === readRevision.current) {
const key = reason instanceof HostBrowserIconError ? reason.reason : 'decode';
setError(t(`terminal.browser.iconError.${key}`));
}
} finally {
if (revision === readRevision.current) { setBusy(false); onBusyChange(false); }
}
};
return <div className="space-y-3" data-section="browser-icon-editor">
<div className="grid grid-flow-col auto-cols-fr gap-1 rounded-md bg-muted/50 p-1" role="group" aria-label={t('terminal.browser.icon')}>
{MODES.filter((mode) => allowWebsite || mode.value !== 'website').map(({ value: mode, Icon }) => <button
key={mode} type="button" aria-pressed={value.mode === mode} disabled={busy}
className={cn('flex min-w-0 items-center justify-center gap-1.5 rounded px-2 py-1.5 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50',
value.mode === mode ? 'bg-background text-foreground shadow-sm' : 'text-muted-foreground hover:text-foreground')}
onClick={() => { onChange({ mode }); setError(null); }}>
<Icon size={13} className="shrink-0" /><span className="truncate">{t(`terminal.browser.${mode}`)}</span>
</button>)}
</div>
{value.mode === 'website' && <p className="text-xs leading-relaxed text-muted-foreground">{t('terminal.browser.websiteIconHint')}</p>}
{value.mode === 'text' && <div className="space-y-3">
<div className="flex items-center gap-3">
<Input id={inputId} aria-label={t('terminal.browser.iconText')} className="h-9 w-20 shrink-0 text-center text-base" maxLength={4} value={value.glyph}
onChange={(event) => onChange({ glyph: event.target.value })} placeholder="A" />
<Label htmlFor={inputId} className="text-xs font-normal leading-relaxed text-muted-foreground">{t('terminal.browser.iconTextHint')}</Label>
</div>
<div className="flex flex-wrap items-center gap-2" role="group" aria-label={t('terminal.browser.color')}>
{COLORS.map((color) => <button key={color} type="button" aria-label={t('terminal.browser.selectColor', { color })} aria-pressed={value.color.toLowerCase() === color}
className={cn('grid h-6 w-6 place-items-center rounded-full border-2 border-background transition-shadow focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', value.color.toLowerCase() === color && 'ring-1 ring-foreground/40')}
style={{ backgroundColor: color }} onClick={() => onChange({ color })}>
{value.color.toLowerCase() === color && <Check size={12} className="text-white" />}
</button>)}
<label className="relative grid h-6 w-6 cursor-pointer place-items-center rounded-full border border-border text-muted-foreground hover:text-foreground focus-within:ring-1 focus-within:ring-ring" title={t('terminal.browser.customColor')}>
<Pipette size={12} /><input aria-label={t('terminal.browser.customColor')} type="color" className="absolute inset-0 h-full w-full cursor-pointer opacity-0" value={value.color} onChange={(event) => onChange({ color: event.target.value })} />
</label>
</div>
</div>}
{value.mode === 'image' && <div className="space-y-2" onDragOver={(event) => { event.preventDefault(); setDragging(true); }} onDragLeave={(event) => { if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setDragging(false); }}
onDrop={(event) => { event.preventDefault(); setDragging(false); if (!busy) void selectFile(event.dataTransfer.files[0]); }}>
<input ref={fileInput} type="file" className="hidden" aria-label={t('terminal.browser.chooseImage')}
accept="image/png,image/jpeg,image/webp,image/gif,image/x-icon,image/vnd.microsoft.icon"
onChange={(event) => { void selectFile(event.target.files?.[0]); event.target.value = ''; }} />
{value.image ? <div className={cn('flex min-w-0 items-center gap-3 rounded-lg border border-border/60 bg-muted/20 p-3', dragging && 'border-primary bg-primary/5')}>
<div className="grid h-12 w-12 shrink-0 place-items-center rounded-md border border-border/40 bg-background p-1.5"><img src={value.image} alt={t('terminal.browser.iconPreview')} className="h-full w-full rounded-[20%] object-contain" /></div>
<div className="min-w-0 flex-1"><p className="truncate text-xs font-medium" title={filename}>{filename || t('terminal.browser.savedImage')}</p><Button type="button" variant="link" size="sm" className="h-auto px-0 py-1 text-xs" disabled={busy} onClick={() => fileInput.current?.click()}>{busy ? t('terminal.browser.processingImage') : t('terminal.browser.replaceImage')}</Button></div>
<Button type="button" variant="ghost" size="icon" className="h-7 w-7 shrink-0 text-muted-foreground" disabled={busy} aria-label={t('terminal.browser.removeImage')} onClick={() => { onChange({ image: null }); setFilename(''); setError(null); }}><X size={14} /></Button>
</div> : <button type="button" disabled={busy} className={cn('flex w-full flex-col items-center gap-2 rounded-lg border border-dashed border-border bg-muted/15 px-4 py-5 text-center transition-colors hover:border-primary/50 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring', dragging && 'border-primary bg-primary/5')}
onClick={() => fileInput.current?.click()}>
<span className="grid h-8 w-8 place-items-center rounded-full bg-secondary/70 text-muted-foreground">{busy ? <Loader2 size={16} className="animate-spin" /> : <Upload size={16} />}</span>
<span className="text-xs font-medium">{t(busy ? 'terminal.browser.processingImage' : 'terminal.browser.uploadImage')}</span>
<span className="text-[11px] leading-relaxed text-muted-foreground">{t('terminal.browser.dropImage')}</span>
</button>}
<p className="text-[11px] leading-relaxed text-muted-foreground">{t('terminal.browser.imageHint')}</p>
</div>}
{error && <p role="alert" className="text-xs leading-relaxed text-destructive">{error}</p>}
</div>;
}
+65
View File
@@ -0,0 +1,65 @@
import type { BrowserBookmark } from './browserBookmark';
export class HostBrowserIconError extends Error {
constructor(public readonly reason: 'file-type' | 'file-size' | 'decode') {
super(reason);
this.name = 'HostBrowserIconError';
}
}
export interface HostBrowserTab {
id: string;
url: string;
title: string;
loading: boolean;
canGoBack: boolean;
canGoForward: boolean;
iconDataUrl: string | null;
iconGlyph: string | null;
iconColor: string | null;
}
export interface HostBrowserState {
hostId: string;
label: string;
route: 'connecting' | 'connected' | 'disconnected';
error: string | null;
activeTabId: string | null;
tabs: HostBrowserTab[];
}
export interface HostBrowserDownload {
id: string;
hostId: string;
filename: string;
url: string;
savePath: string;
status: 'waiting' | 'progressing' | 'completed' | 'cancelled' | 'interrupted';
received: number;
total: number;
startedAt: number;
endedAt: number | null;
missing: boolean;
}
export type HostBrowserBookmark = BrowserBookmark & {
siteIconDataUrl?: string | null;
siteIconGlyph?: string | null;
siteIconColor?: string | null;
};
export type HostBrowserCommand =
| { type: 'new-tab'; url?: string }
| { type: 'navigate'; url: string; tabId?: string }
| { type: 'activate' | 'close-tab' | 'back' | 'forward' | 'reload' | 'stop' | 'reset-icon'; tabId?: string }
| { type: 'reorder'; tabId: string; beforeTabId: string }
| { type: 'set-icon'; tabId: string; dataUrl?: string | null; glyph: string; color: string }
| { type: 'retry' };
export type HostBrowserShortcut = { type: 'focus-address' | 'new-tab' | 'close-tab' | 'reload'; tabId: string };
export type HostBrowserPanelEvent = { browserId: string } & (
| { type: 'state'; state: HostBrowserState }
| { type: 'downloads'; downloads: HostBrowserDownload[]; startedId?: string }
| { type: 'bookmarks' }
| { type: 'shortcut'; shortcut: HostBrowserShortcut }
);
+33
View File
@@ -0,0 +1,33 @@
"use strict";
const fs = require("node:fs");
const path = require("node:path");
// Reserve the name synchronously so simultaneous downloads cannot overwrite
// each other or a file already present in the user's Downloads directory.
function reserveDownloadFile(directory, suggestedName) {
fs.mkdirSync(directory, { recursive: true });
const basename = path.basename(String(suggestedName || "download").replace(/\\/g, "/"));
const filename = !basename || basename === "." || basename === ".." ? "download" : basename;
const extension = path.extname(filename);
const stem = filename.slice(0, filename.length - extension.length);
for (let suffix = 0; ; suffix++) {
const savePath = path.join(directory, suffix ? `${stem} (${suffix})${extension}` : filename);
let descriptor;
try { descriptor = fs.openSync(savePath, "wx", 0o600); }
catch (error) { if (error.code === "EEXIST") continue; throw error; }
const reserved = fs.fstatSync(descriptor);
fs.closeSync(descriptor);
return {
savePath,
cleanupEmpty() {
try {
const current = fs.lstatSync(savePath);
if (current.isFile() && current.size === 0 && current.ino === reserved.ino && current.dev === reserved.dev) fs.unlinkSync(savePath);
} catch { /* Chromium may already have removed a cancelled download. */ }
},
};
}
}
module.exports = { reserveDownloadFile };
+32 -2
View File
@@ -2,13 +2,14 @@
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");
const { reserveDownloadFile } = require("./downloadFile.cjs");
const { getTempFilePath } = require("../bridges/tempDirBridge.cjs");
test("download history stays local to its host and survives restart", () => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "host-browser-downloads-"));
const directory = fs.mkdtempSync(getTempFilePath("host-browser-downloads-"));
try {
const app = { getPath: () => directory };
const file = path.join(directory, "download.bin");
@@ -28,3 +29,32 @@ test("download history stays local to its host and survives restart", () => {
assert.equal(restored.list("host-b").length, 1);
} finally { fs.rmSync(directory, { recursive: true, force: true }); }
});
test("automatic downloads reserve separate names without replacing existing files", (t) => {
const directory = fs.mkdtempSync(getTempFilePath("host-browser-download-names-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
fs.writeFileSync(path.join(directory, "report.txt"), "existing file");
fs.mkdirSync(path.join(directory, "report (1).txt"));
const first = reserveDownloadFile(directory, "report.txt");
const second = reserveDownloadFile(directory, "report.txt");
assert.equal(path.basename(first.savePath), "report (2).txt");
assert.equal(path.basename(second.savePath), "report (3).txt");
assert.equal(fs.readFileSync(path.join(directory, "report.txt"), "utf8"), "existing file");
first.cleanupEmpty();
assert.equal(fs.existsSync(first.savePath), false);
fs.writeFileSync(second.savePath, "downloaded content");
second.cleanupEmpty();
assert.equal(fs.readFileSync(second.savePath, "utf8"), "downloaded content");
});
test("cleanup removes only its own empty reservation", (t) => {
const directory = fs.mkdtempSync(getTempFilePath("host-browser-download-cleanup-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
const reserved = reserveDownloadFile(directory, "../report.txt");
assert.equal(reserved.savePath, path.join(directory, "report.txt"));
const otherFile = path.join(directory, "replacement");
fs.writeFileSync(otherFile, "");
fs.renameSync(otherFile, reserved.savePath);
reserved.cleanupEmpty();
assert.equal(fs.existsSync(reserved.savePath), true, "a replacement made by someone else is left alone");
});
+2 -1
View File
@@ -39,7 +39,7 @@ function size(bytes) {
function statusText(item) {
if (item.missing) return "File moved or deleted";
if (item.status === "waiting") return "Choose where to save\u2026";
if (item.status === "waiting") return "Preparing download\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`;
@@ -112,6 +112,7 @@ function render(downloads) {
actionMenu.append(choice);
};
if (item.status === "completed" && !item.missing) addMenuAction("Open file", "open");
if (item.status === "completed" && !item.missing) addMenuAction("Save as\u2026", "save-as");
if (!["waiting", "progressing"].includes(item.status)) addMenuAction("Remove from list", "remove");
if (actionMenu.childElementCount) actions.append(more);
row.append(file, detail, actions, actionMenu);
@@ -0,0 +1,2 @@
<!doctype html>
<html><head><meta charset="utf-8"><title>Browser renderer acceptance</title></head><body><div id="fixture"></div><script type="module" src="./renderer.tsx"></script></body></html>
@@ -0,0 +1,35 @@
// Real renderer controls with isolated desktop services, used by the Electron acceptance test.
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import { HostBrowserSidePanel } from '../../../components/HostBrowserSidePanel';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '../../../components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '../../../components/ui/popover';
import type { TerminalSettings } from '../../../domain/models';
import '../../../index.css';
for (const [name, value] of Object.entries({ background: '210 25% 18%', foreground: '210 20% 90%', secondary: '210 20% 24%', muted: '210 20% 22%', 'muted-foreground': '210 15% 65%', border: '210 20% 30%', primary: '170 70% 42%', 'primary-foreground': '210 20% 12%', popover: '210 25% 20%' })) document.documentElement.style.setProperty(`--${name}`, value);
document.documentElement.classList.add('dark');
document.documentElement.style.setProperty('--radius', '8px');
document.documentElement.style.setProperty('--ring', '170 70% 42%');
document.documentElement.style.setProperty('--input', '210 20% 30%');
document.documentElement.style.setProperty('--primary-foreground', '210 20% 12%');
document.documentElement.style.setProperty('--destructive', '0 70% 60%');
function Fixture() {
const [visible, setVisible] = useState(true);
const [locale, setLocale] = useState('zh-CN');
return <I18nProvider locale={locale}><TooltipProvider delayDuration={100}>
<div className="flex h-screen flex-col bg-background p-3 text-foreground">
<div className="flex h-10 shrink-0 items-center gap-3">
{['上下分屏', '左右分屏'].map((label, index) => <Popover key={label}><Tooltip><TooltipTrigger asChild><PopoverTrigger asChild><button id={`split-${index}`} className="rounded px-2 py-1 text-xs hover:bg-secondary">{label}</button></PopoverTrigger></TooltipTrigger><TooltipContent side="bottom">{label}</TooltipContent></Tooltip><PopoverContent align="start" className="w-48 p-1">{Array.from({ length: 8 }, (_, item) => <button key={item} className="block w-full rounded px-2 py-2 text-left text-xs hover:bg-secondary" id={`choice-${index}-${item}`} onClick={(event) => { event.currentTarget.dataset.chosen = 'true'; }}>Panel {item + 1}</button>)}</PopoverContent></Popover>)}
<button id="visibility" className="text-xs" onClick={() => setVisible((value) => !value)}>Toggle panel</button>
<select aria-label="Locale" value={locale} onChange={(event) => setLocale(event.target.value)}>{['zh-CN', 'en', 'zh-TW', 'es', 'ru'].map((value) => <option key={value}>{value}</option>)}</select>
</div>
<div className="min-h-0 flex-1 overflow-hidden rounded-md border border-border/50">
<HostBrowserSidePanel browserId="local:renderer-acceptance" host={{ id: 'local', protocol: 'local', label: 'Local browser', hostname: '', username: '', tags: [], os: 'macos' }} hosts={[]} keys={[]} identities={[]} knownHosts={[]} terminalSettings={{} as TerminalSettings} isVisible={visible} />
</div>
</div>
</TooltipProvider></I18nProvider>;
}
createRoot(document.getElementById('fixture')!).render(<React.StrictMode><Fixture /></React.StrictMode>);
@@ -62,7 +62,7 @@ async function main() {
const globals = { ...React, Map, Promise, Object, crypto: require("node:crypto").webcrypto,
setTimeout, clearTimeout, queueMicrotask,
requestAnimationFrame: () => 1, cancelAnimationFrame: () => {},
document: { querySelector: () => null, documentElement: { classList: { contains: () => false } } },
document: { querySelector: () => null, querySelectorAll: () => [], documentElement: { classList: { contains: () => false } } },
getComputedStyle: () => style, netcattyBridge: { get: () => bridge }, isValidHslToken: () => false,
};
const { mountHostBrowser, currentBrowserThemeTokens } = loadModule("application/hostBrowser.ts", globals);
@@ -83,19 +83,19 @@ async function main() {
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");
const snapshot = manager.getPanelState(event, browserId);
assert.equal(snapshot.tabs[0].url, url, "the first click must 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;
loadedTitle = manager.getPanelState(event, browserId).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 root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView
&& child.webContents.getURL() === url));
assert.ok(root, "the first click must open the browser pane");
assert.equal(root.children.length, 1, "only the website has a native view; controls belong to the app renderer");
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');
+12 -11
View File
@@ -3,7 +3,7 @@
// 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 tempDirBridge = require("../bridges/tempDirBridge.cjs");
const path = require("node:path");
const fs = require("node:fs");
const { app, BrowserWindow, WebContentsView, session, ipcMain } = require("electron");
@@ -77,7 +77,10 @@ function makeSocksServer() {
}
async function main() {
app.setPath("userData", path.join(os.tmpdir(), `netcatty-host-browser-live-${process.pid}`));
const profile = fs.mkdtempSync(tempDirBridge.getTempFilePath("browser-standalone-"));
app.setPath("userData", profile);
fs.mkdirSync(path.join(profile, "downloads"));
app.setPath("downloads", path.join(profile, "downloads"));
const socks = makeSocksServer();
await new Promise((resolve) => socks.listen(0, "127.0.0.1", resolve));
const port = socks.address().port;
@@ -137,7 +140,7 @@ async function main() {
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");
const formScreenshot = path.join(profile, "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()");
@@ -155,11 +158,9 @@ async function main() {
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 downloadsScreenshot = await captureView(downloadsView, path.join(profile, "netcatty-host-browser-downloads.png"));
const downloadPath = path.join(app.getPath("downloads"), "host-browser-test.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];
@@ -182,7 +183,7 @@ async function main() {
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"));
const downloadedScreenshot = await captureView(downloadsView, path.join(profile, "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);
@@ -222,7 +223,7 @@ async function main() {
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");
const screenshot = path.join(profile, "netcatty-host-browser-live.png");
require("node:fs").writeFileSync(screenshot, (await page.capturePage()).toPNG());
browser.setSize(720, 500);
await wait(100);
@@ -234,12 +235,12 @@ async function main() {
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");
const lightScreenshot = path.join(profile, "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"));
const lightDownloadsScreenshot = await captureView(downloadsView, path.join(profile, "netcatty-host-browser-downloads-light.png"));
await downloadsView.webContents.executeJavaScript("document.getElementById('close').click()");
assert.equal(browser.isMinimizable(), true);
assert.equal(browser.isMaximizable(), true);
@@ -6,7 +6,8 @@ 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 fs = require("node:fs");
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
const { Server } = require("ssh2");
const electron = require("electron");
const { app, BrowserWindow, WebContentsView } = electron;
@@ -62,141 +63,118 @@ async function fixture(label) {
}
async function main() {
app.setPath("userData", path.join(os.tmpdir(), `netcatty-browser-panel-${process.pid}`));
const profile = fs.mkdtempSync(tempDirBridge.getTempFilePath("browser-panel-"));
app.setPath("userData", profile);
fs.mkdirSync(path.join(profile, "downloads"));
app.setPath("downloads", path.join(profile, "downloads"));
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 handlers = new Map();
const manager = createHostBrowserManager({ electron: { ...electron, ipcMain: {
handle(name, handler) { handlers.set(name, handler); electron.ipcMain.handle(name, handler); },
} }, app, portForwardingBridge });
const event = { sender: owner.webContents };
const bounds = { x: 680, y: 50, width: 400, height: 650 };
const bounds = { x: 680, y: 110, width: 400, height: 590 };
const layout = (browserId, visible, nextBounds = bounds) => manager.updatePanelLayout(event, { browserId, visible, bounds: nextBounds });
const state = (id) => manager.getPanelState(event, id);
const command = (id, input) => manager.runCommand(event, input, id);
const call = (name, ...args) => handlers.get(`netcatty:host-browser:${name}`)(event, ...args);
const rootFor = (url) => owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView && child.webContents.getURL() === url));
const open = async (id, host) => {
manager.openPanelUrl(event, { browserId: id, url: host.url });
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")));
await waitFor(() => state(id).tabs[0].title, host.options.hostId);
return rootFor(host.url);
};
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);
const idA = "workspace:session-a";
const rootA = await open(idA, a);
assert.equal(rootA.children.length, 1, "embedded browser chrome must be part of the main renderer");
const pageA = rootA.children[0];
const pageContentsA = pageA.webContents;
assert.equal(pageA.getBounds().y, 0, "the native view contains only the webpage viewport");
await pageA.webContents.executeJavaScript("window.overlayProbe = 'keep this page'");
const captured = await manager.updatePanelLayout(event, { browserId: idA, visible: true, occluded: true, bounds });
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);
assert.equal(captured.layers.length, 1);
assert.equal(electron.nativeImage.createFromDataURL(captured.layers[0].image).isEmpty(), false);
layout(idA, true);
assert.equal(await pageA.webContents.executeJavaScript("window.overlayProbe"), "keep this page");
let finishCapture;
const originalCapture = pageA.webContents.capturePage.bind(pageA.webContents);
pageA.webContents.capturePage = async () => { await new Promise((resolve) => { finishCapture = resolve; }); return originalCapture(); };
const late = manager.updatePanelLayout(event, { browserId: idA, visible: true, occluded: true, bounds });
assert.ok(finishCapture);
layout(idA, true); finishCapture();
assert.equal(await late, false);
assert.equal(owner.contentView.children.includes(rootA), true);
pageA.webContents.capturePage = originalCapture;
layout(idA, false);
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");
assert.equal(BrowserWindow.getAllWindows().length, 1);
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");
layout(idA, true, { x: 20, y: 100, width: 350, height: 500 });
assert.equal(owner.contentView.children.includes(rootB), false);
assert.equal(state(idA).tabs[0].title, "Host A");
for (let index = 0; index < 6; index++) await command(idA, { type: "new-tab" });
assert.equal(state(idA).tabs.length, 7);
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/);
assert.throws(() => manager.getPanelState({ sender: otherOwner.webContents }, idA), /Untrusted/);
assert.throws(() => manager.getPanelState({ sender: pageA.webContents }, idA), /Untrusted/);
assert.throws(() => manager.updatePanelLayout({ sender: otherOwner.webContents }, { browserId: idA, visible: false }), /another terminal window/);
assert.throws(() => manager.openPanelUrl(event, { browserId: idA, 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 bookmark = call("bookmarks:upsert", { title: "Remote app", url: a.url, scope: "host" }, idA);
assert.ok(call("bookmarks:list", idA).some((item) => item.id === bookmark.id));
assert.equal(call("bookmarks:list", "workspace:session-b").some((item) => item.id === bookmark.id), false);
await assert.rejects(command(idA, { type: "navigate", url: "file:///etc/passwd" }), /HTTP and HTTPS/);
manager.closePanel(event, idA);
await waitFor(() => pageContentsA.isDestroyed(), true);
assert.equal(owner.webContents.isDestroyed(), false, "closing a browser must never close its main renderer");
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>');
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>');
});
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);
const localUrl = `http://127.0.0.1:${localSite.address().port}/`;
manager.openPanelUrl(event, { browserId: 'local:session', url: localUrl });
layout('local:session', true);
await manager.open(event, { browserId: 'local:session', embedded: true, local: true, hostId: 'local', label: 'Local', theme: 'light' });
await waitFor(() => state('local:session').tabs[0].title, 'Local browser');
assert.equal(state('local:session').tabs.length, 1);
const localPage = rootFor(localUrl).children[0];
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;
await manager.open(event, { browserId: 'local:peer', embedded: true, local: true, hostId: 'local', label: 'Peer', theme: 'light' });
let download;
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);
download = undefined;
localPage.webContents.session.once('will-download', (_event, item) => { download = item; });
localPage.webContents.downloadURL(`${localUrl}slow.bin`);
await waitFor(() => Boolean(download), true);
};
await startDownload();
const items = call('downloads:list', 'local:peer');
assert.ok(items.some((item) => item.status === 'progressing' || item.status === 'waiting'));
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))");
download.once('done', (_event, status) => { cancelled = status === 'cancelled'; });
await call('downloads:command', { type: 'cancel', id: items[0].id }, 'local:peer');
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'; });
await startDownload(); cancelled = false;
download.once('done', (_event, 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.equal(owner.webContents.isDestroyed(), false);
manager.closeAll(); localSite.closeAllConnections(); localSite.close();
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 }));
owner.destroy(); a.close(); b.close();
console.log(JSON.stringify({ ok: true, rendererChrome: true, hostSwitching: true, pageRetention: true, scopedControls: true, downloads: true }));
app.quit();
}
app.whenReady().then(main).catch((error) => { console.error(error); app.exit(1); });
@@ -0,0 +1,101 @@
"use strict";
// Run: electron electron/hostBrowser/hostBrowser.pdf.live.test.cjs
const assert = require("node:assert/strict");
const fs = require("node:fs");
const http = require("node:http");
const path = require("node:path");
const electron = require("electron");
const { app, BrowserWindow, WebContentsView } = electron;
const tempDirBridge = require("../bridges/tempDirBridge.cjs");
const { createHostBrowserManager } = require("./hostBrowserManager.cjs");
const portForwardingBridge = require("../bridges/portForwardingBridge.cjs");
// A real, valid PDF whose body can be held open independently of its headers.
function pdf() {
let source = "%PDF-1.4\n";
const offsets = [0];
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>",
"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 600 400] >>",
];
for (const [index, object] of objects.entries()) {
offsets.push(Buffer.byteLength(source));
source += `${index + 1} 0 obj\n${object}\nendobj\n`;
}
source += `%${"padding ".repeat(1024 * 128)}\n`;
const start = Buffer.byteLength(source);
source += `xref\n0 4\n0000000000 65535 f \n${offsets.slice(1).map((offset) => `${String(offset).padStart(10, "0")} 00000 n \n`).join("")}trailer\n<< /Size 4 /Root 1 0 R >>\nstartxref\n${start}\n%%EOF\n`;
return Buffer.from(source);
}
const profile = fs.mkdtempSync(tempDirBridge.getTempFilePath("browser-pdf-"));
app.setPath("userData", profile);
fs.mkdirSync(path.join(profile, "downloads"));
app.setPath("downloads", path.join(profile, "downloads"));
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function until(read, label) {
for (let index = 0; index < 120; index++) { if (await read()) return; await wait(25); }
assert.ok(await read(), label);
}
let owner, manager, site;
const watchdog = setTimeout(() => app.exit(1), 20000);
async function main() {
const body = process.env.NETCATTY_PDF_FIXTURE ? fs.readFileSync(process.env.NETCATTY_PDF_FIXTURE) : pdf();
let pending, sent = 0;
site = http.createServer((request, response) => {
if (request.url === "/failed") { request.socket.destroy(); return; }
if (request.url === "/redirect") { response.writeHead(302, { location: "/slow.pdf" }); response.end(); return; }
if (request.url === "/slow.pdf") {
response.writeHead(200, { "content-type": "application/pdf;charset=UTF-8", "content-length": body.length });
response.write(body.subarray(0, 65536)); sent = 65536; pending = response;
return;
}
response.end('<title>PDF download fixture</title><a href="/slow.pdf">PDF</a>');
});
await new Promise((resolve) => site.listen(0, "127.0.0.1", resolve));
const url = `http://127.0.0.1:${site.address().port}/`;
owner = new BrowserWindow({ width: 900, height: 700, show: true });
await owner.loadURL("data:text/html,<h1>PDF download acceptance</h1>");
const handlers = new Map();
manager = createHostBrowserManager({ electron: { ...electron, ipcMain: { handle(name, handler) { handlers.set(name, handler); } } }, app, portForwardingBridge });
const event = { sender: owner.webContents }, browserId = "local:pdf";
manager.openPanelUrl(event, { browserId, url });
manager.updatePanelLayout(event, { browserId, visible: true, bounds: { x: 0, y: 80, width: 900, height: 600 } });
await manager.open(event, { browserId, embedded: true, local: true, hostId: "local", label: "Local", theme: "light" });
await until(() => manager.getPanelState(event, browserId).tabs[0].title === "PDF download fixture", "fixture loaded");
const root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView));
const page = root.children[0].webContents;
const downloads = () => handlers.get("netcatty:host-browser:downloads:list")(event, browserId);
for (const scenario of ["link", "redirect", "iframe", "address"]) {
pending = undefined;
const previous = new Set(downloads().map((item) => item.id));
const current = () => downloads().find((item) => !previous.has(item.id));
let navigation;
if (scenario === "address") {
navigation = manager.runCommand(event, { type: "navigate", url: `${url}slow.pdf` }, browserId)
.then(() => null, (error) => error);
} else await page.executeJavaScript(`(() => {
const element=document.createElement(${JSON.stringify(scenario === "iframe" ? "iframe" : "a")});
const url=${JSON.stringify(scenario === "redirect" ? "/redirect" : "/slow.pdf")};
if(element.tagName==='IFRAME') element.src=url; else element.href=url;
document.body.append(element); if(element.tagName==='A') element.click();
})()`);
await until(() => pending, "PDF headers and first bytes were sent");
await wait(1000);
console.log(JSON.stringify({ scenario, beforeBodyFinished: true, sent, total: body.length, download: current() }));
assert.ok(current()?.status === "progressing" && current().received < body.length, "inline PDF must appear in downloads before its response finishes");
assert.ok(current().received > 0, "PDF bytes arrive before the response finishes");
if (navigation) assert.equal(await navigation, null, "a PDF download is a successful address action, not a page error");
pending.end(body.subarray(sent));
await until(() => current()?.status === "completed", "PDF completes");
assert.deepEqual(fs.readFileSync(current().savePath), body, "saved PDF matches original bytes");
}
await assert.rejects(manager.runCommand(event, { type: "navigate", url: `${url}failed` }, browserId), /ERR_/, "real navigation failures still surface");
assert.equal(page.session.listenerCount("will-download"), 1, "download handling keeps a single session listener");
console.log(JSON.stringify({ ok: true, earlyPdfProgress: true, redirect: true, iframe: true, addressDownload: true, realErrorsPreserved: true, profile }));
}
app.whenReady().then(main).catch((error) => { console.error(error); process.exitCode = 1; }).finally(() => {
clearTimeout(watchdog); manager?.closeAll(); owner?.destroy(); site?.closeAllConnections(); site?.close(); app.exit(process.exitCode || 0);
});
@@ -0,0 +1,301 @@
"use strict";
// Real React chrome, production preload and native page manager in an isolated profile.
// Run: electron electron/hostBrowser/hostBrowser.renderer.live.test.cjs
const assert = require('node:assert/strict');
const fs = require('node:fs');
const http = require('node:http');
const path = require('node:path');
const { app, BrowserWindow, WebContentsView, ipcMain } = require('electron');
const electron = require('electron');
const { createHostBrowserManager } = require('./hostBrowserManager.cjs');
const portForwardingBridge = require('../bridges/portForwardingBridge.cjs');
const tempDirBridge = require('../bridges/tempDirBridge.cjs');
const repo = path.join(__dirname, '../..');
const userData = fs.mkdtempSync(tempDirBridge.getTempFilePath('browser-renderer-'));
app.setPath('userData', userData);
const downloadsPath = path.join(userData, 'downloads');
fs.mkdirSync(downloadsPath);
app.setPath('downloads', downloadsPath);
let owner, manager, vite, site;
let pendingPdf;
let pendingNavigation;
const pdfBody = Buffer.concat([Buffer.from('%PDF-1.4\n% streamed inline PDF\n'), Buffer.alloc(1048576, 32)]);
const savedCopy = path.join(userData, 'saved-copy.txt');
const shellActions = [];
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
async function until(read, expected, label) {
for (let index = 0; index < 150; index++) { if (await read() === expected) return; await wait(30); }
assert.equal(await read(), expected, label);
}
const run = (code) => owner.webContents.executeJavaScript(code).catch((error) => { throw new Error(`${error.message}\nWhile executing: ${code}`); });
async function capture(file, selector) {
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const bounds = selector ? await run(`(() => { const r=document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect();return {x:Math.round(r.x),y:Math.round(r.y),width:Math.round(r.width),height:Math.round(r.height)};})()`) : undefined;
fs.writeFileSync(file, (await owner.webContents.capturePage(bounds)).toPNG());
}
async function click(selector) {
const rect = await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); element?.scrollIntoView({block:'nearest'}); const r = element?.getBoundingClientRect(); return r && {x:r.x+r.width/2,y:r.y+r.height/2}; })()`);
assert.ok(rect, `missing ${selector}`);
for (const type of ['mouseDown', 'mouseUp']) owner.webContents.sendInputEvent({ type, button: 'left', clickCount: 1, x: Math.round(rect.x), y: Math.round(rect.y) });
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
}
async function input(selector, value) {
await until(() => run(`!!document.querySelector(${JSON.stringify(selector)})`), true, `input exists: ${selector}`);
await run(`(() => { const element = document.querySelector(${JSON.stringify(selector)}); Object.getOwnPropertyDescriptor(HTMLInputElement.prototype,'value').set.call(element,${JSON.stringify(value)}); element.dispatchEvent(new Event('input',{bubbles:true})); })()`);
}
const watchdog = setTimeout(() => { console.error('Browser renderer acceptance timed out'); app.exit(1); }, 90000);
async function main() {
const { createServer } = await import('vite');
vite = await createServer({ root: repo, server: { host: '127.0.0.1', port: 0, strictPort: false }, clearScreen: false, logLevel: 'error' });
await vite.listen();
const base = `http://127.0.0.1:${vite.httpServer.address().port}`;
site = http.createServer((request, response) => {
if (request.url === '/slow-page') {
response.writeHead(200, { 'content-type': 'text/html' });
response.write('<title>Slow navigation</title><input autofocus>'); pendingNavigation = response; return;
}
if (request.url === '/inline.pdf') {
response.writeHead(200, { 'content-type': 'application/pdf;charset=UTF-8', 'content-length': pdfBody.length,
'content-disposition': "inline; filename*=UTF-8''%E6%85%A2%E9%80%9F%E6%8A%A5%E5%91%8A.pdf" });
response.write(pdfBody.subarray(0, 65536)); pendingPdf = response; return;
}
if (request.url === '/report.txt') {
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-disposition': 'attachment; filename="report.txt"' });
response.end('Netcatty automatic download\n'); return;
}
if (request.url === '/slow.bin') {
response.writeHead(200, { 'content-type': 'application/octet-stream', 'content-length': 1048576 });
response.write(Buffer.alloc(16384)); return;
}
response.end(`<html><title>Browser acceptance page</title><body style="background:#c8f1df;color:#142a25;font:22px sans-serif;padding:25px"><h1>This page stays visible</h1><input id="draft" value="Keep my input"><button id="count" onclick="window.count=(window.count||0)+1">Count</button><a id="next" href="/next">Next</a><a id="download" href="/report.txt">Download report</a><p>${request.url}</p></body></html>`);
});
await new Promise((resolve) => site.listen(0, '127.0.0.1', resolve));
const url = `http://127.0.0.1:${site.address().port}/`;
const preload = path.join(userData, 'preload.cjs');
fs.writeFileSync(preload, `const {contextBridge,ipcRenderer,webUtils}=require('electron'); const {createPreloadApi}=require(${JSON.stringify(path.join(repo,'electron/preload/api.cjs'))}); contextBridge.exposeInMainWorld('netcatty',createPreloadApi({ipcRenderer,webUtils}));`);
owner = new BrowserWindow({ width: 1000, height: 800, show: true, webPreferences: { preload, contextIsolation: true, sandbox: false } });
const errors = [];
owner.webContents.on('console-message', (event) => { if (event.level === 'error' || event.level >= 3) errors.push(event.message); });
owner.webContents.on('render-process-gone', (_event, details) => errors.push(JSON.stringify(details)));
manager = createHostBrowserManager({ electron: { ...electron,
dialog: { showSaveDialog: async () => ({ canceled: false, filePath: savedCopy }) },
shell: { openPath: async (file) => { shellActions.push(['open', file]); return ''; }, showItemInFolder: (file) => shellActions.push(['reveal', file]) },
}, app, portForwardingBridge });
const event = { sender: owner.webContents };
const browserId = 'local:renderer-acceptance';
manager.openPanelUrl(event, { browserId, url });
await owner.loadURL(`${base}/electron/hostBrowser/fixtures/renderer.html`);
await until(() => run('document.querySelectorAll("[role=tab]").length'), 1, 'unified tab mounts');
await until(() => manager.getPanelState(event, browserId).tabs[0].title, 'Browser acceptance page', 'page loads');
let root = owner.contentView.children.find((view) => view.children.some((child) => child instanceof WebContentsView && child.webContents.getURL() === url));
assert.ok(root); assert.equal(root.children.length, 1, 'only the website is a separate native view');
const page = root.children[0].webContents;
await page.executeJavaScript("document.getElementById('draft').value='Unsaved browser input'");
for (let index = 0; index < 2; index++) {
app.focus({ steal: true });
owner.focus(); owner.webContents.focus();
await until(() => owner.isFocused(), true, 'acceptance window has native focus');
owner.webContents.sendInputEvent({ type: 'mouseMove', x: 980, y: 740 });
await run('new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))');
const rect = await run(`(() => { const r = document.getElementById('split-${index}').getBoundingClientRect(); return {x:r.x+r.width/2,y:r.y+r.height/2}; })()`);
owner.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(rect.x), y: Math.round(rect.y) });
await until(() => run('!!document.querySelector("[role=tooltip]")'), true, 'split hover tooltip opens');
await wait(100);
assert.equal(owner.contentView.children.includes(root), true, 'hover leaves the website live');
assert.equal(await run('document.querySelectorAll("[data-section=host-browser-panel] img").length'), 0, 'hover does not replace the page with a frozen picture');
await click(`#split-${index}`);
await until(() => run('document.querySelectorAll("[data-section=host-browser-panel] img").length'), 1, 'overlapping split menu preserves a picture of the webpage');
assert.equal(owner.contentView.children.includes(root), false, 'the split menu is above the native content');
await click(`#choice-${index}-0`);
await until(() => run(`document.getElementById('choice-${index}-0').dataset.chosen`), 'true', 'split menu choices are clickable');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
owner.webContents.sendInputEvent({ type: 'mouseMove', x: 980, y: 740 });
await until(() => owner.contentView.children.includes(root), true, 'closing menu restores live content');
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"), 'Unsaved browser input');
}
await click('[aria-label="新标签页"]');
await until(() => run('document.querySelectorAll("[role=tab]").length'), 2, 'new tab');
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'new tab focuses the app address input');
assert.equal(owner.contentView.children.includes(root), false, 'the default tab must not be covered by an empty native container');
await click('[role=tab]');
await until(() => owner.contentView.children.includes(root), true, 'return to original tab');
assert.equal(await page.executeJavaScript("document.getElementById('draft').value"), 'Unsaved browser input');
page.sendInputEvent({ type: 'keyDown', keyCode: 'L', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'page shortcut focuses unified address input');
await input('[aria-label="输入网址"]', `${url}next`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
await until(() => page.getURL(), `${url}next`, 'address navigation');
await until(() => run('document.querySelector("[aria-label=后退]").disabled'), false, 'history back is enabled');
await click('[aria-label="后退"]');
await until(() => page.getURL(), url, 'back navigation');
await click('[aria-label="应用与书签"]');
await until(() => run('!!document.querySelector("aside[aria-label=应用与书签]")'), true, 'bookmarks share the app layout');
await until(() => root.getBounds().width < 800, true, 'bookmark sidebar resizes only the webpage viewport');
await click('aside [aria-label="添加应用"]');
await until(() => run('!!document.querySelector("aside input[type=url]")'), true, 'bookmark editor');
await input('aside input[maxlength="120"]', 'Saved acceptance app');
await input('aside input[type="url"]', url);
await click('aside [data-section="browser-icon-editor"] button:nth-child(3)');
await run(`(() => {
const canvas=document.createElement('canvas'); canvas.width=640; canvas.height=320;
const context=canvas.getContext('2d'); context.fillStyle='#247969'; context.fillRect(0,0,640,320);
context.fillStyle='white'; context.font='bold 180px sans-serif'; context.fillText('N',245,225);
const source=atob(canvas.toDataURL('image/png').split(',')[1]);
const bytes=Uint8Array.from(source,c=>c.charCodeAt(0));
const files=new DataTransfer(); files.items.add(new File([bytes],'team-app.png',{type:'image/png'}));
const element=document.querySelector('aside input[type=file]'); element.files=files.files; element.dispatchEvent(new Event('change',{bubbles:true}));
})()`);
await until(() => run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'chosen image has a real preview');
assert.equal(await run('document.querySelector("aside input[type=file]").classList.contains("hidden")'), true, 'native file picker chrome is hidden');
assert.equal(await run('document.querySelector("aside button[type=submit]").disabled'), false, 'processed image can be saved');
const imageSize = await run(`new Promise(resolve=>{const image=new Image();image.onload=()=>resolve([image.width,image.height]);image.src=document.querySelector('aside img[alt=图标预览]').src;})`);
assert.deepEqual(imageSize, [96, 48], 'large images are resized proportionally');
await click('aside [aria-label="移除图片"]');
await until(() => run('document.querySelector("aside button[type=submit]").disabled'), true, 'an empty image choice cannot be saved');
await click('aside [data-section="browser-icon-editor"] button:nth-child(2)');
await input('aside [aria-label="图标文字"]', 'N');
await click('aside label:has(input[value="all-hosts"])');
const editorTextScreenshot = path.join(userData, 'bookmark-editor-text.png');
await capture(editorTextScreenshot, 'aside');
await click('aside [data-section="browser-icon-editor"] button:nth-child(3)');
await run(`(() => {
const canvas=document.createElement('canvas');canvas.width=96;canvas.height=96;
const context=canvas.getContext('2d');context.fillStyle='#247969';context.fillRect(0,0,96,96);context.fillStyle='white';context.font='bold 58px sans-serif';context.fillText('N',25,69);
const source=atob(canvas.toDataURL().split(',')[1]); const files=new DataTransfer();
files.items.add(new File([Uint8Array.from(source,c=>c.charCodeAt(0))],'new-icon.png',{type:'image/png'}));
document.querySelector('aside input[type=file]').parentElement.dispatchEvent(new DragEvent('drop',{bubbles:true,dataTransfer:files}));
})()`);
await until(() => run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'a dropped replacement image loads');
await run(`(() => {
const files=new DataTransfer();files.items.add(new File(['not an image'],'invalid.txt',{type:'text/plain'}));
const element=document.querySelector('aside input[type=file]');element.files=files.files;element.dispatchEvent(new Event('change',{bubbles:true}));
})()`);
await until(() => run('!!document.querySelector("aside [role=alert]")'), true, 'unsupported files show an inline explanation');
assert.equal(await run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'a failed replacement keeps the previous image');
for (const [source, expected] of [["new Uint8Array(6*1024*1024)", '图片太大'], ["'broken PNG'", '无法读取']]) {
await run(`(() => {const files=new DataTransfer();files.items.add(new File([${source}],'bad.png',{type:'image/png'}));const input=document.querySelector('aside input[type=file]');input.files=files.files;input.dispatchEvent(new Event('change',{bubbles:true}));})()`);
await until(() => run(`document.querySelector('aside [role=alert]')?.textContent.includes(${JSON.stringify(expected)})`), true, 'invalid image has a specific inline explanation');
}
await run('document.querySelector("aside form").requestSubmit()');
await until(() => run('!!Array.from(document.querySelectorAll("aside button")).find(b=>b.textContent.includes("Saved acceptance app"))'), true, 'saved bookmark is shown');
await click('aside [aria-label="编辑"]');
await until(() => run('!!document.querySelector("aside img[alt=图标预览]")'), true, 'the saved image is restored when editing');
assert.equal(await run('document.querySelector("aside input[value=all-hosts]").checked'), true, 'scope survives saving');
const editorScreenshot=path.join(userData,'bookmark-editor-image.png');
await capture(editorScreenshot, 'aside');
for (const width of [400, 1000]) {
owner.setSize(width, 800); await wait(150);
assert.equal(await run('document.querySelector("aside").scrollWidth <= document.querySelector("aside").clientWidth'), true, 'the editor fits a narrow panel');
await click('aside button[type=submit]');
await until(() => run('!document.querySelector("aside input[type=url]")'), true, 'footer remains reachable after resizing');
await click('aside [aria-label="编辑"]');
await until(() => run('!!document.querySelector("aside input[type=url]")'), true, 'reopen after resizing');
}
await click('aside [aria-label="关闭"]');
await click('[role=tab]:nth-child(2)');
await until(() => owner.contentView.children.includes(root), false, 'returning to the default tab removes the native input blocker');
await click('[data-section=host-browser-panel] button');
await until(() => run('!!document.querySelector("aside input[type=url]")'), true, 'default-tab Add app opens the editor');
await click('aside [aria-label="关闭"]');
await click(`[data-section=host-browser-panel] button[title="${url}"]`);
await until(() => manager.getPanelState(event, browserId).tabs.find(tab => tab.id === manager.getPanelState(event, browserId).activeTabId)?.url, url, 'default-tab bookmark opens its website');
await until(() => owner.contentView.children.includes(root), true, 'bookmark navigation attaches the website again');
await click('[aria-label="下载"]');
await until(() => run('document.body.textContent.includes("暂无下载")'), true, 'downloads use the app popover');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
fs.writeFileSync(path.join(downloadsPath, 'report.txt'), 'Keep the existing file');
const activePage = root.children[0].webContents;
await activePage.executeJavaScript("document.getElementById('download').click()");
await until(() => run('document.body.textContent.includes("report (1).txt") && document.body.textContent.includes("已完成")'), true, 'a website download saves immediately and opens its progress list');
await until(() => !owner.contentView.children.includes(root), true, 'the automatic download list is above the native webpage before interacting');
assert.equal(fs.readFileSync(path.join(downloadsPath, 'report (1).txt'), 'utf8'), 'Netcatty automatic download\n');
assert.equal(fs.readFileSync(path.join(downloadsPath, 'report.txt'), 'utf8'), 'Keep the existing file');
await click('[aria-label="更多操作"]');
await until(() => run('Array.from(document.querySelectorAll("button")).some(button => button.textContent === "另存为…")'), true, 'download actions menu opens');
await run('Array.from(document.querySelectorAll("button")).find(button => button.textContent === "另存为…").click()');
await until(() => fs.existsSync(savedCopy), true, 'Save as writes a separate copy');
assert.equal(fs.readFileSync(savedCopy, 'utf8'), 'Netcatty automatic download\n');
assert.equal(fs.existsSync(path.join(downloadsPath, 'report (1).txt')), true, 'Save as retains the original downloaded file');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await click('[title="在文件夹中显示"]');
assert.deepEqual(shellActions.at(-1), ['reveal', path.join(downloadsPath, 'report (1).txt')]);
await click('[aria-label="打开下载文件夹"]');
assert.deepEqual(shellActions.at(-1), ['open', downloadsPath]);
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await activePage.executeJavaScript("document.getElementById('download').click()");
await until(() => run('document.body.textContent.includes("report (2).txt") && document.querySelectorAll("[data-download-id]").length === 2'), true, 'a second download also gets a new name and reopens the list');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
activePage.downloadURL(`${url}slow.bin`);
await until(() => run('!!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'progress is visible while the server is still sending the file');
await until(() => !owner.contentView.children.includes(root), true, 'the progress popover has acquired the webpage surface');
await click('[aria-label="下载"]');
await until(() => run('!!document.querySelector("[aria-label=取消下载]")'), false, 'the download toggle closes its progress list');
await wait(350);
assert.equal(await run('!!document.querySelector("[aria-label=取消下载]")'), false, 'progress updates do not reopen a dismissed list');
await click('[aria-label="下载"]');
await click('[aria-label="取消下载"]');
await until(() => run('document.body.textContent.includes("已取消")'), true, 'download can be cancelled from the list');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await activePage.executeJavaScript("(() => { const a=document.createElement('a');a.href='/inline.pdf';document.body.append(a);a.click();a.remove(); })()");
await until(() => run('document.body.textContent.includes("慢速报告.pdf") && !!document.querySelector("[aria-label=取消下载]") && document.body.textContent.includes("正在下载")'), true, 'inline PDF navigation opens progress before the response finishes');
assert.equal(pendingPdf.writableEnded, false, 'the server has not sent the rest of the PDF');
await until(() => run('document.body.textContent.includes("64.0 KB")'), true, 'received PDF bytes are visible while its body is held open');
const pdfProgressScreenshot = path.join(userData, 'inline-pdf-progress.png');
await capture(pdfProgressScreenshot);
await click('[aria-label="取消下载"]');
await until(() => pendingPdf.destroyed, true, 'cancelling an inline PDF stops the network transfer');
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
pendingPdf = undefined;
await input('[aria-label="输入网址"]', `${url}inline.pdf`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
await until(() => !!pendingPdf && !pendingPdf.destroyed, true, 'another inline PDF starts');
await until(() => run('!!document.querySelector("[aria-label=取消下载]")'), true, 'another PDF also opens progress');
pendingPdf.end(pdfBody.subarray(65536));
await until(() => run('Array.from(document.querySelectorAll("[data-download-id]")).some(row => row.textContent.includes("慢速报告.pdf") && row.textContent.includes("已完成"))'), true, 'PDF progress becomes completed after the response finishes');
assert.deepEqual(fs.readFileSync(path.join(downloadsPath, '慢速报告.pdf')), pdfBody, 'the PDF is saved without changing its contents');
assert.equal(await run('document.body.textContent.includes("ERR_FAILED")'), false, 'address-bar downloads do not show a false page error');
const downloadScreenshot=path.join(userData,'automatic-downloads.png');
await capture(downloadScreenshot);
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
await input('[aria-label="输入网址"]', `${url}slow-page`);
await run('document.querySelector("[aria-label=输入网址]").form.requestSubmit()');
await until(() => !!pendingNavigation, true, 'slow navigation is waiting for its response to finish');
await click('[aria-label="新标签页"]');
await until(() => run('document.activeElement.getAttribute("aria-label")'), '输入网址', 'new tab receives input focus while the previous page is loading');
await input('[aria-label="输入网址"]', 'keep my address draft');
pendingNavigation.end();
await until(() => !activePage.isLoading(), true, 'the background page finishes loading');
await wait(100);
assert.equal(owner.webContents.isFocused(), true, 'background navigation must not steal the new tab input focus');
assert.equal(await run('document.activeElement.getAttribute("aria-label")'), '输入网址');
assert.equal(await run('document.querySelector("[aria-label=输入网址]").value'), 'keep my address draft');
await click('[role=tab]:nth-child(2)');
await until(() => owner.contentView.children.includes(root), true, 'return to the loaded page');
await click('#visibility');
await until(() => owner.contentView.children.includes(root), false, 'hidden terminal hides only its page');
await click('#visibility');
await until(() => owner.contentView.children.includes(root), true, 'returning terminal restores page');
for (const width of [400, 1000]) {
owner.setSize(width, 800); await wait(150);
assert.equal(await run('document.documentElement.scrollWidth <= innerWidth'), true, 'no document overflow at narrow and wide sizes');
}
for (const locale of ['en', 'zh-TW', 'es', 'ru', 'zh-CN']) {
await run(`(() => { const el=document.querySelector('select'); const setter=Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype,'value').set; setter.call(el,${JSON.stringify(locale)}); el.dispatchEvent(new Event('change',{bubbles:true})); })()`);
await wait(50);
assert.equal(await run('document.body.textContent.includes("terminal.browser.")'), false, 'no untranslated browser labels');
}
const screenshot = path.join(userData, 'unified-browser.png');
fs.writeFileSync(screenshot, (await owner.webContents.capturePage()).toPNG());
assert.deepEqual(errors, [], 'no renderer errors');
console.log(JSON.stringify({ ok: true, rendererChrome: true, hoverKeepsPageLive: true, splitMenusClickable: true, tabs: true, addressAndHistory: true, shortcuts: true, bookmarks: true, iconUpload: true, imageResize: true, imageReplaceAndRemove: true, editPersistence: true, downloads: true, automaticDownloads: true, duplicateNames: true, saveAs: true, downloadProgressAndCancel: true, inlinePdfProgressAndCancel: true, hiddenRestore: true, narrowLayout: true, locales: 5, screenshot, editorScreenshot, editorTextScreenshot, downloadScreenshot, pdfProgressScreenshot }));
}
app.whenReady().then(main).catch(async (error) => { console.error(error); if (owner && !owner.isDestroyed()) { const screenshot=path.join(userData,'failure.png'); fs.writeFileSync(screenshot,(await owner.webContents.capturePage()).toPNG()); console.error(screenshot); } process.exitCode = 1; }).finally(async () => {
clearTimeout(watchdog);
manager?.closeAll(); if (owner && !owner.isDestroyed()) owner.destroy();
if (site) { site.closeAllConnections(); site.close(); }
await vite?.close();
// Keep the isolated screenshot and profile under Netcatty's managed temp directory for inspection.
app.exit(process.exitCode || 0);
});
+169 -53
View File
@@ -5,18 +5,33 @@ const fs = require("node:fs");
const { createHash, randomUUID } = require("node:crypto");
const { createBookmarkStore } = require("./bookmarkStore.cjs");
const { createDownloadStore } = require("./downloadStore.cjs");
const { reserveDownloadFile } = require("./downloadFile.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 chromeHeight = (record) => Math.round((record.embedded ? 0 : 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 pdfDownloadHeaders({ resourceType, statusCode, responseHeaders }) {
// The page allowlist deliberately excludes Chromium's extension-based PDF
// viewer. Hand PDF navigations to the download manager at response headers,
// before that viewer can consume/cache the body outside download tracking.
if (!["mainFrame", "subFrame"].includes(resourceType) || statusCode < 200 || statusCode >= 300 || !responseHeaders) return;
const typeKey = Object.keys(responseHeaders).find((key) => key.toLowerCase() === "content-type");
if (responseHeaders[typeKey]?.[0]?.split(";", 1)[0].trim().toLowerCase() !== "application/pdf") return;
const dispositionKey = Object.keys(responseHeaders).find((key) => key.toLowerCase() === "content-disposition") || "Content-Disposition";
const disposition = responseHeaders[dispositionKey]?.[0] || "";
if (/^\s*attachment\s*(;|$)/i.test(disposition)) return;
// Keep the site's filename / filename* parameters, including localized names.
const parameters = disposition.indexOf(";");
return { ...responseHeaders, [dispositionKey]: [`attachment${parameters < 0 ? "" : disposition.slice(parameters)}`] };
}
function validThemeTokens(input) {
const result = {};
for (const name of THEME_TOKEN_NAMES) {
@@ -74,7 +89,7 @@ function normalizeBrowserUrl(input) {
}
function createHostBrowserManager({ electron, app, portForwardingBridge }) {
const { BrowserWindow, WebContentsView, session, ipcMain, shell } = electron;
const { BrowserWindow, WebContentsView, session, ipcMain, shell, dialog } = electron;
const windows = new Map();
const panelLayouts = new Map();
const byChromeId = new Map();
@@ -83,7 +98,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
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");
if (record.embedded && !record.chromeContents.isDestroyed()) panelEvent(record, { type: "bookmarks" });
}
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed() && !win.webContents.isDestroyed()) {
@@ -91,16 +106,21 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
}
}
});
const panelEvent = (record, event) => {
if (!record.sourceWebContents.isDestroyed()) record.sourceWebContents.send("netcatty:host-browser:panel-event", { browserId: record.id, ...event });
};
const send = (record) => {
if (record.win.isDestroyed() || record.chromeContents.isDestroyed()) return;
record.chromeContents.send("netcatty:host-browser:state", snapshot(record));
if (record.embedded) panelEvent(record, { type: "state", state: snapshot(record) });
else record.chromeContents.send("netcatty:host-browser:state", snapshot(record));
};
const notifyDownloads = (record) => {
const notifyDownloads = (record, startedId) => {
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.embedded) panelEvent(target, { type: "downloads", downloads, startedId: target === record ? startedId : undefined });
else 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);
}
@@ -126,7 +146,10 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
});
const notifySiteIcon = (record) => {
send(record);
if (!record.win.isDestroyed()) record.chromeContents.send("netcatty:host-browser:site-icons-changed");
if (!record.win.isDestroyed()) {
if (record.embedded) panelEvent(record, { type: "bookmarks" });
else record.chromeContents.send("netcatty:host-browser:site-icons-changed");
}
};
const updateFavicon = async (record, tab, urls) => {
if (!tab.url || record.route !== "connected") return;
@@ -167,6 +190,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
send(record);
};
const setDownloadsPanel = async (record, open) => {
if (record.embedded) return;
if (!open) { closeDownloadsPanel(record); return; }
if (!record.downloadsView) {
const view = new WebContentsView({ webPreferences: {
@@ -216,6 +240,18 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
try { record.container.removeChildView(record.downloadsView); } catch {}
record.container.addChildView(record.downloadsView);
}
if (record.embedded) {
const attached = record.win.contentView.children.includes(record.container);
// An empty native View still intercepts mouse input on macOS. The
// renderer owns the default tab and connection screens, so attach this
// container only while it contains a website.
if (record.container.children.length) {
if (!attached) record.win.contentView.addChildView(record.container);
} else if (attached) {
record.win.contentView.removeChildView(record.container);
record.sourceWebContents.focus();
}
}
send(record);
};
const refreshTab = (record, tab) => {
@@ -251,7 +287,11 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
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 });
if (type === "focus-address" || type === "new-tab") record.chromeContents.focus();
if (!record.win.isDestroyed()) {
if (record.embedded) panelEvent(record, { type: "shortcut", shortcut: { type, tabId: tab.id } });
else record.chromeContents.send("netcatty:host-browser:shortcut", { type, tabId: tab.id });
}
});
contents.setWindowOpenHandler(({ url }) => {
try { addTab(record, normalizeBrowserUrl(url)); } catch {}
@@ -313,7 +353,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
record.sourceWebContents.off("destroyed", record.onOwnerClosed);
if (record.embedded) {
try { record.win.contentView.removeChildView(record.container); } catch {}
try { record.chromeContents.close(); } catch {}
if (record.chromeView) { try { record.chromeContents.close(); } catch {} }
}
byChromeId.delete(record.chromeId);
if (record.downloadsView) {
@@ -420,7 +460,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
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 visible = layout?.sender === record.sourceWebContents && layout.visible === true && !layout.occluded;
const attached = record.win.contentView.children.includes(record.container);
if (!visible) {
if (attached) {
@@ -445,13 +485,28 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
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 capturePanelLayers = (record) => {
if (record.panelSnapshotPromise) return record.panelSnapshotPromise;
const zoom = record.zoom || 1;
const views = record.container.children.filter((view) => view instanceof WebContentsView);
const operation = Promise.all(views.map(async (view) => {
const bounds = view.getBounds();
const image = await view.webContents.capturePage();
if (image.isEmpty()) return null;
return { image: image.toDataURL(), bounds: Object.fromEntries(
Object.entries(bounds).map(([key, value]) => [key, value / zoom]),
) };
})).then((layers) => layers.filter(Boolean)).catch(() => []);
record.panelSnapshotPromise = operation;
void operation.finally(() => {
if (record.panelSnapshotPromise === operation) record.panelSnapshotPromise = null;
});
return operation;
};
const updatePanelLayout = (event, input) => {
const owner = BrowserWindow.fromWebContents(event.sender);
if (!owner || owner.webContents !== event.sender) throw new Error("Untrusted browser panel owner");
@@ -465,8 +520,18 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
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) });
const layout = { sender: event.sender, visible: input.visible === true, occluded: input.occluded === true, bounds, pendingUrls: previous?.pendingUrls ?? [],
theme: input.theme === "light" || input.theme === "dark" ? input.theme : undefined, themeTokens: validThemeTokens(input.themeTokens) };
panelLayouts.set(id, layout);
if (record && layout.visible && layout.occluded) {
return capturePanelLayers(record).then((layers) => {
// A menu may close, the pane may move or the owner may disappear while
// capture is pending. Never hide a newer layout or return stale pixels.
if (panelLayouts.get(id) !== layout || windows.get(id) !== record || owner.isDestroyed()) return false;
applyPanelLayout(record);
return { layers };
});
}
if (record) applyPanelLayout(record);
return true;
};
@@ -543,18 +608,30 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
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(),
routeGeneration: 0, proxyQueue: Promise.resolve(), tabs: new Map(), downloads: new Map(), navigations: 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) });
let destination;
try {
destination = reserveDownloadFile(app.getPath("downloads"), filename);
item.setSavePath(destination.savePath);
} catch {
destination?.cleanupEmpty();
event.preventDefault();
downloadStore.upsert({ id, hostId, filename, url: item.getURL(), savePath: "", status: "interrupted",
received: 0, total: item.getTotalBytes(), startedAt: Date.now(), endedAt: Date.now() });
notifyDownloads(record, id);
return;
}
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);
downloadStore.upsert({ id, hostId, filename: path.basename(destination.savePath), url: item.getURL(), savePath: destination.savePath,
status: "progressing", received: 0, total: item.getTotalBytes(), startedAt: Date.now() });
notifyDownloads(record, id);
if (!record.embedded) void setDownloadsPanel(record, true).catch(() => {});
let lastProgressAt = 0;
item.on("updated", (_updateEvent, status) => {
const now = Date.now();
@@ -569,6 +646,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
});
item.once("done", (_doneEvent, status) => {
record.downloads.delete(id);
if (status !== "completed") destination.cleanupEmpty();
const previous = downloadStore.get(hostId, id);
if (!previous) return;
const savePath = item.getSavePath() || previous.savePath;
@@ -581,12 +659,20 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
pageSession.webRequest.onBeforeRequest((details, callback) => {
let allowed = false;
try { allowed = record.route === "connected" && ALLOWED_PAGE_SCHEMES.has(new URL(details.url).protocol); } catch {}
const navigation = record.navigations.get(details.webContentsId);
if (allowed && details.resourceType === "mainFrame" && navigation?.url === details.url) navigation.requestId = details.id;
callback({ cancel: !allowed });
});
const chromeOptions = { webPreferences: {
preload: path.join(__dirname, "browserPreload.cjs"),
sandbox: true, contextIsolation: true, nodeIntegration: false,
} };
pageSession.webRequest.onHeadersReceived((details, callback) => {
const responseHeaders = pdfDownloadHeaders(details);
const navigation = record.navigations.get(details.webContentsId);
const headers = responseHeaders || details.responseHeaders || {};
if (navigation?.requestId === details.id && details.resourceType === "mainFrame" && details.statusCode >= 200 && details.statusCode < 300
&& Object.entries(headers).some(([key, values]) => key.toLowerCase() === "content-disposition" && /^\s*attachment\s*(;|$)/i.test(values[0]))) {
navigation.downloading = true;
}
callback(responseHeaders ? { responseHeaders } : {});
});
const win = embedded ? owner : new BrowserWindow({
title: "Browser",
width: 1100, height: 760, minWidth: 720, minHeight: 480,
@@ -602,12 +688,11 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
});
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.chromeView = null;
record.chromeContents = win.webContents;
record.chromeId = record.chromeContents.id;
windows.set(id, record);
byChromeId.set(record.chromeId, record);
if (!embedded) byChromeId.set(record.chromeId, record);
record.onResize = () => resizeView(record);
record.onBlur = () => closeDownloadsPanel(record);
record.onMaximize = () => send(record);
@@ -618,10 +703,12 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
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());
if (!embedded) {
record.chromeContents.setWindowOpenHandler(() => ({ action: "deny" }));
record.chromeContents.on("will-navigate", (navigationEvent) => navigationEvent.preventDefault());
}
try {
await record.chromeContents.loadFile(path.join(__dirname, "browser.html"));
if (!embedded) 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);
@@ -641,12 +728,19 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
record.win.focus();
return record.route === "connected";
};
const getOwned = (event) => {
const getOwned = (event, browserId) => {
if (browserId !== undefined) {
const owned = windows.get(browserId);
if (!owned?.embedded || owned.sourceWebContents !== event.sender
|| BrowserWindow.fromWebContents(event.sender)?.webContents !== event.sender) throw new Error("Untrusted browser control");
return owned;
}
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 getBrowserOwned = (event, browserId) => {
if (browserId !== undefined) return getOwned(event, browserId);
const chrome = byChromeId.get(event.sender.id);
if (chrome && chrome.chromeContents === event.sender) return chrome;
const downloads = byDownloadsId.get(event.sender.id);
@@ -658,17 +752,18 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
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);
const getPanelState = (event, browserId) => snapshot(getOwned(event, browserId));
ipcMain.handle("netcatty:host-browser:snapshot", getPanelState);
ipcMain.handle("netcatty:host-browser:downloads:list", (event, browserId) => {
const record = getBrowserOwned(event, browserId);
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);
ipcMain.handle("netcatty:host-browser:downloads:command", async (event, input, browserId) => {
const record = getBrowserOwned(event, browserId);
const item = downloadStore.get(record.hostId, input?.id);
switch (input?.type) {
case "close": closeDownloadsPanel(record); return true;
@@ -689,11 +784,18 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
notifyDownloads(record);
return true;
case "open":
case "save-as":
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);
if (input.type === "save-as") {
const result = await (dialog || require("electron").dialog).showSaveDialog(record.win, {
defaultPath: item.savePath, properties: ["createDirectory", "showOverwriteConfirmation"],
});
if (result.canceled || !result.filePath) return false;
if (path.resolve(result.filePath) !== path.resolve(item.savePath)) await fs.promises.copyFile(item.savePath, result.filePath);
} else 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);
@@ -703,8 +805,8 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
default: throw new Error("Unknown download action");
}
});
ipcMain.handle("netcatty:host-browser:bookmarks:list", (event) => {
const record = getOwned(event);
ipcMain.handle("netcatty:host-browser:bookmarks:list", (event, browserId) => {
const record = getOwned(event, browserId);
return bookmarkStore.list().filter((item) => item.scope === "all-hosts" || item.hostId === record.hostId)
.map((item) => {
const siteIcon = siteIconStore.get(record.hostId, item.url);
@@ -712,16 +814,16 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
siteIconGlyph: siteIcon?.glyph || null, siteIconColor: siteIcon?.color || null };
});
});
ipcMain.handle("netcatty:host-browser:bookmarks:upsert", (event, input) => {
const record = getOwned(event);
ipcMain.handle("netcatty:host-browser:bookmarks:upsert", (event, input, browserId) => {
const record = getOwned(event, browserId);
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);
ipcMain.handle("netcatty:host-browser:bookmarks:remove", (event, id, browserId) => {
const record = getOwned(event, browserId);
const previous = bookmarkStore.list().find((item) => item.id === id);
if (!previous || (previous.scope === "host" && previous.hostId !== record.hostId)) return false;
bookmarkStore.remove(id);
@@ -736,8 +838,8 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
bookmarkStore.apply(items);
return true;
});
ipcMain.handle("netcatty:host-browser:command", async (event, command) => {
const record = getOwned(event);
const runCommand = async (event, command, browserId) => {
const record = getOwned(event, browserId);
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);
@@ -781,7 +883,7 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
}
break;
}
case "activate": if (tab) { record.activeTabId = tab.id; displayTab(record); } break;
case "activate": if (tab) { record.activeTabId = tab.id; displayTab(record); if (record.visible && tab.url) tab.view.webContents.focus(); } break;
case "close-tab":
if (tab) {
const tabs = [...record.tabs.values()];
@@ -799,7 +901,20 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
if (tab) {
tab.url = normalizeBrowserUrl(command.url);
displayTab(record);
await tab.view.webContents.loadURL(tab.url);
const contents = tab.view.webContents;
const contentsId = contents.id;
const url = tab.url;
const navigation = { url, downloading: false };
record.navigations.set(contentsId, navigation);
if (record.visible && record.activeTabId === tab.id && !record.overlayOpen) contents.focus();
try { await contents.loadURL(url); }
catch (error) {
// Chromium rejects loadURL when a navigation becomes a download.
// Response headers identify this request before will-download fires.
if (!navigation.downloading || !["ERR_FAILED", "ERR_ABORTED"].includes(error.code)) throw error;
} finally {
if (record.navigations.get(contentsId) === navigation) record.navigations.delete(contentsId);
}
}
break;
case "back": if (tab && record.route === "connected") tab.view.webContents.navigationHistory?.goBack(); break;
@@ -809,7 +924,8 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
default: throw new Error("Unknown browser command");
}
return snapshot(record);
});
};
ipcMain.handle("netcatty:host-browser:command", runCommand);
const unsubscribeTunnelStatus = portForwardingBridge.onTunnelStatus((event) => {
for (const record of windows.values()) {
if (record.tunnelId === event.tunnelId && ["inactive", "error"].includes(event.status)) {
@@ -817,10 +933,10 @@ function createHostBrowserManager({ electron, app, portForwardingBridge }) {
}
}
});
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, closeAll: () => {
return { focus, open, updatePanelLayout, closePanel, openPanelUrl, getPanelState, runCommand, closeAll: () => {
unsubscribeTunnelStatus();
for (const record of [...windows.values()]) { closeRecord(record); if (!record.embedded && !record.win.isDestroyed()) record.win.destroy(); }
} };
}
module.exports = { createHostBrowserManager, normalizeBrowserUrl };
module.exports = { createHostBrowserManager, normalizeBrowserUrl, pdfDownloadHeaders };
@@ -4,7 +4,35 @@ 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");
const { normalizeBrowserUrl, pdfDownloadHeaders } = require("./hostBrowserManager.cjs");
test("PDF navigations download before the preview can buffer the response", () => {
const headers = { "Content-Type": ["Application/PDF;charset=UTF-8"], "Content-Length": ["8933895"] };
for (const resourceType of ["mainFrame", "subFrame"]) {
assert.deepEqual(pdfDownloadHeaders({ resourceType, statusCode: 200, responseHeaders: headers }), {
...headers, "Content-Disposition": ["attachment"],
});
}
assert.equal(headers["Content-Disposition"], undefined, "the original response headers are not mutated");
});
test("PDF downloads preserve the site's localized suggested filename", () => {
const filename = "; filename=report.pdf; filename*=UTF-8''%E6%8A%A5%E5%91%8A.pdf";
const headers = { "content-type": ["application/pdf"], "content-disposition": [`inline${filename}`] };
assert.deepEqual(pdfDownloadHeaders({ resourceType: "mainFrame", statusCode: 200, responseHeaders: headers }), {
...headers, "content-disposition": [`attachment${filename}`],
});
});
test("PDF policy leaves webpage requests, failed responses and explicit downloads alone", () => {
const responseHeaders = { "content-type": ["application/pdf"] };
const details = { resourceType: "mainFrame", statusCode: 200, responseHeaders };
for (const resourceType of ["xhr", "object", "other", "script", "image"]) assert.equal(pdfDownloadHeaders({ ...details, resourceType }), undefined);
for (const statusCode of [302, 404, 500]) assert.equal(pdfDownloadHeaders({ ...details, statusCode }), undefined);
for (const mime of ["text/html", "image/png", "application/octet-stream"]) assert.equal(pdfDownloadHeaders({ ...details, responseHeaders: { "content-type": [mime] } }), undefined);
assert.equal(pdfDownloadHeaders({ ...details, responseHeaders: { ...responseHeaders, "content-disposition": ["attachment; filename=report.pdf"] } }), undefined);
assert.equal(pdfDownloadHeaders({ ...details, responseHeaders: undefined }), undefined);
});
test("browser addresses accept bare localhost and domain ports", () => {
assert.equal(normalizeBrowserUrl("localhost:3000"), "http://localhost:3000/");
+12
View File
@@ -1341,6 +1341,18 @@ function createPreloadApi(ctx) {
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),
getHostBrowserPanelState: (browserId) => ipcRenderer.invoke("netcatty:host-browser:snapshot", browserId),
commandHostBrowserPanel: (browserId, command) => ipcRenderer.invoke("netcatty:host-browser:command", command, browserId),
listHostBrowserPanelBookmarks: (browserId) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:list", browserId),
saveHostBrowserPanelBookmark: (browserId, bookmark) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:upsert", bookmark, browserId),
removeHostBrowserPanelBookmark: (browserId, id) => ipcRenderer.invoke("netcatty:host-browser:bookmarks:remove", id, browserId),
listHostBrowserPanelDownloads: (browserId) => ipcRenderer.invoke("netcatty:host-browser:downloads:list", browserId),
commandHostBrowserPanelDownload: (browserId, command) => ipcRenderer.invoke("netcatty:host-browser:downloads:command", command, browserId),
onHostBrowserPanelEvent: (callback) => {
const listener = (_event, value) => callback(value);
ipcRenderer.on("netcatty:host-browser:panel-event", listener);
return () => ipcRenderer.removeListener("netcatty:host-browser:panel-event", listener);
},
openHostBrowserUrl: (options) => ipcRenderer.invoke("netcatty:host-browser:panel-url", options),
requestHostBrowserPanel: (request) => ipcRenderer.invoke("netcatty:host-browser:request-panel", request),
onHostBrowserPanelRequest: (callback) => {
@@ -0,0 +1,52 @@
import type { BrowserBookmark } from '../../domain/browserBookmark';
import type { HostBrowserCommand, HostBrowserPanelEvent } from '../../domain/hostBrowser';
import { HostBrowserIconError } from '../../domain/hostBrowser';
import { netcattyBridge } from './netcattyBridge';
export function hostBrowserClient(browserId: string) {
const bridge = netcattyBridge.require();
if (!bridge.getHostBrowserPanelState || !bridge.commandHostBrowserPanel) {
throw new Error('Restart Netcatty to load the updated browser controls');
}
return {
state: () => bridge.getHostBrowserPanelState!(browserId),
command: (command: HostBrowserCommand) => bridge.commandHostBrowserPanel!(browserId, command),
bookmarks: () => bridge.listHostBrowserPanelBookmarks!(browserId),
saveBookmark: (bookmark: Partial<BrowserBookmark>) => bridge.saveHostBrowserPanelBookmark!(browserId, bookmark),
removeBookmark: (id: string) => bridge.removeHostBrowserPanelBookmark!(browserId, id),
downloads: () => bridge.listHostBrowserPanelDownloads!(browserId),
downloadCommand: (command: Parameters<NonNullable<NetcattyBridge['commandHostBrowserPanelDownload']>>[1]) =>
bridge.commandHostBrowserPanelDownload!(browserId, command),
subscribe: (callback: (event: HostBrowserPanelEvent) => void) => bridge.onHostBrowserPanelEvent?.((event) => {
if (event.browserId === browserId) callback(event);
}) ?? (() => {}),
};
}
export async function readHostBrowserIcon(file: File): Promise<string> {
if (!new Set(['image/png', 'image/jpeg', 'image/webp', 'image/gif', 'image/x-icon', 'image/vnd.microsoft.icon']).has(file.type)
) throw new HostBrowserIconError('file-type');
if (file.size > 5 * 1024 * 1024) throw new HostBrowserIconError('file-size');
const source = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new HostBrowserIconError('decode'));
reader.readAsDataURL(file);
});
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
const element = new Image();
element.onload = () => resolve(element);
element.onerror = () => reject(new HostBrowserIconError('decode'));
element.src = source;
});
if (!image.naturalWidth || !image.naturalHeight) throw new HostBrowserIconError('decode');
if (file.size <= 64 * 1024 && Math.max(image.naturalWidth, image.naturalHeight) <= 128) return source;
const canvas = document.createElement('canvas');
const scale = Math.min(1, 96 / Math.max(image.naturalWidth, image.naturalHeight));
canvas.width = Math.max(1, Math.round(image.naturalWidth * scale));
canvas.height = Math.max(1, Math.round(image.naturalHeight * scale));
const context = canvas.getContext('2d');
if (!context) throw new HostBrowserIconError('decode');
context.drawImage(image, 0, 0, canvas.width, canvas.height);
return canvas.toDataURL('image/png');
}
+15 -1
View File
@@ -1,6 +1,11 @@
import type { S3Config, SyncedFile, WebDAVConfig } from "../../domain/sync";
import type { AppLockSettings } from "../../domain/appLock";
export interface HostBrowserPanelSnapshotLayer {
image: string;
bounds: { x: number; y: number; width: number; height: number };
}
type AppLockRuntimeReason = 'startup' | 'idle' | 'manual' | 'background' | null;
interface AppLockRuntimeState {
@@ -102,8 +107,17 @@ 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>;
setHostBrowserPanelLayout?(options: { browserId: string; visible: boolean; occluded?: boolean; theme?: 'dark' | 'light'; themeTokens?: Record<string, string>; bounds?: { x: number; y: number; width: number; height: number } }): Promise<boolean | { layers: HostBrowserPanelSnapshotLayer[] }>;
closeHostBrowserPanel?(browserId: string): Promise<boolean>;
getHostBrowserPanelState?(browserId: string): Promise<import('../../domain/hostBrowser').HostBrowserState>;
commandHostBrowserPanel?(browserId: string, command: import('../../domain/hostBrowser').HostBrowserCommand): Promise<import('../../domain/hostBrowser').HostBrowserState>;
listHostBrowserPanelBookmarks?(browserId: string): Promise<import('../../domain/hostBrowser').HostBrowserBookmark[]>;
saveHostBrowserPanelBookmark?(browserId: string, bookmark: Partial<import('../../domain/browserBookmark').BrowserBookmark>): Promise<import('../../domain/browserBookmark').BrowserBookmark>;
removeHostBrowserPanelBookmark?(browserId: string, id: string): Promise<boolean>;
listHostBrowserPanelDownloads?(browserId: string): Promise<import('../../domain/hostBrowser').HostBrowserDownload[]>;
commandHostBrowserPanelDownload?(browserId: string, command: { type: 'open-folder' | 'clear' | 'cancel' | 'remove' | 'open' | 'reveal' | 'save-as'; id?: string }): Promise<boolean>;
onHostBrowserPanelEvent?(callback: (event: import('../../domain/hostBrowser').HostBrowserPanelEvent) => void): () => void;
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;