mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ef3ac9e1f8 | ||
|
|
854f1e647f |
No files matched your search
@@ -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…',
|
||||
};
|
||||
@@ -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…',
|
||||
};
|
||||
@@ -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, []);
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
@@ -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 };
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/");
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user