Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98e3a6b952 | ||
|
|
f6f3147afb | ||
|
|
54b26511a1 | ||
|
|
8ef91e1266 | ||
|
|
b2689f96a4 | ||
|
|
1b23bdcf15 | ||
|
|
2e63848e0e | ||
|
|
3a748aa1aa |
101
App.tsx
101
App.tsx
@@ -18,6 +18,7 @@ import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
@@ -992,6 +993,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const addConnectionLogRef = useRef(addConnectionLog);
|
||||
addConnectionLogRef.current = addConnectionLog;
|
||||
|
||||
const closeSidePanelRef = useRef<(() => void) | null>(null);
|
||||
const activeSidePanelTabRef = useRef<string | null>(null);
|
||||
const closeTabInFlightRef = useRef(false);
|
||||
|
||||
const createLocalTerminalWithCurrentShell = useCallback(() => {
|
||||
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
|
||||
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
|
||||
@@ -1025,6 +1030,44 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
|
||||
}, [hotkeyScheme, keyBindings]);
|
||||
|
||||
const confirmIfBusyLocalTerminal = useCallback(
|
||||
async (sessionIds: string[]): Promise<boolean> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const localIds = sessionIds.filter((id) => {
|
||||
const s = sessions.find((x) => x.id === id);
|
||||
return s?.protocol === 'local';
|
||||
});
|
||||
const busyCommands: string[] = [];
|
||||
for (const id of localIds) {
|
||||
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
|
||||
if (children.length > 0) {
|
||||
busyCommands.push(children[0].command);
|
||||
}
|
||||
}
|
||||
if (busyCommands.length === 0) return true;
|
||||
|
||||
const primary = busyCommands[0];
|
||||
const extraCount = busyCommands.length - 1;
|
||||
const message =
|
||||
extraCount > 0
|
||||
? t('confirm.closeBusyTerminal.messageWithMore', {
|
||||
command: primary,
|
||||
count: extraCount,
|
||||
})
|
||||
: t('confirm.closeBusyTerminal.message', { command: primary });
|
||||
|
||||
const ok = await bridge?.confirmCloseBusy?.({
|
||||
command: primary,
|
||||
title: t('confirm.closeBusyTerminal.title'),
|
||||
message,
|
||||
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
|
||||
closeLabel: t('confirm.closeBusyTerminal.close'),
|
||||
});
|
||||
return ok === true;
|
||||
},
|
||||
[sessions, t],
|
||||
);
|
||||
|
||||
// Shared hotkey action handler - used by both global handler and terminal callback
|
||||
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
|
||||
@@ -1069,18 +1112,52 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
case 'closeTab': {
|
||||
const currentId = activeTabStore.getActiveTabId();
|
||||
if (currentId !== 'vault' && currentId !== 'sftp') {
|
||||
// Find if it's a session or workspace
|
||||
const session = sessions.find(s => s.id === currentId);
|
||||
if (session) {
|
||||
closeSession(currentId);
|
||||
} else {
|
||||
const workspace = workspaces.find(w => w.id === currentId);
|
||||
if (workspace) {
|
||||
closeWorkspace(currentId);
|
||||
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
|
||||
if (closeTabInFlightRef.current) break;
|
||||
|
||||
const session = sessions.find((s) => s.id === currentId) ?? null;
|
||||
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
|
||||
|
||||
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
|
||||
const activeSidePanel = activeSidePanelTabRef.current;
|
||||
|
||||
const intent = resolveCloseIntent({
|
||||
activeTabId: currentId,
|
||||
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
|
||||
sessionForTab: session,
|
||||
activeSidePanelTab: activeSidePanel,
|
||||
focusIsInsideTerminal,
|
||||
});
|
||||
|
||||
closeTabInFlightRef.current = true;
|
||||
(async () => {
|
||||
try {
|
||||
switch (intent.kind) {
|
||||
case 'closeTerminal':
|
||||
case 'closeSingleTab': {
|
||||
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
|
||||
if (ok) closeSession(intent.sessionId);
|
||||
return;
|
||||
}
|
||||
case 'closeSidePanel': {
|
||||
closeSidePanelRef.current?.();
|
||||
return;
|
||||
}
|
||||
case 'closeWorkspace': {
|
||||
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
|
||||
const ok = await confirmIfBusyLocalTerminal(ids);
|
||||
if (ok) closeWorkspace(intent.workspaceId);
|
||||
return;
|
||||
}
|
||||
case 'noop':
|
||||
default:
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
closeTabInFlightRef.current = false;
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
break;
|
||||
}
|
||||
case 'newTab':
|
||||
@@ -1193,7 +1270,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
|
||||
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
|
||||
|
||||
// Callback for terminal to invoke app-level hotkey actions
|
||||
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
|
||||
@@ -1684,6 +1761,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
sessionLogsEnabled={sessionLogsEnabled}
|
||||
sessionLogsDir={sessionLogsDir}
|
||||
sessionLogsFormat={sessionLogsFormat}
|
||||
closeSidePanelRef={closeSidePanelRef}
|
||||
activeSidePanelTabRef={activeSidePanelTabRef}
|
||||
/>
|
||||
|
||||
{/* Log Views - readonly terminal replays */}
|
||||
|
||||
@@ -56,6 +56,11 @@ const en: Messages = {
|
||||
'confirm.deleteHost': 'Delete Host "{name}"?',
|
||||
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
|
||||
'confirm.removeProvider': 'Remove provider "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': 'Confirm close',
|
||||
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
|
||||
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
|
||||
'confirm.closeBusyTerminal.cancel': 'Cancel',
|
||||
'confirm.closeBusyTerminal.close': 'Close',
|
||||
'dialog.createWorkspace.title': 'Create Workspace',
|
||||
'dialog.renameWorkspace.title': 'Rename workspace',
|
||||
'dialog.renameSession.title': 'Rename session',
|
||||
@@ -467,6 +472,30 @@ const en: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
|
||||
|
||||
'sync.blocked.title': 'Sync paused',
|
||||
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
|
||||
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
|
||||
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
|
||||
'sync.blocked.restoreButton': 'Restore from local backup',
|
||||
'sync.blocked.forcePushButton': 'Force push anyway',
|
||||
|
||||
'sync.forcePush.title': 'Confirm force push',
|
||||
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
|
||||
'sync.forcePush.confirm': 'Yes, push anyway',
|
||||
'sync.forcePush.cancel': 'Cancel',
|
||||
|
||||
'sync.entityType.hosts': 'hosts',
|
||||
'sync.entityType.keys': 'keys',
|
||||
'sync.entityType.identities': 'identities',
|
||||
'sync.entityType.snippets': 'snippets',
|
||||
'sync.entityType.customGroups': 'groups',
|
||||
'sync.entityType.snippetPackages': 'snippet packages',
|
||||
'sync.entityType.knownHosts': 'known-host entries',
|
||||
'sync.entityType.portForwardingRules': 'port-forwarding rules',
|
||||
'sync.entityType.groupConfigs': 'group configs',
|
||||
|
||||
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
|
||||
'time.never': 'Never',
|
||||
'time.justNow': 'Just now',
|
||||
|
||||
@@ -43,6 +43,11 @@ const zhCN: Messages = {
|
||||
'confirm.deleteHost': '删除主机 "{name}"?',
|
||||
'confirm.deleteIdentity': '删除身份 "{name}"?',
|
||||
'confirm.removeProvider': '移除提供商 "{name}"?',
|
||||
'confirm.closeBusyTerminal.title': '确认关闭',
|
||||
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
|
||||
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
|
||||
'confirm.closeBusyTerminal.cancel': '取消',
|
||||
'confirm.closeBusyTerminal.close': '关闭',
|
||||
'dialog.renameWorkspace.title': '重命名工作区',
|
||||
'dialog.renameSession.title': '重命名会话',
|
||||
'field.name': '名称',
|
||||
@@ -286,6 +291,30 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
|
||||
|
||||
'sync.blocked.title': '同步已暂停',
|
||||
'sync.blocked.reason.bulkShrink': '即将从云端删除 {baseCount} 条 {entityType} 中的 {lost} 条(缩减 {percent}%)。',
|
||||
'sync.blocked.reason.largeShrink': '即将从云端删除 {lost} 条 {entityType}。',
|
||||
'sync.blocked.detail': '通常是本地状态异常(钥匙串故障、数据加载不全)导致。请从本地备份恢复,如果确实要删这些条目请使用强制推送。',
|
||||
'sync.blocked.restoreButton': '从本地备份恢复',
|
||||
'sync.blocked.forcePushButton': '强制推送',
|
||||
|
||||
'sync.forcePush.title': '确认强制推送',
|
||||
'sync.forcePush.body': '你将从云端移除 {lost} 条 {entityType},此操作不可撤销。继续?',
|
||||
'sync.forcePush.confirm': '确认推送',
|
||||
'sync.forcePush.cancel': '取消',
|
||||
|
||||
'sync.entityType.hosts': '主机',
|
||||
'sync.entityType.keys': '密钥',
|
||||
'sync.entityType.identities': '身份',
|
||||
'sync.entityType.snippets': '代码片段',
|
||||
'sync.entityType.customGroups': '分组',
|
||||
'sync.entityType.snippetPackages': '片段包',
|
||||
'sync.entityType.knownHosts': '主机密钥记录',
|
||||
'sync.entityType.portForwardingRules': '端口转发规则',
|
||||
'sync.entityType.groupConfigs': '分组配置',
|
||||
|
||||
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
|
||||
'time.never': '从未',
|
||||
'time.justNow': '刚刚',
|
||||
|
||||
@@ -6,15 +6,38 @@ import {
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { hasMeaningfulSyncData } from './syncPayload';
|
||||
|
||||
/**
|
||||
* Snapshot the current sync data version (the integer that increments
|
||||
* on each successful cloud sync). Returns undefined when the value is
|
||||
* 0 (never synced) or unavailable, so the UI can fall back to timestamp.
|
||||
*/
|
||||
function captureCurrentSyncDataVersion(): number | undefined {
|
||||
try {
|
||||
const state = getCloudSyncManager().getState();
|
||||
const v = state.localVersion;
|
||||
return typeof v === 'number' && v > 0 ? v : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
|
||||
|
||||
export interface LocalVaultBackupPreview {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: LocalVaultBackupReason;
|
||||
/** Sync-data version at the time the snapshot was taken (the integer
|
||||
* that the CloudSyncManager increments on each successful cloud sync).
|
||||
* Undefined when the user had never synced yet, or for legacy backups
|
||||
* persisted before this field was added. */
|
||||
syncDataVersion?: number;
|
||||
/** App version transition fields, only for `app_version_change` records.
|
||||
* Kept for backward compatibility with already-persisted backups. */
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
@@ -94,6 +117,7 @@ export async function createLocalVaultBackup(
|
||||
payload: SyncPayload,
|
||||
options: {
|
||||
reason: LocalVaultBackupReason;
|
||||
syncDataVersion?: number;
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
maxCount?: number;
|
||||
@@ -118,6 +142,10 @@ export async function createLocalVaultBackup(
|
||||
const result = await bridge.createVaultBackup({
|
||||
payload,
|
||||
reason: options.reason,
|
||||
// Default to the live cloud-sync version so every new backup carries
|
||||
// it even when the caller didn't pass one explicitly. Bridge sanitizer
|
||||
// drops invalid values (non-positive / non-finite), so this is safe.
|
||||
syncDataVersion: options.syncDataVersion ?? captureCurrentSyncDataVersion(),
|
||||
sourceAppVersion: options.sourceAppVersion,
|
||||
targetAppVersion: options.targetAppVersion,
|
||||
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),
|
||||
|
||||
110
application/state/resolveCloseIntent.test.ts
Normal file
110
application/state/resolveCloseIntent.test.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
|
||||
|
||||
const baseWorkspace = {
|
||||
id: "w1",
|
||||
focusedSessionId: "s1",
|
||||
};
|
||||
|
||||
const baseSession = { id: "s1" };
|
||||
|
||||
test("non-workspace tab → closeSingleTab with session id", () => {
|
||||
const result = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: baseSession,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "s1",
|
||||
workspace: null,
|
||||
sessionForTab: { id: "s1" },
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("vault/sftp tab → noop", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "vault",
|
||||
workspace: null,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "noop" });
|
||||
});
|
||||
|
||||
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "sftp",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
|
||||
});
|
||||
|
||||
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: baseWorkspace,
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: null,
|
||||
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
|
||||
});
|
||||
|
||||
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
|
||||
const r = resolveCloseIntent({
|
||||
activeTabId: "w1",
|
||||
workspace: { id: "w1", focusedSessionId: undefined },
|
||||
sessionForTab: null,
|
||||
activeSidePanelTab: "ai",
|
||||
focusIsInsideTerminal: false,
|
||||
});
|
||||
assert.deepEqual(r, { kind: "closeSidePanel" });
|
||||
});
|
||||
43
application/state/resolveCloseIntent.ts
Normal file
43
application/state/resolveCloseIntent.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export type CloseIntent =
|
||||
| { kind: 'closeTerminal'; sessionId: string }
|
||||
| { kind: 'closeSidePanel' }
|
||||
| { kind: 'closeWorkspace'; workspaceId: string }
|
||||
| { kind: 'closeSingleTab'; sessionId: string }
|
||||
| { kind: 'noop' };
|
||||
|
||||
export interface ResolveCloseInput {
|
||||
activeTabId: string | null;
|
||||
workspace: { id: string; focusedSessionId?: string } | null;
|
||||
sessionForTab: { id: string } | null;
|
||||
activeSidePanelTab: string | null;
|
||||
focusIsInsideTerminal: boolean;
|
||||
}
|
||||
|
||||
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
|
||||
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
|
||||
|
||||
if (!activeTabId) return { kind: 'noop' };
|
||||
|
||||
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
|
||||
// Modals take priority over this but are intercepted upstream in App.tsx before the
|
||||
// hotkey reaches resolveCloseIntent.
|
||||
if (activeSidePanelTab !== null) {
|
||||
return { kind: 'closeSidePanel' };
|
||||
}
|
||||
|
||||
if (sessionForTab && !workspace) {
|
||||
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
|
||||
}
|
||||
|
||||
if (!workspace) {
|
||||
// e.g. 'vault', 'sftp', or any non-closable pinned tab
|
||||
return { kind: 'noop' };
|
||||
}
|
||||
|
||||
const focusedSessionId = workspace.focusedSessionId;
|
||||
if (focusedSessionId && focusIsInsideTerminal) {
|
||||
return { kind: 'closeTerminal', sessionId: focusedSessionId };
|
||||
}
|
||||
|
||||
return { kind: 'closeWorkspace', workspaceId: workspace.id };
|
||||
}
|
||||
@@ -247,19 +247,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.credentialsUnavailable'));
|
||||
}
|
||||
|
||||
// Prevent pushing an empty vault to cloud. This is almost always
|
||||
// Refuse to push an empty vault to cloud. This is almost always
|
||||
// a sign that the local state was lost (update, import failure,
|
||||
// storage corruption) rather than a deliberate "delete everything".
|
||||
// We only block auto-sync — manual trigger from Settings can still
|
||||
// push if the user explicitly wants to.
|
||||
// Both auto and manual triggers are blocked; the user can still
|
||||
// use Force Push from the SyncBlocked banner if they genuinely
|
||||
// want to wipe the cloud.
|
||||
//
|
||||
// This pairs with the inspect-failure "fail open" behavior in
|
||||
// checkRemoteVersion below: if inspect transiently errors we still
|
||||
// let auto-sync run, trusting this guard to refuse if local is
|
||||
// truly empty rather than letting an empty state clobber remote.
|
||||
if (!hasMeaningfulSyncData(payload) && trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.emptyVaultManual'));
|
||||
}
|
||||
|
||||
const results = await sync.syncNow(payload);
|
||||
@@ -479,7 +483,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// that only approximated the correct ordering.
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
await manager.syncAllProviders(mergeResult.payload);
|
||||
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
|
||||
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
|
||||
(r) => r.shrinkBlocked === true,
|
||||
);
|
||||
if (wasShrinkBlocked) {
|
||||
// The merged payload is already applied locally and is the source of truth
|
||||
// for THIS device. The blocking only prevents pushing it to cloud, which
|
||||
// is acceptable here — the next user-edit-triggered sync will re-check
|
||||
// (and the user can also force-push from the Settings banner if they
|
||||
// navigate there). Reset syncState so we don't leave the manager wedged
|
||||
// in BLOCKED with no banner visible.
|
||||
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
|
||||
manager.clearShrinkBlockedState();
|
||||
}
|
||||
// Suppress the debounced follow-up tick that otherwise fires
|
||||
// once React commits the applied state, since we've just
|
||||
// already pushed that exact payload upstream.
|
||||
|
||||
@@ -26,7 +26,9 @@ import {
|
||||
import {
|
||||
getCloudSyncManager,
|
||||
type SyncManagerState,
|
||||
type SyncEventCallback,
|
||||
} from '../../infrastructure/services/CloudSyncManager';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
|
||||
|
||||
@@ -55,7 +57,7 @@ export interface CloudSyncHook {
|
||||
// Computed
|
||||
hasAnyConnectedProvider: boolean;
|
||||
connectedProviderCount: number;
|
||||
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict';
|
||||
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked';
|
||||
|
||||
// Master Key Actions
|
||||
setupMasterKey: (password: string, confirmPassword: string) => Promise<void>;
|
||||
@@ -86,8 +88,8 @@ export interface CloudSyncHook {
|
||||
resetProviderStatus: (provider: CloudProvider) => void;
|
||||
|
||||
// Sync Actions
|
||||
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
|
||||
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
|
||||
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
|
||||
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
|
||||
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
|
||||
|
||||
@@ -116,6 +118,12 @@ export interface CloudSyncHook {
|
||||
formatLastSync: (timestamp?: number) => string;
|
||||
getProviderDotColor: (provider: CloudProvider) => string;
|
||||
refresh: () => void;
|
||||
|
||||
// Event subscription (for non-state events like SYNC_BLOCKED_SHRINK)
|
||||
subscribeToEvents: (callback: SyncEventCallback) => () => void;
|
||||
|
||||
// Shrink-block state query (for banner hydration on mount)
|
||||
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
@@ -190,7 +198,8 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
).length;
|
||||
}, [state.providers]);
|
||||
|
||||
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' => {
|
||||
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked' => {
|
||||
if (state.syncState === 'BLOCKED') return 'blocked';
|
||||
if (state.syncState === 'CONFLICT') return 'conflict';
|
||||
if (state.syncState === 'ERROR') return 'error';
|
||||
if (state.syncState === 'SYNCING') return 'syncing';
|
||||
@@ -422,14 +431,14 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
throw new Error('Vault is locked');
|
||||
}, []);
|
||||
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload) => {
|
||||
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncAllProviders(payload);
|
||||
return await manager.syncAllProviders(payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload) => {
|
||||
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.syncToProvider(provider, payload);
|
||||
return await manager.syncToProvider(provider, payload, opts);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
|
||||
@@ -437,6 +446,16 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
return await manager.downloadFromProvider(provider);
|
||||
}, [ensureUnlocked]);
|
||||
|
||||
const subscribeToEvents = useCallback(
|
||||
(callback: SyncEventCallback) => manager.subscribe(callback),
|
||||
[],
|
||||
);
|
||||
|
||||
const getShrinkBlockedFinding = useCallback(
|
||||
() => manager.getShrinkBlockedFinding(),
|
||||
[],
|
||||
);
|
||||
|
||||
const resolveConflictWithUnlock = useCallback(async (resolution: ConflictResolution) => {
|
||||
await ensureUnlocked();
|
||||
return await manager.resolveConflict(resolution);
|
||||
@@ -505,6 +524,12 @@ export const useCloudSync = (): CloudSyncHook => {
|
||||
formatLastSync,
|
||||
getProviderDotColor,
|
||||
refresh,
|
||||
|
||||
// Event subscription
|
||||
subscribeToEvents,
|
||||
|
||||
// Shrink-block state query
|
||||
getShrinkBlockedFinding,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -141,19 +141,48 @@ export const useSessionState = () => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
}, []);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
if (currentActiveTabId === workspaceId) {
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
|
||||
} else {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return remainingWorkspaces;
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
|
||||
e?.stopPropagation();
|
||||
|
||||
|
||||
// Pre-compute outside the setSessions updater so we don't depend on React
|
||||
// having run the updater by the time we queue the microtask. React 18+ does
|
||||
// not guarantee updater execution timing under concurrent scheduling.
|
||||
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
|
||||
const workspaceIdToMaybeClose =
|
||||
sessionBeingClosed?.workspaceId &&
|
||||
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
|
||||
? sessionBeingClosed.workspaceId
|
||||
: undefined;
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const targetSession = prevSessions.find(s => s.id === sessionId);
|
||||
const wsId = targetSession?.workspaceId;
|
||||
|
||||
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
let removedWorkspaceId: string | null = null;
|
||||
let nextWorkspaces = prevWorkspaces;
|
||||
let dissolvedWorkspaceId: string | null = null;
|
||||
let lastRemainingSessionId: string | null = null;
|
||||
|
||||
|
||||
if (wsId) {
|
||||
nextWorkspaces = prevWorkspaces
|
||||
.map(ws => {
|
||||
@@ -163,7 +192,7 @@ export const useSessionState = () => {
|
||||
removedWorkspaceId = ws.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
// Check if only 1 session remains - dissolve workspace
|
||||
const remainingSessionIds = collectSessionIds(pruned);
|
||||
if (remainingSessionIds.length === 1) {
|
||||
@@ -171,12 +200,12 @@ export const useSessionState = () => {
|
||||
lastRemainingSessionId = remainingSessionIds[0];
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
return { ...ws, root: pruned };
|
||||
})
|
||||
.filter((ws): ws is Workspace => Boolean(ws));
|
||||
}
|
||||
|
||||
|
||||
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
|
||||
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
|
||||
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
|
||||
@@ -198,10 +227,10 @@ export const useSessionState = () => {
|
||||
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
|
||||
setActiveTabId(getFallback());
|
||||
}
|
||||
|
||||
|
||||
return nextWorkspaces;
|
||||
});
|
||||
|
||||
|
||||
// Check if we need to dissolve a workspace (convert remaining session to orphan)
|
||||
if (targetSession?.workspaceId) {
|
||||
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
|
||||
@@ -218,29 +247,14 @@ export const useSessionState = () => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
});
|
||||
}, [workspaces, setActiveTabId]);
|
||||
|
||||
const closeWorkspace = useCallback((workspaceId: string) => {
|
||||
setWorkspaces(prevWorkspaces => {
|
||||
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
|
||||
|
||||
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
|
||||
|
||||
const currentActiveTabId = activeTabStore.getActiveTabId();
|
||||
if (currentActiveTabId === workspaceId) {
|
||||
if (remainingWorkspaces.length > 0) {
|
||||
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
|
||||
} else {
|
||||
setActiveTabId('vault');
|
||||
}
|
||||
}
|
||||
|
||||
return remainingWorkspaces;
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
return prevSessions.filter(s => s.id !== sessionId);
|
||||
});
|
||||
|
||||
if (workspaceIdToMaybeClose) {
|
||||
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
|
||||
}
|
||||
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
|
||||
|
||||
const startSessionRename = useCallback((sessionId: string) => {
|
||||
setSessions(prevSessions => {
|
||||
@@ -654,16 +668,22 @@ export const useSessionState = () => {
|
||||
const copySession = useCallback((sessionId: string, options?: {
|
||||
localShellType?: TerminalSession['shellType'];
|
||||
}) => {
|
||||
// Pre-allocate the new id outside the updater so StrictMode's
|
||||
// double-invocation of the functional updater doesn't mint two ids.
|
||||
const newSessionId = crypto.randomUUID();
|
||||
|
||||
setSessions(prevSessions => {
|
||||
const session = prevSessions.find(s => s.id === sessionId);
|
||||
// Source may have been closed between the user's action and this
|
||||
// update running; in that case skip entirely — do NOT switch the
|
||||
// active tab or insert into tabOrder, which would leave dangling ids.
|
||||
if (!session) return prevSessions;
|
||||
const nextShellType = session.protocol === 'local'
|
||||
? options?.localShellType
|
||||
: session.shellType;
|
||||
|
||||
// Create a new session with the same connection info
|
||||
const newSession: TerminalSession = {
|
||||
id: crypto.randomUUID(),
|
||||
id: newSessionId,
|
||||
hostId: session.hostId,
|
||||
hostLabel: session.hostLabel,
|
||||
hostname: session.hostname,
|
||||
@@ -681,10 +701,40 @@ export const useSessionState = () => {
|
||||
localShellIcon: session.localShellIcon,
|
||||
};
|
||||
|
||||
setActiveTabId(newSession.id);
|
||||
// Schedule the activeTab + tabOrder updates only when creation
|
||||
// actually happens. These nested setStates are idempotent, so
|
||||
// StrictMode's double-invocation is harmless.
|
||||
setActiveTabId(newSessionId);
|
||||
setTabOrder(prevTabOrder => {
|
||||
// Fast path: source is already tracked in tabOrder — splice directly.
|
||||
const directIdx = prevTabOrder.indexOf(sessionId);
|
||||
if (directIdx !== -1) {
|
||||
const next = [...prevTabOrder];
|
||||
next.splice(directIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
}
|
||||
// Fallback: source is only in the derived tab collections. Rebuild the
|
||||
// effective order (same pattern as reorderTabs) to locate its position.
|
||||
const allTabIds = [
|
||||
...orphanSessions.map(s => s.id),
|
||||
...workspaces.map(w => w.id),
|
||||
...logViews.map(lv => lv.id),
|
||||
];
|
||||
const allTabIdSet = new Set(allTabIds);
|
||||
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
|
||||
const orderedIdSet = new Set(orderedIds);
|
||||
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
|
||||
const currentOrder = [...orderedIds, ...newIds];
|
||||
const sourceIdx = currentOrder.indexOf(sessionId);
|
||||
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
|
||||
const next = [...currentOrder];
|
||||
next.splice(sourceIdx + 1, 0, newSessionId);
|
||||
return next;
|
||||
});
|
||||
|
||||
return [...prevSessions, newSession];
|
||||
});
|
||||
}, [setActiveTabId]);
|
||||
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
|
||||
|
||||
// Toggle broadcast mode for a workspace
|
||||
const toggleBroadcast = useCallback((workspaceId: string) => {
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
* - Sync status and conflict resolution
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useEffect } from 'react';
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
@@ -43,7 +43,9 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type SyncResult, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
|
||||
import type { ShrinkFinding } from '../domain/syncGuards';
|
||||
import { SyncBlockedBanner } from './sync/SyncBlockedBanner';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -897,20 +899,29 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm font-medium">
|
||||
{getReasonLabel(backup.reason)}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatTimestamp(backup.createdAt)}
|
||||
</span>
|
||||
<div className="text-sm font-medium">
|
||||
{backup.syncDataVersion
|
||||
? `v${backup.syncDataVersion}`
|
||||
: formatTimestamp(backup.createdAt)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(backup.reason)}</span>
|
||||
{backup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(backup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{backup.sourceAppVersion && backup.targetAppVersion && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
@@ -974,13 +985,30 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
</DialogHeader>
|
||||
{pendingRestoreBackup && (
|
||||
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium">
|
||||
{getReasonLabel(pendingRestoreBackup.reason)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{formatTimestamp(pendingRestoreBackup.createdAt)}
|
||||
</span>
|
||||
<div className="font-medium">
|
||||
{pendingRestoreBackup.syncDataVersion
|
||||
? `v${pendingRestoreBackup.syncDataVersion}`
|
||||
: formatTimestamp(pendingRestoreBackup.createdAt)}
|
||||
</div>
|
||||
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
|
||||
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
|
||||
{pendingRestoreBackup.syncDataVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
|
||||
</>
|
||||
)}
|
||||
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
|
||||
<>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span>
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: pendingRestoreBackup.sourceAppVersion,
|
||||
to: pendingRestoreBackup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
@@ -1172,6 +1200,17 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// Clear local data dialog
|
||||
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
|
||||
|
||||
// Sync-blocked banner (Task 7) + force-push confirmation modal (Task 8)
|
||||
const [blockedFinding, setBlockedFinding] = useState<Extract<ShrinkFinding, { suspicious: true }> | null>(null);
|
||||
const [showForcePushConfirm, setShowForcePushConfirm] = useState(false);
|
||||
|
||||
// Ref for scrolling to LocalBackupsPanel when the banner's Restore button is clicked
|
||||
const localBackupsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Active tab state — lets the banner's "Restore" button switch to the
|
||||
// local-backups tab without a separate DOM query.
|
||||
const [activeTab, setActiveTab] = useState<'providers' | 'status'>('providers');
|
||||
|
||||
const ensureSyncablePayload = useCallback(
|
||||
(payload: SyncPayload): boolean => {
|
||||
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
|
||||
@@ -1190,6 +1229,35 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
}, [sync.currentConflict]);
|
||||
|
||||
// Subscribe to sync events to show/clear the blocked-shrink banner.
|
||||
// Destructure the stable useCallback reference so the effect runs once on
|
||||
// mount rather than re-subscribing on every render when `sync` object ref changes.
|
||||
const { subscribeToEvents, getShrinkBlockedFinding } = sync;
|
||||
|
||||
// Hydrate from current manager state in case a shrink-block happened
|
||||
// before this component mounted (e.g., auto-sync ran while the user
|
||||
// was on a different tab). Without this, the banner only shows
|
||||
// blocks that occur after Settings is open.
|
||||
useEffect(() => {
|
||||
const existing = getShrinkBlockedFinding();
|
||||
if (existing) {
|
||||
setBlockedFinding(existing);
|
||||
}
|
||||
}, [getShrinkBlockedFinding]);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribeToEvents((event) => {
|
||||
if (event.type === 'SYNC_BLOCKED_SHRINK') {
|
||||
if (event.finding.suspicious) {
|
||||
setBlockedFinding(event.finding);
|
||||
}
|
||||
} else if (event.type === 'SYNC_BLOCKED_CLEARED') {
|
||||
setBlockedFinding(null);
|
||||
}
|
||||
});
|
||||
return unsub;
|
||||
}, [subscribeToEvents]);
|
||||
|
||||
// If we have a master key but we're still locked (e.g. older installs),
|
||||
// prompt once and persist the password via safeStorage.
|
||||
useEffect(() => {
|
||||
@@ -1441,9 +1509,30 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
// window's push, not ours.
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
|
||||
let results: Map<CloudProvider, SyncResult> | null = null;
|
||||
await withRestoreBarrier(async () => {
|
||||
await sync.syncNow(localPayload);
|
||||
results = await sync.syncNow(localPayload, { overrideShrink: true });
|
||||
});
|
||||
|
||||
if (results) {
|
||||
// Apply any merged payload BEFORE closing the modal so local state
|
||||
// reflects what's now on cloud (in case remote changed during the merge).
|
||||
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
|
||||
if (result.mergedPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
break;
|
||||
}
|
||||
}
|
||||
const allOk = Array.from((results as Map<CloudProvider, SyncResult>).values()).every((r) => r.success);
|
||||
if (!allOk) {
|
||||
const firstError = Array.from((results as Map<CloudProvider, SyncResult>).values())
|
||||
.find((r) => !r.success)?.error
|
||||
?? t('common.unknownError');
|
||||
toast.error(firstError, t('cloudSync.resolve.failedTitle'));
|
||||
return; // KEEP the modal open so user can retry / pick USE_REMOTE
|
||||
}
|
||||
}
|
||||
toast.success(t('cloudSync.resolve.uploaded'));
|
||||
}
|
||||
setShowConflictModal(false);
|
||||
@@ -1554,7 +1643,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="providers" className="space-y-4">
|
||||
{blockedFinding && (
|
||||
<SyncBlockedBanner
|
||||
finding={blockedFinding}
|
||||
onRestore={() => {
|
||||
setActiveTab('status');
|
||||
requestAnimationFrame(() => {
|
||||
localBackupsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
});
|
||||
}}
|
||||
onForcePush={() => setShowForcePushConfirm(true)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
|
||||
<TabsTrigger value="status">{t('cloudSync.status.title')}</TabsTrigger>
|
||||
@@ -1739,9 +1841,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
/>
|
||||
<div ref={localBackupsRef}>
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear Local Data */}
|
||||
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
|
||||
@@ -2361,6 +2465,69 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Force-push confirmation modal (Task 8) */}
|
||||
{showForcePushConfirm && blockedFinding && (
|
||||
<Dialog open onOpenChange={(open) => !open && setShowForcePushConfirm(false)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('sync.forcePush.title')}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-sm">
|
||||
{t('sync.forcePush.body', {
|
||||
lost: blockedFinding.lost,
|
||||
entityType: t(`sync.entityType.${blockedFinding.entityType}`),
|
||||
})}
|
||||
</p>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowForcePushConfirm(false)}>
|
||||
{t('sync.forcePush.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) {
|
||||
setShowForcePushConfirm(false);
|
||||
return;
|
||||
}
|
||||
setShowForcePushConfirm(false);
|
||||
try {
|
||||
const results = await sync.syncNow(localPayload, { overrideShrink: true });
|
||||
|
||||
// Apply any merged payload BEFORE clearing the banner. If a merge happened
|
||||
// during force-push (remote changed), the merged result is what the cloud
|
||||
// now has — applying it to local state prevents the next sync from
|
||||
// re-deleting the remote additions we just merged in.
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload) {
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
}
|
||||
|
||||
const allOk = Array.from(results.values()).every((r) => r.success);
|
||||
if (allOk) {
|
||||
setBlockedFinding(null);
|
||||
} else {
|
||||
// Surface the failure but KEEP the banner so the user can retry or
|
||||
// restore. Find the first error string to display.
|
||||
const firstError = Array.from(results.values())
|
||||
.find((r) => !r.success)
|
||||
?.error ?? t('sync.toast.errorTitle');
|
||||
toast.error(firstError, t('sync.toast.errorTitle'));
|
||||
}
|
||||
} catch (err) {
|
||||
toast.error(String(err), t('sync.toast.errorTitle'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('sync.forcePush.confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -136,7 +136,13 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
// Determine overall status for the button indicator
|
||||
const getOverallStatus = (): StatusIndicatorProps['status'] => {
|
||||
if (sync.overallSyncStatus === 'syncing') return 'syncing';
|
||||
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
|
||||
if (
|
||||
sync.overallSyncStatus === 'error' ||
|
||||
sync.overallSyncStatus === 'conflict' ||
|
||||
sync.overallSyncStatus === 'blocked'
|
||||
) {
|
||||
return 'error';
|
||||
}
|
||||
if (sync.overallSyncStatus === 'synced') return 'synced';
|
||||
return 'none';
|
||||
};
|
||||
|
||||
@@ -49,6 +49,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
|
||||
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
|
||||
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
|
||||
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
|
||||
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
|
||||
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
|
||||
@@ -620,6 +621,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0) return;
|
||||
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
|
||||
@@ -1706,6 +1713,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
|
||||
onMouseDownCapture={handleTopOverlayMouseDownCapture}
|
||||
style={{
|
||||
backgroundColor: 'var(--terminal-ui-bg)',
|
||||
color: 'var(--terminal-ui-fg)',
|
||||
|
||||
@@ -429,6 +429,8 @@ interface TerminalLayerProps {
|
||||
sessionLogsEnabled?: boolean;
|
||||
sessionLogsDir?: string;
|
||||
sessionLogsFormat?: string;
|
||||
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
|
||||
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
|
||||
}
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
@@ -482,6 +484,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
sessionLogsEnabled,
|
||||
sessionLogsDir,
|
||||
sessionLogsFormat,
|
||||
closeSidePanelRef,
|
||||
activeSidePanelTabRef,
|
||||
}) => {
|
||||
// Subscribe to activeTabId from external store
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -662,6 +666,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
// Whether side panel is open for the currently active tab and which sub-panel
|
||||
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
|
||||
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
|
||||
if (activeSidePanelTabRef) {
|
||||
activeSidePanelTabRef.current = activeSidePanelTab;
|
||||
}
|
||||
|
||||
// Legacy compatibility helpers for SFTP-specific logic
|
||||
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
|
||||
@@ -1258,9 +1265,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
|
||||
|
||||
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
|
||||
if (!sessionId) return;
|
||||
|
||||
const focusTarget = () => {
|
||||
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
};
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
focusTarget();
|
||||
setTimeout(focusTarget, 50);
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Close the entire side panel for the current tab
|
||||
const handleCloseSidePanel = useCallback(() => {
|
||||
if (!activeTabId) return;
|
||||
const sessionIdToRefocus = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
setSidePanelOpenTabs(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(activeTabId);
|
||||
@@ -1283,7 +1306,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
next.delete(activeTabId);
|
||||
return next;
|
||||
});
|
||||
}, [activeTabId]);
|
||||
refocusTerminalSession(sessionIdToRefocus);
|
||||
}, [activeTabId, activeWorkspace?.focusedSessionId, activeSession?.id, refocusTerminalSession]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!closeSidePanelRef) return;
|
||||
closeSidePanelRef.current = handleCloseSidePanel;
|
||||
return () => {
|
||||
closeSidePanelRef.current = null;
|
||||
};
|
||||
}, [closeSidePanelRef, handleCloseSidePanel]);
|
||||
|
||||
// Switch side panel to a specific tab (or toggle if already on that tab)
|
||||
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
|
||||
@@ -2402,14 +2434,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onSend={handleComposeSend}
|
||||
onClose={() => {
|
||||
setIsComposeBarOpen(false);
|
||||
// Refocus the terminal pane (matching solo-session behavior)
|
||||
if (focusedSessionId) {
|
||||
requestAnimationFrame(() => {
|
||||
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
textarea?.focus();
|
||||
});
|
||||
}
|
||||
refocusTerminalSession(focusedSessionId);
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
|
||||
@@ -304,11 +304,23 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
updateScrollState();
|
||||
const container = tabsContainerRef.current;
|
||||
if (container) {
|
||||
// Translate vertical wheel to horizontal scroll so users can reach
|
||||
// off-screen tabs with a standard mouse wheel. Trackpad gestures that
|
||||
// already carry horizontal delta are left alone so native two-finger
|
||||
// swiping still works.
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY !== 0 && e.deltaX === 0) {
|
||||
e.preventDefault();
|
||||
container.scrollLeft += e.deltaY;
|
||||
}
|
||||
};
|
||||
container.addEventListener('scroll', updateScrollState);
|
||||
container.addEventListener('wheel', handleWheel, { passive: false });
|
||||
const resizeObserver = new ResizeObserver(updateScrollState);
|
||||
resizeObserver.observe(container);
|
||||
return () => {
|
||||
container.removeEventListener('scroll', updateScrollState);
|
||||
container.removeEventListener('wheel', handleWheel);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
|
||||
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
|
||||
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
|
||||
import type { VaultImportFormat } from "../domain/vaultImport";
|
||||
import {
|
||||
@@ -2941,13 +2941,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
groupDefaults={editingHostGroupDefaults}
|
||||
groupConfigs={groupConfigs}
|
||||
onSave={(host) => {
|
||||
// Check if host already exists in the list (for updates vs. new/duplicate)
|
||||
const hostExists = hosts.some((h) => h.id === host.id);
|
||||
onUpdateHosts(
|
||||
hostExists
|
||||
? hosts.map((h) => (h.id === host.id ? host : h))
|
||||
: [...hosts, host],
|
||||
);
|
||||
onUpdateHosts(upsertHostById(hosts, host));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
@@ -2973,15 +2967,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
allTags={allTags}
|
||||
groups={allGroupPaths}
|
||||
onSave={(host) => {
|
||||
onUpdateHosts(
|
||||
hosts.map((h) => (h.id === host.id ? host : h)),
|
||||
);
|
||||
onUpdateHosts(upsertHostById(hosts, host));
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
onCancel={() => {
|
||||
setIsHostPanelOpen(false);
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(null);
|
||||
}}
|
||||
layout="inline"
|
||||
/>
|
||||
|
||||
51
components/sync/SyncBlockedBanner.tsx
Normal file
51
components/sync/SyncBlockedBanner.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
import type { ShrinkFinding } from '../../domain/syncGuards';
|
||||
import { Button } from '../ui/button';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
|
||||
interface Props {
|
||||
finding: Extract<ShrinkFinding, { suspicious: true }>;
|
||||
onRestore: () => void;
|
||||
onForcePush: () => void;
|
||||
}
|
||||
|
||||
export const SyncBlockedBanner: React.FC<Props> = ({ finding, onRestore, onForcePush }) => {
|
||||
const { t } = useI18n();
|
||||
const entityLabel = t(`sync.entityType.${finding.entityType}`);
|
||||
const percent = finding.baseCount > 0 ? Math.round((finding.lost / finding.baseCount) * 100) : 0;
|
||||
|
||||
const reasonText = finding.reason === 'bulk-shrink'
|
||||
? t('sync.blocked.reason.bulkShrink', {
|
||||
lost: finding.lost,
|
||||
baseCount: finding.baseCount,
|
||||
entityType: entityLabel,
|
||||
percent,
|
||||
})
|
||||
: t('sync.blocked.reason.largeShrink', {
|
||||
lost: finding.lost,
|
||||
entityType: entityLabel,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
role="alert"
|
||||
className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-4"
|
||||
>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-4 w-4 text-amber-500" />
|
||||
<span>{t('sync.blocked.title')}</span>
|
||||
</div>
|
||||
<p className="text-sm">{reasonText}</p>
|
||||
<p className="text-xs opacity-70">{t('sync.blocked.detail')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="default" size="sm" onClick={onRestore}>
|
||||
{t('sync.blocked.restoreButton')}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onForcePush}>
|
||||
{t('sync.blocked.forcePushButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -690,7 +690,9 @@ export function useTerminalAutocomplete(
|
||||
}
|
||||
}
|
||||
|
||||
// Tab: accept selected popup suggestion, or accept ghost text
|
||||
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
|
||||
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
|
||||
// which is otherwise shadowed by our single-Tab ghost accept.
|
||||
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
|
||||
if (s.popupVisible && s.suggestions.length > 0) {
|
||||
e.preventDefault();
|
||||
@@ -698,16 +700,10 @@ export function useTerminalAutocomplete(
|
||||
if (selected) insertSuggestion(selected, false);
|
||||
return false;
|
||||
}
|
||||
// Hide stale ghost text before Tab reaches the shell — the shell's
|
||||
// completion will rewrite the line and the old ghost would mislead.
|
||||
if (ghost?.isVisible()) {
|
||||
e.preventDefault();
|
||||
const ghostText = ghost.getGhostText();
|
||||
if (ghostText) {
|
||||
writeToTerminal(ghostText);
|
||||
lastAcceptedCommandRef.current = ghost.getSuggestion();
|
||||
ghost.hide();
|
||||
clearState();
|
||||
}
|
||||
return false;
|
||||
ghost.hide();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
64
components/terminal/toolbarFocus.test.ts
Normal file
64
components/terminal/toolbarFocus.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { shouldPreserveTerminalFocusOnMouseDown } from "./toolbarFocus.ts";
|
||||
|
||||
test("preserves terminal focus for non-editable overlay clicks", () => {
|
||||
const buttonLikeTarget = {
|
||||
tagName: "button",
|
||||
isContentEditable: false,
|
||||
closest() {
|
||||
return null;
|
||||
},
|
||||
getAttribute() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(buttonLikeTarget as unknown as EventTarget), true);
|
||||
});
|
||||
|
||||
test("allows native focus for direct editable targets", () => {
|
||||
const inputTarget = {
|
||||
tagName: "input",
|
||||
isContentEditable: false,
|
||||
closest() {
|
||||
return null;
|
||||
},
|
||||
getAttribute() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(inputTarget as unknown as EventTarget), false);
|
||||
});
|
||||
|
||||
test("allows native focus for descendants inside editable controls", () => {
|
||||
const nestedTarget = {
|
||||
tagName: "span",
|
||||
isContentEditable: false,
|
||||
closest(selector: string) {
|
||||
return selector.includes("input") ? { tagName: "INPUT" } : null;
|
||||
},
|
||||
getAttribute() {
|
||||
return null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(nestedTarget as unknown as EventTarget), false);
|
||||
});
|
||||
|
||||
test("allows native focus for contenteditable regions", () => {
|
||||
const editableTarget = {
|
||||
tagName: "div",
|
||||
isContentEditable: false,
|
||||
closest() {
|
||||
return null;
|
||||
},
|
||||
getAttribute(name: string) {
|
||||
return name === "contenteditable" ? "true" : null;
|
||||
},
|
||||
};
|
||||
|
||||
assert.equal(shouldPreserveTerminalFocusOnMouseDown(editableTarget as unknown as EventTarget), false);
|
||||
});
|
||||
44
components/terminal/toolbarFocus.ts
Normal file
44
components/terminal/toolbarFocus.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
type FocusTargetLike = {
|
||||
tagName?: string | null;
|
||||
isContentEditable?: boolean;
|
||||
closest?: (selector: string) => unknown;
|
||||
getAttribute?: (name: string) => string | null;
|
||||
};
|
||||
|
||||
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]';
|
||||
|
||||
/**
|
||||
* The terminal's top overlay sits above the xterm textarea. Pointer clicks on
|
||||
* that layer should usually keep focus in the terminal so typing can continue.
|
||||
* Only allow native focus changes for genuinely editable controls.
|
||||
*/
|
||||
export const shouldPreserveTerminalFocusOnMouseDown = (target: EventTarget | null): boolean => {
|
||||
if (!target || typeof target !== "object") return true;
|
||||
|
||||
const candidate = target as FocusTargetLike;
|
||||
const tagName = typeof candidate.tagName === "string"
|
||||
? candidate.tagName.toUpperCase()
|
||||
: "";
|
||||
|
||||
if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (candidate.isContentEditable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (typeof candidate.getAttribute === "function") {
|
||||
const contentEditable = candidate.getAttribute("contenteditable");
|
||||
const role = candidate.getAttribute("role");
|
||||
if (contentEditable === "" || contentEditable === "true" || role === "textbox") {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof candidate.closest === "function" && candidate.closest(EDITABLE_SELECTOR)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
51
domain/host.test.ts
Normal file
51
domain/host.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host } from "./models.ts";
|
||||
import { upsertHostById } from "./host.ts";
|
||||
|
||||
const makeHost = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Primary Host",
|
||||
hostname: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
authType: "password",
|
||||
createdAt: 1,
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("upsertHostById updates an existing host in place", () => {
|
||||
const existing = makeHost();
|
||||
const updated = makeHost({ label: "Updated Host" });
|
||||
|
||||
assert.deepEqual(upsertHostById([existing], updated), [updated]);
|
||||
});
|
||||
|
||||
test("upsertHostById appends a duplicated host with a fresh id", () => {
|
||||
const existing = makeHost({
|
||||
id: "serial-original",
|
||||
label: "Serial Config",
|
||||
protocol: "serial",
|
||||
hostname: "/dev/ttyUSB0",
|
||||
port: 115200,
|
||||
serialConfig: {
|
||||
path: "/dev/ttyUSB0",
|
||||
baudRate: 115200,
|
||||
dataBits: 8,
|
||||
stopBits: 1,
|
||||
parity: "none",
|
||||
flowControl: "none",
|
||||
localEcho: false,
|
||||
lineMode: false,
|
||||
},
|
||||
});
|
||||
const duplicate = makeHost({
|
||||
...existing,
|
||||
id: "serial-duplicate",
|
||||
label: "Serial Config (copy)",
|
||||
});
|
||||
|
||||
assert.deepEqual(upsertHostById([existing], duplicate), [existing, duplicate]);
|
||||
});
|
||||
@@ -153,6 +153,13 @@ export const formatHostPort = (hostname: string, port?: number | null): string =
|
||||
return `${display}:${port}`;
|
||||
};
|
||||
|
||||
export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
|
||||
const hostExists = hosts.some((entry) => entry.id === host.id);
|
||||
return hostExists
|
||||
? hosts.map((entry) => (entry.id === host.id ? host : entry))
|
||||
: [...hosts, host];
|
||||
};
|
||||
|
||||
export const sanitizeHost = (host: Host): Host => {
|
||||
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
|
||||
const cleanDistro = normalizeDistroId(host.distro);
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
/**
|
||||
* Cloud Sync Domain Types & Interfaces
|
||||
*
|
||||
*
|
||||
* Zero-Knowledge Encrypted Multi-Cloud Sync System
|
||||
* Supports: GitHub Gist, Google Drive, Microsoft OneDrive, WebDAV, S3 Compatible
|
||||
*/
|
||||
|
||||
import type { ShrinkFinding } from './syncGuards';
|
||||
|
||||
// ============================================================================
|
||||
// Security State Machine
|
||||
// ============================================================================
|
||||
@@ -22,10 +24,11 @@ export type SecurityState =
|
||||
* Sync Operation State Machine
|
||||
* Tracks the current sync operation status
|
||||
*/
|
||||
export type SyncState =
|
||||
export type SyncState =
|
||||
| 'IDLE' // Waiting for sync trigger
|
||||
| 'SYNCING' // Active sync operation in progress
|
||||
| 'CONFLICT' // Version conflict detected - needs resolution
|
||||
| 'BLOCKED' // Outgoing payload would delete too much — user must choose restore or force-push
|
||||
| 'ERROR'; // Operation failed - needs attention
|
||||
|
||||
/**
|
||||
@@ -284,6 +287,10 @@ export interface SyncResult {
|
||||
conflictDetected?: boolean;
|
||||
/** Present when action === 'merge'; caller should apply this to update local state */
|
||||
mergedPayload?: import('./sync').SyncPayload;
|
||||
/** True when a shrink-detection guard blocked the upload */
|
||||
shrinkBlocked?: boolean;
|
||||
/** The finding that triggered the shrink block or force-push */
|
||||
finding?: ShrinkFinding;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -351,10 +358,13 @@ export type SyncEvent =
|
||||
| { type: 'SYNC_COMPLETED'; provider: CloudProvider; result: SyncResult }
|
||||
| { type: 'SYNC_ERROR'; provider: CloudProvider; error: string }
|
||||
| { type: 'CONFLICT_DETECTED'; conflict: ConflictInfo }
|
||||
| { type: 'SYNC_BLOCKED_SHRINK'; provider: CloudProvider; finding: ShrinkFinding }
|
||||
| { type: 'SYNC_FORCED'; provider: CloudProvider; finding: ShrinkFinding }
|
||||
| { type: 'CONFLICT_RESOLVED'; resolution: ConflictResolution }
|
||||
| { type: 'AUTH_REQUIRED'; provider: CloudProvider }
|
||||
| { type: 'AUTH_COMPLETED'; provider: CloudProvider; account: ProviderAccount }
|
||||
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState };
|
||||
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState }
|
||||
| { type: 'SYNC_BLOCKED_CLEARED' };
|
||||
|
||||
// ============================================================================
|
||||
// Storage Keys
|
||||
|
||||
139
domain/syncGuards.test.ts
Normal file
139
domain/syncGuards.test.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { detectSuspiciousShrink } from "./syncGuards.ts";
|
||||
import type { SyncPayload } from "./sync.ts";
|
||||
|
||||
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
|
||||
return {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
knownHosts: [],
|
||||
portForwardingRules: [],
|
||||
groupConfigs: [],
|
||||
settings: undefined,
|
||||
syncedAt: 0,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function hosts(n: number): SyncPayload["hosts"] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
id: `h${i}`,
|
||||
label: `h${i}`,
|
||||
hostname: `h${i}.example`,
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
})) as SyncPayload["hosts"];
|
||||
}
|
||||
|
||||
test("null base → not suspicious (first sync / null after re-auth)", () => {
|
||||
const result = detectSuspiciousShrink(payload({ hosts: hosts(1) }), null);
|
||||
assert.deepEqual(result, { suspicious: false });
|
||||
});
|
||||
|
||||
test("no shrink — same counts → not suspicious", () => {
|
||||
const base = payload({ hosts: hosts(5) });
|
||||
const out = payload({ hosts: hosts(5) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("growth only → not suspicious", () => {
|
||||
const base = payload({ hosts: hosts(5) });
|
||||
const out = payload({ hosts: hosts(10) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("shrink under both thresholds → not suspicious (delete 2 of 4)", () => {
|
||||
const base = payload({ hosts: hosts(4) });
|
||||
const out = payload({ hosts: hosts(2) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("bulk-shrink 50% AND absolute 3 — exactly at threshold → suspicious", () => {
|
||||
const base = payload({ hosts: hosts(6) });
|
||||
const out = payload({ hosts: hosts(3) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), {
|
||||
suspicious: true,
|
||||
reason: "bulk-shrink",
|
||||
entityType: "hosts",
|
||||
baseCount: 6,
|
||||
outgoingCount: 3,
|
||||
lost: 3,
|
||||
});
|
||||
});
|
||||
|
||||
test("bulk-shrink 50% but absolute 2 → not suspicious (absolute gate)", () => {
|
||||
const base = payload({ hosts: hosts(4) });
|
||||
const out = payload({ hosts: hosts(2) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("bulk-shrink 40% absolute 4 → not suspicious (ratio gate)", () => {
|
||||
const base = payload({ hosts: hosts(10) });
|
||||
const out = payload({ hosts: hosts(6) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("large-shrink absolute 10 regardless of ratio → suspicious", () => {
|
||||
const base = payload({ hosts: hosts(100) });
|
||||
const out = payload({ hosts: hosts(90) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), {
|
||||
suspicious: true,
|
||||
reason: "large-shrink",
|
||||
entityType: "hosts",
|
||||
baseCount: 100,
|
||||
outgoingCount: 90,
|
||||
lost: 10,
|
||||
});
|
||||
});
|
||||
|
||||
test("dual-trigger (large-shrink AND bulk-shrink both satisfied) → reason is 'large-shrink'", () => {
|
||||
// base=20, lost=10: satisfies large-shrink (>=10) AND bulk-shrink (50%, >=3)
|
||||
const base = payload({ hosts: hosts(20) });
|
||||
const out = payload({ hosts: hosts(10) });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) assert.equal(result.reason, "large-shrink");
|
||||
});
|
||||
|
||||
test("multiple entity types shrinking — returns first in declaration order (hosts before keys)", () => {
|
||||
const base = payload({ hosts: hosts(6), keys: Array.from({ length: 6 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
|
||||
const out = payload({ hosts: hosts(3), keys: Array.from({ length: 3 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) assert.equal(result.entityType, "hosts");
|
||||
});
|
||||
|
||||
test("only non-hosts entity shrinks → reports that entity", () => {
|
||||
const snippets = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `s${i}`, label: `s${i}`, command: "" })) as SyncPayload["snippets"];
|
||||
const base = payload({ snippets: snippets(10) });
|
||||
const out = payload({ snippets: snippets(0) });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) {
|
||||
assert.equal(result.entityType, "snippets");
|
||||
assert.equal(result.reason, "large-shrink");
|
||||
}
|
||||
});
|
||||
|
||||
test("knownHosts shrink triggers (security-sensitive)", () => {
|
||||
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
|
||||
const base = payload({ knownHosts: kh(12) });
|
||||
const out = payload({ knownHosts: kh(2) });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) assert.equal(result.entityType, "knownHosts");
|
||||
});
|
||||
|
||||
test("empty base (all zeros) — no shrink possible, returns not suspicious", () => {
|
||||
const base = payload();
|
||||
const out = payload({ hosts: hosts(5) });
|
||||
// All base counts are 0; no shrink possible
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
85
domain/syncGuards.ts
Normal file
85
domain/syncGuards.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { SyncPayload } from './sync';
|
||||
|
||||
export type ShrinkFinding =
|
||||
| { suspicious: false }
|
||||
| {
|
||||
suspicious: true;
|
||||
reason: 'bulk-shrink' | 'large-shrink';
|
||||
entityType:
|
||||
| 'hosts'
|
||||
| 'keys'
|
||||
| 'identities'
|
||||
| 'snippets'
|
||||
| 'customGroups'
|
||||
| 'snippetPackages'
|
||||
| 'knownHosts'
|
||||
| 'portForwardingRules'
|
||||
| 'groupConfigs';
|
||||
baseCount: number;
|
||||
outgoingCount: number;
|
||||
lost: number;
|
||||
};
|
||||
|
||||
// Keep in sync with all array-typed fields of SyncPayload. When a new
|
||||
// array entity type is added there, add it here too — there is no
|
||||
// compile-time check enforcing this.
|
||||
const CHECKED_ENTITIES = [
|
||||
'hosts',
|
||||
'keys',
|
||||
'identities',
|
||||
'snippets',
|
||||
'customGroups',
|
||||
'snippetPackages',
|
||||
'knownHosts',
|
||||
'portForwardingRules',
|
||||
'groupConfigs',
|
||||
] as const;
|
||||
|
||||
type CheckedEntityType = typeof CHECKED_ENTITIES[number];
|
||||
|
||||
const BULK_SHRINK_RATIO = 0.5;
|
||||
const BULK_SHRINK_MIN_ABSOLUTE = 3;
|
||||
const LARGE_SHRINK_ABSOLUTE = 10;
|
||||
|
||||
function countOf(p: SyncPayload, key: CheckedEntityType): number {
|
||||
const v = p[key];
|
||||
return Array.isArray(v) ? v.length : 0;
|
||||
}
|
||||
|
||||
export function detectSuspiciousShrink(
|
||||
outgoing: SyncPayload,
|
||||
base: SyncPayload | null,
|
||||
): ShrinkFinding {
|
||||
if (!base) return { suspicious: false };
|
||||
|
||||
for (const entityType of CHECKED_ENTITIES) {
|
||||
const baseCount = countOf(base, entityType);
|
||||
const outgoingCount = countOf(outgoing, entityType);
|
||||
const lost = baseCount - outgoingCount;
|
||||
if (lost <= 0) continue;
|
||||
|
||||
if (lost >= LARGE_SHRINK_ABSOLUTE) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: 'large-shrink',
|
||||
entityType,
|
||||
baseCount,
|
||||
outgoingCount,
|
||||
lost,
|
||||
};
|
||||
}
|
||||
|
||||
if (baseCount > 0 && lost / baseCount >= BULK_SHRINK_RATIO && lost >= BULK_SHRINK_MIN_ABSOLUTE) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: 'bulk-shrink',
|
||||
entityType,
|
||||
baseCount,
|
||||
outgoingCount,
|
||||
lost,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return { suspicious: false };
|
||||
}
|
||||
@@ -29,6 +29,7 @@ module.exports = {
|
||||
'node_modules/node-pty/**/*',
|
||||
'node_modules/ssh2/**/*',
|
||||
'node_modules/cpu-features/**/*',
|
||||
'node_modules/@vscode/windows-process-tree/**/*',
|
||||
'node_modules/@zed-industries/claude-agent-acp/**/*',
|
||||
'node_modules/@agentclientprotocol/sdk/**/*',
|
||||
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',
|
||||
|
||||
89
electron/bridges/ptyProcessTree.cjs
Normal file
89
electron/bridges/ptyProcessTree.cjs
Normal file
@@ -0,0 +1,89 @@
|
||||
const { execFile } = require("node:child_process");
|
||||
|
||||
function createProcessTree({ platform, listPosix, listWindows } = {}) {
|
||||
const sessionPidMap = new Map();
|
||||
|
||||
function registerPid(sessionId, pid) {
|
||||
if (!sessionId || typeof pid !== "number") return;
|
||||
if (sessionPidMap.has(sessionId) && sessionPidMap.get(sessionId) !== pid) {
|
||||
console.warn(
|
||||
`[ptyProcessTree] sessionId "${sessionId}" already registered with pid ${sessionPidMap.get(sessionId)}; overwriting with ${pid}.`,
|
||||
);
|
||||
}
|
||||
sessionPidMap.set(sessionId, pid);
|
||||
}
|
||||
|
||||
function unregisterPid(sessionId) {
|
||||
sessionPidMap.delete(sessionId);
|
||||
}
|
||||
|
||||
async function getChildProcesses(sessionId) {
|
||||
const pid = sessionPidMap.get(sessionId);
|
||||
if (!pid) return [];
|
||||
if (platform === "win32") {
|
||||
return listWindows ? listWindows(pid) : [];
|
||||
}
|
||||
return listPosix ? listPosix(pid) : [];
|
||||
}
|
||||
|
||||
return { registerPid, unregisterPid, getChildProcesses };
|
||||
}
|
||||
|
||||
function defaultListPosix(ppid) {
|
||||
return new Promise((resolve) => {
|
||||
// `ps -A -o pid=,ppid=,args=` works on both BSD (macOS) and GNU (Linux).
|
||||
// `args=` shows the full command line (not truncated like `comm=`).
|
||||
// The trailing `=` on each column suppresses the header row.
|
||||
execFile("ps", ["-A", "-o", "pid=,ppid=,args="], (err, stdout) => {
|
||||
if (err || typeof stdout !== "string") return resolve([]);
|
||||
const out = [];
|
||||
for (const line of stdout.split("\n")) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(.+)$/);
|
||||
if (!m) continue;
|
||||
if (Number(m[2]) !== ppid) continue;
|
||||
out.push({ pid: Number(m[1]), command: m[3].trim() });
|
||||
}
|
||||
resolve(out);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function defaultListWindows(ppid) {
|
||||
return new Promise((resolve) => {
|
||||
let wpt;
|
||||
try {
|
||||
wpt = require("@vscode/windows-process-tree");
|
||||
} catch {
|
||||
return resolve([]);
|
||||
}
|
||||
try {
|
||||
wpt.getProcessTree(ppid, (tree) => {
|
||||
if (!tree || !Array.isArray(tree.children)) return resolve([]);
|
||||
resolve(tree.children.map((c) => ({ pid: c.pid, command: c.name })));
|
||||
});
|
||||
} catch {
|
||||
resolve([]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createDefaultProcessTree() {
|
||||
const platform = process.platform;
|
||||
return createProcessTree({
|
||||
platform,
|
||||
listPosix: platform === "win32" ? undefined : defaultListPosix,
|
||||
listWindows: platform === "win32" ? defaultListWindows : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const defaultTree = createDefaultProcessTree();
|
||||
|
||||
module.exports = {
|
||||
createProcessTree,
|
||||
processTree: defaultTree,
|
||||
registerPid: (id, pid) => defaultTree.registerPid(id, pid),
|
||||
unregisterPid: (id) => defaultTree.unregisterPid(id),
|
||||
getChildProcesses: (id) => defaultTree.getChildProcesses(id),
|
||||
};
|
||||
79
electron/bridges/ptyProcessTree.test.cjs
Normal file
79
electron/bridges/ptyProcessTree.test.cjs
Normal file
@@ -0,0 +1,79 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const { createProcessTree } = require("./ptyProcessTree.cjs");
|
||||
|
||||
test("getChildProcesses returns [] when session has no registered pid", async () => {
|
||||
const tree = createProcessTree({ platform: "darwin", listPosix: async () => [] });
|
||||
assert.deepEqual(await tree.getChildProcesses("unknown-session"), []);
|
||||
});
|
||||
|
||||
test("getChildProcesses calls listPosix with the registered ppid and returns its result", async () => {
|
||||
const calls = [];
|
||||
const listPosix = async (ppid) => {
|
||||
calls.push(ppid);
|
||||
return [
|
||||
{ pid: 2001, command: "sleep 100" },
|
||||
{ pid: 2002, command: "node server.js" },
|
||||
];
|
||||
};
|
||||
const tree = createProcessTree({ platform: "linux", listPosix });
|
||||
tree.registerPid("s1", 1234);
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), [
|
||||
{ pid: 2001, command: "sleep 100" },
|
||||
{ pid: 2002, command: "node server.js" },
|
||||
]);
|
||||
assert.deepEqual(calls, [1234]);
|
||||
});
|
||||
|
||||
test("unregisterPid clears mapping", async () => {
|
||||
const tree = createProcessTree({
|
||||
platform: "darwin",
|
||||
listPosix: async () => [{ pid: 9, command: "x" }],
|
||||
});
|
||||
tree.registerPid("s1", 1234);
|
||||
tree.unregisterPid("s1");
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), []);
|
||||
});
|
||||
|
||||
test("getChildProcesses on windows uses listWindows", async () => {
|
||||
const calls = [];
|
||||
const listWindows = async (pid) => {
|
||||
calls.push(pid);
|
||||
return [{ pid: 55, command: "python.exe" }];
|
||||
};
|
||||
const tree = createProcessTree({ platform: "win32", listWindows });
|
||||
tree.registerPid("s1", 3000);
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), [{ pid: 55, command: "python.exe" }]);
|
||||
assert.deepEqual(calls, [3000]);
|
||||
});
|
||||
|
||||
test("getChildProcesses returns [] when listPosix missing on posix", async () => {
|
||||
const tree = createProcessTree({ platform: "darwin" });
|
||||
tree.registerPid("s1", 1234);
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), []);
|
||||
});
|
||||
|
||||
test("getChildProcesses returns [] when listWindows missing on windows", async () => {
|
||||
const tree = createProcessTree({ platform: "win32" });
|
||||
tree.registerPid("s1", 3000);
|
||||
assert.deepEqual(await tree.getChildProcesses("s1"), []);
|
||||
});
|
||||
|
||||
test("registerPid warns when overwriting an existing sessionId with a different pid", async () => {
|
||||
const warnCalls = [];
|
||||
const origWarn = console.warn;
|
||||
console.warn = (...args) => warnCalls.push(args);
|
||||
try {
|
||||
const tree = createProcessTree({ platform: "darwin", listPosix: async () => [] });
|
||||
tree.registerPid("s1", 1234);
|
||||
tree.registerPid("s1", 1234); // same pid — no warn
|
||||
tree.registerPid("s1", 5678); // different — should warn
|
||||
assert.equal(warnCalls.length, 1);
|
||||
assert.match(warnCalls[0][0], /s1/);
|
||||
assert.match(warnCalls[0][0], /1234/);
|
||||
assert.match(warnCalls[0][0], /5678/);
|
||||
} finally {
|
||||
console.warn = origWarn;
|
||||
}
|
||||
});
|
||||
46
electron/bridges/sshIdentificationCompatibility.test.cjs
Normal file
46
electron/bridges/sshIdentificationCompatibility.test.cjs
Normal file
@@ -0,0 +1,46 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const Protocol = require("ssh2/lib/protocol/Protocol");
|
||||
|
||||
function parseIdentification(line) {
|
||||
let header;
|
||||
const protocol = new Protocol({
|
||||
onWrite() {},
|
||||
onError(err) {
|
||||
throw err;
|
||||
},
|
||||
onHeader(nextHeader) {
|
||||
header = nextHeader;
|
||||
},
|
||||
});
|
||||
|
||||
const data = Buffer.from(`${line}\r\n`, "latin1");
|
||||
protocol.parse(data, 0, data.length);
|
||||
|
||||
assert.ok(header, "expected SSH header to be parsed");
|
||||
return header;
|
||||
}
|
||||
|
||||
test("ssh2 accepts an empty softwareversion for compatibility", () => {
|
||||
const header = parseIdentification("SSH-2.0-");
|
||||
|
||||
assert.equal(header.versions.protocol, "2.0");
|
||||
assert.equal(header.versions.software, "");
|
||||
assert.equal(header.comments, undefined);
|
||||
});
|
||||
|
||||
test("ssh2 still accepts standard identification strings", () => {
|
||||
const header = parseIdentification("SSH-2.0-OpenSSH_9.9 Netcatty");
|
||||
|
||||
assert.equal(header.versions.protocol, "2.0");
|
||||
assert.equal(header.versions.software, "OpenSSH_9.9");
|
||||
assert.equal(header.comments, "Netcatty");
|
||||
});
|
||||
|
||||
test("ssh2 still rejects malformed identification strings", () => {
|
||||
assert.throws(
|
||||
() => parseIdentification("SSH-2.0"),
|
||||
/Invalid identification string/,
|
||||
);
|
||||
});
|
||||
@@ -10,6 +10,7 @@ const path = require("node:path");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
const ptyProcessTree = require("./ptyProcessTree.cjs");
|
||||
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
@@ -326,6 +327,7 @@ function startLocalSession(event, payload) {
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
ptyProcessTree.registerPid(sessionId, proc.pid);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) {
|
||||
@@ -382,6 +384,7 @@ function startLocalSession(event, payload) {
|
||||
proc.onExit((evt) => {
|
||||
flushLocal();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
// Signal present = killed externally (show disconnected UI).
|
||||
@@ -648,6 +651,7 @@ async function startTelnetSession(event, options) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
}
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
}
|
||||
});
|
||||
@@ -664,6 +668,7 @@ async function startTelnetSession(event, options) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
|
||||
}
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
@@ -802,6 +807,7 @@ async function startMoshSession(event, options) {
|
||||
proc.onExit((evt) => {
|
||||
flushMosh();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
// Mosh non-zero exit typically means connection/auth failure — show error UI
|
||||
@@ -931,6 +937,7 @@ async function startSerialSession(event, options) {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
@@ -940,6 +947,7 @@ async function startSerialSession(event, options) {
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
});
|
||||
|
||||
@@ -1043,6 +1051,7 @@ function closeSession(event, payload) {
|
||||
} catch (err) {
|
||||
console.warn("Close failed", err);
|
||||
}
|
||||
ptyProcessTree.unregisterPid(payload.sessionId);
|
||||
sessions.delete(payload.sessionId);
|
||||
}
|
||||
|
||||
@@ -1166,6 +1175,9 @@ function cleanupAllSessions() {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
for (const [sessionId] of sessions) {
|
||||
ptyProcessTree.unregisterPid(sessionId);
|
||||
}
|
||||
sessions.clear();
|
||||
}
|
||||
|
||||
|
||||
@@ -97,6 +97,7 @@ function toBackupSummary(record) {
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
reason: record.reason,
|
||||
syncDataVersion: record.syncDataVersion,
|
||||
sourceAppVersion: record.sourceAppVersion,
|
||||
targetAppVersion: record.targetAppVersion,
|
||||
preview: record.preview,
|
||||
@@ -131,6 +132,15 @@ function sanitizeOptionalVersionString(value) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// Sync data version is the integer that the CloudSyncManager increments
|
||||
// on each successful cloud sync. Reject anything non-finite, non-positive,
|
||||
// or non-integer so the persisted record only carries meaningful values.
|
||||
function sanitizeOptionalSyncDataVersion(value) {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
||||
if (value < 1) return undefined;
|
||||
return Math.floor(value);
|
||||
}
|
||||
|
||||
// UTF-8 byte length of a payload's JSON serialization. Earlier revisions
|
||||
// returned `JSON.stringify(payload).length` (UTF-16 code units), which
|
||||
// under-counted by ~3x for non-ASCII vaults — a deck full of CJK snippet
|
||||
@@ -415,6 +425,7 @@ function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
id,
|
||||
createdAt,
|
||||
reason: sanitizeReason(options.reason),
|
||||
syncDataVersion: sanitizeOptionalSyncDataVersion(options.syncDataVersion),
|
||||
sourceAppVersion: sanitizeOptionalVersionString(options.sourceAppVersion),
|
||||
targetAppVersion: sanitizeOptionalVersionString(options.targetAppVersion),
|
||||
fingerprint,
|
||||
|
||||
@@ -417,6 +417,70 @@ test("createBackup accepts a legitimate SemVer-ish version string", async () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup persists syncDataVersion when given a positive integer", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "before_restore",
|
||||
syncDataVersion: 5,
|
||||
});
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backup.syncDataVersion, 5);
|
||||
|
||||
// Round-trip via list
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed[0].syncDataVersion, 5);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup drops invalid syncDataVersion values (zero, negative, non-finite, non-numeric)", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const cases = [0, -1, NaN, Infinity, "5", null, undefined];
|
||||
let idx = 0;
|
||||
for (const syncDataVersion of cases) {
|
||||
// Vary an actual content-bearing field to avoid fingerprint dedupe
|
||||
// (top-level syncedAt is normalized away in the fingerprint).
|
||||
const payload = samplePayload({
|
||||
hosts: [{ ...samplePayload().hosts[0], id: `h-case-${idx}` }],
|
||||
});
|
||||
const result = await service.createBackup({
|
||||
payload,
|
||||
reason: "before_restore",
|
||||
syncDataVersion,
|
||||
});
|
||||
assert.equal(result.created, true, `iteration ${idx}: created should be true`);
|
||||
assert.equal(result.backup.syncDataVersion, undefined, `value ${String(syncDataVersion)} should be dropped`);
|
||||
idx += 1;
|
||||
}
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup floors a fractional syncDataVersion", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "before_restore",
|
||||
syncDataVersion: 7.9,
|
||||
});
|
||||
assert.equal(result.backup.syncDataVersion, 7);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup rejects an array payload (not an object)", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
@@ -165,6 +165,7 @@ const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
|
||||
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
|
||||
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
|
||||
const getVaultBackupBridge = createLazyModule("./bridges/vaultBackupBridge.cjs");
|
||||
const ptyProcessTree = require("./bridges/ptyProcessTree.cjs");
|
||||
|
||||
// GPU settings
|
||||
// NOTE: Do not disable Chromium sandbox by default.
|
||||
@@ -683,6 +684,40 @@ const registerBridges = (win) => {
|
||||
};
|
||||
});
|
||||
|
||||
// PTY child process list for busy-check before close
|
||||
ipcMain.handle("netcatty:pty:childProcesses", async (_event, sessionId) => {
|
||||
if (typeof sessionId !== "string") return [];
|
||||
return ptyProcessTree.getChildProcesses(sessionId);
|
||||
});
|
||||
|
||||
// Native confirmation dialog when closing a session with a running process
|
||||
// Returns true only if the user explicitly clicks "Close". ESC/dialog-dismiss
|
||||
// resolves as cancelId (0) → false, which is the safe default (do not close).
|
||||
ipcMain.handle(
|
||||
"netcatty:dialog:confirmCloseBusy",
|
||||
async (event, payload) => {
|
||||
const command = typeof payload?.command === "string" ? payload.command : "unknown";
|
||||
const title = typeof payload?.title === "string" ? payload.title : "Confirm close";
|
||||
const message = typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: `Process "${command}" is still running and will be terminated.`;
|
||||
const cancelLabel = typeof payload?.cancelLabel === "string" ? payload.cancelLabel : "Cancel";
|
||||
const closeLabel = typeof payload?.closeLabel === "string" ? payload.closeLabel : "Close";
|
||||
const { dialog } = electronModule;
|
||||
const win = BrowserWindow.fromWebContents(event.sender);
|
||||
const { response } = await dialog.showMessageBox(win || undefined, {
|
||||
type: "warning",
|
||||
title,
|
||||
message,
|
||||
buttons: [cancelLabel, closeLabel],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
noLink: true,
|
||||
});
|
||||
return response === 1; // true = user picked Close
|
||||
},
|
||||
);
|
||||
|
||||
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
|
||||
ipcMain.handle("netcatty:clipboard:readText", async () => {
|
||||
try {
|
||||
|
||||
@@ -858,6 +858,10 @@ const api = {
|
||||
|
||||
// App info
|
||||
getAppInfo: () => ipcRenderer.invoke("netcatty:app:getInfo"),
|
||||
ptyGetChildProcesses: (sessionId) =>
|
||||
ipcRenderer.invoke("netcatty:pty:childProcesses", sessionId),
|
||||
confirmCloseBusy: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:dialog:confirmCloseBusy", payload),
|
||||
getVaultBackupCapabilities: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:capabilities"),
|
||||
createVaultBackup: (payload) =>
|
||||
|
||||
8
global.d.ts
vendored
8
global.d.ts
vendored
@@ -512,6 +512,14 @@ declare global {
|
||||
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
ptyGetChildProcesses?(sessionId: string): Promise<Array<{ pid: number; command: string }>>;
|
||||
confirmCloseBusy?(payload: {
|
||||
command: string;
|
||||
title?: string;
|
||||
message?: string;
|
||||
cancelLabel?: string;
|
||||
closeLabel?: string;
|
||||
}): Promise<boolean>;
|
||||
getVaultBackupCapabilities?(): Promise<{ encryptionAvailable: boolean }>;
|
||||
createVaultBackup?(payload: {
|
||||
payload: import('./domain/sync').SyncPayload;
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
encryptProviderSecrets,
|
||||
} from '../persistence/secureFieldAdapter';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
import { detectSuspiciousShrink, type ShrinkFinding } from '../../domain/syncGuards';
|
||||
// Extracted into a plain ESM module so the signature logic is covered by
|
||||
// the node --test harness (see syncSignature.test.mjs). The previous
|
||||
// inline implementation only hashed a handful of meta fields and was
|
||||
@@ -77,6 +78,12 @@ export interface SyncManagerState {
|
||||
autoSyncEnabled: boolean;
|
||||
autoSyncInterval: number;
|
||||
syncHistory: SyncHistoryEntry[];
|
||||
/** Last shrink finding that put us into BLOCKED state, retained until
|
||||
* a sync actually succeeds (SYNC_COMPLETED with result.success) or
|
||||
* `clearShrinkBlockedState()` is called. Renderer hydrates the banner
|
||||
* from this on mount so a block that happened off-screen is still
|
||||
* visible to the user. */
|
||||
lastShrinkFinding?: Extract<ShrinkFinding, { suspicious: true }>;
|
||||
}
|
||||
|
||||
export type SyncEventCallback = (event: SyncEvent) => void;
|
||||
@@ -752,6 +759,12 @@ export class CloudSyncManager {
|
||||
const ghAdapter = adapter as GitHubAdapter;
|
||||
|
||||
try {
|
||||
// Snapshot the prior account BEFORE we overwrite providers[provider].
|
||||
// Used as a fallback for the same-account comparison when the persisted
|
||||
// accountId key is absent (e.g., first re-auth after upgrading to this
|
||||
// version, where the key didn't exist yet).
|
||||
const previousAccount = this.state.providers.github?.account;
|
||||
|
||||
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
|
||||
|
||||
++this.providerDecryptSeq.github;
|
||||
@@ -769,9 +782,20 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
await this.saveProviderConnection('github', this.state.providers.github);
|
||||
// Clear merge base when (re)authenticating to a potentially different account
|
||||
this.removeFromStorage(this.syncBaseKey('github'));
|
||||
this.clearSyncAnchor('github');
|
||||
|
||||
// Only clear the merge base if the authenticated account identity differs
|
||||
// from the previously-stored one. See notes in completePKCEAuth.
|
||||
const newId = ghAdapter.accountInfo?.id ?? null;
|
||||
const previousId = this.loadProviderAccountId('github') ?? previousAccount?.id ?? null;
|
||||
const sameAccount = newId !== null && previousId !== null && newId === previousId;
|
||||
if (!sameAccount) {
|
||||
this.removeFromStorage(this.syncBaseKey('github'));
|
||||
this.clearSyncAnchor('github');
|
||||
}
|
||||
if (newId) {
|
||||
this.saveProviderAccountId('github', newId);
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: 'github',
|
||||
@@ -797,6 +821,12 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
try {
|
||||
// Snapshot the prior account BEFORE we overwrite providers[provider].
|
||||
// Used as a fallback for the same-account comparison when the persisted
|
||||
// accountId key is absent (e.g., first re-auth after upgrading to this
|
||||
// version, where the key didn't exist yet).
|
||||
const previousAccount = this.state.providers[provider]?.account;
|
||||
|
||||
let tokens: OAuthTokens;
|
||||
let account;
|
||||
|
||||
@@ -825,9 +855,22 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear merge base when (re)authenticating to a potentially different account
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
|
||||
// Only clear the merge base if the authenticated account identity differs
|
||||
// from the previously-stored one. Same-account re-auth preserves the base
|
||||
// so the next sync computes correct local-deletions instead of treating
|
||||
// it as "first sync" and resurrecting zombie entries via null-base union.
|
||||
const newId = account?.id ?? null;
|
||||
const previousId = this.loadProviderAccountId(provider) ?? previousAccount?.id ?? null;
|
||||
const sameAccount = newId !== null && previousId !== null && newId === previousId;
|
||||
if (!sameAccount) {
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
}
|
||||
if (newId) {
|
||||
this.saveProviderAccountId(provider, newId);
|
||||
}
|
||||
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -912,6 +955,13 @@ export class CloudSyncManager {
|
||||
// account/resource doesn't reuse an unrelated snapshot
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
this.removeFromStorage(this.providerAccountIdKey(provider));
|
||||
// Reset BLOCKED state if it was present — disconnect implicitly resolves
|
||||
// any pending shrink-block warning since there's no provider to push to.
|
||||
this.exitBlockedState();
|
||||
if (this.state.syncState === 'BLOCKED') {
|
||||
this.state.syncState = 'IDLE';
|
||||
}
|
||||
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
||||
}
|
||||
|
||||
@@ -1227,7 +1277,8 @@ export class CloudSyncManager {
|
||||
*/
|
||||
async syncToProvider(
|
||||
provider: CloudProvider,
|
||||
payload: SyncPayload
|
||||
payload: SyncPayload,
|
||||
opts: { overrideShrink?: boolean } = {},
|
||||
): Promise<SyncResult> {
|
||||
if (this.state.securityState !== 'UNLOCKED') {
|
||||
return {
|
||||
@@ -1247,6 +1298,8 @@ export class CloudSyncManager {
|
||||
};
|
||||
}
|
||||
|
||||
const overrideShrinkRequested = opts.overrideShrink === true;
|
||||
|
||||
let adapter: CloudAdapter;
|
||||
try {
|
||||
adapter = await this.getConnectedAdapter(provider);
|
||||
@@ -1288,6 +1341,30 @@ export class CloudSyncManager {
|
||||
|
||||
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
|
||||
|
||||
// Shrink guard: refuse to push a merged payload that silently deletes
|
||||
// entities we still have in base. The merge itself is correct if local
|
||||
// state is trustworthy — but a degraded local (keychain failure,
|
||||
// partial load) can make merge produce a smaller-than-expected result.
|
||||
const mergedShrink = detectSuspiciousShrink(mergeResult.payload, base);
|
||||
const shouldBlockMerged = mergedShrink.suspicious && !overrideShrinkRequested;
|
||||
const shouldForceMerged = mergedShrink.suspicious && overrideShrinkRequested;
|
||||
if (shouldBlockMerged) {
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = mergedShrink;
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: mergedShrink });
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
shrinkBlocked: true,
|
||||
finding: mergedShrink,
|
||||
};
|
||||
}
|
||||
if (shouldForceMerged) {
|
||||
this.emit({ type: 'SYNC_FORCED', provider, finding: mergedShrink });
|
||||
}
|
||||
|
||||
// Encrypt and upload merged payload
|
||||
const mergedSyncedFile = await EncryptionService.encryptPayload(
|
||||
mergeResult.payload,
|
||||
@@ -1309,6 +1386,7 @@ export class CloudSyncManager {
|
||||
// Base was persisted inside uploadToProvider before the
|
||||
// anchor advanced, so a crash between them cannot leave a
|
||||
// stale base pointing at pre-merge state.
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
|
||||
this.addSyncHistoryEntry({
|
||||
@@ -1361,6 +1439,29 @@ export class CloudSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Shrink guard (no-conflict path): same rationale as the merge branch —
|
||||
// refuse a payload that drops entities versus the stored base.
|
||||
const directBase = await this.loadSyncBase(provider);
|
||||
const directShrink = detectSuspiciousShrink(payload, directBase);
|
||||
const shouldBlockDirect = directShrink.suspicious && !overrideShrinkRequested;
|
||||
const shouldForceDirect = directShrink.suspicious && overrideShrinkRequested;
|
||||
if (shouldBlockDirect) {
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = directShrink;
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: directShrink });
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
return {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
shrinkBlocked: true,
|
||||
finding: directShrink,
|
||||
};
|
||||
}
|
||||
if (shouldForceDirect) {
|
||||
this.emit({ type: 'SYNC_FORCED', provider, finding: directShrink });
|
||||
}
|
||||
|
||||
// 2. Encrypt
|
||||
const syncedFile = await EncryptionService.encryptPayload(
|
||||
payload,
|
||||
@@ -1377,7 +1478,9 @@ export class CloudSyncManager {
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile, payload);
|
||||
|
||||
if (result.success) {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
if (result.error) {
|
||||
@@ -1550,26 +1653,73 @@ export class CloudSyncManager {
|
||||
// Download and return remote data
|
||||
const payload = await this.downloadFromProvider(provider);
|
||||
this.state.currentConflict = null;
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.notifyStateChange(); // Notify UI of conflict resolution
|
||||
return payload;
|
||||
} else {
|
||||
// USE_LOCAL - just clear conflict, caller will re-sync
|
||||
this.state.currentConflict = null;
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.notifyStateChange(); // Notify UI of conflict resolution
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Side-effect helper: called BEFORE any syncState assignment that transitions
|
||||
* away from BLOCKED. Clears lastShrinkFinding and emits SYNC_BLOCKED_CLEARED
|
||||
* so the UI banner (and any other subscriber) gets a single, authoritative
|
||||
* "block resolved" signal. The guard on syncState === 'BLOCKED' makes it safe
|
||||
* to call unconditionally at every non-BLOCKED assignment site — it no-ops
|
||||
* when the state was already non-BLOCKED.
|
||||
*/
|
||||
private exitBlockedState(): void {
|
||||
if (this.state.syncState === 'BLOCKED') {
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
this.emit({ type: 'SYNC_BLOCKED_CLEARED' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset BLOCKED back to IDLE without going through a successful sync.
|
||||
* Used by post-merge round-trip to avoid wedging the manager in BLOCKED
|
||||
* when the merge already produced safe local state and the round-trip
|
||||
* push is just an optimization.
|
||||
*/
|
||||
clearShrinkBlockedState(): void {
|
||||
if (this.state.syncState === 'BLOCKED') {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.notifyStateChange();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the last shrink finding that triggered BLOCKED state, or
|
||||
* null if not currently blocked. Used by the renderer to hydrate the
|
||||
* SyncBlockedBanner when opening Settings after a block happened
|
||||
* off-screen.
|
||||
*/
|
||||
getShrinkBlockedFinding(): Extract<ShrinkFinding, { suspicious: true }> | null {
|
||||
if (this.state.syncState !== 'BLOCKED') return null;
|
||||
return this.state.lastShrinkFinding ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync to all connected providers
|
||||
*/
|
||||
async syncAllProviders(inputPayload?: SyncPayload): Promise<Map<CloudProvider, SyncResult>> {
|
||||
async syncAllProviders(
|
||||
inputPayload?: SyncPayload,
|
||||
opts: { overrideShrink?: boolean } = {},
|
||||
): Promise<Map<CloudProvider, SyncResult>> {
|
||||
const results = new Map<CloudProvider, SyncResult>();
|
||||
let payload = inputPayload;
|
||||
let wasMerged = false;
|
||||
|
||||
const overrideShrinkRequested = opts.overrideShrink === true;
|
||||
|
||||
if (!payload) {
|
||||
// Caller should provide payload from app state
|
||||
return results;
|
||||
@@ -1743,6 +1893,80 @@ export class CloudSyncManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Shrink guard (multi-provider): check the final outgoing payload against
|
||||
// each provider's stored base. If ANY provider would suffer a suspicious
|
||||
// shrink, block ALL uploads — the same payload goes to every provider, so
|
||||
// any one provider's "would lose too much" is a global block. Override flag
|
||||
// is one-shot and clears regardless of outcome.
|
||||
const shrinkSuspectByProvider: Array<{
|
||||
provider: CloudProvider;
|
||||
finding: Extract<ShrinkFinding, { suspicious: true }>;
|
||||
}> = [];
|
||||
const candidateProviders = checkResults
|
||||
.filter((r) => !r.error && !r.check?.conflict && r.adapter)
|
||||
.map((r) => r.provider as CloudProvider);
|
||||
for (const provider of candidateProviders) {
|
||||
const providerBase = await this.loadSyncBase(provider);
|
||||
const finding = detectSuspiciousShrink(payload, providerBase);
|
||||
if (finding.suspicious) {
|
||||
shrinkSuspectByProvider.push({ provider, finding });
|
||||
}
|
||||
}
|
||||
const shouldBlockAll = shrinkSuspectByProvider.length > 0 && !overrideShrinkRequested;
|
||||
const shouldForceAll = shrinkSuspectByProvider.length > 0 && overrideShrinkRequested;
|
||||
|
||||
if (shouldBlockAll) {
|
||||
this.state.syncState = 'BLOCKED';
|
||||
this.state.lastShrinkFinding = shrinkSuspectByProvider[0].finding;
|
||||
for (const { provider, finding } of shrinkSuspectByProvider) {
|
||||
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding });
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
|
||||
results.set(provider, {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
shrinkBlocked: true,
|
||||
finding,
|
||||
});
|
||||
}
|
||||
// Process check errors from the parallel check phase so a provider that
|
||||
// failed during checkProviderConflict is not silently dropped from results.
|
||||
checkResults.forEach((r) => {
|
||||
if (r.error) {
|
||||
results.set(r.provider as CloudProvider, {
|
||||
success: false,
|
||||
provider: r.provider as CloudProvider,
|
||||
action: 'none',
|
||||
error: r.error,
|
||||
});
|
||||
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
|
||||
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
|
||||
}
|
||||
});
|
||||
// Providers in candidateProviders that didn't trip the shrink check still
|
||||
// share the same payload — mark them as not-uploaded so the caller doesn't
|
||||
// think a "successful" no-op happened.
|
||||
const blockedProviders = new Set(shrinkSuspectByProvider.map((e) => e.provider));
|
||||
for (const provider of candidateProviders) {
|
||||
if (!results.has(provider) && !blockedProviders.has(provider)) {
|
||||
results.set(provider, {
|
||||
success: false,
|
||||
provider,
|
||||
action: 'none',
|
||||
error: 'Sync blocked: another provider would lose too much data',
|
||||
});
|
||||
this.updateProviderStatus(provider, 'error', 'Sync blocked due to peer provider');
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
if (shouldForceAll) {
|
||||
for (const { provider, finding } of shrinkSuspectByProvider) {
|
||||
this.emit({ type: 'SYNC_FORCED', provider, finding });
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Encrypt Once
|
||||
const validUploads = checkResults.filter(
|
||||
(r) => !r.error && !r.check?.conflict && r.adapter
|
||||
@@ -1819,7 +2043,9 @@ export class CloudSyncManager {
|
||||
// 5. Final State Update
|
||||
const hasSuccess = Array.from(results.values()).some((r) => r.success);
|
||||
if (hasSuccess) {
|
||||
this.exitBlockedState();
|
||||
this.state.syncState = 'IDLE';
|
||||
this.state.lastShrinkFinding = undefined;
|
||||
|
||||
// If a merge happened, attach the merged payload to successful results
|
||||
// so callers can apply remote additions to local state
|
||||
@@ -1922,6 +2148,18 @@ export class CloudSyncManager {
|
||||
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${suffix}`;
|
||||
}
|
||||
|
||||
private providerAccountIdKey(provider: CloudProvider): string {
|
||||
return `netcatty.sync.accountId.${provider}`;
|
||||
}
|
||||
|
||||
private loadProviderAccountId(provider: CloudProvider): string | null {
|
||||
return this.loadFromStorage<string>(this.providerAccountIdKey(provider)) ?? null;
|
||||
}
|
||||
|
||||
private saveProviderAccountId(provider: CloudProvider, id: string): void {
|
||||
this.saveToStorage(this.providerAccountIdKey(provider), id);
|
||||
}
|
||||
|
||||
async saveSyncBase(payload: SyncPayload, provider?: CloudProvider): Promise<void> {
|
||||
const key = this.state.unlockedKey?.derivedKey;
|
||||
if (!key) return;
|
||||
|
||||
107
package-lock.json
generated
107
package-lock.json
generated
@@ -81,9 +81,13 @@
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@vscode/windows-process-tree": "^0.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@agentclientprotocol/sdk": {
|
||||
@@ -1154,6 +1158,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1799,7 +1804,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1821,7 +1825,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1838,7 +1841,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1853,7 +1855,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -3309,6 +3310,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
|
||||
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@hono/node-server": "^1.19.9",
|
||||
"ajv": "^8.17.1",
|
||||
@@ -6297,6 +6299,7 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/unist": "*"
|
||||
}
|
||||
@@ -6377,6 +6380,7 @@
|
||||
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
@@ -6406,6 +6410,7 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -6640,6 +6645,27 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/windows-process-tree": {
|
||||
"version": "0.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.7.0.tgz",
|
||||
"integrity": "sha512-uH58Fofu1bgiulsY2svyGOLLKAYNJ0Req4PioPd7BZzHRziuiBRw1SxyT7wvsYHvm7eKWwzwo6mZpExDfwX9iA==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"node-addon-api": "7.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vscode/windows-process-tree/node_modules/node-addon-api": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz",
|
||||
"integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": "^16 || ^18 || >= 20"
|
||||
}
|
||||
},
|
||||
"node_modules/@withfig/autocomplete": {
|
||||
"version": "2.692.3",
|
||||
"resolved": "https://registry.npmjs.org/@withfig/autocomplete/-/autocomplete-2.692.3.tgz",
|
||||
@@ -6935,6 +6961,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6985,6 +7012,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -7545,6 +7573,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -8287,8 +8316,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -8572,6 +8600,7 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -8953,7 +8982,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -8974,7 +9002,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -9204,6 +9231,7 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -10102,6 +10130,19 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/get-tsconfig": {
|
||||
"version": "4.14.0",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
|
||||
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/glob": {
|
||||
"version": "7.2.3",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
|
||||
@@ -10552,6 +10593,7 @@
|
||||
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
|
||||
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=16.9.0"
|
||||
}
|
||||
@@ -12041,6 +12083,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/debug": "^4.0.0",
|
||||
"debug": "^4.0.0",
|
||||
@@ -12658,7 +12701,8 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/micromatch": {
|
||||
"version": "4.0.8",
|
||||
@@ -12913,7 +12957,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -12926,6 +12969,7 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -13685,7 +13729,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -13703,7 +13746,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -13894,6 +13936,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -13903,6 +13946,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -14252,6 +14296,16 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/responselike": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
|
||||
@@ -15223,7 +15277,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -15288,7 +15341,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -15363,6 +15415,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -15471,6 +15524,27 @@
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
"get-tsconfig": "^4.7.5"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/tweetnacl": {
|
||||
"version": "0.14.5",
|
||||
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
|
||||
@@ -15555,6 +15629,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -15575,6 +15650,7 @@
|
||||
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
|
||||
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/unist": "^3.0.0",
|
||||
"bail": "^2.0.0",
|
||||
@@ -15913,6 +15989,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -16006,6 +16083,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -16284,6 +16362,7 @@
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -29,7 +29,8 @@
|
||||
"rebuild": "electron-builder install-app-deps",
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix"
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs application/state/*.test.ts domain/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
@@ -100,10 +101,14 @@
|
||||
"eslint-plugin-unused-imports": "^4.3.0",
|
||||
"patch-package": "^8.0.0",
|
||||
"tailwindcss": "^4.1.17",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.7",
|
||||
"wait-on": "^9.0.3"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@vscode/windows-process-tree": "^0.7.0"
|
||||
},
|
||||
"overrides": {
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0",
|
||||
"axios": "1.13.5"
|
||||
|
||||
@@ -33,7 +33,7 @@ index 7291c2c..8943c9a 100644
|
||||
}
|
||||
|
||||
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
index 7302488..95584c5 100644
|
||||
index 7302488..634acdd 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
|
||||
@@ -701,11 +701,19 @@ class Protocol {
|
||||
@@ -107,6 +107,18 @@ index 7302488..95584c5 100644
|
||||
packet.set(signature, p += 4);
|
||||
|
||||
this._authsQueue.push('hostbased');
|
||||
@@ -1916,7 +1932,10 @@ class Protocol {
|
||||
}
|
||||
|
||||
// SSH-protoversion-softwareversion (SP comments) CR LF
|
||||
-const RE_IDENT = /^SSH-(2\.0|1\.99)-([^ ]+)(?: (.*))?$/;
|
||||
+// RFC 4253 requires a non-empty softwareversion, but some embedded SSH
|
||||
+// daemons send "SSH-2.0-" with an empty token. Accept that specific
|
||||
+// compatibility case while still rejecting whitespace in the token itself.
|
||||
+const RE_IDENT = /^SSH-(2\.0|1\.99)-([^ ]*)(?: (.*))?$/;
|
||||
|
||||
// TODO: optimize this by starting n bytes from the end of this._buffer instead
|
||||
// of the beginning
|
||||
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
index 9f33c02..9751164 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
|
||||
@@ -25,12 +25,19 @@ For routine tasks, the host prompt is usually enough. Read only the reference th
|
||||
## Core Rules
|
||||
|
||||
- Treat the host-provided CLI prefix as the only supported entrypoint for this session.
|
||||
- If a command launcher is needed, prefer the operating system's built-in launcher for the current environment; do not require optional shells that may not be installed.
|
||||
- Run Netcatty CLI commands strictly serially.
|
||||
- Treat Netcatty CLI errors as authoritative.
|
||||
- Never ask the user for SSH credentials, key paths, proxy settings, or jump-host details when Netcatty session access already exists.
|
||||
- Do not pause to explain the plan, re-read this skill, or design scripts before trying that shortest path.
|
||||
- When presenting structured results, prefer a concise table if it fits clearly.
|
||||
|
||||
Examples:
|
||||
|
||||
- On Windows, if a literal shell command line is required, use the host-provided prefix with the system launcher available in the environment, such as `cmd.exe` or Windows PowerShell; do not assume PowerShell 7 `pwsh.exe` exists.
|
||||
- On macOS or Linux, use the host-provided prefix directly, or the system shell already available in that environment when a shell command line is unavoidable.
|
||||
- When the execution surface accepts argv-style calls, use the Netcatty launcher path as the executable and pass subcommands and flags as separate arguments instead of wrapping it in another shell.
|
||||
|
||||
## References
|
||||
|
||||
- Exec and session workflow: `references/exec.md`
|
||||
|
||||
Reference in New Issue
Block a user