mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-05 14:03:01 +00:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
680eb5eb0f | ||
|
|
df5c033dfa | ||
|
|
b5fee9632e | ||
|
|
fb69f4a48d | ||
|
|
1d8cba97ae | ||
|
|
88caf82e83 | ||
|
|
e3c45097c2 | ||
|
|
46de2023bd | ||
|
|
48f1a1cecf |
No files matched your search
@@ -113,7 +113,7 @@ jobs:
|
||||
- name: Exercise Windows pipes, PIN bridge, SK signatures and cancellation
|
||||
env:
|
||||
NETCATTY_FIDO_WINDOWS: "1"
|
||||
run: node --test scripts/fido-windows.live.test.cjs
|
||||
run: node --test scripts/fido-windows.live.test.cjs electron/bridges/fidoNativeAgent.test.cjs electron/bridges/fidoAskpass.winpath.test.cjs
|
||||
|
||||
windows-broadcast-ssh:
|
||||
name: windows-broadcast-ssh
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
|
||||
import { useMonitorRuntime } from './application/state/useMonitorRuntime';
|
||||
import { MonitorRuntimeHost } from './application/app/MonitorRuntimeHost';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { initializeFonts } from './application/state/fontStore';
|
||||
@@ -47,9 +47,9 @@ function SettingsI18nProvider({ children }: { children: React.ReactNode }) {
|
||||
* shell bags; AppSideEffects owns reactive effects + local UI publishing.
|
||||
*/
|
||||
function App() {
|
||||
useMonitorRuntime();
|
||||
return (
|
||||
<AppLocalStateProvider>
|
||||
<MonitorRuntimeHost />
|
||||
<AppSideEffects />
|
||||
<MonitorHostKeyDialog />
|
||||
<AppShell />
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { useMonitorRuntime } from '../state/useMonitorRuntime';
|
||||
|
||||
/** Keep monitoring state and subscriptions outside the app coordinator. */
|
||||
export function MonitorRuntimeHost() {
|
||||
useMonitorRuntime();
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import vm from 'node:vm';
|
||||
import React, { useState } from 'react';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import ts from 'typescript';
|
||||
|
||||
function loadComponent(path: string, name: string, bindings: Record<string, unknown>) {
|
||||
const source = readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
const ast = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
||||
const declaration = ast.statements.find((node) => ts.isFunctionDeclaration(node) && node.name?.text === name);
|
||||
assert.ok(declaration, `${name} must exist`);
|
||||
const code = ts.transpile(declaration.getText(ast).replace(/^export /, ''), {
|
||||
target: ts.ScriptTarget.ES2022, jsx: ts.JsxEmit.React,
|
||||
});
|
||||
return vm.runInNewContext(`${code}; ${name}`, { React, ...bindings }) as React.ComponentType;
|
||||
}
|
||||
|
||||
test('monitor controls update their runtime without rendering unrelated app siblings', async () => {
|
||||
const env = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = env.IS_REACT_ACT_ENVIRONMENT;
|
||||
env.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
let updateMonitor: (() => void) | undefined;
|
||||
let runtimeRenders = 0;
|
||||
let sideEffectRenders = 0;
|
||||
let shellRenders = 0;
|
||||
const useMonitorRuntime = () => {
|
||||
const [, setVersion] = useState(0);
|
||||
updateMonitor = () => setVersion((version) => version + 1);
|
||||
runtimeRenders += 1;
|
||||
};
|
||||
const MonitorRuntimeHost = loadComponent('./MonitorRuntimeHost.tsx', 'MonitorRuntimeHost', { useMonitorRuntime });
|
||||
const App = loadComponent('../../App.tsx', 'App', {
|
||||
useMonitorRuntime,
|
||||
MonitorRuntimeHost,
|
||||
AppLocalStateProvider: ({ children }: { children: React.ReactNode }) => children,
|
||||
AppSideEffects: () => { sideEffectRenders += 1; return null; },
|
||||
MonitorHostKeyDialog: () => null,
|
||||
AppShell: React.memo(() => { shellRenders += 1; return null; }),
|
||||
});
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(App)); });
|
||||
const initialRuntimeRenders = runtimeRenders;
|
||||
const initialSideEffectRenders = sideEffectRenders;
|
||||
const initialShellRenders = shellRenders;
|
||||
for (let i = 0; i < 10; i += 1) {
|
||||
await act(async () => updateMonitor!());
|
||||
}
|
||||
assert.equal(runtimeRenders - initialRuntimeRenders, 10, 'monitor updates must still run');
|
||||
assert.equal(sideEffectRenders - initialSideEffectRenders, 0, 'monitor-only state must not render AppSideEffects');
|
||||
assert.equal(shellRenders - initialShellRenders, 0, 'monitor controls must leave the shell stable');
|
||||
} finally {
|
||||
await act(async () => renderer?.unmount());
|
||||
env.IS_REACT_ACT_ENVIRONMENT = previous;
|
||||
}
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export const enMonitorMessages = {
|
||||
"monitor.stopAll": "Stop all",
|
||||
"monitor.list": "List",
|
||||
"monitor.charts": "Charts",
|
||||
"monitor.search": "Search name, address or group",
|
||||
"monitor.search": "Search hosts, groups, tags or notes",
|
||||
"monitor.interval": "Update interval",
|
||||
"monitor.range": "Trend range",
|
||||
"monitor.seconds": "Every {count}s",
|
||||
@@ -34,6 +34,13 @@ export const enMonitorMessages = {
|
||||
"monitor.sort.name": "Name",
|
||||
"monitor.selectedCount": "Selected hosts: {count}",
|
||||
"monitor.noMatches": "No matching hosts",
|
||||
"monitor.latency": "Latency",
|
||||
"monitor.responseLatency": "Response latency (includes remote processing)",
|
||||
"monitor.tags": "Tags",
|
||||
"monitor.notes": "Notes",
|
||||
"monitor.visibleColumns": "Visible columns",
|
||||
"monitor.terminal": "Terminal",
|
||||
"monitor.resizeColumn": "Resize {column} column",
|
||||
"monitor.host": "Host",
|
||||
"monitor.hosts": "Hosts",
|
||||
"monitor.status": "Status",
|
||||
@@ -53,8 +60,6 @@ export const enMonitorMessages = {
|
||||
"monitor.memoryTrend": "Memory trend",
|
||||
"monitor.downloadTrend": "Download trend",
|
||||
"monitor.uploadTrend": "Upload trend",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Readings stay in memory for up to 30 minutes and disappear when the app closes. Gaps mean no reading. Shared connections use the fastest active update interval.",
|
||||
"monitor.name": "Panel name",
|
||||
"monitor.description": "Description (optional)",
|
||||
"monitor.chooseHosts": "Choose hosts",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const enScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Library',
|
||||
'scripts.sidePanel.running': 'Running',
|
||||
'scripts.sidePanel.newScript': 'New script',
|
||||
'scripts.sidePanel.viewList': 'List view',
|
||||
'scripts.sidePanel.viewStacked': 'Stacked view',
|
||||
'scripts.running.empty': 'No scripts are running in this session.',
|
||||
'scripts.running.unnamed': 'Untitled script',
|
||||
'scripts.running.status.running': 'Running',
|
||||
|
||||
@@ -439,6 +439,12 @@ export const enTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.clearBusy': 'Finish restoring or recovering the vault before clearing backups.',
|
||||
'cloudSync.localBackups.clear': 'Clear all backups',
|
||||
'cloudSync.localBackups.clearConfirmTitle': 'Clear local backup history?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Permanently delete all local vault backups on this device. Your current hosts, saved credentials and cloud copies will remain. This does not prepare the device for handover. New backups will still be created during normal use.',
|
||||
'cloudSync.localBackups.clearSuccess': 'Local backup history cleared.',
|
||||
'cloudSync.localBackups.clearFailed': 'Could not clear all local backups. Please retry.',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
@@ -936,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…',
|
||||
};
|
||||
@@ -24,7 +24,7 @@ export const esMonitorMessages = {
|
||||
"monitor.stopAll": "Detener todo",
|
||||
"monitor.list": "Lista",
|
||||
"monitor.charts": "Gráficos",
|
||||
"monitor.search": "Buscar nombre, dirección o grupo",
|
||||
"monitor.search": "Buscar hosts, grupos, etiquetas o notas",
|
||||
"monitor.interval": "Intervalo de actualización",
|
||||
"monitor.range": "Rango de tendencias",
|
||||
"monitor.seconds": "Cada {count} s",
|
||||
@@ -34,6 +34,13 @@ export const esMonitorMessages = {
|
||||
"monitor.sort.name": "Nombre",
|
||||
"monitor.selectedCount": "Equipos seleccionados: {count}",
|
||||
"monitor.noMatches": "No hay equipos coincidentes",
|
||||
"monitor.latency": "Latencia",
|
||||
"monitor.responseLatency": "Latencia de respuesta (incluye procesamiento remoto)",
|
||||
"monitor.tags": "Etiquetas",
|
||||
"monitor.notes": "Notas",
|
||||
"monitor.visibleColumns": "Columnas visibles",
|
||||
"monitor.terminal": "Terminal",
|
||||
"monitor.resizeColumn": "Ajustar la columna {column}",
|
||||
"monitor.host": "Equipo",
|
||||
"monitor.hosts": "Equipos",
|
||||
"monitor.status": "Estado",
|
||||
@@ -53,8 +60,6 @@ export const esMonitorMessages = {
|
||||
"monitor.memoryTrend": "Tendencia de memoria",
|
||||
"monitor.downloadTrend": "Tendencia de descarga",
|
||||
"monitor.uploadTrend": "Tendencia de subida",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Las lecturas se conservan en memoria hasta 30 minutos y desaparecen al cerrar. Los huecos indican datos ausentes. Las conexiones compartidas usan el intervalo activo más rápido.",
|
||||
"monitor.name": "Nombre del panel",
|
||||
"monitor.description": "Descripción (opcional)",
|
||||
"monitor.chooseHosts": "Elegir equipos",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const esScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Biblioteca',
|
||||
'scripts.sidePanel.running': 'En ejecución',
|
||||
'scripts.sidePanel.newScript': 'Nuevo script',
|
||||
'scripts.sidePanel.viewList': 'Vista de lista',
|
||||
'scripts.sidePanel.viewStacked': 'Vista apilada',
|
||||
'scripts.running.empty': 'No hay scripts en ejecución en esta sesión.',
|
||||
'scripts.running.unnamed': 'Script sin título',
|
||||
'scripts.running.status.running': 'En ejecución',
|
||||
|
||||
@@ -438,6 +438,12 @@ export const esTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Descarga',
|
||||
'cloudSync.history.resolved': 'Resuelto',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.clearBusy': 'Finaliza la restauración o recuperación de la bóveda antes de eliminar las copias.',
|
||||
'cloudSync.localBackups.clear': 'Eliminar todas las copias',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '¿Eliminar el historial de copias locales?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Elimina permanentemente todas las copias locales de la bóveda en este dispositivo. Los hosts actuales, las credenciales guardadas y las copias en la nube se conservan. Esto no prepara el dispositivo para entregarlo a otra persona. Se seguirán creando nuevas copias durante el uso normal.',
|
||||
'cloudSync.localBackups.clearSuccess': 'Historial de copias locales eliminado.',
|
||||
'cloudSync.localBackups.clearFailed': 'No se pudieron eliminar todas las copias locales. Inténtalo de nuevo.',
|
||||
'cloudSync.localBackups.title': 'Historial de copias de seguridad locales',
|
||||
'cloudSync.localBackups.desc': 'Netcatty conserva puntos de restauración locales antes de los cambios de versión de la app y antes de las restauraciones de la bóveda.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Retención de copias de seguridad',
|
||||
@@ -898,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…',
|
||||
};
|
||||
@@ -23,7 +23,7 @@ export const ruMonitorMessages = {
|
||||
"monitor.stopAll": "Остановить всё",
|
||||
"monitor.list": "Список",
|
||||
"monitor.charts": "Графики",
|
||||
"monitor.search": "Имя, адрес или группа",
|
||||
"monitor.search": "Поиск узлов, групп, тегов и заметок",
|
||||
"monitor.interval": "Интервал обновления",
|
||||
"monitor.range": "Период графика",
|
||||
"monitor.seconds": "Каждые {count} с",
|
||||
@@ -33,6 +33,13 @@ export const ruMonitorMessages = {
|
||||
"monitor.sort.name": "Имя",
|
||||
"monitor.selectedCount": "Выбрано серверов: {count}",
|
||||
"monitor.noMatches": "Нет подходящих серверов",
|
||||
"monitor.latency": "Задержка",
|
||||
"monitor.responseLatency": "Задержка ответа (включая обработку на сервере)",
|
||||
"monitor.tags": "Теги",
|
||||
"monitor.notes": "Заметки",
|
||||
"monitor.visibleColumns": "Видимые столбцы",
|
||||
"monitor.terminal": "Терминал",
|
||||
"monitor.resizeColumn": "Изменить ширину столбца «{column}»",
|
||||
"monitor.host": "Сервер",
|
||||
"monitor.hosts": "Серверы",
|
||||
"monitor.status": "Состояние",
|
||||
@@ -52,8 +59,6 @@ export const ruMonitorMessages = {
|
||||
"monitor.memoryTrend": "График памяти",
|
||||
"monitor.downloadTrend": "График приёма",
|
||||
"monitor.uploadTrend": "График передачи",
|
||||
"monitor.memoryOnlyHint":
|
||||
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
|
||||
"monitor.name": "Название панели",
|
||||
"monitor.description": "Описание (необязательно)",
|
||||
"monitor.chooseHosts": "Выбрать серверы",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const ruScriptsMessages = {
|
||||
'scripts.sidePanel.library': 'Библиотека',
|
||||
'scripts.sidePanel.running': 'Выполняются',
|
||||
'scripts.sidePanel.newScript': 'Новый скрипт',
|
||||
'scripts.sidePanel.viewList': 'Список',
|
||||
'scripts.sidePanel.viewStacked': 'Плитка',
|
||||
'scripts.running.empty': 'В этой сессии не выполняются скрипты.',
|
||||
'scripts.running.unnamed': 'Скрипт без названия',
|
||||
'scripts.running.status.running': 'Выполняется',
|
||||
|
||||
@@ -457,6 +457,12 @@ export const ruTerminalMessages: Messages = {
|
||||
'cloudSync.history.download': 'Скачивание',
|
||||
'cloudSync.history.resolved': 'Разрешено',
|
||||
'cloudSync.history.error': 'Ошибка',
|
||||
'cloudSync.localBackups.clearBusy': 'Завершите восстановление хранилища перед удалением резервных копий.',
|
||||
'cloudSync.localBackups.clear': 'Удалить все копии',
|
||||
'cloudSync.localBackups.clearConfirmTitle': 'Удалить историю локальных копий?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': 'Все локальные резервные копии хранилища на этом устройстве будут удалены безвозвратно. Текущие хосты, сохранённые учётные данные и облачные копии останутся. Это не очистка устройства для передачи другому человеку. При обычном использовании новые копии будут создаваться по-прежнему.',
|
||||
'cloudSync.localBackups.clearSuccess': 'История локальных копий удалена.',
|
||||
'cloudSync.localBackups.clearFailed': 'Не удалось удалить все локальные копии. Повторите попытку.',
|
||||
'cloudSync.localBackups.title': 'История локальных резервных копий',
|
||||
'cloudSync.localBackups.desc': 'Netcatty сохраняет локальные точки восстановления перед сменой версии приложения и перед восстановлением хранилища.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Хранение резервных копий',
|
||||
@@ -943,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': 'Сохранить как…',
|
||||
};
|
||||
@@ -1,16 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import en from './en.ts';
|
||||
import ru from './ru.ts';
|
||||
import es from './es.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
test('scripts side panel view-toggle copy exists in every locale', () => {
|
||||
const KEYS = ['scripts.sidePanel.viewList', 'scripts.sidePanel.viewStacked'];
|
||||
for (const [locale, messages] of Object.entries({ en, ru, es, zhCN, zhTW })) {
|
||||
const missing = KEYS.filter((key) => !(messages as Record<string, string>)[key]);
|
||||
assert.deepEqual(missing, [], `${locale} is missing scripts view-toggle copy`);
|
||||
}
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "图表",
|
||||
"monitor.search": "搜索名称、地址或分组",
|
||||
"monitor.search": "搜索主机、分组、标签或备注",
|
||||
"monitor.interval": "更新频率",
|
||||
"monitor.range": "趋势范围",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
@@ -32,6 +32,13 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.sort.name": "名称",
|
||||
"monitor.selectedCount": "已选 {count} 台主机",
|
||||
"monitor.noMatches": "没有符合条件的主机",
|
||||
"monitor.latency": "延迟",
|
||||
"monitor.responseLatency": "响应延迟(含远端处理)",
|
||||
"monitor.tags": "标签",
|
||||
"monitor.notes": "备注",
|
||||
"monitor.visibleColumns": "显示的列",
|
||||
"monitor.terminal": "终端",
|
||||
"monitor.resizeColumn": "调整「{column}」列宽",
|
||||
"monitor.host": "主机",
|
||||
"monitor.hosts": "主机",
|
||||
"monitor.status": "状态",
|
||||
@@ -51,8 +58,6 @@ export const zhCNMonitorMessages = {
|
||||
"monitor.memoryTrend": "内存趋势",
|
||||
"monitor.downloadTrend": "下载趋势",
|
||||
"monitor.uploadTrend": "上传趋势",
|
||||
"monitor.memoryOnlyHint":
|
||||
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
|
||||
"monitor.name": "面板名称",
|
||||
"monitor.description": "说明(可选)",
|
||||
"monitor.chooseHosts": "选择主机",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const zhCNScriptsMessages = {
|
||||
'scripts.sidePanel.library': '脚本库',
|
||||
'scripts.sidePanel.running': '运行中',
|
||||
'scripts.sidePanel.newScript': '新建脚本',
|
||||
'scripts.sidePanel.viewList': '列表视图',
|
||||
'scripts.sidePanel.viewStacked': '堆叠视图',
|
||||
'scripts.running.empty': '当前会话没有正在运行的脚本。',
|
||||
'scripts.running.unnamed': '未命名脚本',
|
||||
'scripts.running.status.running': '运行中',
|
||||
|
||||
@@ -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': '另存为…',
|
||||
};
|
||||
@@ -842,6 +842,12 @@ export const zhCNVaultMessages: Messages = {
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.localBackups.clearBusy': '请先完成资料恢复或处理中断的恢复,再清空备份。',
|
||||
'cloudSync.localBackups.clear': '清空全部备份',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '清空本地备份历史?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': '将永久删除此设备上全部主机库的历史备份。当前主机、保存的凭据和云端副本仍会保留,这不代表设备已完成交接清理。以后正常使用时仍会生成新备份。',
|
||||
'cloudSync.localBackups.clearSuccess': '已清空本地备份历史。',
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本地备份,请重试。',
|
||||
'cloudSync.localBackups.title': '本地备份历史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||
|
||||
@@ -22,7 +22,7 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.stopAll": "全部停止",
|
||||
"monitor.list": "列表",
|
||||
"monitor.charts": "圖表",
|
||||
"monitor.search": "搜尋名稱、位址或群組",
|
||||
"monitor.search": "搜尋主機、群組、標籤或備註",
|
||||
"monitor.interval": "更新頻率",
|
||||
"monitor.range": "趨勢範圍",
|
||||
"monitor.seconds": "每 {count} 秒",
|
||||
@@ -32,6 +32,13 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.sort.name": "名稱",
|
||||
"monitor.selectedCount": "已選 {count} 台主機",
|
||||
"monitor.noMatches": "沒有符合條件的主機",
|
||||
"monitor.latency": "延遲",
|
||||
"monitor.responseLatency": "回應延遲(含遠端處理)",
|
||||
"monitor.tags": "標籤",
|
||||
"monitor.notes": "備註",
|
||||
"monitor.visibleColumns": "顯示的欄位",
|
||||
"monitor.terminal": "終端",
|
||||
"monitor.resizeColumn": "調整「{column}」欄寬",
|
||||
"monitor.host": "主機",
|
||||
"monitor.hosts": "主機",
|
||||
"monitor.status": "狀態",
|
||||
@@ -51,8 +58,6 @@ export const zhTWMonitorMessages = {
|
||||
"monitor.memoryTrend": "記憶體趨勢",
|
||||
"monitor.downloadTrend": "下載趨勢",
|
||||
"monitor.uploadTrend": "上傳趨勢",
|
||||
"monitor.memoryOnlyHint":
|
||||
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
|
||||
"monitor.name": "面板名稱",
|
||||
"monitor.description": "說明(選填)",
|
||||
"monitor.chooseHosts": "選擇主機",
|
||||
|
||||
@@ -36,8 +36,6 @@ export const zhTWScriptsMessages = {
|
||||
'scripts.sidePanel.library': '指令碼庫',
|
||||
'scripts.sidePanel.running': '執行中',
|
||||
'scripts.sidePanel.newScript': '新增指令碼',
|
||||
'scripts.sidePanel.viewList': '列表檢視',
|
||||
'scripts.sidePanel.viewStacked': '堆疊檢視',
|
||||
'scripts.running.empty': '目前工作階段沒有正在執行的指令碼。',
|
||||
'scripts.running.unnamed': '未命名指令碼',
|
||||
'scripts.running.status.running': '執行中',
|
||||
|
||||
@@ -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': '另存新檔…',
|
||||
};
|
||||
@@ -842,6 +842,12 @@ export const zhTWVaultMessages: Messages = {
|
||||
'cloudSync.history.download': '下載',
|
||||
'cloudSync.history.resolved': '已解決',
|
||||
'cloudSync.history.error': '錯誤',
|
||||
'cloudSync.localBackups.clearBusy': '請先完成資料還原或處理中斷的還原,再清空備份。',
|
||||
'cloudSync.localBackups.clear': '清空全部備份',
|
||||
'cloudSync.localBackups.clearConfirmTitle': '清空本機備份歷史?',
|
||||
'cloudSync.localBackups.clearConfirmDesc': '將永久刪除此裝置上全部主機庫的歷史備份。目前的主機、儲存的憑證和雲端副本仍會保留,這不代表裝置已完成交接清理。日後正常使用時仍會產生新備份。',
|
||||
'cloudSync.localBackups.clearSuccess': '已清空本機備份歷史。',
|
||||
'cloudSync.localBackups.clearFailed': '未能清空全部本機備份,請重試。',
|
||||
'cloudSync.localBackups.title': '本機備份歷史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 會在版本變化前,以及恢復主機庫前,自動留下一份本機恢復點。',
|
||||
'cloudSync.localBackups.retentionTitle': '備份保留數量',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
|
||||
import type { SyncPayload } from '../domain/sync.ts';
|
||||
|
||||
@@ -132,3 +132,89 @@ for (const localRestore of [false, true]) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function installBackupLocks(t: TestContext) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'navigator');
|
||||
let readers = 0;
|
||||
let writer: Promise<void> | null = null;
|
||||
const locks = {
|
||||
async request(_name: string, options: { mode: string; ifAvailable?: boolean }, callback: (lock: object | null) => Promise<unknown>) {
|
||||
if (options.mode === 'shared') {
|
||||
if (writer) await writer;
|
||||
readers += 1;
|
||||
try { return await callback({}); } finally { readers -= 1; }
|
||||
}
|
||||
assert.equal(options.ifAvailable, true, 'cleanup must refuse rather than queue behind a restore');
|
||||
if (readers || writer) return callback(null);
|
||||
let release!: () => void;
|
||||
writer = new Promise<void>((resolve) => { release = resolve; });
|
||||
try { return await callback({}); } finally { writer = null; release(); }
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'navigator', { configurable: true, value: { locks } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'navigator', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'navigator');
|
||||
});
|
||||
}
|
||||
|
||||
function installClearBridge(t: TestContext, clearVaultBackups: () => Promise<unknown>) {
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { clearVaultBackups } } });
|
||||
t.after(() => {
|
||||
if (previous) Object.defineProperty(globalThis, 'window', previous);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
});
|
||||
}
|
||||
|
||||
test('cleanup refuses while another restore is applying, including nested restore helpers', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, withRestoreBarrier, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
|
||||
let clears = 0;
|
||||
installClearBridge(t, async () => { clears += 1; });
|
||||
let finish!: () => void;
|
||||
let entered!: () => void;
|
||||
const active = new Promise<void>((resolve) => { entered = resolve; });
|
||||
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
||||
const restoring = withRestoreBarrier(() => withRestoreBarrier(async () => { entered(); await gate; }));
|
||||
await active;
|
||||
try {
|
||||
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
|
||||
assert.equal(clears, 0);
|
||||
} finally {
|
||||
finish();
|
||||
await restoring;
|
||||
}
|
||||
await clearLocalVaultBackups();
|
||||
assert.equal(clears, 1);
|
||||
});
|
||||
|
||||
test('restore waits for an in-progress cleanup before reading or applying data', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, withRestoreBarrier } = await import('./localVaultBackups.ts');
|
||||
let finish!: () => void;
|
||||
let entered!: () => void;
|
||||
const active = new Promise<void>((resolve) => { entered = resolve; });
|
||||
const gate = new Promise<void>((resolve) => { finish = resolve; });
|
||||
installClearBridge(t, async () => { entered(); await gate; });
|
||||
const clearing = clearLocalVaultBackups();
|
||||
await active;
|
||||
let applied = false;
|
||||
const restoring = withRestoreBarrier(async () => { applied = true; });
|
||||
await Promise.resolve();
|
||||
assert.equal(applied, false);
|
||||
finish();
|
||||
await Promise.all([clearing, restoring]);
|
||||
assert.equal(applied, true);
|
||||
});
|
||||
|
||||
test('cleanup retains recovery backups after an interrupted protected apply', async (t) => {
|
||||
installBackupLocks(t);
|
||||
const { clearLocalVaultBackups, LocalVaultBackupBusyError } = await import('./localVaultBackups.ts');
|
||||
const { STORAGE_KEY_VAULT_APPLY_IN_PROGRESS } = await import('../infrastructure/config/storageKeys.ts');
|
||||
let clears = 0;
|
||||
installClearBridge(t, async () => { clears += 1; });
|
||||
localStorageValues.set(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS, JSON.stringify({ startedAt: Date.now(), protectiveBackupId: 'recovery' }));
|
||||
await assert.rejects(clearLocalVaultBackups(), LocalVaultBackupBusyError);
|
||||
assert.equal(clears, 0);
|
||||
});
|
||||
@@ -10,6 +10,15 @@ import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { hasMeaningfulSyncData } from './syncPayload';
|
||||
|
||||
const BACKUP_APPLY_LOCK = 'netcatty:vault-backup-apply';
|
||||
|
||||
export class LocalVaultBackupBusyError extends Error {
|
||||
constructor() {
|
||||
super('Finish restoring or recovering the vault before clearing backups.');
|
||||
this.name = 'LocalVaultBackupBusyError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Snapshot the current sync data version (the integer that increments
|
||||
* on each successful cloud sync). Returns undefined when the value is
|
||||
@@ -86,6 +95,16 @@ export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCou
|
||||
await bridge?.trimVaultBackups?.({ maxCount });
|
||||
}
|
||||
|
||||
export async function clearLocalVaultBackups(): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
|
||||
if (!bridge?.clearVaultBackups || !locks) throw new Error('Local backup cleanup is unavailable.');
|
||||
await locks.request(BACKUP_APPLY_LOCK, { mode: 'exclusive', ifAvailable: true }, async (lock) => {
|
||||
if (!lock || readInterruptedVaultApply()) throw new LocalVaultBackupBusyError();
|
||||
await bridge.clearVaultBackups!();
|
||||
});
|
||||
}
|
||||
|
||||
export async function getLocalVaultBackupCapabilities(): Promise<{
|
||||
encryptionAvailable: boolean;
|
||||
}> {
|
||||
@@ -265,6 +284,15 @@ export async function withRestoreBarrier<T>(
|
||||
task: () => Promise<T>,
|
||||
holdMs: number = RESTORE_BARRIER_HOLD_MS,
|
||||
): Promise<T> {
|
||||
const locks = typeof navigator === 'undefined' ? undefined : navigator.locks;
|
||||
// Shared locks allow existing nested restore helpers. Cleanup never queues
|
||||
// an exclusive lock, so a nested helper cannot wait behind its own cleanup.
|
||||
return locks
|
||||
? locks.request(BACKUP_APPLY_LOCK, { mode: 'shared' }, () => runWithRestoreBarrier(task, holdMs))
|
||||
: runWithRestoreBarrier(task, holdMs);
|
||||
}
|
||||
|
||||
async function runWithRestoreBarrier<T>(task: () => Promise<T>, holdMs: number): Promise<T> {
|
||||
const writeDeadline = () => {
|
||||
try {
|
||||
localStorageAdapter.writeNumber(
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
publishMonitorSnapshot,
|
||||
getMonitorHistoryCursor,
|
||||
} from "./monitorRuntimeStore";
|
||||
import type { MonitorSnapshot } from "../../domain/monitorRuntime";
|
||||
|
||||
test("renderer cursor acknowledges GPU publication separately from CPU sample and history", () => {
|
||||
const snapshot: MonitorSnapshot = {
|
||||
historyMode: "replace",
|
||||
runtimeId: "gpu-cursor-test",
|
||||
epoch: 1,
|
||||
takenAt: 1000,
|
||||
running: ["panel"],
|
||||
count: 1,
|
||||
hostCount: 1,
|
||||
activeSessions: [],
|
||||
startedAt: { panel: 0 },
|
||||
hosts: {
|
||||
host: {
|
||||
historyId: "history",
|
||||
status: "live",
|
||||
at: 1000,
|
||||
statsAt: 1001,
|
||||
points: [
|
||||
{ at: 1000, cpu: 10, memory: 20, disk: null, rx: null, tx: null },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
publishMonitorSnapshot(snapshot);
|
||||
assert.deepEqual(getMonitorHistoryCursor().after.host, {
|
||||
at: 1000,
|
||||
statsAt: 1001,
|
||||
historyId: "history",
|
||||
});
|
||||
publishMonitorSnapshot({
|
||||
...snapshot,
|
||||
historyMode: "append",
|
||||
takenAt: 2000,
|
||||
hosts: { host: { ...snapshot.hosts.host, statsAt: 2001, points: [] } },
|
||||
});
|
||||
assert.deepEqual(getMonitorHistoryCursor().after.host, {
|
||||
at: 1000,
|
||||
statsAt: 2001,
|
||||
historyId: "history",
|
||||
});
|
||||
});
|
||||
@@ -22,7 +22,7 @@ export function getMonitorHistoryCursor(): MonitorHistoryCursor {
|
||||
return {
|
||||
runtimeId: snapshot.runtimeId,
|
||||
epoch: snapshot.epoch,
|
||||
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.at, historyId: host.historyId }])),
|
||||
after: Object.fromEntries(Object.entries(snapshot.hosts).map(([id, host]) => [id, { at: host.points.at(-1)?.at ?? 0, statsAt: host.statsAt ?? host.at, historyId: host.historyId }])),
|
||||
};
|
||||
}
|
||||
export function publishMonitorUnavailable() {
|
||||
|
||||
@@ -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,248 @@
|
||||
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 resizeObservers = new Map<ResizeObserver, Set<Element>>();
|
||||
const resizeCallbacks = new Map<ResizeObserver, ResizeObserverCallback>();
|
||||
class LayoutResizeObserver implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) { resizeCallbacks.set(this, callback); resizeObservers.set(this, new Set()); }
|
||||
observe(target: Element) { resizeObservers.get(this)?.add(target); }
|
||||
unobserve(target: Element) { resizeObservers.get(this)?.delete(target); }
|
||||
disconnect() { resizeObservers.delete(this); resizeCallbacks.delete(this); }
|
||||
}
|
||||
Object.defineProperty(env.window, 'ResizeObserver', { configurable: true, value: LayoutResizeObserver });
|
||||
const fonts = Object.assign(new env.window.EventTarget(), { status: 'loaded' });
|
||||
Object.defineProperty(env.document, 'fonts', { configurable: true, value: fonts });
|
||||
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 = env.document.createElement('div');
|
||||
env.document.body.append(element);
|
||||
let bounds = new env.window.DOMRect(200, 100, 500, 600);
|
||||
let reads = 0;
|
||||
element.getBoundingClientRect = () => { reads += 1; return bounds; };
|
||||
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 () => {
|
||||
await Promise.resolve();
|
||||
const pending = [...frames.values()]; frames.clear();
|
||||
for (const callback of pending) callback(0);
|
||||
});
|
||||
};
|
||||
return { layouts, overlay, tick, panel: () => panel, pendingFrames: () => frames.size,
|
||||
env, element, style, fonts, reads: () => reads, setBounds: (next: DOMRect) => { bounds = next; },
|
||||
observerCount: () => resizeObservers.size,
|
||||
resize: () => { for (const [observer, callback] of resizeCallbacks) callback([], observer); },
|
||||
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, []);
|
||||
});
|
||||
|
||||
|
||||
test('a stationary visible browser leaves no repeating layout work', async (t) => {
|
||||
const f = await fixture(t);
|
||||
for (let index = 0; index < 600; index += 1) await f.tick();
|
||||
assert.equal(f.layouts.length, 1);
|
||||
assert.equal(f.pendingFrames(), 0, 'idle browser must stop scheduling document/style/geometry scans');
|
||||
});
|
||||
|
||||
|
||||
test('layout, scroll, font, stylesheet and theme changes coalesce without resuming idle scans', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const reads = f.reads();
|
||||
f.setBounds(new f.env.window.DOMRect(100, 50, 400, 500));
|
||||
for (let index = 0; index < 20; index += 1) {
|
||||
f.resize();
|
||||
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
|
||||
f.element.dispatchEvent(new f.env.window.Event('scroll', { bubbles: true }));
|
||||
}
|
||||
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
|
||||
f.env.document.dispatchEvent(new f.env.window.Event('load'));
|
||||
f.style.colorScheme = 'light';
|
||||
f.env.document.documentElement.classList.add('light');
|
||||
assert.equal(f.reads(), reads, 'bursts do not synchronously force repeated layout reads');
|
||||
await f.tick();
|
||||
assert.equal(f.reads(), reads + 1);
|
||||
assert.deepEqual(f.layouts.at(-1)?.bounds, { x: 100, y: 50, width: 400, height: 500 });
|
||||
assert.equal(f.layouts.at(-1)?.theme, 'light');
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
f.env.document.body.setAttribute('inert', ''); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, false);
|
||||
f.env.document.body.removeAttribute('inert'); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.visible, true);
|
||||
});
|
||||
|
||||
test('resize observation follows a moving overlay and is released while hidden', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const popper = f.overlay(tooltip, 10, 10); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
popper.firstElementChild!.getBoundingClientRect = () => new f.env.window.DOMRect(220, 110, 150, 40);
|
||||
f.resize(); await f.tick();
|
||||
assert.equal(f.layouts.at(-1)?.occluded, true);
|
||||
await f.hide();
|
||||
assert.equal(f.observerCount(), 0);
|
||||
const reads = f.reads();
|
||||
f.overlay('<div role="dialog" aria-modal="true"></div>');
|
||||
f.env.window.dispatchEvent(new f.env.window.Event('resize'));
|
||||
f.fonts.dispatchEvent(new f.env.window.Event('loadingdone'));
|
||||
await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
assert.equal(f.reads(), reads);
|
||||
});
|
||||
|
||||
test('only pane/ancestor and overlay motion follows frames, then stops at settlement', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const animation = { pending: false, playState: 'running' };
|
||||
f.element.getAnimations = () => [animation as unknown as Animation];
|
||||
f.element.dispatchEvent(new f.env.window.Event('transitionrun', { bubbles: true }));
|
||||
const reads = f.reads();
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
f.setBounds(new f.env.window.DOMRect(200 + index * 10, 100, 500, 600));
|
||||
await f.tick();
|
||||
}
|
||||
assert.equal(f.reads(), reads + 5);
|
||||
assert.equal(f.layouts.at(-1)?.bounds?.x, 240);
|
||||
animation.playState = 'finished'; await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
|
||||
const spinner = f.env.document.createElement('div'); f.env.document.body.append(spinner); await f.tick();
|
||||
spinner.getAnimations = () => [animation as unknown as Animation];
|
||||
const settledReads = f.reads();
|
||||
spinner.dispatchEvent(new f.env.window.Event('animationstart', { bubbles: true }));
|
||||
await f.tick();
|
||||
assert.equal(f.reads(), settledReads, 'unrelated cursor/spinner animation does not wake the browser');
|
||||
});
|
||||
|
||||
test('removing an animating overlay cannot leave an endless layout loop', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const popper = f.overlay(tooltip);
|
||||
popper.firstElementChild!.getAnimations = () => [{ pending: false, playState: 'running' } as Animation];
|
||||
await f.tick();
|
||||
assert.equal(f.pendingFrames(), 1);
|
||||
popper.remove(); await f.tick();
|
||||
assert.equal(f.pendingFrames(), 0);
|
||||
assert.equal(f.layouts.at(-1)?.occluded, false);
|
||||
});
|
||||
|
||||
test('an old capture stays obsolete when an identical overlay is opened again', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const captures: ((result: Result) => void)[] = [];
|
||||
f.respond((layout) => layout.occluded ? new Promise((resolve) => { captures.push(resolve); }) : Promise.resolve(true));
|
||||
const first = f.overlay(tooltip); await f.tick();
|
||||
first.remove(); await f.tick();
|
||||
f.overlay(tooltip); await f.tick();
|
||||
assert.equal(captures.length, 2);
|
||||
await act(async () => captures[0]({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, [], 'the first A capture must not become valid after A -> B -> A');
|
||||
await act(async () => captures[1]({ layers }));
|
||||
assert.deepEqual(f.panel().snapshot, layers);
|
||||
});
|
||||
@@ -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,33 +32,126 @@ 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 frame: number | null = null;
|
||||
let previous = '';
|
||||
let disposed = false;
|
||||
let layoutRevision = 0;
|
||||
const element = elementRef.current;
|
||||
const doc = element?.ownerDocument;
|
||||
const view = doc?.defaultView;
|
||||
const observed = new Set<Element>();
|
||||
const animations = new Map<Animation, Element>();
|
||||
const schedule = () => {
|
||||
if (disposed || frame !== null) return;
|
||||
frame = requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
for (const [animation, target] of animations) {
|
||||
if (!target.isConnected || (!animation.pending && animation.playState !== 'running')) animations.delete(animation);
|
||||
}
|
||||
update();
|
||||
if (animations.size > 0) schedule();
|
||||
});
|
||||
};
|
||||
const trackAnimations = (target: Element) => {
|
||||
for (const animation of target.getAnimations?.() ?? []) {
|
||||
if (!animation.pending && animation.playState !== 'running') continue;
|
||||
animations.set(animation, target);
|
||||
}
|
||||
};
|
||||
const resizeObserver = isVisible && view?.ResizeObserver ? new view.ResizeObserver(schedule) : null;
|
||||
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 overlays = Array.from(document.querySelectorAll(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
|
||||
));
|
||||
const covered = modal || (!!rect && overlays.some((overlay) => overlapsBrowser(rect, overlay)));
|
||||
const targets = new Set<Element>(overlays);
|
||||
for (let target: Element | null = element; target; target = target.parentElement) targets.add(target);
|
||||
for (const target of observed) {
|
||||
if (!targets.has(target)) { resizeObserver?.unobserve(target); observed.delete(target); }
|
||||
}
|
||||
for (const target of targets) {
|
||||
if (observed.has(target)) continue;
|
||||
observed.add(target);
|
||||
resizeObserver?.observe(target);
|
||||
trackAnimations(target);
|
||||
}
|
||||
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(() => {});
|
||||
const revision = ++layoutRevision;
|
||||
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
|
||||
if (disposed || layoutRevision !== revision) return;
|
||||
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
|
||||
}).catch(() => {});
|
||||
}
|
||||
if (isVisible) frame = requestAnimationFrame(update);
|
||||
};
|
||||
if (!isVisible) {
|
||||
setSnapshot(null);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Layout, theme and portal changes coalesce into a single frame. Keep
|
||||
// following only running animations on the pane's ancestors or overlay
|
||||
// containers; unrelated spinner/cursor animations must not restart polling.
|
||||
const mutationObserver = view?.MutationObserver ? new view.MutationObserver(schedule) : null;
|
||||
if (doc) mutationObserver?.observe(doc.documentElement, {
|
||||
subtree: true, childList: true, attributes: true,
|
||||
attributeFilter: ['style', 'class', 'hidden', 'inert', 'role', 'aria-modal', 'data-state',
|
||||
'data-app-lock-overlay', 'data-radix-popper-content-wrapper', 'href', 'media', 'disabled'],
|
||||
});
|
||||
const onMotion = (event: Event) => {
|
||||
const target = event.target;
|
||||
if (!element || !view || !(target instanceof view.Element)) return;
|
||||
if (!target.contains(element) && !target.matches(
|
||||
'[role="dialog"], [role="alertdialog"], [role="menu"], [data-app-lock-overlay], '
|
||||
+ '[data-radix-popper-content-wrapper], [data-radix-popper-content-wrapper] > *',
|
||||
)) return;
|
||||
trackAnimations(target);
|
||||
schedule();
|
||||
};
|
||||
const motionEvents = ['transitionrun', 'transitionend', 'transitioncancel', 'animationstart', 'animationend', 'animationcancel'];
|
||||
for (const event of motionEvents) doc?.addEventListener(event, onMotion, true);
|
||||
doc?.addEventListener('scroll', schedule, true);
|
||||
doc?.addEventListener('load', schedule, true);
|
||||
view?.addEventListener('resize', schedule);
|
||||
doc?.fonts?.addEventListener('loadingdone', schedule);
|
||||
doc?.fonts?.addEventListener('loadingerror', schedule);
|
||||
if (doc?.fonts?.status === 'loading') void doc.fonts.ready.then(schedule);
|
||||
update();
|
||||
if (animations.size > 0) schedule();
|
||||
return () => {
|
||||
cancelAnimationFrame(frame);
|
||||
disposed = true;
|
||||
if (frame !== null) cancelAnimationFrame(frame);
|
||||
resizeObserver?.disconnect();
|
||||
mutationObserver?.disconnect();
|
||||
for (const event of motionEvents) doc?.removeEventListener(event, onMotion, true);
|
||||
doc?.removeEventListener('scroll', schedule, true);
|
||||
doc?.removeEventListener('load', schedule, true);
|
||||
view?.removeEventListener('resize', schedule);
|
||||
doc?.fonts?.removeEventListener('loadingdone', schedule);
|
||||
doc?.fonts?.removeEventListener('loadingerror', schedule);
|
||||
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
|
||||
};
|
||||
}, [browserId, isVisible]);
|
||||
@@ -58,7 +161,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 +185,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,6 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
type LocalVaultBackupPreview,
|
||||
clearLocalVaultBackups,
|
||||
getLocalVaultBackupCapabilities,
|
||||
getLocalVaultBackupMaxCount,
|
||||
listLocalVaultBackups,
|
||||
@@ -20,13 +21,15 @@ export function useLocalVaultBackups() {
|
||||
// a destructive action that might later be disabled.
|
||||
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const refreshSequence = useRef(0);
|
||||
const refreshBackups = useCallback(async () => {
|
||||
const sequence = ++refreshSequence.current;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const next = await listLocalVaultBackups();
|
||||
setBackups(next);
|
||||
if (sequence === refreshSequence.current) setBackups(next);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
if (sequence === refreshSequence.current) setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -76,6 +79,14 @@ export function useLocalVaultBackups() {
|
||||
return sanitized;
|
||||
}, [refreshBackups]);
|
||||
|
||||
const clearBackups = useCallback(async () => {
|
||||
try {
|
||||
await clearLocalVaultBackups();
|
||||
} finally {
|
||||
await refreshBackups();
|
||||
}
|
||||
}, [refreshBackups]);
|
||||
|
||||
const openBackupDirectory = useCallback(async () => {
|
||||
await openLocalVaultBackupDir();
|
||||
}, []);
|
||||
@@ -86,6 +97,7 @@ export function useLocalVaultBackups() {
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
clearBackups,
|
||||
readBackup: readLocalVaultBackup,
|
||||
setMaxBackups: updateMaxBackups,
|
||||
openBackupDirectory,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useRef, useState, type PointerEvent } from "react";
|
||||
import {
|
||||
clampMonitorColumnWidth,
|
||||
normalizeMonitorColumnWidths,
|
||||
normalizeMonitorHiddenColumns,
|
||||
type MonitorColumn,
|
||||
} from "../../domain/monitorTableLayout";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
STORAGE_KEY_MONITOR_HIDDEN_COLUMNS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
|
||||
export function useMonitorTableColumns() {
|
||||
const [widths, setWidths] = useState(() =>
|
||||
normalizeMonitorColumnWidths(
|
||||
localStorageAdapter.read(STORAGE_KEY_MONITOR_TABLE_COLUMNS),
|
||||
),
|
||||
);
|
||||
const [hidden, setHidden] = useState(() =>
|
||||
normalizeMonitorHiddenColumns(
|
||||
localStorageAdapter.read(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS),
|
||||
),
|
||||
);
|
||||
const currentHidden = useRef(hidden);
|
||||
const setVisible = (key: MonitorColumn, visible: boolean) => {
|
||||
const previous = currentHidden.current;
|
||||
const next = normalizeMonitorHiddenColumns(
|
||||
visible ? previous.filter((item) => item !== key) : [...previous, key],
|
||||
);
|
||||
currentHidden.current = next;
|
||||
setHidden(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_MONITOR_HIDDEN_COLUMNS, next);
|
||||
};
|
||||
const current = useRef(widths);
|
||||
const cleanup = useRef<(() => void) | null>(null);
|
||||
useEffect(() => () => cleanup.current?.(), []);
|
||||
const resize = (key: MonitorColumn, width: number, persist = true) => {
|
||||
current.current = {
|
||||
...current.current,
|
||||
[key]: clampMonitorColumnWidth(key, width),
|
||||
};
|
||||
setWidths(current.current);
|
||||
if (persist)
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
current.current,
|
||||
);
|
||||
};
|
||||
const startResize = (
|
||||
key: MonitorColumn,
|
||||
event: PointerEvent<HTMLElement>,
|
||||
) => {
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
cleanup.current?.();
|
||||
const pointerId = event.pointerId;
|
||||
const handle = event.currentTarget;
|
||||
handle.setPointerCapture(pointerId);
|
||||
const start = event.clientX;
|
||||
const width = current.current[key];
|
||||
const cursor = document.body.style.cursor;
|
||||
const userSelect = document.body.style.userSelect;
|
||||
document.body.style.cursor = "col-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
const move = (next: globalThis.PointerEvent) => {
|
||||
if (next.pointerId === pointerId)
|
||||
resize(key, width + next.clientX - start, false);
|
||||
};
|
||||
const finish = (next: globalThis.PointerEvent) => {
|
||||
if (next.pointerId !== pointerId) return;
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
current.current,
|
||||
);
|
||||
cleanup.current?.();
|
||||
};
|
||||
const blur = () => {
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_MONITOR_TABLE_COLUMNS,
|
||||
current.current,
|
||||
);
|
||||
cleanup.current?.();
|
||||
};
|
||||
cleanup.current = () => {
|
||||
window.removeEventListener("pointermove", move);
|
||||
window.removeEventListener("pointerup", finish);
|
||||
window.removeEventListener("pointercancel", finish);
|
||||
window.removeEventListener("blur", blur);
|
||||
if (handle.hasPointerCapture(pointerId))
|
||||
handle.releasePointerCapture(pointerId);
|
||||
document.body.style.cursor = cursor;
|
||||
document.body.style.userSelect = userSelect;
|
||||
cleanup.current = null;
|
||||
};
|
||||
window.addEventListener("pointermove", move);
|
||||
window.addEventListener("pointerup", finish);
|
||||
window.addEventListener("pointercancel", finish);
|
||||
window.addEventListener("blur", blur);
|
||||
};
|
||||
return { widths, resize, startResize, hidden, setVisible };
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
parseScriptsViewMode,
|
||||
SCRIPTS_VIEW_MODE_STORAGE_KEY,
|
||||
} from "./useScriptsViewMode.ts";
|
||||
|
||||
test("scripts view mode parser only accepts 'stacked'", () => {
|
||||
assert.equal(parseScriptsViewMode("stacked"), "stacked");
|
||||
assert.equal(parseScriptsViewMode("list"), "list");
|
||||
assert.equal(parseScriptsViewMode("grid"), "list");
|
||||
assert.equal(parseScriptsViewMode(null), "list");
|
||||
assert.equal(parseScriptsViewMode(""), "list");
|
||||
});
|
||||
|
||||
test("scripts view mode hook persists the chosen mode", () => {
|
||||
const source = readFileSync(new URL("./useScriptsViewMode.ts", import.meta.url), "utf8");
|
||||
assert.match(source, /useStoredString/);
|
||||
// The shared hook owns persistence and same-/cross-window subscriptions.
|
||||
assert.match(
|
||||
source,
|
||||
/useStoredString\(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode\)/,
|
||||
);
|
||||
assert.equal(SCRIPTS_VIEW_MODE_STORAGE_KEY.startsWith("netcatty:"), true);
|
||||
});
|
||||
@@ -1,19 +0,0 @@
|
||||
import { STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW } from "../../infrastructure/config/storageKeys";
|
||||
import { useStoredString } from "./useStoredString";
|
||||
|
||||
/** View modes for the scripts side panel library:
|
||||
* 'list' (one snippet per row, tree of packages) and 'stacked' (compact
|
||||
* wrap-around chips). */
|
||||
export type ScriptsViewMode = "list" | "stacked";
|
||||
|
||||
export const SCRIPTS_VIEW_MODE_STORAGE_KEY = STORAGE_KEY_SCRIPTS_SIDE_PANEL_VIEW;
|
||||
|
||||
/** Pure resolver so callers and tests share one validation rule. */
|
||||
export const parseScriptsViewMode = (value: string | null): ScriptsViewMode =>
|
||||
value === "stacked" ? "stacked" : "list";
|
||||
|
||||
const isScriptsViewMode = (value: string | null): value is ScriptsViewMode =>
|
||||
value === "list" || value === "stacked";
|
||||
|
||||
export const useScriptsViewMode = () =>
|
||||
useStoredString(SCRIPTS_VIEW_MODE_STORAGE_KEY, "list", isScriptsViewMode);
|
||||
@@ -1,59 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { act, create, type ReactTestRenderer } from "react-test-renderer";
|
||||
import { SCRIPTS_VIEW_MODE_STORAGE_KEY, useScriptsViewMode } from "./useScriptsViewMode.ts";
|
||||
|
||||
test("script view changes follow native storage events from another window", async (t) => {
|
||||
const windowA = new EventTarget();
|
||||
const windowB = new EventTarget();
|
||||
let activeWindow = windowA;
|
||||
const data = new Map<string, string>();
|
||||
const values = {
|
||||
localStorage: { getItem: (key: string) => data.get(key) ?? null, setItem: (key: string, value: string) => { data.set(key, value); } },
|
||||
addEventListener: (type: string, listener: EventListener) => activeWindow.addEventListener(type, listener),
|
||||
removeEventListener: (type: string, listener: EventListener) => activeWindow.removeEventListener(type, listener),
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
}
|
||||
let a!: ReturnType<typeof useScriptsViewMode>;
|
||||
let b!: ReturnType<typeof useScriptsViewMode>;
|
||||
function FirstWindow() { a = useScriptsViewMode(); return null; }
|
||||
function SecondWindow() { b = useScriptsViewMode(); return null; }
|
||||
let first!: ReactTestRenderer;
|
||||
let second!: ReactTestRenderer;
|
||||
t.after(async () => {
|
||||
activeWindow = windowA; await act(async () => first?.unmount());
|
||||
activeWindow = windowB; await act(async () => second?.unmount());
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
});
|
||||
activeWindow = windowA; await act(async () => { first = create(React.createElement(FirstWindow)); });
|
||||
activeWindow = windowB; await act(async () => { second = create(React.createElement(SecondWindow)); });
|
||||
assert.equal(a[0], "list"); assert.equal(b[0], "list");
|
||||
// BrowserWindow A writes shared storage. Electron emits the native event
|
||||
// in B, not an adapter CustomEvent from A's independent JS environment.
|
||||
activeWindow = windowA; await act(async () => a[1]("stacked"));
|
||||
assert.equal(data.get(SCRIPTS_VIEW_MODE_STORAGE_KEY), "stacked");
|
||||
assert.equal(b[0], "list");
|
||||
const notify = async (target: EventTarget, key = SCRIPTS_VIEW_MODE_STORAGE_KEY) => {
|
||||
const event = new Event("storage");
|
||||
Object.defineProperty(event, "key", { value: key });
|
||||
await act(async () => { target.dispatchEvent(event); });
|
||||
};
|
||||
activeWindow = windowB; await notify(windowB);
|
||||
assert.equal(b[0], "stacked");
|
||||
await act(async () => b[1]("list"));
|
||||
assert.equal(a[0], "stacked");
|
||||
activeWindow = windowA; await notify(windowA);
|
||||
assert.equal(a[0], "list");
|
||||
data.set(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
await notify(windowA, "unrelated");
|
||||
assert.equal(a[0], "list", "unrelated preferences must not trigger an update");
|
||||
});
|
||||
@@ -1,22 +1,48 @@
|
||||
/** Serialized Web Locks fixture for tests with a simulated browser window. */
|
||||
/** Web Locks fixture for tests with a simulated browser window. */
|
||||
export function installBrowserLockFixture(): void {
|
||||
const tails = new Map<string, Promise<void>>();
|
||||
const states = new Map<string, {
|
||||
readers: number;
|
||||
writer: boolean;
|
||||
pending: Array<{ mode: LockMode; grant: () => void }>;
|
||||
}>();
|
||||
const locks = {
|
||||
async request<T>(name: string, task: () => Promise<T>): Promise<T> {
|
||||
const previous = tails.get(name) ?? Promise.resolve();
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
const tail = previous.then(() => gate);
|
||||
tails.set(name, tail);
|
||||
await previous;
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
release();
|
||||
if (tails.get(name) === tail) tails.delete(name);
|
||||
async request<T>(
|
||||
name: string,
|
||||
optionsOrTask: LockOptions | ((lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>),
|
||||
callback?: (lock: Pick<Lock, "name" | "mode"> | null) => T | Promise<T>,
|
||||
): Promise<T> {
|
||||
const options = typeof optionsOrTask === "function" ? {} : optionsOrTask;
|
||||
const task = typeof optionsOrTask === "function" ? optionsOrTask : callback!;
|
||||
const mode = options.mode ?? "exclusive";
|
||||
const state = states.get(name) ?? { readers: 0, writer: false, pending: [] };
|
||||
states.set(name, state);
|
||||
if (options.ifAvailable && (state.writer || state.pending.length || (mode === "exclusive" && state.readers))) {
|
||||
return task(null);
|
||||
}
|
||||
const drain = () => {
|
||||
while (!state.writer && state.pending.length) {
|
||||
const next = state.pending[0];
|
||||
if (next.mode === "exclusive" && state.readers) return;
|
||||
state.pending.shift();
|
||||
if (next.mode === "exclusive") state.writer = true;
|
||||
else state.readers += 1;
|
||||
next.grant();
|
||||
}
|
||||
};
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
state.pending.push({ mode, grant: () => {
|
||||
void (async () => {
|
||||
try {
|
||||
return await task({ name, mode });
|
||||
} finally {
|
||||
if (mode === "exclusive") state.writer = false;
|
||||
else state.readers -= 1;
|
||||
drain();
|
||||
}
|
||||
})().then(resolve, reject);
|
||||
} });
|
||||
drain();
|
||||
});
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { I18nProvider } from '../application/i18n/I18nProvider';
|
||||
import { createDomRenderer, dispatchDomEvent, flushEffects, installDomEnvironment, runWithAct } from './test-support/renderReactDom';
|
||||
|
||||
test('backup cleanup confirms, preserves data on cancel, reports errors and refreshes after retry', async () => {
|
||||
const dom = installDomEnvironment();
|
||||
const globals = {
|
||||
localStorage: dom.window.localStorage,
|
||||
MutationObserver: dom.window.MutationObserver,
|
||||
NodeFilter: dom.window.NodeFilter,
|
||||
HTMLInputElement: dom.window.HTMLInputElement,
|
||||
};
|
||||
const previous = new Map(Object.keys(globals).map((key) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries(globals)) Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
Object.defineProperty(dom.window.navigator, 'locks', { configurable: true, value: {
|
||||
request: async (_name: string, _options: unknown, callback: (lock: object) => Promise<void>) => callback({}),
|
||||
} });
|
||||
const { LocalBackupsPanel } = await import('./cloud-sync/CloudSyncLocalBackupsPanel');
|
||||
const { toast } = await import('./ui/toast');
|
||||
const renderer = await createDomRenderer(dom.document);
|
||||
const notices: string[] = [];
|
||||
const success = toast.success;
|
||||
const error = toast.error;
|
||||
toast.success = (message) => { notices.push(`success:${message}`); };
|
||||
toast.error = (message) => { notices.push(`error:${message}`); };
|
||||
let calls = 0;
|
||||
let fail = true;
|
||||
let hasBackup = true;
|
||||
let finish: (() => void) | undefined;
|
||||
let finishProbe: ((result: { encryptionAvailable: boolean }) => void) | undefined;
|
||||
Object.defineProperty(dom.window, 'netcatty', { configurable: true, value: {
|
||||
// Cleanup must remain available even when encrypted backups cannot be read.
|
||||
getVaultBackupCapabilities: () => new Promise((resolve) => { finishProbe = resolve; }),
|
||||
listVaultBackups: async () => hasBackup ? [{
|
||||
id: 'fixture', createdAt: 1, reason: 'before_restore', fingerprint: 'fixture',
|
||||
preview: { hostCount: 1, keyCount: 0, snippetCount: 0, noteCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
|
||||
}] : [],
|
||||
clearVaultBackups: async () => {
|
||||
calls += 1;
|
||||
if (fail) throw new Error('access denied');
|
||||
await new Promise<void>((resolve) => { finish = resolve; });
|
||||
hasBackup = false;
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
} });
|
||||
const click = async (node: Element) => {
|
||||
await dispatchDomEvent(node, new dom.window.MouseEvent('click', { bubbles: true }));
|
||||
await flushEffects();
|
||||
};
|
||||
const button = (text: string, scope: ParentNode = dom.document) => {
|
||||
const node = [...scope.querySelectorAll('button')].find((el) => el.textContent === text);
|
||||
assert.ok(node, `button ${text}`);
|
||||
return node as HTMLButtonElement;
|
||||
};
|
||||
try {
|
||||
await renderer.render(<I18nProvider locale="en"><LocalBackupsPanel onApplyPayload={() => { throw new Error('must not restore'); }} /></I18nProvider>);
|
||||
await flushEffects();
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
|
||||
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
|
||||
await runWithAct(async () => { finishProbe?.({ encryptionAvailable: false }); });
|
||||
await flushEffects();
|
||||
assert.doesNotMatch(dom.document.body.textContent ?? '', /Master key required/);
|
||||
assert.equal([...dom.document.querySelectorAll('button')].some((el) => el.textContent === 'Restore'), false);
|
||||
await click(button('Clear all backups'));
|
||||
let dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
assert.match(dialog.textContent ?? '', /current hosts, saved credentials and cloud copies will remain/);
|
||||
await click(button('Cancel', dialog));
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(hasBackup, true);
|
||||
await click(button('Clear all backups'));
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
await click(button('Clear all backups', dialog));
|
||||
assert.equal(calls, 1);
|
||||
assert.match(notices[0], /^error:/);
|
||||
assert.equal(hasBackup, true);
|
||||
dialog = dom.document.querySelector('[role="dialog"]');
|
||||
assert.ok(dialog);
|
||||
fail = false;
|
||||
await click(button('Clear all backups', dialog));
|
||||
assert.equal(calls, 2);
|
||||
assert.equal(button('Clear all backups', dialog).disabled, true);
|
||||
assert.equal(button('Cancel', dialog).disabled, true);
|
||||
assert.equal(notices.length, 1);
|
||||
await runWithAct(async () => { finish?.(); });
|
||||
await flushEffects();
|
||||
assert.equal(dom.document.querySelector('[role="dialog"]'), null);
|
||||
assert.match(dom.document.body.textContent ?? '', /No local backups yet/);
|
||||
assert.match(notices[1], /^success:/);
|
||||
} finally {
|
||||
toast.success = success;
|
||||
toast.error = error;
|
||||
await renderer.unmount();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
dom.cleanup();
|
||||
}
|
||||
});
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { createDomRenderer, dispatchDomEvent, installDomEnvironment, runWithAct } from "./test-support/renderReactDom.tsx";
|
||||
import type { Snippet } from "../types";
|
||||
|
||||
test("stacked library continues loading after running a script and returning", async (t) => {
|
||||
const env = installDomEnvironment();
|
||||
const previous = Object.getOwnPropertyDescriptor(globalThis, "IntersectionObserver");
|
||||
const previousStorage = Object.getOwnPropertyDescriptor(globalThis, "localStorage");
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: env.window.localStorage });
|
||||
const observers: Array<{ target: Element | null; intersect: () => void }> = [];
|
||||
class Observer {
|
||||
target: Element | null = null;
|
||||
constructor(private callback: IntersectionObserverCallback) { observers.push(this); }
|
||||
observe(target: Element) { this.target = target; }
|
||||
disconnect() { this.target = null; }
|
||||
intersect = () => {
|
||||
if (this.target?.isConnected) {
|
||||
this.callback([{ target: this.target, isIntersecting: true } as IntersectionObserverEntry], this as unknown as IntersectionObserver);
|
||||
}
|
||||
};
|
||||
}
|
||||
Object.defineProperty(globalThis, "IntersectionObserver", { configurable: true, value: Observer });
|
||||
const { ScriptsSidePanel } = await import("./ScriptsSidePanel.tsx");
|
||||
const { I18nProvider } = await import("../application/i18n/I18nProvider.tsx");
|
||||
const { SCRIPTS_VIEW_MODE_STORAGE_KEY } = await import("../application/state/useScriptsViewMode.ts");
|
||||
env.window.localStorage.setItem(SCRIPTS_VIEW_MODE_STORAGE_KEY, "stacked");
|
||||
const renderer = await createDomRenderer(env.document);
|
||||
t.after(async () => {
|
||||
await renderer.unmount();
|
||||
if (previous) Object.defineProperty(globalThis, "IntersectionObserver", previous);
|
||||
else Reflect.deleteProperty(globalThis, "IntersectionObserver");
|
||||
if (previousStorage) Object.defineProperty(globalThis, "localStorage", previousStorage);
|
||||
else Reflect.deleteProperty(globalThis, "localStorage");
|
||||
env.cleanup();
|
||||
});
|
||||
const snippets: Snippet[] = Array.from({ length: 300 }, (_, i) => ({
|
||||
id: `script-${i}`, label: `Script ${String(i).padStart(3, "0")}`, command: "echo ok", kind: "script", package: "",
|
||||
}));
|
||||
const executed: string[] = [];
|
||||
await renderer.render(
|
||||
<I18nProvider locale="en"><ScriptsSidePanel snippets={snippets} packages={[]}
|
||||
onSnippetClick={() => assert.fail("scripts use the existing script execution path")}
|
||||
onRunScript={(snippet) => executed.push(snippet.id)} /></I18nProvider>,
|
||||
);
|
||||
const library = () => env.document.querySelector('[data-scripts-view="stacked"]');
|
||||
const chipCount = () => library()?.querySelectorAll("button").length;
|
||||
const button = (label: string) => {
|
||||
const result = Array.from(env.document.querySelectorAll("button")).find((element) => element.textContent === label);
|
||||
assert.ok(result, `missing button ${label}`);
|
||||
return result;
|
||||
};
|
||||
assert.equal(chipCount(), 120);
|
||||
const firstSentinel = observers.at(-1)?.target;
|
||||
assert.ok(firstSentinel?.isConnected);
|
||||
await dispatchDomEvent(button("Script 000"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.deepEqual(executed, ["script-0"]);
|
||||
assert.equal(library(), null, "execution opens Running");
|
||||
assert.equal(firstSentinel.isConnected, false);
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 120);
|
||||
assert.ok(observers.at(-1)?.target?.isConnected, "the new sentinel must be observed");
|
||||
assert.notEqual(observers.at(-1)?.target, firstSentinel);
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 240);
|
||||
await dispatchDomEvent(button("Running"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
await dispatchDomEvent(button("Library"), new env.window.MouseEvent("click", { bubbles: true }));
|
||||
assert.equal(chipCount(), 240, "returning preserves the loaded window");
|
||||
await runWithAct(() => { observers.at(-1)?.intersect(); });
|
||||
assert.equal(chipCount(), 300);
|
||||
assert.ok(button("Script 299"));
|
||||
});
|
||||
@@ -174,31 +174,3 @@ test("scripts side panel does not start a drag from a non-primary pointer", () =
|
||||
assert.match(source, /isNonPrimaryPointer/);
|
||||
assert.match(source, /primaryOnlyDragHandlers/);
|
||||
});
|
||||
|
||||
test("scripts side panel offers a stacked view toggle that persists the choice", () => {
|
||||
assert.match(source, /useScriptsViewMode/);
|
||||
const toggle = source.match(/viewStacked[\s\S]*?\{viewMode === 'stacked' \? <List size=\{14\} \/> : <LayoutGrid size=\{14\} \/>\}/);
|
||||
assert.ok(toggle, "view toggle button should swap list / stacked icons");
|
||||
assert.match(source, /aria-pressed=\{viewMode === 'stacked'\}/);
|
||||
// The chosen mode persists via the shared hook so it survives app restarts.
|
||||
assert.match(source, /useScriptsViewMode\(\)/);
|
||||
assert.match(source, /setViewMode\(viewMode === 'stacked' \? 'list' : 'stacked'\)/);
|
||||
});
|
||||
|
||||
test("scripts side panel stacked view renders wrap-around chips and keeps run behavior", () => {
|
||||
const stacked = source.match(/viewMode === 'stacked' \? \([\s\S]*?\) : \(/);
|
||||
assert.ok(stacked, "stacked branch should exist in the content area");
|
||||
assert.match(stacked[0], /flex flex-wrap/);
|
||||
// Chips reuse the same click handler (multi-select / run / execute) as rows.
|
||||
assert.match(stacked[0], /onClick=\{\(\) => handleSnippetClick\(snippet\)\}/);
|
||||
// Packages stay visible as section headers with their counts.
|
||||
assert.match(stacked[0], /item\.row\.name/);
|
||||
assert.match(stacked[0], /item\.countLabel/);
|
||||
assert.match(source, /const SnippetChip = memo/);
|
||||
// The stacked chip keeps the same context menu contract (edit / delete / run).
|
||||
const chip = source.match(/const SnippetChip = memo[\s\S]*?SnippetChip\.displayName/);
|
||||
assert.ok(chip, "SnippetChip component should exist");
|
||||
assert.match(chip[0], /ContextMenuContent/);
|
||||
assert.match(chip[0], /onRunParallel/);
|
||||
assert.match(chip[0], /onEdit/);
|
||||
});
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
Expand,
|
||||
FolderPlus,
|
||||
Layers,
|
||||
LayoutGrid,
|
||||
List,
|
||||
Minimize2,
|
||||
Package,
|
||||
Play,
|
||||
@@ -29,7 +27,6 @@ import {
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { getScriptRecordingSnapshot, subscribeScriptRecording } from '../application/state/scriptRecordingStore.ts';
|
||||
import { useScriptsViewMode } from '../application/state/useScriptsViewMode.ts';
|
||||
import { VaultDeleteConfirmDialog } from './vault/VaultDeleteConfirmDialog';
|
||||
import {
|
||||
collectSnippetPackageTreePaths,
|
||||
@@ -64,12 +61,6 @@ const toolbarIconButtonClass =
|
||||
'h-7 w-7 shrink-0 flex items-center justify-center rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors disabled:opacity-40 disabled:pointer-events-none';
|
||||
|
||||
const SCRIPT_ROW_HEIGHT = 34;
|
||||
// The stacked view renders wrapping chips that cannot use FixedSizeVirtualList,
|
||||
// so cap the initial render and grow lazily as the user scrolls to keep a huge
|
||||
// library (or a broad search) from mounting every chip at once.
|
||||
const STACKED_INITIAL_RENDER_COUNT = 120;
|
||||
const STACKED_RENDER_BATCH = 120;
|
||||
|
||||
const isRootPackagePath = (path: string): boolean => {
|
||||
const body = path.startsWith('/') ? path.slice(1) : path;
|
||||
return body.length > 0 && !body.includes('/');
|
||||
@@ -179,14 +170,10 @@ export function buildScriptsSidePanelRows({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
snippetsBeforeChildren = false,
|
||||
}: {
|
||||
snippets: Snippet[];
|
||||
packages: string[];
|
||||
expandedPaths: Set<string>;
|
||||
/** Emit each package's own snippets before its sub-packages so flat views
|
||||
* (stacked chips) keep every package's chips attached to its section. */
|
||||
snippetsBeforeChildren?: boolean;
|
||||
}): TreeRow[] {
|
||||
const normalizedPackages = new Set(collectScriptsSidePanelPackagePaths(packages, snippets));
|
||||
|
||||
@@ -263,13 +250,6 @@ export function buildScriptsSidePanelRows({
|
||||
});
|
||||
|
||||
if (!isExpanded) return;
|
||||
if (snippetsBeforeChildren) {
|
||||
// Flat stacked view: a package's chips must follow its own section
|
||||
// header, not trail after its nested children's chips.
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
return;
|
||||
}
|
||||
children.forEach((child) => walk(child, depth + 1));
|
||||
emitSnippets(localSnippets, pkg, depth);
|
||||
};
|
||||
@@ -303,7 +283,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [search, setSearch] = useState('');
|
||||
const [searchExpanded, setSearchExpanded] = useState(false);
|
||||
const [subView, setSubView] = useState<'library' | 'running'>('library');
|
||||
const [viewMode, setViewMode] = useScriptsViewMode();
|
||||
const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());
|
||||
const [isMultiSelectMode, setIsMultiSelectMode] = useState(false);
|
||||
const [selectedSnippetIds, setSelectedSnippetIds] = useState<Set<string>>(new Set());
|
||||
@@ -314,7 +293,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
const [renamingPackagePath, setRenamingPackagePath] = useState('');
|
||||
const [newPackageName, setNewPackageName] = useState('');
|
||||
const [packageError, setPackageError] = useState('');
|
||||
const [stackedRenderLimit, setStackedRenderLimit] = useState(STACKED_INITIAL_RENDER_COUNT);
|
||||
const packageDialogRef = useRef<HTMLDivElement>(null);
|
||||
const packageNameInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -509,11 +487,8 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
snippets,
|
||||
packages,
|
||||
expandedPaths,
|
||||
// Stacked chips wrap flat, so each package's chips must stay attached
|
||||
// to its own section header.
|
||||
snippetsBeforeChildren: viewMode === 'stacked',
|
||||
});
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]);
|
||||
}, [snippets, packages, expandedPaths, searchMatches, isVisible]);
|
||||
|
||||
type ScriptsListItem =
|
||||
| { key: string; kind: 'search'; snippet: Snippet }
|
||||
@@ -546,48 +521,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
});
|
||||
}, [rows, searchMatches, t, isVisible]);
|
||||
|
||||
const stackedSentinelRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Restart the lazy window whenever the underlying result set changes
|
||||
// (new search, package list changes, snippet edits, view-mode switch) — but
|
||||
// NOT on package expand/collapse. Expanding/collapsing only reshapes the
|
||||
// same result set; resetting there would drop the user's lazy-loaded window
|
||||
// and remount the package they just toggled, forcing them to re-scroll.
|
||||
// Keyed by data identity, not by listItems reference, for that reason.
|
||||
// Reset during render — not in an effect — so the first render of a new
|
||||
// result set never slices with the previous, potentially very large window.
|
||||
const stackedResultSetKey = useMemo(() => {
|
||||
if (searchMatches !== null) {
|
||||
return `search:${searchMatches.map((snippet) => snippet.id).join('\u0000')}`;
|
||||
}
|
||||
return `tree:${viewMode}:${packages.join('\u0000')}:${snippets
|
||||
.map((snippet) => snippet.id)
|
||||
.join('\u0000')}`;
|
||||
}, [searchMatches, viewMode, packages, snippets]);
|
||||
|
||||
const [prevResultSetKey, setPrevResultSetKey] = useState(stackedResultSetKey);
|
||||
if (prevResultSetKey !== stackedResultSetKey) {
|
||||
setPrevResultSetKey(stackedResultSetKey);
|
||||
setStackedRenderLimit(STACKED_INITIAL_RENDER_COUNT);
|
||||
}
|
||||
|
||||
const hasMoreStackedItems = viewMode === 'stacked' && stackedRenderLimit < listItems.length;
|
||||
|
||||
useEffect(() => {
|
||||
if (subView !== 'library' || !hasMoreStackedItems) return;
|
||||
const sentinel = stackedSentinelRef.current;
|
||||
if (!sentinel) return;
|
||||
// Re-created per batch: observing always emits an initial intersection
|
||||
// callback, so short result sets that never scroll keep loading.
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) {
|
||||
setStackedRenderLimit((limit) => limit + STACKED_RENDER_BATCH);
|
||||
}
|
||||
});
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [hasMoreStackedItems, stackedRenderLimit, subView]);
|
||||
|
||||
const handleSnippetClick = useCallback(
|
||||
(snippet: Snippet) => {
|
||||
if (isMultiSelectMode) {
|
||||
@@ -1046,30 +979,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{t('snippets.searchPlaceholder')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
toolbarIconButtonClass,
|
||||
viewMode === 'stacked' && 'bg-muted/70 text-foreground',
|
||||
)}
|
||||
disabled={!hasAnyContent}
|
||||
aria-label={viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
aria-pressed={viewMode === 'stacked'}
|
||||
onClick={() => setViewMode(viewMode === 'stacked' ? 'list' : 'stacked')}
|
||||
>
|
||||
{viewMode === 'stacked' ? <List size={14} /> : <LayoutGrid size={14} />}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{viewMode === 'stacked'
|
||||
? t('scripts.sidePanel.viewList')
|
||||
: t('scripts.sidePanel.viewStacked')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
@@ -1217,107 +1126,6 @@ const ScriptsSidePanelInner: React.FC<ScriptsSidePanelProps> = ({
|
||||
<div className="px-3 py-4 text-xs text-muted-foreground italic text-center">
|
||||
{t('common.noResultsFound')}
|
||||
</div>
|
||||
) : viewMode === 'stacked' ? (
|
||||
<div
|
||||
className="h-full overflow-auto px-2 py-1.5"
|
||||
data-scripts-view="stacked"
|
||||
>
|
||||
<div className="flex flex-wrap items-center content-start gap-1.5">
|
||||
{listItems.slice(0, stackedRenderLimit).map((item) => {
|
||||
if (item.kind === 'package') {
|
||||
// Mirror the list view's PackageRow context menu so stacked
|
||||
// mode keeps rename/delete reachable without switching views.
|
||||
const canMutatePackages = Boolean(onPackagesChange && onSnippetsChange);
|
||||
const header = (
|
||||
<button
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => togglePackage(item.row.path)}
|
||||
aria-expanded={item.row.isExpanded}
|
||||
className="w-full basis-full flex items-center gap-1.5 pt-1.5 pb-0.5 text-left rounded-sm hover:bg-accent/40 transition-colors"
|
||||
style={{ paddingLeft: 8 + Math.min(item.row.depth, 4) * 10 }}
|
||||
>
|
||||
<ChevronRight
|
||||
size={11}
|
||||
className={cn(
|
||||
'shrink-0 text-muted-foreground transition-transform',
|
||||
item.row.isExpanded && 'rotate-90',
|
||||
!item.row.hasChildren && 'opacity-0',
|
||||
)}
|
||||
/>
|
||||
<Package size={11} className="shrink-0 text-primary/80" />
|
||||
<span className="min-w-0 truncate text-[10px] font-medium text-muted-foreground">
|
||||
{item.row.name}
|
||||
</span>
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground tabular-nums">
|
||||
{item.countLabel}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
if (!canMutatePackages) {
|
||||
return header;
|
||||
}
|
||||
return (
|
||||
<ContextMenu key={item.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{header}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => openRenamePackageDialog(item.row.path)}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t('common.rename')}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => requestDeletePackage(item.row.path)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t('action.delete')}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
const snippet = item.kind === 'search' ? item.snippet : item.row.snippet;
|
||||
const isScript = isScriptSnippet(snippet);
|
||||
// Search results drop the package headers, so surface each
|
||||
// snippet's package on the chip itself to keep results
|
||||
// distinguishable (labels are not unique across packages).
|
||||
const chipSubtitle = item.kind === 'search'
|
||||
? (snippet.package || t('terminal.toolbar.library'))
|
||||
: undefined;
|
||||
return (
|
||||
<SnippetChip
|
||||
key={item.key}
|
||||
snippet={snippet}
|
||||
subtitle={chipSubtitle}
|
||||
selected={selectedSnippetIds.has(snippet.id)}
|
||||
multiSelect={isMultiSelectMode}
|
||||
onClick={() => handleSnippetClick(snippet)}
|
||||
onEdit={() => handleEditSnippet(snippet)}
|
||||
onDelete={() => handleDeleteSnippet(snippet.id)}
|
||||
onCopyCommand={() => handleCopySnippetCommand(snippet)}
|
||||
copyCommandLabel={t('scripts.actions.copyCommand')}
|
||||
onRunParallel={onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'parallel')
|
||||
: undefined}
|
||||
onRunSequential={isScript && onRunScriptOnWorkspace
|
||||
? () => onRunScriptOnWorkspace(snippet, 'sequential')
|
||||
: undefined}
|
||||
runParallelLabel={isScript
|
||||
? t('scripts.actions.runParallel')
|
||||
: t('scripts.actions.runOnAllTabs')}
|
||||
runSequentialLabel={t('scripts.actions.runSequential')}
|
||||
editLabel={t('action.edit')}
|
||||
deleteLabel={t('action.delete')}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{hasMoreStackedItems ? (
|
||||
<div ref={stackedSentinelRef} className="h-px w-full" aria-hidden="true" />
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<FixedSizeVirtualList
|
||||
className="h-full"
|
||||
@@ -1842,117 +1650,5 @@ const SnippetRow = memo<SnippetRowProps>(({
|
||||
));
|
||||
SnippetRow.displayName = 'SnippetRow';
|
||||
|
||||
interface SnippetChipProps {
|
||||
snippet: Snippet;
|
||||
/** Optional package context shown on the chip (used by stacked search
|
||||
* results, where the package headers are dropped). */
|
||||
subtitle?: string;
|
||||
selected?: boolean;
|
||||
multiSelect?: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
onRunParallel?: () => void;
|
||||
onRunSequential?: () => void;
|
||||
onCopyCommand?: () => void;
|
||||
copyCommandLabel?: string;
|
||||
runParallelLabel?: string;
|
||||
runSequentialLabel?: string;
|
||||
editLabel: string;
|
||||
deleteLabel: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact chip for the stacked (wrap) view. Shares the same click → run /
|
||||
* execute and right-click context menu as the list rows; only the layout is
|
||||
* denser. Drag reordering stays list-view-only.
|
||||
*/
|
||||
const SnippetChip = memo<SnippetChipProps>(({
|
||||
snippet,
|
||||
subtitle,
|
||||
selected = false,
|
||||
multiSelect = false,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onRunParallel,
|
||||
onRunSequential,
|
||||
onCopyCommand,
|
||||
copyCommandLabel,
|
||||
runParallelLabel,
|
||||
runSequentialLabel,
|
||||
editLabel,
|
||||
deleteLabel,
|
||||
}) => (
|
||||
<ContextMenu>
|
||||
{/* Radix ContextMenuTrigger needs a real DOM element (Tooltip.Root renders
|
||||
none), so mirror SnippetRow and wrap the tooltip in a plain div. */}
|
||||
<ContextMenuTrigger asChild>
|
||||
<div className="min-w-0 max-w-full">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-pressed={multiSelect ? selected : undefined}
|
||||
className={cn(
|
||||
'h-6 max-w-full min-w-0 px-2 rounded-md border border-border/60 bg-muted/40 hover:bg-accent/60 text-[11px] flex items-center gap-1 transition-colors',
|
||||
selected && 'bg-primary/10 border-primary/40 hover:bg-primary/15',
|
||||
)}
|
||||
>
|
||||
{multiSelect ? (
|
||||
<CheckSquare
|
||||
size={11}
|
||||
className={cn('shrink-0', selected ? 'text-primary' : 'text-muted-foreground/70')}
|
||||
/>
|
||||
) : isScriptSnippet(snippet) ? (
|
||||
<Play size={11} className="shrink-0 text-primary" />
|
||||
) : (
|
||||
<Zap size={11} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<span className="min-w-0 truncate">{snippet.label}</span>
|
||||
{subtitle ? (
|
||||
<>
|
||||
<span aria-hidden="true" className="shrink-0 text-muted-foreground/50">·</span>
|
||||
<span className="min-w-0 truncate text-[10px] text-muted-foreground/80">
|
||||
{subtitle}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" align="start">
|
||||
<SnippetCommandTooltipContent label={snippet.label} command={snippet.command} />
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{onRunParallel ? (
|
||||
<ContextMenuItem onClick={onRunParallel}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runParallelLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onRunSequential ? (
|
||||
<ContextMenuItem onClick={onRunSequential}>
|
||||
<Layers className="mr-2 h-4 w-4" /> {runSequentialLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
{onCopyCommand ? (
|
||||
<ContextMenuItem onClick={onCopyCommand}>
|
||||
<Copy className="mr-2 h-4 w-4" /> {copyCommandLabel}
|
||||
</ContextMenuItem>
|
||||
) : null}
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {editLabel}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {deleteLabel}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
));
|
||||
SnippetChip.displayName = 'SnippetChip';
|
||||
|
||||
export const ScriptsSidePanel = memo(ScriptsSidePanelInner);
|
||||
ScriptsSidePanel.displayName = 'ScriptsSidePanel';
|
||||
@@ -14,9 +14,11 @@ import {
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
import { useLocalVaultBackups } from '../../application/state/useLocalVaultBackups';
|
||||
import {
|
||||
LocalVaultBackupBusyError,
|
||||
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
withRestoreBarrier,
|
||||
@@ -29,6 +31,7 @@ import { cn } from '../../lib/utils';
|
||||
import { Button } from '../ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
|
||||
import { Input } from '../ui/input';
|
||||
import { ConfirmDialog } from '../ui/confirm-dialog';
|
||||
import { toast } from '../ui/toast';
|
||||
|
||||
// ============================================================================
|
||||
@@ -54,10 +57,13 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
clearBackups,
|
||||
readBackup,
|
||||
setMaxBackups,
|
||||
openBackupDirectory,
|
||||
} = useLocalVaultBackups();
|
||||
const [showClearDialog, setShowClearDialog] = useState(false);
|
||||
const [isClearing, setIsClearing] = useState(false);
|
||||
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
|
||||
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
|
||||
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
|
||||
@@ -175,34 +181,34 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAllowed = restoreDisabledReason === null;
|
||||
// While encryptionAvailable is still `null` we're mid-probe — render the
|
||||
// restore button as disabled so the user never sees a path they can't
|
||||
// actually take (I1 surface). Once resolved, `false` hides the panel body
|
||||
// via the unavailable banner below.
|
||||
const encryptionResolved = encryptionAvailable !== null;
|
||||
const encryptionUsable = encryptionAvailable === true;
|
||||
const performClear = async () => {
|
||||
if (isClearing) return;
|
||||
setIsClearing(true);
|
||||
try {
|
||||
await clearBackups();
|
||||
setShowClearDialog(false);
|
||||
toast.success(t('cloudSync.localBackups.clearSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(t(error instanceof LocalVaultBackupBusyError
|
||||
? 'cloudSync.localBackups.clearBusy'
|
||||
: 'cloudSync.localBackups.clearFailed'), t('sync.toast.errorTitle'));
|
||||
} finally {
|
||||
setIsClearing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// safeStorage probe finished and returned "not available" → disable the
|
||||
// panel entirely; the main process refuses to write in this state (I1).
|
||||
if (encryptionResolved && !encryptionUsable) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm font-medium">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.unavailableDesc')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const restoreAllowed = restoreDisabledReason === null && encryptionAvailable === true;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{encryptionAvailable === false && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="text-sm font-medium text-amber-600 dark:text-amber-400">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">{t('cloudSync.localBackups.unavailableDesc')}</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-lg">
|
||||
@@ -224,7 +230,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveMaxBackups()}
|
||||
disabled={isSavingMaxBackups}
|
||||
disabled={isSavingMaxBackups || isClearing || restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
|
||||
@@ -235,7 +241,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!restoreAllowed && (
|
||||
{restoreDisabledReason !== null && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
|
||||
<AlertTriangle size={14} />
|
||||
@@ -248,19 +254,19 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshBackups()}
|
||||
disabled={isLoading}
|
||||
disabled={isLoading || isClearing}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
|
||||
@@ -275,6 +281,16 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<FolderOpen size={14} />
|
||||
{t('settings.system.openFolder')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowClearDialog(true)}
|
||||
disabled={isLoading || isClearing || isSavingMaxBackups || restoringBackupId !== null}
|
||||
className="gap-1 text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t('cloudSync.localBackups.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -339,7 +355,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
// clicks here would interleave destructive
|
||||
// writes and the second run's sentinel-clear
|
||||
// could mask a still-partial first apply.
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId === backup.id ? (
|
||||
@@ -356,6 +372,17 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={showClearDialog}
|
||||
title={t('cloudSync.localBackups.clearConfirmTitle')}
|
||||
message={t('cloudSync.localBackups.clearConfirmDesc')}
|
||||
confirmLabel={t('cloudSync.localBackups.clear')}
|
||||
busy={isClearing}
|
||||
destructive
|
||||
onOpenChange={(open) => { if (!isClearing) setShowClearDialog(open); }}
|
||||
onConfirm={() => void performClear()}
|
||||
/>
|
||||
|
||||
{/* Restore confirmation dialog (I2). Keeps the destructive action
|
||||
gated behind an explicit second click, mirroring the clear-local
|
||||
dialog elsewhere in this screen. */}
|
||||
@@ -416,7 +443,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(null)}
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
>
|
||||
{t('cloudSync.localBackups.restoreConfirmCancel')}
|
||||
</Button>
|
||||
@@ -428,7 +455,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
setPendingRestoreBackup(null);
|
||||
await performRestore(target.id);
|
||||
}}
|
||||
disabled={restoringBackupId !== null}
|
||||
disabled={restoringBackupId !== null || isClearing}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId !== null ? (
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -145,10 +145,16 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
|
||||
};
|
||||
|
||||
// Content Component (wraps children with scroll)
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
|
||||
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string; scrollMode?: "custom" | "native" }> = ({
|
||||
children,
|
||||
className,
|
||||
scrollMode = "custom",
|
||||
}) => {
|
||||
if (scrollMode === "native") return (
|
||||
<div data-aside-scroll-viewport className={cn("flex-1 min-h-0 min-w-0 overflow-y-auto overscroll-y-contain host-tree-notes-scroll", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-hidden">{children}</div>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<ScrollArea className={cn("flex-1 min-w-0 [&>[data-radix-scroll-area-viewport]>div]:!block [&>[data-radix-scroll-area-viewport]>div]:!min-w-0", className)}>
|
||||
<div className="p-4 space-y-4 min-w-0 overflow-hidden">
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
import React, { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
|
||||
import {
|
||||
MONITOR_CARD_HEIGHT,
|
||||
MONITOR_COLUMNS,
|
||||
monitorGridColumns,
|
||||
type MonitorColumn,
|
||||
} from "../../domain/monitorTableLayout";
|
||||
import { useMonitorTableColumns } from "../../application/state/useMonitorTableColumns";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuLabel,
|
||||
} from "../ui/context-menu";
|
||||
|
||||
export function MonitorHostCollection<T extends { id: string }>({
|
||||
items,
|
||||
view,
|
||||
layoutKey,
|
||||
pinnedId,
|
||||
renderCard,
|
||||
renderCells,
|
||||
rowProps,
|
||||
renderMenu,
|
||||
}: {
|
||||
items: T[];
|
||||
view: "list" | "charts";
|
||||
layoutKey: string;
|
||||
pinnedId?: string;
|
||||
renderCard: (item: T) => React.ReactNode;
|
||||
renderCells: (item: T) => Record<MonitorColumn, React.ReactNode>;
|
||||
rowProps: (item: T) => React.HTMLAttributes<HTMLTableRowElement>;
|
||||
renderMenu: (item: T) => React.ReactNode;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [width, setWidth] = useState(1248);
|
||||
const { widths, resize, startResize, hidden, setVisible } =
|
||||
useMonitorTableColumns();
|
||||
const visibleColumns = MONITOR_COLUMNS.filter(
|
||||
({ key }) => !hidden.includes(key),
|
||||
);
|
||||
const columns =
|
||||
view === "charts" ? monitorGridColumns(width, items.length) : 1;
|
||||
const scrollMargin = view === "charts" ? 16 : 56;
|
||||
const pinnedIndex = pinnedId
|
||||
? items.findIndex((item) => item.id === pinnedId)
|
||||
: -1;
|
||||
const getItemKey = useCallback(
|
||||
(index: number) => items[index * columns]?.id ?? index,
|
||||
[items, columns],
|
||||
);
|
||||
const virtualizer = useVirtualizer<HTMLDivElement, HTMLElement>({
|
||||
count: Math.ceil(items.length / columns),
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => (view === "charts" ? MONITOR_CARD_HEIGHT : 76),
|
||||
gap: view === "charts" ? 16 : 8,
|
||||
overscan: view === "charts" ? 2 : 5,
|
||||
getItemKey,
|
||||
scrollMargin,
|
||||
initialRect: { width: 1280, height: 800 },
|
||||
rangeExtractor: useCallback(
|
||||
(range) => {
|
||||
const visible = defaultRangeExtractor(range);
|
||||
const pinned = Math.floor(pinnedIndex / columns);
|
||||
return pinnedIndex < 0 || visible.includes(pinned)
|
||||
? visible
|
||||
: [...visible, pinned].sort((a, b) => a - b);
|
||||
},
|
||||
[pinnedIndex, columns],
|
||||
),
|
||||
});
|
||||
useLayoutEffect(() => {
|
||||
const element = scrollRef.current;
|
||||
if (!element) return;
|
||||
const measure = () => setWidth(Math.max(0, element.clientWidth));
|
||||
measure();
|
||||
const observer = new ResizeObserver(measure);
|
||||
observer.observe(element);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
useLayoutEffect(() => {
|
||||
virtualizer.measure();
|
||||
}, [columns, view, hidden, virtualizer]);
|
||||
useLayoutEffect(() => {
|
||||
if (scrollRef.current) scrollRef.current.scrollTop = 0;
|
||||
virtualizer.scrollToOffset(0);
|
||||
}, [layoutKey, virtualizer]);
|
||||
const template = visibleColumns
|
||||
.map((column) => `${widths[column.key]}px`)
|
||||
.join(" ");
|
||||
const tableWidth = visibleColumns.reduce(
|
||||
(sum, column) => sum + widths[column.key],
|
||||
0,
|
||||
);
|
||||
return (
|
||||
<div className="min-h-0 min-w-0 flex-1 px-4">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="h-full min-w-0 overflow-auto"
|
||||
data-section="vault-monitor-list"
|
||||
data-monitor-virtual={view}
|
||||
>
|
||||
<div className="py-4">
|
||||
{view === "charts" ? (
|
||||
<div
|
||||
className="relative"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((row) => (
|
||||
<div
|
||||
key={row.key}
|
||||
data-monitor-virtual-row={row.index}
|
||||
className="absolute left-0 top-0 grid w-full gap-4"
|
||||
style={{
|
||||
height: MONITOR_CARD_HEIGHT,
|
||||
gridTemplateColumns: `repeat(${columns},minmax(0,1fr))`,
|
||||
transform: `translateY(${row.start - scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{items
|
||||
.slice(row.index * columns, (row.index + 1) * columns)
|
||||
.map((item) => (
|
||||
<ContextMenu key={item.id}>
|
||||
<ContextMenuTrigger asChild>
|
||||
{renderCard(item)}
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{renderMenu(item)}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<table
|
||||
className="block text-xs"
|
||||
style={{ width: tableWidth }}
|
||||
aria-rowcount={items.length + 1}
|
||||
>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<thead className="sticky top-0 z-20 block bg-background text-muted-foreground">
|
||||
<tr
|
||||
className="grid h-10"
|
||||
style={{ gridTemplateColumns: template }}
|
||||
>
|
||||
{visibleColumns.map((column) => (
|
||||
<th
|
||||
key={column.key}
|
||||
data-monitor-column={column.key}
|
||||
className={`relative flex min-w-0 items-center px-3 text-left text-[11px] font-medium${column.key === "terminal" ? " sticky right-0 z-10 bg-background" : ""}`}
|
||||
>
|
||||
<span
|
||||
className="truncate"
|
||||
title={t(`monitor.${column.key}`)}
|
||||
>
|
||||
{column.key === "terminal" ? (
|
||||
<span className="sr-only">
|
||||
{t("monitor.openTerminal")}
|
||||
</span>
|
||||
) : (
|
||||
t(`monitor.${column.key}`)
|
||||
)}
|
||||
</span>
|
||||
<div
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={t("monitor.resizeColumn", {
|
||||
column: t(`monitor.${column.key}`),
|
||||
})}
|
||||
aria-valuenow={widths[column.key]}
|
||||
aria-valuemin={column.min}
|
||||
aria-valuemax={
|
||||
column.key === "terminal" ? 200 : 640
|
||||
}
|
||||
tabIndex={0}
|
||||
className="absolute inset-y-1 right-0 w-2 cursor-col-resize touch-none outline-none after:absolute after:inset-y-1 after:right-0.5 after:w-px hover:after:bg-primary focus-visible:after:bg-primary"
|
||||
onPointerDown={(event) =>
|
||||
startResize(column.key, event)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
resize(column.key, column.width)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowLeft" ||
|
||||
event.key === "ArrowRight"
|
||||
) {
|
||||
event.preventDefault();
|
||||
resize(
|
||||
column.key,
|
||||
widths[column.key] +
|
||||
(event.key === "ArrowRight" ? 1 : -1) *
|
||||
(event.shiftKey ? 32 : 8),
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent data-monitor-column-menu>
|
||||
<ContextMenuLabel>
|
||||
{t("monitor.visibleColumns")}
|
||||
</ContextMenuLabel>
|
||||
{MONITOR_COLUMNS.map(({ key }) => (
|
||||
<ContextMenuCheckboxItem
|
||||
key={key}
|
||||
data-monitor-toggle-column={key}
|
||||
checked={!hidden.includes(key)}
|
||||
disabled={key === "host" || key === "terminal"}
|
||||
onSelect={(event) => event.preventDefault()}
|
||||
onCheckedChange={(checked) =>
|
||||
setVisible(key, checked === true)
|
||||
}
|
||||
>
|
||||
{t(`monitor.${key}`)}
|
||||
</ContextMenuCheckboxItem>
|
||||
))}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
<tbody
|
||||
className="relative block"
|
||||
style={{ height: virtualizer.getTotalSize() }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((row) => {
|
||||
const cells = renderCells(items[row.index]);
|
||||
return (
|
||||
<ContextMenu key={row.key}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<tr
|
||||
{...rowProps(items[row.index])}
|
||||
ref={virtualizer.measureElement}
|
||||
data-index={row.index}
|
||||
aria-rowindex={row.index + 2}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: template,
|
||||
minHeight: 72,
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: tableWidth,
|
||||
transform: `translateY(${row.start - scrollMargin}px)`,
|
||||
}}
|
||||
>
|
||||
{visibleColumns.map(({ key }) => (
|
||||
<React.Fragment key={key}>
|
||||
{cells[key]}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tr>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
{renderMenu(items[row.index])}
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import React from "react";
|
||||
import { useI18n } from "../../application/i18n/I18nProvider";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip";
|
||||
import { Badge } from "../ui/badge";
|
||||
|
||||
export function MonitorHostLatency({
|
||||
value,
|
||||
responseValue,
|
||||
}: {
|
||||
value?: number | null;
|
||||
responseValue?: number | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const isValid = (n: number | null | undefined): n is number =>
|
||||
typeof n === "number" && Number.isFinite(n) && n >= 0;
|
||||
const usesResponse = !isValid(value) && isValid(responseValue);
|
||||
const latency = usesResponse ? responseValue : value;
|
||||
const known = isValid(latency);
|
||||
const bars = !known ? 0 : latency <= 100 ? 4 : latency <= 300 ? 2 : 1;
|
||||
const tone = !known
|
||||
? "text-muted-foreground"
|
||||
: bars === 4
|
||||
? "text-emerald-600 dark:text-emerald-400"
|
||||
: bars === 2
|
||||
? "text-amber-500 dark:text-amber-400"
|
||||
: "text-red-600 dark:text-red-400";
|
||||
const label = `${t(usesResponse ? "monitor.responseLatency" : "monitor.latency")}: ${known ? `${Number(latency.toFixed(1))} ms` : "--"}`;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={`inline-flex h-5 shrink-0 items-center ${tone}`}
|
||||
data-monitor-latency
|
||||
data-monitor-signal-bars={bars}
|
||||
>
|
||||
<svg width="18" height="14" viewBox="0 0 18 14" aria-hidden="true">
|
||||
{[4, 7, 10, 13].map((height, index) => (
|
||||
<rect
|
||||
key={height}
|
||||
x={1 + index * 4}
|
||||
y={14 - height}
|
||||
width="3"
|
||||
height={height}
|
||||
rx="0.8"
|
||||
fill="currentColor"
|
||||
opacity={index < bars ? 1 : 0.18}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={tooltipClass}>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipClass =
|
||||
"max-w-sm whitespace-pre-wrap break-words bg-popover text-popover-foreground shadow-md ring-1 ring-border/40";
|
||||
export function MonitorHostTags({ tags = [] }: { tags?: string[] }) {
|
||||
if (!tags.length)
|
||||
return (
|
||||
<span className="inline-flex h-5 items-center text-[10px] text-muted-foreground">
|
||||
--
|
||||
</span>
|
||||
);
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className="flex h-5 min-w-0 max-w-full items-center gap-1 overflow-hidden"
|
||||
data-monitor-tags
|
||||
>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="inline-block max-w-20 truncate border-0 px-1.5 py-0 text-[10px] font-normal leading-5"
|
||||
>
|
||||
{tags[0]}
|
||||
</Badge>
|
||||
{tags.length > 1 && (
|
||||
<span className="shrink-0 text-[10px] text-muted-foreground">
|
||||
+{tags.length - 1}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={tooltipClass}>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="max-w-full break-all text-[10px] font-normal"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
export function MonitorHostNotes({
|
||||
notes,
|
||||
focusable = false,
|
||||
}: {
|
||||
notes?: string;
|
||||
focusable?: boolean;
|
||||
}) {
|
||||
if (!notes?.trim())
|
||||
return <span className="text-[10px] text-muted-foreground">--</span>;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
data-monitor-notes
|
||||
tabIndex={focusable ? 0 : undefined}
|
||||
className="block min-w-0 truncate text-[10px] text-muted-foreground outline-none focus-visible:ring-1 focus-visible:ring-primary"
|
||||
>
|
||||
{notes}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={`${tooltipClass} max-h-64 overflow-y-auto`}>
|
||||
{notes}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,26 @@ import type { Host } from "../../domain/models";
|
||||
import type { MonitorPoint } from "../../domain/monitorRuntime";
|
||||
import { MonitorPanels, MonitorTrend } from "./MonitorPanels";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
import { MonitorHostLatency } from "./MonitorHostMetadata";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
|
||||
test("latency signals use the stats response when transport ping is unavailable and prefer valid ping readings", () => {
|
||||
for (const [ping, response, bars, label] of [
|
||||
[null, 145, "2", "Response latency (includes remote processing): 145 ms"],
|
||||
[0, 500, "4", "Latency: 0 ms"],
|
||||
[NaN, 450, "1", "Response latency (includes remote processing): 450 ms"],
|
||||
[null, null, "0", "Latency: --"],
|
||||
] as const) {
|
||||
const document = new JSDOM(renderToStaticMarkup(
|
||||
<I18nProvider locale="en"><TooltipProvider>
|
||||
<MonitorHostLatency value={ping} responseValue={response} />
|
||||
</TooltipProvider></I18nProvider>,
|
||||
)).window.document;
|
||||
const signal = document.querySelector("[data-monitor-latency]")!;
|
||||
assert.equal(signal.getAttribute("data-monitor-signal-bars"), bars);
|
||||
assert.equal(signal.getAttribute("aria-label"), label);
|
||||
}
|
||||
});
|
||||
|
||||
const now = 1_800_000_000_000;
|
||||
const point = (ago: number, cpu: number | null): MonitorPoint => ({
|
||||
@@ -161,7 +181,7 @@ for (const view of ["list", "charts"] as const) {
|
||||
assert.equal(document.querySelector("header .lucide-gauge"), null);
|
||||
assert.equal(document.querySelector(".lucide-activity"), null);
|
||||
if (view === "list") {
|
||||
const network = document.querySelector("tbody tr")!.children[5];
|
||||
const network = document.querySelector("[data-monitor-cell=network]")!;
|
||||
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
|
||||
}
|
||||
});
|
||||
|
||||
+370
-261
@@ -6,6 +6,7 @@ import {
|
||||
Copy,
|
||||
Cpu,
|
||||
Gauge,
|
||||
Gpu,
|
||||
HardDrive,
|
||||
LayoutGrid,
|
||||
List,
|
||||
@@ -38,6 +39,7 @@ import type { Host } from "../../domain/models";
|
||||
import { buildMonitorTrendGeometry, findMonitorChartPoint } from "../../domain/monitorChart";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { Button } from "../ui/button";
|
||||
import { ContextMenuItem } from "../ui/context-menu";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
@@ -70,6 +72,9 @@ import { ResourceBar } from "../systemManager/ResourceBar";
|
||||
import { MonitorDiskUsage } from "./MonitorDiskUsage";
|
||||
import { VaultHeaderSearch, VaultPageHeader, vaultHeaderIconButtonClass, vaultHeaderSecondaryButtonClass } from "./VaultPageHeader";
|
||||
import { toast } from "../ui/toast";
|
||||
import { TooltipProvider } from "../ui/tooltip";
|
||||
import { MonitorHostCollection } from "./MonitorHostCollection";
|
||||
import { MonitorHostLatency, MonitorHostNotes, MonitorHostTags } from "./MonitorHostMetadata";
|
||||
|
||||
const emptyMonitorPoints: MonitorPoint[] = [];
|
||||
const percent = (n: number | null | undefined) =>
|
||||
@@ -214,22 +219,15 @@ export function MonitorPanels({
|
||||
} | null>(null);
|
||||
const [selecting, setSelecting] = useState(false);
|
||||
const [detail, setDetail] = useState<string | null>(null);
|
||||
const [recovery, setRecovery] = useState<{ hostId: string; username: string; password: string } | null>(null);
|
||||
const [deleting, setDeleting] = useState<MonitorPanel | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [login, setLogin] = useState({
|
||||
hostId: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
useEffect(
|
||||
() => setLogin({ hostId: "", username: "", password: "" }),
|
||||
[detail],
|
||||
);
|
||||
const panel = panels.find((p) => p.id === selected) ?? panels[0];
|
||||
const running = !!panel && runtime.running.includes(panel.id);
|
||||
const startEdit = (base: MonitorPanel | null) => {
|
||||
setDetail(null);
|
||||
setRecovery(null);
|
||||
setSelecting(false);
|
||||
setError(null);
|
||||
setEditor({
|
||||
@@ -305,28 +303,39 @@ export function MonitorPanels({
|
||||
toast.error(t("monitor.saveFailed"));
|
||||
}
|
||||
};
|
||||
const hostById = useMemo(
|
||||
() => new Map(hosts.map((host) => [host.id, host])),
|
||||
[hosts],
|
||||
);
|
||||
const orderedHostIds = useMemo(() => {
|
||||
if (!panel) return [];
|
||||
return panel.sort === "manual" ? panel.hostIds : [...panel.hostIds].sort((a, b) =>
|
||||
(hosts.find((host) => host.id === a)?.label ?? a).localeCompare(hosts.find((host) => host.id === b)?.label ?? b),
|
||||
);
|
||||
}, [panel, hosts]);
|
||||
return panel.sort === "manual"
|
||||
? panel.hostIds
|
||||
: [...panel.hostIds].sort((a, b) =>
|
||||
(hostById.get(a)?.label ?? a).localeCompare(
|
||||
hostById.get(b)?.label ?? b,
|
||||
),
|
||||
);
|
||||
}, [panel, hostById]);
|
||||
const rows = useMemo(() => {
|
||||
if (!panel) return [];
|
||||
const q = search.trim().toLocaleLowerCase();
|
||||
const rows = orderedHostIds.map((id) => ({
|
||||
id,
|
||||
host: hosts.find((h) => h.id === id),
|
||||
host: hostById.get(id),
|
||||
state: monitorStateSince(runtime.hosts[id], runtime.startedAt[panel.id]),
|
||||
}));
|
||||
return rows.filter(
|
||||
({ host }) => !q ||
|
||||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""}`
|
||||
({ host }) =>
|
||||
!q ||
|
||||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""} ${host?.tags.join(" ") ?? ""} ${host?.notes ?? ""}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(q),
|
||||
);
|
||||
}, [panel, orderedHostIds, hosts, runtime, search]);
|
||||
const dragProps = (hostId: string): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
|
||||
}, [panel, orderedHostIds, hostById, runtime, search]);
|
||||
const dragProps = (
|
||||
hostId: string,
|
||||
): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
|
||||
draggable: true,
|
||||
"data-monitor-host": hostId,
|
||||
onDragStart: (event) => {
|
||||
@@ -336,7 +345,12 @@ export function MonitorPanels({
|
||||
setDragging({ panelId: panel.id, hostId });
|
||||
},
|
||||
onDragOver: (event) => {
|
||||
if (!panel || dragging?.panelId !== panel.id || dragging.hostId === hostId) return;
|
||||
if (
|
||||
!panel ||
|
||||
dragging?.panelId !== panel.id ||
|
||||
dragging.hostId === hostId
|
||||
)
|
||||
return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
setDropTarget(hostId);
|
||||
@@ -344,10 +358,11 @@ export function MonitorPanels({
|
||||
onDrop: (event) => {
|
||||
if (!panel || dragging?.panelId !== panel.id) return;
|
||||
event.preventDefault();
|
||||
if (dragging.hostId !== hostId) void patchPanel({
|
||||
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
|
||||
sort: "manual",
|
||||
});
|
||||
if (dragging.hostId !== hostId)
|
||||
void patchPanel({
|
||||
hostIds: reorderMonitorHosts(orderedHostIds, dragging.hostId, hostId),
|
||||
sort: "manual",
|
||||
});
|
||||
setDragging(null);
|
||||
setDropTarget(null);
|
||||
},
|
||||
@@ -362,18 +377,24 @@ export function MonitorPanels({
|
||||
: !running
|
||||
? "stopped"
|
||||
: state?.status === "live" &&
|
||||
Date.now() - state.at > Math.max(5000, (panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500)
|
||||
Date.now() - state.at >
|
||||
Math.max(
|
||||
5000,
|
||||
(panel?.interval ?? DEFAULT_MONITOR_INTERVAL) * 2500,
|
||||
)
|
||||
? "stale"
|
||||
: (state?.status ?? "queued");
|
||||
const stateBadge = (host: Host | undefined, state?: MonitorHostState) => (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
|
||||
"inline-flex min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
|
||||
status(host, state) === "live"
|
||||
? "text-emerald-700 dark:text-emerald-400"
|
||||
: ["error", "disconnected", "missing"].includes(status(host, state))
|
||||
? "text-destructive"
|
||||
: ["login", "stale", "unsupported", "limit"].includes(status(host, state))
|
||||
: ["login", "stale", "unsupported", "limit"].includes(
|
||||
status(host, state),
|
||||
)
|
||||
? "text-amber-700 dark:text-amber-400"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
@@ -382,7 +403,9 @@ export function MonitorPanels({
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-1.5 rounded-full bg-current"
|
||||
/>
|
||||
{t(`monitor.status.${status(host, state)}`)}
|
||||
<span className="truncate">
|
||||
{t(`monitor.status.${status(host, state)}`)}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
const renderMetrics = (
|
||||
@@ -445,6 +468,10 @@ export function MonitorPanels({
|
||||
};
|
||||
const hostDetail = hosts.find((h) => h.id === detail);
|
||||
const detailState = detail && panel ? monitorStateSince(runtime.hosts[detail], runtime.startedAt[panel.id]) : undefined;
|
||||
const detailAvailable = status(hostDetail, detailState) === "live";
|
||||
useEffect(() => {
|
||||
if (detail && !detailAvailable) setDetail(null);
|
||||
}, [detail, detailAvailable]);
|
||||
return (
|
||||
<section
|
||||
className="relative flex min-h-0 min-w-0 flex-1 overflow-hidden"
|
||||
@@ -647,35 +674,76 @@ export function MonitorPanels({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-auto" data-section="vault-monitor-list">
|
||||
<div className="p-4">
|
||||
{!rows.length ? (
|
||||
<p className="p-8 text-center text-sm text-muted-foreground">
|
||||
{t("monitor.noMatches")}
|
||||
</p>
|
||||
) : panel.view === "charts" ? (
|
||||
<div className="grid auto-rows-fr grid-cols-[repeat(auto-fit,minmax(min(100%,280px),1fr))] gap-4">
|
||||
{rows.map(({ id, host, state }) => (
|
||||
{!rows.length ? (
|
||||
<div className="flex-1 overflow-auto p-12 text-center text-sm text-muted-foreground">
|
||||
{t("monitor.noMatches")}
|
||||
</div>
|
||||
) : (
|
||||
<TooltipProvider delayDuration={180}>
|
||||
<MonitorHostCollection
|
||||
items={rows}
|
||||
view={panel.view}
|
||||
layoutKey={`${panel.id}:${panel.view}:${search}`}
|
||||
pinnedId={
|
||||
dragging?.panelId === panel.id ? dragging.hostId : undefined
|
||||
}
|
||||
renderMenu={({ id, host, state }) => (
|
||||
<>
|
||||
<ContextMenuItem disabled={!host} onSelect={() => host && onConnect(host)}>
|
||||
{t("monitor.openTerminal")}
|
||||
</ContextMenuItem>
|
||||
{host && running && ["login", "error", "disconnected"].includes(status(host, state)) && (
|
||||
<ContextMenuItem data-monitor-retry={id} onSelect={() => setRecovery({hostId: id, username: host.username ?? "", password: ""})}>
|
||||
{t("monitor.retry")}
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
rowProps={({ id, host, state }) => ({
|
||||
...dragProps(id),
|
||||
className: cn(
|
||||
"group cursor-default [&>td]:bg-card/70 hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td]:min-w-0 [&>td]:px-3 [&>td]:py-3 [&>td]:flex [&>td]:items-center",
|
||||
dragging?.panelId === panel.id &&
|
||||
dragging.hostId === id &&
|
||||
"cursor-grabbing opacity-40",
|
||||
dropTarget === id && "[&>td]:bg-accent",
|
||||
status(host, state) !== "live" && "text-muted-foreground opacity-50",
|
||||
),
|
||||
})}
|
||||
renderCard={({ id, host, state }) => (
|
||||
<article
|
||||
{...dragProps(id)}
|
||||
className={cn(
|
||||
"relative h-full min-w-0 overflow-hidden rounded-xl bg-card/70 shadow-sm shadow-black/[0.02] transition-colors hover:bg-card",
|
||||
dragging?.panelId === panel.id &&
|
||||
dragging.hostId === id &&
|
||||
"cursor-grabbing opacity-40",
|
||||
dropTarget === id && "bg-accent",
|
||||
status(host, state) !== "live" && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<button
|
||||
key={id}
|
||||
{...dragProps(id)}
|
||||
className={cn(
|
||||
"flex h-full min-w-0 cursor-default flex-col overflow-hidden rounded-xl bg-card/70 p-4 text-left shadow-sm shadow-black/[0.02] transition-colors hover:bg-card focus-visible:outline focus-visible:outline-primary",
|
||||
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
|
||||
dropTarget === id && "bg-accent",
|
||||
status(host, state) !== "live" &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
type="button"
|
||||
className="flex h-full w-full min-w-0 cursor-default flex-col p-4 text-left outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-primary"
|
||||
aria-disabled={status(host, state) !== "live"}
|
||||
onClick={() => {
|
||||
if (status(host, state) !== "live") return;
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
<div className="mb-5 flex items-center gap-3">
|
||||
<div className="mb-2 flex w-full min-w-0 items-center gap-3 pr-7">
|
||||
{host ? (
|
||||
<DistroAvatar host={host} fallback={host.label} size="log" />
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label}
|
||||
size="log"
|
||||
/>
|
||||
) : (
|
||||
<Server size={24} className="shrink-0 text-muted-foreground" />
|
||||
<Server
|
||||
size={24}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="truncate text-sm font-semibold text-foreground">
|
||||
@@ -685,20 +753,39 @@ export function MonitorPanels({
|
||||
{host?.hostname ?? id}
|
||||
</p>
|
||||
</div>
|
||||
{stateBadge(host, state)}
|
||||
</div>
|
||||
<div className={cn(status(host, state) !== "live" && "opacity-60")}>
|
||||
<div className="mb-2 flex h-5 w-full min-w-0 items-center gap-2">
|
||||
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<MonitorHostTags tags={host?.tags} />
|
||||
</div>
|
||||
<div className="min-w-0 max-w-[40%]">
|
||||
{stateBadge(host, state)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full min-w-0",
|
||||
status(host, state) !== "live" && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{renderMetrics(state)}
|
||||
<div className="mt-4">
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
||||
<HardDrive size={13} className="text-amber-600 dark:text-amber-400" />
|
||||
<HardDrive
|
||||
size={13}
|
||||
className="text-amber-600 dark:text-amber-400"
|
||||
/>
|
||||
<span>{t("monitor.disks")}</span>
|
||||
</div>
|
||||
<MonitorDiskUsage stats={state?.stats} limit={2} />
|
||||
</div>
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
|
||||
<div className="mt-2 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
|
||||
<span className="flex items-center gap-1.5 py-1">
|
||||
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
|
||||
<ArrowDown
|
||||
size={13}
|
||||
className="shrink-0 text-sky-600 dark:text-sky-400"
|
||||
/>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netRxSpeed
|
||||
@@ -706,7 +793,10 @@ export function MonitorPanels({
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5 py-1">
|
||||
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
|
||||
<ArrowUp
|
||||
size={13}
|
||||
className="shrink-0 text-violet-600 dark:text-violet-400"
|
||||
/>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netTxSpeed
|
||||
@@ -715,127 +805,169 @@ export function MonitorPanels({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-auto flex items-center gap-1.5 pt-4 text-[10px] text-muted-foreground">
|
||||
<Clock3 size={11} />
|
||||
{state?.at
|
||||
? t("monitor.updatedAt", {
|
||||
time: new Date(state.at).toLocaleTimeString(),
|
||||
})
|
||||
: t("monitor.noSamples")}
|
||||
</p>
|
||||
<div className="mt-auto flex w-full min-w-0 items-center gap-2 pt-2 text-[10px] text-muted-foreground">
|
||||
<div className="min-w-0 flex-1">
|
||||
<MonitorHostNotes notes={host?.notes} />
|
||||
</div>
|
||||
<span className="inline-flex shrink-0 items-center gap-1">
|
||||
<Clock3 size={11} />
|
||||
{state?.at
|
||||
? new Date(state.at).toLocaleTimeString()
|
||||
: t("monitor.noSamples")}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[840px] border-separate border-spacing-x-0 border-spacing-y-2 text-xs">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
{[
|
||||
"host",
|
||||
"status",
|
||||
"cpu",
|
||||
"memory",
|
||||
"disks",
|
||||
"network",
|
||||
"updated",
|
||||
].map((k) => (
|
||||
<th
|
||||
key={k}
|
||||
className="px-3 py-3 text-left font-medium"
|
||||
>
|
||||
{t(`monitor.${k}`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map(({ id, host, state }) => {
|
||||
const s = state?.stats;
|
||||
return (
|
||||
<tr
|
||||
key={id}
|
||||
{...dragProps(id)}
|
||||
className={cn(
|
||||
"cursor-default [&>td]:bg-card/70 [&>td]:transition-colors hover:[&>td]:bg-card [&>td:first-child]:rounded-l-xl [&>td:last-child]:rounded-r-xl",
|
||||
dragging?.panelId === panel.id && dragging.hostId === id && "cursor-grabbing opacity-40",
|
||||
dropTarget === id && "[&>td]:bg-accent",
|
||||
status(host, state) !== "live" &&
|
||||
"text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
<td className="px-3 py-3">
|
||||
<button
|
||||
draggable
|
||||
className="flex cursor-inherit items-center gap-2.5 text-left hover:underline"
|
||||
onClick={() => {
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
{host ? (
|
||||
<DistroAvatar host={host} fallback={host.label} size="md" />
|
||||
) : (
|
||||
<Server size={24} className="shrink-0 text-muted-foreground" />
|
||||
)}
|
||||
<div className="min-w-0 max-w-52">
|
||||
<div className="truncate font-medium">
|
||||
{host?.label ?? t("monitor.missingHost")}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{host?.hostname ?? id}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-3">
|
||||
{stateBadge(host, state)}
|
||||
</td>
|
||||
<td className="px-3 tabular-nums">
|
||||
<ResourceBar label="" value={s?.cpu} />
|
||||
</td>
|
||||
<td className="px-3 tabular-nums">
|
||||
<ResourceBar
|
||||
label=""
|
||||
value={
|
||||
s?.memTotal && s.memUsed != null
|
||||
? (s.memUsed / s.memTotal) * 100
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</td>
|
||||
<td className="min-w-[220px] px-3 py-3">
|
||||
<MonitorDiskUsage stats={s} />
|
||||
</td>
|
||||
<td className="px-3 tabular-nums whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowDown size={13} className="shrink-0 text-sky-600 dark:text-sky-400" />
|
||||
<span className="sr-only">{t("monitor.download")}: </span>
|
||||
{rate(s?.networkReady ? s.netRxSpeed : null)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowUp size={13} className="shrink-0 text-violet-600 dark:text-violet-400" />
|
||||
<span className="sr-only">{t("monitor.upload")}: </span>
|
||||
{rate(s?.networkReady ? s.netTxSpeed : null)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 whitespace-nowrap">
|
||||
{state?.at
|
||||
? new Date(state.at).toLocaleTimeString()
|
||||
: "--"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
<p className="pt-4 text-[11px] leading-relaxed text-muted-foreground">
|
||||
{t("monitor.memoryOnlyHint")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-3 top-3 h-7 w-7 text-muted-foreground"
|
||||
data-monitor-terminal={id}
|
||||
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
|
||||
title={t("monitor.openTerminal")}
|
||||
disabled={!host}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (host) onConnect(host);
|
||||
}}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
</Button>
|
||||
</article>
|
||||
)}
|
||||
renderCells={({ id, host, state }) => ({
|
||||
host: (<td>
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full min-w-0 items-center gap-2.5 text-left aria-disabled:no-underline hover:underline"
|
||||
aria-disabled={status(host, state) !== "live"}
|
||||
onClick={() => {
|
||||
if (status(host, state) !== "live") return;
|
||||
setEditor(null);
|
||||
setDetail(id);
|
||||
}}
|
||||
>
|
||||
{host ? (
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label}
|
||||
size="md"
|
||||
/>
|
||||
) : (
|
||||
<Server
|
||||
size={24}
|
||||
className="shrink-0 text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{host?.label ?? t("monitor.missingHost")}
|
||||
</div>
|
||||
<div className="truncate text-[10px] text-muted-foreground">
|
||||
{host?.hostname ?? id}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</td>),
|
||||
latency: (<td>
|
||||
<MonitorHostLatency value={state?.stats?.latencyMs} responseValue={state?.stats?.responseLatencyMs} />
|
||||
</td>),
|
||||
tags: (<td>
|
||||
<MonitorHostTags tags={host?.tags} />
|
||||
</td>),
|
||||
notes: (<td>
|
||||
<div className="min-w-0 w-full">
|
||||
<MonitorHostNotes notes={host?.notes} focusable />
|
||||
</div>
|
||||
</td>),
|
||||
cpu: (<td className="tabular-nums">
|
||||
<ResourceBar
|
||||
className="w-full"
|
||||
label=""
|
||||
value={state?.stats?.cpu}
|
||||
/>
|
||||
</td>),
|
||||
memory: (<td className="tabular-nums">
|
||||
<ResourceBar
|
||||
className="w-full"
|
||||
label=""
|
||||
value={
|
||||
state?.stats?.memTotal &&
|
||||
state.stats.memUsed != null
|
||||
? (state.stats.memUsed / state.stats.memTotal) *
|
||||
100
|
||||
: null
|
||||
}
|
||||
/>
|
||||
</td>),
|
||||
disks: (<td>
|
||||
<div className="w-full min-w-0">
|
||||
<MonitorDiskUsage stats={state?.stats} />
|
||||
</div>
|
||||
</td>),
|
||||
network: (<td
|
||||
data-monitor-cell="network"
|
||||
className="tabular-nums whitespace-nowrap"
|
||||
>
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowDown
|
||||
size={13}
|
||||
className="shrink-0 text-sky-600 dark:text-sky-400"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{t("monitor.download")}:{" "}
|
||||
</span>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netRxSpeed
|
||||
: null,
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ArrowUp
|
||||
size={13}
|
||||
className="shrink-0 text-violet-600 dark:text-violet-400"
|
||||
/>
|
||||
<span className="sr-only">
|
||||
{t("monitor.upload")}:{" "}
|
||||
</span>
|
||||
{rate(
|
||||
state?.stats?.networkReady
|
||||
? state.stats.netTxSpeed
|
||||
: null,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>),
|
||||
updated: (<td className="whitespace-nowrap tabular-nums">
|
||||
{state?.at
|
||||
? new Date(state.at).toLocaleTimeString()
|
||||
: "--"}
|
||||
</td>),
|
||||
status: (<td>{stateBadge(host, state)}</td>),
|
||||
terminal: (<td className="sticky right-0 z-10 justify-center !bg-background before:pointer-events-none before:absolute before:inset-0 before:rounded-r-xl before:bg-card/70 group-hover:before:bg-card">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="relative h-7 w-7 text-muted-foreground"
|
||||
data-monitor-terminal={id}
|
||||
aria-label={`${t("monitor.openTerminal")}: ${host?.label ?? t("monitor.missingHost")}`}
|
||||
title={t("monitor.openTerminal")}
|
||||
disabled={!host}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
if (host) onConnect(host);
|
||||
}}
|
||||
>
|
||||
<Terminal size={14} />
|
||||
</Button>
|
||||
</td>)
|
||||
})}
|
||||
/>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1006,8 +1138,9 @@ export function MonitorPanels({
|
||||
className={monitorAsideClass}
|
||||
/>
|
||||
)}
|
||||
{detail && (
|
||||
{detail && detailAvailable && (
|
||||
<AsidePanel
|
||||
dataSection="vault-monitor-detail"
|
||||
open
|
||||
onClose={() => setDetail(null)}
|
||||
title={hostDetail?.label ?? t("monitor.missingHost")}
|
||||
@@ -1019,7 +1152,7 @@ export function MonitorPanels({
|
||||
width="w-[380px]"
|
||||
className={monitorAsideClass}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<AsidePanelContent scrollMode="native">
|
||||
<div className="flex items-center gap-3">
|
||||
{hostDetail && <DistroAvatar host={hostDetail} fallback={hostDetail.label} size="lg" />}
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -1027,73 +1160,41 @@ export function MonitorPanels({
|
||||
{stateBadge(hostDetail, detailState)}
|
||||
</div>
|
||||
</div>
|
||||
{["login", "error", "disconnected"].includes(
|
||||
detailState?.status ?? "",
|
||||
) && (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.loginHint")}
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monitor-login-user">
|
||||
{t("monitor.username")}
|
||||
</Label>
|
||||
<Input
|
||||
id="monitor-login-user"
|
||||
autoComplete="off"
|
||||
value={
|
||||
login.hostId === detail
|
||||
? login.username
|
||||
: (hostDetail?.username ?? "")
|
||||
}
|
||||
onChange={(e) =>
|
||||
setLogin((prev) => ({
|
||||
hostId: detail,
|
||||
username: e.target.value,
|
||||
password: prev.hostId === detail ? prev.password : "",
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="monitor-login-password">
|
||||
{t("monitor.password")}
|
||||
</Label>
|
||||
<Input
|
||||
id="monitor-login-password"
|
||||
type="password"
|
||||
autoComplete="new-password"
|
||||
value={login.hostId === detail ? login.password : ""}
|
||||
onChange={(e) =>
|
||||
setLogin((prev) => ({
|
||||
hostId: detail,
|
||||
username:
|
||||
prev.hostId === detail
|
||||
? prev.username
|
||||
: (hostDetail?.username ?? ""),
|
||||
password: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
monitorActions.retry(
|
||||
detail,
|
||||
login.hostId === detail && login.password
|
||||
? {
|
||||
username: login.username,
|
||||
password: login.password,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
setLogin({ hostId: "", username: "", password: "" });
|
||||
}}
|
||||
>
|
||||
{t("monitor.retry")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{renderMetrics(detailState, true)}
|
||||
{detailState?.stats && (
|
||||
detailState.stats.gpu != null ||
|
||||
detailState.stats.gpuName ||
|
||||
(detailState.stats.gpuMemTotal ?? 0) > 0
|
||||
) && (
|
||||
<section className="space-y-3" data-monitor-gpu>
|
||||
<h3 className="flex items-center gap-1.5 text-sm font-medium">
|
||||
<Gpu size={15} className="text-muted-foreground" />
|
||||
{t("systemManager.tabs.gpu")}
|
||||
</h3>
|
||||
{detailState.stats.gpuName && (
|
||||
<p className="break-words text-xs text-muted-foreground">
|
||||
{detailState.stats.gpuName}
|
||||
</p>
|
||||
)}
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[11px] text-muted-foreground">{t("systemManager.gpu.util")}</p>
|
||||
<ResourceBar label="" value={detailState.stats.gpu} />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex justify-between gap-2 text-[11px] text-muted-foreground">
|
||||
<span>{t("systemManager.gpu.memory")}</span>
|
||||
<span className="tabular-nums">
|
||||
{capacity(detailState.stats.gpuMemUsed)} / {capacity(detailState.stats.gpuMemTotal)}
|
||||
</span>
|
||||
</div>
|
||||
<ResourceBar label="" value={
|
||||
detailState.stats.gpuMemUsed != null && (detailState.stats.gpuMemTotal ?? 0) > 0
|
||||
? detailState.stats.gpuMemUsed / detailState.stats.gpuMemTotal! * 100
|
||||
: null
|
||||
} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.observed", {
|
||||
count: detailState?.points.length
|
||||
@@ -1188,24 +1289,6 @@ export function MonitorPanels({
|
||||
<p>{detailState?.stats?.kernelRelease ?? "--"}</p>
|
||||
<p>{detailState?.stats?.hostname ?? "--"}</p>
|
||||
</div>
|
||||
{detailState?.status === "unsupported" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("monitor.unsupportedHint")}
|
||||
</p>
|
||||
)}
|
||||
{!hostDetail && panel && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
void patchPanel({
|
||||
hostIds: panel.hostIds.filter((id) => id !== detail),
|
||||
});
|
||||
setDetail(null);
|
||||
}}
|
||||
>
|
||||
{t("monitor.removeHost")}
|
||||
</Button>
|
||||
)}
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
<Button
|
||||
@@ -1219,6 +1302,32 @@ export function MonitorPanels({
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
)}
|
||||
<Dialog open={!!recovery} onOpenChange={(open) => !open && setRecovery(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("monitor.retry")}</DialogTitle>
|
||||
<DialogDescription>{t("monitor.loginHint")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
if (!recovery) return;
|
||||
monitorActions.retry(recovery.hostId, recovery.password ? {username: recovery.username, password: recovery.password} : undefined);
|
||||
setRecovery(null);
|
||||
}} className="space-y-4">
|
||||
<p className="truncate text-sm font-medium">{hostById.get(recovery?.hostId ?? "")?.label}</p>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="monitor-login-user">{t("monitor.username")}</Label>
|
||||
<Input id="monitor-login-user" autoComplete="off" value={recovery?.username ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, username: event.target.value})} />
|
||||
<Label htmlFor="monitor-login-password">{t("monitor.password")}</Label>
|
||||
<Input id="monitor-login-password" type="password" autoComplete="new-password" value={recovery?.password ?? ""} onChange={(event) => setRecovery(previous => previous && {...previous, password: event.target.value})} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRecovery(null)}>{t("common.cancel")}</Button>
|
||||
<Button type="submit">{t("monitor.retry")}</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={!!deleting}
|
||||
onOpenChange={(open) => !open && setDeleting(null)}
|
||||
|
||||
@@ -31,11 +31,27 @@ signature framing. `npm test` skips the opt-in integration simulation.
|
||||
This verifies the OpenSSH integration on Linux with a software authenticator.
|
||||
Real USB/CTAP devices and Windows/macOS installations still need device testing.
|
||||
|
||||
The Linux simulation also removes the optional `.pub` file before strict agent
|
||||
selection, runs two real native OpenSSH clients concurrently against separate
|
||||
caller-bound agent sockets, and checks native SK certificate authentication.
|
||||
It verifies that releasing a second certificate preserves the bare key and
|
||||
the first certificate while removing only the released certificate. Native
|
||||
Mosh/ET authentication uses these sockets so its signatures follow the same
|
||||
window attribution and recovery wrapper as ssh2.
|
||||
|
||||
Identity preparation and removal are serialized per underlying agent socket.
|
||||
Bare-key removal uses `ssh-add -d -k` and certificate removal has its own hold;
|
||||
staging directories remain until all associated removals have completed.
|
||||
The tests also cover partial credential-write failures, socket paths containing
|
||||
spaces, and unknown agent-extension requests followed by normal requests on
|
||||
the same connection.
|
||||
|
||||
## Native Windows integration check
|
||||
|
||||
The `windows-fido` job in `.github/workflows/test.yml` runs native Windows Node
|
||||
with real MSYS2 OpenSSH and an MSYS-built version of the disposable provider.
|
||||
It exercises Windows named-pipe askpass IPC, enrollment and actual signatures
|
||||
It uses a managed temporary path containing spaces and exercises Windows
|
||||
named-pipe askpass IPC, enrollment and actual signatures
|
||||
for both SK algorithms, PIN plus a separately gated touch, incorrect PIN,
|
||||
missing-device cancellation with a saved password, and explicit password
|
||||
fallback. A local SSH protocol server verifies the signatures and executes a
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Catty prompt cache repair and verification
|
||||
|
||||
## Scope and issue history
|
||||
|
||||
- Original report: [#3545](https://github.com/binaricat/Netcatty/issues/3545), duplicate [#3546](https://github.com/binaricat/Netcatty/issues/3546).
|
||||
- [#3547](https://github.com/binaricat/Netcatty/pull/3547) moved static guidelines before scope/session details. That repair remains valid, but its closing comment did not claim a measured DeepSeek result.
|
||||
- This change starts from main `b5fee9632ea012e5c15fbdae454dec2aa7d32572` and repairs additional cache invalidation and usage-reporting paths.
|
||||
|
||||
## Confirmed defects and changes
|
||||
|
||||
1. Completed terminal results were rewritten when the next user turn rebuilt history. Keep the already bounded result, with secret redaction, and let context budgeting own necessary reductions.
|
||||
2. Live SDK results use JSON while saved history uses text. Apply the same bounded compression to both before sending them, avoiding a second, different representation on the next turn.
|
||||
3. Routine output cleanup inserted the latest goal and pending handles at the start of otherwise intact history. Reserve that reinjection for actual budget pressure or summarization.
|
||||
4. Large-output handle notices were replaced at the front on every step. Results already containing a handle need no duplicate notice. Announce missing handles once at the tail and preserve prior notices; continue to reserve their budget if older results are pruned.
|
||||
5. DeepSeek's documented `prompt_cache_hit_tokens` was lost when the OpenAI-compatible SDK parsed responses. Normalize it to the standard cached-token field before parsing, preserving an existing standard value, including zero. Cover streaming and non-streaming responses.
|
||||
6. Catty omitted cache counts from its usage events and saved assistant messages. Preserve provider counts and attach aggregate usage after the final assistant message has been created.
|
||||
7. Native Anthropic requests lacked cache breakpoints. Cache the static system block and a bounded conversation tail; leave implicit-cache OpenAI/Google requests untouched and preserve signed reasoning content. Enable these vendor fields only for the official HTTPS Anthropic endpoint (including the SDK's default endpoint), not third-party Messages-compatible gateways. Use the shared resolved SDK address, including OpenRouter/Ollama fallbacks when a stored URL is empty. Review follow-ups add endpoint gating and full-driver gateway/fallback regressions; they do not change the measured DeepSeek path.
|
||||
|
||||
## Comparison with mature agents and official guidance
|
||||
|
||||
- [Codex prompt-caching tests](https://github.com/openai/codex/blob/8c076942cb70fba8e5c4732c23f1faabc4aed484/codex-rs/core/tests/suite/prompt_caching.rs): exercise stable tools and append-only request prefixes. The new tests compare the actual serialized requests produced by Catty's driver, SDK and bridge adapter across consecutive turns.
|
||||
- [OpenCode cache policy](https://github.com/anomalyco/opencode/blob/c38d83c0c4f92466ff9c8e12b8a741698e811012/packages/ai/src/cache-policy.ts): use provider-specific explicit cache boundaries where required and leave implicit-cache protocols alone.
|
||||
- [DeepSeek context caching](https://api-docs.deepseek.com/guides/kv_cache/): exact shared prefixes, asynchronous/best-effort cache persistence and provider-reported hit/miss counts. Live probes wait between turns and use the official endpoint.
|
||||
- [Anthropic prompt caching](https://platform.claude.com/docs/en/build-with-claude/prompt-caching): explicit boundaries, tool/system/message ordering and a limit of four breakpoints. Catty uses at most three automatic markers.
|
||||
- [OpenAI prompt caching](https://developers.openai.com/api/docs/guides/prompt-caching): stable shared prefixes. No additional explicit cache-control fields are sent on this path.
|
||||
|
||||
## Live verification
|
||||
|
||||
The final fixed Catty driver was bundled from the isolated worktree and executed inside the running Electron renderer, using the app's existing IPC and configured `https://api.deepseek.com` service, model `deepseek-v4-flash`. The API key stayed inside the app's credential handling.
|
||||
|
||||
Each run contains six consecutive turns for each of three scenarios: plain chat, one command per turn, and a command producing 14,000 synthetic log lines followed by a bounded saved-output read. Model requests and usage are real. Tool execution uses an in-memory fixture; no command is sent to the user's connected SSH hosts. The prompts contain only synthetic session/host information.
|
||||
|
||||
The results file records every provider-reported input/cache/output count, per-step counts and errors. The rate is total cached input divided by total input, including all model calls in a turn. It is not an average of rounded percentages.
|
||||
|
||||
Two complete runs of the final source produced 36 turns / 72 real model requests, with zero errors:
|
||||
|
||||
| Scenario | Turns | Weighted cache hit rate | Per-turn range |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Plain chat | 12 | 98.55% | 98.19%-98.92% |
|
||||
| Command execution | 12 | 98.59% | 98.12%-99.03% |
|
||||
| Large command output + saved-output read | 12 | 97.80% | 97.30%-98.32% |
|
||||
|
||||
Raw per-turn and per-step counters are in [the JSON evidence file](./catty-prompt-cache-live-2026-10-18.json). These are warm-cache measurements; an earlier probe's first request correctly reported zero cached input before subsequent requests reached about 98%. The earlier probe is excluded from the final-source totals.
|
||||
|
||||
The production chat-message component was rendered with the live conversation in Electron. Its usage footer showed input, output, cache and reasoning counts; opening the command entry showed the expected fixture result. No product UI layout was changed.
|
||||
|
||||
Limits: live provider evidence covers the configured DeepSeek model. Native Anthropic request formatting and usage preservation are verified with the real SDK and a protocol fixture, not a paid Claude session. Cache hits remain provider-managed; a new prefix, necessary compaction or expired cache can legitimately miss.
|
||||
|
||||
## Automated verification
|
||||
|
||||
- Full AI/history/UI-related suite after the endpoint review follow-ups: 600 tests passed.
|
||||
- New regression coverage includes six consecutive plain-chat turns, six command turns, six large-file turns, six large-terminal-output turns, long-output handles, streaming/non-streaming DeepSeek usage, aggregate multi-step usage and native Anthropic boundaries.
|
||||
- Production build passed.
|
||||
- Full lint passed with eight existing warnings; final changed-file lint passed without warnings.
|
||||
- Type checking matches the untouched main baseline: 758 existing errors, zero additional errors after normalizing checkout paths and line numbers.
|
||||
|
||||
Full-repository verification also found setup and unrelated timing limits:
|
||||
|
||||
- The first run passed 13,968 tests and skipped 36, with one failed file caused by the fresh worktree missing the nested `plugin-cli/node_modules/yauzl` dependency. Linking the existing dependency directory repaired the setup; all 29 CLI tests then passed.
|
||||
- A repeat full run hit the unchanged `large directory progress coalesces synchronous persistence work` timing assertion (2,638,624 characters persisted against a 588,644-character snapshot). That assertion passed in the prior full run. All 102 tests in its file passed on isolated rerun; the same assertion also passed on untouched main. This is an observed full-run timing fluctuation, not a claimed baseline reproduction or an SFTP repair.
|
||||
- The Homebrew Node 22 binary is broken locally (missing shared library); verification uses an isolated official npm Node 22 runtime. `TMPDIR=/tmp` selects Netcatty's shorter private per-user temporary directory for Unix-socket tests. The default macOS path exceeded the FIDO test socket limit; no product source was changed to accommodate this environment.
|
||||
|
||||
The full suite is therefore not reported as unconditionally green. No Catty/cache test failed in the final implementation.
|
||||
@@ -0,0 +1,955 @@
|
||||
{
|
||||
"date": "2026-10-18",
|
||||
"endpoint": "https://api.deepseek.com",
|
||||
"model": "deepseek-v4-flash",
|
||||
"input": "synthetic prompts and in-memory tool output",
|
||||
"totalTurns": 36,
|
||||
"totalModelCalls": 72,
|
||||
"summary": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"turns": 12,
|
||||
"inputTokens": 162870,
|
||||
"cachedTokens": 160512,
|
||||
"weightedHitRate": 98.55,
|
||||
"minTurnRate": 98.19,
|
||||
"maxTurnRate": 98.92
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"turns": 12,
|
||||
"inputTokens": 341856,
|
||||
"cachedTokens": 337024,
|
||||
"weightedHitRate": 98.59,
|
||||
"minTurnRate": 98.12,
|
||||
"maxTurnRate": 99.03
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"turns": 12,
|
||||
"inputTokens": 598639,
|
||||
"cachedTokens": 585472,
|
||||
"weightedHitRate": 97.8,
|
||||
"minTurnRate": 97.3,
|
||||
"maxTurnRate": 98.32
|
||||
}
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"rows": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"inputTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.61,
|
||||
"outputTokens": 21,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 690
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"inputTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.4,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 898
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"inputTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.19,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 658
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"inputTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.92,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 982
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"inputTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.71,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 611
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"inputTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.5,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 676
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"inputTokens": 27473,
|
||||
"cachedTokens": 27008,
|
||||
"hitRate": 98.31,
|
||||
"outputTokens": 384,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 1,
|
||||
"milliseconds": 5711
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"inputTokens": 28156,
|
||||
"cachedTokens": 27776,
|
||||
"hitRate": 98.65,
|
||||
"outputTokens": 93,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 4606
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"inputTokens": 28538,
|
||||
"cachedTokens": 28160,
|
||||
"hitRate": 98.68,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 3,
|
||||
"milliseconds": 4738
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"inputTokens": 28940,
|
||||
"cachedTokens": 28544,
|
||||
"hitRate": 98.63,
|
||||
"outputTokens": 113,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5000
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"inputTokens": 29342,
|
||||
"cachedTokens": 29056,
|
||||
"hitRate": 99.03,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 5,
|
||||
"milliseconds": 5011
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"inputTokens": 29714,
|
||||
"cachedTokens": 29312,
|
||||
"hitRate": 98.65,
|
||||
"outputTokens": 83,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 4517
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"inputTokens": 42570,
|
||||
"cachedTokens": 41472,
|
||||
"hitRate": 97.42,
|
||||
"outputTokens": 344,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 6895
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"inputTokens": 45647,
|
||||
"cachedTokens": 44416,
|
||||
"hitRate": 97.3,
|
||||
"outputTokens": 163,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5644
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"inputTokens": 48547,
|
||||
"cachedTokens": 47488,
|
||||
"hitRate": 97.82,
|
||||
"outputTokens": 165,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 5271
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"inputTokens": 51449,
|
||||
"cachedTokens": 50304,
|
||||
"hitRate": 97.77,
|
||||
"outputTokens": 163,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 8,
|
||||
"milliseconds": 5826
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"inputTokens": 54353,
|
||||
"cachedTokens": 53248,
|
||||
"hitRate": 97.97,
|
||||
"outputTokens": 166,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 10,
|
||||
"milliseconds": 6235
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"inputTokens": 57280,
|
||||
"cachedTokens": 56320,
|
||||
"hitRate": 98.32,
|
||||
"outputTokens": 168,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 12,
|
||||
"milliseconds": 5692
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"promptTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 21
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"promptTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"promptTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"promptTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"promptTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13531,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 364
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13942,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 14018,
|
||||
"cachedTokens": 13824,
|
||||
"completionTokens": 73
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 14138,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14214,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14324,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14400,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 93
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14540,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14616,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14726,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14802,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14912,
|
||||
"cachedTokens": 14720,
|
||||
"completionTokens": 20
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 243
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14394,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14618,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 14705,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15360,
|
||||
"cachedTokens": 14720,
|
||||
"completionTokens": 96
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15582,
|
||||
"cachedTokens": 15232,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 15669,
|
||||
"cachedTokens": 15488,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16326,
|
||||
"cachedTokens": 15616,
|
||||
"completionTokens": 98
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16552,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 16639,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17294,
|
||||
"cachedTokens": 16640,
|
||||
"completionTokens": 96
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17516,
|
||||
"cachedTokens": 17280,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 17603,
|
||||
"cachedTokens": 17408,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18261,
|
||||
"cachedTokens": 17536,
|
||||
"completionTokens": 99
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18489,
|
||||
"cachedTokens": 18304,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 18576,
|
||||
"cachedTokens": 18432,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19236,
|
||||
"cachedTokens": 18560,
|
||||
"completionTokens": 101
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19468,
|
||||
"cachedTokens": 19328,
|
||||
"completionTokens": 4
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
},
|
||||
{
|
||||
"model": "deepseek-v4-flash",
|
||||
"rows": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"inputTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.61,
|
||||
"outputTokens": 57,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 1278
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"inputTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.4,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 658
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"inputTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"hitRate": 98.19,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 809
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"inputTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.92,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 958
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"inputTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.71,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 857
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"inputTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"hitRate": 98.5,
|
||||
"outputTokens": 4,
|
||||
"modelCalls": 1,
|
||||
"toolResults": 0,
|
||||
"milliseconds": 1061
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"inputTokens": 27300,
|
||||
"cachedTokens": 26880,
|
||||
"hitRate": 98.46,
|
||||
"outputTokens": 195,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 1,
|
||||
"milliseconds": 4773
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"inputTokens": 27785,
|
||||
"cachedTokens": 27264,
|
||||
"hitRate": 98.12,
|
||||
"outputTokens": 84,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 4529
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"inputTokens": 28142,
|
||||
"cachedTokens": 27776,
|
||||
"hitRate": 98.7,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 3,
|
||||
"milliseconds": 4584
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"inputTokens": 28482,
|
||||
"cachedTokens": 28032,
|
||||
"hitRate": 98.42,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 4706
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"inputTokens": 28822,
|
||||
"cachedTokens": 28416,
|
||||
"hitRate": 98.59,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 5,
|
||||
"milliseconds": 4558
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"inputTokens": 29162,
|
||||
"cachedTokens": 28800,
|
||||
"hitRate": 98.76,
|
||||
"outputTokens": 67,
|
||||
"modelCalls": 2,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 4496
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"inputTokens": 42428,
|
||||
"cachedTokens": 41344,
|
||||
"hitRate": 97.45,
|
||||
"outputTokens": 270,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 2,
|
||||
"milliseconds": 5945
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"inputTokens": 45447,
|
||||
"cachedTokens": 44416,
|
||||
"hitRate": 97.73,
|
||||
"outputTokens": 164,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 4,
|
||||
"milliseconds": 5125
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"inputTokens": 48356,
|
||||
"cachedTokens": 47232,
|
||||
"hitRate": 97.68,
|
||||
"outputTokens": 166,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 6,
|
||||
"milliseconds": 5238
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"inputTokens": 51279,
|
||||
"cachedTokens": 50176,
|
||||
"hitRate": 97.85,
|
||||
"outputTokens": 167,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 8,
|
||||
"milliseconds": 5535
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"inputTokens": 54195,
|
||||
"cachedTokens": 53120,
|
||||
"hitRate": 98.02,
|
||||
"outputTokens": 164,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 10,
|
||||
"milliseconds": 5977
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"inputTokens": 57088,
|
||||
"cachedTokens": 55936,
|
||||
"hitRate": 97.98,
|
||||
"outputTokens": 162,
|
||||
"modelCalls": 3,
|
||||
"toolResults": 12,
|
||||
"milliseconds": 5391
|
||||
}
|
||||
],
|
||||
"steps": [
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 1,
|
||||
"promptTokens": 13500,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 57
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 2,
|
||||
"promptTokens": 13529,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 3,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 4,
|
||||
"promptTokens": 13587,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 5,
|
||||
"promptTokens": 13616,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "chat",
|
||||
"round": 6,
|
||||
"promptTokens": 13645,
|
||||
"cachedTokens": 13440,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13531,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 191
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 1,
|
||||
"promptTokens": 13769,
|
||||
"cachedTokens": 13568,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 13829,
|
||||
"cachedTokens": 13568,
|
||||
"completionTokens": 80
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 2,
|
||||
"promptTokens": 13956,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14016,
|
||||
"cachedTokens": 13824,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 3,
|
||||
"promptTokens": 14126,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14186,
|
||||
"cachedTokens": 13952,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 4,
|
||||
"promptTokens": 14296,
|
||||
"cachedTokens": 14080,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14356,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 5,
|
||||
"promptTokens": 14466,
|
||||
"cachedTokens": 14208,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14526,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "command",
|
||||
"round": 6,
|
||||
"promptTokens": 14636,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 13558,
|
||||
"cachedTokens": 13312,
|
||||
"completionTokens": 166
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14320,
|
||||
"cachedTokens": 13696,
|
||||
"completionTokens": 100
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 1,
|
||||
"promptTokens": 14550,
|
||||
"cachedTokens": 14336,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 14637,
|
||||
"cachedTokens": 14464,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15293,
|
||||
"cachedTokens": 14592,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 2,
|
||||
"promptTokens": 15517,
|
||||
"cachedTokens": 15360,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 15604,
|
||||
"cachedTokens": 15360,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16262,
|
||||
"cachedTokens": 15616,
|
||||
"completionTokens": 99
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 3,
|
||||
"promptTokens": 16490,
|
||||
"cachedTokens": 16256,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 16577,
|
||||
"cachedTokens": 16384,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17236,
|
||||
"cachedTokens": 16512,
|
||||
"completionTokens": 100
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 4,
|
||||
"promptTokens": 17466,
|
||||
"cachedTokens": 17280,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 17553,
|
||||
"cachedTokens": 17408,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18209,
|
||||
"cachedTokens": 17536,
|
||||
"completionTokens": 97
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 5,
|
||||
"promptTokens": 18433,
|
||||
"cachedTokens": 18176,
|
||||
"completionTokens": 4
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 18520,
|
||||
"cachedTokens": 18304,
|
||||
"completionTokens": 63
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19174,
|
||||
"cachedTokens": 18432,
|
||||
"completionTokens": 95
|
||||
},
|
||||
{
|
||||
"kind": "large-output",
|
||||
"round": 6,
|
||||
"promptTokens": 19394,
|
||||
"cachedTokens": 19200,
|
||||
"completionTokens": 4
|
||||
}
|
||||
],
|
||||
"errors": []
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
@@ -22,6 +22,8 @@ export type MonitorHostState = {
|
||||
| "disconnected"
|
||||
| "limit";
|
||||
at: number;
|
||||
/** GPU publication can advance independently of the primary sample/history. */
|
||||
statsAt?: number;
|
||||
/** Omitted by incremental replies when the receiver already has this sample. */
|
||||
stats?: MonitorStats | null;
|
||||
points: MonitorPoint[];
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
MONITOR_COLUMNS,
|
||||
clampMonitorColumnWidth,
|
||||
normalizeMonitorColumnWidths,
|
||||
normalizeMonitorHiddenColumns,
|
||||
monitorGridColumns,
|
||||
} from "./monitorTableLayout";
|
||||
|
||||
test("saved column sizes cannot hide data or create unbounded table widths", () => {
|
||||
const widths = normalizeMonitorColumnWidths({
|
||||
host: -1,
|
||||
latency: Infinity,
|
||||
notes: 100000,
|
||||
terminal: NaN,
|
||||
unknown: 400,
|
||||
});
|
||||
assert.equal(widths.host, 160);
|
||||
assert.equal(widths.latency, 90);
|
||||
assert.equal(widths.notes, 640);
|
||||
assert.equal(widths.terminal, 56);
|
||||
assert.equal(Object.keys(widths).length, MONITOR_COLUMNS.length);
|
||||
assert.deepEqual(
|
||||
normalizeMonitorColumnWidths(null),
|
||||
normalizeMonitorColumnWidths({}),
|
||||
);
|
||||
for (const column of MONITOR_COLUMNS) {
|
||||
assert.equal(clampMonitorColumnWidth(column.key, -100), column.min);
|
||||
assert.equal(
|
||||
clampMonitorColumnWidth(column.key, 10000),
|
||||
column.key === "terminal" ? 200 : 640,
|
||||
);
|
||||
}
|
||||
});
|
||||
test("virtual card rows adapt to narrow panes and do not allocate empty columns", () => {
|
||||
assert.equal(monitorGridColumns(200, 1000), 1);
|
||||
assert.equal(monitorGridColumns(575, 1000), 1);
|
||||
assert.equal(monitorGridColumns(576, 1000), 2);
|
||||
assert.equal(monitorGridColumns(1248, 1000), 4);
|
||||
assert.equal(monitorGridColumns(1248, 2), 2);
|
||||
assert.equal(monitorGridColumns(0, 1000), 1);
|
||||
});
|
||||
|
||||
test("hidden columns always preserve host and terminal and discard unknown settings", () => {
|
||||
assert.deepEqual(
|
||||
normalizeMonitorHiddenColumns([
|
||||
"host",
|
||||
"terminal",
|
||||
"cpu",
|
||||
"cpu",
|
||||
"unknown",
|
||||
"status",
|
||||
]),
|
||||
["cpu", "status"],
|
||||
);
|
||||
assert.deepEqual(normalizeMonitorHiddenColumns(null), []);
|
||||
assert.deepEqual(normalizeMonitorHiddenColumns({ cpu: true }), []);
|
||||
assert.equal(
|
||||
normalizeMonitorHiddenColumns(MONITOR_COLUMNS.map((c) => c.key)).length,
|
||||
MONITOR_COLUMNS.length - 2,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
export const MONITOR_COLUMNS = [
|
||||
{ key: "host", width: 220, min: 160 },
|
||||
{ key: "latency", width: 90, min: 80 },
|
||||
{ key: "tags", width: 150, min: 90 },
|
||||
{ key: "notes", width: 180, min: 110 },
|
||||
{ key: "cpu", width: 120, min: 120 },
|
||||
{ key: "memory", width: 120, min: 120 },
|
||||
{ key: "disks", width: 240, min: 220 },
|
||||
{ key: "network", width: 150, min: 140 },
|
||||
{ key: "updated", width: 110, min: 95 },
|
||||
{ key: "status", width: 100, min: 90 },
|
||||
{ key: "terminal", width: 56, min: 56 },
|
||||
] as const;
|
||||
export type MonitorColumn = (typeof MONITOR_COLUMNS)[number]["key"];
|
||||
export type MonitorColumnWidths = Record<MonitorColumn, number>;
|
||||
export const MONITOR_CARD_HEIGHT = 368;
|
||||
|
||||
export function clampMonitorColumnWidth(
|
||||
key: MonitorColumn,
|
||||
width: number,
|
||||
): number {
|
||||
const column = MONITOR_COLUMNS.find((column) => column.key === key)!;
|
||||
return Number.isFinite(width)
|
||||
? Math.round(
|
||||
Math.max(column.min, Math.min(key === "terminal" ? 200 : 640, width)),
|
||||
)
|
||||
: column.width;
|
||||
}
|
||||
export function normalizeMonitorColumnWidths(
|
||||
value: unknown,
|
||||
): MonitorColumnWidths {
|
||||
const stored =
|
||||
value && typeof value === "object"
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return Object.fromEntries(
|
||||
MONITOR_COLUMNS.map((column) => [
|
||||
column.key,
|
||||
clampMonitorColumnWidth(
|
||||
column.key,
|
||||
typeof stored[column.key] === "number"
|
||||
? (stored[column.key] as number)
|
||||
: column.width,
|
||||
),
|
||||
]),
|
||||
) as MonitorColumnWidths;
|
||||
}
|
||||
export function monitorGridColumns(width: number, count: number): number {
|
||||
return Math.max(
|
||||
1,
|
||||
Math.min(count, Math.floor((Math.max(0, width) + 16) / 296)),
|
||||
);
|
||||
}
|
||||
|
||||
/** Only optional columns can be hidden, including when restoring old or corrupt settings. */
|
||||
export function normalizeMonitorHiddenColumns(value: unknown): MonitorColumn[] {
|
||||
return MONITOR_COLUMNS.filter(
|
||||
({ key }) =>
|
||||
key !== "host" &&
|
||||
key !== "terminal" &&
|
||||
Array.isArray(value) &&
|
||||
value.includes(key),
|
||||
).map(({ key }) => key);
|
||||
}
|
||||
@@ -117,10 +117,10 @@ function resolveSshAgentBinary(env = process.env, platform = process.platform) {
|
||||
}
|
||||
|
||||
function parseAgentStdout(stdout) {
|
||||
const sockMatch = /SSH_AUTH_SOCK=([^;\s]+)/.exec(stdout || "");
|
||||
const sockMatch = /SSH_AUTH_SOCK=([^;\r\n]+)/.exec(stdout || "");
|
||||
const pidMatch = /SSH_AGENT_PID=(\d+)/.exec(stdout || "");
|
||||
return {
|
||||
socketPath: sockMatch?.[1] || null,
|
||||
socketPath: sockMatch?.[1]?.trim().replace(/^(['"])(.*)\1$/, "$2") || null,
|
||||
agentPid: pidMatch ? Number(pidMatch[1]) : null,
|
||||
};
|
||||
}
|
||||
@@ -230,7 +230,9 @@ async function acquireFidoAgent(options = {}) {
|
||||
// Caller-specific askpass lease — never reuse the starter's resolver.
|
||||
return {
|
||||
socketPath: shared.socketPath,
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents }),
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: resolveSshAgentBinary(options.env || process.env, options.platform || process.platform),
|
||||
env: options.env }),
|
||||
owned: shared.owned,
|
||||
generation: shared.generation,
|
||||
sshAddPath: shared.sshAddPath || null,
|
||||
@@ -242,7 +244,9 @@ async function acquireFidoAgent(options = {}) {
|
||||
refCount += 1;
|
||||
return {
|
||||
socketPath: agentSocket,
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents }),
|
||||
askpassEnv: buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: resolveSshAgentBinary(options.env || process.env, options.platform || process.platform),
|
||||
env: options.env }),
|
||||
// Preserve ownership of the live singleton (Windows system-pipe fallback is not owned).
|
||||
owned: Boolean(agentChild),
|
||||
generation: agentGeneration,
|
||||
@@ -256,8 +260,10 @@ async function acquireFidoAgent(options = {}) {
|
||||
startingPromise = (async () => {
|
||||
const run = options.execFile || execFileAsync;
|
||||
const env = options.env || process.env;
|
||||
const askpassEnv = buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents });
|
||||
const platform = options.platform || process.platform;
|
||||
const sshAgent = resolveSshAgentBinary(env, platform);
|
||||
const askpassEnv = buildFidoAskpassEnv({ resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: sshAgent, env });
|
||||
const releaseStarterAskpassLease = () => {
|
||||
try {
|
||||
releaseFidoAskpassLease(askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
@@ -267,10 +273,11 @@ async function acquireFidoAgent(options = {}) {
|
||||
};
|
||||
|
||||
try {
|
||||
const sshAgent = resolveSshAgentBinary(env, platform);
|
||||
const sshAddPath = companionSshAddPath(sshAgent);
|
||||
|
||||
agentDir = path.join(getTempBase(), `netcatty-fido-agent-${randomUUID().slice(0, 8)}`);
|
||||
// MSYS/Cygwin sockaddr_un still has the Unix 108-byte path limit. The
|
||||
// managed base already identifies Netcatty; keep the socket suffix short.
|
||||
agentDir = path.join(getTempBase(), `fa-${randomUUID().slice(0, 8)}`);
|
||||
fs.mkdirSync(agentDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
// Keep SSH_ASKPASS on the agent so verify-required sk-helper prompts work,
|
||||
@@ -285,7 +292,7 @@ async function acquireFidoAgent(options = {}) {
|
||||
const sockCandidates = platform === "win32"
|
||||
? [
|
||||
`\\\\.\\pipe\\netcatty-fido-agent-${randomUUID().slice(0, 8)}`,
|
||||
path.join(agentDir, "agent.sock"),
|
||||
path.join(agentDir, "agent.sock").replace(/\\/g, "/"),
|
||||
]
|
||||
: [path.join(agentDir, "agent.sock")];
|
||||
|
||||
|
||||
@@ -20,6 +20,13 @@ test("parseAgentStdout extracts sock and pid", () => {
|
||||
assert.equal(parsed.agentPid, 9999);
|
||||
});
|
||||
|
||||
test("parseAgentStdout preserves spaces in Unix and Windows socket paths", () => {
|
||||
for (const socket of ["/tmp/profile name/agent.sock", "C:\\Users\\First Last\\agent.sock"]) {
|
||||
assert.equal(parseAgentStdout(`SSH_AUTH_SOCK=${socket}; export SSH_AUTH_SOCK;`).socketPath, socket);
|
||||
assert.equal(parseAgentStdout(`SSH_AUTH_SOCK='${socket}'; export SSH_AUTH_SOCK;`).socketPath, socket);
|
||||
}
|
||||
});
|
||||
|
||||
test("getTempBase uses Netcatty managed temp dir (no os.tmpdir fallback)", () => {
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
const managed = tempDirBridge.getTempDir();
|
||||
|
||||
@@ -182,7 +182,7 @@ function resolveFidoAskpassSocketPath(baseDir, platform = process.platform) {
|
||||
if (platform === "win32") {
|
||||
return `\\\\.\\pipe\\netcatty-fido-askpass-${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
return path.join(baseDir, "askpass.sock");
|
||||
return path.posix.join(baseDir, "askpass.sock");
|
||||
}
|
||||
|
||||
function writeSecureFile(filePath, contents, mode = 0o700) {
|
||||
@@ -243,7 +243,7 @@ function resolveSharedAgentPromptSender() {
|
||||
|| liveSenderFromResolver(resolveWebContents);
|
||||
}
|
||||
|
||||
function handleAskpassClient(socket) {
|
||||
function handleAskpassClient(socket, platform = process.platform) {
|
||||
let buf = "";
|
||||
let handled = false;
|
||||
const controller = new AbortController();
|
||||
@@ -302,14 +302,28 @@ function handleAskpassClient(socket) {
|
||||
}
|
||||
const signing = signingQueue[0]?.leaseId === ownerLeaseId ? signingQueue[0] : null;
|
||||
try {
|
||||
const result = await fidoPromptHandler.requestFidoPrompt(sender, {
|
||||
const detachedNotification = notificationOnly && platform === "win32";
|
||||
if (kind === "pin" && platform === "win32") {
|
||||
fidoPromptHandler.cancelFidoPromptRequestsForLease(ownerLeaseId, "pin-requested", true);
|
||||
}
|
||||
const pending = fidoPromptHandler.requestFidoPrompt(sender, {
|
||||
kind,
|
||||
notificationOnly,
|
||||
message: prompt,
|
||||
keyName: signing?.keyName || "",
|
||||
leaseId: ownerLeaseId,
|
||||
signal: controller.signal,
|
||||
signal: detachedNotification
|
||||
? (signing?.attemptController || signing?.controller)?.signal : controller.signal,
|
||||
});
|
||||
if (detachedNotification) {
|
||||
// MSYS cannot reliably terminate a native Node notification helper.
|
||||
// Acknowledge this non-authorizing notification immediately; retain the
|
||||
// hint under the signing attempt (or key-generation lease) instead.
|
||||
void pending;
|
||||
socket.end('{"ok":true,"response":""}\n');
|
||||
return;
|
||||
}
|
||||
const result = await pending;
|
||||
if (socket.destroyed) return;
|
||||
if (!result || result.cancelled) {
|
||||
if (kind === "pin" && signing) signing.cancelled = true;
|
||||
@@ -319,14 +333,16 @@ function handleAskpassClient(socket) {
|
||||
// Touch/confirm: empty string is fine; PIN: return entered secret.
|
||||
const response = kind === "pin" ? (result.response || "") : "";
|
||||
if (kind === "pin" && signing) signing.pinSubmitted = true;
|
||||
if (kind === "pin" && response && /user presence|touch|tap/i.test(prompt)
|
||||
&& signing?.leaseId === ownerLeaseId && !signing.controller.signal.aborted) {
|
||||
if (kind === "pin" && response
|
||||
&& (platform === "win32" || /user presence|touch|tap/i.test(prompt))
|
||||
&& ((signing?.leaseId === ownerLeaseId && !signing.controller.signal.aborted)
|
||||
|| (platform === "win32" && ownerLeaseId))) {
|
||||
// ssh-agent ends its initial notification before asking for a PIN and
|
||||
// does not restart it on retry. Keep a device-only waiting hint visible
|
||||
// after PIN submission until the actual signature callback completes.
|
||||
void fidoPromptHandler.requestFidoPrompt(sender, {
|
||||
kind: "touch", notificationOnly: true, message: prompt, keyName: signing.keyName || "",
|
||||
leaseId: ownerLeaseId, signal: (signing.attemptController || signing.controller).signal,
|
||||
kind: "touch", notificationOnly: true, message: prompt, keyName: signing?.keyName || "",
|
||||
leaseId: ownerLeaseId, signal: (signing?.attemptController || signing?.controller)?.signal,
|
||||
});
|
||||
}
|
||||
socket.end(`${JSON.stringify({ ok: true, response })}\n`);
|
||||
@@ -360,6 +376,12 @@ function ensureFidoAskpass(options = {}) {
|
||||
askpassScriptPath = path.join(base, "netcatty-fido-askpass.cjs");
|
||||
writeSecureFile(askpassScriptPath, FIDO_ASKPASS_SCRIPT, 0o700);
|
||||
|
||||
const posixWrapperPath = path.join(base, "netcatty-fido-askpass.sh");
|
||||
const shellQuote = value => `'${value.replace(/'/g, "'\\''")}'`;
|
||||
const shellPath = value => process.platform === "win32" ? value.replace(/\\/g, "/") : value;
|
||||
writeSecureFile(posixWrapperPath,
|
||||
`#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${shellQuote(shellPath(process.execPath))} ${shellQuote(shellPath(askpassScriptPath))} "$@"\n`,
|
||||
0o700);
|
||||
if (process.platform === "win32") {
|
||||
askpassWrapperPath = path.join(base, "netcatty-fido-askpass.cmd");
|
||||
writeSecureFile(
|
||||
@@ -368,14 +390,7 @@ function ensureFidoAskpass(options = {}) {
|
||||
0o700,
|
||||
);
|
||||
} else {
|
||||
askpassWrapperPath = path.join(base, "netcatty-fido-askpass.sh");
|
||||
const electronExec = JSON.stringify(process.execPath);
|
||||
const scriptExec = JSON.stringify(askpassScriptPath);
|
||||
writeSecureFile(
|
||||
askpassWrapperPath,
|
||||
`#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${electronExec} ${scriptExec} "$@"\n`,
|
||||
0o700,
|
||||
);
|
||||
askpassWrapperPath = posixWrapperPath;
|
||||
}
|
||||
|
||||
if (!isWindowsNamedPipePath(askpassSocketPath) && fs.existsSync(askpassSocketPath)) {
|
||||
@@ -402,9 +417,31 @@ function ensureFidoAskpass(options = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
function isWindowsPosixSsh(binaryPath, platform = process.platform, env = process.env, exists = fs.existsSync) {
|
||||
if (platform !== "win32" || !binaryPath) return false;
|
||||
const binary = /\.exe$/i.test(binaryPath) ? binaryPath : `${binaryPath}.exe`;
|
||||
const candidates = /[\\/]/.test(binaryPath) ? [binary]
|
||||
: (env.PATH || env.Path || "").split(";").map(dir => path.win32.join(dir, binary));
|
||||
for (const candidate of candidates) {
|
||||
if (!exists(candidate)) continue;
|
||||
const dir = path.win32.dirname(candidate);
|
||||
if (exists(path.win32.join(dir, "msys-2.0.dll")) || exists(path.win32.join(dir, "cygwin1.dll"))) {
|
||||
return true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function selectFidoAskpassWrapper(wrapperPath, binaryPath, platform = process.platform, env = process.env, exists = fs.existsSync) {
|
||||
// MSYS uses its own shell/shebang execution so spaces in helper paths survive.
|
||||
return isWindowsPosixSsh(binaryPath, platform, env, exists)
|
||||
? wrapperPath.replace(/\.cmd$/i, ".sh").replace(/\\/g, "/") : wrapperPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Environment for ssh-agent / ssh-add / ssh-keygen so PIN/touch use Netcatty UI.
|
||||
* @param {{ resolveWebContents?: () => import("electron").WebContents|null }} [options]
|
||||
* @param {{ resolveWebContents?: () => import("electron").WebContents|null, sshBinaryPath?: string, env?: NodeJS.ProcessEnv }} [options]
|
||||
*/
|
||||
function buildFidoAskpassEnv(options = {}) {
|
||||
const artifacts = ensureFidoAskpass(options);
|
||||
@@ -413,7 +450,7 @@ function buildFidoAskpassEnv(options = {}) {
|
||||
askpassLeases.set(leaseId, options.resolveWebContents);
|
||||
}
|
||||
const env = {
|
||||
SSH_ASKPASS: artifacts.wrapperPath,
|
||||
SSH_ASKPASS: selectFidoAskpassWrapper(artifacts.wrapperPath, options.sshBinaryPath, process.platform, options.env),
|
||||
SSH_ASKPASS_REQUIRE: "force",
|
||||
NETCATTY_FIDO_ASKPASS_SOCK: artifacts.socketPath,
|
||||
NETCATTY_FIDO_ASKPASS_LEASE: leaseId,
|
||||
@@ -485,6 +522,8 @@ module.exports = {
|
||||
classifyAskpassPrompt: fidoPromptHandler.classifyAskpassPrompt,
|
||||
resolveFidoAskpassSocketPath,
|
||||
isWindowsNamedPipePath,
|
||||
selectFidoAskpassWrapper,
|
||||
isWindowsPosixSsh,
|
||||
// exposed for tests
|
||||
getTempBase,
|
||||
handleAskpassClient,
|
||||
|
||||
@@ -14,7 +14,7 @@ const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
// Keep the managed temp path short: Unix domain sockets reject long sun_path.
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fp-"));
|
||||
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fp 'q$-"));
|
||||
const originalGetTempDir = tempDirBridge.getTempDir;
|
||||
tempDirBridge.getTempDir = () => managedTemp;
|
||||
const {
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
} = require("./fidoAskpass.cjs");
|
||||
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
|
||||
const fs = require("node:fs");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
test("buildFidoAskpassEnv creates helper artifacts", () => {
|
||||
const env = buildFidoAskpassEnv();
|
||||
@@ -54,3 +55,35 @@ test("releaseFidoAskpassLease cancels outstanding FIDO prompts for that lease",
|
||||
assert.equal(fidoPromptHandler.getRequests().size, 0);
|
||||
assert.ok(sent.some((entry) => entry.channel === "netcatty:fido-prompt-cancelled"));
|
||||
});
|
||||
|
||||
test("Windows notification helpers exit immediately while hints follow PIN and lease ownership", async () => {
|
||||
class Socket extends EventEmitter {
|
||||
setEncoding() {}
|
||||
end(data) { this.reply = JSON.parse(data); this.emit("close"); }
|
||||
}
|
||||
const sender = { id: 2823, isDestroyed: () => false, send() {} };
|
||||
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
|
||||
const leaseId = env.NETCATTY_FIDO_ASKPASS_LEASE;
|
||||
const { handleAskpassClient } = require("./fidoAskpass.cjs");
|
||||
try {
|
||||
const notification = new Socket();
|
||||
handleAskpassClient(notification, "win32");
|
||||
notification.emit("data", `${JSON.stringify({ mode: "none", leaseId, prompt: "Touch security key" })}\n`);
|
||||
assert.equal(notification.reply.ok, true, "native helper must not block device-failure replies");
|
||||
assert.equal(fidoPromptHandler.getRequests().size, 1, "helper exit must not dismiss the independent hint");
|
||||
const pin = new Socket();
|
||||
handleAskpassClient(pin, "win32");
|
||||
pin.emit("data", `${JSON.stringify({ leaseId, prompt: "Enter PIN for authenticator:" })}\n`);
|
||||
const requests = [...fidoPromptHandler.getRequests()];
|
||||
assert.equal(requests.length, 1, "a stale touch hint must not block PIN input");
|
||||
assert.equal(requests[0][1].kind, "pin");
|
||||
fidoPromptHandler.handleResponse({ sender }, { requestId: requests[0][0], response: "fixture-pin" });
|
||||
await new Promise(setImmediate);
|
||||
assert.equal(pin.reply.response, "fixture-pin");
|
||||
const hints = [...fidoPromptHandler.getRequests().values()];
|
||||
assert.equal(hints.length, 1);
|
||||
assert.equal(hints[0].kind, "touch");
|
||||
releaseFidoAskpassLease(leaseId);
|
||||
assert.equal(fidoPromptHandler.getRequests().size, 0);
|
||||
} finally { releaseFidoAskpassLease(leaseId); shutdownFidoAskpass(); }
|
||||
});
|
||||
@@ -6,13 +6,27 @@ const path = require("node:path");
|
||||
const {
|
||||
resolveFidoAskpassSocketPath,
|
||||
isWindowsNamedPipePath,
|
||||
selectFidoAskpassWrapper,
|
||||
} = require("./fidoAskpass.cjs");
|
||||
|
||||
test("resolveFidoAskpassSocketPath uses Windows named-pipe namespace on win32", () => {
|
||||
const sock = resolveFidoAskpassSocketPath("C:\\Users\\netcatty\\Temp\\askpass-base", "win32");
|
||||
assert.equal(isWindowsNamedPipePath(sock), true);
|
||||
assert.match(sock, /^\\\\\.\\pipe\\netcatty-fido-askpass-/);
|
||||
assert.equal(path.isAbsolute(sock), false);
|
||||
assert.equal(path.isAbsolute(sock), process.platform === "win32");
|
||||
});
|
||||
|
||||
test("Windows MSYS helpers use a shell wrapper while native OpenSSH keeps its command wrapper", () => {
|
||||
const wrapper = "C:\\Users\\First Last\\askpass.cmd";
|
||||
const msys = "C:\\msys64\\usr\\bin\\ssh-keygen.exe";
|
||||
const native = "C:\\Windows\\System32\\OpenSSH\\ssh-keygen.exe";
|
||||
const files = new Set([msys, native, "C:\\msys64\\usr\\bin\\msys-2.0.dll"]);
|
||||
const exists = file => files.has(file);
|
||||
assert.equal(selectFidoAskpassWrapper(wrapper, msys, "win32", {}, exists), "C:/Users/First Last/askpass.sh");
|
||||
assert.equal(selectFidoAskpassWrapper(wrapper, native, "win32", {}, exists), wrapper);
|
||||
assert.equal(selectFidoAskpassWrapper(wrapper, "ssh-keygen", "win32", {
|
||||
PATH: "C:\\Windows\\System32\\OpenSSH;C:\\msys64\\usr\\bin",
|
||||
}, exists), wrapper, "the first executable on PATH determines the runtime");
|
||||
});
|
||||
|
||||
test("resolveFidoAskpassSocketPath uses filesystem sockets on unix", () => {
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
|
||||
// Native OpenSSH clients must sign through the same caller-bound agent wrapper
|
||||
// as ssh2. A raw singleton socket cannot attribute a request to its window.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { randomUUID, randomBytes, timingSafeEqual } = require("node:crypto");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const { isWindowsPosixSsh } = require("./fidoAskpass.cjs");
|
||||
const { AgentProtocol } = require("ssh2/lib/agent.js");
|
||||
|
||||
// MSYS/Cygwin emulate AF_UNIX with an authenticated loopback connection.
|
||||
// Match their secret + peer-credential handshake; never expose agent protocol
|
||||
// until the caller proves it can read the private socket descriptor file.
|
||||
function authenticateCygwinSocket(socket, secret, onAuthenticated) {
|
||||
let buffered = Buffer.alloc(0);
|
||||
let haveSecret = false;
|
||||
socket.setTimeout(5000, () => socket.destroy());
|
||||
function receive(chunk) {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
if (!haveSecret) {
|
||||
if (buffered.length < 16) return;
|
||||
if (!timingSafeEqual(buffered.subarray(0, 16), secret)) { socket.destroy(); return; }
|
||||
buffered = buffered.subarray(16);
|
||||
haveSecret = true;
|
||||
socket.write(secret);
|
||||
}
|
||||
if (buffered.length < 12) return;
|
||||
const credentials = Buffer.from(buffered.subarray(0, 12));
|
||||
credentials.writeUInt32LE(process.pid, 0);
|
||||
socket.write(credentials);
|
||||
socket.removeListener("data", receive);
|
||||
socket.setTimeout(0);
|
||||
if (buffered.length > 12) socket.unshift(buffered.subarray(12));
|
||||
onAuthenticated(socket);
|
||||
}
|
||||
socket.on("data", receive);
|
||||
}
|
||||
|
||||
async function createNativeFidoAgent(agent, options = {}) {
|
||||
const base = require("./tempDirBridge.cjs").getTempDir();
|
||||
const dir = await fs.promises.mkdtemp(path.join(base, "fn-"));
|
||||
const cygwin = isWindowsPosixSsh(options.sshBinaryPath);
|
||||
const secret = cygwin ? randomBytes(16) : null;
|
||||
const socketPath = process.platform === "win32" && !cygwin
|
||||
? `\\\\.\\pipe\\netcatty-fido-native-${randomUUID()}`
|
||||
: path.join(dir, "agent.sock");
|
||||
const sockets = new Set();
|
||||
let closed = false;
|
||||
function serve(socket) {
|
||||
const protocol = new AgentProtocol(false);
|
||||
protocol.on("error", () => socket.destroy());
|
||||
const respond = operation => {
|
||||
if (!closed && !socket.destroyed && !protocol.destroyed) {
|
||||
try { operation(); } catch { socket.destroy(); }
|
||||
}
|
||||
};
|
||||
protocol.on("identities", request => {
|
||||
agent.getIdentities((error, keys) => respond(() => error
|
||||
? protocol.failureReply(request) : protocol.getIdentitiesReply(request, keys)));
|
||||
});
|
||||
protocol.on("sign", (request, key, data, flags) => {
|
||||
// Enforce the selected identities for direct native signing requests too.
|
||||
agent.getIdentities((error, keys) => {
|
||||
if (closed || socket.destroyed) return;
|
||||
if (error || !Array.isArray(keys)
|
||||
|| !keys.some(candidate => candidate.getPublicSSH().equals(key.getPublicSSH()))) {
|
||||
respond(() => protocol.failureReply(request));
|
||||
return;
|
||||
}
|
||||
agent.sign(key, data, flags, (signError, signature) => respond(() => signError
|
||||
? protocol.failureReply(request) : protocol.signReply(request, signature)));
|
||||
});
|
||||
});
|
||||
socket.pipe(protocol).pipe(socket);
|
||||
}
|
||||
const server = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
if (cygwin) authenticateCygwinSocket(socket, secret, serve);
|
||||
else serve(socket);
|
||||
});
|
||||
function close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const socket of sockets) socket.destroy();
|
||||
server.close(() => fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {}));
|
||||
}
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
const address = cygwin ? { host: "127.0.0.1", port: 0 } : socketPath;
|
||||
server.listen(address, () => { server.removeListener("error", reject); resolve(); });
|
||||
});
|
||||
if (cygwin) {
|
||||
const words = Array.from({ length: 4 }, (_, i) => secret.readUInt32LE(i * 4).toString(16).toUpperCase().padStart(8, "0"));
|
||||
await fs.promises.writeFile(socketPath, `!<socket >${server.address().port} s ${words.join("-")}\0`, { mode: 0o600, flag: "wx" });
|
||||
// The SYSTEM attribute makes MSYS recognize the descriptor as a socket.
|
||||
await promisify(execFile)(path.win32.join(process.env.SystemRoot || "C:\\Windows", "System32", "attrib.exe"),
|
||||
["+S", socketPath], { windowsHide: true, timeout: 5000 });
|
||||
}
|
||||
if (process.platform !== "win32") await fs.promises.chmod(socketPath, 0o600);
|
||||
server.on("error", close);
|
||||
return { socketPath: cygwin ? socketPath.replace(/\\/g, "/") : socketPath, close };
|
||||
} catch (error) {
|
||||
close();
|
||||
await fs.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { createNativeFidoAgent, authenticateCygwinSocket };
|
||||
@@ -0,0 +1,179 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { generateKeyPairSync, randomBytes } = require("node:crypto");
|
||||
const { execFile } = require("node:child_process");
|
||||
const { promisify } = require("node:util");
|
||||
const net = require("node:net");
|
||||
const { once } = require("node:events");
|
||||
const { utils } = require("ssh2");
|
||||
const { createAgent } = require("ssh2/lib/agent.js");
|
||||
const { createNativeFidoAgent, authenticateCygwinSocket } = require("./fidoNativeAgent.cjs");
|
||||
const askpass = require("./fidoAskpass.cjs");
|
||||
const prompts = require("./fidoPromptHandler.cjs");
|
||||
const run = promisify(execFile);
|
||||
|
||||
function key() {
|
||||
const pair = generateKeyPairSync("rsa", { modulusLength: 2048,
|
||||
privateKeyEncoding: { type: "pkcs1", format: "pem" },
|
||||
publicKeyEncoding: { type: "spki", format: "pem" } });
|
||||
return utils.parseKey(pair.privateKey);
|
||||
}
|
||||
|
||||
function sign(agent, key, data) {
|
||||
return new Promise((resolve, reject) => agent.sign(key, data, { hash: "sha256" },
|
||||
(error, signature) => error ? reject(error) : resolve(signature)));
|
||||
}
|
||||
|
||||
test("native agent sockets attribute concurrent helper subprocess prompts to their signing windows", async () => {
|
||||
const selected = key();
|
||||
const seen = [];
|
||||
const proxies = [];
|
||||
const envs = [];
|
||||
try {
|
||||
for (const id of [2823, 2824]) {
|
||||
const sender = { id, isDestroyed: () => false, send(channel, request) {
|
||||
if (channel !== "netcatty:fido-prompt-request") return;
|
||||
seen.push(id);
|
||||
queueMicrotask(() => prompts.handleResponse({ sender }, {
|
||||
requestId: request.requestId, response: `test-pin-${id}`, cancelled: false,
|
||||
}));
|
||||
} };
|
||||
const env = askpass.buildFidoAskpassEnv({ resolveWebContents: () => sender });
|
||||
envs.push(env);
|
||||
const wrapped = {
|
||||
getIdentities: callback => callback(null, [selected]),
|
||||
sign(publicKey, data, flags, callback) {
|
||||
askpass.runWithFidoSigningLease(env.NETCATTY_FIDO_ASKPASS_LEASE, finish => {
|
||||
const { NETCATTY_FIDO_ASKPASS_LEASE: _lease, ...helperEnv } = env;
|
||||
// Real generated helper subprocess, with the shared agent's
|
||||
// leaseless environment, as OpenSSH's ssh-sk-helper would use it.
|
||||
run(process.execPath, [askpass.ensureFidoAskpass().scriptPath, "Enter PIN for authenticator:"], {
|
||||
env: { ...process.env, ...helperEnv }, timeout: 10000,
|
||||
}).then(result => {
|
||||
assert.equal(result.stdout.trim(), `test-pin-${id}`);
|
||||
finish();
|
||||
callback(null, selected.sign(data, flags.hash));
|
||||
}, error => { finish(); callback(error); });
|
||||
});
|
||||
},
|
||||
};
|
||||
proxies.push(await createNativeFidoAgent(wrapped));
|
||||
}
|
||||
const data = Buffer.from("native SSH signing request");
|
||||
const signatures = await Promise.all(proxies.map(proxy => sign(createAgent(proxy.socketPath), selected, data)));
|
||||
assert.deepEqual(seen, process.platform === "win32" ? [2823, 2823, 2824, 2824] : [2823, 2824]);
|
||||
for (const signature of signatures) assert.equal(selected.verify(data, signature, "sha256"), true);
|
||||
} finally {
|
||||
for (const proxy of proxies) proxy.close();
|
||||
for (const env of envs) askpass.releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
|
||||
askpass.shutdownFidoAskpass();
|
||||
}
|
||||
});
|
||||
|
||||
test("native agent rejects signing identities outside the session selection and closes on release", async () => {
|
||||
const selected = key();
|
||||
const unrelated = key();
|
||||
let signCalls = 0;
|
||||
const proxy = await createNativeFidoAgent({
|
||||
getIdentities: callback => callback(null, [selected]),
|
||||
sign: () => { signCalls += 1; },
|
||||
});
|
||||
try {
|
||||
const client = createAgent(proxy.socketPath);
|
||||
await assert.rejects(sign(client, unrelated, Buffer.from("unauthorized")), /failure/);
|
||||
assert.equal(signCalls, 0);
|
||||
} finally { proxy.close(); }
|
||||
await assert.rejects(new Promise((resolve, reject) => createAgent(proxy.socketPath).getIdentities(
|
||||
error => error ? reject(error) : resolve())), /Failed to connect to agent/);
|
||||
});
|
||||
|
||||
test("unsupported OpenSSH extensions do not corrupt the following identity request", { timeout: 5000 }, async () => {
|
||||
const selected = key();
|
||||
const proxy = await createNativeFidoAgent({ getIdentities: callback => callback(null, [selected]) });
|
||||
const socket = net.createConnection(proxy.socketPath);
|
||||
function frame(type, payload = Buffer.alloc(0)) {
|
||||
const buffer = Buffer.alloc(5 + payload.length);
|
||||
buffer.writeUInt32BE(1 + payload.length);
|
||||
buffer[4] = type;
|
||||
payload.copy(buffer, 5);
|
||||
return buffer;
|
||||
}
|
||||
try {
|
||||
await once(socket, "connect");
|
||||
const responses = new Promise((resolve, reject) => {
|
||||
let buffered = Buffer.alloc(0);
|
||||
const types = [];
|
||||
socket.on("error", reject);
|
||||
socket.on("data", chunk => {
|
||||
buffered = Buffer.concat([buffered, chunk]);
|
||||
while (buffered.length >= 5 && buffered.length >= 4 + buffered.readUInt32BE()) {
|
||||
types.push(buffered[4]);
|
||||
buffered = buffered.subarray(4 + buffered.readUInt32BE());
|
||||
}
|
||||
if (types.length >= 3) resolve(types);
|
||||
});
|
||||
});
|
||||
// Coalesce identities, an unknown payload, and identities in one write.
|
||||
// This also proves parsing advances correctly after a preceding frame.
|
||||
socket.write(Buffer.concat([frame(11), frame(27, Buffer.from("unsupported-extension-payload")), frame(11)]));
|
||||
assert.deepEqual(await responses, [12, 5, 12]);
|
||||
} finally { socket.destroy(); proxy.close(); }
|
||||
});
|
||||
|
||||
|
||||
test("Cygwin socket authenticates fragmented handshakes and rejects a wrong secret", { timeout: 5000 }, async () => {
|
||||
const secret = randomBytes(16);
|
||||
let admitted = 0;
|
||||
const sockets = new Set();
|
||||
const server = net.createServer(socket => {
|
||||
sockets.add(socket);
|
||||
socket.on("error", () => {});
|
||||
socket.once("close", () => sockets.delete(socket));
|
||||
authenticateCygwinSocket(socket, secret, authenticated => {
|
||||
admitted += 1;
|
||||
authenticated.on("data", data => authenticated.write(data));
|
||||
});
|
||||
});
|
||||
server.listen(0, "127.0.0.1");
|
||||
await once(server, "listening");
|
||||
const connect = () => net.createConnection(server.address().port, "127.0.0.1");
|
||||
const bad = connect();
|
||||
const good = connect();
|
||||
const goodConnected = once(good, "connect");
|
||||
try {
|
||||
await once(bad, "connect");
|
||||
const closed = once(bad, "close");
|
||||
bad.write(Buffer.alloc(16));
|
||||
await closed;
|
||||
assert.equal(admitted, 0);
|
||||
await goodConnected;
|
||||
const echo = once(good, "data");
|
||||
good.write(secret.subarray(0, 7));
|
||||
good.write(secret.subarray(7));
|
||||
assert.deepEqual((await echo)[0], secret);
|
||||
const credentials = Buffer.alloc(12);
|
||||
credentials.writeUInt32LE(1001, 4);
|
||||
credentials.writeUInt32LE(1002, 8);
|
||||
const reply = new Promise(resolve => {
|
||||
let bytes = Buffer.alloc(0);
|
||||
good.on("data", chunk => {
|
||||
bytes = Buffer.concat([bytes, chunk]);
|
||||
if (bytes.length >= 17) resolve(bytes);
|
||||
});
|
||||
});
|
||||
good.write(credentials.subarray(0, 5));
|
||||
// Preserve agent bytes coalesced with the end of the credential handshake.
|
||||
good.write(Buffer.concat([credentials.subarray(5), Buffer.from("agent")]));
|
||||
const bytes = await reply;
|
||||
assert.equal(bytes.readUInt32LE(0), process.pid);
|
||||
assert.deepEqual(bytes.subarray(4, 12), credentials.subarray(4));
|
||||
assert.equal(bytes.subarray(12).toString(), "agent");
|
||||
assert.equal(admitted, 1);
|
||||
} finally {
|
||||
bad.destroy(); good.destroy();
|
||||
for (const socket of sockets) socket.destroy();
|
||||
await new Promise(resolve => server.close(resolve));
|
||||
}
|
||||
});
|
||||
@@ -66,11 +66,12 @@ function cancelFidoPromptRequest(requestId, reason = "cancelled") {
|
||||
* @param {string} [reason]
|
||||
* @returns {number}
|
||||
*/
|
||||
function cancelFidoPromptRequestsForLease(leaseId, reason = "lease-released") {
|
||||
function cancelFidoPromptRequestsForLease(leaseId, reason = "lease-released", notificationsOnly = false) {
|
||||
if (typeof leaseId !== "string" || !leaseId) return 0;
|
||||
let cancelled = 0;
|
||||
for (const [requestId, pending] of [...fidoPromptRequests.entries()]) {
|
||||
if (pending.leaseId !== leaseId) continue;
|
||||
if (notificationsOnly && !pending.notificationOnly) continue;
|
||||
if (cancelFidoPromptRequest(requestId, reason)) cancelled += 1;
|
||||
}
|
||||
return cancelled;
|
||||
@@ -116,6 +117,7 @@ function requestFidoPrompt(sender, options = {}) {
|
||||
webContentsId: sender.id,
|
||||
kind,
|
||||
leaseId,
|
||||
notificationOnly: options.notificationOnly === true,
|
||||
createdAt: Date.now(),
|
||||
timeoutId,
|
||||
});
|
||||
|
||||
@@ -90,6 +90,8 @@ async function generateFidoSshKeyPair(options = {}) {
|
||||
const { buildFidoAskpassEnv } = require("./fidoAskpass.cjs");
|
||||
askpassEnv = buildFidoAskpassEnv({
|
||||
resolveWebContents: options.resolveWebContents,
|
||||
sshBinaryPath: sshKeygen,
|
||||
env,
|
||||
});
|
||||
askpassLeaseId = askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE || null;
|
||||
} catch {
|
||||
|
||||
@@ -352,7 +352,8 @@ function createMonitorService({
|
||||
historyId,
|
||||
status: entry?.status ?? host.status ?? "limit",
|
||||
at: shared?.at ?? 0,
|
||||
...(append && last?.historyId === historyId && last.statsAt === (shared?.at ?? 0)
|
||||
statsAt: shared?.statsAt ?? shared?.at ?? 0,
|
||||
...(append && last?.historyId === historyId && last.statsAt === (shared?.statsAt ?? shared?.at ?? 0)
|
||||
? {}
|
||||
: { stats: shared?.result?.success ? shared.result.stats : null }),
|
||||
points: append && last?.historyId === historyId
|
||||
|
||||
@@ -280,7 +280,7 @@ test("full terminal readers upgrade a lightweight cached sample and keep subsequ
|
||||
memTotal: 100,
|
||||
memUsed: null,
|
||||
topProcesses: ctx._monitorStats ? [] : [{ name: "full-process" }],
|
||||
gpu: ctx._monitorStats ? null : 75,
|
||||
gpu: 75,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -752,3 +752,23 @@ test("snapshot deltas send new points only, even with 256 full retained historie
|
||||
assert.ok(Object.entries(fresh.hosts).every(([id, host]) => id === "h0" || !Object.hasOwn(host, "stats")));
|
||||
f.service.dispose();
|
||||
});
|
||||
|
||||
test('a completed GPU probe reaches incremental snapshots before the next CPU sample',async()=>{
|
||||
const {cacheFullGpuStats}=require('./sshBridge/monitorGpuStats.cjs');
|
||||
const f=fixture();
|
||||
try {
|
||||
f.service.reconcile(event(1),[{...demand(),interval:60}]);
|
||||
await until(()=>f.service.snapshot(event(1)).hosts.h?.status==='live');
|
||||
const initial=f.service.snapshot(event(1));
|
||||
const host=initial.hosts.h;
|
||||
const cursor={runtimeId:initial.runtimeId,epoch:initial.epoch,after:{h:{at:host.points.at(-1)?.at??0,statsAt:host.statsAt,historyId:host.historyId}}};
|
||||
cacheFullGpuStats(f.c,{gpu:75,gpuName:'NVIDIA independent',gpuMemUsed:512,gpuMemTotal:8192});
|
||||
const next=f.service.snapshot(event(1),cursor).hosts.h;
|
||||
assert.equal(next.stats.gpu,75,'GPU data must not be omitted by the primary sample cursor');
|
||||
assert.equal(next.at,host.at,'GPU publication must not change CPU freshness');
|
||||
assert.deepEqual(next.points,[],'GPU publication must not add trend samples');
|
||||
assert.ok(next.statsAt>host.statsAt);
|
||||
const caughtUp={...cursor,after:{h:{...cursor.after.h,statsAt:next.statsAt}}};
|
||||
assert.equal(f.service.snapshot(event(1),caughtUp).hosts.h.stats,undefined,'unchanged GPU data is not repeatedly sent');
|
||||
} finally {f.service.dispose();}
|
||||
});
|
||||
@@ -532,17 +532,18 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
await fs.promises.writeFile(path.join(secondDir, "target.bin"), secondOriginal);
|
||||
await fs.promises.symlink(path.basename(firstDir), parentLink);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 1) {
|
||||
await fs.promises.unlink(parentLink);
|
||||
await fs.promises.symlink(path.basename(secondDir), parentLink);
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
// Preparation renames on the same filesystem and copies across devices.
|
||||
for (const operation of ["rename", "copyFile"]) {
|
||||
const original = fs.promises[operation];
|
||||
fs.promises[operation] = async (...args) => {
|
||||
await original(...args);
|
||||
if (String(args[1]).endsWith(".ready")) {
|
||||
await fs.promises.unlink(parentLink);
|
||||
await fs.promises.symlink(path.basename(secondDir), parentLink);
|
||||
}
|
||||
};
|
||||
t.after(() => { fs.promises[operation] = original; });
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
() => sftpBridge.downloadSftpToLocal(null, {
|
||||
@@ -618,6 +619,9 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
// Unified transfers pre-create their stage as 0600. writeFile's mode
|
||||
// only applies to creation; make a real mode change necessary here.
|
||||
await fs.promises.chmod(localPath, 0o644);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
@@ -661,6 +665,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
await fs.promises.chmod(localPath, 0o644);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
@@ -710,6 +715,7 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
},
|
||||
async downloadFile(_remotePath, localPath) {
|
||||
await fs.promises.writeFile(localPath, downloaded, { mode: 0o644 });
|
||||
await fs.promises.chmod(localPath, 0o644);
|
||||
return { fileSize: downloaded.length, transferred: downloaded.length };
|
||||
},
|
||||
};
|
||||
@@ -773,11 +779,9 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
fs.writeFileSync(localPath, original);
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
fs.promises.rename = async (...args) => {
|
||||
await originalRename(...args);
|
||||
renameCalls += 1;
|
||||
if (renameCalls === 2) controller.abort();
|
||||
if (String(args[1]).endsWith(".backup")) controller.abort();
|
||||
};
|
||||
t.after(() => { fs.promises.rename = originalRename; });
|
||||
|
||||
@@ -907,15 +911,13 @@ describe("AI/MCP SCP transfer abort on shipped download/upload entry points", ()
|
||||
fs.writeFileSync(localPath, Buffer.from("existing-local-content"));
|
||||
|
||||
const originalRename = fs.promises.rename;
|
||||
let renameCalls = 0;
|
||||
let readyPath = null;
|
||||
let backupPath = null;
|
||||
fs.promises.rename = async (...args) => {
|
||||
renameCalls += 1;
|
||||
await originalRename(...args);
|
||||
if (renameCalls === 1) readyPath = args[1];
|
||||
if (renameCalls === 2) {
|
||||
if (String(args[1]).endsWith(".backup")) {
|
||||
backupPath = args[1];
|
||||
readyPath = backupPath.replace(/\.backup$/, ".ready");
|
||||
controller.abort();
|
||||
}
|
||||
};
|
||||
|
||||
+191
-189
@@ -1086,21 +1086,26 @@ async function materializeSkPrivateKeyFile(privateKey, injected = {}) {
|
||||
}
|
||||
const baseDir = tempDirBridge.getTempDir();
|
||||
const dir = await fsMod.promises.mkdtemp(pathMod.join(baseDir, "netcatty-sk-key-"));
|
||||
const keyPath = pathMod.join(dir, "id_sk");
|
||||
await fsMod.promises.writeFile(keyPath, privateKey, { mode: 0o600 });
|
||||
// Companion .pub lets shared-agent prep recognize an already-loaded identity
|
||||
// across unique per-session temp paths (refcount cleanup keys on the blob).
|
||||
const publicKeyHint = typeof injected.publicKey === "string" ? injected.publicKey.trim() : "";
|
||||
const publicKeyLine = publicKeyHint || deriveSkPublicKeyLine(privateKey);
|
||||
if (publicKeyLine) {
|
||||
await fsMod.promises.writeFile(`${keyPath}.pub`, `${publicKeyLine}\n`, { mode: 0o600 });
|
||||
try {
|
||||
const keyPath = pathMod.join(dir, "id_sk");
|
||||
await fsMod.promises.writeFile(keyPath, privateKey, { mode: 0o600 });
|
||||
// Companion .pub lets shared-agent prep recognize an already-loaded identity
|
||||
// across unique per-session temp paths (refcount cleanup keys on the blob).
|
||||
const publicKeyHint = typeof injected.publicKey === "string" ? injected.publicKey.trim() : "";
|
||||
const publicKeyLine = publicKeyHint || deriveSkPublicKeyLine(privateKey);
|
||||
if (publicKeyLine) {
|
||||
await fsMod.promises.writeFile(`${keyPath}.pub`, `${publicKeyLine}\n`, { mode: 0o600 });
|
||||
}
|
||||
const certificate = typeof injected.certificate === "string" ? injected.certificate.trim() : "";
|
||||
if (certificate) {
|
||||
// OpenSSH ssh-add auto-loads `<key>-cert.pub` next to the private handle.
|
||||
await fsMod.promises.writeFile(`${keyPath}-cert.pub`, `${certificate}\n`, { mode: 0o600 });
|
||||
}
|
||||
return { keyPath, cleanupDir: dir };
|
||||
} catch (error) {
|
||||
await fsMod.promises.rm(dir, { recursive: true, force: true }).catch(() => {});
|
||||
throw error;
|
||||
}
|
||||
const certificate = typeof injected.certificate === "string" ? injected.certificate.trim() : "";
|
||||
if (certificate) {
|
||||
// OpenSSH ssh-add auto-loads `<key>-cert.pub` next to the private handle.
|
||||
await fsMod.promises.writeFile(`${keyPath}-cert.pub`, `${certificate}\n`, { mode: 0o600 });
|
||||
}
|
||||
return { keyPath, cleanupDir: dir };
|
||||
}
|
||||
|
||||
async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
@@ -1204,66 +1209,16 @@ async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
? options.agentPublicKeys.find((key) => looksLikeSkOpenSshMaterial(key))
|
||||
: null;
|
||||
let skTempCleanup = null;
|
||||
if (looksLikeSkOpenSshMaterial(options.privateKey)) {
|
||||
try {
|
||||
const materialized = await materializeSkPrivateKeyFile(options.privateKey, {
|
||||
certificate: options.certificate,
|
||||
publicKey: publicKeyHint,
|
||||
});
|
||||
if (materialized?.keyPath) {
|
||||
identityFilePaths.push(materialized.keyPath);
|
||||
skTempCleanup = materialized.cleanupDir;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${logPrefix} Could not materialize FIDO2 key handle for agent load`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
isFidoFlow
|
||||
&& typeof options.certificate === "string"
|
||||
&& options.certificate.trim()
|
||||
&& Array.isArray(options.identityFilePaths)
|
||||
) {
|
||||
// Path-backed SK + vault certificate: copy handle into temp and stage
|
||||
// `<key>-cert.pub` so ssh-add advertises the certified identity. Never
|
||||
// mutate the user's IdentityFile tree.
|
||||
try {
|
||||
for (const rawPath of options.identityFilePaths) {
|
||||
const identityPath = expandIdentityFilePath(rawPath, options);
|
||||
if (!identityPath || identityPath.endsWith(".pub")) continue;
|
||||
let privateKeyText = "";
|
||||
try {
|
||||
privateKeyText = await fs.promises.readFile(identityPath, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!looksLikeSkOpenSshMaterial(privateKeyText)) continue;
|
||||
const materialized = await materializeSkPrivateKeyFile(privateKeyText, {
|
||||
certificate: options.certificate,
|
||||
publicKey: publicKeyHint,
|
||||
});
|
||||
if (materialized?.keyPath) {
|
||||
identityFilePaths.push(materialized.keyPath);
|
||||
skTempCleanup = materialized.cleanupDir;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${logPrefix} Could not stage FIDO certificate for agent load`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** @type {string[]} */
|
||||
let newlyLoadedIdentityPaths = [];
|
||||
/** @type {Array<{ key: string, identityPath: string }>} */
|
||||
let sharedAgentIdentities = [];
|
||||
let fidoResourcesReleased = false;
|
||||
let nativeFidoAgent = null;
|
||||
const releaseAcquiredFidoResources = () => {
|
||||
if (fidoResourcesReleased) return;
|
||||
fidoResourcesReleased = true;
|
||||
nativeFidoAgent?.close();
|
||||
// Netcatty-owned agents outlive a single session (releaseFidoAgent keeps
|
||||
// the process alive while other sessions hold refs), just like the shared
|
||||
// Windows system agent. In both cases: drop our hold and only ssh-add -d
|
||||
@@ -1276,40 +1231,27 @@ async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
releaseSharedAgentIdentity,
|
||||
} = require("./systemSshAgent.cjs");
|
||||
const sshAdd = fidoSshAddPath || resolveSshAddBinary(process.platform, process.env);
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const { shouldRemove, identityPath, cleanupDir } = releaseSharedAgentIdentity(entry.key);
|
||||
if (!shouldRemove) continue;
|
||||
const removeCleanupDir = () => {
|
||||
if (!cleanupDir) return;
|
||||
fs.promises.rm(cleanupDir, { recursive: true, force: true }).catch(() => {});
|
||||
};
|
||||
if (identityPath) {
|
||||
try {
|
||||
// ssh-add -d treats operands as identity-file paths; keep the
|
||||
// staging dir until the child finishes (or fails to spawn).
|
||||
childProcess.execFile(
|
||||
sshAdd,
|
||||
["-d", identityPath],
|
||||
{
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: {
|
||||
...process.env,
|
||||
SSH_AUTH_SOCK: socketPath,
|
||||
SSH_ASKPASS_REQUIRE: "never",
|
||||
},
|
||||
},
|
||||
() => {
|
||||
removeCleanupDir();
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
removeCleanupDir();
|
||||
void systemSshAgent.withAgentIdentityLock(socketPath, async () => {
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const { shouldRemove, identityPath, cleanupDir, plain } = releaseSharedAgentIdentity(entry.key, socketPath);
|
||||
if (!shouldRemove) continue;
|
||||
if (identityPath) {
|
||||
// Keep all selectors until ssh-add finishes, and prevent a new
|
||||
// preparation from borrowing an identity that is being removed.
|
||||
await new Promise(resolve => {
|
||||
try {
|
||||
childProcess.execFile(sshAdd, plain ? ["-d", "-k", identityPath] : ["-d", identityPath], {
|
||||
timeout: 5000,
|
||||
windowsHide: true,
|
||||
env: { ...process.env, SSH_AUTH_SOCK: socketPath, SSH_ASKPASS_REQUIRE: "never" },
|
||||
}, () => resolve());
|
||||
} catch { resolve(); }
|
||||
});
|
||||
}
|
||||
} else {
|
||||
removeCleanupDir();
|
||||
const removable = systemSshAgent.finishSharedCleanupDir(cleanupDir);
|
||||
if (removable) await fs.promises.rm(removable, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}).catch(() => {});
|
||||
}
|
||||
try {
|
||||
// Always drop the acquire refcount (owned child or Windows system-pipe slot).
|
||||
@@ -1330,103 +1272,163 @@ async function prepareSystemSshAgentForAuth(options, logPrefix = "[SSHAuth]") {
|
||||
};
|
||||
|
||||
try {
|
||||
if (looksLikeSkOpenSshMaterial(options.privateKey)) {
|
||||
try {
|
||||
const materialized = await materializeSkPrivateKeyFile(options.privateKey, {
|
||||
certificate: options.certificate,
|
||||
publicKey: publicKeyHint,
|
||||
});
|
||||
if (materialized?.keyPath) {
|
||||
identityFilePaths.push(materialized.keyPath);
|
||||
skTempCleanup = materialized.cleanupDir;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${logPrefix} Could not materialize FIDO2 key handle for agent load`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
} else if (
|
||||
isFidoFlow
|
||||
&& typeof options.certificate === "string"
|
||||
&& options.certificate.trim()
|
||||
&& Array.isArray(options.identityFilePaths)
|
||||
) {
|
||||
// Path-backed SK + vault certificate: copy handle into temp and stage
|
||||
// `<key>-cert.pub` so ssh-add advertises the certified identity. Never
|
||||
// mutate the user's IdentityFile tree.
|
||||
try {
|
||||
for (const rawPath of options.identityFilePaths) {
|
||||
const identityPath = expandIdentityFilePath(rawPath, options);
|
||||
if (!identityPath || identityPath.endsWith(".pub")) continue;
|
||||
let privateKeyText = "";
|
||||
try {
|
||||
privateKeyText = await fs.promises.readFile(identityPath, "utf8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
if (!looksLikeSkOpenSshMaterial(privateKeyText)) continue;
|
||||
const materialized = await materializeSkPrivateKeyFile(privateKeyText, {
|
||||
certificate: options.certificate,
|
||||
publicKey: publicKeyHint,
|
||||
});
|
||||
if (materialized?.keyPath) {
|
||||
identityFilePaths.push(materialized.keyPath);
|
||||
skTempCleanup = materialized.cleanupDir;
|
||||
}
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`${logPrefix} Could not stage FIDO certificate for agent load`, {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const prepEnv = fidoSshAddPath
|
||||
? { ...process.env, NETCATTY_SSH_ADD_PATH: fidoSshAddPath }
|
||||
: process.env;
|
||||
const agent = await systemSshAgent.prepareSystemSshAgent({
|
||||
socketPath,
|
||||
identityFilePaths,
|
||||
identitiesOnly: options.identitiesOnly,
|
||||
addKeysToAgent: options.addKeysToAgent,
|
||||
useKeychain: ownedFidoAgent ? false : options.useKeychain,
|
||||
agentPublicKeys: isFidoFlow && options.certificate
|
||||
? [...(options.agentPublicKeys || []), options.certificate]
|
||||
: options.agentPublicKeys,
|
||||
loadIdentityFilesIntoAgent: looksLikeSkOpenSshMaterial(options.privateKey)
|
||||
|| (Array.isArray(options.agentPublicKeys)
|
||||
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|
||||
|| options.loadIdentityFilesIntoAgent === true
|
||||
|| ownedFidoAgent
|
||||
// Windows system-pipe fallback still needs SK handles loaded for ssh2.
|
||||
|| (!ownedFidoAgent && acquiredFidoAgent),
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
username: options.username,
|
||||
}, {
|
||||
log: (message, details) => console.log(`${logPrefix} ${message}`, details ?? ""),
|
||||
askpassEnv,
|
||||
env: prepEnv,
|
||||
});
|
||||
if (agent) {
|
||||
// Expose the socket so Mosh/ET/native OpenSSH can point IdentityAgent at
|
||||
// the same agent that received ssh-add of SK handles.
|
||||
agent._netcattyAgentSocket = socketPath;
|
||||
newlyLoadedIdentityPaths = Array.isArray(agent._netcattyNewlyLoadedIdentityPaths)
|
||||
? agent._netcattyNewlyLoadedIdentityPaths
|
||||
: [];
|
||||
sharedAgentIdentities = Array.isArray(agent._netcattySharedAgentIdentities)
|
||||
? agent._netcattySharedAgentIdentities
|
||||
: newlyLoadedIdentityPaths.map((identityPath) => ({
|
||||
key: identityPath,
|
||||
identityPath,
|
||||
}));
|
||||
if (sharedAgentIdentities.length > 0) {
|
||||
// Reference-count identities for owned agents too: the owned agent is
|
||||
// a long-lived singleton, so closing one session must drop only that
|
||||
// session's hold, not leave its SK identity advertised to survivors.
|
||||
const { retainSharedAgentIdentity } = require("./systemSshAgent.cjs");
|
||||
let transferredSkTempCleanup = false;
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const cleanupDir = skTempCleanup
|
||||
&& typeof entry.identityPath === "string"
|
||||
&& entry.identityPath.startsWith(skTempCleanup)
|
||||
? skTempCleanup
|
||||
: null;
|
||||
const retained = retainSharedAgentIdentity(entry.key, entry.identityPath, cleanupDir);
|
||||
// Only the first retainer for a public identity adopts staging cleanup.
|
||||
// Later sessions materialize a redundant temp dir that must stay on
|
||||
// skTempCleanup so the finally block can remove it immediately.
|
||||
if (retained?.acceptedCleanup) {
|
||||
transferredSkTempCleanup = true;
|
||||
}
|
||||
}
|
||||
if (transferredSkTempCleanup) {
|
||||
skTempCleanup = null;
|
||||
}
|
||||
}
|
||||
const askpassLeaseId = askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE;
|
||||
if (askpassLeaseId) {
|
||||
const { runWithFidoSigningLease } = require("./fidoAskpass.cjs");
|
||||
const sign = agent.sign.bind(agent);
|
||||
agent.sign = (key, data, signOptions, callback) => {
|
||||
if (typeof signOptions === "function") {
|
||||
callback = signOptions;
|
||||
signOptions = undefined;
|
||||
}
|
||||
runWithFidoSigningLease(askpassLeaseId, (finish, context) => {
|
||||
if (fidoResourcesReleased) {
|
||||
finish();
|
||||
callback(new Error("FIDO authentication session has ended"));
|
||||
return;
|
||||
return await systemSshAgent.withAgentIdentityLock(socketPath, async () => {
|
||||
const agent = await systemSshAgent.prepareSystemSshAgent({
|
||||
socketPath,
|
||||
identityFilePaths,
|
||||
identitiesOnly: options.identitiesOnly,
|
||||
addKeysToAgent: options.addKeysToAgent,
|
||||
useKeychain: ownedFidoAgent ? false : options.useKeychain,
|
||||
agentPublicKeys: isFidoFlow && options.certificate
|
||||
? [...(options.agentPublicKeys || []), options.certificate]
|
||||
: options.agentPublicKeys,
|
||||
loadIdentityFilesIntoAgent: looksLikeSkOpenSshMaterial(options.privateKey)
|
||||
|| (Array.isArray(options.agentPublicKeys)
|
||||
&& options.agentPublicKeys.some((key) => looksLikeSkOpenSshMaterial(key)))
|
||||
|| options.loadIdentityFilesIntoAgent === true
|
||||
|| ownedFidoAgent
|
||||
// Windows system-pipe fallback still needs SK handles loaded for ssh2.
|
||||
|| (!ownedFidoAgent && acquiredFidoAgent),
|
||||
hostname: options.hostname,
|
||||
port: options.port,
|
||||
username: options.username,
|
||||
}, {
|
||||
log: (message, details) => console.log(`${logPrefix} ${message}`, details ?? ""),
|
||||
askpassEnv,
|
||||
env: prepEnv,
|
||||
});
|
||||
if (agent) {
|
||||
// Expose the socket so Mosh/ET/native OpenSSH can point IdentityAgent at
|
||||
// the same agent that received ssh-add of SK handles.
|
||||
agent._netcattyAgentSocket = socketPath;
|
||||
newlyLoadedIdentityPaths = Array.isArray(agent._netcattyNewlyLoadedIdentityPaths)
|
||||
? agent._netcattyNewlyLoadedIdentityPaths
|
||||
: [];
|
||||
sharedAgentIdentities = Array.isArray(agent._netcattySharedAgentIdentities)
|
||||
? agent._netcattySharedAgentIdentities
|
||||
: newlyLoadedIdentityPaths.map((identityPath) => ({
|
||||
key: identityPath,
|
||||
identityPath,
|
||||
}));
|
||||
if (sharedAgentIdentities.length > 0) {
|
||||
// Reference-count identities for owned agents too: the owned agent is
|
||||
// a long-lived singleton, so closing one session must drop only that
|
||||
// session's hold, not leave its SK identity advertised to survivors.
|
||||
const { retainSharedAgentIdentity } = require("./systemSshAgent.cjs");
|
||||
let transferredSkTempCleanup = false;
|
||||
for (const entry of sharedAgentIdentities) {
|
||||
const cleanupDir = skTempCleanup
|
||||
&& typeof entry.identityPath === "string"
|
||||
&& entry.identityPath.startsWith(skTempCleanup)
|
||||
? skTempCleanup
|
||||
: null;
|
||||
const retained = retainSharedAgentIdentity(entry.key, entry.identityPath, cleanupDir, socketPath, entry.plain === true);
|
||||
// Only the first retainer for a public identity adopts staging cleanup.
|
||||
// Later sessions materialize a redundant temp dir that must stay on
|
||||
// skTempCleanup so the finally block can remove it immediately.
|
||||
if (retained?.acceptedCleanup) {
|
||||
transferredSkTempCleanup = true;
|
||||
}
|
||||
require("./fidoSignFlow.cjs").signWithRecovery(context, options,
|
||||
settle => sign(key, data, signOptions, settle)).then(result => {
|
||||
}
|
||||
if (transferredSkTempCleanup) {
|
||||
skTempCleanup = null;
|
||||
}
|
||||
}
|
||||
const askpassLeaseId = askpassEnv?.NETCATTY_FIDO_ASKPASS_LEASE;
|
||||
if (askpassLeaseId) {
|
||||
const { runWithFidoSigningLease } = require("./fidoAskpass.cjs");
|
||||
const sign = agent.sign.bind(agent);
|
||||
agent.sign = (key, data, signOptions, callback) => {
|
||||
if (typeof signOptions === "function") {
|
||||
callback = signOptions;
|
||||
signOptions = undefined;
|
||||
}
|
||||
runWithFidoSigningLease(askpassLeaseId, (finish, context) => {
|
||||
if (fidoResourcesReleased) {
|
||||
finish();
|
||||
callback(result.error, result.signature);
|
||||
}, error => { finish(); callback(error); });
|
||||
});
|
||||
};
|
||||
callback(new Error("FIDO authentication session has ended"));
|
||||
return;
|
||||
}
|
||||
require("./fidoSignFlow.cjs").signWithRecovery(context, options,
|
||||
settle => sign(key, data, signOptions, settle)).then(result => {
|
||||
finish();
|
||||
callback(result.error, result.signature);
|
||||
}, error => { finish(); callback(error); });
|
||||
});
|
||||
};
|
||||
}
|
||||
if (options.nativeFidoAgent === true && isFidoFlow) {
|
||||
nativeFidoAgent = await require("./fidoNativeAgent.cjs").createNativeFidoAgent(agent, { sshBinaryPath: options.nativeSshPath });
|
||||
agent._netcattyAgentSocket = nativeFidoAgent.socketPath;
|
||||
}
|
||||
if (ownedFidoAgent || askpassLeaseId || sharedAgentIdentities.length > 0 || acquiredFidoAgent) {
|
||||
agent._netcattyOwnedFidoAgent = ownedFidoAgent === true;
|
||||
// Shared lease; quit/exit hooks hard-kill the owned agent as a backstop.
|
||||
agent._releaseNetcattyFidoAgent = releaseAcquiredFidoResources;
|
||||
}
|
||||
} else {
|
||||
// Acquisition succeeded but preparation produced no agent — drop leases.
|
||||
releaseAcquiredFidoResources();
|
||||
}
|
||||
if (ownedFidoAgent || askpassLeaseId || sharedAgentIdentities.length > 0 || acquiredFidoAgent) {
|
||||
agent._netcattyOwnedFidoAgent = ownedFidoAgent === true;
|
||||
// Shared lease; quit/exit hooks hard-kill the owned agent as a backstop.
|
||||
agent._releaseNetcattyFidoAgent = releaseAcquiredFidoResources;
|
||||
}
|
||||
} else {
|
||||
// Acquisition succeeded but preparation produced no agent — drop leases.
|
||||
releaseAcquiredFidoResources();
|
||||
}
|
||||
return agent;
|
||||
return agent;
|
||||
});
|
||||
} catch (error) {
|
||||
// prepareSystemSshAgent can throw after acquire (e.g. IdentitiesOnly without
|
||||
// a readable .pub selector); release owned agent + askpass before rethrowing.
|
||||
|
||||
@@ -189,6 +189,59 @@ test("materializeSkPrivateKeyFile fails closed without managed temp dir", async
|
||||
);
|
||||
});
|
||||
|
||||
test("failed companion writes remove the partially staged SK handle", async () => {
|
||||
const root = fs.mkdtempSync(path.join(require("./tempDirBridge.cjs").getTempDir(), "fw-"));
|
||||
try {
|
||||
for (const failAt of [2, 3]) {
|
||||
let writes = 0;
|
||||
const injectedFs = { promises: { ...fs.promises, writeFile: async (...args) => {
|
||||
writes += 1;
|
||||
if (writes === failAt) throw Object.assign(new Error("fixture write denied"), { code: "EACCES" });
|
||||
return fs.promises.writeFile(...args);
|
||||
} } };
|
||||
await assert.rejects(materializeSkPrivateKeyFile(makeSkPrivatePem(SK_SSH_ED25519), {
|
||||
fs: injectedFs, publicKey: skPub, certificate: "fixture certificate",
|
||||
tempDirBridge: { getTempDir: () => root },
|
||||
}), { code: "EACCES" });
|
||||
assert.deepEqual(fs.readdirSync(root), [], "partial credential directories must not remain");
|
||||
}
|
||||
} finally { fs.rmSync(root, { recursive: true, force: true }); }
|
||||
});
|
||||
|
||||
test("failed staging rejects the selected-key login and releases the acquired agent", async () => {
|
||||
const manager = require("./fidoAgentManager.cjs");
|
||||
const askpass = require("./fidoAskpass.cjs");
|
||||
const system = require("./systemSshAgent.cjs");
|
||||
const saved = { acquire: manager.acquireFidoAgent, release: manager.releaseFidoAgent,
|
||||
lease: askpass.releaseFidoAskpassLease, prepare: system.prepareSystemSshAgent, write: fs.promises.writeFile };
|
||||
let prepared = false;
|
||||
let releases = 0;
|
||||
let leases = 0;
|
||||
manager.acquireFidoAgent = async () => ({ socketPath: "/tmp/staging-failure.sock", generation: 7,
|
||||
owned: true, askpassEnv: { NETCATTY_FIDO_ASKPASS_LEASE: "staging-failure" } });
|
||||
manager.releaseFidoAgent = () => { releases += 1; };
|
||||
askpass.releaseFidoAskpassLease = () => { leases += 1; };
|
||||
system.prepareSystemSshAgent = async () => { prepared = true; return {}; };
|
||||
fs.promises.writeFile = async (...args) => {
|
||||
if (path.basename(args[0]) === "id_sk") throw Object.assign(new Error("fixture write denied"), { code: "EACCES" });
|
||||
return saved.write(...args);
|
||||
};
|
||||
try {
|
||||
await assert.rejects(require("./sshAuthHelper.cjs").prepareSystemSshAgentForAuth({
|
||||
privateKey: makeSkPrivatePem(SK_SSH_ED25519),
|
||||
}), { code: "EACCES" });
|
||||
assert.equal(prepared, false, "staging failure must not offer other sessions' identities");
|
||||
assert.equal(releases, 1);
|
||||
assert.equal(leases, 1);
|
||||
} finally {
|
||||
manager.acquireFidoAgent = saved.acquire;
|
||||
manager.releaseFidoAgent = saved.release;
|
||||
askpass.releaseFidoAskpassLease = saved.lease;
|
||||
system.prepareSystemSshAgent = saved.prepare;
|
||||
fs.promises.writeFile = saved.write;
|
||||
}
|
||||
});
|
||||
|
||||
test("prepareSystemSshAgentForAuth releases FIDO resources when preparation throws", async () => {
|
||||
const fidoAgentManager = require("./fidoAgentManager.cjs");
|
||||
const fidoAskpass = require("./fidoAskpass.cjs");
|
||||
@@ -307,8 +360,10 @@ test("owned FIDO agent reference-counts identities: ssh-add -d only on the last
|
||||
|
||||
// Closing session A must not leave A's identity advertised to session B.
|
||||
agentA._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [], "first release must keep the identity for the surviving session");
|
||||
agentB._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [pathA], "last release removes the identity from the owned agent");
|
||||
} finally {
|
||||
fidoAgentManager.acquireFidoAgent = originalAcquire;
|
||||
@@ -378,6 +433,7 @@ test("prepareSystemSshAgentForAuth removes newly loaded identities from a shared
|
||||
assert.ok(agent);
|
||||
assert.equal(typeof agent._releaseNetcattyFidoAgent, "function");
|
||||
agent._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [identityPath]);
|
||||
assert.equal(releasedAgent, 1);
|
||||
} finally {
|
||||
@@ -462,8 +518,10 @@ test("shared Windows agent defers ssh-add -d until the last identity user releas
|
||||
}, "[test-b]");
|
||||
|
||||
agentA._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [], "first release must keep the shared identity loaded");
|
||||
agentB._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.deepEqual(deleted, [pathA], "last release removes the identity via the retained path");
|
||||
} finally {
|
||||
fidoAgentManager.acquireFidoAgent = originalAcquire;
|
||||
@@ -505,8 +563,8 @@ test("shared Windows agent keeps staging dir until ssh-add -d finishes", async (
|
||||
fidoAskpass.releaseFidoAskpassLease = () => {};
|
||||
// Prep is mocked (no real ssh-add); still attach staging cleanup the way
|
||||
// materializeSkPrivateKeyFile + retainSharedAgentIdentity would in production.
|
||||
systemSshAgent.retainSharedAgentIdentity = (key, idPath, cleanupDir) => (
|
||||
originalRetain(key, idPath, cleanupDir || stagingDir)
|
||||
systemSshAgent.retainSharedAgentIdentity = (key, idPath, cleanupDir, socketPath) => (
|
||||
originalRetain(key, idPath, cleanupDir || stagingDir, socketPath)
|
||||
);
|
||||
systemSshAgent.prepareSystemSshAgent = async () => ({
|
||||
getIdentities: (cb) => cb(null, []),
|
||||
@@ -535,6 +593,7 @@ test("shared Windows agent keeps staging dir until ssh-add -d finishes", async (
|
||||
assert.equal(typeof agent._releaseNetcattyFidoAgent, "function");
|
||||
|
||||
agent._releaseNetcattyFidoAgent();
|
||||
await new Promise(setImmediate);
|
||||
assert.ok(pendingDeleteCb, "expected ssh-add -d to start");
|
||||
assert.equal(
|
||||
fs.existsSync(stagingDir),
|
||||
|
||||
@@ -91,6 +91,54 @@ test("worker IPC installs one owner teardown across all monitor handlers", async
|
||||
]);
|
||||
});
|
||||
|
||||
test("starting monitoring publishes latency even when SSH keepalives are disabled", async () => {
|
||||
pool.resetSshTransportRegistryForTests({ defaultIdleTtlMs: 0 });
|
||||
const { selectServerStatsFixtureOutput } = require("./sshBridge/serverStatsTestHelpers.cjs");
|
||||
const handlers = register();
|
||||
const e = event(93);
|
||||
const options = {
|
||||
hostname: "latency-fixture",
|
||||
username: "fixture",
|
||||
password: "fixture-only",
|
||||
keepaliveInterval: 0,
|
||||
verifyHostKeys: false,
|
||||
};
|
||||
const conn = new EventEmitter();
|
||||
conn._sock = { destroyed: false };
|
||||
conn.config = { keepaliveInterval: 0 };
|
||||
conn.end = () => {};
|
||||
conn.exec = (command, callback) => {
|
||||
const stream = new EventEmitter();
|
||||
stream.stderr = new EventEmitter();
|
||||
stream.write = () => true;
|
||||
callback(null, stream);
|
||||
setImmediate(() => {
|
||||
stream.emit("data", Buffer.from(selectServerStatsFixtureOutput(command,
|
||||
"CPURAW:1000 900|CORES:4|PERCORERAW:|MEMINFO:8000 4000 100 900 0 0|PROCS:|DISKS:|NET:")));
|
||||
stream.emit("close", 0);
|
||||
});
|
||||
};
|
||||
const transport = pool.createTransport({ conn, endpoint: pool.buildConnectionReuseEndpoint(options) });
|
||||
const holder = {};
|
||||
pool.borrowTransport(transport, { kind: pool.LEASE_KINDS.shell, holder });
|
||||
try {
|
||||
await handlers.get("netcatty:monitor:update")(e, [{ id: "latency", hosts: [{ id: "host", options }] }]);
|
||||
let snapshot;
|
||||
const deadline = Date.now() + 2000;
|
||||
do {
|
||||
await tick();
|
||||
snapshot = handlers.get("netcatty:monitor:snapshot")(e);
|
||||
} while (snapshot.hosts.host?.status !== "live" && Date.now() < deadline);
|
||||
assert.equal(snapshot.hosts.host?.status, "live");
|
||||
assert.equal(snapshot.hosts.host.stats.memTotal, 8000);
|
||||
assert.equal(snapshot.hosts.host.stats.latencyMs, null, "keepalive opt-out is preserved");
|
||||
assert.equal(typeof snapshot.hosts.host.stats.responseLatencyMs, "number");
|
||||
} finally {
|
||||
bridge.stopAllMonitoring();
|
||||
pool.returnTransport(holder);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("worker receiving handlers accept the actual IPC sender without WebContents lifecycle methods", async () => {
|
||||
const { createWorkerSender } = require("../terminalWorker/process.cjs");
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
|
||||
const GPU_ASSIGNMENT = `gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`;
|
||||
const EMPTY_GPU = {
|
||||
gpu: null,
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
};
|
||||
const states = new WeakMap();
|
||||
function stateFor(conn) {
|
||||
let state = states.get(conn);
|
||||
if (!state) {
|
||||
state = {
|
||||
stats: EMPTY_GPU,
|
||||
nextAt: 0,
|
||||
pending: null,
|
||||
controller: null,
|
||||
updatedAt: 0,
|
||||
disabled: false,
|
||||
};
|
||||
states.set(conn, state);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
function updateGpuStats(state, next) {
|
||||
if (Object.keys(EMPTY_GPU).some((key) => state.stats[key] !== next[key])) {
|
||||
// Publication time is separate from CPU history and strictly advances even
|
||||
// when a fast GPU reply shares the CPU sample's millisecond.
|
||||
state.updatedAt = Math.max(Date.now() + 1, state.updatedAt + 1);
|
||||
}
|
||||
state.stats = next;
|
||||
}
|
||||
function monitorGpuUpdatedAt(conn) {
|
||||
return states.get(conn)?.updatedAt ?? 0;
|
||||
}
|
||||
function parseGpuStats(output) {
|
||||
const raw =
|
||||
String(output || "")
|
||||
.split("|")
|
||||
.find((part) => part.startsWith("GPU:"))
|
||||
?.slice(4)
|
||||
.trim()
|
||||
.split(/\s+/) ?? [];
|
||||
const utilization = parseInt(raw[0], 10);
|
||||
if (!Number.isFinite(utilization)) return EMPTY_GPU;
|
||||
const number = (value) =>
|
||||
Number.isFinite(parseInt(value, 10)) ? parseInt(value, 10) : null;
|
||||
return {
|
||||
gpu: Math.max(0, Math.min(100, utilization)),
|
||||
gpuName: raw.slice(3).join(" ") || null,
|
||||
gpuMemUsed: number(raw[1]),
|
||||
gpuMemTotal: number(raw[2]),
|
||||
};
|
||||
}
|
||||
function withCachedMonitorGpu(conn, result) {
|
||||
const state = states.get(conn);
|
||||
return state && result?.success
|
||||
? { ...result, stats: { ...result.stats, ...state.stats } }
|
||||
: result;
|
||||
}
|
||||
function cacheFullGpuStats(conn, stats) {
|
||||
const state = stateFor(conn);
|
||||
state.controller?.abort();
|
||||
updateGpuStats(
|
||||
state,
|
||||
Object.fromEntries(
|
||||
Object.keys(EMPTY_GPU).map((key) => [key, stats[key] ?? null]),
|
||||
),
|
||||
);
|
||||
state.nextAt = Date.now() + 10000;
|
||||
}
|
||||
/** Optional GPU work never joins the CPU/memory sampler or its history clock. */
|
||||
function startMonitorGpuProbe(conn, command, options = {}) {
|
||||
const state = stateFor(conn);
|
||||
if (state.disabled || state.pending || Date.now() < state.nextAt) return;
|
||||
const controller = new AbortController();
|
||||
state.controller = controller;
|
||||
state.nextAt = Date.now() + 30000;
|
||||
state.pending = Promise.resolve()
|
||||
.then(() =>
|
||||
executeBoundedSshCommand(conn, command, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
invalidateTransportOnAbort: false,
|
||||
invalidateOnOpenTimeout: false,
|
||||
openingTimeoutMs: 2500,
|
||||
runTimeoutMs: 2500,
|
||||
maxOutputBytes: 65536,
|
||||
}),
|
||||
)
|
||||
.then(({ stdout }) => {
|
||||
if (controller.signal.aborted) return;
|
||||
updateGpuStats(state, parseGpuStats(stdout));
|
||||
state.nextAt = Date.now() + (state.stats.gpu == null ? 30000 : 10000);
|
||||
})
|
||||
.catch((error) => {
|
||||
// A timed-out channel open cannot be withdrawn from ssh2's request FIFO.
|
||||
// Keep this optional probe from disconnecting SSH or piling up requests.
|
||||
if (error?.code === "SSH_EXEC_OPEN_TIMEOUT") state.disabled = true;
|
||||
if (!controller.signal.aborted) updateGpuStats(state, EMPTY_GPU);
|
||||
})
|
||||
.finally(() => {
|
||||
state.pending = null;
|
||||
if (state.controller === controller) state.controller = null;
|
||||
});
|
||||
}
|
||||
function cancelMonitorGpuProbe(conn) {
|
||||
states.get(conn)?.controller?.abort();
|
||||
}
|
||||
module.exports = {
|
||||
GPU_ASSIGNMENT,
|
||||
monitorGpuUpdatedAt,
|
||||
parseGpuStats,
|
||||
withCachedMonitorGpu,
|
||||
cacheFullGpuStats,
|
||||
startMonitorGpuProbe,
|
||||
cancelMonitorGpuProbe,
|
||||
};
|
||||
@@ -0,0 +1,166 @@
|
||||
"use strict";
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const {
|
||||
startMonitorGpuProbe,
|
||||
withCachedMonitorGpu,
|
||||
cacheFullGpuStats,
|
||||
cancelMonitorGpuProbe,
|
||||
} = require("./monitorGpuStats.cjs");
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
function stream() {
|
||||
const s = new EventEmitter();
|
||||
s.stderr = new EventEmitter();
|
||||
s.close = () => {};
|
||||
s.destroy = () => {};
|
||||
return s;
|
||||
}
|
||||
const sample = { success: true, stats: { cpu: 12, memTotal: 4096, gpu: null } };
|
||||
test("GPU work is coalesced, cached independently, and never changes CPU readings", async (t) => {
|
||||
let now = 100000,
|
||||
calls = 0,
|
||||
current;
|
||||
t.mock.method(Date, "now", () => now);
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
calls++;
|
||||
current = stream();
|
||||
cb(null, current);
|
||||
},
|
||||
};
|
||||
t.after(() => cancelMonitorGpuProbe(conn));
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
|
||||
current.emit("data", Buffer.from("GPU:75 1024 8192 NVIDIA T4"));
|
||||
current.emit("close", 0);
|
||||
await tick();
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 75);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpuName, "NVIDIA T4");
|
||||
now += 9999;
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 1);
|
||||
now += 1;
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 2);
|
||||
current.emit("close", 0);
|
||||
await tick();
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, null);
|
||||
now += 29000;
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 2, "missing GPU probes back off");
|
||||
});
|
||||
test("cancelling optional GPU work preserves a full reader cache and ignores late results", async (t) => {
|
||||
let current;
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
current = stream();
|
||||
cb(null, current);
|
||||
},
|
||||
};
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
cacheFullGpuStats(conn, {
|
||||
gpu: 42,
|
||||
gpuName: "NVIDIA full",
|
||||
gpuMemUsed: 512,
|
||||
gpuMemTotal: 8192,
|
||||
});
|
||||
current.emit("data", Buffer.from("GPU:99 1 2 NVIDIA late"));
|
||||
current.emit("close", 0);
|
||||
await tick();
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.gpu, 42);
|
||||
assert.equal(withCachedMonitorGpu(conn, sample).stats.cpu, 12);
|
||||
cancelMonitorGpuProbe(conn);
|
||||
t.after(() => cancelMonitorGpuProbe(conn));
|
||||
});
|
||||
|
||||
test("monitor snapshots include cached GPU without adding history samples or changing sample time", async () => {
|
||||
const { sampleSharedStats, readSharedStats } = require("./sharedStats.cjs");
|
||||
const conn = {};
|
||||
await sampleSharedStats({ conn, _monitorStats: true }, async () => sample);
|
||||
const before = readSharedStats(conn);
|
||||
cacheFullGpuStats(conn, {
|
||||
gpu: 42,
|
||||
gpuName: "NVIDIA cached",
|
||||
gpuMemUsed: 1,
|
||||
gpuMemTotal: 2,
|
||||
});
|
||||
const after = readSharedStats(conn);
|
||||
assert.equal(after.result.stats.gpu, 42);
|
||||
assert.equal(after.result.stats.cpu, 12);
|
||||
assert.equal(after.at, before.at);
|
||||
assert.deepEqual(after.points, before.points);
|
||||
});
|
||||
test("stopping GPU work before channel-open closes the late channel without disconnecting SSH", async () => {
|
||||
let callback,
|
||||
closed = 0,
|
||||
disconnected = 0;
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
callback = cb;
|
||||
},
|
||||
end() {
|
||||
disconnected++;
|
||||
},
|
||||
};
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
require("./sharedStats.cjs").cancelSharedStats(conn);
|
||||
const late = stream();
|
||||
late.close = () => closed++;
|
||||
callback(null, late);
|
||||
await tick();
|
||||
assert.ok(closed > 0);
|
||||
assert.equal(disconnected, 0);
|
||||
});
|
||||
|
||||
test("optional GPU channel-open timeout neither disconnects SSH nor stacks repeated open requests", async () => {
|
||||
let callback,
|
||||
fireTimer,
|
||||
calls = 0,
|
||||
disconnected = 0,
|
||||
closed = 0;
|
||||
const conn = {
|
||||
exec(_command, cb) {
|
||||
calls++;
|
||||
callback = cb;
|
||||
},
|
||||
end() {
|
||||
disconnected++;
|
||||
},
|
||||
destroy() {
|
||||
disconnected++;
|
||||
},
|
||||
};
|
||||
startMonitorGpuProbe(conn, "gpu", {
|
||||
setTimeoutFn: (fn) => {
|
||||
fireTimer = fn;
|
||||
return 1;
|
||||
},
|
||||
clearTimeoutFn: () => {},
|
||||
});
|
||||
await tick();
|
||||
fireTimer();
|
||||
await tick();
|
||||
assert.equal(
|
||||
disconnected,
|
||||
0,
|
||||
"optional channel timeout must leave the shared transport intact",
|
||||
);
|
||||
startMonitorGpuProbe(conn, "gpu");
|
||||
await tick();
|
||||
assert.equal(calls, 1, "one unresolved GPU open per transport at most");
|
||||
const late = stream();
|
||||
late.close = () => closed++;
|
||||
callback(null, late);
|
||||
await tick();
|
||||
assert.ok(closed > 0);
|
||||
});
|
||||
@@ -264,7 +264,11 @@ function createMoshStatsConnectionApi(ctx) {
|
||||
}
|
||||
|
||||
if (!agent && auth.useSshAgent && typeof prepareSystemSshAgentForAuth === "function") {
|
||||
const prepared = await prepareSystemSshAgentForAuth(auth, `[${label} Stats]`);
|
||||
const prepared = await prepareSystemSshAgentForAuth({
|
||||
...auth,
|
||||
resolveWebContents: () => auth.webContents && !auth.webContents.isDestroyed?.()
|
||||
? auth.webContents : null,
|
||||
}, `[${label} Stats]`);
|
||||
connectOpts.agent = prepared;
|
||||
if (typeof prepared?._releaseNetcattyFidoAgent === "function") {
|
||||
// Surface the release hook on connectOpts so establishStatsConnection
|
||||
|
||||
@@ -385,6 +385,24 @@ test("path-backed sk-* key handles are filtered out of the companion's identity
|
||||
assert.equal(FakeSSHClient.instances[0].connectOpts.agent, "/tmp/prepared-fido-agent.sock");
|
||||
});
|
||||
|
||||
test("FIDO stats preparation resolves only its requesting live window", async () => {
|
||||
let prepared;
|
||||
let destroyed = false;
|
||||
const sender = { id: 2823, isDestroyed: () => destroyed };
|
||||
const { api, sessions } = makeApi({ prepareSystemSshAgentForAuth: async auth => {
|
||||
prepared = auth;
|
||||
return "/tmp/fido-stats.sock";
|
||||
} });
|
||||
const session = { moshStatsAuth: { hostname: "h", username: "u", useSshAgent: true } };
|
||||
sessions.set("sid", session);
|
||||
api.ensureMoshStatsConnection(session, "sid", sender);
|
||||
await tick();
|
||||
assert.equal(prepared.resolveWebContents(), sender);
|
||||
destroyed = true;
|
||||
assert.equal(prepared.resolveWebContents(), null);
|
||||
FakeSSHClient.instances[0].emit("close");
|
||||
});
|
||||
|
||||
test("falls back to ssh-agent when a socket is available and no inline creds", async () => {
|
||||
// The system ssh used by the Mosh handshake authenticates via the local
|
||||
// agent by default, so the companion should too — regardless of the
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/* eslint-disable no-undef */
|
||||
const { sampleSharedStats } = require("./sharedStats.cjs");
|
||||
const { GPU_ASSIGNMENT, parseGpuStats, withCachedMonitorGpu, cacheFullGpuStats, startMonitorGpuProbe } = require("./monitorGpuStats.cjs");
|
||||
const { executeBoundedSshCommand } = require("../boundedSshExec.cjs");
|
||||
const { listInteractiveShellPids } = require("../sshInteractiveShells.cjs");
|
||||
function extraExecUnsupportedError(session) {
|
||||
@@ -1066,7 +1067,18 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
|
||||
async function getStatsForContext(session, event, sessionId) {
|
||||
return sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
|
||||
const result = await sampleSharedStats(session, (context) => collectServerStats(context, event, sessionId));
|
||||
const conn = session.conn || session.moshStatsConn || session.etStatsConn;
|
||||
if (!conn || !result.success || !result.stats) return result;
|
||||
if (!session._monitorStats) {
|
||||
cacheFullGpuStats(conn, result.stats);
|
||||
return result;
|
||||
}
|
||||
if (!session.singleChannelSsh) {
|
||||
const command = withPosixRemoteWatchdog(`if [ "$(uname -s 2>/dev/null)" = "Linux" ]; then ${GPU_ASSIGNMENT}; echo "GPU:$gpustat"; fi`, 2500);
|
||||
startMonitorGpuProbe(conn, command, {setTimeoutFn:setTimeout,clearTimeoutFn:clearTimeout});
|
||||
}
|
||||
return withCachedMonitorGpu(conn, result);
|
||||
}
|
||||
|
||||
async function collectServerStats(session, event, sessionId) {
|
||||
@@ -1199,7 +1211,7 @@ function createSessionOpsApi(ctx) {
|
||||
`uptime=$(awk -v n="$nowsec" -v b="$bootsec" 'BEGIN{if(n>0&&b>0)printf "%.0f",n-b}')`,
|
||||
`loadavg=$(sysctl -n vm.loadavg 2>/dev/null | tr -d '{}' | awk '{print $1" "$2" "$3"}')`,
|
||||
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`,
|
||||
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
|
||||
].filter(command => !session._monitorStats || !command.startsWith('procs=')).join('; ');
|
||||
|
||||
// Prefer df's filesystem type column so FUSE mounts can be identified
|
||||
// even when their source name looks like an ordinary remote path.
|
||||
@@ -1302,7 +1314,7 @@ function createSessionOpsApi(ctx) {
|
||||
// Bound GPU queries independently so a stuck driver cannot stop CPU/memory
|
||||
// polling. Hosts without timeout also omit this optional metric.
|
||||
// macOS is skipped: powermetrics needs root, which stats must not.
|
||||
`gpustat=$(gpucsv=$(timeout -s KILL 2 nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,name --format=csv,noheader,nounits 2>/dev/null) && printf '%s\\n' "$gpucsv" | awk -F', *' 'NF >= 4 {g++; if($1 ~ /^[0-9]+$/) {n++; u+=$1} if($2 ~ /^[0-9]+$/) {mu+=$2; uc++} if($3 ~ /^[0-9]+$/) {mt+=$3; tc++} if(name=="") name=$4} END{if(n>0) printf "%.0f %s %s %s", u/n, (uc==g ? sprintf("%.0f",mu) : "N/A"), (tc==g ? sprintf("%.0f",mt) : "N/A"), name}' 2>/dev/null || echo "")`,
|
||||
GPU_ASSIGNMENT,
|
||||
// Output all stats (using CPURAW and PERCORERAW instead of CPU and PERCORE)
|
||||
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|NET:$net|GPU:$gpustat|HOST:$hostname_value|OS:$osname|KERNEL:$kernel|UPTIME:$uptime|LOAD:$loadavg"`
|
||||
].filter(command => !session._monitorStats || (!command.startsWith('procs=') && !command.startsWith('gpustat='))).join('; ');
|
||||
@@ -1364,6 +1376,12 @@ function createSessionOpsApi(ctx) {
|
||||
const pingLatencyPromise = sshStatsConn && !pingDisabled && typeof measureSshPingLatency === 'function'
|
||||
? Promise.resolve(measureSshPingLatency(sshStatsConn)).catch(() => null)
|
||||
: Promise.resolve(null);
|
||||
// Observe the exec acknowledgement already needed for this sample. This
|
||||
// remains available when global-request keepalives are disabled, without
|
||||
// sending another ping or opening an additional channel/connection.
|
||||
// Keep it separate from the transport RTT: channel setup also includes
|
||||
// remote processing, and shared terminal readers must retain that distinction.
|
||||
let responseLatencyMs = null;
|
||||
const formatStatsError = (error) => (
|
||||
error?.code === "SSH_EXEC_OPEN_TIMEOUT" || error?.code === "SSH_EXEC_RUN_TIMEOUT"
|
||||
? "Timeout getting server stats"
|
||||
@@ -1379,7 +1397,13 @@ function createSessionOpsApi(ctx) {
|
||||
};
|
||||
void (async () => {
|
||||
try {
|
||||
const { stdout } = await executeStatsCommand(statsCommand);
|
||||
const responseStartedAt = performance.now();
|
||||
const { stdout } = await executeStatsCommand(statsCommand, {
|
||||
...statsExecOptions,
|
||||
onStream: () => {
|
||||
if (sshStatsConn) responseLatencyMs = Math.max(0, Math.round(performance.now() - responseStartedAt));
|
||||
},
|
||||
});
|
||||
if (settled) return;
|
||||
let combinedStdout = String(stdout || '').trim();
|
||||
const primaryOutput = combinedStdout.replace(new RegExp(`^${latencyMarker}\\|?`), '');
|
||||
@@ -1555,27 +1579,7 @@ function createSessionOpsApi(ctx) {
|
||||
}
|
||||
}
|
||||
} else if (part.startsWith('GPU:')) {
|
||||
// Linux: "<avgUtil> <sumMemUsed> <sumMemTotal> <first GPU name>"
|
||||
const gpuStr = part.substring(4).trim();
|
||||
if (gpuStr && gpuStr !== '') {
|
||||
const gpuFields = gpuStr.split(/\s+/);
|
||||
const util = parseInt(gpuFields[0], 10);
|
||||
if (!isNaN(util)) {
|
||||
gpu = Math.min(100, Math.max(0, util));
|
||||
if (gpuFields.length >= 2) {
|
||||
const memUsed = parseInt(gpuFields[1], 10);
|
||||
if (!isNaN(memUsed)) gpuMemUsed = memUsed;
|
||||
}
|
||||
if (gpuFields.length >= 3) {
|
||||
const memTotal = parseInt(gpuFields[2], 10);
|
||||
if (!isNaN(memTotal)) gpuMemTotal = memTotal;
|
||||
}
|
||||
if (gpuFields.length >= 4) {
|
||||
const name = gpuFields.slice(3).join(' ').trim();
|
||||
if (name) gpuName = name;
|
||||
}
|
||||
}
|
||||
}
|
||||
({gpu,gpuName,gpuMemUsed,gpuMemTotal}=parseGpuStats(part));
|
||||
} else if (part.startsWith('HOST:')) {
|
||||
hostname = part.substring(5).trim();
|
||||
} else if (part.startsWith('OS:')) {
|
||||
@@ -1744,6 +1748,7 @@ function createSessionOpsApi(ctx) {
|
||||
netRxSpeed, // Total network receive speed (bytes/sec)
|
||||
netTxSpeed, // Total network transmit speed (bytes/sec)
|
||||
latencyMs, // SSH transport ping on the existing session (ms); never a second TCP dial (#3630)
|
||||
responseLatencyMs, // Existing stats exec acknowledgement (ms), including remote channel setup
|
||||
netInterfaces, // Per-interface network stats
|
||||
hostname,
|
||||
osName,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { spawn, spawnSync } = require("node:child_process");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { mkdtempSync, writeFileSync, rmSync } = require("node:fs");
|
||||
const { join } = require("node:path");
|
||||
@@ -682,7 +682,8 @@ for (const scenario of [
|
||||
{ name: "missing timeout tool", script: "exec sleep 20", gpu: null, noTimeout: true },
|
||||
{ name: "blocked GPU query", script: "trap '' TERM; exec sleep 20", gpu: null },
|
||||
]) {
|
||||
test(`real stats shell preserves other metrics with ${scenario.name}`, async (t) => {
|
||||
for (const monitorOnly of [false, true]) {
|
||||
test(`real stats shell preserves other metrics with ${scenario.name} (${monitorOnly ? "monitor-only" : "terminal"})`, async (t) => {
|
||||
if (spawnSync("timeout", ["-s", "KILL", "2", "true"]).status !== 0) {
|
||||
t.skip("GNU/BusyBox-compatible timeout is unavailable on this test host");
|
||||
return;
|
||||
@@ -690,11 +691,17 @@ for (const scenario of [
|
||||
const dir = mkdtempSync(getTempFilePath("gpu-stats-"));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
writeFileSync(join(dir, "nvidia-smi"), `#!/bin/sh\n${scenario.script}\n`, { mode: 0o755 });
|
||||
let finishGpu;
|
||||
const gpuDone = new Promise(resolve => {finishGpu = resolve;});
|
||||
let gpuCalls = 0;
|
||||
const sessions = new Map([["sid", {
|
||||
type: "ssh",
|
||||
_monitorStats: monitorOnly,
|
||||
conn: {
|
||||
exec(command, cb) {
|
||||
const execution = spawnSync("sh", ["-c", [
|
||||
const gpuOnly = !command.includes("NC_LATENCY_MARK") && command.includes("nvidia-smi");
|
||||
if(gpuOnly) gpuCalls++;
|
||||
const child = spawn("sh", ["-c", [
|
||||
"uname() { printf '%s\\n' Linux; }",
|
||||
"nproc() { printf '%s\\n' 4; }",
|
||||
"top() { return 1; }",
|
||||
@@ -703,22 +710,39 @@ for (const scenario of [
|
||||
? unwrapExecShC(command).replace("timeout -s KILL 2 nvidia-smi", "nc_missing_timeout -s KILL 2 nvidia-smi")
|
||||
: unwrapExecShC(command),
|
||||
].join("\n")], {
|
||||
encoding: "utf8",
|
||||
env: { ...process.env, PATH: `${dir}:${process.env.PATH}` },
|
||||
timeout: 7000,
|
||||
});
|
||||
assert.equal(execution.status, 0, execution.stderr);
|
||||
cb(null, fakeStream(execution.stdout));
|
||||
let stdout="", stderr="";
|
||||
child.stdout.on("data", data => {stdout+=data;});
|
||||
child.stderr.on("data", data => {stderr+=data;});
|
||||
child.on("error", cb);
|
||||
child.on("close", code => {
|
||||
assert.equal(code,0,stderr);
|
||||
cb(null,fakeStream(stdout));
|
||||
if(gpuOnly)finishGpu();
|
||||
});
|
||||
},
|
||||
},
|
||||
}]]);
|
||||
const result = await makeSessionOps(sessions).getServerStats({ sender: {} }, { sessionId: "sid" });
|
||||
const api=makeSessionOps(sessions);
|
||||
const sampleStarted=Date.now();
|
||||
let result = await api.getServerStats({ sender: {} }, { sessionId: "sid" });
|
||||
if(monitorOnly){
|
||||
if(scenario.name === "blocked GPU query")assert.ok(Date.now()-sampleStarted<1500,"a hung GPU must not add its two-second timeout to CPU samples");
|
||||
// Main readings arrive independently, before the optional GPU query ends.
|
||||
assert.equal(result.stats.gpu,null);
|
||||
await gpuDone;
|
||||
await new Promise(resolve=>setImmediate(()=>setImmediate(resolve)));
|
||||
result=await api.getServerStats({sender:{}},{sessionId:"sid"});
|
||||
assert.equal(gpuCalls,1,"cached GPU fields must not open another probe");
|
||||
}
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.stats.cpuCores, 4);
|
||||
assert.equal(result.stats.gpu, scenario.gpu);
|
||||
assert.equal(result.stats.gpuMemUsed, scenario.used ?? null);
|
||||
assert.equal(result.stats.gpuMemTotal, scenario.total ?? null);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test("getServerStats measures latency by pinging the stats connection", async () => {
|
||||
@@ -1406,6 +1430,7 @@ for (const keepaliveInterval of [0, 10000]) {
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(pingCalls, keepaliveInterval === 0 ? 0 : 1);
|
||||
assert.equal(result.stats.latencyMs, keepaliveInterval === 0 ? null : 2);
|
||||
assert.equal(Number.isFinite(result.stats.responseLatencyMs), true);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1449,3 +1474,25 @@ test("real macOS stats shell parses the root disk without broken awk escaping",
|
||||
assert.equal(result.stats.disks[0].capacityKey, "apfs:/dev/disk3");
|
||||
assert.equal(result.stats.diskPercent, 50);
|
||||
});
|
||||
|
||||
test("monitor CPU samples return while an optional GPU driver is still blocked", async () => {
|
||||
let finishGpu, gpuCalls = 0, result;
|
||||
const c = {exec(command, callback) {
|
||||
if (command.includes('nvidia-smi')) {
|
||||
gpuCalls++;
|
||||
finishGpu = () => callback(null, fakeStream('GPU:50 1024 8192 NVIDIA test'));
|
||||
} else callback(null, fakeStream(selectServerStatsFixtureOutput(command, LINUX_STATS)));
|
||||
}};
|
||||
const api = makeSessionOps(new Map());
|
||||
const pending=api.getStatsForContext({conn:c,_monitorStats:true,_monitorIntervalMs:1}).then(value=>{result=value;});
|
||||
try {
|
||||
for(let i=0;i<10;i++)await new Promise(resolve=>setImmediate(resolve));
|
||||
assert.ok(result?.success, 'CPU and memory must not wait for the GPU probe');
|
||||
assert.equal(result.stats.memTotal,8000);
|
||||
assert.equal(gpuCalls,1);
|
||||
} finally {
|
||||
finishGpu?.();
|
||||
require("./sharedStats.cjs").cancelSharedStats(c);
|
||||
await pending;
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
"use strict";
|
||||
// One in-memory sampler per physical authenticated transport. No persistence/logging.
|
||||
const { withCachedMonitorGpu, cancelMonitorGpuProbe, monitorGpuUpdatedAt } = require("./monitorGpuStats.cjs");
|
||||
const { buildEndpointKey } = require("../sshConnectionPool.cjs");
|
||||
let states = new WeakMap();
|
||||
const identities = new Map();
|
||||
@@ -100,7 +101,7 @@ async function sampleSharedStats(session, collect, now = Date.now()) {
|
||||
appendPoint(state, result, state.at, session._monitorFreshAfter);
|
||||
return result;
|
||||
}
|
||||
// A terminal needs process/GPU fields omitted from a monitor-only query.
|
||||
// A terminal needs process and GPU fields omitted from a monitor-only query.
|
||||
// Finish the shared lightweight query, then upgrade once for all full readers.
|
||||
await state.pending;
|
||||
return sampleSharedStats(session, collect);
|
||||
@@ -156,9 +157,10 @@ function readSharedStats(conn) {
|
||||
const state = stateFor(conn);
|
||||
if (state.points[0] && Date.now() - state.points[0].at > 30 * 60000)
|
||||
state.points = state.points.filter((p) => Date.now() - p.at <= 30 * 60000);
|
||||
return { result: state.latest, at: state.at, points: state.points };
|
||||
return { result: state.latestFull ? state.latest : withCachedMonitorGpu(conn, state.latest), at: state.at, statsAt: Math.max(state.at, monitorGpuUpdatedAt(conn)), points: state.points };
|
||||
}
|
||||
function cancelSharedStats(conn) {
|
||||
cancelMonitorGpuProbe(conn);
|
||||
const state = states.get(conn);
|
||||
if (state && !state.terminalJoined) state.controller?.abort();
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ for (const keepaliveInterval of [10000, 0]) {
|
||||
assert.equal(forwardPort, 43210);
|
||||
if (keepaliveInterval > 0) assert.equal(Number.isFinite(stats.stats.latencyMs), true);
|
||||
else assert.equal(stats.stats.latencyMs, null);
|
||||
assert.equal(Number.isFinite(stats.stats.responseLatencyMs), true);
|
||||
await new Promise((resolve, reject) => conn.unforwardIn("127.0.0.1", forwardPort,
|
||||
(err) => err ? reject(err) : resolve()));
|
||||
}
|
||||
|
||||
@@ -25,23 +25,51 @@ function publicKeyBlob(key) {
|
||||
* Shared (e.g. Windows system-pipe) agent identity holds. Concurrent sessions
|
||||
* that materialize the same SK key get unique temp paths, so cleanup must be
|
||||
* keyed by public identity blob and only `ssh-add -d` when the last user leaves.
|
||||
* @type {Map<string, { refCount: number, identityPath: string, cleanupDir: string|null }>}
|
||||
* @type {Map<string, { refCount: number, identityPath: string, cleanupDir: string|null, plain?: boolean }>}
|
||||
*/
|
||||
const sharedAgentIdentityRefs = new Map();
|
||||
const agentIdentityLocks = new Map();
|
||||
const sharedCleanupDirs = new Map();
|
||||
|
||||
function withAgentIdentityLock(socketPath, operation) {
|
||||
const previous = agentIdentityLocks.get(socketPath) || Promise.resolve();
|
||||
const result = previous.catch(() => {}).then(operation);
|
||||
const tail = result.catch(() => {});
|
||||
agentIdentityLocks.set(socketPath, tail);
|
||||
void tail.then(() => {
|
||||
if (agentIdentityLocks.get(socketPath) === tail) agentIdentityLocks.delete(socketPath);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
function identityRefKey(key, socketPath) {
|
||||
return socketPath ? `${socketPath}\0${key}` : key;
|
||||
}
|
||||
|
||||
function finishSharedCleanupDir(cleanupDir) {
|
||||
if (!cleanupDir) return null;
|
||||
const remaining = (sharedCleanupDirs.get(cleanupDir) || 1) - 1;
|
||||
if (remaining > 0) { sharedCleanupDirs.set(cleanupDir, remaining); return null; }
|
||||
sharedCleanupDirs.delete(cleanupDir);
|
||||
return cleanupDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key Public identity blob (or path fallback)
|
||||
* @param {string} identityPath Path suitable for `ssh-add -d`
|
||||
* @param {string|null} [cleanupDir] Temp dir to remove with the last release
|
||||
* @param {string} [socketPath] Agent owning this identity
|
||||
* @param {boolean} [plain] Remove the bare key with ssh-add -k, preserving certificates
|
||||
* @returns {{ entry: { refCount: number, identityPath: string, cleanupDir: string|null }, acceptedCleanup: boolean }|null}
|
||||
* `acceptedCleanup` is true only when this call recorded `cleanupDir` on a new
|
||||
* map entry. Reuses increment refcount but do not adopt a second staging dir.
|
||||
*/
|
||||
function retainSharedAgentIdentity(key, identityPath, cleanupDir = null) {
|
||||
function retainSharedAgentIdentity(key, identityPath, cleanupDir = null, socketPath = "", plain = false) {
|
||||
if (typeof key !== "string" || !key || typeof identityPath !== "string" || !identityPath) {
|
||||
return null;
|
||||
}
|
||||
const existing = sharedAgentIdentityRefs.get(key);
|
||||
const refKey = identityRefKey(key, socketPath);
|
||||
const existing = sharedAgentIdentityRefs.get(refKey);
|
||||
if (existing) {
|
||||
existing.refCount += 1;
|
||||
// First retainer owns identityPath + cleanupDir for ssh-add -d / rm.
|
||||
@@ -52,20 +80,24 @@ function retainSharedAgentIdentity(key, identityPath, cleanupDir = null) {
|
||||
refCount: 1,
|
||||
identityPath,
|
||||
cleanupDir: normalizedCleanup,
|
||||
...(plain ? { plain: true } : {}),
|
||||
};
|
||||
sharedAgentIdentityRefs.set(key, entry);
|
||||
sharedAgentIdentityRefs.set(refKey, entry);
|
||||
if (normalizedCleanup) sharedCleanupDirs.set(normalizedCleanup, (sharedCleanupDirs.get(normalizedCleanup) || 0) + 1);
|
||||
return { entry, acceptedCleanup: Boolean(normalizedCleanup) };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} key
|
||||
* @param {string} [socketPath]
|
||||
* @returns {{ shouldRemove: boolean, identityPath: string|null, cleanupDir: string|null }}
|
||||
*/
|
||||
function releaseSharedAgentIdentity(key) {
|
||||
function releaseSharedAgentIdentity(key, socketPath = "") {
|
||||
if (typeof key !== "string" || !key) {
|
||||
return { shouldRemove: false, identityPath: null, cleanupDir: null };
|
||||
}
|
||||
const existing = sharedAgentIdentityRefs.get(key);
|
||||
const refKey = identityRefKey(key, socketPath);
|
||||
const existing = sharedAgentIdentityRefs.get(refKey);
|
||||
if (!existing) return { shouldRemove: false, identityPath: null, cleanupDir: null };
|
||||
existing.refCount -= 1;
|
||||
if (existing.refCount > 0) {
|
||||
@@ -75,9 +107,10 @@ function releaseSharedAgentIdentity(key) {
|
||||
cleanupDir: existing.cleanupDir,
|
||||
};
|
||||
}
|
||||
sharedAgentIdentityRefs.delete(key);
|
||||
sharedAgentIdentityRefs.delete(refKey);
|
||||
return {
|
||||
shouldRemove: true,
|
||||
...(existing.plain ? { plain: true } : {}),
|
||||
identityPath: existing.identityPath,
|
||||
cleanupDir: existing.cleanupDir,
|
||||
};
|
||||
@@ -87,12 +120,13 @@ function releaseSharedAgentIdentity(key) {
|
||||
* @param {string} key
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasSharedAgentIdentity(key) {
|
||||
return typeof key === "string" && key.length > 0 && sharedAgentIdentityRefs.has(key);
|
||||
function hasSharedAgentIdentity(key, socketPath = "") {
|
||||
return typeof key === "string" && key.length > 0 && sharedAgentIdentityRefs.has(identityRefKey(key, socketPath));
|
||||
}
|
||||
|
||||
function resetSharedAgentIdentityRefsForTests() {
|
||||
sharedAgentIdentityRefs.clear();
|
||||
sharedCleanupDirs.clear();
|
||||
}
|
||||
|
||||
function resolveIdentityPath(rawPath, context = {}) {
|
||||
@@ -133,6 +167,7 @@ function resolveIdentityPath(rawPath, context = {}) {
|
||||
|
||||
async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, options, deps) {
|
||||
const preferred = new Set();
|
||||
const identityBlobs = new Map();
|
||||
const resolvedIdentityPaths = [];
|
||||
const unavailablePublicKeyPaths = [];
|
||||
let providedPreferredCount = 0;
|
||||
@@ -151,9 +186,18 @@ async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, option
|
||||
try {
|
||||
const contents = await deps.readFile(publicKeyPath, "utf8");
|
||||
const blob = publicKeyBlob(contents);
|
||||
if (blob) preferred.add(blob);
|
||||
if (blob) { preferred.add(blob); identityBlobs.set(identityPath, blob); }
|
||||
else unavailablePublicKeyPaths.push(publicKeyPath);
|
||||
} catch (error) {
|
||||
// An unencrypted SK handle contains its public identity. Keep strict
|
||||
// filtering even when the optional companion .pub file is absent.
|
||||
try {
|
||||
const key = utils.parseKey(await deps.readFile(identityPath, "utf8"));
|
||||
if (!(key instanceof Error) && key.type?.startsWith("sk-")) {
|
||||
const blob = publicKeyBlob(key);
|
||||
if (blob) { preferred.add(blob); identityBlobs.set(identityPath, blob); continue; }
|
||||
}
|
||||
} catch { /* retain the missing-selector error below */ }
|
||||
unavailablePublicKeyPaths.push(publicKeyPath);
|
||||
deps.log?.("Configured SSH public key is unavailable", {
|
||||
publicKeyPath,
|
||||
@@ -161,7 +205,7 @@ async function loadPreferredPublicKeyBlobs(identityFilePaths, publicKeys, option
|
||||
});
|
||||
}
|
||||
}
|
||||
return { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths };
|
||||
return { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths, identityBlobs };
|
||||
}
|
||||
|
||||
function getIdentities(agent) {
|
||||
@@ -335,7 +379,7 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
askpassEnv: injected.askpassEnv,
|
||||
};
|
||||
const agent = deps.createAgent(options.socketPath);
|
||||
const { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths } = await loadPreferredPublicKeyBlobs(
|
||||
const { preferred, providedPreferredCount, resolvedIdentityPaths, unavailablePublicKeyPaths, identityBlobs } = await loadPreferredPublicKeyBlobs(
|
||||
options.identityFilePaths,
|
||||
options.agentPublicKeys,
|
||||
{ hostname: options.hostname, port: options.port, username: options.username, env: deps.env },
|
||||
@@ -397,11 +441,12 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
const pubPath = identityPath.endsWith(".pub") ? identityPath : `${identityPath}.pub`;
|
||||
const certPath = identityPath.endsWith(".pub") ? null : `${identityPath}-cert.pub`;
|
||||
/** @type {string|null} */
|
||||
let blob = null;
|
||||
let blob = identityBlobs.get(identityPath) || null;
|
||||
/** @type {string|null} */
|
||||
let certBlob = null;
|
||||
let bareAlreadyPresent = false;
|
||||
let bareAlreadyPresent = Boolean(blob && loadedBlobs.has(blob));
|
||||
let companionCertificateMissing = false;
|
||||
let certificateAlreadyPresent = false;
|
||||
try {
|
||||
const pub = await deps.readFile(pubPath, "utf8");
|
||||
blob = publicKeyBlob(pub);
|
||||
@@ -409,14 +454,15 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
} catch {
|
||||
// No .pub selector — still attempt ssh-add when requested.
|
||||
}
|
||||
if (bareAlreadyPresent && certPath) {
|
||||
if (certPath) {
|
||||
// OpenSSH ssh-add loads `<identity>-cert.pub` with the private handle.
|
||||
// If only the bare key is already advertised, still run ssh-add so a
|
||||
// staged vault certificate reaches the agent.
|
||||
try {
|
||||
const certPub = await deps.readFile(certPath, "utf8");
|
||||
certBlob = publicKeyBlob(certPub);
|
||||
if (certBlob && !loadedBlobs.has(certBlob)) {
|
||||
certificateAlreadyPresent = Boolean(certBlob && loadedBlobs.has(certBlob));
|
||||
if (certBlob && !certificateAlreadyPresent) {
|
||||
companionCertificateMissing = true;
|
||||
}
|
||||
} catch {
|
||||
@@ -428,8 +474,11 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
// session loaded it). Pre-existing user agent identities must not be
|
||||
// adopted into cleanup — last release would ssh-add -d them.
|
||||
const key = blob || identityPath;
|
||||
if (hasSharedAgentIdentity(key)) {
|
||||
sharedAgentIdentities.push({ key, identityPath });
|
||||
if (hasSharedAgentIdentity(key, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
}
|
||||
if (certBlob && hasSharedAgentIdentity(certBlob, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key: certBlob, identityPath: certPath });
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -465,21 +514,17 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
}
|
||||
}
|
||||
const key = blob || identityPath;
|
||||
if (bareAlreadyPresent) {
|
||||
// Reloaded only to advertise a missing companion certificate.
|
||||
if (hasSharedAgentIdentity(key)) {
|
||||
// Another Netcatty session already owns the bare identity; join its
|
||||
// refcount so the last release can ssh-add -d the private handle.
|
||||
sharedAgentIdentities.push({ key, identityPath });
|
||||
} else if (certBlob && certPath) {
|
||||
// Bare key was already in the shared agent (user-managed). Track the
|
||||
// certificate alone so cleanup removes only what we added.
|
||||
newlyLoadedIdentityPaths.push(certPath);
|
||||
if (!bareAlreadyPresent) {
|
||||
newlyLoadedIdentityPaths.push(identityPath);
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
} else if (hasSharedAgentIdentity(key, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key, identityPath, plain: true });
|
||||
}
|
||||
if (certBlob && certPath) {
|
||||
if (!certificateAlreadyPresent) newlyLoadedIdentityPaths.push(certPath);
|
||||
if (!certificateAlreadyPresent || hasSharedAgentIdentity(certBlob, options.socketPath)) {
|
||||
sharedAgentIdentities.push({ key: certBlob, identityPath: certPath });
|
||||
}
|
||||
} else {
|
||||
newlyLoadedIdentityPaths.push(identityPath);
|
||||
sharedAgentIdentities.push({ key, identityPath });
|
||||
}
|
||||
deps.log?.("Loaded identity into SSH agent", { identityPath });
|
||||
} catch (error) {
|
||||
@@ -499,7 +544,8 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
// into a shared (e.g. Windows system) agent.
|
||||
for (const identityPath of newlyLoadedIdentityPaths) {
|
||||
try {
|
||||
const args = ["-d", identityPath];
|
||||
const plain = sharedAgentIdentities.some(entry => entry.identityPath === identityPath && entry.plain);
|
||||
const args = plain ? ["-d", "-k", identityPath] : ["-d", identityPath];
|
||||
if (deps.runSshAdd) await deps.runSshAdd(args);
|
||||
else {
|
||||
await defaultRunSshAdd(args, {
|
||||
@@ -534,6 +580,8 @@ async function prepareSystemSshAgent(options, injected = {}) {
|
||||
|
||||
module.exports = {
|
||||
IdentityAwareAgent,
|
||||
withAgentIdentityLock,
|
||||
finishSharedCleanupDir,
|
||||
prepareSystemSshAgent,
|
||||
publicKeyBlob,
|
||||
resolveIdentityPath,
|
||||
|
||||
@@ -10,6 +10,38 @@ const { parseKey } = require("ssh2/lib/protocol/keyParser.js");
|
||||
|
||||
const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
|
||||
|
||||
test("identity preparation waits for deletion on the same socket, without blocking other agents", async () => {
|
||||
const { withAgentIdentityLock } = require("./systemSshAgent.cjs");
|
||||
let finishDelete;
|
||||
let inspected = false;
|
||||
const deletion = withAgentIdentityLock("agent-a", () => new Promise(resolve => { finishDelete = resolve; }));
|
||||
await new Promise(setImmediate);
|
||||
const preparation = withAgentIdentityLock("agent-a", async () => { inspected = true; });
|
||||
await withAgentIdentityLock("agent-b", async () => {});
|
||||
assert.equal(inspected, false, "a new login cannot borrow an identity pending removal");
|
||||
finishDelete();
|
||||
await Promise.all([deletion, preparation]);
|
||||
assert.equal(inspected, true);
|
||||
});
|
||||
|
||||
test("identity holds are scoped to their agent and retain staging until bare key and certificate removal finish", () => {
|
||||
const { retainSharedAgentIdentity, releaseSharedAgentIdentity, finishSharedCleanupDir,
|
||||
resetSharedAgentIdentityRefsForTests } = require("./systemSshAgent.cjs");
|
||||
resetSharedAgentIdentityRefsForTests();
|
||||
try {
|
||||
retainSharedAgentIdentity("bare", "/tmp/key", "/tmp/stage", "agent-a", true);
|
||||
retainSharedAgentIdentity("cert", "/tmp/cert.pub", "/tmp/stage", "agent-a");
|
||||
retainSharedAgentIdentity("bare", "/tmp/other-key", null, "agent-b", true);
|
||||
const bare = releaseSharedAgentIdentity("bare", "agent-a");
|
||||
assert.equal(bare.shouldRemove, true);
|
||||
assert.equal(bare.plain, true, "bare removal must skip companion certificates");
|
||||
assert.equal(finishSharedCleanupDir(bare.cleanupDir), null);
|
||||
const cert = releaseSharedAgentIdentity("cert", "agent-a");
|
||||
assert.equal(finishSharedCleanupDir(cert.cleanupDir), "/tmp/stage");
|
||||
assert.equal(releaseSharedAgentIdentity("bare", "agent-b").identityPath, "/tmp/other-key");
|
||||
} finally { resetSharedAgentIdentityRefsForTests(); }
|
||||
});
|
||||
|
||||
function makeSkPrivatePem() {
|
||||
const body = Buffer.from(`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${SK_SSH_ED25519}`).toString("base64");
|
||||
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
|
||||
@@ -124,7 +156,7 @@ test("prepareSystemSshAgent tracks newly loaded identities for shared-agent clea
|
||||
assert.deepEqual(sshAddCalls, [[identityPath]]);
|
||||
assert.deepEqual(prepared._netcattyNewlyLoadedIdentityPaths, [identityPath]);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: publicKeyBlob(pubLine), identityPath },
|
||||
{ key: publicKeyBlob(pubLine), identityPath, plain: true },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -243,7 +275,7 @@ test("prepareSystemSshAgent ssh-adds when bare identity is loaded but companion
|
||||
};
|
||||
|
||||
try {
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first");
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first", null, "/tmp/fake-agent.sock");
|
||||
const prepared = await prepareSystemSshAgent({
|
||||
socketPath: "/tmp/fake-agent.sock",
|
||||
identityFilePaths: [identityPath],
|
||||
@@ -262,9 +294,10 @@ test("prepareSystemSshAgent ssh-adds when bare identity is loaded but companion
|
||||
assert.deepEqual(sshAddCalls, [[identityPath]]);
|
||||
// Bare key was already Netcatty-owned; join that refcount (do not treat
|
||||
// the cert-only reload as a newly loaded private identity).
|
||||
assert.equal(prepared._netcattyNewlyLoadedIdentityPaths, undefined);
|
||||
assert.deepEqual(prepared._netcattyNewlyLoadedIdentityPaths, [certPath]);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: blob, identityPath },
|
||||
{ key: blob, identityPath, plain: true },
|
||||
{ key: certBlob, identityPath: certPath },
|
||||
]);
|
||||
} finally {
|
||||
resetSharedAgentIdentityRefsForTests();
|
||||
@@ -387,7 +420,7 @@ test("prepareSystemSshAgent joins Netcatty-owned already-loaded identities for s
|
||||
};
|
||||
|
||||
try {
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first");
|
||||
retainSharedAgentIdentity(blob, "/tmp/id_ed25519_sk_first", null, "/tmp/fake-agent.sock");
|
||||
const prepared = await prepareSystemSshAgent({
|
||||
socketPath: "/tmp/fake-agent.sock",
|
||||
identityFilePaths: [identityPath],
|
||||
@@ -406,7 +439,7 @@ test("prepareSystemSshAgent joins Netcatty-owned already-loaded identities for s
|
||||
|
||||
assert.equal(prepared._netcattyNewlyLoadedIdentityPaths, undefined);
|
||||
assert.deepEqual(prepared._netcattySharedAgentIdentities, [
|
||||
{ key: blob, identityPath },
|
||||
{ key: blob, identityPath, plain: true },
|
||||
]);
|
||||
} finally {
|
||||
resetSharedAgentIdentityRefsForTests();
|
||||
|
||||
@@ -165,7 +165,7 @@ test("Mosh prepares the configured system agent before building native ssh optio
|
||||
assert.deepEqual(auth.sshArgs, [
|
||||
"-i", path.join(os.homedir(), ".ssh", "id_work.pub"),
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "IdentityAgent=/tmp/custom-agent.sock",
|
||||
"-o", 'IdentityAgent="/tmp/custom-agent.sock"',
|
||||
"-o", "StrictHostKeyChecking=ask",
|
||||
]);
|
||||
assert.equal(env.SSH_AUTH_SOCK, "/tmp/custom-agent.sock");
|
||||
|
||||
@@ -176,7 +176,7 @@ main();
|
||||
let releasePreparedAgent = null;
|
||||
try {
|
||||
if (connectionOptions.useSshAgent === true || forceFido) {
|
||||
const agent = await prepareSystemSshAgentForAuth(prepOptions, logPrefix);
|
||||
const agent = await prepareSystemSshAgentForAuth({ ...prepOptions, nativeFidoAgent: true, nativeSshPath: process.platform === "win32" ? findExecutable("ssh") : undefined }, logPrefix);
|
||||
if (typeof agent?._releaseNetcattyFidoAgent === "function") {
|
||||
releasePreparedAgent = agent._releaseNetcattyFidoAgent;
|
||||
}
|
||||
|
||||
@@ -145,7 +145,7 @@ function createMoshSessionApi(ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareMoshSshAgentOptions(options, sender) {
|
||||
async function prepareMoshSshAgentOptions(options, sender, sshExe) {
|
||||
const {
|
||||
enhanceAuthOptionsForFido,
|
||||
resolvePreparedAgentSocket,
|
||||
@@ -158,7 +158,7 @@ function createMoshSessionApi(ctx) {
|
||||
let releasePreparedAgent = null;
|
||||
try {
|
||||
if (options.useSshAgent === true || forceFido) {
|
||||
const agent = await prepareSystemSshAgentForAuth(prepOptions, "[Mosh]");
|
||||
const agent = await prepareSystemSshAgentForAuth({ ...prepOptions, nativeFidoAgent: true, nativeSshPath: sshExe }, "[Mosh]");
|
||||
if (typeof agent?._releaseNetcattyFidoAgent === "function") {
|
||||
releasePreparedAgent = agent._releaseNetcattyFidoAgent;
|
||||
}
|
||||
@@ -412,7 +412,9 @@ function createMoshSessionApi(ctx) {
|
||||
if (options.useSshAgent === false) {
|
||||
sshArgs.push("-o", "IdentityAgent=none");
|
||||
} else if (options.useSshAgent && options._resolvedSshAgentSocket) {
|
||||
sshArgs.push("-o", `IdentityAgent=${options._resolvedSshAgentSocket}`);
|
||||
// -o is parsed again as ssh_config syntax, even with argv spawning.
|
||||
const quotedSocket = `"${String(options._resolvedSshAgentSocket).replace(/(["\\])/g, "\\$1")}"`;
|
||||
sshArgs.push("-o", `IdentityAgent=${quotedSocket}`);
|
||||
if (options.identitiesOnly && !sshArgs.includes("IdentitiesOnly=yes")) {
|
||||
sshArgs.push("-o", "IdentitiesOnly=yes");
|
||||
}
|
||||
@@ -1011,7 +1013,7 @@ function createMoshSessionApi(ctx) {
|
||||
throw new Error("OpenSSH client not found. Netcatty needs ssh to start the remote mosh-server handshake.");
|
||||
}
|
||||
|
||||
const preparedOptions = await prepareMoshSshAgentOptions(options, event?.sender);
|
||||
const preparedOptions = await prepareMoshSshAgentOptions(options, event?.sender, sshExe);
|
||||
return startMoshSessionViaHandshake(event, preparedOptions, { bareClient, sshExe });
|
||||
}
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ test("failed copy followed by exclusive-open collision leaves that entry intact"
|
||||
|
||||
for (const metadataError of ["ENOTSUP", "EPERM", "EACCES"]) {
|
||||
test(`rollback retains backup when its original timestamps cannot be restored: ${metadataError}`, async (t) => {
|
||||
const setup = await mountedDestination(t, { existingMode: 0o644, cancelBeforeFinal: true, metadataError });
|
||||
const setup = await mountedDestination(t, { existingMode: 0o644, virtualMode: 0o644, cancelBeforeFinal: true, metadataError });
|
||||
await assert.rejects(setup.run, /Recovery files preserved/);
|
||||
const backup = fs.readdirSync(setup.root).find(name => name.endsWith(".backup"));
|
||||
assert.ok(backup);
|
||||
|
||||
@@ -312,12 +312,17 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
|
||||
const getBackupDir = () => path.join(app.getPath("userData"), BACKUP_DIR_NAME);
|
||||
|
||||
// Serialize createBackup so two concurrent calls (version-change backup
|
||||
// Serialize backup mutations so two concurrent calls (version-change backup
|
||||
// running at startup + an explicit protective-before-restore triggered
|
||||
// by the user's click, etc.) observe each other's writes. Without this,
|
||||
// both observers would see an empty directory, compute the same
|
||||
// fingerprint, skip the dedupe, and write two identical files.
|
||||
let createBackupLock = Promise.resolve();
|
||||
let mutationLock = Promise.resolve();
|
||||
const mutate = (operation) => {
|
||||
const next = mutationLock.then(operation);
|
||||
mutationLock = next.catch(() => undefined);
|
||||
return next;
|
||||
};
|
||||
// Monotonically increasing `createdAt` per service instance. `Date.now()`
|
||||
// has 1ms resolution and back-to-back async calls (version-change backup
|
||||
// followed immediately by a protective backup) can land in the same
|
||||
@@ -332,12 +337,7 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
},
|
||||
|
||||
async createBackup(options = {}) {
|
||||
const next = createBackupLock.then(() => doCreateBackup(options));
|
||||
// Swallow the rejection on the lock chain so one caller's error
|
||||
// does not poison subsequent calls; each individual await sees its
|
||||
// own rejection via the `next` return.
|
||||
createBackupLock = next.catch(() => undefined);
|
||||
return next;
|
||||
return mutate(() => doCreateBackup(options));
|
||||
},
|
||||
|
||||
async listBackups() {
|
||||
@@ -364,7 +364,44 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
},
|
||||
|
||||
async trimBackups(options = {}) {
|
||||
return pruneBackupRecords(getBackupDir(), options.maxCount);
|
||||
return mutate(() => pruneBackupRecords(getBackupDir(), options.maxCount));
|
||||
},
|
||||
|
||||
async clearBackups() {
|
||||
return mutate(async () => {
|
||||
const dirPath = getBackupDir();
|
||||
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
for (const entry of entries) {
|
||||
// Include unreadable records and interrupted writes without reading
|
||||
// or decrypting them. Preserve unrelated files and directories.
|
||||
if (!entry.isFile() && !entry.isSymbolicLink()) continue;
|
||||
if (!entry.name.startsWith(BACKUP_FILE_PREFIX)) continue;
|
||||
if (!entry.name.endsWith(BACKUP_FILE_EXT)
|
||||
&& !entry.name.includes(`${BACKUP_FILE_EXT}.tmp-`)) continue;
|
||||
try {
|
||||
await fs.promises.unlink(path.join(dirPath, entry.name));
|
||||
deletedCount += 1;
|
||||
} catch (error) {
|
||||
if (error.code !== "ENOENT") failedCount += 1;
|
||||
}
|
||||
}
|
||||
// Persist directory changes before reporting success, including on a
|
||||
// retry after a previous sync failure left no backup files to unlink.
|
||||
// Windows does not support opening directory handles this way.
|
||||
if (process.platform !== 'win32') {
|
||||
const dirHandle = await fs.promises.open(dirPath, 'r');
|
||||
try {
|
||||
await dirHandle.sync();
|
||||
} finally {
|
||||
await dirHandle.close();
|
||||
}
|
||||
}
|
||||
if (failedCount) throw new Error(`Could not delete ${failedCount} vault backup file(s).`);
|
||||
return { deletedCount };
|
||||
});
|
||||
},
|
||||
|
||||
async openBackupDir() {
|
||||
@@ -577,6 +614,14 @@ function registerHandlers(ipcMain, electronModule) {
|
||||
}
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:clear", async () => {
|
||||
try {
|
||||
return await service.clearBackups();
|
||||
} finally {
|
||||
// Refresh every window after partial deletion as well as full success.
|
||||
broadcastBackupsChanged();
|
||||
}
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:openDir", async () => {
|
||||
return service.openBackupDir();
|
||||
});
|
||||
|
||||
@@ -716,3 +716,100 @@ test("readBackupRecord rejects oversized files before buffering them", async ()
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups removes encrypted, corrupt and interrupted backup files only', async () => {
|
||||
const root = createTempRoot();
|
||||
try {
|
||||
const service = createService(root);
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
const dir = path.join(root, BACKUP_DIR_NAME);
|
||||
fs.writeFileSync(path.join(dir, 'vault-backup-bad.json'), '{broken');
|
||||
fs.writeFileSync(path.join(dir, 'vault-backup-interrupted.json.tmp-abc'), 'secret');
|
||||
fs.writeFileSync(path.join(dir, 'keep.txt'), 'unrelated');
|
||||
fs.mkdirSync(path.join(dir, 'vault-backup-directory.json'));
|
||||
const unavailable = createService(root, { encrypted: false });
|
||||
assert.deepEqual(await unavailable.clearBackups(), { deletedCount: 3 });
|
||||
assert.deepEqual(await service.listBackups(), []);
|
||||
assert.deepEqual(fs.readdirSync(dir).sort(), ['keep.txt', 'vault-backup-directory.json']);
|
||||
assert.deepEqual(await service.clearBackups(), { deletedCount: 0 });
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups waits for pending creates and allows future backups', async () => {
|
||||
const root = createTempRoot();
|
||||
try {
|
||||
const service = createService(root);
|
||||
const create = service.createBackup({ payload: samplePayload() });
|
||||
const clear = service.clearBackups();
|
||||
await create;
|
||||
assert.deepEqual(await clear, { deletedCount: 1 });
|
||||
assert.deepEqual(await service.listBackups(), []);
|
||||
const next = await service.createBackup({ payload: samplePayload() });
|
||||
assert.equal(next.created, true);
|
||||
assert.equal((await service.listBackups()).length, 1);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups reports partial deletion failures and permits retry', async (t) => {
|
||||
const root = createTempRoot();
|
||||
const originalUnlink = fs.promises.unlink;
|
||||
try {
|
||||
const service = createService(root);
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
const dir = path.join(root, BACKUP_DIR_NAME);
|
||||
const blocked = path.join(dir, 'vault-backup-blocked.json');
|
||||
fs.writeFileSync(blocked, '{broken');
|
||||
const mock = t.mock.method(fs.promises, 'unlink', async (target) => {
|
||||
if (target === blocked) throw Object.assign(new Error('denied'), { code: 'EACCES' });
|
||||
return originalUnlink(target);
|
||||
});
|
||||
await assert.rejects(service.clearBackups(), /Could not delete 1 vault backup file/);
|
||||
assert.deepEqual(fs.readdirSync(dir), ['vault-backup-blocked.json']);
|
||||
mock.mock.restore();
|
||||
assert.deepEqual(await service.clearBackups(), { deletedCount: 1 });
|
||||
assert.deepEqual(fs.readdirSync(dir), []);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('clearBackups persists deletion and retries a failed directory sync even when empty', {
|
||||
skip: process.platform === 'win32',
|
||||
}, async (t) => {
|
||||
const root = createTempRoot();
|
||||
try {
|
||||
const service = createService(root);
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
const dir = path.join(root, BACKUP_DIR_NAME);
|
||||
const originalOpen = fs.promises.open;
|
||||
let syncCalls = 0;
|
||||
let closeCalls = 0;
|
||||
t.mock.method(fs.promises, 'open', async (target, ...args) => {
|
||||
const handle = await originalOpen(target, ...args);
|
||||
if (target !== dir) return handle;
|
||||
return {
|
||||
async sync() {
|
||||
syncCalls += 1;
|
||||
assert.deepEqual(fs.readdirSync(dir), []);
|
||||
if (syncCalls === 1) throw new Error('fixture directory sync failed');
|
||||
await handle.sync();
|
||||
},
|
||||
async close() {
|
||||
closeCalls += 1;
|
||||
await handle.close();
|
||||
},
|
||||
};
|
||||
});
|
||||
await assert.rejects(service.clearBackups(), /fixture directory sync failed/);
|
||||
assert.equal(closeCalls, 1);
|
||||
assert.deepEqual(await service.clearBackups(), { deletedCount: 0 });
|
||||
assert.equal(syncCalls, 2);
|
||||
assert.equal(closeCalls, 2);
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -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,381 @@
|
||||
"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) {
|
||||
// Electron requires native focus before sendInputEvent can deliver a click.
|
||||
app.focus({ steal: true }); owner.focus(); owner.webContents.focus();
|
||||
await until(() => owner.isFocused(), true, 'acceptance window has native focus for click');
|
||||
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}`);
|
||||
owner.webContents.sendInputEvent({ type: 'mouseMove', x: Math.round(rect.x), y: Math.round(rect.y) });
|
||||
await run('new Promise(resolve => requestAnimationFrame(resolve))');
|
||||
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 pressEscape() {
|
||||
app.focus({ steal: true }); owner.focus(); owner.webContents.focus();
|
||||
await until(() => owner.isFocused(), true, 'acceptance window has native focus for Escape');
|
||||
await run('new Promise(resolve => requestAnimationFrame(resolve))');
|
||||
owner.webContents.sendInputEvent({ type: 'keyDown', keyCode: 'Escape' });
|
||||
owner.webContents.sendInputEvent({ type: 'keyUp', keyCode: 'Escape' });
|
||||
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'");
|
||||
// A visible but stationary browser must not spend every frame reading the
|
||||
// whole renderer. Settle startup/FontFaceSet work before observing idle time.
|
||||
await wait(300);
|
||||
const assertIdleLayout = async () => {
|
||||
await run(`(() => {
|
||||
const query=document.querySelector.bind(document),queryAll=document.querySelectorAll.bind(document),style=getComputedStyle;
|
||||
window.layoutReads={queries:0,styles:0};
|
||||
document.querySelector=(...args)=>{window.layoutReads.queries++;return query(...args)};
|
||||
document.querySelectorAll=(...args)=>{window.layoutReads.queries++;return queryAll(...args)};
|
||||
window.getComputedStyle=(...args)=>{window.layoutReads.styles++;return style(...args)};
|
||||
window.restoreLayoutReads=()=>{document.querySelector=query;document.querySelectorAll=queryAll;window.getComputedStyle=style;};
|
||||
})()`);
|
||||
await wait(250);
|
||||
const reads=await run('window.restoreLayoutReads();window.layoutReads');
|
||||
assert.deepEqual(reads,{queries:0,styles:0},'idle browser leaves no repeated overlay/style scans');
|
||||
};
|
||||
await assertIdleLayout();
|
||||
|
||||
// Position-only CSS motion does not fire ResizeObserver on the viewport.
|
||||
// Follow its real animation frames, then release the frame loop again.
|
||||
await run(`(() => {const parent=document.querySelector('[data-section=host-browser-root]').parentElement;parent.id='moving-browser-fixture';parent.style.transition='transform 300ms linear';parent.style.transform='translateX(32px)';})()`);
|
||||
const positions=[];
|
||||
for(let index=0;index<15;index++){await wait(25);positions.push(root.getBounds().x);}
|
||||
assert.ok(new Set(positions).size>=4,'the native browser follows intermediate transform positions');
|
||||
const expectedX=await run('Math.round(document.querySelector("[data-section=host-browser-panel]").getBoundingClientRect().x)');
|
||||
assert.equal(root.getBounds().x,expectedX,'native browser reaches the final CSS position');
|
||||
await assertIdleLayout();
|
||||
await run('document.getElementById("moving-browser-fixture").style.transform="none"');await wait(350);
|
||||
|
||||
// Exercise continuous pointer-driven size updates through the same hook and
|
||||
// native view. No application session or real SSH host is involved.
|
||||
await run(`(() => {
|
||||
const handle=document.createElement('div');handle.id='browser-drag-fixture';handle.style.cssText='position:fixed;left:2px;top:100px;width:8px;height:200px;z-index:99999';
|
||||
const parent=document.getElementById('moving-browser-fixture');
|
||||
handle.onmousedown=()=>{const move=event=>{parent.style.width=Math.max(300,event.clientX-parent.getBoundingClientRect().x)+'px'};const up=()=>{window.removeEventListener('mousemove',move);window.removeEventListener('mouseup',up)};window.addEventListener('mousemove',move);window.addEventListener('mouseup',up)};
|
||||
document.body.append(handle);
|
||||
})()`);
|
||||
owner.webContents.sendInputEvent({type:'mouseDown',button:'left',clickCount:1,x:6,y:150});
|
||||
for(const x of [850,750,650,550]){owner.webContents.sendInputEvent({type:'mouseMove',x,y:150});await wait(30);}
|
||||
owner.webContents.sendInputEvent({type:'mouseUp',button:'left',clickCount:1,x:550,y:150});await wait(80);
|
||||
const expectedWidth=await run('Math.round(document.querySelector("[data-section=host-browser-panel]").getBoundingClientRect().width)');
|
||||
assert.equal(root.getBounds().width,expectedWidth,'drag-driven viewport width reaches the native browser');
|
||||
assert.ok(expectedWidth<600,'the pointer drag actually resized the fixture');
|
||||
await run('document.getElementById("moving-browser-fixture").style.width="";document.getElementById("browser-drag-fixture").remove()');await wait(100);
|
||||
|
||||
await run('document.documentElement.style.colorScheme="light";document.documentElement.style.setProperty("--background","40 70% 90%")');
|
||||
await until(()=>manager.getPanelState(event,browserId).theme,'light','theme reaches native browser without a frame poll');
|
||||
await run('document.documentElement.style.colorScheme="dark";document.documentElement.style.setProperty("--background","210 25% 18%")');
|
||||
await until(()=>manager.getPanelState(event,browserId).theme,'dark','theme restores');
|
||||
await run(`document.body.insertAdjacentHTML('beforeend','<div id="modal-layout-fixture" role="dialog" aria-modal="true" style="position:fixed;left:0;top:0;width:1px;height:1px"></div>')`);
|
||||
await until(()=>owner.contentView.children.includes(root),false,'modal beside the viewport still covers native content');
|
||||
await run('document.getElementById("modal-layout-fixture").remove()');
|
||||
await until(()=>owner.contentView.children.includes(root),true,'closing modal restores native content');
|
||||
await run(`document.body.insertAdjacentHTML('beforeend','<div id="lock-layout-fixture" data-app-lock-overlay></div>')`);
|
||||
await until(()=>owner.contentView.children.includes(root),false,'app lock hides native content');
|
||||
await run('document.getElementById("lock-layout-fixture").remove()');
|
||||
await until(()=>owner.contentView.children.includes(root),true,'unlock restores native content');
|
||||
assert.equal(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');
|
||||
await pressEscape();
|
||||
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');
|
||||
await pressEscape();
|
||||
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');
|
||||
await pressEscape();
|
||||
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]);
|
||||
await pressEscape();
|
||||
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');
|
||||
await pressEscape();
|
||||
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');
|
||||
await pressEscape();
|
||||
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');
|
||||
await pressEscape();
|
||||
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);
|
||||
// Close the list through its own trigger before testing independent
|
||||
// navigation focus. A prior Escape may leave the baseline list open.
|
||||
await click('[aria-label="下载"]');
|
||||
await until(() => run('!!document.querySelector("[data-radix-popper-content-wrapper] > [role=dialog]")'), false, 'download list is closed before starting navigation');
|
||||
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');
|
||||
assert.equal(activePage.isLoading(), true, 'the response remains unfinished while switching tabs');
|
||||
// Exercise the native page shortcut while its response remains unfinished.
|
||||
// The new-tab button is covered above with the same real mouse input helper.
|
||||
activePage.focus();
|
||||
activePage.sendInputEvent({ type: 'keyDown', keyCode: 'T', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
|
||||
activePage.sendInputEvent({ type: 'keyUp', keyCode: 'T', modifiers: [process.platform === 'darwin' ? 'meta' : 'control'] });
|
||||
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, idleLayout: true, animatedLayout: true, dragLayout: true, themeLayout: true, modalAndLockLayout: 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);
|
||||
});
|
||||
Loaded 100 of 128 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user