Compare commits

..
Author SHA1 Message Date
bincxz 3107e50cbb Revert "fix(sync): link provider page to auto-sync settings"
This reverts commit 7e48780a34.
2026-10-17 21:40:22 +08:00
bincxz 7e48780a34 fix(sync): link provider page to auto-sync settings 2026-10-17 21:32:29 +08:00
172 changed files with 1619 additions and 15990 deletions

No files matched your search

-41
View File
@@ -74,47 +74,6 @@ jobs:
- name: Build
run: npm run build
windows-fido:
name: windows-fido
runs-on: windows-latest
timeout-minutes: 20
steps:
- name: Checkout
uses: actions/checkout@v7
- name: Setup Node
uses: actions/setup-node@v7
with:
node-version: 22
cache: npm
- name: Install deps
run: npm ci --ignore-scripts
- name: Apply production dependency patches
run: npx patch-package --error-on-fail
- name: Install user-mode OpenSSH and software authenticator toolchain
uses: msys2/setup-msys2@v2
with:
msystem: MSYS
update: true
install: gcc openssl-devel openssh
- name: Build disposable software authenticator
shell: msys2 {0}
run: |
gcc -shared -DHAVE_STDINT_H scripts/fixtures/fido/provider.c -lcrypto -o /usr/lib/netcatty-fido-test-provider.dll
printf 'NETCATTY_WINDOWS_SSH_AGENT=%s\n' "$(cygpath -w /usr/bin/ssh-agent.exe)" >> "$GITHUB_ENV"
printf 'NETCATTY_WINDOWS_SSH_KEYGEN=%s\n' "$(cygpath -w /usr/bin/ssh-keygen.exe)" >> "$GITHUB_ENV"
cygpath -w /usr/bin >> "$GITHUB_PATH"
ssh -V
- name: Exercise Windows pipes, PIN bridge, SK signatures and cancellation
env:
NETCATTY_FIDO_WINDOWS: "1"
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
runs-on: windows-latest
+2 -2
View File
@@ -1,5 +1,5 @@
import { MonitorHostKeyDialog } from './components/vault/MonitorHostKeyDialog';
import { MonitorRuntimeHost } from './application/app/MonitorRuntimeHost';
import { useMonitorRuntime } from './application/state/useMonitorRuntime';
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 />
-14
View File
@@ -500,20 +500,6 @@ export function handlePassphraseSkipImpl(getCtx: AppContextGetter, requestId: st
}
}
export function handleFidoPromptSubmitImpl(getCtx: AppContextGetter, requestId: string, response: string) {
const { netcattyBridge, setFidoPromptQueue } = getCtx();
const bridge = netcattyBridge.get();
void bridge?.respondFidoPrompt?.(requestId, response, false);
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
}
export function handleFidoPromptCancelImpl(getCtx: AppContextGetter, requestId: string) {
const { netcattyBridge, setFidoPromptQueue } = getCtx();
const bridge = netcattyBridge.get();
void bridge?.respondFidoPrompt?.(requestId, '', true);
setFidoPromptQueue((prev: { requestId: string }[]) => prev.filter((r) => r.requestId !== requestId));
}
export function createLocalTerminalWithCurrentShellImpl(getCtx: AppContextGetter) {
const { classifyLocalShellType, createLocalTerminal, discoveredShells, resolveShellSetting, terminalSettings } = getCtx();
{
+1 -52
View File
@@ -86,7 +86,6 @@ import { toast } from '../../components/ui/toast';
import { VaultSection } from '../../components/VaultView';
import { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import { PassphraseRequest } from '../../components/PassphraseModal';
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
import { classifyLocalShellType } from '../../lib/localShell';
import { useDiscoveredShells, resolveShellSetting, ensureDiscoveredShells } from '../../lib/useDiscoveredShells';
import { Host, HostProtocol, KnownHost, SerialConfig, Snippet, SSHKey, TerminalSession } from '../../types';
@@ -95,7 +94,7 @@ import { isScriptSnippet } from '../../domain/snippetScript.ts';
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
import { useAppStartupEffects } from './useAppStartupEffects';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
type OpenSessionInNewWindowPayload = {
title?: string;
@@ -140,8 +139,6 @@ export function AppSideEffects() {
const [keyboardInteractiveQueue, setKeyboardInteractiveQueue] = useState<KeyboardInteractiveRequest[]>([]);
// Passphrase request queue for encrypted SSH keys
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
// FIDO2 PIN / touch prompt queue (OpenSSH sk-*)
const [fidoPromptQueue, setFidoPromptQueue] = useState<FidoPromptRequest[]>([]);
const [deleteHostConfirm, setDeleteHostConfirm] = useState<{ hostId: string; name: string } | null>(null);
const [pendingNewWindowSession, setPendingNewWindowSession] = useState<OpenSessionInNewWindowPayload | null>(null);
const [pendingTrayPanelConnectHostIds, setPendingTrayPanelConnectHostIds] = useState<string[]>([]);
@@ -839,48 +836,6 @@ export function AppSideEffects() {
// Handle passphrase skip (skip this key, continue with others)
const handlePassphraseSkip = useCallback((requestId: string) => { return handlePassphraseSkipImpl(() => ({ netcattyBridge, requestId, setPassphraseQueue }), requestId); }, []);
// FIDO2 PIN / touch prompts from main-process askpass / sk-helper
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFidoPromptRequest) return;
const unsubscribe = bridge.onFidoPromptRequest((request) => {
console.log('[App] FIDO prompt request:', request);
setFidoPromptQueue((prev) => [...prev, {
...request,
kind: request.kind === 'touch' || request.kind === 'confirm' || request.kind === 'recovery' ? request.kind : 'pin',
}]);
});
return () => { unsubscribe?.(); };
}, []);
const handleFidoPromptSubmit = useCallback((requestId: string, response: string) => {
if (response === 'fallback') toast.info(t('fido.recovery.passwordFallback'));
return handleFidoPromptSubmitImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId, response);
}, [t]);
const handleFidoPromptCancel = useCallback((requestId: string) => {
return handleFidoPromptCancelImpl(() => ({ netcattyBridge, requestId, setFidoPromptQueue }), requestId);
}, []);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFidoPromptTimeout) return;
const unsubscribe = bridge.onFidoPromptTimeout((event) => {
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
toast.error(t('fido.prompt.timeout'));
});
return () => { unsubscribe?.(); };
}, [t]);
useEffect(() => {
const bridge = netcattyBridge.get();
if (!bridge?.onFidoPromptCancelled) return;
const unsubscribe = bridge.onFidoPromptCancelled((event) => {
setFidoPromptQueue((prev) => prev.filter((r) => r.requestId !== event.requestId));
});
return () => { unsubscribe?.(); };
}, []);
// Handle passphrase timeout (request expired on backend)
useEffect(() => {
const bridge = netcattyBridge.get();
@@ -2013,8 +1968,6 @@ export function AppSideEffects() {
handlePassphraseCancel,
handlePassphraseSkip,
handlePassphraseSubmit,
handleFidoPromptCancel,
handleFidoPromptSubmit,
handleProtocolSelect,
handleRequestCloseEditorTabRef,
resolveEmptyVaultConflict,
@@ -2038,7 +1991,6 @@ export function AppSideEffects() {
portForwardingRules,
keyboardInteractiveQueue,
passphraseQueue,
fidoPromptQueue,
deleteHostConfirm,
vaultFocusRequest,
openNoteRequest,
@@ -2087,8 +2039,6 @@ export function AppSideEffects() {
handlePassphraseCancel,
handlePassphraseSkip,
handlePassphraseSubmit,
handleFidoPromptCancel,
handleFidoPromptSubmit,
handleProtocolSelect,
resolveEmptyVaultConflict,
handleCancelDeleteHost,
@@ -2104,7 +2054,6 @@ export function AppSideEffects() {
portForwardingRules,
keyboardInteractiveQueue,
passphraseQueue,
fidoPromptQueue,
deleteHostConfirm,
vaultFocusRequest,
openNoteRequest,
+2 -10
View File
@@ -13,7 +13,6 @@ import { QuickScriptEditorDialog } from '../../components/scripts/QuickScriptEdi
import { AddToWorkspaceDialog } from '../../components/workspace/AddToWorkspaceDialog';
import { KeyboardInteractiveModal } from '../../components/KeyboardInteractiveModal';
import { PassphraseModal } from '../../components/PassphraseModal';
import { FidoPromptModal } from '../../components/FidoPromptModal';
import { UnsavedChangesProvider, promptUnsavedChanges } from '../../components/editor/UnsavedChangesDialog';
import { SnippetExecutionProvider } from '../../components/SnippetExecutionProvider';
import { Button } from '../../components/ui/button';
@@ -269,11 +268,11 @@ function AppViewInner({ domains }: AppViewProps) {
followAppTerminalTheme,
groupConfigs, handleAddKnownHost, handleConnectSerial, handleConnectToHost, handleCreateLocalTerminal, handleDefaultTerminalThemeChange, handleDeleteHost,
handleEndSessionDrag, handleFollowAppTerminalThemeChange, handleHostConnectWithProtocolCheck, handleHotkeyAction, handleKeyboardInteractiveCancel, handleKeyboardInteractiveSubmit,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleFidoPromptSubmit, handleFidoPromptCancel, handleProtocolSelect,
handleOpenHostFromVaultNote, handleOpenQuickSwitcher, handleOpenSettings, handleOpenVaultHostFromChat, handleOpenVaultNoteFromChat, handleOpenVaultSectionFromChat, handleOpenVaultSnippetFromChat, handleRootContextMenu, handlePassphraseCancel, handlePassphraseSkip, handlePassphraseSubmit, handleProtocolSelect,
handleRequestCloseEditorTabRef, handleSessionStatusChange, handleSyncNowManual, handleTerminalDataCapture, handleUpdateHostFromTerminal,
hostById, hosts, terminalHosts, updateTerminalHosts, hotkeyScheme, identities, importOrReuseKey, isBroadcastEnabled, isGlobalBroadcastEnabled, onToggleGlobalBroadcast, canUseGlobalBroadcast, isCreateWorkspaceOpen, isMacClient, isQuickSwitcherOpen,
keyBindings, keyboardInteractiveQueue, keys, logViews, managedSources, navigateToSection, openLogView, openNoteRequest, orderedTabsWithEditors, orphanSessions,
passphraseQueue, fidoPromptQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
passphraseQueue, protocolSelectHost, proxyProfiles, portForwardingRules, quickResults, quickSearch, removeSessionFromWorkspace, reorderWorkTabs, reorderWorkspaceSessions,
resolveEmptyVaultConflict, resolveSessionAppearance, runSnippet, sessionLogsDir, sessionLogsEnabled, sessionLogsFormat, sessionLogsTimestampsEnabled, sessionRenameTarget, sshDebugLogsEnabled,
sessions, setActiveTabId, setDeepLinkHostDraft, setDraggingSessionId, setEditorWordWrap,
setNavigateToSection, setTerminalFontFamilyId, setTerminalFontSize, setVaultFocusRequest, updateSessionFontSize, updateSessionRestoreCwd, updateSessionDynamicTitle, updateSessionCodingCliProvider, clearSessionFontSizeOverride,
@@ -1038,13 +1037,6 @@ function AppViewInner({ domains }: AppViewProps) {
onSkip={handlePassphraseSkip}
/>
{/* FIDO2 PIN / touch presence (OpenSSH sk-*) */}
<FidoPromptModal
request={fidoPromptQueue?.[0] || null}
onSubmit={handleFidoPromptSubmit}
onCancel={handleFidoPromptCancel}
/>
{/* Empty vault vs cloud data confirmation dialog (#679).
This dialog intentionally cannot be dismissed — the user MUST
choose "Restore" or "Keep Empty" before the sync flow can
-7
View File
@@ -1,7 +0,0 @@
import { useMonitorRuntime } from '../state/useMonitorRuntime';
/** Keep monitoring state and subscriptions outside the app coordinator. */
export function MonitorRuntimeHost() {
useMonitorRuntime();
return null;
}
-4
View File
@@ -4,7 +4,6 @@ import type { Host, PortForwardingRule } from '../../domain/models';
import type { VaultSection } from '../../components/VaultView';
import type { KeyboardInteractiveRequest } from '../../components/KeyboardInteractiveModal';
import type { PassphraseRequest } from '../../components/PassphraseModal';
import type { FidoPromptRequest } from '../../components/FidoPromptModal';
type Listener = () => void;
@@ -32,7 +31,6 @@ export type AppLocalUiSnapshot = {
portForwardingRules: readonly PortForwardingRule[];
keyboardInteractiveQueue: readonly KeyboardInteractiveRequest[];
passphraseQueue: readonly PassphraseRequest[];
fidoPromptQueue: readonly FidoPromptRequest[];
deleteHostConfirm: { hostId: string; name: string } | null;
vaultFocusRequest: unknown;
openNoteRequest: unknown;
@@ -51,7 +49,6 @@ export const EMPTY_APP_LOCAL_UI: AppLocalUiSnapshot = Object.freeze({
portForwardingRules: Object.freeze([]) as readonly PortForwardingRule[],
keyboardInteractiveQueue: Object.freeze([]) as readonly KeyboardInteractiveRequest[],
passphraseQueue: Object.freeze([]) as readonly PassphraseRequest[],
fidoPromptQueue: Object.freeze([]) as readonly FidoPromptRequest[],
deleteHostConfirm: null,
vaultFocusRequest: null,
openNoteRequest: null,
@@ -84,7 +81,6 @@ class AppLocalUiStore {
&& prev.portForwardingRules === next.portForwardingRules
&& prev.keyboardInteractiveQueue === next.keyboardInteractiveQueue
&& prev.passphraseQueue === next.passphraseQueue
&& prev.fidoPromptQueue === next.fidoPromptQueue
&& prev.deleteHostConfirm === next.deleteHostConfirm
&& prev.vaultFocusRequest === next.vaultFocusRequest
&& prev.openNoteRequest === next.openNoteRequest
-4
View File
@@ -58,15 +58,12 @@ export function DialogsHost() {
handlePassphraseCancel: handlers.handlePassphraseCancel,
handlePassphraseSkip: handlers.handlePassphraseSkip,
handlePassphraseSubmit: handlers.handlePassphraseSubmit,
handleFidoPromptCancel: handlers.handleFidoPromptCancel,
handleFidoPromptSubmit: handlers.handleFidoPromptSubmit,
handleProtocolSelect: handlers.handleProtocolSelect,
handleRequestCloseEditorTabRef: handlers.handleRequestCloseEditorTabRef,
isCreateWorkspaceOpen: local.isCreateWorkspaceOpen,
isQuickSwitcherOpen: local.isQuickSwitcherOpen,
keyboardInteractiveQueue: local.keyboardInteractiveQueue,
passphraseQueue: local.passphraseQueue,
fidoPromptQueue: local.fidoPromptQueue,
protocolSelectHost: local.protocolSelectHost,
quickResults,
quickSearch: local.quickSearch,
@@ -86,7 +83,6 @@ export function DialogsHost() {
local.isQuickSwitcherOpen,
local.keyboardInteractiveQueue,
local.passphraseQueue,
local.fidoPromptQueue,
local.protocolSelectHost,
local.quickSearch,
quickResults,
@@ -1,58 +0,0 @@
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;
}
});
+3 -8
View File
@@ -24,7 +24,7 @@ export const enMonitorMessages = {
"monitor.stopAll": "Stop all",
"monitor.list": "List",
"monitor.charts": "Charts",
"monitor.search": "Search hosts, groups, tags or notes",
"monitor.search": "Search name, address or group",
"monitor.interval": "Update interval",
"monitor.range": "Trend range",
"monitor.seconds": "Every {count}s",
@@ -34,13 +34,6 @@ 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",
@@ -60,6 +53,8 @@ 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",
+2
View File
@@ -36,6 +36,8 @@ 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',
-108
View File
@@ -439,12 +439,6 @@ 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',
@@ -637,43 +631,6 @@ export const enTerminalMessages: Messages = {
'keychain.field.publicKey': 'Public key',
'keychain.field.certificatePlaceholder': 'Certificate content (optional)',
'keychain.generate.keyType': 'Key type',
'keychain.generate.standardKeys': 'Standard keys',
'keychain.generate.securityKeys': 'Security keys · FIDO2',
'keychain.generate.residentDesc': 'Allow credentials to be recovered from this security key.',
'keychain.generate.verifyRequiredDesc': 'Ask for the device PIN before signing.',
'keychain.generate.generating': 'Generating…',
'fido.prompt.pinTouchDesc': 'Enter your PIN, then touch the key to confirm.',
'fido.prompt.details': 'Request details',
'fido.prompt.showPin': 'Show PIN',
'fido.prompt.hidePin': 'Hide PIN',
'fido.prompt.verifying': 'Verifying…',
'keychain.generate.fidoHint': 'Connect a FIDO2 security key. You may be asked for its PIN or a touch.',
'fido.prompt.pinTitle': 'Security key PIN',
'fido.prompt.touchTitle': 'Touch your security key',
'fido.prompt.pinDesc': 'Enter your security key PIN to continue.',
'fido.prompt.touchDesc': 'Touch your security key to confirm it is you.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Waiting for your key…',
'fido.prompt.touchAutomatic': 'Continues automatically after your touch',
"fido.recovery.device-unavailable.title": "Connect the matching security key",
"fido.recovery.device-unavailable.desc": "Connect the device containing this key's credential, then retry.",
"fido.recovery.timeout.title": "Security key confirmation timed out",
"fido.recovery.timeout.desc": "This device operation has stopped. Check that your key is connected, then retry and follow its touch or verification prompt.",
"fido.recovery.pin-failed.title": "Security key PIN was not accepted",
"fido.recovery.pin-failed.desc": "Check the FIDO2 PIN for this device before retrying. Repeated incorrect entries can lock the device.",
"fido.recovery.failed.title": "Security key verification did not complete",
"fido.recovery.failed.desc": "Check that the matching device is connected and supports this key. Retry, or cancel to check the device and OpenSSH configuration.",
"fido.recovery.retry": "Retry verification",
"fido.recovery.usePassword": "Use configured password",
"fido.recovery.passwordFallback": "Trying the password configured for this host",
"fido.prompt.cancelled": "Security key authentication cancelled.",
'fido.prompt.touchDone': 'I touched it',
'fido.prompt.timeout': 'FIDO prompt timed out. Try connecting again.',
'fido.error.opensshMissing': 'OpenSSH with FIDO/libfido2 is required. On macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'Could not start a FIDO-capable SSH agent.',
'keychain.generate.resident': 'Store credentials on the key',
'keychain.generate.verifyRequired': 'Verify PIN each time',
'keychain.action.loadResident': 'Load resident keys from security key',
'keychain.generate.keySize': 'Key size',
'keychain.generate.labelPlaceholder': 'Key label',
'keychain.generate.passphrasePlaceholder': 'Passphrase (optional)',
@@ -942,69 +899,4 @@ 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…',
};
+3 -8
View File
@@ -24,7 +24,7 @@ export const esMonitorMessages = {
"monitor.stopAll": "Detener todo",
"monitor.list": "Lista",
"monitor.charts": "Gráficos",
"monitor.search": "Buscar hosts, grupos, etiquetas o notas",
"monitor.search": "Buscar nombre, dirección o grupo",
"monitor.interval": "Intervalo de actualización",
"monitor.range": "Rango de tendencias",
"monitor.seconds": "Cada {count} s",
@@ -34,13 +34,6 @@ 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",
@@ -60,6 +53,8 @@ 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",
+2
View File
@@ -36,6 +36,8 @@ 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',
-71
View File
@@ -438,12 +438,6 @@ 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',
@@ -904,69 +898,4 @@ 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…',
};
+3 -8
View File
@@ -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,13 +33,6 @@ 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": "Состояние",
@@ -59,6 +52,8 @@ export const ruMonitorMessages = {
"monitor.memoryTrend": "График памяти",
"monitor.downloadTrend": "График приёма",
"monitor.uploadTrend": "График передачи",
"monitor.memoryOnlyHint":
"Измерения хранятся в памяти до 30 минут и удаляются при выходе. Пробелы означают отсутствие данных. Общие соединения используют наименьший активный интервал.",
"monitor.name": "Название панели",
"monitor.description": "Описание (необязательно)",
"monitor.chooseHosts": "Выбрать серверы",
+2
View File
@@ -36,6 +36,8 @@ 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': 'Выполняется',
-108
View File
@@ -457,12 +457,6 @@ 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': 'Хранение резервных копий',
@@ -652,43 +646,6 @@ export const ruTerminalMessages: Messages = {
'keychain.field.publicKey': 'Публичный ключ',
'keychain.field.certificatePlaceholder': 'Содержимое сертификата (необязательно)',
'keychain.generate.keyType': 'Тип ключа',
'keychain.generate.standardKeys': 'Обычные ключи',
'keychain.generate.securityKeys': 'Ключи безопасности · FIDO2',
'keychain.generate.residentDesc': 'Позволить восстановить учётные данные с ключа безопасности.',
'keychain.generate.verifyRequiredDesc': 'Запрашивать PIN устройства перед подписью.',
'keychain.generate.generating': 'Создание…',
'fido.prompt.pinTouchDesc': 'Введите PIN, затем коснитесь ключа для подтверждения.',
'fido.prompt.details': 'Подробности запроса',
'fido.prompt.showPin': 'Показать PIN',
'fido.prompt.hidePin': 'Скрыть PIN',
'fido.prompt.verifying': 'Проверка…',
'keychain.generate.fidoHint': 'Подключите ключ FIDO2. При создании может потребоваться PIN или касание.',
'fido.prompt.pinTitle': 'PIN ключа безопасности',
'fido.prompt.touchTitle': 'Коснитесь ключа безопасности',
'fido.prompt.pinDesc': 'Введите PIN ключа безопасности, чтобы продолжить.',
'fido.prompt.touchDesc': 'Коснитесь ключа безопасности, чтобы подтвердить свою личность.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Ожидание ответа ключа…',
'fido.prompt.touchAutomatic': 'Продолжим автоматически после касания',
"fido.recovery.device-unavailable.title": "Подключите нужный ключ безопасности",
"fido.recovery.device-unavailable.desc": "Подключите устройство с учётными данными этого ключа и повторите попытку.",
"fido.recovery.timeout.title": "Время подтверждения ключа истекло",
"fido.recovery.timeout.desc": "Операция устройства остановлена. Проверьте подключение ключа, повторите попытку и подтвердите её на устройстве.",
"fido.recovery.pin-failed.title": "PIN ключа безопасности не принят",
"fido.recovery.pin-failed.desc": "Проверьте PIN FIDO2 этого устройства. Повторные ошибки могут заблокировать устройство.",
"fido.recovery.failed.title": "Проверка ключа не завершена",
"fido.recovery.failed.desc": "Проверьте подключение нужного устройства и поддержку ключа. Повторите попытку или отмените её и проверьте настройки OpenSSH.",
"fido.recovery.retry": "Повторить проверку",
"fido.recovery.usePassword": "Использовать сохранённый пароль",
"fido.recovery.passwordFallback": "Вход с паролем, настроенным для этого хоста",
"fido.prompt.cancelled": "Проверка ключа безопасности отменена.",
'fido.prompt.touchDone': 'Я коснулся',
'fido.prompt.timeout': 'Время ожидания FIDO истекло. Попробуйте снова.',
'fido.error.opensshMissing': 'Нужен OpenSSH с FIDO/libfido2. macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'Не удалось запустить SSH agent с FIDO.',
'keychain.generate.resident': 'Хранить учётные данные на ключе',
'keychain.generate.verifyRequired': 'Проверять PIN каждый раз',
'keychain.action.loadResident': 'Загрузить resident-ключи с устройства',
'keychain.generate.keySize': 'Размер ключа',
'keychain.generate.labelPlaceholder': 'Метка ключа',
'keychain.generate.passphrasePlaceholder': 'Парольная фраза (необязательно)',
@@ -949,69 +906,4 @@ 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': 'Сохранить как…',
};
@@ -0,0 +1,16 @@
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`);
}
});
+3 -8
View File
@@ -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,13 +32,6 @@ 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": "状态",
@@ -58,6 +51,8 @@ export const zhCNMonitorMessages = {
"monitor.memoryTrend": "内存趋势",
"monitor.downloadTrend": "下载趋势",
"monitor.uploadTrend": "上传趋势",
"monitor.memoryOnlyHint":
"读数只保留在内存中,最多 30 分钟,退出后清空。空白表示没有读数。共享连接按当前最快的更新需求采集。",
"monitor.name": "面板名称",
"monitor.description": "说明(可选)",
"monitor.chooseHosts": "选择主机",
@@ -36,6 +36,8 @@ 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': '运行中',
+2 -104
View File
@@ -716,50 +716,13 @@ export const zhCNTerminalMessages: Messages = {
'keychain.export.scriptRequired': '脚本 *',
'keychain.export.exportToHost': '导出到主机',
'keychain.export.failedGeneric': '导出失败:{message}',
'keychain.field.label': '名称',
'keychain.field.labelRequired': '名称 *',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.labelPlaceholder': '密钥 Label',
'keychain.field.privateKeyRequired': '私钥 *',
'keychain.field.publicKey': '公钥',
'keychain.field.certificatePlaceholder': '证书内容(可选)',
'keychain.generate.keyType': '密钥类型',
'keychain.generate.standardKeys': '标准密钥',
'keychain.generate.securityKeys': '安全密钥 · FIDO2',
'keychain.generate.residentDesc': '允许从安全密钥中找回凭据。',
'keychain.generate.verifyRequiredDesc': '签名前验证设备 PIN,增加一层保护。',
'keychain.generate.generating': '正在生成…',
'fido.prompt.pinTouchDesc': '输入 PIN 后,触摸安全密钥确认身份。',
'fido.prompt.details': '查看请求详情',
'fido.prompt.showPin': '显示 PIN',
'fido.prompt.hidePin': '隐藏 PIN',
'fido.prompt.verifying': '正在验证…',
'keychain.generate.fidoHint': '请连接 FIDO2 安全密钥,生成时按提示输入 PIN 或触摸设备。',
'fido.prompt.pinTitle': '安全密钥 PIN',
'fido.prompt.touchTitle': '请触摸安全密钥',
'fido.prompt.pinDesc': '输入安全密钥的 PIN 以继续。',
'fido.prompt.touchDesc': '轻触安全密钥,确认是你本人在操作。',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': '等待安全密钥响应…',
'fido.prompt.touchAutomatic': '触摸后将自动继续',
"fido.recovery.device-unavailable.title": "请连接对应的安全密钥",
"fido.recovery.device-unavailable.desc": "请连接保存了此密钥凭据的设备,然后重试。",
"fido.recovery.timeout.title": "等待安全密钥确认超时",
"fido.recovery.timeout.desc": "本次设备操作已停止。请确认设备仍已连接,重试后按提示触摸或完成设备验证。",
"fido.recovery.pin-failed.title": "安全密钥 PIN 未通过验证",
"fido.recovery.pin-failed.desc": "请先检查此设备的 FIDO2 PIN,再重试。连续输错 PIN 可能导致设备锁定。",
"fido.recovery.failed.title": "安全密钥验证未完成",
"fido.recovery.failed.desc": "请确认对应设备已连接,并支持当前密钥。你可以重试,或取消后检查设备和 OpenSSH 配置。",
"fido.recovery.retry": "重试验证",
"fido.recovery.usePassword": "改用已配置的密码",
"fido.recovery.passwordFallback": "正在使用此主机已配置的密码登录",
"fido.prompt.cancelled": "已取消安全密钥验证。",
'fido.prompt.touchDone': '已触摸',
'fido.prompt.timeout': 'FIDO 提示已超时,请重新连接。',
'fido.error.opensshMissing': '需要带 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
'fido.error.agentUnavailable': '无法启动支持 FIDO 的 SSH agent。',
'keychain.generate.resident': '在设备上保存凭据',
'keychain.generate.verifyRequired': '每次验证 PIN',
'keychain.action.loadResident': '从安全密钥加载驻留密钥',
'keychain.generate.keySize': '密钥长度',
'keychain.generate.labelPlaceholder': '密钥 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可选)',
@@ -1028,69 +991,4 @@ 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': '另存为…',
};
-6
View File
@@ -842,12 +842,6 @@ 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': '备份保留数量',
+3 -8
View File
@@ -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,13 +32,6 @@ 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": "狀態",
@@ -58,6 +51,8 @@ export const zhTWMonitorMessages = {
"monitor.memoryTrend": "記憶體趨勢",
"monitor.downloadTrend": "下載趨勢",
"monitor.uploadTrend": "上傳趨勢",
"monitor.memoryOnlyHint":
"讀數只保留在記憶體中,最多 30 分鐘,結束後清空。空白表示沒有讀數。共用連線依目前最快的更新需求採集。",
"monitor.name": "面板名稱",
"monitor.description": "說明(選填)",
"monitor.chooseHosts": "選擇主機",
@@ -36,6 +36,8 @@ 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': '執行中',
+2 -104
View File
@@ -716,50 +716,13 @@ export const zhTWTerminalMessages: Messages = {
'keychain.export.scriptRequired': '指令碼 *',
'keychain.export.exportToHost': '匯出到主機',
'keychain.export.failedGeneric': '匯出失敗:{message}',
'keychain.field.label': '名稱',
'keychain.field.labelRequired': '名稱 *',
'keychain.field.label': 'Label',
'keychain.field.labelRequired': 'Label *',
'keychain.field.labelPlaceholder': '金鑰 Label',
'keychain.field.privateKeyRequired': '私鑰 *',
'keychain.field.publicKey': '公鑰',
'keychain.field.certificatePlaceholder': '憑證內容(可選)',
'keychain.generate.keyType': '金鑰型別',
'keychain.generate.standardKeys': '標準金鑰',
'keychain.generate.securityKeys': '安全金鑰 · FIDO2',
'keychain.generate.residentDesc': '允許從安全金鑰中找回憑證。',
'keychain.generate.verifyRequiredDesc': '簽章前驗證裝置 PIN,多一層保護。',
'keychain.generate.generating': '正在產生…',
'fido.prompt.pinTouchDesc': '輸入 PIN 後,觸碰安全金鑰確認身分。',
'fido.prompt.details': '檢視請求詳情',
'fido.prompt.showPin': '顯示 PIN',
'fido.prompt.hidePin': '隱藏 PIN',
'fido.prompt.verifying': '正在驗證…',
'keychain.generate.fidoHint': '請連接 FIDO2 安全金鑰,產生時依提示輸入 PIN 或觸碰裝置。',
'fido.prompt.pinTitle': '安全金鑰 PIN',
'fido.prompt.touchTitle': '請觸摸安全金鑰',
'fido.prompt.pinDesc': '輸入安全金鑰的 PIN 以繼續。',
'fido.prompt.touchDesc': '輕觸安全金鑰,確認是你本人操作。',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': '等待安全金鑰回應…',
'fido.prompt.touchAutomatic': '觸摸後將自動繼續',
"fido.recovery.device-unavailable.title": "請連接對應的安全金鑰",
"fido.recovery.device-unavailable.desc": "請連接儲存了此金鑰憑證的裝置,然後重試。",
"fido.recovery.timeout.title": "等待安全金鑰確認逾時",
"fido.recovery.timeout.desc": "本次裝置操作已停止。請確認裝置仍已連接,重試後依提示觸摸或完成裝置驗證。",
"fido.recovery.pin-failed.title": "安全金鑰 PIN 未通過驗證",
"fido.recovery.pin-failed.desc": "請先檢查此裝置的 FIDO2 PIN,再重試。連續輸錯 PIN 可能導致裝置鎖定。",
"fido.recovery.failed.title": "安全金鑰驗證未完成",
"fido.recovery.failed.desc": "請確認對應裝置已連接,並支援目前的金鑰。你可以重試,或取消後檢查裝置和 OpenSSH 設定。",
"fido.recovery.retry": "重試驗證",
"fido.recovery.usePassword": "改用已設定的密碼",
"fido.recovery.passwordFallback": "正在使用此主機已設定的密碼登入",
"fido.prompt.cancelled": "已取消安全金鑰驗證。",
'fido.prompt.touchDone': '已觸摸',
'fido.prompt.timeout': 'FIDO 提示已逾時,請重新連線。',
'fido.error.opensshMissing': '需要支援 FIDO/libfido2 的 OpenSSH。macOS:brew install openssh libfido2。',
'fido.error.agentUnavailable': '無法啟動支援 FIDO 的 SSH agent。',
'keychain.generate.resident': '在裝置上儲存憑證',
'keychain.generate.verifyRequired': '每次驗證 PIN',
'keychain.action.loadResident': '從安全金鑰載入駐留金鑰',
'keychain.generate.keySize': '金鑰長度',
'keychain.generate.labelPlaceholder': '金鑰 Label',
'keychain.generate.passphrasePlaceholder': 'Passphrase(可選)',
@@ -1028,69 +991,4 @@ 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': '另存新檔…',
};
-6
View File
@@ -842,12 +842,6 @@ 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 -87
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict';
import test, { type TestContext } from 'node:test';
import test from 'node:test';
import type { SyncPayload } from '../domain/sync.ts';
@@ -132,89 +132,3 @@ 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);
});
-28
View File
@@ -10,15 +10,6 @@ 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
@@ -95,16 +86,6 @@ 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;
}> {
@@ -284,15 +265,6 @@ 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(
@@ -1,49 +0,0 @@
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",
});
});
+1 -1
View File
@@ -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.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.at, historyId: host.historyId }])),
};
}
export function publishMonitorUnavailable() {
@@ -1,60 +0,0 @@
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']);
});
@@ -1,95 +0,0 @@
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) };
}
@@ -1,130 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create } from 'react-test-renderer';
import { installDomEnvironment } from '../../components/test-support/renderReactDom';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { TerminalSettings } from '../../domain/models';
import { useHostBrowserPanel } from './useHostBrowserPanel';
type Layout = Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0];
type Result = Awaited<ReturnType<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>>;
const layers = [{ image: 'data:image/png;base64,fixture', bounds: { x: 0, y: 0, width: 500, height: 600 } }];
async function fixture(t: test.TestContext) {
const env = installDomEnvironment();
const frames = new Map<number, FrameRequestCallback>();
let frameId = 0;
const originals = new Map(['requestAnimationFrame', 'cancelAnimationFrame', 'getComputedStyle']
.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)]));
Object.defineProperty(globalThis, 'requestAnimationFrame', { configurable: true,
value: (callback: FrameRequestCallback) => { frames.set(++frameId, callback); return frameId; } });
Object.defineProperty(globalThis, 'cancelAnimationFrame', { configurable: true,
value: (id: number) => frames.delete(id) });
const style = env.document.createElement('div').style;
style.colorScheme = 'dark';
globalThis.getComputedStyle = () => style;
const layouts: Layout[] = [];
let respond: (layout: Layout) => Promise<Result> = async (layout) => layout.occluded ? { layers } : true;
t.mock.method(netcattyBridge, 'get', () => ({
setHostBrowserPanelLayout: async (layout: Layout) => { layouts.push(layout); return respond(layout); },
openHostBrowser: async () => {},
startPortForward: async () => assert.fail('a local browser needs no SSH transport'),
closeHostBrowserPanel: async () => {},
}));
let panel: ReturnType<typeof useHostBrowserPanel>;
function Panel({ visible = true, browserId = 'local:overlay-test' }) {
panel = useHostBrowserPanel({ browserId,
host: { id: 'local', protocol: 'local', label: 'Local terminal', hostname: '', username: '', tags: [], os: 'macos' },
hosts: [], keys: [], identities: [], knownHosts: [], terminalSettings: {} as TerminalSettings,
isVisible: visible });
return React.createElement('div', { ref: panel.elementRef });
}
const element = { isConnected: true, closest: () => null,
getBoundingClientRect: () => new env.window.DOMRect(200, 100, 500, 600) };
let renderer: ReturnType<typeof create>;
t.after(async () => {
if (renderer) await act(async () => renderer.unmount());
for (const [name, previous] of originals) {
if (previous) Object.defineProperty(globalThis, name, previous);
else Reflect.deleteProperty(globalThis, name);
}
env.cleanup();
});
await act(async () => { renderer = create(React.createElement(Panel), { createNodeMock: () => element }); });
const overlay = (html: string, x = 220, y = 110) => {
env.document.body.insertAdjacentHTML('beforeend', html);
const node = env.document.body.lastElementChild!;
for (const target of [node, ...node.children]) {
target.getBoundingClientRect = () => new env.window.DOMRect(x, y, 150, 40);
}
return node;
};
const tick = async () => {
await act(async () => {
const pending = [...frames.values()]; frames.clear();
for (const callback of pending) callback(0);
});
};
return { layouts, overlay, tick, panel: () => panel,
respond: (callback: typeof respond) => { respond = callback; },
hide: async () => { await act(async () => renderer.update(React.createElement(Panel, { visible: false }))); } };
}
const tooltip = '<div data-radix-popper-content-wrapper><div data-state="delayed-open"><span role="tooltip">Split vertically</span></div></div>';
test('a split-button tooltip keeps the browser picture, then restores its live view', async (t) => {
const f = await fixture(t);
assert.equal(f.layouts.at(-1)?.visible, true);
const hint = f.overlay(tooltip);
await f.tick();
assert.equal(f.layouts.at(-1)?.visible, true, 'the owning panel remains open');
assert.equal(f.layouts.at(-1)?.occluded, true);
assert.deepEqual(f.panel().snapshot, layers, 'the native page is replaced by its picture, not a blank pane');
const count = f.layouts.length;
await f.tick();
assert.equal(f.layouts.length, count, 'a stationary tooltip captures only once');
hint.remove(); await f.tick();
assert.equal(f.layouts.at(-1)?.occluded, false);
assert.deepEqual(f.panel().snapshot, []);
});
test('unrelated and closed poppers leave the browser live', async (t) => {
const f = await fixture(t);
f.overlay(tooltip, 10, 10);
f.overlay('<div data-radix-popper-content-wrapper><div role="menu" data-state="closed"></div></div>');
await f.tick();
assert.equal(f.layouts.length, 1);
assert.equal(f.layouts.at(-1)?.occluded, false);
});
test('overlapping nonmodal menus yield with a picture; modal dialogs yield even beside the pane', async (t) => {
const f = await fixture(t);
const menu = f.overlay('<div data-radix-popper-content-wrapper><div role="dialog" data-state="open"></div></div>');
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
menu.remove(); await f.tick();
f.overlay('<div role="dialog" aria-modal="true"></div>', 0, 0);
await f.tick(); assert.equal(f.layouts.at(-1)?.occluded, true);
});
test('closing a menu while capture is pending cannot restore stale pictures', async (t) => {
const f = await fixture(t);
let complete: (result: Result) => void = () => assert.fail('capture was not started');
f.respond((layout) => layout.occluded ? new Promise((resolve) => { complete = resolve; }) : Promise.resolve(true));
const menu = f.overlay(tooltip); await f.tick();
menu.remove(); await f.tick();
await act(async () => complete({ layers }));
assert.deepEqual(f.panel().snapshot, []);
});
test('hiding or locking the pane clears pictures without taking another capture', async (t) => {
const f = await fixture(t);
f.overlay(tooltip); await f.tick();
assert.deepEqual(f.panel().snapshot, layers);
f.overlay('<div data-app-lock-overlay role="dialog"></div>'); await f.tick();
assert.equal(f.layouts.at(-1)?.visible, false);
assert.equal(f.layouts.at(-1)?.occluded, false);
assert.deepEqual(f.panel().snapshot, []);
await f.hide();
assert.deepEqual(f.panel().snapshot, []);
});
+8 -36
View File
@@ -2,16 +2,6 @@ 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;
@@ -32,47 +22,32 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
const mountedBrowserId = useRef<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
const [readyId, setReadyId] = useState<string | null>(null);
const [snapshot, setSnapshot] = useState<{ browserId: string; layers: HostBrowserPanelSnapshotLayer[] } | null>(null);
const { browserId, isVisible } = options;
useLayoutEffect(() => {
const bridge = netcattyBridge.get();
let frame = 0;
let previous = '';
let disposed = false;
const update = () => {
const element = elementRef.current;
const rect = element?.getBoundingClientRect();
// Native views sit above renderer portals. Only overlapping poppers need
// to yield; dialogs are modal even when their content is beside the pane.
const locked = !!document.querySelector('[data-app-lock-overlay]');
const modal = !!document.querySelector('[role="dialog"][aria-modal="true"], [role="alertdialog"]');
const covered = modal || (!!rect && Array.from(document.querySelectorAll(
'[role="dialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper] > *',
)).some((overlay) => overlapsBrowser(rect, overlay)));
// Native page views must leave the screen with their owning pane and
// must not cover a renderer dialog or a toolbar's open menu.
const covered = !!document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"][data-state="open"], [data-radix-popper-content-wrapper]');
const visible = isVisible && !!element?.isConnected && !!rect
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !locked;
if (!visible) setSnapshot(null);
&& rect.width > 0 && rect.height > 0 && !element?.closest('[inert]') && !covered;
const bounds = rect ? { x: rect.x, y: rect.y, width: rect.width, height: rect.height } : undefined;
const theme = element && getComputedStyle(element).colorScheme === 'light' ? 'light' : 'dark';
const themeTokens = currentBrowserThemeTokens(element ?? undefined);
const layout: Parameters<NonNullable<NetcattyBridge['setHostBrowserPanelLayout']>>[0] = {
browserId, visible, occluded: visible && covered, bounds, theme, themeTokens,
};
const next = JSON.stringify(layout);
const next = JSON.stringify({ browserId, visible, bounds, theme, themeTokens });
if (next !== previous) {
previous = next;
void bridge?.setHostBrowserPanelLayout?.(layout).then((result) => {
if (disposed || previous !== next) return;
setSnapshot(visible && covered && typeof result === 'object' ? { browserId, layers: result.layers } : null);
}).catch(() => {});
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible, bounds, theme, themeTokens }).catch(() => {});
}
if (isVisible) frame = requestAnimationFrame(update);
};
update();
return () => {
disposed = true;
cancelAnimationFrame(frame);
void bridge?.setHostBrowserPanelLayout?.({ browserId, visible: false }).catch(() => {});
};
@@ -83,9 +58,7 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
opened.current = true;
setError(null);
let cancelled = false;
void mountHostBrowser({ ...optionsRef.current, browserId }).then(() => {
if (mountedBrowserId.current === browserId) setReadyId(browserId);
}).catch((reason: unknown) => {
void mountHostBrowser({ ...optionsRef.current, browserId }).catch((reason: unknown) => {
opened.current = false;
if (cancelled) return;
setError(reason instanceof Error ? reason.message : String(reason));
@@ -107,6 +80,5 @@ export function useHostBrowserPanel(options: BrowserPanelOptions) {
};
}, [browserId]);
return { elementRef, error, ready: readyId === browserId, snapshot: isVisible && snapshot?.browserId === browserId ? snapshot.layers : [],
retry: () => setAttempt((value) => value + 1) };
return { elementRef, error, retry: () => setAttempt((value) => value + 1) };
}
+3 -15
View File
@@ -1,7 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import {
type LocalVaultBackupPreview,
clearLocalVaultBackups,
getLocalVaultBackupCapabilities,
getLocalVaultBackupMaxCount,
listLocalVaultBackups,
@@ -21,15 +20,13 @@ 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();
if (sequence === refreshSequence.current) setBackups(next);
setBackups(next);
} finally {
if (sequence === refreshSequence.current) setIsLoading(false);
setIsLoading(false);
}
}, []);
@@ -79,14 +76,6 @@ export function useLocalVaultBackups() {
return sanitized;
}, [refreshBackups]);
const clearBackups = useCallback(async () => {
try {
await clearLocalVaultBackups();
} finally {
await refreshBackups();
}
}, [refreshBackups]);
const openBackupDirectory = useCallback(async () => {
await openLocalVaultBackupDir();
}, []);
@@ -97,7 +86,6 @@ export function useLocalVaultBackups() {
maxBackups,
encryptionAvailable,
refreshBackups,
clearBackups,
readBackup: readLocalVaultBackup,
setMaxBackups: updateMaxBackups,
openBackupDirectory,
-103
View File
@@ -1,103 +0,0 @@
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 };
}
@@ -0,0 +1,27 @@
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);
});
+19
View File
@@ -0,0 +1,19 @@
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);
@@ -0,0 +1,59 @@
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");
});
+16 -42
View File
@@ -1,48 +1,22 @@
/** Web Locks fixture for tests with a simulated browser window. */
/** Serialized Web Locks fixture for tests with a simulated browser window. */
export function installBrowserLockFixture(): void {
const states = new Map<string, {
readers: number;
writer: boolean;
pending: Array<{ mode: LockMode; grant: () => void }>;
}>();
const tails = new Map<string, Promise<void>>();
const locks = {
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();
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);
}
},
};
Object.defineProperty(globalThis, "navigator", {
@@ -1,104 +0,0 @@
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();
}
});
-36
View File
@@ -1,36 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
test("FidoPromptModal ships PIN and touch UI wiring", () => {
const source = readFileSync(join(import.meta.dirname, "FidoPromptModal.tsx"), "utf8");
assert.match(source, /kind === "touch"/);
assert.match(source, /fido-pin-input/);
assert.match(source, /fido\.prompt\.pinTitle/);
assert.match(source, /fido\.prompt\.touchTitle/);
assert.match(source, /onSubmit\(request\.requestId/);
assert.match(source, /onCancel\(request\.requestId/);
});
test("AppView mounts FidoPromptModal", () => {
const source = readFileSync(
join(import.meta.dirname, "../application/app/AppView.tsx"),
"utf8",
);
assert.match(source, /FidoPromptModal/);
assert.match(source, /fidoPromptQueue/);
assert.match(source, /handleFidoPromptSubmit/);
});
test("GenerateStandardPanel exposes FIDO options", () => {
const source = readFileSync(
join(import.meta.dirname, "keychain/GenerateStandardPanel.tsx"),
"utf8",
);
assert.match(source, /ED25519-SK/);
assert.match(source, /ECDSA-SK/);
assert.match(source, /resident/);
assert.match(source, /verifyRequired/);
assert.match(source, /fidoHint/);
});
-155
View File
@@ -1,155 +0,0 @@
/** PIN entry and user-presence prompts from OpenSSH. */
import { Clock3, Eye, EyeOff, Hand, KeyRound, Loader2, ShieldAlert, Usb } from "lucide-react";
import React, { useCallback, useEffect, useId, useState } from "react";
import { useI18n } from "../application/i18n/I18nProvider";
import { Button } from "./ui/button";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./ui/dialog";
import { Input } from "./ui/input";
import { Label } from "./ui/label";
export type FidoPromptKind = "pin" | "touch" | "confirm" | "recovery";
export interface FidoPromptRequest {
requestId: string;
kind: FidoPromptKind;
message?: string;
title?: string;
keyName?: string;
notificationOnly?: boolean;
reason?: "device-unavailable" | "timeout" | "pin-failed" | "failed";
allowPasswordFallback?: boolean;
}
interface FidoPromptModalProps {
request: FidoPromptRequest | null;
onSubmit: (requestId: string, response: string) => void;
onCancel: (requestId: string) => void;
}
export const FidoPromptModal: React.FC<FidoPromptModalProps> = ({ request, onSubmit, onCancel }) => {
const { t } = useI18n();
// Retain the content during Radix's closing animation, but clear the PIN.
const [lastRequest, setLastRequest] = useState(request);
const [pin, setPin] = useState("");
const [showPin, setShowPin] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const descriptionId = useId();
useEffect(() => { if (request) setLastRequest(request); }, [request]);
useEffect(() => {
setPin("");
setShowPin(false);
setIsSubmitting(false);
}, [request?.requestId]);
const visibleRequest = request || lastRequest;
const isTouch = visibleRequest?.kind === "touch" || visibleRequest?.kind === "confirm";
const isRecovery = visibleRequest?.kind === "recovery";
const recoveryReason = visibleRequest?.reason || "failed";
const RecoveryIcon = recoveryReason === "device-unavailable" ? Usb : recoveryReason === "timeout" ? Clock3 : ShieldAlert;
const handleSubmit = useCallback(() => {
if (!request || request.notificationOnly || isSubmitting || (!isTouch && !isRecovery && !pin)) return;
setIsSubmitting(true);
onSubmit(request.requestId, isRecovery ? "retry" : isTouch ? "" : pin);
}, [request, isSubmitting, isTouch, isRecovery, pin, onSubmit]);
const handleCancel = useCallback(() => {
if (request) onCancel(request.requestId);
}, [request, onCancel]);
const message = visibleRequest?.message?.trim();
const hasPinAndTouch = !isTouch && /user presence|touch|tap/i.test(message || "");
const isErrorPrompt = /incorrect|invalid|wrong|failed|retries|attempts|错误|錯誤|剩余|剩餘/i.test(message || "");
return (
<Dialog open={!!request} onOpenChange={open => !open && handleCancel()}>
{visibleRequest && (
<DialogContent
hideCloseButton
aria-describedby={descriptionId}
overlayClassName="fido-prompt-overlay bg-black/20"
className="fido-prompt-dialog z-[51] w-[calc(100%_-_2rem)] max-w-[400px] gap-0 overflow-hidden rounded-xl border-border/70 p-6 sm:rounded-xl"
style={{ boxShadow: '0 20px 60px -20px rgb(0 0 0 / 0.28)' }}
>
<div key={visibleRequest.requestId} className="fido-prompt-stage">
{isRecovery ? (
<div className="pb-2 pt-2 text-center">
<div className="mx-auto mb-5 flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
<RecoveryIcon size={29} strokeWidth={1.5} />
</div>
<DialogTitle className="text-base">{t(`fido.recovery.${recoveryReason}.title`)}</DialogTitle>
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
{t(`fido.recovery.${recoveryReason}.desc`)}
</DialogDescription>
</div>
) : isTouch ? (
<div className="pb-5 pt-2 text-center">
<div className="relative mx-auto mb-6 flex h-24 w-24 items-center justify-center" aria-hidden="true">
<span className="fido-touch-ring absolute inset-0 rounded-full bg-primary/5" />
<span className="fido-touch-ring fido-touch-ring-delayed absolute inset-0 rounded-full bg-primary/5" />
<div className="fido-touch-symbol relative flex h-16 w-16 items-center justify-center rounded-full bg-primary/8 text-primary">
<Hand size={30} strokeWidth={1.5} />
</div>
</div>
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.touchTitle")}</DialogTitle>
{visibleRequest.keyName && <p className="mt-2 truncate text-sm font-medium">{visibleRequest.keyName}</p>}
<DialogDescription id={descriptionId} className="mt-2 text-sm leading-relaxed">
{t("fido.prompt.touchDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
</DialogDescription>
<div role="status" className="mt-5 flex items-center justify-center gap-2 text-xs text-muted-foreground">
<span className="fido-status-dot h-1.5 w-1.5 rounded-full bg-primary" aria-hidden="true" />
{t(visibleRequest.notificationOnly ? "fido.prompt.touchAutomatic" : "fido.prompt.touchWaiting")}
</div>
</div>
) : (
<>
<div className="mb-5 flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-primary/8 text-primary" aria-hidden="true">
<KeyRound size={21} strokeWidth={1.7} />
</div>
<div className="min-w-0">
<DialogTitle className="text-base">{visibleRequest.title || t("fido.prompt.pinTitle")}</DialogTitle>
{visibleRequest.keyName && <p className="mt-1 truncate text-xs font-medium">{visibleRequest.keyName}</p>}
<DialogDescription id={descriptionId} className="mt-1 text-xs leading-relaxed">
{t(hasPinAndTouch ? "fido.prompt.pinTouchDesc" : "fido.prompt.pinDesc", { keyName: visibleRequest.keyName || "FIDO2" })}
</DialogDescription>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="fido-pin-input" className="text-xs">{t("fido.prompt.pinLabel")}</Label>
<div className="relative">
<Input id="fido-pin-input" type={showPin ? "text" : "password"}
value={pin} onChange={e => setPin(e.target.value)} disabled={isSubmitting}
onKeyDown={e => {
if (e.key === "Enter") { e.preventDefault(); handleSubmit(); }
}} autoFocus className="h-11 pr-11" autoComplete="off" spellCheck={false} />
<Button type="button" variant="ghost" size="icon" disabled={isSubmitting}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
aria-label={t(showPin ? "fido.prompt.hidePin" : "fido.prompt.showPin")}
aria-pressed={showPin} onClick={() => setShowPin(value => !value)}>
{showPin ? <EyeOff size={15} /> : <Eye size={15} />}
</Button>
</div>
</div>
</>
)}
{message && (isErrorPrompt ? (
<p role="alert" className="mt-3 break-words text-xs leading-relaxed text-destructive">{message}</p>
) : (
<details className="mt-3 text-xs text-muted-foreground">
<summary className="w-fit cursor-pointer rounded-sm text-[11px] outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring">{t("fido.prompt.details")}</summary>
<p className="mt-2 break-words leading-relaxed">{message}</p>
</details>
))}
</div>
<div className="-mx-6 -mb-6 mt-5 flex items-center justify-end gap-2 border-t border-border/50 bg-muted/15 px-6 py-3.5">
<Button type="button" variant="ghost" className="h-9" onClick={handleCancel} disabled={isSubmitting}>{t(visibleRequest.notificationOnly ? "common.close" : "common.cancel")}</Button>
{isRecovery && visibleRequest.allowPasswordFallback && <Button type="button" variant="outline" className="h-9" onClick={() => request && onSubmit(request.requestId, "fallback")}>{t("fido.recovery.usePassword")}</Button>}
{!visibleRequest.notificationOnly && <Button type="button" className="h-9 min-w-20 gap-2" onClick={handleSubmit} disabled={isSubmitting || (!isTouch && !isRecovery && !pin)}>
{isSubmitting && <Loader2 className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />}
{t(isSubmitting ? "fido.prompt.verifying" : isRecovery ? "fido.recovery.retry" : "common.continue")}
</Button>}
</div>
</DialogContent>
)}
</Dialog>
);
};
export default FidoPromptModal;
+9 -130
View File
@@ -1,137 +1,16 @@
import React, { useEffect, useRef, useState } from 'react';
import { ArrowLeft, ArrowRight, Bookmark, Download, Edit2, Loader2, Plus, RotateCw, Square, Trash2, X } from 'lucide-react';
import React from '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: mountError, snapshot, retry, ready } = useHostBrowserPanel(props);
const browser = useHostBrowserControls(props.browserId, ready && props.isVisible);
const { elementRef, error, retry } = useHostBrowserPanel(props);
const { t } = useI18n();
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>
return (
<div ref={elementRef} className="relative h-full min-h-0 w-full overflow-hidden" data-section="host-browser-panel" data-browser-host-id={props.host.id}>
{error && <div className="p-4 text-sm text-muted-foreground">
<p>{error}</p>
<button type="button" className="mt-3 text-primary" onClick={retry}>{t('common.retry')}</button>
</div>}
</div>
{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>;
);
}
+4 -12
View File
@@ -379,12 +379,6 @@ echo $3 >> "$FILE"`);
type: keyType,
bits: keySize,
comment: `${draftKey.label.trim()}@netcatty`,
resident: keyType === "ED25519-SK" || keyType === "ECDSA-SK"
? !!(draftKey as { resident?: boolean }).resident
: undefined,
verifyRequired: keyType === "ED25519-SK" || keyType === "ECDSA-SK"
? !!(draftKey as { verifyRequired?: boolean }).verifyRequired
: undefined,
});
if (!result) {
throw new Error(
@@ -395,17 +389,15 @@ echo $3 >> "$FILE"`);
throw new Error(result.error || t("keychain.error.generateKeyPairFailed"));
}
const resolvedType = (result.keyType as KeyType | undefined) || keyType;
const isFidoSk = resolvedType === "ED25519-SK" || resolvedType === "ECDSA-SK";
const newKey: SSHKey = {
id: crypto.randomUUID(),
label: draftKey.label.trim(),
type: resolvedType,
keySize: resolvedType === "ED25519" || isFidoSk ? undefined : keySize,
type: keyType,
keySize: keyType !== "ED25519" ? keySize : undefined,
privateKey: result.privateKey,
publicKey: result.publicKey,
passphrase: isFidoSk ? undefined : draftKey.passphrase,
savePassphrase: isFidoSk ? undefined : draftKey.savePassphrase,
passphrase: draftKey.passphrase,
savePassphrase: draftKey.savePassphrase,
source: "generated",
category: "key",
created: Date.now(),
@@ -0,0 +1,73 @@
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"));
});
+28
View File
@@ -174,3 +174,31 @@ 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/);
});
+305 -1
View File
@@ -15,6 +15,8 @@ import {
Expand,
FolderPlus,
Layers,
LayoutGrid,
List,
Minimize2,
Package,
Play,
@@ -27,6 +29,7 @@ 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,
@@ -61,6 +64,12 @@ 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('/');
@@ -170,10 +179,14 @@ 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));
@@ -250,6 +263,13 @@ 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);
};
@@ -283,6 +303,7 @@ 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());
@@ -293,6 +314,7 @@ 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);
@@ -487,8 +509,11 @@ 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]);
}, [snippets, packages, expandedPaths, searchMatches, isVisible, viewMode]);
type ScriptsListItem =
| { key: string; kind: 'search'; snippet: Snippet }
@@ -521,6 +546,48 @@ 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) {
@@ -979,6 +1046,30 @@ 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
@@ -1126,6 +1217,107 @@ 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"
@@ -1650,5 +1842,117 @@ 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,11 +14,9 @@ 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,
@@ -31,7 +29,6 @@ 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';
// ============================================================================
@@ -57,13 +54,10 @@ 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);
@@ -181,34 +175,34 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
}
};
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);
}
};
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 restoreAllowed = restoreDisabledReason === null && encryptionAvailable === true;
// 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>
);
}
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">
@@ -230,7 +224,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
<Button
variant="outline"
onClick={() => void handleSaveMaxBackups()}
disabled={isSavingMaxBackups || isClearing || restoringBackupId !== null}
disabled={isSavingMaxBackups}
className="gap-2"
>
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
@@ -241,7 +235,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
</div>
</div>
{restoreDisabledReason !== null && (
{!restoreAllowed && (
<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} />
@@ -254,19 +248,19 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
)}
<div className="rounded-lg border bg-card p-4 space-y-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex 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 flex-wrap items-center gap-2">
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => void refreshBackups()}
disabled={isLoading || isClearing}
disabled={isLoading}
className="gap-1"
>
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
@@ -281,16 +275,6 @@ 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>
@@ -355,7 +339,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 || isClearing}
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId === backup.id ? (
@@ -372,17 +356,6 @@ 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. */}
@@ -443,7 +416,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
<Button
variant="outline"
onClick={() => setPendingRestoreBackup(null)}
disabled={restoringBackupId !== null || isClearing}
disabled={restoringBackupId !== null}
>
{t('cloudSync.localBackups.restoreConfirmCancel')}
</Button>
@@ -455,7 +428,7 @@ export const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
setPendingRestoreBackup(null);
await performRestore(target.id);
}}
disabled={restoringBackupId !== null || isClearing}
disabled={restoringBackupId !== null}
className="gap-2"
>
{restoringBackupId !== null ? (
@@ -1,150 +0,0 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createRequire } from 'node:module';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import ts from 'typescript';
import * as i18n from '../../application/i18n/I18nProvider';
import * as editorTabs from '../../application/state/editorTabStore';
import * as editorClient from '../../application/state/editorWindowClient';
import * as editorRuntime from '../../application/state/useEditorWindow';
import * as windowControls from '../../application/state/useWindowControls';
import * as editorTabBar from './EditorWindowTabBar';
import type { TextEditorPaneProps } from './TextEditorPane';
// Run the actual page, pane hosts, runtime and store. Only Monaco's visual
// surface and the unrelated confirmation/toast chrome are replaced here.
// Counting the memoized pane boundary catches content updates leaking through
// the page root into every inactive editor, rather than testing a hand-written
// approximation of the subscription graph.
function loadPage(pane: React.ComponentType<TextEditorPaneProps>) {
const source = readFileSync(new URL('./EditorWindowPage.tsx', import.meta.url), 'utf8');
const compiled = ts.transpileModule(source, { compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
jsx: ts.JsxEmit.ReactJSX,
esModuleInterop: true,
} }).outputText;
const require = createRequire(import.meta.url);
const dependencies: Record<string, unknown> = {
'../../application/i18n/I18nProvider': i18n,
'../../application/state/editorTabStore': editorTabs,
'../../application/state/editorWindowClient': editorClient,
'../../application/state/useEditorWindow': editorRuntime,
'../../application/state/useWindowControls': windowControls,
'../ui/toast': { toast: { success() {}, error() {}, warning() {} } },
'./UnsavedChangesDialog': {
UnsavedChangesProvider: ({ children }: { children: () => React.ReactNode }) => children(),
promptUnsavedChanges: async () => 'cancel',
},
'./EditorWindowTabBar': editorTabBar,
'./TextEditorPane': { TextEditorPane: pane },
};
const module = { exports: {} as { default: React.ComponentType<{ settings: unknown }> } };
new Function('require', 'module', 'exports', compiled)(
(id: string) => dependencies[id] ?? require(id), module, module.exports,
);
return module.exports.default;
}
const makeTab = (index: number): editorTabs.EditorTab => ({
id: `performance-editor-${index}`,
kind: 'editor',
sessionId: 'fixture-session',
sftpTabId: 'fixture-sftp',
hostId: 'fixture-host',
remotePath: `/file-${index}.txt`,
fileName: `file-${index}.txt`,
languageId: 'text',
content: 'original',
baselineContent: 'original',
wordWrap: false,
viewState: null,
savingState: 'idle',
saveError: null,
});
test('detached editor typing and tab switches leave unrelated editor panes untouched', async () => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
const panes = new Map<string, TextEditorPaneProps>();
const renders = new Map<string, number>();
const titles: string[] = [];
const saves: unknown[] = [];
const docked: unknown[] = [];
let activate: ((payload: { editorId: string }) => void) | undefined;
let finishDock: ((result: { success: boolean }) => void) | undefined;
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { configurable: true, value: true });
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
setTimeout, clearTimeout, addEventListener() {}, removeEventListener() {},
netcatty: {
windowIsMaximized: async () => false,
setWindowTitle: async (title: string) => { titles.push(title); },
onEditorWindowActivateTab: (callback: typeof activate) => { activate = callback; return () => { activate = undefined; }; },
saveEditorWindowTab: async (snapshot: unknown) => { saves.push(snapshot); return { ok: true }; },
dockEditorWindowTab: (snapshot: unknown) => {
docked.push(snapshot);
return new Promise<{ success: boolean }>((resolve) => { finishDock = resolve; });
},
},
} });
const Pane = React.memo((props: TextEditorPaneProps) => {
const name = props.fileName.replace(/ \*$/, '');
renders.set(name, (renders.get(name) ?? 0) + 1);
panes.set(name, props);
return React.createElement('div', { 'data-editor': name });
});
const Page = loadPage(Pane);
const tabs = Array.from({ length: 10 }, (_, index) => makeTab(index));
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => {
for (const tab of tabs) editorTabs.editorTabStore._debugInsert(tab);
renderer = create(React.createElement(i18n.I18nProvider, { locale: 'en-US', children: React.createElement(Page, { settings: { hotkeyScheme: 'mac', keyBindings: [] } }) }));
});
renders.clear();
for (let index = 0; index < 20; index += 1) {
await act(async () => panes.get('file-9.txt')!.onContentChange(`updated ${index}`, null));
}
assert.deepEqual(Object.fromEntries(renders), { 'file-9.txt': 20 });
assert.equal(editorTabs.editorTabStore.getTab(tabs[9].id)?.content, 'updated 19');
assert.equal(titles.at(-1), '* file-9.txt');
renders.clear();
for (let index = 0; index < 10; index += 1) {
await act(async () => activate?.({ editorId: tabs[index].id }));
}
assert.equal([...renders.values()].reduce((sum, count) => sum + count, 0), 0);
assert.equal(editorTabs.editorTabStore.getTab(tabs[9].id)?.content, 'updated 19');
assert.equal(titles.at(-1), '* file-9.txt');
await act(async () => { await panes.get('file-9.txt')!.onSave(); });
assert.equal(saves.length, 1);
assert.equal(editorTabs.editorTabStore.isDirty(tabs[9].id), false);
assert.equal(titles.at(-1), 'file-9.txt');
// Dock completion must not steal focus from a tab selected while the
// acknowledgement was pending; stable callbacks read the latest selection.
await act(async () => { void panes.get('file-9.txt')!.onDockToMain?.(); });
assert.equal(docked.length, 1);
await act(async () => activate?.({ editorId: tabs[0].id }));
await act(async () => finishDock?.({ success: true }));
assert.equal(editorTabs.editorTabStore.getTab(tabs[9].id), undefined);
assert.equal(titles.at(-1), 'file-0.txt');
await act(async () => panes.get('file-0.txt')!.onRequestClose?.());
assert.equal(editorTabs.editorTabStore.getTab(tabs[0].id), undefined);
assert.equal(titles.at(-1), 'file-8.txt');
} finally {
await act(async () => {
renderer?.unmount();
for (const tab of tabs) editorTabs.editorTabStore.close(tab.id);
});
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
else Reflect.deleteProperty(globalThis, 'window');
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
}
});
+30 -51
View File
@@ -84,7 +84,7 @@ function EditorWindowControls({ onClose }: { onClose: () => void }) {
);
}
const EditorWindowPaneHost = React.memo(function EditorWindowPaneHost({
function EditorWindowPaneHost({
tabId,
isVisible,
settings,
@@ -130,16 +130,6 @@ const EditorWindowPaneHost = React.memo(function EditorWindowPaneHost({
}
}, [tabId, t]);
const handleLanguageChange = useCallback((language: string) => {
editorTabStore.setLanguage(tabId, language);
}, [tabId]);
const handleToggleWordWrap = useCallback(() => {
const current = editorTabStore.getTab(tabId);
if (current) editorTabStore.setWordWrap(tabId, !current.wordWrap);
}, [tabId]);
const handleRequestClose = useCallback(() => onRequestClose(tabId), [onRequestClose, tabId]);
const handleDock = useCallback(() => onDock(tabId), [onDock, tabId]);
if (!tab) return null;
const isDirty = tabIsDirty(tab);
const hostLabel = tab.hostLabel || tab.hostId;
@@ -161,35 +151,18 @@ const EditorWindowPaneHost = React.memo(function EditorWindowPaneHost({
hotkeyScheme={settings.hotkeyScheme}
keyBindings={settings.keyBindings}
onContentChange={handleContentChange}
onLanguageChange={handleLanguageChange}
onToggleWordWrap={handleToggleWordWrap}
onSave={handleSave}
onRequestClose={handleRequestClose}
onDockToMain={handleDock}
onLanguageChange={(lang) => editorTabStore.setLanguage(tabId, lang)}
onToggleWordWrap={() => {
const current = editorTabStore.getTab(tabId);
if (current) editorTabStore.setWordWrap(tabId, !current.wordWrap);
}}
onSave={() => { void handleSave(); }}
onRequestClose={() => onRequestClose(tabId)}
onDockToMain={() => onDock(tabId)}
initialViewState={tab.viewState}
/>
</div>
);
});
/** Keep content/dirty notifications out of the page and inactive editor panes. */
function EditorWindowTitle({
activeTabId,
setWindowTitle,
}: {
activeTabId: EditorTabId | null;
setWindowTitle: (title: string) => Promise<void>;
}) {
const { t } = useI18n();
const activeTab = useEditorTab(activeTabId ?? "");
const activeDirty = useEditorTabDirty(activeTabId ?? "");
useEffect(() => {
const name = activeTab?.fileName;
void setWindowTitle(name
? `${activeDirty ? "* " : ""}${name}`
: t("sftp.editor.windowTitle"));
}, [activeDirty, activeTab?.fileName, setWindowTitle, t]);
return null;
}
function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
@@ -198,12 +171,23 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
const { onWindowCommandCloseRequested } = useWindowControls();
const tabs = useEditorTabChromeList();
const closeInFlightRef = useRef<Promise<boolean> | null>(null);
const activeTab = useEditorTab(activeTabId ?? "");
const activeDirty = useEditorTabDirty(activeTabId ?? "");
useEffect(() => {
if (activeTabId && editorTabStore.getTab(activeTabId)) return;
if (tabs.length > 0) setActiveTabId(tabs[tabs.length - 1].id);
}, [activeTabId, setActiveTabId, tabs]);
useEffect(() => {
const name = activeTab?.fileName;
if (!name) {
void setWindowTitle(t("sftp.editor.windowTitle"));
return;
}
void setWindowTitle(`${activeDirty ? "* " : ""}${name}`);
}, [activeDirty, activeTab?.fileName, setWindowTitle, t]);
const saveTab = useCallback(async (tabId: EditorTabId): Promise<boolean> => {
const result = await saveDetachedEditorTab(tabId);
if (result.ok) return true;
@@ -237,16 +221,16 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
await closeWindow();
return true;
}
setActiveTabId((current) => current === tabId
? remaining[remaining.length - 1]?.id ?? null
: current);
if (activeTabId === tabId) {
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
}
return true;
})().finally(() => {
closeInFlightRef.current = null;
});
closeInFlightRef.current = task;
return task;
}, [closeTab, closeWindow, setActiveTabId]);
}, [activeTabId, closeTab, closeWindow, setActiveTabId]);
const handleDock = useCallback(async (tabId: EditorTabId) => {
const result = await dockDetachedEditorTab(tabId);
@@ -260,14 +244,10 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
await closeWindow();
return;
}
setActiveTabId((current) => current === tabId
? remaining[remaining.length - 1]?.id ?? null
: current);
}, [closeWindow, setActiveTabId, t]);
const handleRequestClose = useCallback((tabId: EditorTabId) => {
void closeTabAndMaybeWindow(tabId);
}, [closeTabAndMaybeWindow]);
if (activeTabId === tabId) {
setActiveTabId(remaining[remaining.length - 1]?.id ?? null);
}
}, [activeTabId, closeWindow, setActiveTabId, t]);
const handleWindowClose = useCallback(async () => {
// Native close owns confirmation and blocks new transfers until it settles.
@@ -316,7 +296,6 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
return (
<div className="flex h-screen flex-col bg-background text-foreground">
<EditorWindowTitle activeTabId={activeTabId} setWindowTitle={setWindowTitle} />
<div className="flex h-10 shrink-0 items-center border-b border-border/60 app-drag">
{isMac ? <div className="w-[72px] shrink-0" /> : null}
<div className="px-3 text-xs font-semibold text-muted-foreground app-no-drag">
@@ -342,8 +321,8 @@ function EditorWindowPageInner({ settings }: { settings: SettingsState }) {
tabId={tab.id}
isVisible={tab.id === activeTabId}
settings={settings}
onRequestClose={handleRequestClose}
onDock={handleDock}
onRequestClose={(id) => { void closeTabAndMaybeWindow(id); }}
onDock={(id) => { void handleDock(id); }}
/>
))
)}
@@ -1,82 +0,0 @@
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>;
}
@@ -1,41 +0,0 @@
import React from 'react';
import { File, FolderOpen, MoreHorizontal, X } from 'lucide-react';
import type { HostBrowserDownload } from '../../domain/hostBrowser';
import type { useHostBrowserControls } from '../../application/state/useHostBrowserControls';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Button } from '../ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
function size(bytes: number) {
const units = ['B', 'KB', 'MB', 'GB'];
const index = bytes > 0 ? Math.min(3, Math.floor(Math.log(bytes) / Math.log(1024))) : 0;
return `${(bytes / 1024 ** index).toFixed(index ? 1 : 0)} ${units[index]}`;
}
export function BrowserDownloads({ items, command }: { items: HostBrowserDownload[]; command: ReturnType<typeof useHostBrowserControls>['downloadCommand'] }) {
const { t } = useI18n();
return <div className="min-w-0">
<div className="flex items-center justify-between gap-2 border-b border-border/50 px-3 py-2"><div className="min-w-0"><p className="text-xs font-medium">{t('terminal.browser.downloads')}</p><p className="mt-1 text-[11px] text-muted-foreground">{t('terminal.browser.downloadLocation')}</p></div><Button size="icon" variant="ghost" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.openFolder')} title={t('terminal.browser.openFolder')} onClick={() => void command('open-folder')}><FolderOpen size={14} /></Button></div>
<div className="max-h-80 overflow-y-auto p-1">
{!items.length && <p className="px-3 py-8 text-center text-xs text-muted-foreground">{t('terminal.browser.downloadEmpty')}</p>}
{items.map((item) => {
const running = item.status === 'waiting' || item.status === 'progressing';
const canOpen = item.status === 'completed' && !item.missing;
return <div key={item.id} data-download-id={item.id} className="flex min-w-0 items-center gap-2 rounded-md px-2 py-2 hover:bg-secondary/50">
<File size={16} className="shrink-0 text-muted-foreground" /><div className="min-w-0 flex-1"><p className="truncate text-xs" title={item.filename}>{item.filename}</p><p className="mt-0.5 text-[11px] text-muted-foreground">{t(`terminal.browser.${item.missing ? 'missing' : item.status}`)}{item.status === 'progressing' ? ` · ${size(item.received)}${item.total ? ` / ${size(item.total)}` : ''}` : item.status === 'completed' ? ` · ${size(item.total || item.received)}` : ''}</p>
{item.status === 'progressing' && <div className="mt-1 h-1 overflow-hidden rounded bg-secondary"><div className={`h-full bg-primary${item.total ? '' : ' animate-pulse'}`} style={{ width: `${item.total ? Math.min(100, item.received * 100 / item.total) : 20}%` }} /></div>}</div>
{running ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.cancelDownload')} title={t('terminal.browser.cancelDownload')} onClick={() => void command('cancel', item.id)}><X size={13} /></Button>
: canOpen ? <Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" title={t('terminal.browser.reveal')} onClick={() => void command('reveal', item.id)}><FolderOpen size={13} /></Button> : null}
<Popover><PopoverTrigger asChild><Button variant="ghost" size="icon" className="h-6 w-6 shrink-0" aria-label={t('terminal.browser.more')}><MoreHorizontal size={13} /></Button></PopoverTrigger><PopoverContent align="end" className="w-40 p-1">
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('open', item.id)}>{t('terminal.browser.openFile')}</Button>}
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('save-as', item.id)}>{t('terminal.browser.saveAs')}</Button>}
{canOpen && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('reveal', item.id)}>{t('terminal.browser.reveal')}</Button>}
{!running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('remove', item.id)}>{t('terminal.browser.removeDownload')}</Button>}
{running && <Button variant="ghost" size="sm" className="w-full justify-start text-xs" onClick={() => void command('cancel', item.id)}>{t('terminal.browser.cancelDownload')}</Button>}
</PopoverContent></Popover>
</div>;
})}
</div>
{items.some((item) => !['waiting', 'progressing'].includes(item.status)) && <div className="border-t border-border/50 p-1"><Button variant="ghost" size="sm" className="w-full text-xs" onClick={() => void command('clear')}>{t('terminal.browser.clearDownloads')}</Button></div>}
</div>;
}
-12
View File
@@ -1,12 +0,0 @@
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>;
}
@@ -1,103 +0,0 @@
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>;
}
+105 -99
View File
@@ -1,17 +1,19 @@
/** Standard and hardware-backed SSH key generation. */
import { Eye, EyeOff, Loader2, Usb } from 'lucide-react';
/**
* Generate Key Panel - Standard SSH Key generation form
*/
import { Eye, EyeOff } from 'lucide-react';
import React from 'react';
import { useI18n } from '../../application/i18n/I18nProvider';
import { cn } from '../../lib/utils';
import { KeyType, SSHKey } from '../../types';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from '../ui/select';
import { Switch } from '../ui/switch';
interface GenerateStandardPanelProps {
draftKey: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean };
setDraftKey: (key: Partial<SSHKey> & { resident?: boolean; verifyRequired?: boolean }) => void;
draftKey: Partial<SSHKey>;
setDraftKey: (key: Partial<SSHKey>) => void;
showPassphrase: boolean;
setShowPassphrase: (show: boolean) => void;
isGenerating: boolean;
@@ -19,115 +21,119 @@ interface GenerateStandardPanelProps {
}
export const GenerateStandardPanel: React.FC<GenerateStandardPanelProps> = ({
draftKey, setDraftKey, showPassphrase, setShowPassphrase, isGenerating, onGenerate,
draftKey,
setDraftKey,
showPassphrase,
setShowPassphrase,
isGenerating,
onGenerate,
}) => {
const { t } = useI18n();
const isFido = draftKey.type === 'ED25519-SK' || draftKey.type === 'ECDSA-SK';
const selectType = (value: string) => {
const type = value as KeyType;
setDraftKey({ ...draftKey, type, keySize: type === 'RSA' ? 4096 : type === 'ECDSA' ? 256 : undefined });
};
return (
<div className="space-y-5" data-section="keychain-generate-form">
<>
<div className="space-y-2">
<Label htmlFor="generate-key-label">{t('keychain.field.label')}</Label>
<Input id="generate-key-label" value={draftKey.label || ''}
<Label>{t('keychain.field.label')}</Label>
<Input
value={draftKey.label || ''}
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
placeholder={t('keychain.generate.labelPlaceholder')} disabled={isGenerating} />
placeholder={t('keychain.generate.labelPlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="generate-key-type">{t('keychain.generate.keyType')}</Label>
<Select value={draftKey.type || 'ED25519'} onValueChange={selectType} disabled={isGenerating}>
<SelectTrigger id="generate-key-type"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>{t('keychain.generate.standardKeys')}</SelectLabel>
{['ED25519', 'ECDSA', 'RSA'].map(type => <SelectItem key={type} value={type}>{type}</SelectItem>)}
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>{t('keychain.generate.securityKeys')}</SelectLabel>
<SelectItem value="ED25519-SK">ED25519-SK</SelectItem>
<SelectItem value="ECDSA-SK">ECDSA-SK</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
<Label>{t('keychain.generate.keyType')}</Label>
<div className="flex gap-2">
{(['ED25519', 'ECDSA', 'RSA'] as KeyType[]).map((t) => (
<Button
key={t}
variant={draftKey.type === t ? 'secondary' : 'ghost'}
className={cn(
"flex-1 h-10",
draftKey.type === t && "bg-primary/15 text-primary"
)}
onClick={() => {
// Set default keySize based on type
const defaultSize = t === 'ED25519' ? undefined : (t === 'RSA' ? 4096 : 256);
setDraftKey({ ...draftKey, type: t, keySize: defaultSize });
}}
>
{t}
</Button>
))}
</div>
</div>
{/* Key Size selector - only for RSA and ECDSA */}
{(draftKey.type === 'RSA' || draftKey.type === 'ECDSA') && (
<div className="space-y-2 fido-field-stage" key="key-size">
<Label htmlFor="generate-key-size">{t('keychain.generate.keySize')}</Label>
<Select value={String(draftKey.keySize || (draftKey.type === 'RSA' ? 4096 : 256))}
onValueChange={value => setDraftKey({ ...draftKey, keySize: Number(value) })} disabled={isGenerating}>
<SelectTrigger id="generate-key-size"><SelectValue /></SelectTrigger>
<SelectContent>
{(draftKey.type === 'RSA' ? [4096, 2048, 1024] : [256, 384, 521]).map(size => (
<SelectItem key={size} value={String(size)}>{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{isFido ? (
<div key="hardware" className="fido-field-stage fido-key-options space-y-4">
<div className="flex gap-2.5 rounded-lg bg-muted/40 px-3 py-3 text-xs leading-relaxed text-muted-foreground">
<Usb size={15} className="mt-0.5 shrink-0" aria-hidden="true" />
<p>{t('keychain.generate.fidoHint')}</p>
</div>
<div className="divide-y divide-border/50 border-y border-border/50">
<div className="flex items-center justify-between gap-4 py-3.5">
<div className="min-w-0 space-y-1">
<Label id="fido-resident-label" className="text-sm font-medium">{t('keychain.generate.resident')}</Label>
<p id="fido-resident-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.residentDesc')}</p>
</div>
<Switch id="fido-resident" checked={!!draftKey.resident} disabled={isGenerating}
aria-labelledby="fido-resident-label" aria-describedby="fido-resident-description"
onCheckedChange={resident => setDraftKey({ ...draftKey, resident })} />
</div>
<div className="flex items-center justify-between gap-4 py-3.5">
<div className="min-w-0 space-y-1">
<Label id="fido-verify-label" className="text-sm font-medium">{t('keychain.generate.verifyRequired')}</Label>
<p id="fido-verify-description" className="text-xs leading-relaxed text-muted-foreground">{t('keychain.generate.verifyRequiredDesc')}</p>
</div>
<Switch id="fido-verify-required" checked={!!draftKey.verifyRequired} disabled={isGenerating}
aria-labelledby="fido-verify-label" aria-describedby="fido-verify-description"
onCheckedChange={verifyRequired => setDraftKey({ ...draftKey, verifyRequired })} />
</div>
</div>
</div>
) : (
<div key="software" className="fido-field-stage space-y-4">
<div className="space-y-2">
<Label htmlFor="generate-key-passphrase">{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input id="generate-key-passphrase" type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''} disabled={isGenerating}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')} className="pr-10" />
<Button type="button" variant="ghost" size="icon" disabled={isGenerating}
aria-label={t(showPassphrase ? 'hostDetails.password.hide' : 'hostDetails.password.show')}
className="absolute right-1 top-1/2 h-8 w-8 -translate-y-1/2 text-muted-foreground"
onClick={() => setShowPassphrase(!showPassphrase)}>
{showPassphrase ? <EyeOff size={15} /> : <Eye size={15} />}
<div className="space-y-2">
<Label>{t('keychain.generate.keySize')}</Label>
<div className="flex gap-2">
{(draftKey.type === 'RSA'
? [4096, 2048, 1024]
: [256, 384, 521]
).map((size) => (
<Button
key={size}
variant={draftKey.keySize === size ? 'secondary' : 'ghost'}
className={cn(
"flex-1 h-10",
draftKey.keySize === size && "bg-primary/15 text-primary"
)}
onClick={() => setDraftKey({ ...draftKey, keySize: size })}
>
{draftKey.type === 'RSA' ? `${size} bits` : `P-${size}`}
</Button>
</div>
</div>
<div className="flex items-center justify-between gap-4">
<Label id="generate-save-passphrase">{t('keychain.generate.savePassphrase')}</Label>
<Switch checked={!!draftKey.savePassphrase} disabled={isGenerating}
aria-labelledby="generate-save-passphrase"
onCheckedChange={savePassphrase => setDraftKey({ ...draftKey, savePassphrase })} />
))}
</div>
</div>
)}
<Button className="h-10 w-full gap-2" onClick={onGenerate} disabled={isGenerating || !draftKey.label?.trim()}>
{isGenerating && <Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />}
{t(isGenerating ? 'keychain.generate.generating' : 'keychain.generate.generateSave')}
<div className="space-y-2">
<Label>{t('terminal.auth.passphrase')}</Label>
<div className="relative">
<Input
type={showPassphrase ? 'text' : 'password'}
value={draftKey.passphrase || ''}
onChange={e => setDraftKey({ ...draftKey, passphrase: e.target.value })}
placeholder={t('keychain.generate.passphrasePlaceholder')}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2 h-8 w-8"
onClick={() => setShowPassphrase(!showPassphrase)}
>
{showPassphrase ? <EyeOff size={14} /> : <Eye size={14} />}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="savePassphrase"
checked={draftKey.savePassphrase || false}
onChange={e => setDraftKey({ ...draftKey, savePassphrase: e.target.checked })}
className="h-4 w-4 rounded border-border"
/>
<Label htmlFor="savePassphrase" className="text-sm font-normal cursor-pointer">
{t('keychain.generate.savePassphrase')}
</Label>
</div>
<Button
className="w-full h-11"
onClick={onGenerate}
disabled={isGenerating || !draftKey.label?.trim()}
>
{isGenerating ? (
<div className="h-4 w-4 border-2 border-primary-foreground/30 border-t-primary-foreground rounded-full animate-spin" />
) : (
t('keychain.generate.generateSave')
)}
</Button>
</div>
</>
);
};
-61
View File
@@ -1,61 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { SK_ECDSA_NISTP256, SK_SSH_ED25519 } from "../../domain/fidoSsh.ts";
import { detectKeyType, resolveImportedKeyType } from "./utils.ts";
/** Real OpenSSH shape: algorithm only after base64 decode. */
function makeSkPrivatePem(algo: string): string {
const body = Buffer.from(
`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`,
).toString("base64");
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
assert.equal(pem.includes("@openssh.com"), false);
return pem;
}
test("resolveImportedKeyType prefers FIDO detection over seeded ED25519 draft", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
// Simulate openImport seed + paste import (handleImport path).
const seededDraftType = "ED25519" as const;
const detected = resolveImportedKeyType({ privateKey: pem });
assert.equal(detected, "ED25519-SK");
// Product rule: material wins; never keep seeded ED25519 when SK is detected.
const savedType = detected; // same as fixed handleImport
assert.notEqual(savedType, seededDraftType);
assert.equal(savedType, "ED25519-SK");
});
test("resolveImportedKeyType distinguishes ECDSA-SK from base64-only PEM", () => {
assert.equal(
resolveImportedKeyType({ privateKey: makeSkPrivatePem(SK_ECDSA_NISTP256) }),
"ECDSA-SK",
);
});
test("detectKeyType uses the same material path for file drop", () => {
assert.equal(detectKeyType(makeSkPrivatePem(SK_SSH_ED25519)), "ED25519-SK");
assert.equal(detectKeyType("-----BEGIN RSA PRIVATE KEY-----\nMII\n-----END RSA PRIVATE KEY-----"), "RSA");
});
test("resolveImportedKeyType keeps soft keys", () => {
assert.equal(
resolveImportedKeyType({
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----",
}),
"ED25519",
);
});
test("KeychainManager handleImport uses material type not seeded draft type", () => {
const source = readFileSync(join(import.meta.dirname, "../KeychainManager.tsx"), "utf8");
// Must resolve from material; must not keep draftKey.type first (openImport seeds ED25519).
assert.match(source, /detectSshKeyType/);
assert.doesNotMatch(
source,
/type:\s*\(draftKey\.type as KeyType\)\s*\|\|\s*detectedType/,
);
assert.match(source, /type:\s*detected.type/);
});
+3 -17
View File
@@ -4,7 +4,6 @@
import { BadgeCheck, Key } from 'lucide-react';
import React from 'react';
import { detectFidoSshKeyType } from '../../domain/fidoSsh';
import { logger } from '../../lib/logger';
import { detectSshKeyType } from '../../domain/keyTypeDetect';
import { KeyType, SSHKey } from '../../types';
@@ -26,25 +25,12 @@ export const getKeyTypeDisplay = (key: SSHKey, isMac: boolean): string => {
};
/**
* Resolve vault KeyType from imported key material.
*
* Always prefer material detection over the import form's seeded default
* (`openImport` seeds `type: "ED25519"`). Real OpenSSH sk private PEMs only
* expose sk-* algorithms after base64 decode — handled by detectFidoSshKeyType.
* Detect key type from private key content
*/
export const resolveImportedKeyType = (args: {
privateKey: string;
publicKey?: string;
}): KeyType => {
return detectFidoSshKeyType(args) || detectSshKeyType(args.privateKey, args.publicKey).type;
export const detectKeyType = (privateKey: string): KeyType => {
return detectSshKeyType(privateKey).type;
};
/**
* Detect key type from private key content (file drop / paste helpers).
*/
export const detectKeyType = (privateKey: string): KeyType =>
resolveImportedKeyType({ privateKey });
/**
* Copy text to clipboard
*/
@@ -37,7 +37,6 @@ import {
sanitizeCredentialValue,
} from "../../../domain/credentials";
import { resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
import { requiresFidoSshAgentAuth } from "../../../domain/fidoSsh";
import {
hostRestrictsExtraSshChannels,
resolveHostKeepalive,
@@ -112,32 +111,6 @@ const createPluginConnectionRequestId = (): string => {
return `plugin-connection-${randomId}`.slice(0, 128);
};
/**
* Private key material for the bridge.
* Soft keys: PEM/OpenSSH private key for ssh2.
* FIDO2 SK: still send the OpenSSH key *handle* so the main process can
* ssh-add it into the system agent (signing stays on the hardware).
* Reference keys: path-only (no inline material).
*/
const resolveBridgePrivateKeyMaterial = (
key: SSHKey | undefined | null,
options?: { forceAgent?: boolean },
): string | undefined => {
if (!key || key.source === "reference") return undefined;
// Soft-key agent mode still omits inline privateKey when the agent will sign.
if (
options?.forceAgent
&& !requiresFidoSshAgentAuth({
type: key.type,
publicKey: key.publicKey,
privateKey: key.privateKey,
})
) {
return undefined;
}
return sanitizeCredentialValue(key.privateKey) || undefined;
};
const formatPluginDiagnosticLines = (
diagnostics: ReadonlyArray<ProviderValidationIssue> | undefined,
): string[] => (diagnostics ?? [])
@@ -478,9 +451,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
authMethod: jumpAuth.authMethod,
requiresMfa: !!jumpHost.requiresMfa,
password: jumpPassword,
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
}),
privateKey: jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
certificate: jumpKey?.certificate,
passphrase: jumpPassphrase,
publicKey: jumpKey?.publicKey,
@@ -708,11 +679,10 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
sftpReuseOptions: sftpReuseOptions?.password || sftpReuseOptions?.username !== effectiveUsername
? undefined
: sftpReuseOptions,
privateKey: resolveBridgePrivateKeyMaterial(attempt.key),
privateKey: attempt.key?.source === 'reference' ? undefined : (sanitizeCredentialValue(attempt.key?.privateKey) || undefined),
certificate: attempt.key?.certificate,
publicKey: attempt.key?.publicKey,
keyId: attempt.key?.id,
keyLabel: attempt.key?.label,
keySource: attempt.key?.source,
passphrase: attempt.key
? (effectivePassphrase || sanitizeCredentialValue(attempt.key.passphrase))
@@ -885,10 +855,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
ctx.setIsConnectionAwaitingUserInput?.(false);
ctx.setIsConnectionPastTcpDial?.(false);
const rawMessage = err instanceof Error ? err.message : String(err);
const message = rawMessage.includes("Security key authentication cancelled")
? tr("fido.prompt.cancelled", "Security key authentication cancelled.")
: rawMessage;
const message = err instanceof Error ? err.message : String(err);
const authError = isAuthError(err);
if (isJumpHostAuthError(err, message)) {
@@ -1302,9 +1269,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
authMethod,
requiresMfa: !!ctx.host.requiresMfa,
password: effectivePassword,
privateKey: resolveBridgePrivateKeyMaterial(key, {
forceAgent: usesSystemAgent && !key?.certificate,
}),
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
@@ -1598,9 +1563,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
username: jumpAuth.username || "root",
authMethod: jumpAuth.authMethod,
password: jumpPassword,
privateKey: resolveBridgePrivateKeyMaterial(jumpKey, {
forceAgent: jumpAgentAuth.useSshAgent === true && !jumpKey?.certificate,
}),
privateKey: (jumpAgentAuth.useSshAgent && !jumpKey?.certificate) || jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
certificate: jumpKey?.certificate,
passphrase: jumpAgentAuth.useSshAgent && !jumpKey?.certificate ? undefined : jumpPassphrase,
keyId: jumpAuth.keyId,
@@ -1639,9 +1602,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
hostId: ctx.host.id,
username: resolvedAuth.username || "root",
password: effectivePassword,
privateKey: resolveBridgePrivateKeyMaterial(key, {
forceAgent: usesSystemAgent && !key?.certificate,
}),
privateKey: (usesSystemAgent && !key?.certificate) || key?.source === 'reference' ? undefined : (sanitizeCredentialValue(key?.privateKey) || undefined),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key && (!usesSystemAgent || Boolean(key.certificate))
+1 -7
View File
@@ -145,16 +145,10 @@ export const AsidePanelHeader: React.FC<AsidePanelHeaderProps> = ({
};
// Content Component (wraps children with scroll)
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string; scrollMode?: "custom" | "native" }> = ({
export const AsidePanelContent: React.FC<{ children: ReactNode; className?: string }> = ({
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">
-275
View File
@@ -1,275 +0,0 @@
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>
);
}
-129
View File
@@ -1,129 +0,0 @@
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>
);
}
+1 -21
View File
@@ -10,26 +10,6 @@ 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 => ({
@@ -181,7 +161,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("[data-monitor-cell=network]")!;
const network = document.querySelector("tbody tr")!.children[5];
assert.deepEqual([...network.querySelectorAll(".sr-only")].map((e) => e.textContent), ["Download: ", "Upload: "]);
}
});
+259 -368
View File
@@ -6,7 +6,6 @@ import {
Copy,
Cpu,
Gauge,
Gpu,
HardDrive,
LayoutGrid,
List,
@@ -39,7 +38,6 @@ 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";
@@ -72,9 +70,6 @@ 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) =>
@@ -219,15 +214,22 @@ 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({
@@ -303,39 +305,28 @@ 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) =>
(hostById.get(a)?.label ?? a).localeCompare(
hostById.get(b)?.label ?? b,
),
);
}, [panel, hostById]);
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]);
const rows = useMemo(() => {
if (!panel) return [];
const q = search.trim().toLocaleLowerCase();
const rows = orderedHostIds.map((id) => ({
id,
host: hostById.get(id),
host: hosts.find((h) => h.id === id),
state: monitorStateSince(runtime.hosts[id], runtime.startedAt[panel.id]),
}));
return rows.filter(
({ host }) =>
!q ||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""} ${host?.tags.join(" ") ?? ""} ${host?.notes ?? ""}`
({ host }) => !q ||
`${host?.label ?? ""} ${host?.hostname ?? ""} ${host?.group ?? ""}`
.toLocaleLowerCase()
.includes(q),
);
}, [panel, orderedHostIds, hostById, runtime, search]);
const dragProps = (
hostId: string,
): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
}, [panel, orderedHostIds, hosts, runtime, search]);
const dragProps = (hostId: string): React.HTMLAttributes<HTMLElement> & { "data-monitor-host": string } => ({
draggable: true,
"data-monitor-host": hostId,
onDragStart: (event) => {
@@ -345,12 +336,7 @@ 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);
@@ -358,11 +344,10 @@ 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);
},
@@ -377,24 +362,18 @@ 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 min-w-0 max-w-full items-center gap-1.5 whitespace-nowrap text-[10px] shrink-0 font-medium",
"inline-flex 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",
)}
@@ -403,9 +382,7 @@ export function MonitorPanels({
aria-hidden="true"
className="h-1.5 w-1.5 rounded-full bg-current"
/>
<span className="truncate">
{t(`monitor.status.${status(host, state)}`)}
</span>
{t(`monitor.status.${status(host, state)}`)}
</span>
);
const renderMetrics = (
@@ -468,10 +445,6 @@ 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"
@@ -674,76 +647,35 @@ export function MonitorPanels({
</SelectContent>
</Select>
</div>
{!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",
)}
>
<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 }) => (
<button
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"}
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",
)}
onClick={() => {
if (status(host, state) !== "live") return;
setEditor(null);
setDetail(id);
}}
>
<div className="mb-2 flex w-full min-w-0 items-center gap-3 pr-7">
<div className="mb-5 flex items-center gap-3">
{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">
@@ -753,39 +685,20 @@ export function MonitorPanels({
{host?.hostname ?? id}
</p>
</div>
{stateBadge(host, state)}
</div>
<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",
)}
>
<div className={cn(status(host, state) !== "live" && "opacity-60")}>
{renderMetrics(state)}
<div className="mt-3">
<div className="mt-4">
<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-2 grid grid-cols-2 gap-2 text-[11px] tabular-nums">
<div className="mt-3 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
@@ -793,10 +706,7 @@ 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
@@ -805,169 +715,127 @@ export function MonitorPanels({
</span>
</div>
</div>
<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>
<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>
</button>
<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>
) : (
<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>
</>
)}
</div>
@@ -1138,9 +1006,8 @@ export function MonitorPanels({
className={monitorAsideClass}
/>
)}
{detail && detailAvailable && (
{detail && (
<AsidePanel
dataSection="vault-monitor-detail"
open
onClose={() => setDetail(null)}
title={hostDetail?.label ?? t("monitor.missingHost")}
@@ -1152,7 +1019,7 @@ export function MonitorPanels({
width="w-[380px]"
className={monitorAsideClass}
>
<AsidePanelContent scrollMode="native">
<AsidePanelContent>
<div className="flex items-center gap-3">
{hostDetail && <DistroAvatar host={hostDetail} fallback={hostDetail.label} size="lg" />}
<div className="min-w-0 flex-1">
@@ -1160,41 +1027,73 @@ export function MonitorPanels({
{stateBadge(hostDetail, detailState)}
</div>
</div>
{renderMetrics(detailState, true)}
{detailState?.stats && (
detailState.stats.gpu != null ||
detailState.stats.gpuName ||
(detailState.stats.gpuMemTotal ?? 0) > 0
{["login", "error", "disconnected"].includes(
detailState?.status ?? "",
) && (
<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} />
<>
<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>
<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>
<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)}
<p className="text-xs text-muted-foreground">
{t("monitor.observed", {
count: detailState?.points.length
@@ -1289,6 +1188,24 @@ 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
@@ -1302,32 +1219,6 @@ 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)}
-172
View File
@@ -1,172 +0,0 @@
# FIDO2 SSH validation without hardware
Run the real OpenSSH integration simulation on Linux:
```sh
npm run test:fido-sim
```
Prerequisites: Node.js 22+, a C compiler, OpenSSL development headers, and
OpenSSH `ssh-keygen`, `/usr/bin/ssh-agent`, `ssh-add`, `ssh-sk-helper`, and
`/usr/sbin/sshd`. The test runs an unprivileged server bound to a random
localhost port and uses Netcatty's managed temporary directory. It does not
modify system SSH configuration or the user's login agent.
The opt-in test compiles `scripts/fixtures/fido/provider.c` as an OpenSSH SK
provider and exercises the production key generation, askpass and agent helpers.
It covers both ED25519-SK and ECDSA-SK, PIN-required enrollment and signatures,
tokenized IdentityFile references, inline handles, SSH command execution, SFTP,
TCP forwarding, CA certificates under IdentitiesOnly, and simultaneous callers
from separate windows. The independent OpenSSH server verifies the signatures.
The provider is strictly a test fixture: its opaque handles contain disposable
software private keys, and its PIN is always `123456`. It must never be selected
for a real account. Its resident flag support only simulates enrollment; it does
not enumerate stored resident credentials.
The ordinary test suite also exercises PIN/touch helper subprocesses, modal
cancellation, helper-exit cleanup, platform selection, resource release and SK
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 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
test command. The independent Docker/OpenSSH server check above runs on Linux.
This checks a specific **user-mode MSYS2 OpenSSH** configuration. It does not
establish support for every Windows OpenSSH distribution, the Windows system
agent service, Git for Windows, native WebAuthn dialogs, or physical USB devices.
Windows currently has no `ssh-sk-helper` error observer: recovery must retain
the generic verification-failed reason rather than guessing which device error
occurred. The Unix helper's 30-second cancellation deadline also does not apply
to this Windows path.
For a physical-device check of this configuration, launch Netcatty with
`NETCATTY_SSH_AGENT_PATH` and `NETCATTY_SSH_KEYGEN_PATH` pointing to the matching
MSYS2 executables and put that installation's `usr/bin` on `PATH`. Use its real
built-in FIDO provider; **never** set `SSH_SK_PROVIDER` to the CI fixture. Check
the complete application flow against a separate SSH server: select the saved
SK identity on a host, connect, fulfill the actual PIN/touch requests, execute a
command, and repeat with the device absent, removed, and with PIN cancellation.
Record the Windows/OpenSSH/device versions and which dialogs are owned by the
application or OS. A successful software-provider CI run does not replace this
hardware check.
## Docker server and gated user presence
```sh
npm run test:fido-docker
```
Requires Linux, Docker, a C compiler, OpenSSL headers and the OpenSSH client
tools above. Host `sshd` is not required. The command builds a disposable Debian
OpenSSH server, publishes a random port on `127.0.0.1`, and removes its container
and temporary files on completion. The server accepts only public-key login as
the test user `netcatty`. Host keys are generated when the container starts.
Only disposable public authorized keys are mounted into the server; the client
and software provider run on the host through Netcatty's production helpers.
For each of ED25519-SK and ECDSA-SK, both touch-only and PIN-plus-touch modes
are exercised. The fixture's optional `NETCATTY_FIDO_SIM_CONTROL` file gates the
actual signature: the test first asserts that login has not completed, then
simulates physical touch independently of the renderer. No renderer response
is sent for OpenSSH's touch notifications. The test verifies automatic prompt
dismissal, command execution and SFTP, as well as rejection of missing touch,
device removal, wrong/cancelled PIN, and signed responses missing the UP or UV
flag. The latter assertions also check the independent server's rejection log.
A fresh login after each group of failures checks that the agent remains usable.
The fixture accelerates the simulated touch timeout to three seconds; it does
not reproduce device retry counters, PIN lockout or CTAP/USB transport.
## Expected security-key flow
This feature implements **FIDO2 OpenSSH security keys**, not the YubiKey PIV or
OpenPGP applications. The public key must first be installed on the SSH server.
During login, OpenSSH asks the device to sign; normal keys require touch, while
`verify-required` additionally requests user verification (typically the device
PIN). The private key remains on the real hardware; Netcatty stores its handle.
Resident credentials support recovery of handles on another machine, and do not
remove the presence/verification requirement.
OpenSSH's `notify_start()` invokes askpass with `SSH_ASKPASS_PROMPT=none` and
terminates the notification helper when the hardware operation finishes. That
mode is forwarded to the UI as `notificationOnly`: there is no Continue button,
and the dialog closes automatically when the helper exits. Closing this hint
only dismisses the notification; it does not authorize or cancel the hardware
operation. PIN prompts still have explicit Continue and Cancel actions. OpenSSH
can combine PIN and touch instructions into one prompt, so the UI also explains
that the user should touch the device after entering the PIN. The exact prompt
order depends on the OpenSSH/device implementation; the UI follows its requests.
For the agent's combined PIN/presence prompt, the UI keeps a touch-only waiting
hint after PIN submission, tied to the real signing operation's completion.
The feature is vendor-neutral: compatible security keys from other vendors can
be used. Check the device's algorithm support: ECDSA-SK and ED25519-SK are not
universally interchangeable. PIN/resident/biometric support also varies. A
touch indicates physical presence; it does not imply fingerprint recognition.
### Recovery and alternative authentication
On supported Unix OpenSSH installations, Netcatty wraps `ssh-sk-helper` to
observe its framed error reply before `ssh-agent` collapses it into generic
agent failure. The wrapper forwards the request/reply bytes unchanged and never
logs request contents, PINs or signatures. It pairs with the selected OpenSSH
helper, honors an existing `SSH_SK_HELPER`, and lives in the managed temp dir.
Unrecognized helper reply versions retain generic recovery while their bytes
are forwarded unchanged.
If no compatible wrapper is available (including native Windows agents), use
the generic verification-failed message instead of inventing a missing-device
diagnosis. Real Windows/macOS device behavior still requires hardware testing.
| Situation | Behavior |
| --- | --- |
| Matching device unavailable | Show the selected key's label and ask to connect its device; Retry continues the pending login. |
| Waiting for device confirmation | Show a touch/verification hint; the device completes it automatically. Closing the hint only hides it. |
| Device operation exceeds 30 seconds | Stop that helper operation, discard late signatures and offer Retry. PIN entry time is excluded. |
| Device removed while waiting | The helper's device-unavailable result returns to the connection/retry prompt. |
| PIN rejected | Explain that the device PIN was not accepted; require an explicit retry, without guessing remaining attempts. |
| Other helper failure | Offer generic recovery; do not claim the device is missing. |
| Saved password available | Offer an explicit "Use configured password" action after a failed device operation. Cancel does not select the password. |
| Device removed after SSH login | The established SSH session continues; the device signs authentication, not each terminal command. |
The configured SSH authentication deadline still bounds the pending connection.
Device recovery also closes when its owning session/window is torn down. A
lost prompt window or explicit cancellation fails closed; late results cannot
authorize the cancelled attempt.
The Docker suite accelerates the helper deadline to 1.5 seconds and verifies
both key algorithms, device insertion/retry, removal, timeout, incorrect PIN,
unknown/unsupported failures, explicit password fallback and cancellation with
a valid saved password. UI recordings shorten the deadline to 4.5 seconds and
use the complete host -> key selection -> production SSH bridge -> Docker shell
path; only the Electron transport and physical authenticator are simulated.
References:
- [Yubico: Securing SSH with FIDO2](https://developers.yubico.com/SSH/Securing_SSH_with_FIDO2.html)
- [OpenSSH notify_start/notify_complete](https://github.com/openssh/openssh-portable/blob/V_10_0_P2/readpass.c)
- [OpenSSH agent security-key signing](https://github.com/openssh/openssh-portable/blob/V_10_0_P2/ssh-agent.c)
@@ -1,63 +0,0 @@
# 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.
@@ -1,955 +0,0 @@
{
"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": []
}
]
}
-88
View File
@@ -1,88 +0,0 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
SK_ECDSA_NISTP256,
SK_SSH_ED25519,
decodeOpenSshPrivateKeyBody,
detectFidoSshKeyType,
extractOpenSshPublicKeyType,
isSkPrivateKey,
isSkPublicKey,
requiresFidoSshAgentAuth,
} from "./fidoSsh.ts";
const skEdPub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= user@fido";
const skEcdsaPub =
"sk-ecdsa-sha2-nistp256@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTZAb3BlbnNzaC5jb20AAAAIbmlzdHAyNTYAAABBBAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= user@fido";
test("extractOpenSshPublicKeyType recognizes sk algorithms", () => {
assert.equal(extractOpenSshPublicKeyType(skEdPub), SK_SSH_ED25519);
assert.equal(extractOpenSshPublicKeyType(skEcdsaPub), SK_ECDSA_NISTP256);
assert.equal(extractOpenSshPublicKeyType("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test"), "ssh-ed25519");
assert.equal(isSkPublicKey(skEdPub), true);
assert.equal(isSkPublicKey("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test"), false);
});
test("extractOpenSshPublicKeyType recognizes OpenSSH sk certificate algorithms", () => {
const skEdCert =
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA user@fido";
const skEcdsaCert =
"sk-ecdsa-sha2-nistp256-cert-v01@openssh.com AAAAInNrLWVjZHNhLXNoYTItbmlzdHAyNTYtY2VydC12MDFAb3BlbnNzaC5jb20AAAA user@fido";
assert.equal(extractOpenSshPublicKeyType(skEdCert), SK_SSH_ED25519);
assert.equal(extractOpenSshPublicKeyType(skEcdsaCert), SK_ECDSA_NISTP256);
assert.equal(isSkPublicKey(skEdCert), true);
assert.equal(detectFidoSshKeyType({ publicKey: skEdCert }), "ED25519-SK");
assert.equal(requiresFidoSshAgentAuth({ publicKey: skEcdsaCert }), true);
});
test("detectFidoSshKeyType maps vault and OpenSSH types", () => {
assert.equal(detectFidoSshKeyType({ type: "ED25519-SK" }), "ED25519-SK");
assert.equal(detectFidoSshKeyType({ publicKey: skEdPub }), "ED25519-SK");
assert.equal(detectFidoSshKeyType({ publicKey: skEcdsaPub }), "ECDSA-SK");
assert.equal(detectFidoSshKeyType({ type: "ED25519", publicKey: "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJust test" }), undefined);
});
/** Real-shaped PEM: sk type only appears after base64 decode (`@` not in b64 alphabet). */
function makeSkPrivatePem(algo: string): string {
const raw = Buffer.from(`openssh-key-v1\0\0\0\0\0none\0\0\0\0\0\0\0\0\0\x01${algo}`);
const body = raw.toString("base64");
// Guard: the PEM text must NOT contain the plain algorithm (skeptic regression).
const pem = `-----BEGIN OPENSSH PRIVATE KEY-----\n${body}\n-----END OPENSSH PRIVATE KEY-----\n`;
assert.equal(pem.includes("@openssh.com"), false);
return pem;
}
test("isSkPrivateKey detects OpenSSH sk key handles", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
assert.equal(isSkPrivateKey(pem), true);
assert.equal(isSkPrivateKey("-----BEGIN OPENSSH PRIVATE KEY-----\nAAAA\n-----END OPENSSH PRIVATE KEY-----"), false);
assert.equal(requiresFidoSshAgentAuth({ publicKey: skEdPub }), true);
assert.equal(requiresFidoSshAgentAuth({ privateKey: pem }), true);
assert.equal(requiresFidoSshAgentAuth({ type: "ED25519", privateKey: "soft-key" }), false);
});
test("detectFidoSshKeyType reads sk algorithm from base64-only private PEM", () => {
const edPem = makeSkPrivatePem(SK_SSH_ED25519);
const ecPem = makeSkPrivatePem(SK_ECDSA_NISTP256);
assert.equal(detectFidoSshKeyType({ privateKey: edPem }), "ED25519-SK");
assert.equal(detectFidoSshKeyType({ privateKey: ecPem }), "ECDSA-SK");
});
test("decodeOpenSshPrivateKeyBody works without Node Buffer (renderer)", () => {
const pem = makeSkPrivatePem(SK_SSH_ED25519);
const originalBuffer = globalThis.Buffer;
// @ts-expect-error intentional renderer simulation
delete globalThis.Buffer;
try {
assert.equal(typeof globalThis.Buffer, "undefined");
const decoded = decodeOpenSshPrivateKeyBody(pem);
assert.ok(decoded);
assert.equal(decoded.includes(SK_SSH_ED25519), true);
assert.equal(detectFidoSshKeyType({ privateKey: pem }), "ED25519-SK");
} finally {
globalThis.Buffer = originalBuffer;
}
});
-137
View File
@@ -1,137 +0,0 @@
/**
* OpenSSH FIDO2 / U2F security-key (sk-*) helpers.
*
* Netcatty does not drive CTAP itself. SK keys authenticate through the system
* OpenSSH agent + ssh-sk-helper (libfido2). These helpers detect sk material so
* the app can force the agent path instead of software privateKey signing.
*/
export const SK_SSH_ED25519 = "sk-ssh-ed25519@openssh.com";
export const SK_ECDSA_NISTP256 = "sk-ecdsa-sha2-nistp256@openssh.com";
export type SkPublicKeyType = typeof SK_SSH_ED25519 | typeof SK_ECDSA_NISTP256;
export type FidoSshKeyType = "ED25519-SK" | "ECDSA-SK";
const SK_PUBLIC_KEY_TYPES = new Set<string>([SK_SSH_ED25519, SK_ECDSA_NISTP256]);
const OPENSSH_PRIVATE_KEY_RE =
/-----BEGIN OPENSSH PRIVATE KEY-----([\s\S]+?)-----END OPENSSH PRIVATE KEY-----/;
export const isSkPublicKeyType = (type: string | undefined | null): type is SkPublicKeyType =>
typeof type === "string" && SK_PUBLIC_KEY_TYPES.has(type.trim());
export const isFidoSshVaultKeyType = (
type: string | undefined | null,
): type is FidoSshKeyType => type === "ED25519-SK" || type === "ECDSA-SK";
/** Parse the algorithm field from an OpenSSH authorized_keys / .pub line. */
export const extractOpenSshPublicKeyType = (
publicKey: string | undefined | null,
): string | undefined => {
if (typeof publicKey !== "string") return undefined;
const line = publicKey
.split(/\r?\n/)
.map((entry) => entry.trim())
.find((entry) => entry && !entry.startsWith("#"));
if (!line) return undefined;
// OpenSSH certificate algorithms embed `-cert-v01` *before* `@openssh.com`
// (e.g. `sk-ssh-ed25519-cert-v01@openssh.com`), not after the RP id.
const skCert = /^(sk-(?:ssh-ed25519|ecdsa-sha2-nistp256))-cert-v0[01]@openssh\.com\s+/.exec(line);
if (skCert) return `${skCert[1]}@openssh.com`;
const match = /^(sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com|ssh-(?:rsa|ed25519|dss)|ecdsa-sha2-nistp(?:256|384|521))(?:-cert-v0[01]@openssh\.com)?\s+/.exec(line);
return match?.[1];
};
export const isSkPublicKey = (publicKey: string | undefined | null): boolean =>
isSkPublicKeyType(extractOpenSshPublicKeyType(publicKey));
/**
* Renderer-safe base64 decode. Avoids Node `Buffer`, which is unavailable in
* Electron windows with `nodeIntegration: false`.
*/
const decodeBase64ToBinaryString = (payload: string): string | null => {
const normalized = payload.replace(/\s+/g, "");
if (!normalized) return null;
try {
if (typeof atob === "function") {
return atob(normalized);
}
} catch {
// fall through to Node Buffer when present (tests / main)
}
if (typeof Buffer !== "undefined") {
try {
return Buffer.from(normalized, "base64").toString("binary");
} catch {
return null;
}
}
return null;
};
/**
* Decode OpenSSH private-key PEM body (base64) for algorithm probing.
* Real sk-* handles only contain `sk-*-@openssh.com` *inside* the base64 payload
* (`@` is not a base64 alphabet character), so raw-text regex on PEM is insufficient.
*/
export const decodeOpenSshPrivateKeyBody = (
privateKey: string | undefined | null,
): string | null => {
if (typeof privateKey !== "string" || !privateKey.trim()) return null;
const match = OPENSSH_PRIVATE_KEY_RE.exec(privateKey);
if (!match) return null;
return decodeBase64ToBinaryString(match[1]);
};
const skTypeInText = (text: string | undefined | null): FidoSshKeyType | undefined => {
if (!text) return undefined;
if (text.includes(SK_ECDSA_NISTP256) || /ecdsa-sk|sk-ecdsa/i.test(text)) return "ECDSA-SK";
if (text.includes(SK_SSH_ED25519) || /ed25519-sk|sk-ssh-ed25519/i.test(text)) return "ED25519-SK";
return undefined;
};
/**
* Best-effort detection of an OpenSSH sk-* private key (handle) blob.
* Decodes the base64 body and looks for the algorithm string.
*/
export const isSkPrivateKey = (privateKey: string | undefined | null): boolean => {
if (typeof privateKey !== "string" || !privateKey.trim()) return false;
const decoded = decodeOpenSshPrivateKeyBody(privateKey);
if (decoded) {
return decoded.includes(SK_SSH_ED25519) || decoded.includes(SK_ECDSA_NISTP256);
}
// Some exporters only put the type on a comment line / nearby text.
return /sk-(?:ssh-ed25519|ecdsa-sha2-nistp256)@openssh\.com/.test(privateKey);
};
export const detectFidoSshKeyType = (args: {
type?: string | null;
publicKey?: string | null;
privateKey?: string | null;
}): FidoSshKeyType | undefined => {
if (isFidoSshVaultKeyType(args.type)) return args.type;
const pubType = extractOpenSshPublicKeyType(args.publicKey);
if (pubType === SK_SSH_ED25519) return "ED25519-SK";
if (pubType === SK_ECDSA_NISTP256) return "ECDSA-SK";
if (isSkPrivateKey(args.privateKey)) {
const decoded = decodeOpenSshPrivateKeyBody(args.privateKey);
return skTypeInText(decoded) || skTypeInText(args.privateKey) || "ED25519-SK";
}
return undefined;
};
/** True when vault/host material must authenticate via agent + hardware helper. */
export const requiresFidoSshAgentAuth = (args: {
type?: string | null;
publicKey?: string | null;
privateKey?: string | null;
}): boolean => Boolean(detectFidoSshKeyType(args) || isSkPublicKey(args.publicKey) || isSkPrivateKey(args.privateKey));
export const fidoSshKeyTypeToOpenSsh = (type: FidoSshKeyType): SkPublicKeyType =>
type === "ECDSA-SK" ? SK_ECDSA_NISTP256 : SK_SSH_ED25519;
export const openSshSkTypeToVaultKeyType = (type: string | undefined | null): FidoSshKeyType | undefined => {
if (type === SK_SSH_ED25519) return "ED25519-SK";
if (type === SK_ECDSA_NISTP256) return "ECDSA-SK";
return undefined;
};
-65
View File
@@ -1,65 +0,0 @@
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 }
);
-3
View File
@@ -9,7 +9,6 @@
*/
import type { KeyType } from './models';
import { detectFidoSshKeyType } from './fidoSsh';
export interface SshKeyTypeInfo {
type: KeyType;
@@ -155,8 +154,6 @@ export const detectSshKeyType = (
privateKey?: string | null,
publicKey?: string | null,
): SshKeyTypeInfo => {
const fidoType = detectFidoSshKeyType({ privateKey, publicKey });
if (fidoType) return { type: fidoType };
const fromPrivate = privateKey ? detectFromPrivateKey(privateKey) : undefined;
if (fromPrivate) return fromPrivate;
const fromPublic = publicKey ? detectFromPublicKey(publicKey) : undefined;
+1 -2
View File
@@ -313,8 +313,7 @@ export interface Host {
order?: number;
}
/** Software keys plus OpenSSH FIDO2 security-key types (agent + hardware only). */
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519' | 'ED25519-SK' | 'ECDSA-SK';
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
type KeySource = 'generated' | 'imported' | 'reference';
export type KeyCategory = 'key' | 'certificate' | 'identity';
type IdentityAuthMethod = 'password' | 'key' | 'certificate';
-2
View File
@@ -22,8 +22,6 @@ 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[];
-63
View File
@@ -1,63 +0,0 @@
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,
);
});
-64
View File
@@ -1,64 +0,0 @@
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);
}
-109
View File
@@ -99,36 +99,6 @@ test("resolveBridgeSshAgentAuth carries system agent settings without private ma
);
});
test("resolveBridgeSshAgentAuth forces agent auth for FIDO2 SK vault keys", () => {
const skKey: SSHKey = {
id: "sk-1",
label: "YubiKey",
type: "ED25519-SK",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
publicKey: "sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= yubi",
source: "imported",
category: "key",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skKey, "key"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
agentPublicKeys: [skKey.publicKey!],
},
);
assert.equal(
resolveBridgeKeyAuth({ key: skKey }).privateKey?.includes("OPENSSH PRIVATE KEY"),
true,
);
});
test("resolveBridgeSshAgentAuth keeps certificate authentication independent", () => {
assert.deepEqual(
resolveBridgeSshAgentAuth({
@@ -139,85 +109,6 @@ test("resolveBridgeSshAgentAuth keeps certificate authentication independent", (
);
});
test("resolveBridgeSshAgentAuth keeps agent path for FIDO SK certificates", () => {
const skCertKey: SSHKey = {
id: "sk-cert-1",
label: "YubiKey cert",
type: "ED25519-SK",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
publicKey: "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi",
certificate: "sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi",
source: "imported",
category: "certificate",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skCertKey, "certificate"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
agentPublicKeys: [skCertKey.publicKey!],
},
);
});
test("resolveBridgeSshAgentAuth selects staged SK certificate under IdentitiesOnly", () => {
const barePub =
"sk-ssh-ed25519@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5QG9wZW5zc2guY29tAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAABHNzaDo= yubi";
const cert =
"sk-ssh-ed25519-cert-v01@openssh.com AAAAGnNrLXNzaC1lZDI1NTE5LWNlcnQtdjAxQG9wZW5zc2guY29tAAAA yubi";
const skCertKey: SSHKey = {
id: "sk-cert-bare-1",
label: "YubiKey cert staged",
type: "ED25519-SK",
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nsk-ssh-ed25519@openssh.com\n-----END OPENSSH PRIVATE KEY-----",
publicKey: barePub,
certificate: cert,
source: "imported",
category: "certificate",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skCertKey, "certificate"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
agentPublicKeys: [barePub, cert],
},
);
});
test("resolveBridgeSshAgentAuth forces agent for path-only SK vault type", () => {
const skRef: SSHKey = {
id: "sk-ref-1",
label: "YubiKey file",
type: "ED25519-SK",
source: "reference",
filePath: "/home/user/.ssh/id_ed25519_sk",
category: "key",
created: 1,
};
assert.deepEqual(
resolveBridgeSshAgentAuth({ ...autofillBaseHost, useSshAgent: false }, skRef, "key"),
{
useSshAgent: true,
identityAgent: undefined,
identitiesOnly: true,
addKeysToAgent: "yes",
useKeychain: undefined,
},
);
});
test("resolveBridgeSshAgentAuth forwards a selected vault public key", () => {
assert.deepEqual(
resolveBridgeSshAgentAuth({
+10 -91
View File
@@ -2,7 +2,6 @@ import type { GroupConfig, Host, HostAuthMethod, Identity, SSHKey } from "./mode
import { sanitizeCredentialValue } from "./credentials";
import { applyGroupDefaults } from "./groupConfig";
import { isSshAgentNoneValue } from "./sshAgentSettings";
import { requiresFidoSshAgentAuth } from "./fidoSsh";
type HostAuthOverride = {
authMethod?: HostAuthMethod;
@@ -241,30 +240,10 @@ export const resolveBridgeKeyAuth = (args: {
passphrase?: string;
} => {
const { key, fallbackIdentityFilePaths, passphrase } = args;
const isFidoSk = requiresFidoSshAgentAuth({
type: key?.type,
publicKey: key?.publicKey,
privateKey: key?.privateKey,
});
const identityFilePaths = key?.source === "reference" && key.filePath
? [key.filePath]
: fallbackIdentityFilePaths;
// SK private material is an OpenSSH key *handle*. Pass it through so the
// main process can load it into the system agent; never use it for local
// software signing (startSession routes SK handles to ssh-add).
if (isFidoSk) {
return {
privateKey: key?.source === "reference"
? undefined
: sanitizeCredentialValue(key?.privateKey),
identityFilePaths: key?.source === "reference" && key.filePath
? [key.filePath]
: identityFilePaths,
passphrase: sanitizeCredentialValue(passphrase ?? key?.passphrase),
};
}
return {
privateKey: key?.source === "reference" ? undefined : sanitizeCredentialValue(key?.privateKey),
identityFilePaths,
@@ -272,23 +251,9 @@ export const resolveBridgeKeyAuth = (args: {
};
};
/** Preferred agent identity blobs for IdentitiesOnly selection. */
const resolveAgentPublicKeys = (
key?: Pick<SSHKey, "publicKey" | "certificate">,
): string[] | undefined => {
const keys: string[] = [];
const publicKey = key?.publicKey?.trim();
const certificate = key?.certificate?.trim();
if (publicKey) keys.push(publicKey);
// ssh-add -L lists the bare key and certificate as distinct identities; when
// the vault stores them separately, both must be selectable under IdentitiesOnly.
if (certificate && certificate !== publicKey) keys.push(certificate);
return keys.length > 0 ? keys : undefined;
};
export const resolveBridgeSshAgentAuth = (
host: Pick<Host, "authMethod" | "useSshAgent" | "identityAgent" | "identityFilePaths" | "identitiesOnly" | "addKeysToAgent" | "useKeychain" | "agentForwarding">,
key?: Pick<SSHKey, "certificate" | "publicKey" | "source" | "filePath" | "type" | "privateKey">,
key?: Pick<SSHKey, "certificate" | "publicKey" | "source" | "filePath">,
authMethod?: HostAuthMethod,
): {
useSshAgent?: boolean;
@@ -303,70 +268,25 @@ export const resolveBridgeSshAgentAuth = (
&& !isSshAgentNoneValue(host.identityAgent)
? { identityAgent: host.identityAgent }
: {};
const isFidoSk = requiresFidoSshAgentAuth({
type: key?.type,
publicKey: key?.publicKey,
privateKey: key?.privateKey,
});
const hasAgentSelector = Boolean(
key?.publicKey?.trim()
|| (key?.source === "reference" && key.filePath?.trim())
|| host.identityFilePaths?.some((filePath) => filePath.trim())
// Inline imported sk private key handle still needs agent; selection uses
// the public key when present, otherwise the agent is used non-strictly.
|| (isFidoSk && key?.privateKey?.trim()),
);
// FIDO2 SK keys always require the system agent (hardware signs via
// ssh-sk-helper), whether selected explicitly or via a CA certificate
// wrapping the SK public key.
const fidoAgentAuth = () => {
if (!hasAgentSelector && !key?.privateKey?.trim()) {
return { useSshAgent: false };
}
const agentPublicKeys = resolveAgentPublicKeys(key);
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: Boolean(key?.publicKey?.trim() || (key?.source === "reference" && key.filePath)),
addKeysToAgent: host.addKeysToAgent ?? "yes",
useKeychain: host.useKeychain,
...(agentPublicKeys ? { agentPublicKeys } : {}),
};
};
if (authMethod === "password") {
if (authMethod === "password" || authMethod === "certificate" || key?.certificate?.trim()) {
return { useSshAgent: false, ...forwardingAgent };
}
if (authMethod === "certificate" || key?.certificate?.trim()) {
// A certificate over an SK public key still needs the agent for hardware
// signing; only non-FIDO certificate auth bypasses the agent entirely.
if (isFidoSk) {
return fidoAgentAuth();
}
return { useSshAgent: false, ...forwardingAgent };
}
if (authMethod === "key" || isFidoSk) {
// Explicit key auth without agent only works for soft keys; FIDO SK
// material is routed through the agent regardless of authMethod.
if (isFidoSk) {
return fidoAgentAuth();
}
if (authMethod === "key") {
const hasAgentSelector = Boolean(
key?.publicKey?.trim()
|| (key?.source === "reference" && key.filePath?.trim())
|| host.identityFilePaths?.some((filePath) => filePath.trim()),
);
if (host.useSshAgent !== true || !hasAgentSelector) {
return { useSshAgent: false, ...forwardingAgent };
}
const agentPublicKeys = resolveAgentPublicKeys(key);
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: true,
addKeysToAgent: host.addKeysToAgent,
useKeychain: host.useKeychain,
...(agentPublicKeys ? { agentPublicKeys } : {}),
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
};
}
if (host.useSshAgent !== true) {
@@ -374,14 +294,13 @@ export const resolveBridgeSshAgentAuth = (
? forwardingAgent
: { useSshAgent: false, ...forwardingAgent };
}
const agentPublicKeys = resolveAgentPublicKeys(key);
return {
useSshAgent: true,
identityAgent: host.identityAgent,
identitiesOnly: host.identitiesOnly,
addKeysToAgent: host.addKeysToAgent,
useKeychain: host.useKeychain,
...(agentPublicKeys ? { agentPublicKeys } : {}),
...(key?.publicKey?.trim() ? { agentPublicKeys: [key.publicKey] } : {}),
};
};
@@ -1,32 +0,0 @@
"use strict";
/**
* Attach a one-shot release for an owned FIDO agent / askpass lease to a
* connection-like EventEmitter (ssh2 Client, etc.).
*
* @param {import("events").EventEmitter|null|undefined} conn
* @param {{ _releaseNetcattyFidoAgent?: () => void }|null|undefined} agent
*/
function attachFidoAgentRelease(conn, agent) {
if (!conn || typeof conn.once !== "function") return;
if (!agent || typeof agent._releaseNetcattyFidoAgent !== "function") return;
let released = false;
const releaseOwned = () => {
if (released) return;
released = true;
try { agent._releaseNetcattyFidoAgent(); } catch { /* ignore */ }
};
conn.once("close", releaseOwned);
conn.once("end", releaseOwned);
const onError = error => {
if (error?.code !== "ERR_FIDO_CANCELLED") return;
// ssh2 would otherwise try another key/password after an agent failure.
// Explicitly cancelling hardware authentication must end this connection.
try { conn.end?.(); } catch { /* already closed */ }
};
conn.on("error", onError);
conn.once("close", () => conn.removeListener("error", onError));
return releaseOwned;
}
module.exports = { attachFidoAgentRelease };
@@ -1,76 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { attachFidoAgentRelease } = require("./attachFidoAgentRelease.cjs");
test("attachFidoAgentRelease invokes release once on close", () => {
const conn = new EventEmitter();
let releaseCount = 0;
const agent = {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
attachFidoAgentRelease(conn, agent);
conn.emit("close");
assert.equal(releaseCount, 1);
conn.emit("close");
assert.equal(releaseCount, 1);
});
test("attachFidoAgentRelease invokes release once on end", () => {
const conn = new EventEmitter();
let releaseCount = 0;
const agent = {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
attachFidoAgentRelease(conn, agent);
conn.emit("end");
assert.equal(releaseCount, 1);
conn.emit("end");
assert.equal(releaseCount, 1);
});
test("attachFidoAgentRelease is one-shot across close and end", () => {
const conn = new EventEmitter();
let releaseCount = 0;
const agent = {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
attachFidoAgentRelease(conn, agent);
conn.emit("close");
conn.emit("end");
assert.equal(releaseCount, 1);
});
test("attachFidoAgentRelease no-ops without conn or release hook", () => {
let releaseCount = 0;
const agent = {
_releaseNetcattyFidoAgent: () => { releaseCount += 1; },
};
attachFidoAgentRelease(null, agent);
attachFidoAgentRelease(new EventEmitter(), null);
attachFidoAgentRelease(new EventEmitter(), {});
assert.equal(releaseCount, 0);
});
test("explicit FIDO cancellation ends the transport instead of trying another credential", () => {
const conn = new EventEmitter();
let ended = 0;
conn.end = () => { ended++; };
attachFidoAgentRelease(conn, { _releaseNetcattyFidoAgent() {} });
conn.emit("error", Object.assign(new Error("cancelled"), { code: "ERR_FIDO_CANCELLED" }));
assert.equal(ended, 1);
conn.emit("error", new Error("ordinary agent error"));
assert.equal(ended, 1);
conn.emit("close");
assert.equal(conn.listenerCount("error"), 0);
});
-500
View File
@@ -1,500 +0,0 @@
"use strict";
/**
* Netcatty-owned ssh-agent for FIDO2 sk-* keys.
*
* System agents started at login often lack SSH_ASKPASS, so PIN/touch prompts
* never reach our GUI. Spawning a short-lived agent as our child with askpass
* env makes sk-helper prompts work inside Netcatty.
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const { randomUUID } = require("node:crypto");
const {
buildFidoAskpassEnv,
releaseFidoAskpassLease,
shutdownFidoAskpass,
} = require("./fidoAskpass.cjs");
const execFileAsync = promisify(execFile);
/** @type {{ killed: boolean, pid: number, kill: (sig?: string) => void }|null} */
let agentChild = null;
/** @type {string|null} */
let agentSocket = null;
/** @type {string|null} */
let agentDir = null;
let refCount = 0;
/**
* Monotonic id for the currently tracked agent. Cleared/restarted agents bump
* this so stale release callbacks from earlier acquisitions cannot kill a newer
* agent that reused the singleton slot.
*/
let agentGeneration = 0;
/** @type {Promise<{ socketPath: string, askpassEnv: Record<string, string>, owned: boolean, generation: number }>|null} */
let startingPromise = null;
function getTempBase() {
const tempDirBridge = require("./tempDirBridge.cjs");
if (typeof tempDirBridge.getTempDir !== "function") {
throw new Error("FIDO agent requires Netcatty temp directory (tempDirBridge unavailable).");
}
return tempDirBridge.getTempDir();
}
const WIN_SYSTEM_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
/**
* User-mode ssh-agent binaries on Windows (not the Win32-OpenSSH service host).
* These can run as our child with SSH_ASKPASS so verify-required sk-helper
* prompts reach the Netcatty modal.
* @param {NodeJS.ProcessEnv} [env]
* @returns {string[]}
*/
function listWindowsUserModeSshAgentCandidates(env = process.env) {
const programFiles = env.ProgramFiles || "C:\\Program Files";
const programFilesX86 = env["ProgramFiles(x86)"] || "C:\\Program Files (x86)";
const localAppData = env.LOCALAPPDATA || "";
const userProfile = env.USERPROFILE || "";
return [
path.join(programFiles, "Git", "usr", "bin", "ssh-agent.exe"),
path.join(programFilesX86, "Git", "usr", "bin", "ssh-agent.exe"),
path.join(localAppData, "Programs", "Git", "usr", "bin", "ssh-agent.exe"),
path.join(userProfile, "scoop", "apps", "git", "current", "usr", "bin", "ssh-agent.exe"),
];
}
/**
* Pair ssh-add with a user-mode ssh-agent (same directory) when possible.
* @param {string} sshAgentPath
* @returns {string|null}
*/
function companionSshAddPath(sshAgentPath) {
if (typeof sshAgentPath !== "string" || !sshAgentPath.trim()) return null;
const dir = path.dirname(sshAgentPath);
const base = path.basename(sshAgentPath).toLowerCase().replace(/\.exe$/i, "");
if (base !== "ssh-agent") return null;
const candidate = path.join(dir, path.basename(sshAgentPath).replace(/ssh-agent/i, "ssh-add"));
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// ignore
}
return null;
}
function resolveSshAgentBinary(env = process.env, platform = process.platform) {
if (typeof env.NETCATTY_SSH_AGENT_PATH === "string" && env.NETCATTY_SSH_AGENT_PATH.trim()) {
return env.NETCATTY_SSH_AGENT_PATH.trim();
}
if (platform === "darwin") {
for (const candidate of [
"/opt/homebrew/bin/ssh-agent",
"/usr/local/bin/ssh-agent",
"/usr/bin/ssh-agent",
]) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// continue
}
}
}
if (platform === "win32") {
// Prefer Git/MSYS (or similar) user-mode agents over System32's service binary.
for (const candidate of listWindowsUserModeSshAgentCandidates(env)) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// continue
}
}
}
return "ssh-agent";
}
function parseAgentStdout(stdout) {
const sockMatch = /SSH_AUTH_SOCK=([^;\r\n]+)/.exec(stdout || "");
const pidMatch = /SSH_AGENT_PID=(\d+)/.exec(stdout || "");
return {
socketPath: sockMatch?.[1]?.trim().replace(/^(['"])(.*)\1$/, "$2") || null,
agentPid: pidMatch ? Number(pidMatch[1]) : null,
};
}
function isProcessAlive(pid) {
if (!pid || !Number.isFinite(pid) || pid <= 0) return false;
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
function isWindowsNamedPipe(socketPath) {
return typeof socketPath === "string"
&& /^\\\\[.,]\\pipe\\/i.test(socketPath);
}
function isAgentLive() {
if (!agentSocket) return false;
// Named pipes are not reliable with existsSync; rely on PID / child state.
if (!isWindowsNamedPipe(agentSocket)) {
try {
if (!fs.existsSync(agentSocket)) return false;
} catch {
return false;
}
}
if (agentChild?.pid) return isProcessAlive(agentChild.pid);
// Socket exists but no PID tracking — treat as live until connect fails.
// System-pipe fallback sets agentChild=null; treat that as live while socket is set.
if (!agentChild) return isWindowsNamedPipe(agentSocket);
return !agentChild.killed;
}
function clearAgentState({ kill = true } = {}) {
if (kill && agentChild) {
const ownedPid = agentChild.pid;
// Only kill OUR tracked pid. Never run `ssh-agent -k` with the ambient
// process.env SSH_AGENT_PID — that can kill the user's login agent.
try {
if (ownedPid && isProcessAlive(ownedPid)) {
process.kill(ownedPid, "TERM");
}
} catch {
// ignore
}
if (ownedPid && agentSocket) {
try {
const sshAgent = resolveSshAgentBinary();
// Isolate env: only our sock + pid, no inheritance of login agent vars.
execFile(sshAgent, ["-k"], {
env: {
PATH: process.env.PATH || "/usr/bin:/bin",
SSH_AUTH_SOCK: agentSocket,
SSH_AGENT_PID: String(ownedPid),
},
windowsHide: true,
timeout: 3000,
}, () => {});
} catch {
// ignore
}
}
try {
agentChild.kill?.("TERM");
} catch {
// ignore
}
}
agentChild = null;
agentSocket = null;
if (agentDir) {
fs.promises.rm(agentDir, { recursive: true, force: true }).catch(() => {});
agentDir = null;
}
refCount = 0;
agentGeneration += 1;
}
/**
* Env for the long-lived ssh-agent process. Must not include a caller-bound
* NETCATTY_FIDO_ASKPASS_LEASE: ssh-sk-helper for later verify-required signing
* is a descendant of the agent and would inherit that lease, routing a second
* window's PIN/touch prompt to the starter forever. Callers still receive a
* per-acquire lease via askpassEnv for ssh-add. Agent-spawned prompts route to
* the last leased signing window in fidoAskpass (not the sticky last acquire).
* @param {Record<string, string>|null|undefined} askpassEnv
* @returns {Record<string, string>}
*/
function askpassEnvForAgentProcess(askpassEnv) {
if (!askpassEnv || typeof askpassEnv !== "object") return {};
const { NETCATTY_FIDO_ASKPASS_LEASE: _callerLease, ...agentAskpassEnv } = askpassEnv;
return agentAskpassEnv;
}
/**
* Acquire a Netcatty FIDO agent socket. Multiple callers share one agent.
* Concurrent acquires are serialized via startingPromise.
*/
async function acquireFidoAgent(options = {}) {
if (startingPromise) {
const shared = await startingPromise;
if (isAgentLive()) {
refCount += 1;
// Caller-specific askpass lease — never reuse the starter's resolver.
return {
socketPath: shared.socketPath,
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,
};
}
}
if (isAgentLive() && agentSocket) {
refCount += 1;
return {
socketPath: agentSocket,
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,
sshAddPath: companionSshAddPath(resolveSshAgentBinary(options.env || process.env, options.platform || process.platform)),
};
}
// Dead leftover state
if (agentSocket || agentChild) clearAgentState({ kill: true });
startingPromise = (async () => {
const run = options.execFile || execFileAsync;
const env = options.env || process.env;
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);
} catch {
// ignore
}
};
try {
const sshAddPath = companionSshAddPath(sshAgent);
// 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,
// but never bake the starter's caller lease into the shared process env.
const agentProcessEnv = {
...env, ...askpassEnvForAgentProcess(askpassEnv),
...require("./fidoSkHelper.cjs").buildSkHelperEnv(sshAgent, askpassEnv, env, platform),
};
// Windows: try a Netcatty-owned named pipe first (ssh2 speaks it natively),
// then a filesystem socket for MSYS/Git user-mode agents.
const sockCandidates = platform === "win32"
? [
`\\\\.\\pipe\\netcatty-fido-agent-${randomUUID().slice(0, 8)}`,
path.join(agentDir, "agent.sock").replace(/\\/g, "/"),
]
: [path.join(agentDir, "agent.sock")];
let stdout = "";
let boundSockPath = sockCandidates[0];
let started = false;
/** @type {Error|null} */
let lastStartError = null;
for (const sockPath of sockCandidates) {
boundSockPath = sockPath;
try {
const result = await run(sshAgent, ["-a", sockPath, "-s"], {
timeout: 10000,
windowsHide: true,
env: agentProcessEnv,
});
stdout = result.stdout?.toString?.() || result.stdout || "";
started = true;
break;
} catch (error) {
lastStartError = error instanceof Error ? error : new Error(String(error));
}
}
if (!started) {
try {
const result = await run(sshAgent, ["-s"], {
timeout: 10000,
windowsHide: true,
env: agentProcessEnv,
});
stdout = result.stdout?.toString?.() || result.stdout || "";
started = true;
} catch (fallbackError) {
lastStartError = fallbackError instanceof Error
? fallbackError
: new Error(String(fallbackError));
}
}
if (started) {
const parsed = parseAgentStdout(stdout);
const fallbackSock = isWindowsNamedPipe(boundSockPath)
? boundSockPath
: (fs.existsSync(boundSockPath) ? boundSockPath : null);
agentSocket = parsed.socketPath || fallbackSock;
if (!agentSocket) {
clearAgentState({ kill: true });
const err = new Error("ssh-agent started but SSH_AUTH_SOCK was not reported.");
err.code = "ERR_FIDO_AGENT_SOCK";
throw err;
}
// In-box Win32-OpenSSH `ssh-agent -s` often just advertises the system
// service pipe — that is not a Netcatty child and must not be killed.
const isSystemServicePipe = typeof agentSocket === "string"
&& /openssh-ssh-agent$/i.test(agentSocket.replace(/\//g, "\\"));
if (isSystemServicePipe) {
agentChild = null;
agentDir = null;
refCount = 1;
return {
socketPath: agentSocket,
askpassEnv,
owned: false,
generation: agentGeneration,
sshAddPath: null,
};
}
if (parsed.agentPid && Number.isFinite(parsed.agentPid) && parsed.agentPid > 0) {
const pid = parsed.agentPid;
agentChild = {
killed: false,
pid,
kill(sig = "TERM") {
try { process.kill(pid, sig); } catch { /* ignore */ }
this.killed = true;
},
};
} else {
// No PID — still usable via socket; kill via ssh-agent -k on release.
agentChild = {
killed: false,
pid: 0,
kill() { this.killed = true; },
};
}
refCount = 1;
return {
socketPath: agentSocket,
askpassEnv,
owned: true,
generation: agentGeneration,
sshAddPath,
};
}
// Win32-OpenSSH's in-box ssh-agent is a service bound to a fixed named
// pipe and usually rejects `ssh-agent -a`. Fall back to that pipe so
// non-verify-required keys still work, but mark owned:false so callers
// remove any identities they ssh-add (see prepareSystemSshAgentForAuth).
// verify-required signing still needs a prompt-capable child agent above.
if (platform === "win32") {
clearAgentState({ kill: false });
agentSocket = WIN_SYSTEM_AGENT_PIPE;
agentChild = null;
agentDir = null;
refCount = 1;
return {
socketPath: WIN_SYSTEM_AGENT_PIPE,
askpassEnv,
owned: false,
generation: agentGeneration,
sshAddPath: null,
};
}
clearAgentState({ kill: false });
const message = lastStartError instanceof Error ? lastStartError.message : String(lastStartError || "unknown");
const err = new Error(
`Could not start a FIDO-capable ssh-agent (${message}). Install OpenSSH with libfido2 (macOS: brew install openssh libfido2).`,
);
err.code = "ERR_FIDO_AGENT_START";
throw err;
} catch (error) {
// Starter allocated a caller-specific askpass lease before launch; drop it
// whenever startup fails so WebContents resolvers are not retained.
releaseStarterAskpassLease();
throw error;
}
})();
try {
return await startingPromise;
} finally {
startingPromise = null;
}
}
/**
* Release one acquisition. Pass the `generation` returned by acquireFidoAgent so
* a late close after the agent was replaced cannot decrement/kill the newer agent.
*/
function releaseFidoAgent(generation) {
if (generation !== agentGeneration) return;
refCount = Math.max(0, refCount - 1);
if (refCount > 0) return;
clearAgentState({ kill: true });
}
function getActiveFidoAgentSocket() {
return isAgentLive() ? agentSocket : null;
}
function shutdownFidoAgentSubsystem() {
clearAgentState({ kill: true });
try {
shutdownFidoAskpass();
} catch {
// ignore
}
}
let quitHookInstalled = false;
function installFidoAgentQuitHook() {
if (quitHookInstalled) return;
quitHookInstalled = true;
const shutdown = () => {
try { shutdownFidoAgentSubsystem(); } catch { /* ignore */ }
};
// Terminal/auth often runs in a utilityProcess where `app` is unavailable.
// Use only the `exit` hook so we do not swallow SIGTERM/SIGINT default exit
// behavior (installing those handlers without process.exit leaves workers hung).
process.once("exit", shutdown);
try {
const { app } = require("electron");
app.once("before-quit", shutdown);
app.once("will-quit", shutdown);
} catch {
// non-electron / utility process
}
}
// Best-effort install when module loads in Electron main or worker.
try { installFidoAgentQuitHook(); } catch { /* ignore */ }
module.exports = {
acquireFidoAgent,
releaseFidoAgent,
getActiveFidoAgentSocket,
resolveSshAgentBinary,
parseAgentStdout,
shutdownFidoAgentSubsystem,
installFidoAgentQuitHook,
isAgentLive,
askpassEnvForAgentProcess,
listWindowsUserModeSshAgentCandidates,
companionSshAddPath,
WIN_SYSTEM_AGENT_PIPE,
// exposed for tests
getTempBase,
};
-305
View File
@@ -1,305 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseAgentStdout,
isAgentLive,
releaseFidoAgent,
getActiveFidoAgentSocket,
shutdownFidoAgentSubsystem,
acquireFidoAgent,
getTempBase,
} = require("./fidoAgentManager.cjs");
test("parseAgentStdout extracts sock and pid", () => {
const parsed = parseAgentStdout(
"SSH_AUTH_SOCK=/tmp/agent.123; export SSH_AUTH_SOCK;\nSSH_AGENT_PID=9999; export SSH_AGENT_PID;\n",
);
assert.equal(parsed.socketPath, "/tmp/agent.123");
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();
assert.equal(getTempBase(), managed);
assert.match(managed, /Netcatty/i);
});
test("release without acquire is safe", () => {
releaseFidoAgent();
releaseFidoAgent();
assert.equal(getActiveFidoAgentSocket(), null);
assert.equal(isAgentLive(), false);
shutdownFidoAgentSubsystem();
});
async function withManagedTemp(run) {
const fs = require("node:fs");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
try {
return await run(managedTemp);
} finally {
tempDirBridge.getTempDir = originalGetTempDir;
shutdownFidoAgentSubsystem();
fs.rmSync(managedTemp, { recursive: true, force: true });
}
}
test("acquireFidoAgent on win32 starts a prompt-capable child agent when -a works", async () => {
await withManagedTemp(async () => {
shutdownFidoAgentSubsystem();
const pipe = "\\\\.\\pipe\\netcatty-fido-agent-testown";
const agent = await acquireFidoAgent({
platform: "win32",
resolveWebContents: () => null,
env: { PATH: process.env.PATH || "", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async (_bin, args, opts) => {
assert.ok(opts?.env?.SSH_ASKPASS, "child agent must inherit askpass");
assert.equal(opts?.env?.NETCATTY_FIDO_ASKPASS_LEASE, undefined);
assert.ok(Array.isArray(args) && args.includes("-a"));
return {
stdout: `SSH_AUTH_SOCK=${pipe}; export SSH_AUTH_SOCK;\nSSH_AGENT_PID=4242; export SSH_AGENT_PID;\n`,
};
},
});
assert.equal(agent.socketPath, pipe);
assert.equal(agent.owned, true);
assert.ok(agent.askpassEnv?.SSH_ASKPASS);
releaseFidoAgent(agent.generation);
});
});
test("acquireFidoAgent on win32 falls back to system pipe when child agent cannot start", async () => {
await withManagedTemp(async () => {
shutdownFidoAgentSubsystem();
const agent = await acquireFidoAgent({
platform: "win32",
resolveWebContents: () => null,
env: { PATH: process.env.PATH || "", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => {
throw new Error("ssh-agent -a unsupported");
},
});
assert.equal(agent.socketPath, "\\\\.\\pipe\\openssh-ssh-agent");
assert.equal(agent.owned, false);
assert.ok(agent.askpassEnv?.SSH_ASKPASS);
releaseFidoAgent(agent.generation);
});
});
test("acquireFidoAgent on win32 does not claim ownership of the system service pipe", async () => {
await withManagedTemp(async () => {
shutdownFidoAgentSubsystem();
const systemPipe = "\\\\.\\pipe\\openssh-ssh-agent";
const agent = await acquireFidoAgent({
platform: "win32",
resolveWebContents: () => null,
env: { PATH: process.env.PATH || "", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => ({
stdout: `SSH_AUTH_SOCK=${systemPipe}; export SSH_AUTH_SOCK;\n`,
}),
});
assert.equal(agent.socketPath, systemPipe);
assert.equal(agent.owned, false);
releaseFidoAgent(agent.generation);
});
});
test("acquireFidoAgent releases askpass lease when agent start fails", async () => {
shutdownFidoAgentSubsystem();
const fs = require("node:fs");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
const { getAskpassLeaseCountForTests } = require("./fidoAskpass.cjs");
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
const before = getAskpassLeaseCountForTests();
try {
await assert.rejects(
() => acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "fail-start" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => {
throw new Error("ssh-agent missing");
},
}),
(error) => error?.code === "ERR_FIDO_AGENT_START",
);
assert.equal(getAskpassLeaseCountForTests(), before);
} finally {
tempDirBridge.getTempDir = originalGetTempDir;
shutdownFidoAgentSubsystem();
fs.rmSync(managedTemp, { recursive: true, force: true });
}
});
test("askpassEnvForAgentProcess strips caller-bound askpass lease", () => {
const { askpassEnvForAgentProcess } = require("./fidoAgentManager.cjs");
const stripped = askpassEnvForAgentProcess({
SSH_ASKPASS: "/tmp/askpass.sh",
SSH_ASKPASS_REQUIRE: "force",
NETCATTY_FIDO_ASKPASS_SOCK: "/tmp/askpass.sock",
NETCATTY_FIDO_ASKPASS_LEASE: "starter-lease",
});
assert.equal(stripped.SSH_ASKPASS, "/tmp/askpass.sh");
assert.equal(stripped.NETCATTY_FIDO_ASKPASS_SOCK, "/tmp/askpass.sock");
assert.equal(stripped.SSH_ASKPASS_REQUIRE, "force");
assert.equal("NETCATTY_FIDO_ASKPASS_LEASE" in stripped, false);
});
test("ssh-agent spawn env omits caller-bound askpass lease", async () => {
shutdownFidoAgentSubsystem();
const fs = require("node:fs");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
const { releaseFidoAskpassLease } = require("./fidoAskpass.cjs");
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
const sockPath = path.join(managedTemp, "agent.sock");
fs.writeFileSync(sockPath, "");
/** @type {Record<string, string>|null} */
let spawnEnv = null;
try {
const acquired = await acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "starter" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async (_bin, _args, opts) => {
spawnEnv = opts?.env || null;
return {
stdout: `SSH_AUTH_SOCK=${sockPath}; export SSH_AUTH_SOCK;\n`,
};
},
});
assert.ok(spawnEnv);
assert.ok(spawnEnv.SSH_ASKPASS);
assert.equal(spawnEnv.NETCATTY_FIDO_ASKPASS_LEASE, undefined);
assert.ok(acquired.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAskpassLease(acquired.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAgent(acquired.generation);
} finally {
tempDirBridge.getTempDir = originalGetTempDir;
shutdownFidoAgentSubsystem();
fs.rmSync(managedTemp, { recursive: true, force: true });
}
});
test("concurrent acquireFidoAgent returns a fresh askpass lease per caller", async () => {
await withManagedTemp(async (managedTemp) => {
shutdownFidoAgentSubsystem();
const fs = require("node:fs");
const path = require("node:path");
const { releaseFidoAskpassLease } = require("./fidoAskpass.cjs");
const sockPath = path.join(managedTemp, "agent.sock");
fs.writeFileSync(sockPath, "");
let releaseStart;
const startGate = new Promise((resolve) => { releaseStart = resolve; });
const first = acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "first" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => {
releaseStart();
await new Promise((r) => setTimeout(r, 80));
return {
stdout: `SSH_AUTH_SOCK=${sockPath}; export SSH_AUTH_SOCK;\n`,
};
},
});
await startGate;
const second = acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "second" }),
execFile: async () => {
throw new Error("second caller must await startingPromise");
},
});
const [a, b] = await Promise.all([first, second]);
assert.equal(a.socketPath, sockPath);
assert.equal(b.socketPath, sockPath);
assert.notEqual(
a.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE,
b.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE,
);
releaseFidoAskpassLease(a.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAskpassLease(b.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAgent(a.generation);
releaseFidoAgent(b.generation);
});
});
test("stale releaseFidoAgent ignores a newer agent generation", async () => {
shutdownFidoAgentSubsystem();
const fs = require("node:fs");
const path = require("node:path");
const tempDirBridge = require("./tempDirBridge.cjs");
const { releaseFidoAskpassLease } = require("./fidoAskpass.cjs");
const managedTemp = fs.mkdtempSync(path.join(tempDirBridge.getTempDir(), "fa-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
const firstSock = path.join(managedTemp, "agent1.sock");
const secondSock = path.join(managedTemp, "agent2.sock");
fs.writeFileSync(firstSock, "");
fs.writeFileSync(secondSock, "");
try {
const first = await acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "gen-first" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => ({
stdout: `SSH_AUTH_SOCK=${firstSock}; export SSH_AUTH_SOCK;\n`,
}),
});
assert.equal(getActiveFidoAgentSocket(), firstSock);
// Simulate the managed agent dying while a connection still holds a release.
fs.rmSync(firstSock, { force: true });
assert.equal(isAgentLive(), false);
const second = await acquireFidoAgent({
platform: "linux",
resolveWebContents: () => ({ id: "gen-second" }),
env: { PATH: process.env.PATH || "/usr/bin:/bin", NETCATTY_SSH_AGENT_PATH: "/bin/true" },
execFile: async () => ({
stdout: `SSH_AUTH_SOCK=${secondSock}; export SSH_AUTH_SOCK;\n`,
}),
});
assert.notEqual(first.generation, second.generation);
assert.equal(getActiveFidoAgentSocket(), secondSock);
// Late close of the old connection must not kill the replacement agent.
releaseFidoAgent(first.generation);
assert.equal(getActiveFidoAgentSocket(), secondSock);
assert.equal(isAgentLive(), true);
releaseFidoAskpassLease(first.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAskpassLease(second.askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE);
releaseFidoAgent(second.generation);
assert.equal(getActiveFidoAgentSocket(), null);
} finally {
tempDirBridge.getTempDir = originalGetTempDir;
shutdownFidoAgentSubsystem();
fs.rmSync(managedTemp, { recursive: true, force: true });
}
});
-537
View File
@@ -1,537 +0,0 @@
"use strict";
/**
* SSH_ASKPASS bridge for FIDO2 (ssh-keygen / ssh-add / ssh-sk-helper).
*
* OpenSSH invokes SSH_ASKPASS as a subprocess with the prompt text as argv.
* The helper connects to a Netcatty-owned IPC socket; main process shows a
* native PIN/touch modal and returns the response on stdout of the helper.
*/
const fs = require("node:fs");
const net = require("node:net");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
const FIDO_ASKPASS_SCRIPT = String.raw`#!/usr/bin/env node
"use strict";
const net = require("node:net");
const fs = require("node:fs");
const sockPath = process.env.NETCATTY_FIDO_ASKPASS_SOCK;
const leaseId = process.env.NETCATTY_FIDO_ASKPASS_LEASE || "";
const prompt = process.argv.slice(2).join(" ") || process.env.SSH_ASKPASS_PROMPT || "";
function fail(code) {
process.exit(code == null ? 1 : code);
}
if (!sockPath) fail(1);
const payload = JSON.stringify({ prompt, type: "askpass", leaseId,
mode: process.env.SSH_ASKPASS_PROMPT || "" }) + "\n";
const client = net.createConnection(sockPath);
let buf = "";
let settled = false;
function finish(ok, text) {
if (settled) return;
settled = true;
try { client.end(); } catch { /* ignore */ }
if (!ok) fail(1);
process.stdout.write(text == null ? "" : String(text));
process.stdout.write("\n");
process.exit(0);
}
client.setEncoding("utf8");
client.on("connect", () => {
client.write(payload);
});
client.on("data", (chunk) => {
buf += chunk;
const nl = buf.indexOf("\n");
if (nl === -1) return;
let msg;
try {
msg = JSON.parse(buf.slice(0, nl));
} catch {
finish(false);
return;
}
if (msg && msg.ok === true) finish(true, msg.response || "");
else finish(false);
});
client.on("error", () => finish(false));
client.on("end", () => {
if (!settled) finish(false);
});
setTimeout(() => finish(false), 170000);
`;
/** @type {net.Server|null} */
let askpassServer = null;
/** @type {string|null} */
let askpassSocketPath = null;
/** @type {string|null} */
let askpassScriptPath = null;
/** @type {string|null} */
let askpassWrapperPath = null;
/** @type {(() => import("electron").WebContents|null)|null} */
let resolveWebContents = null;
/** @type {Map<string, () => import("electron").WebContents|null>} */
const askpassLeases = new Map();
/**
* Last resolver that successfully handled a caller-bound (leased) askpass
* prompt. Agent-spawned ssh-sk-helper has no lease after we strip the starter
* lease from the shared agent env; route those prompts to this signing window
* instead of the sticky last-acquire global resolver (wrong under multi-window
* / terminal-worker where BrowserWindow focus is unavailable).
* @type {(() => import("electron").WebContents|null)|null}
*/
let lastLeasedSigningResolver = null;
/** @type {string|null} Lease id that last marked lastLeasedSigningResolver. */
let lastLeasedSigningLeaseId = null;
let activeSigningLeaseId = null;
const signingQueue = [];
let helperTimeoutMs = 30000;
function setHelperTimeoutForTests(value) {
if (value != null && (!Number.isFinite(value) || value <= 0)) throw new Error("Invalid FIDO helper timeout");
helperTimeoutMs = value == null ? 30000 : value;
}
function observeSkHelper(socket, entry, initial = "") {
if (!entry) { socket.end('{"ack":true}\n'); return; }
if (entry.controller.signal.aborted) { socket.end('{"cancel":true}\n'); return; }
let buffer = initial;
const timer = setTimeout(() => {
entry.timedOut = true;
if (!socket.destroyed) socket.write('{"cancel":true}\n');
}, helperTimeoutMs);
const cancelHelper = () => {
if (!socket.destroyed) socket.write('{"cancel":true}\n');
};
const onAbort = cancelHelper;
entry.controller.signal.addEventListener("abort", onAbort, { once: true });
const consume = chunk => {
buffer += chunk;
const nl = buffer.indexOf("\n");
if (nl === -1) return;
let result;
try { result = JSON.parse(buffer.slice(0, nl)); } catch { socket.destroy(); return; }
if (result.phase !== "result") return;
clearTimeout(timer);
entry.helperError = Number.isInteger(result.errorCode) ? result.errorCode : null;
socket.end('{"ack":true}\n');
};
socket.on("data", consume);
socket.once("close", () => {
clearTimeout(timer);
entry.controller.signal.removeEventListener("abort", onAbort);
});
if (buffer) consume("");
}
// A shared OpenSSH agent cannot pass a per-request lease to its helper.
// Serialize hardware signatures and bind each prompt to its calling window
// until that signature's callback settles.
function runWithFidoSigningLease(leaseId, operation) {
signingQueue.push({ leaseId, operation, controller: new AbortController() });
if (signingQueue.length > 1) return;
const next = () => {
const entry = signingQueue[0];
if (!entry) return;
activeSigningLeaseId = entry.leaseId;
let finished = false;
entry.operation(() => {
if (finished) return;
finished = true;
entry.attemptController?.abort();
entry.controller.abort();
activeSigningLeaseId = null;
signingQueue.shift();
if (signingQueue.length) queueMicrotask(next);
}, entry);
};
next();
}
function getTempBase() {
const tempDirBridge = require("./tempDirBridge.cjs");
if (typeof tempDirBridge.getTempDir !== "function") {
throw new Error("FIDO askpass requires Netcatty temp directory (tempDirBridge unavailable).");
}
return tempDirBridge.getTempDir();
}
function isWindowsNamedPipePath(socketPath) {
return typeof socketPath === "string"
&& /^\\\\[.,]\\pipe\\/i.test(socketPath);
}
/**
* Askpass IPC address. Windows Node `net.Server` only binds named pipes under
* `\\.\pipe\...` — a filesystem `askpass.sock` path fails to listen.
* @param {string} baseDir
* @param {NodeJS.Platform} [platform]
*/
function resolveFidoAskpassSocketPath(baseDir, platform = process.platform) {
if (platform === "win32") {
return `\\\\.\\pipe\\netcatty-fido-askpass-${randomUUID().slice(0, 8)}`;
}
return path.posix.join(baseDir, "askpass.sock");
}
function writeSecureFile(filePath, contents, mode = 0o700) {
fs.writeFileSync(filePath, contents, { mode });
try {
fs.chmodSync(filePath, mode);
} catch {
// Windows may ignore mode
}
}
function defaultResolveWebContents() {
try {
const { BrowserWindow } = require("electron");
const focused = BrowserWindow.getFocusedWindow();
if (focused && !focused.isDestroyed()) return focused.webContents;
for (const win of BrowserWindow.getAllWindows()) {
if (!win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) {
return win.webContents;
}
}
} catch {
// not in electron
}
return null;
}
function liveSenderFromResolver(resolver) {
if (typeof resolver !== "function") return null;
try {
const sender = resolver();
if (sender && !sender.isDestroyed?.()) return sender;
} catch {
// ignore dead resolvers
}
return null;
}
/**
* Resolve the WebContents for agent-spawned (leaseless) askpass prompts.
* Prefer the window that most recently completed a leased signing prompt, then
* focus / any open window, then the sticky global fallback.
*/
function resolveSharedAgentPromptSender() {
// An active but released/dead caller must not fall through to another window.
if (activeSigningLeaseId) {
return liveSenderFromResolver(askpassLeases.get(activeSigningLeaseId));
}
const fromLastLease = liveSenderFromResolver(lastLeasedSigningResolver);
if (fromLastLease) return fromLastLease;
for (const resolver of askpassLeases.values()) {
const sender = liveSenderFromResolver(resolver);
if (sender) return sender;
}
return defaultResolveWebContents()
|| liveSenderFromResolver(resolveWebContents);
}
function handleAskpassClient(socket, platform = process.platform) {
let buf = "";
let handled = false;
const controller = new AbortController();
// OpenSSH kills its notification helper as soon as the token is touched.
// Dismiss that prompt even when the renderer has not clicked Continue.
socket.once("close", () => controller.abort());
socket.on("error", () => controller.abort());
socket.setEncoding("utf8");
socket.on("data", async (chunk) => {
if (handled) return;
buf += chunk;
const nl = buf.indexOf("\n");
if (nl === -1) return;
handled = true;
let msg;
try {
msg = JSON.parse(buf.slice(0, nl));
} catch {
socket.end(`${JSON.stringify({ ok: false, error: "bad_json" })}\n`);
return;
}
if (msg?.type === "sk-helper") {
observeSkHelper(socket, signingQueue[0], buf.slice(nl + 1));
return;
}
const prompt = String(msg?.prompt || "");
// OpenSSH notify_start() uses mode=none: hardware completion, not a UI
// answer, authorizes the signature. Exiting this helper only hides the hint.
const notificationOnly = msg?.mode === "none";
const kind = notificationOnly ? "touch"
: msg?.mode === "confirm" ? "confirm"
: fidoPromptHandler.classifyAskpassPrompt(prompt);
const leaseId = typeof msg?.leaseId === "string" ? msg.leaseId : "";
const leaseResolver = leaseId ? askpassLeases.get(leaseId) : null;
// Caller-bound leases (ssh-add) win and mark the active signing window.
// Agent-spawned ssh-sk-helper has no lease (shared agent must not inherit a
// starter lease) — route to that signing window, not the last acquire.
// Tag leaseless prompts with the last signing lease so teardown can cancel.
let sender = null;
/** @type {string} */
let ownerLeaseId = "";
if (leaseResolver) {
sender = liveSenderFromResolver(leaseResolver);
if (sender) {
lastLeasedSigningResolver = leaseResolver;
lastLeasedSigningLeaseId = leaseId;
ownerLeaseId = leaseId;
}
} else {
sender = resolveSharedAgentPromptSender();
ownerLeaseId = activeSigningLeaseId || lastLeasedSigningLeaseId || "";
}
if (!sender) {
socket.end(`${JSON.stringify({ ok: false, error: "no_window" })}\n`);
return;
}
const signing = signingQueue[0]?.leaseId === ownerLeaseId ? signingQueue[0] : null;
try {
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: 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;
socket.end(`${JSON.stringify({ ok: false, error: "cancelled" })}\n`);
return;
}
// 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
&& (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,
});
}
socket.end(`${JSON.stringify({ ok: true, response })}\n`);
} catch (err) {
socket.end(`${JSON.stringify({
ok: false,
error: err instanceof Error ? err.message : String(err),
})}\n`);
}
});
}
/**
* Ensure askpass server + helper scripts exist.
* @param {{ resolveWebContents?: () => import("electron").WebContents|null }} [options]
*/
function ensureFidoAskpass(options = {}) {
if (options.resolveWebContents) resolveWebContents = options.resolveWebContents;
if (askpassServer && askpassSocketPath && askpassWrapperPath) {
return {
socketPath: askpassSocketPath,
scriptPath: askpassScriptPath,
wrapperPath: askpassWrapperPath,
};
}
const base = path.join(getTempBase(), `netcatty-fido-askpass-${randomUUID().slice(0, 8)}`);
fs.mkdirSync(base, { recursive: true, mode: 0o700 });
askpassSocketPath = resolveFidoAskpassSocketPath(base);
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(
askpassWrapperPath,
`@echo off\r\nset ELECTRON_RUN_AS_NODE=1\r\n"${process.execPath.replace(/"/g, '""')}" "${askpassScriptPath.replace(/"/g, '""')}" %*\r\n`,
0o700,
);
} else {
askpassWrapperPath = posixWrapperPath;
}
if (!isWindowsNamedPipePath(askpassSocketPath) && fs.existsSync(askpassSocketPath)) {
try { fs.unlinkSync(askpassSocketPath); } catch { /* ignore */ }
}
askpassServer = net.createServer(handleAskpassClient);
askpassServer.on("error", (err) => {
console.error("[FidoAskpass] server error:", err instanceof Error ? err.message : err);
});
askpassServer.listen(askpassSocketPath);
if (!isWindowsNamedPipePath(askpassSocketPath)) {
try {
fs.chmodSync(askpassSocketPath, 0o600);
} catch {
// ignore
}
}
return {
socketPath: askpassSocketPath,
scriptPath: askpassScriptPath,
wrapperPath: askpassWrapperPath,
};
}
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, sshBinaryPath?: string, env?: NodeJS.ProcessEnv }} [options]
*/
function buildFidoAskpassEnv(options = {}) {
const artifacts = ensureFidoAskpass(options);
const leaseId = randomUUID().slice(0, 12);
if (typeof options.resolveWebContents === "function") {
askpassLeases.set(leaseId, options.resolveWebContents);
}
const env = {
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,
// OpenSSH only runs askpass when no TTY unless REQUIRE=force; still set DISPLAY on Linux.
...(process.platform === "linux" && !process.env.DISPLAY
? { DISPLAY: process.env.DISPLAY || ":0" }
: {}),
};
return env;
}
function releaseFidoAskpassLease(leaseId) {
if (typeof leaseId !== "string" || !leaseId) return;
const released = askpassLeases.get(leaseId);
askpassLeases.delete(leaseId);
for (const entry of signingQueue) {
if (entry.leaseId === leaseId) entry.controller.abort();
}
try {
fidoPromptHandler.cancelFidoPromptRequestsForLease(leaseId, "lease-released");
} catch {
// ignore
}
if (released && lastLeasedSigningResolver === released) {
lastLeasedSigningResolver = null;
lastLeasedSigningLeaseId = null;
for (const [otherLeaseId, resolver] of askpassLeases.entries()) {
if (liveSenderFromResolver(resolver)) {
lastLeasedSigningResolver = resolver;
lastLeasedSigningLeaseId = otherLeaseId;
break;
}
}
} else if (lastLeasedSigningLeaseId === leaseId) {
lastLeasedSigningLeaseId = null;
}
}
function shutdownFidoAskpass() {
try {
askpassServer?.close();
} catch {
// ignore
}
askpassServer = null;
askpassSocketPath = null;
askpassScriptPath = null;
askpassWrapperPath = null;
const outstandingLeaseIds = [...askpassLeases.keys()];
askpassLeases.clear();
lastLeasedSigningResolver = null;
lastLeasedSigningLeaseId = null;
for (const leaseId of outstandingLeaseIds) {
try {
fidoPromptHandler.cancelFidoPromptRequestsForLease(leaseId, "askpass-shutdown");
} catch {
// ignore
}
}
}
module.exports = {
ensureFidoAskpass,
buildFidoAskpassEnv,
runWithFidoSigningLease,
setHelperTimeoutForTests,
releaseFidoAskpassLease,
shutdownFidoAskpass,
classifyAskpassPrompt: fidoPromptHandler.classifyAskpassPrompt,
resolveFidoAskpassSocketPath,
isWindowsNamedPipePath,
selectFidoAskpassWrapper,
isWindowsPosixSsh,
// exposed for tests
getTempBase,
handleAskpassClient,
FIDO_ASKPASS_SCRIPT,
setResolveWebContentsForTests(resolver) {
resolveWebContents = typeof resolver === "function" ? resolver : null;
},
getAskpassLeaseCountForTests() {
return askpassLeases.size;
},
};
-319
View File
@@ -1,319 +0,0 @@
"use strict";
/**
* Simulated FIDO askpass verification (no hardware).
*
* Exercises the OpenSSH SSH_ASKPASS helper → Netcatty IPC socket → prompt
* handler → PIN response path that real ssh-sk-helper would trigger.
*/
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
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 'q$-"));
const originalGetTempDir = tempDirBridge.getTempDir;
tempDirBridge.getTempDir = () => managedTemp;
const {
buildFidoAskpassEnv,
ensureFidoAskpass,
shutdownFidoAskpass,
setResolveWebContentsForTests,
} = require("./fidoAskpass.cjs");
const fidoPromptHandler = require("./fidoPromptHandler.cjs");
test.after(() => {
tempDirBridge.getTempDir = originalGetTempDir;
try { fs.rmSync(managedTemp, { recursive: true, force: true }); } catch { /* ignore */ }
});
function runAskpassHelper({ wrapperPath, socketPath, prompt, timeoutMs = 5000, envExtra = {} }) {
return new Promise((resolve, reject) => {
const child = spawn(wrapperPath, [prompt], {
env: {
...process.env,
ELECTRON_RUN_AS_NODE: "1",
NETCATTY_FIDO_ASKPASS_SOCK: socketPath,
SSH_ASKPASS_REQUIRE: "force",
...envExtra,
},
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
const timer = setTimeout(() => {
child.kill("SIGKILL");
reject(new Error("askpass helper timed out"));
}, timeoutMs);
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout += chunk; });
child.stderr.on("data", (chunk) => { stderr += chunk; });
child.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
child.on("close", (code) => {
clearTimeout(timer);
resolve({ code, stdout, stderr });
});
});
}
test("simulated FIDO askpass returns PIN through helper subprocess", async () => {
const pending = new Map();
const sender = {
id: 4242,
isDestroyed: () => false,
send(_channel, payload) {
pending.set(payload.requestId, payload);
// Auto-respond like the renderer FidoPromptModal would.
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 4242 } },
{ requestId: payload.requestId, response: "sim-pin-1234", cancelled: false },
);
});
},
};
setResolveWebContentsForTests(() => sender);
try {
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
const artifacts = ensureFidoAskpass({ resolveWebContents: () => sender });
assert.equal(artifacts.socketPath, env.NETCATTY_FIDO_ASKPASS_SOCK);
assert.ok(env.NETCATTY_FIDO_ASKPASS_LEASE);
// Give the listen() a tick — Node can accept before the event loop settles.
await new Promise((r) => setTimeout(r, 50));
const result = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:",
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: env.NETCATTY_FIDO_ASKPASS_LEASE },
});
assert.equal(result.code, 0, `stderr=${result.stderr}`);
assert.equal(result.stdout.trim(), "sim-pin-1234");
assert.ok(pending.size >= 1, "expected a prompt request to be delivered");
const delivered = [...pending.values()][0];
assert.equal(delivered.kind, "pin");
} finally {
setResolveWebContentsForTests(null);
shutdownFidoAskpass();
}
});
test("askpass leases route prompts to the originating resolver", async () => {
const seen = [];
const senderA = {
id: 100,
isDestroyed: () => false,
send(_channel, payload) {
seen.push(100);
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 100 } },
{ requestId: payload.requestId, response: "pin-a", cancelled: false },
);
});
},
};
const senderB = {
id: 200,
isDestroyed: () => false,
send(_channel, payload) {
seen.push(200);
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 200 } },
{ requestId: payload.requestId, response: "pin-b", cancelled: false },
);
});
},
};
// Last global resolver would be B; lease must still deliver to A.
setResolveWebContentsForTests(() => senderB);
try {
const envA = buildFidoAskpassEnv({ resolveWebContents: () => senderA });
buildFidoAskpassEnv({ resolveWebContents: () => senderB });
const artifacts = ensureFidoAskpass();
await new Promise((r) => setTimeout(r, 50));
const result = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:",
// Override lease to A's env lease id.
envExtra: {
NETCATTY_FIDO_ASKPASS_LEASE: envA.NETCATTY_FIDO_ASKPASS_LEASE,
},
});
assert.equal(result.code, 0, `stderr=${result.stderr}`);
assert.equal(result.stdout.trim(), "pin-a");
assert.deepEqual(seen, [100]);
} finally {
setResolveWebContentsForTests(null);
shutdownFidoAskpass();
}
});
test("simulated FIDO askpass touch prompt returns empty confirmation", async () => {
const sender = {
id: 4243,
isDestroyed: () => false,
send(_channel, payload) {
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 4243 } },
{ requestId: payload.requestId, response: "", cancelled: false },
);
});
},
};
setResolveWebContentsForTests(() => sender);
try {
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
const artifacts = ensureFidoAskpass({ resolveWebContents: () => sender });
await new Promise((r) => setTimeout(r, 50));
const result = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Confirm user presence for key ED25519-SK",
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: env.NETCATTY_FIDO_ASKPASS_LEASE },
});
assert.equal(result.code, 0, `stderr=${result.stderr}`);
assert.equal(result.stdout.trim(), "");
} finally {
setResolveWebContentsForTests(null);
shutdownFidoAskpass();
}
});
test("leaseless agent prompts route to the last leased signing window", async () => {
const seen = [];
const senderA = {
id: 501,
isDestroyed: () => false,
send(_channel, payload) {
seen.push(501);
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 501 } },
{ requestId: payload.requestId, response: "pin-a", cancelled: false },
);
});
},
};
const senderB = {
id: 502,
isDestroyed: () => false,
send(_channel, payload) {
seen.push(502);
queueMicrotask(() => {
fidoPromptHandler.handleResponse(
{ sender: { id: 502 } },
{ requestId: payload.requestId, response: "pin-b", cancelled: false },
);
});
},
};
// Global / last-acquire resolver is B (terminal-worker has no BrowserWindow).
setResolveWebContentsForTests(() => senderB);
try {
const envA = buildFidoAskpassEnv({ resolveWebContents: () => senderA });
buildFidoAskpassEnv({ resolveWebContents: () => senderB });
const artifacts = ensureFidoAskpass();
await new Promise((r) => setTimeout(r, 50));
// First: leased ssh-add prompt binds the active signing window to A.
const leased = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:",
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: envA.NETCATTY_FIDO_ASKPASS_LEASE },
});
assert.equal(leased.code, 0, `stderr=${leased.stderr}`);
assert.equal(leased.stdout.trim(), "pin-a");
// Then: agent-spawned ssh-sk-helper has no lease; must still reach A, not B.
const leaseless = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath,
socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:",
envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: "" },
});
assert.equal(leaseless.code, 0, `stderr=${leaseless.stderr}`);
assert.equal(leaseless.stdout.trim(), "pin-a");
assert.deepEqual(seen, [501, 501]);
} finally {
setResolveWebContentsForTests(null);
shutdownFidoAskpass();
}
});
test("OpenSSH notification helper exit dismisses its pending touch prompt", async () => {
let delivered;
const requested = new Promise((resolve) => { delivered = resolve; });
const notifications = [];
const sender = {
id: 2823, isDestroyed: () => false,
send(channel, payload) {
notifications.push({ channel, payload });
if (channel === "netcatty:fido-prompt-request") delivered(payload);
},
};
let child;
const env = buildFidoAskpassEnv({ resolveWebContents: () => sender });
try {
child = spawn(env.SSH_ASKPASS, ["Confirm user presence for key ED25519-SK"], {
env: { ...process.env, ...env, SSH_ASKPASS_PROMPT: "none" }, stdio: "ignore",
});
const request = await requested;
assert.equal(request.kind, "touch");
assert.equal(request.notificationOnly, true);
const closed = new Promise((resolve) => child.once("close", resolve));
child.kill("SIGTERM");
await closed;
for (let i = 0; i < 50 && fidoPromptHandler.getRequests().has(request.requestId); i++) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
assert.equal(fidoPromptHandler.getRequests().has(request.requestId), false);
assert.ok(notifications.some(({ channel, payload }) =>
channel === "netcatty:fido-prompt-cancelled" && payload.requestId === request.requestId));
} finally {
child?.kill();
require("./fidoAskpass.cjs").releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
shutdownFidoAskpass();
}
});
test("late prompts for a released signing lease never reach another window", async () => {
let otherPrompts = 0;
const owner = { id: 810, isDestroyed: () => false, send() {} };
const other = { id: 811, isDestroyed: () => false, send() { otherPrompts++; } };
const env = buildFidoAskpassEnv({ resolveWebContents: () => owner });
buildFidoAskpassEnv({ resolveWebContents: () => other });
const api = require("./fidoAskpass.cjs");
let finish;
api.runWithFidoSigningLease(env.NETCATTY_FIDO_ASKPASS_LEASE, done => { finish = done; });
api.releaseFidoAskpassLease(env.NETCATTY_FIDO_ASKPASS_LEASE);
try {
const artifacts = ensureFidoAskpass();
const result = await runAskpassHelper({
wrapperPath: artifacts.wrapperPath, socketPath: artifacts.socketPath,
prompt: "Enter PIN for authenticator:", envExtra: { NETCATTY_FIDO_ASKPASS_LEASE: "" },
});
assert.equal(result.code, 1);
assert.equal(otherPrompts, 0);
} finally { finish(); shutdownFidoAskpass(); }
});
-89
View File
@@ -1,89 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
classifyAskpassPrompt,
buildFidoAskpassEnv,
ensureFidoAskpass,
releaseFidoAskpassLease,
shutdownFidoAskpass,
getTempBase,
} = 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();
assert.ok(env.SSH_ASKPASS);
assert.equal(env.SSH_ASKPASS_REQUIRE, "force");
assert.ok(env.NETCATTY_FIDO_ASKPASS_SOCK);
assert.ok(fs.existsSync(env.SSH_ASKPASS));
const artifacts = ensureFidoAskpass();
assert.equal(artifacts.wrapperPath, env.SSH_ASKPASS);
shutdownFidoAskpass();
});
test("getTempBase uses Netcatty managed temp dir (no os.tmpdir fallback)", () => {
const tempDirBridge = require("./tempDirBridge.cjs");
const managed = tempDirBridge.getTempDir();
assert.equal(getTempBase(), managed);
assert.match(managed, /Netcatty/i);
});
test("classifyAskpassPrompt re-export works", () => {
assert.equal(classifyAskpassPrompt("Enter PIN for authenticator"), "pin");
assert.equal(classifyAskpassPrompt("Confirm user presence"), "touch");
});
test("releaseFidoAskpassLease cancels outstanding FIDO prompts for that lease", async () => {
const sent = [];
const sender = {
id: 77,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const leaseId = "lease-release-cancel-test";
const pending = fidoPromptHandler.requestFidoPrompt(sender, {
kind: "pin",
message: "Enter PIN",
leaseId,
});
releaseFidoAskpassLease(leaseId);
assert.deepEqual(await pending, { cancelled: true });
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(); }
});
@@ -1,36 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
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), 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", () => {
const sock = resolveFidoAskpassSocketPath("/tmp/netcatty-fido-askpass-abcd", "linux");
assert.equal(sock, "/tmp/netcatty-fido-askpass-abcd/askpass.sock");
assert.equal(isWindowsNamedPipePath(sock), false);
});
-115
View File
@@ -1,115 +0,0 @@
"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 };
-179
View File
@@ -1,179 +0,0 @@
"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));
}
});
-209
View File
@@ -1,209 +0,0 @@
"use strict";
/**
* FIDO2 PIN / touch prompts for OpenSSH sk-* flows.
* Mirrors passphraseHandler: main requests → renderer modal → respond IPC.
*/
const { randomUUID } = require("node:crypto");
/** @type {Map<string, {
* resolveCallback: (result: { cancelled?: boolean, response?: string|null }) => void,
* sender: Electron.WebContents,
* webContentsId: number,
* kind: string,
* leaseId: string,
* createdAt: number,
* timeoutId: NodeJS.Timeout,
* }>} */
const fidoPromptRequests = new Map();
const REQUEST_TTL_MS = 3 * 60 * 1000;
function generateRequestId(prefix = "fido") {
return `${prefix}-${randomUUID()}`;
}
function settleRequest(requestId, result, notification) {
const pending = fidoPromptRequests.get(requestId);
if (!pending) return false;
if (pending.timeoutId) clearTimeout(pending.timeoutId);
pending.abortCleanup?.();
fidoPromptRequests.delete(requestId);
if (notification) {
try {
if (!pending.sender?.isDestroyed?.()) {
pending.sender?.send?.(notification.channel, {
requestId,
...(notification.payload || {}),
});
}
} catch (err) {
console.warn(`[FidoPrompt] Failed to send ${notification.channel}:`, err.message);
}
}
pending.resolveCallback(result);
return true;
}
function cancelFidoPromptRequest(requestId, reason = "cancelled") {
return settleRequest(
requestId,
{ cancelled: true },
{
channel: "netcatty:fido-prompt-cancelled",
payload: { reason },
},
);
}
/**
* Cancel prompts owned by an askpass lease (ssh-add or inherited agent signing).
* @param {string} leaseId
* @param {string} [reason]
* @returns {number}
*/
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;
}
/**
* @param {Electron.WebContents|null|undefined} sender
* @param {{
* kind: "pin" | "touch" | "confirm" | "recovery",
* reason?: string,
* allowPasswordFallback?: boolean,
* message?: string,
* title?: string,
* keyName?: string,
* notificationOnly?: boolean,
* leaseId?: string,
* signal?: AbortSignal,
* }} options
* @returns {Promise<{ cancelled?: boolean, response?: string|null } | null>}
*/
function requestFidoPrompt(sender, options = {}) {
return new Promise((resolve) => {
if (!sender || sender.isDestroyed?.()) {
console.warn("[FidoPrompt] No live sender; cannot show FIDO prompt");
resolve(null);
return;
}
const kind = ["touch", "confirm", "recovery"].includes(options.kind) ? options.kind : "pin";
const leaseId = typeof options.leaseId === "string" ? options.leaseId : "";
const requestId = generateRequestId(kind);
const timeoutId = setTimeout(() => {
settleRequest(
requestId,
{ cancelled: true },
{ channel: "netcatty:fido-prompt-timeout" },
);
}, REQUEST_TTL_MS);
fidoPromptRequests.set(requestId, {
resolveCallback: resolve,
sender,
webContentsId: sender.id,
kind,
leaseId,
notificationOnly: options.notificationOnly === true,
createdAt: Date.now(),
timeoutId,
});
if (options.signal) {
const onAbort = () => cancelFidoPromptRequest(requestId, "helper-exited");
const pending = fidoPromptRequests.get(requestId);
pending.abortCleanup = () => options.signal.removeEventListener("abort", onAbort);
options.signal.addEventListener("abort", onAbort, { once: true });
if (options.signal.aborted) {
onAbort();
return;
}
}
try {
sender.send("netcatty:fido-prompt-request", {
requestId,
kind,
message: options.message || "",
title: options.title || "",
keyName: options.keyName || "",
notificationOnly: options.notificationOnly === true,
reason: options.reason,
allowPasswordFallback: options.allowPasswordFallback === true,
});
} catch (err) {
console.error("[FidoPrompt] Failed to send request:", err);
settleRequest(requestId, null);
}
});
}
function handleResponse(_event, payload = {}) {
const { requestId, response, cancelled } = payload;
const pending = fidoPromptRequests.get(requestId);
if (!pending) {
return { success: false, error: "Request not found" };
}
if (_event?.sender?.id !== pending.webContentsId) {
return { success: false, error: "Wrong sender" };
}
if (cancelled) {
settleRequest(requestId, { cancelled: true });
} else {
settleRequest(requestId, { response: typeof response === "string" ? response : "" });
}
return { success: true };
}
function registerHandler(ipcMain) {
ipcMain.handle("netcatty:fido-prompt:respond", handleResponse);
}
function getRequests() {
return fidoPromptRequests;
}
/**
* Classify OpenSSH / ssh-sk-helper askpass prompt text.
* @param {string} prompt
* @returns {"pin"|"touch"|"confirm"}
*/
function classifyAskpassPrompt(prompt) {
const p = String(prompt || "").toLowerCase();
// ssh-agent combines both requirements in one prompt for verify-required
// keys: "Enter PIN and confirm user presence ...". It still expects a PIN
// on stdout; classifying it as touch would silently return an empty string.
if (/\bpin\b|密码|密碼|口令/.test(p)) return "pin";
if (/confirm user presence|touch (your |the )?(security )?key|tap (your |the )?(security )?key|user presence|请触摸|請觸摸|请轻触/.test(p)) {
return "touch";
}
if (/pin|authenticator|fido|security key|enter.*code|密码|pin 码|口令/.test(p)) {
return "pin";
}
// Default to PIN-style secret entry for unknown askpass prompts from sk-helper.
return "pin";
}
module.exports = {
generateRequestId,
requestFidoPrompt,
cancelFidoPromptRequest,
cancelFidoPromptRequestsForLease,
handleResponse,
registerHandler,
getRequests,
classifyAskpassPrompt,
};
-106
View File
@@ -1,106 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
classifyAskpassPrompt,
requestFidoPrompt,
cancelFidoPromptRequestsForLease,
handleResponse,
getRequests,
} = require("./fidoPromptHandler.cjs");
test("classifyAskpassPrompt maps OpenSSH sk-helper prompts", () => {
assert.equal(classifyAskpassPrompt("Enter PIN for authenticator:"), "pin");
assert.equal(classifyAskpassPrompt("Confirm user presence for key ED25519-SK"), "touch");
assert.equal(classifyAskpassPrompt("Touch your security key"), "touch");
assert.equal(classifyAskpassPrompt("请触摸你的安全密钥"), "touch");
assert.equal(classifyAskpassPrompt("Something unknown"), "pin");
});
test("requestFidoPrompt delivers IPC payload and resolves on respond", async () => {
const sent = [];
const sender = {
id: 42,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const pending = requestFidoPrompt(sender, {
kind: "pin",
message: "Enter PIN for authenticator:",
keyName: "yubi",
});
assert.equal(sent.length, 1);
assert.equal(sent[0].channel, "netcatty:fido-prompt-request");
assert.equal(sent[0].payload.kind, "pin");
assert.equal(getRequests().size, 1);
const requestId = sent[0].payload.requestId;
const result = handleResponse(
{ sender: { id: 42 } },
{ requestId, response: "123456", cancelled: false },
);
assert.equal(result.success, true);
const resolved = await pending;
assert.deepEqual(resolved, { response: "123456" });
assert.equal(getRequests().size, 0);
});
test("requestFidoPrompt cancel path", async () => {
const sent = [];
const sender = {
id: 7,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const pending = requestFidoPrompt(sender, { kind: "touch", message: "Touch key" });
const requestId = sent[0].payload.requestId;
handleResponse({ sender: { id: 7 } }, { requestId, cancelled: true });
const resolved = await pending;
assert.deepEqual(resolved, { cancelled: true });
});
test("cancelFidoPromptRequestsForLease settles owned prompts and notifies renderer", async () => {
const sent = [];
const sender = {
id: 11,
isDestroyed: () => false,
send: (channel, payload) => sent.push({ channel, payload }),
};
const pending = requestFidoPrompt(sender, {
kind: "pin",
message: "Enter PIN",
leaseId: "lease-abc",
});
const other = requestFidoPrompt(sender, {
kind: "touch",
message: "Touch key",
leaseId: "lease-other",
});
const otherRequestId = sent.find((entry) => (
entry.channel === "netcatty:fido-prompt-request" && entry.payload.kind === "touch"
)).payload.requestId;
assert.equal(cancelFidoPromptRequestsForLease("lease-abc", "lease-released"), 1);
assert.deepEqual(await pending, { cancelled: true });
assert.equal(getRequests().size, 1);
assert.ok(sent.some((entry) => (
entry.channel === "netcatty:fido-prompt-cancelled"
&& entry.payload.reason === "lease-released"
)));
handleResponse({ sender: { id: 11 } }, { requestId: otherRequestId, cancelled: true });
await other;
});
test("combined OpenSSH PIN and user-presence prompts require secret entry", () => {
for (const algorithm of ["ED25519-SK", "ECDSA-SK"]) {
assert.equal(
classifyAskpassPrompt(`Enter PIN and confirm user presence for ${algorithm} key SHA256:test: `),
"pin",
);
}
});
-49
View File
@@ -1,49 +0,0 @@
"use strict";
const prompts = require("./fidoPromptHandler.cjs");
function failureReason(context) {
if (context.timedOut) return "timeout";
if (context.helperError === 60) return "device-unavailable";
if (context.helperError === 43 && context.pinSubmitted) return "pin-failed";
return "failed";
}
function cancelledError() {
const error = new Error("Security key authentication cancelled.");
error.code = "ERR_FIDO_CANCELLED";
return error;
}
async function signWithRecovery(context, options, sign) {
context.keyName = options.keyLabel || "";
context.allowPasswordFallback = Boolean(options.password);
for (;;) {
if (context.controller.signal.aborted) return { error: cancelledError() };
context.helperError = null;
context.pinSubmitted = false;
context.cancelled = false;
context.timedOut = false;
context.attemptController = new AbortController();
const result = await new Promise(resolve => {
try { sign((error, signature) => resolve({ error, signature })); }
catch (error) { resolve({ error }); }
});
context.attemptController.abort();
if (context.cancelled || context.controller.signal.aborted) return { error: cancelledError() };
if (!result.error && !context.timedOut) return result;
if (context.timedOut) result.error = new Error("Security key operation timed out");
const sender = options.resolveWebContents?.();
if (!sender || sender.isDestroyed?.()) return { error: cancelledError() };
const response = await prompts.requestFidoPrompt(sender, {
kind: "recovery", reason: failureReason(context), keyName: context.keyName,
allowPasswordFallback: context.allowPasswordFallback, leaseId: context.leaseId,
signal: context.controller.signal,
});
if (response?.response === "retry") continue;
if (response?.response === "fallback" && context.allowPasswordFallback) return result;
return { error: cancelledError() };
}
}
module.exports = { signWithRecovery, failureReason, cancelledError };
-41
View File
@@ -1,41 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { signWithRecovery } = require("./fidoSignFlow.cjs");
const prompts = require("./fidoPromptHandler.cjs");
test("a signature arriving after cancellation is never returned to SSH", async () => {
const context = { leaseId: "cancelled-sign", controller: new AbortController() };
const result = await signWithRecovery(context, {}, callback => {
context.controller.abort();
callback(null, Buffer.from("late signature"));
});
assert.equal(result.error.code, "ERR_FIDO_CANCELLED");
assert.equal(result.signature, undefined);
});
test("a device result after the deadline requires recovery even if its signature is valid", async () => {
const context = { leaseId: "late-sign", controller: new AbortController() };
const received = [];
const sender = { id: 311, isDestroyed: () => false, send(channel, request) {
if (channel !== "netcatty:fido-prompt-request") return;
received.push(request);
queueMicrotask(() => prompts.handleResponse({ sender }, { requestId: request.requestId, cancelled: true }));
} };
const result = await signWithRecovery(context, { resolveWebContents: () => sender }, callback => {
context.timedOut = true;
callback(null, Buffer.from("late signature"));
});
assert.equal(received[0].reason, "timeout");
assert.equal(result.error.code, "ERR_FIDO_CANCELLED");
assert.equal(result.signature, undefined);
});
test("losing the prompt window fails closed instead of choosing a saved password", async () => {
const context = { leaseId: "missing-window", controller: new AbortController() };
const result = await signWithRecovery(context, { password: "test-only", resolveWebContents: () => null }, callback => {
callback(new Error("agent refused"));
});
assert.equal(result.error.code, "ERR_FIDO_CANCELLED");
});
-102
View File
@@ -1,102 +0,0 @@
"use strict";
// Observe the OpenSSH helper's error reply before ssh-agent reduces it to a
// generic SSH_AGENT_FAILURE. Requests/signatures are forwarded unchanged and
// are never logged. Windows agents without an executable wrapper keep their
// native helper; callers then use the generic recovery message.
const fs = require("node:fs");
const path = require("node:path");
const HELPER_SCRIPT = String.raw`"use strict";
const { spawn } = require("node:child_process");
const net = require("node:net");
const child = spawn(process.env.NETCATTY_FIDO_REAL_SK_HELPER, process.argv.slice(2), {
env: process.env, stdio: ["pipe", "pipe", "inherit"],
});
let control, response = [], size = 0, finished = false, exiting = false, exitCode = 1, connected = false;
const flush = () => {
if (exiting || !finished) return;
exiting = true;
control?.destroy();
process.stdout.end(Buffer.concat(response), () => process.exit(exitCode));
};
child.stdin.on("error", () => {});
process.stdin.pipe(child.stdin);
child.stdout.on("data", chunk => {
size += chunk.length;
if (size > 1024 * 1024) { child.kill(); return; }
response.push(chunk);
});
let incoming = "";
let errorCode = null;
const report = () => {
if (!finished) return;
if (connected && control?.writable && !control.destroyed) {
control.write(JSON.stringify({ phase: "result", errorCode }) + "\n");
} else if (!control || control.destroyed) flush();
};
try {
control = net.createConnection(process.env.NETCATTY_FIDO_ASKPASS_SOCK);
control.on("connect", () => {
connected = true;
control.write(JSON.stringify({ type: "sk-helper" }) + "\n");
report();
});
control.setEncoding("utf8");
control.on("data", chunk => {
incoming += chunk;
let nl;
while ((nl = incoming.indexOf("\n")) !== -1) {
const line = incoming.slice(0, nl); incoming = incoming.slice(nl + 1);
let message; try { message = JSON.parse(line); } catch { continue; }
if (message.cancel === true) child.kill("SIGTERM");
if (message.ack === true) flush();
}
});
control.on("error", flush);
control.on("close", flush);
} catch { /* The native helper still works if observation is unavailable. */ }
const complete = code => {
if (finished) return;
finished = true; exitCode = code === 0 ? 0 : 1;
const packet = Buffer.concat(response);
// ssh_msg framing: length, version, reply type, positive SSH error number.
errorCode = packet.length === 13 && packet[4] === 5 && packet.readUInt32BE(0) === 9
&& packet.readUInt32BE(5) === 0 ? packet.readUInt32BE(9) : null;
report();
setTimeout(flush, 1000).unref();
};
child.on("error", () => complete(1));
child.on("close", complete);
process.on("SIGTERM", () => { child.kill("SIGTERM"); complete(1); });
`;
function resolveSkHelper(sshAgentPath, env = process.env) {
const agents = [sshAgentPath];
try { agents.push(fs.realpathSync(sshAgentPath)); } catch { /* PATH binary */ }
const candidates = [env.SSH_SK_HELPER];
for (const agent of agents.filter(Boolean)) {
candidates.push(path.resolve(path.dirname(agent), "../libexec/ssh-sk-helper"));
candidates.push(path.resolve(path.dirname(agent), "../libexec/ssh/ssh-sk-helper"));
}
candidates.push("/usr/lib/openssh/ssh-sk-helper", "/usr/libexec/ssh-sk-helper", "/usr/lib/ssh/ssh-sk-helper");
return candidates.find(candidate => {
if (!candidate) return false;
try { fs.accessSync(candidate, fs.constants.X_OK); return true; } catch { return false; }
}) || null;
}
function buildSkHelperEnv(sshAgentPath, askpassEnv, env = process.env, platform = process.platform) {
if (platform === "win32") return {};
const helper = resolveSkHelper(sshAgentPath, env);
if (!helper || !askpassEnv?.SSH_ASKPASS) return {};
const base = path.dirname(askpassEnv.SSH_ASKPASS);
const script = path.join(base, "sk-helper.cjs");
const wrapper = path.join(base, "sk-helper.sh");
fs.writeFileSync(script, HELPER_SCRIPT, { mode: 0o700 });
const quote = value => "'" + value.replace(/'/g, "'\\''") + "'";
fs.writeFileSync(wrapper, `#!/bin/sh\nELECTRON_RUN_AS_NODE=1 exec ${quote(process.execPath)} ${quote(script)} "$@"\n`, { mode: 0o700 });
return { SSH_SK_HELPER: wrapper, NETCATTY_FIDO_REAL_SK_HELPER: helper };
}
module.exports = { buildSkHelperEnv, resolveSkHelper };
-152
View File
@@ -1,152 +0,0 @@
"use strict";
/**
* Generate OpenSSH FIDO2 (sk-*) key pairs via ssh-keygen.
*
* Requires a platform OpenSSH build with libfido2 (e.g. Homebrew openssh on
* macOS). The user must touch the security key (and enter a PIN when required).
*/
const fs = require("node:fs");
const path = require("node:path");
const { execFile } = require("node:child_process");
const { promisify } = require("node:util");
const { randomUUID } = require("node:crypto");
const execFileAsync = promisify(execFile);
function resolveSshKeygenBinary(env = process.env) {
if (typeof env.NETCATTY_SSH_KEYGEN_PATH === "string" && env.NETCATTY_SSH_KEYGEN_PATH.trim()) {
return env.NETCATTY_SSH_KEYGEN_PATH.trim();
}
if (process.platform === "darwin") {
// Prefer Homebrew OpenSSH when present (system ssh often lacks libfido2).
for (const candidate of [
"/opt/homebrew/bin/ssh-keygen",
"/usr/local/bin/ssh-keygen",
"/usr/bin/ssh-keygen",
]) {
try {
if (fs.existsSync(candidate)) return candidate;
} catch {
// continue
}
}
}
return "ssh-keygen";
}
/**
* @param {{
* type: "ED25519-SK" | "ECDSA-SK",
* comment?: string,
* resident?: boolean,
* verifyRequired?: boolean,
* env?: NodeJS.ProcessEnv,
* execFile?: typeof execFileAsync,
* sshKeygenPath?: string,
* }} options
*/
async function generateFidoSshKeyPair(options = {}) {
const type = options.type === "ECDSA-SK" ? "ecdsa-sk" : "ed25519-sk";
const comment = options.comment || "netcatty-fido-key";
const env = options.env || process.env;
const run = options.execFile || execFileAsync;
const sshKeygen = options.sshKeygenPath || resolveSshKeygenBinary(env);
let baseDir;
try {
const tempDirBridge = require("./tempDirBridge.cjs");
if (typeof tempDirBridge.getTempDir !== "function") {
return {
success: false,
error: "FIDO2 key generation requires Netcatty temp directory (tempDirBridge unavailable).",
};
}
baseDir = tempDirBridge.getTempDir();
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return {
success: false,
error: `FIDO2 key generation requires Netcatty temp directory: ${message}`,
};
}
const tempDir = await fs.promises.mkdtemp(path.join(baseDir, "netcatty-fido-keygen-"));
const keyPath = path.join(tempDir, `id_${type.replace(/-sk$/, "_sk")}_${randomUUID().slice(0, 8)}`);
let askpassLeaseId = null;
try {
const args = [
"-t", type,
"-f", keyPath,
"-C", comment,
"-N", "", // empty passphrase on the key *handle* file (PIN is on the device)
];
if (options.resident) args.push("-O", "resident");
if (options.verifyRequired) args.push("-O", "verify-required");
let askpassEnv = {};
try {
const { buildFidoAskpassEnv } = require("./fidoAskpass.cjs");
askpassEnv = buildFidoAskpassEnv({
resolveWebContents: options.resolveWebContents,
sshBinaryPath: sshKeygen,
env,
});
askpassLeaseId = askpassEnv.NETCATTY_FIDO_ASKPASS_LEASE || null;
} catch {
// GUI askpass unavailable in pure unit tests
}
try {
await run(sshKeygen, args, {
timeout: 180000,
windowsHide: true,
env: {
...env,
...askpassEnv,
// Force askpass so PIN/touch go through Netcatty modals (no TTY).
SSH_ASKPASS_REQUIRE: askpassEnv.SSH_ASKPASS
? "force"
: (env.SSH_ASKPASS_REQUIRE || undefined),
},
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
const stderr = error?.stderr?.toString?.() || error?.message || String(error);
const hint = /provider|fido|sk_|feature not supported|invalid format|device not found|no such file/i.test(stderr)
? " Ensure OpenSSH is built with libfido2 (macOS: brew install openssh libfido2) and a FIDO2 key is plugged in."
: "";
return {
success: false,
error: `FIDO2 key generation failed: ${stderr.trim()}.${hint}`,
};
}
const [privateKey, publicKey] = await Promise.all([
fs.promises.readFile(keyPath, "utf8"),
fs.promises.readFile(`${keyPath}.pub`, "utf8"),
]);
return {
success: true,
privateKey,
publicKey: publicKey.trim(),
keyType: options.type === "ECDSA-SK" ? "ECDSA-SK" : "ED25519-SK",
};
} finally {
if (askpassLeaseId) {
try {
require("./fidoAskpass.cjs").releaseFidoAskpassLease(askpassLeaseId);
} catch {
// ignore
}
}
await fs.promises.rm(tempDir, { recursive: true, force: true }).catch(() => {});
}
}
module.exports = {
generateFidoSshKeyPair,
resolveSshKeygenBinary,
};
+1 -2
View File
@@ -352,8 +352,7 @@ function createMonitorService({
historyId,
status: entry?.status ?? host.status ?? "limit",
at: shared?.at ?? 0,
statsAt: shared?.statsAt ?? shared?.at ?? 0,
...(append && last?.historyId === historyId && last.statsAt === (shared?.statsAt ?? shared?.at ?? 0)
...(append && last?.historyId === historyId && last.statsAt === (shared?.at ?? 0)
? {}
: { stats: shared?.result?.success ? shared.result.stats : null }),
points: append && last?.historyId === historyId
+1 -21
View File
@@ -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: 75,
gpu: ctx._monitorStats ? null : 75,
},
};
};
@@ -752,23 +752,3 @@ 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();}
});
+5 -35
View File
@@ -1405,19 +1405,13 @@ async function startPortForward(event, payload) {
let defaultKeys = [];
let portForwardAuthPhase = { hadPartialSuccess: false, passwordAlreadySucceeded: false };
let authBanner = "";
/** @type {(() => void)|null} */
let releasePreparedFidoAgent = null;
try {
const fallbackAgentSocket = useSshAgent === false
? null
: useSshAgent === true
? undefined
: await getAvailableAgentSocket(identityAgent, { hostname, port, username });
const {
enhanceAuthOptionsForFido,
looksLikeSkOpenSshMaterial,
} = require("./sshAuthHelper.cjs");
const prep = await enhanceAuthOptionsForFido({
const systemAuthAgent = hasCertificate ? null : await prepareSystemSshAgentForAuth({
useSshAgent,
agentPublicKeys,
identityAgent,
@@ -1425,24 +1419,10 @@ async function startPortForward(event, payload) {
identitiesOnly,
addKeysToAgent,
useKeychain,
privateKey,
certificate,
hostname,
port,
username,
}, sender);
const isFido = prep.useFidoAgent === true;
const systemAuthAgent = (hasCertificate && !isFido) ? null : await prepareSystemSshAgentForAuth(
prep,
"[PortForward]",
);
// Attach immediately so cancelTunnel ending `conn` during/after prepare
// still drops the owned FIDO agent + askpass lease. Also keep a direct
// handle for cancel-before-connect returns (close may have fired before
// this listener existed).
releasePreparedFidoAgent = systemAuthAgent
? require("./attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent)
: null;
}, "[PortForward]");
const identityFile = !privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
sender,
@@ -1456,9 +1436,7 @@ async function startPortForward(event, payload) {
},
})
: null;
const inlineKey = privateKey
&& !systemAuthAgent
&& !looksLikeSkOpenSshMaterial(privateKey)
const inlineKey = privateKey && !systemAuthAgent
? await preparePrivateKeyForAuth({
sender,
privateKey,
@@ -1474,7 +1452,6 @@ async function startPortForward(event, payload) {
const effectivePassphrase = inlineKey?.passphrase || identityFile?.passphrase;
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
@@ -1483,7 +1460,7 @@ async function startPortForward(event, payload) {
if (systemAuthAgent) {
connectOpts.agent = systemAuthAgent;
}
if (hasCertificate && !isFido) {
if (hasCertificate) {
connectOpts.agent = new NetcattyAgent({
mode: "certificate",
webContents: sender,
@@ -1494,7 +1471,7 @@ async function startPortForward(event, payload) {
passphrase: effectivePassphrase,
},
});
} else if (effectivePrivateKey && !isFido) {
} else if (effectivePrivateKey) {
connectOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connectOpts.passphrase = effectivePassphrase;
@@ -1511,7 +1488,6 @@ async function startPortForward(event, payload) {
? []
: discoveredDefaultKeys;
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
@@ -1534,7 +1510,6 @@ async function startPortForward(event, payload) {
applyAuthToConnOpts(connectOpts, authConfig);
portForwardAuthPhase = authConfig.authPhase || portForwardAuthPhase;
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
portForwardingTunnels.delete(tunnelId);
abandonPendingDial("Port forward connection cancelled");
return { tunnelId, success: false, cancelled: true };
@@ -1581,7 +1556,6 @@ async function startPortForward(event, payload) {
chainConnections = chainResult.connections;
tunnelState.chainConnections = chainConnections;
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
cleanupChainConnections(chainConnections);
if (!tunnelState.cleanupFailed) {
portForwardingTunnels.delete(tunnelId);
@@ -1600,7 +1574,6 @@ async function startPortForward(event, payload) {
},
});
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
try { connectionSocket?.end?.(); } catch { /* ignore */ }
try { connectionSocket?.destroy?.(); } catch { /* ignore */ }
if (!tunnelState.cleanupFailed) {
@@ -1616,7 +1589,6 @@ async function startPortForward(event, payload) {
}
} catch (err) {
if (isTunnelCancelled(tunnelState)) {
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
if (!tunnelState.cleanupFailed) {
portForwardingTunnels.delete(tunnelId);
}
@@ -1629,12 +1601,10 @@ async function startPortForward(event, payload) {
} catch {
/* best-effort cancel on passphrase cancel */
}
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
abandonPendingDial(err);
return { tunnelId, success: false, cancelled: true };
}
tunnelState.cancelled = true;
try { releasePreparedFidoAgent?.(); } catch { /* ignore */ }
if (tunnelState.pendingConn) {
try { tunnelState.pendingConn.end(); } catch { /* ignore */ }
}
+11 -32
View File
@@ -245,15 +245,9 @@ function createOpenConnectionApi(ctx) {
const hasCertificate =
typeof jump.certificate === "string" && jump.certificate.trim().length > 0;
const { enhanceAuthOptionsForFido, looksLikeSkOpenSshMaterial } = require("../sshAuthHelper.cjs");
const prep = await enhanceAuthOptionsForFido(jump, sender);
const isFido = prep.useFidoAgent === true;
const systemAuthAgent = (hasCertificate && !isFido)
const systemAuthAgent = hasCertificate
? null
: await prepareSystemSshAgentForAuth(
prep,
`[SFTP Chain] Hop ${i + 1}:`,
);
: await prepareSystemSshAgentForAuth(jump, `[SFTP Chain] Hop ${i + 1}:`);
const identityFile = !jump.privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
@@ -270,9 +264,7 @@ function createOpenConnectionApi(ctx) {
},
})
: null;
const inlineKey = jump.privateKey
&& !systemAuthAgent
&& !looksLikeSkOpenSshMaterial(jump.privateKey)
const inlineKey = jump.privateKey && !systemAuthAgent
? await preparePrivateKeyForAuth({
sender,
privateKey: jump.privateKey,
@@ -289,9 +281,8 @@ function createOpenConnectionApi(ctx) {
let authAgent = null;
if (systemAuthAgent) {
connOpts.agent = systemAuthAgent;
require("../attachFidoAgentRelease.cjs").attachFidoAgentRelease(conn, systemAuthAgent);
}
if (hasCertificate && !isFido) {
if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
@@ -303,7 +294,7 @@ function createOpenConnectionApi(ctx) {
},
});
connOpts.agent = authAgent;
} else if (effectivePrivateKey && !isFido) {
} else if (effectivePrivateKey) {
connOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connOpts.passphrase = effectivePassphrase;
@@ -1008,17 +999,10 @@ function createOpenConnectionApi(ctx) {
let identityFile = null;
let inlineKey = null;
let systemAuthAgent = null;
let isFido = false;
try {
const { enhanceAuthOptionsForFido, looksLikeSkOpenSshMaterial } = require("../sshAuthHelper.cjs");
const prep = await enhanceAuthOptionsForFido(options, event.sender);
isFido = prep.useFidoAgent === true;
systemAuthAgent = (hasCertificate && !isFido)
systemAuthAgent = hasCertificate
? null
: await prepareSystemSshAgentForAuth(
prep,
"[SFTP]",
);
: await prepareSystemSshAgentForAuth(options, "[SFTP]");
identityFile = !options.privateKey && !systemAuthAgent
? await loadFirstIdentityFileForAuth({
sender: event.sender,
@@ -1034,9 +1018,7 @@ function createOpenConnectionApi(ctx) {
},
})
: null;
inlineKey = options.privateKey
&& !systemAuthAgent
&& !looksLikeSkOpenSshMaterial(options.privateKey)
inlineKey = options.privateKey && !systemAuthAgent
? await preparePrivateKeyForAuth({
sender: event.sender,
privateKey: options.privateKey,
@@ -1058,7 +1040,7 @@ function createOpenConnectionApi(ctx) {
if (systemAuthAgent) {
connectOpts.agent = systemAuthAgent;
}
if (hasCertificate && !isFido) {
if (hasCertificate) {
authAgent = new NetcattyAgent({
mode: "certificate",
webContents: event.sender,
@@ -1070,7 +1052,7 @@ function createOpenConnectionApi(ctx) {
},
});
connectOpts.agent = authAgent;
} else if (effectivePrivateKey && !isFido) {
} else if (effectivePrivateKey) {
connectOpts.privateKey = effectivePrivateKey;
if (effectivePassphrase) {
connectOpts.passphrase = effectivePassphrase;
@@ -1168,10 +1150,7 @@ function createOpenConnectionApi(ctx) {
// By connecting directly, we can filter these non-fatal errors and allow
// the auth flow to continue to keyboard-interactive/password/etc.
const sshClient = client.client;
if (systemAuthAgent) {
require("../attachFidoAgentRelease.cjs").attachFidoAgentRelease(sshClient, systemAuthAgent);
}
await new Promise((resolve, reject) => {
let settled = false;
let authReadyTimer = null;
@@ -532,18 +532,17 @@ 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);
// 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; });
}
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; });
await assert.rejects(
() => sftpBridge.downloadSftpToLocal(null, {
@@ -619,9 +618,6 @@ 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 };
},
};
@@ -665,7 +661,6 @@ 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 };
},
};
@@ -715,7 +710,6 @@ 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 };
},
};
@@ -779,9 +773,11 @@ 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);
if (String(args[1]).endsWith(".backup")) controller.abort();
renameCalls += 1;
if (renameCalls === 2) controller.abort();
};
t.after(() => { fs.promises.rename = originalRename; });
@@ -911,13 +907,15 @@ 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 (String(args[1]).endsWith(".backup")) {
if (renameCalls === 1) readyPath = args[1];
if (renameCalls === 2) {
backupPath = args[1];
readyPath = backupPath.replace(/\.backup$/, ".ready");
controller.abort();
}
};
-148
View File
@@ -1,148 +0,0 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const {
parseKey,
getOpenSSHCertSignatureAlgorithm,
} = require("ssh2/lib/protocol/keyParser.js");
function buildSkEd25519PublicLine() {
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
const pub = Buffer.alloc(32, 9);
const app = Buffer.from("ssh:");
const parts = [type, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
return `sk-ssh-ed25519@openssh.com ${buf.toString("base64")} netcatty@sk`;
}
function buildSkEcdsaPublicLine() {
// Uncompressed P-256 point: 0x04 || X(32) || Y(32)
const point = Buffer.alloc(65, 0);
point[0] = 0x04;
point[64] = 1;
const type = Buffer.from("sk-ecdsa-sha2-nistp256@openssh.com");
const curve = Buffer.from("nistp256");
const app = Buffer.from("ssh:");
const parts = [type, curve, point, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
return `sk-ecdsa-sha2-nistp256@openssh.com ${buf.toString("base64")} netcatty@sk`;
}
test("ssh2 parseKey accepts sk-ssh-ed25519 public keys", () => {
const key = parseKey(buildSkEd25519PublicLine());
assert.equal(key instanceof Error, false, key instanceof Error ? key.message : "");
assert.equal(key.type, "sk-ssh-ed25519@openssh.com");
assert.equal(typeof key.getPublicSSH, "function");
assert.equal(key.getApplication?.(), "ssh:");
const signErr = key.sign(Buffer.from("challenge"));
assert.equal(signErr instanceof Error, true);
assert.match(String(signErr.message), /security-key|ssh-agent|FIDO/i);
});
test("ssh2 parseKey accepts sk-ecdsa-sha2-nistp256 public keys", () => {
const key = parseKey(buildSkEcdsaPublicLine());
assert.equal(key instanceof Error, false, key instanceof Error ? key.message : "");
assert.equal(key.type, "sk-ecdsa-sha2-nistp256@openssh.com");
assert.equal(key.getApplication?.(), "ssh:");
});
function buildSkEd25519CertPublicLine() {
// Minimal OpenSSH SK cert wire shape: type, nonce, then SK public fields.
const type = Buffer.from("sk-ssh-ed25519-cert-v01@openssh.com");
const nonce = Buffer.alloc(16, 7);
const pub = Buffer.alloc(32, 9);
const app = Buffer.from("ssh:");
const parts = [type, nonce, pub, app];
let len = 0;
for (const part of parts) len += 4 + part.length;
const buf = Buffer.alloc(len);
let offset = 0;
for (const part of parts) {
buf.writeUInt32BE(part.length, offset);
offset += 4;
part.copy(buf, offset);
offset += part.length;
}
return `sk-ssh-ed25519-cert-v01@openssh.com ${buf.toString("base64")} netcatty@sk-cert`;
}
test("ssh2 parseKey accepts OpenSSH-order SK certificate public keys", () => {
const line = buildSkEd25519CertPublicLine();
const certBlob = Buffer.from(line.split(/\s+/)[1], "base64");
const key = parseKey(line);
assert.equal(key instanceof Error, false, key instanceof Error ? key.message : "");
assert.equal(key.type, "sk-ssh-ed25519-cert-v01@openssh.com");
assert.equal(key.getApplication?.(), "ssh:");
assert.deepEqual(key.getPublicSSH(), certBlob);
});
test("ssh2 maps SK certificate identities to full wire signature algorithms", () => {
assert.equal(
getOpenSSHCertSignatureAlgorithm("sk-ssh-ed25519-cert-v01@openssh.com"),
"sk-ssh-ed25519@openssh.com",
);
assert.equal(
getOpenSSHCertSignatureAlgorithm("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com"),
"sk-ecdsa-sha2-nistp256@openssh.com",
);
assert.equal(
getOpenSSHCertSignatureAlgorithm("rsa-sha2-256-cert-v01@openssh.com"),
"rsa-sha2-256",
);
assert.equal(
getOpenSSHCertSignatureAlgorithm("ecdsa-sha2-nistp256-cert-v01@openssh.com"),
"ecdsa-sha2-nistp256",
);
assert.equal(
getOpenSSHCertSignatureAlgorithm("sk-ssh-ed25519@openssh.com"),
"sk-ssh-ed25519@openssh.com",
);
});
test("ssh2 agent protocol preserves SK signature framing, flags and counter", async () => {
const { AgentProtocol } = require("ssh2/lib/agent.js");
for (const line of [buildSkEd25519PublicLine(), buildSkEcdsaPublicLine(), buildSkEd25519CertPublicLine()]) {
const client = new AgentProtocol(true);
const server = new AgentProtocol(false);
client.pipe(server).pipe(client);
const raw = Buffer.alloc(64, 7);
const payload = Buffer.alloc(4 + raw.length + 5);
payload.writeUInt32BE(raw.length, 0);
raw.copy(payload, 4);
payload[4 + raw.length] = 5; // presence and verification
payload.writeUInt32BE(0x12345678, 5 + raw.length);
server.on("sign", (request, key) => {
assert.equal(request.getContext(), getOpenSSHCertSignatureAlgorithm(key.type));
server.signReply(request, payload);
});
try {
const returned = await new Promise((resolve, reject) => client.sign(
parseKey(line), Buffer.from("challenge"), {},
(error, signature) => error ? reject(error) : resolve(signature),
));
assert.deepEqual(returned, payload);
} finally {
client.destroy();
server.destroy();
}
}
});
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff. Show more