Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4574f1e2b2 | ||
|
|
081b167172 | ||
|
|
a818a7004f | ||
|
|
5bc5a6c8b2 | ||
|
|
6c8a39d269 | ||
|
|
db69d5ac39 | ||
|
|
ee400f424b | ||
|
|
ba93e2fa35 | ||
|
|
591b240d12 | ||
|
|
880812f48d | ||
|
|
445ce92dbc | ||
|
|
7f582bb355 | ||
|
|
59f9a1443b | ||
|
|
bcb56d8229 | ||
|
|
1ca2cd8ec2 | ||
|
|
717d8b718a | ||
|
|
363f03a92d | ||
|
|
c5d15a14c9 | ||
|
|
75dc3dd72b |
148
App.tsx
148
App.tsx
@@ -20,12 +20,21 @@ import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from './application/state/customThemeStore';
|
||||
import { applySyncPayload } from './application/syncPayload';
|
||||
import type { SyncPayload } from './domain/sync';
|
||||
import { applySyncPayload, buildSyncPayload, hasMeaningfulSyncData } from './application/syncPayload';
|
||||
import {
|
||||
applyProtectedSyncPayload,
|
||||
ensureVersionChangeBackup,
|
||||
} from './application/localVaultBackups';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { AlertTriangle, Download, Trash2 } from 'lucide-react';
|
||||
import { STORAGE_KEY_DEBUG_HOTKEYS } from './infrastructure/config/storageKeys';
|
||||
import {
|
||||
STORAGE_KEY_DEBUG_HOTKEYS,
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
} from './infrastructure/config/storageKeys';
|
||||
import { getEffectiveKnownHosts } from './infrastructure/syncHelpers';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -222,6 +231,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [workspaceFocusStyle]);
|
||||
|
||||
const {
|
||||
isInitialized: isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
@@ -395,6 +405,129 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
const buildCurrentSyncPayload = useCallback(() => {
|
||||
let effectivePortForwardingRules = portForwardingRulesForSync;
|
||||
if (effectivePortForwardingRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<typeof portForwardingRulesForSync>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
effectivePortForwardingRules = stored.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return buildSyncPayload(
|
||||
{
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts: getEffectiveKnownHosts(knownHosts),
|
||||
groupConfigs,
|
||||
},
|
||||
effectivePortForwardingRules,
|
||||
);
|
||||
}, [
|
||||
customGroups,
|
||||
groupConfigs,
|
||||
hosts,
|
||||
identities,
|
||||
keys,
|
||||
knownHosts,
|
||||
portForwardingRulesForSync,
|
||||
snippetPackages,
|
||||
snippets,
|
||||
]);
|
||||
|
||||
const [startupSyncSafetyReady, setStartupSyncSafetyReady] = useState(false);
|
||||
// buildCurrentSyncPayload's identity changes each time the vault
|
||||
// settles. The retry effect below watches the underlying data arrays
|
||||
// for hydration progress, and uses the ref to always read the latest
|
||||
// builder without pulling buildCurrentSyncPayload itself into deps
|
||||
// (its identity churns on unrelated state updates too).
|
||||
const buildCurrentSyncPayloadRef = useRef(buildCurrentSyncPayload);
|
||||
useEffect(() => {
|
||||
buildCurrentSyncPayloadRef.current = buildCurrentSyncPayload;
|
||||
}, [buildCurrentSyncPayload]);
|
||||
|
||||
const versionBackupAttemptedRef = useRef(false);
|
||||
// Two-stage gate: once the vault has initialized we open the auto-sync
|
||||
// gate immediately — the hook's own hasMeaningfulSyncData guard and
|
||||
// the cross-window restore barrier prevent an empty-but-not-yet-
|
||||
// hydrated snapshot from overwriting cloud data. The version-change
|
||||
// backup itself is best-effort and retries below as vault data arrives.
|
||||
useEffect(() => {
|
||||
if (isVaultInitialized && !startupSyncSafetyReady) {
|
||||
setStartupSyncSafetyReady(true);
|
||||
}
|
||||
}, [isVaultInitialized, startupSyncSafetyReady]);
|
||||
|
||||
// Retry the version-change backup as hosts/keys/snippets become
|
||||
// available. ensureVersionChangeBackup refuses to advance the stored
|
||||
// version stamp when the observed payload is empty, so running this
|
||||
// effect repeatedly is safe and eventually latches once the vault has
|
||||
// hydrated enough to be backed up (or the user genuinely stays empty,
|
||||
// in which case the effect continues to no-op).
|
||||
useEffect(() => {
|
||||
if (!isVaultInitialized || versionBackupAttemptedRef.current) return;
|
||||
const payload = buildCurrentSyncPayloadRef.current();
|
||||
if (!hasMeaningfulSyncData(payload)) return;
|
||||
versionBackupAttemptedRef.current = true;
|
||||
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const info = await netcattyBridge.get()?.getAppInfo?.();
|
||||
await ensureVersionChangeBackup(payload, info?.version ?? null);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
// Reset the latch so a later data change (or the next mount)
|
||||
// can retry. ensureVersionChangeBackup already leaves the
|
||||
// version stamp untouched on failure, so retrying is safe.
|
||||
versionBackupAttemptedRef.current = false;
|
||||
}
|
||||
console.error('[App] Failed to create version-change backup:', error);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVaultInitialized, hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts]);
|
||||
|
||||
// Memoized "apply a remote payload safely" callback. Stable identity
|
||||
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
|
||||
// on unrelated App-level state changes (which would churn the debounced
|
||||
// auto-sync useEffect dep chain).
|
||||
const handleApplySyncPayload = useCallback(
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: () => buildCurrentSyncPayload(),
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
}),
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t('cloudSync.localBackups.protectiveBackupFailed', { message }),
|
||||
}),
|
||||
[
|
||||
buildCurrentSyncPayload,
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
settings.rehydrateAllFromStorage,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// Auto-sync hook for cloud sync
|
||||
const { syncNow: handleSyncNow, emptyVaultConflict, resolveEmptyVaultConflict } = useAutoSync({
|
||||
hosts,
|
||||
@@ -407,13 +540,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
onApplyPayload: (payload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
});
|
||||
},
|
||||
startupReady: startupSyncSafetyReady,
|
||||
onApplyPayload: handleApplySyncPayload,
|
||||
});
|
||||
|
||||
const { clearAndRemoveSource, clearAndRemoveSources, unmanageSource } = useManagedSourceSync({
|
||||
@@ -559,7 +687,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
const terminalActions = ['copy', 'paste', 'pasteSelection', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
if (terminalActions.includes(binding.action)) {
|
||||
if (isTerminalElement) {
|
||||
return;
|
||||
|
||||
@@ -443,10 +443,15 @@ const en: Messages = {
|
||||
'sync.toast.completedMessage': 'Sync completed successfully',
|
||||
'sync.toast.errorTitle': 'Sync Error',
|
||||
'sync.autoSync.failedTitle': 'Sync failed',
|
||||
'sync.autoSync.inspectFailedTitle': 'Sync paused',
|
||||
'sync.autoSync.inspectFailedMessage': 'Could not reach the cloud to check for changes. Auto-sync will retry when data changes or the app is restarted.',
|
||||
'sync.autoSync.syncedTitle': 'Synced from cloud',
|
||||
'sync.autoSync.syncedMessage': 'Your data has been updated from the cloud.',
|
||||
'sync.autoSync.noProvider': 'No cloud provider connected. Open Settings → Sync & Cloud to connect one.',
|
||||
'sync.autoSync.alreadySyncing': 'Sync is already in progress.',
|
||||
'sync.autoSync.restoreInProgress': 'A vault restore is in progress in another window. Please wait for it to finish.',
|
||||
'sync.autoSync.interruptedApplyTitle': 'Sync paused — previous restore interrupted',
|
||||
'sync.autoSync.interruptedApplyMessage': 'A previous restore did not finish cleanly, so the local vault may be inconsistent. Open Settings → Sync & Cloud → Restore and apply a protective backup before auto-sync resumes.',
|
||||
'sync.autoSync.vaultLocked': 'Vault is locked. Open Settings → Sync & Cloud to unlock.',
|
||||
'sync.autoSync.conflictDetected': 'Sync conflict detected. Open Settings → Sync & Cloud to resolve.',
|
||||
'sync.autoSync.syncFailed': 'Sync failed',
|
||||
@@ -1212,6 +1217,7 @@ const en: Messages = {
|
||||
'terminal.search.nextMatch': 'Next match (Enter)',
|
||||
'terminal.menu.copy': 'Copy',
|
||||
'terminal.menu.paste': 'Paste',
|
||||
'terminal.menu.pasteSelection': 'Paste Selection',
|
||||
'terminal.menu.selectAll': 'Select All',
|
||||
'terminal.menu.splitHorizontal': 'Split Horizontal',
|
||||
'terminal.menu.splitVertical': 'Split Vertical',
|
||||
@@ -1390,6 +1396,31 @@ const en: Messages = {
|
||||
'cloudSync.history.download': 'Download',
|
||||
'cloudSync.history.resolved': 'Resolved',
|
||||
'cloudSync.history.error': 'Error',
|
||||
'cloudSync.localBackups.title': 'Local Backup History',
|
||||
'cloudSync.localBackups.desc': 'Netcatty keeps local restore points before app version changes and before vault restores.',
|
||||
'cloudSync.localBackups.retentionTitle': 'Backup Retention',
|
||||
'cloudSync.localBackups.retentionDesc': 'Choose how many local backups Netcatty should keep.',
|
||||
'cloudSync.localBackups.maxCount': 'Max backups',
|
||||
'cloudSync.localBackups.maxSaved': 'Saved backup retention: {count}',
|
||||
'cloudSync.localBackups.maxInvalid': 'Please enter a number between 1 and 100.',
|
||||
'cloudSync.localBackups.empty': 'No local backups yet.',
|
||||
'cloudSync.localBackups.reason.appVersionChange': 'Before app version change',
|
||||
'cloudSync.localBackups.reason.beforeRestore': 'Before restore',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'cloudSync.localBackups.restore': 'Restore',
|
||||
'cloudSync.localBackups.restoreSuccess': 'Local backup restored.',
|
||||
'cloudSync.localBackups.restoreFailedTitle': 'Restore failed',
|
||||
'cloudSync.localBackups.restoreMissing': 'Backup not found.',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': 'Safety backup could not be created, so the restore was aborted to protect your current data. Resolve the underlying issue (e.g. keychain access) and try again. Details: {message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': 'Restore this backup?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': 'Your current hosts, keys, snippets and settings will be replaced with the contents of this backup. A protective snapshot of your current data is taken automatically first.',
|
||||
'cloudSync.localBackups.restoreConfirmButton': 'Restore',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': 'Cancel',
|
||||
'cloudSync.localBackups.unavailableTitle': 'Local backups unavailable',
|
||||
'cloudSync.localBackups.unavailableDesc': 'This platform does not expose a secure keychain to Netcatty, so local backups cannot be written safely. Install Netcatty on a system with a supported keychain to enable the local backup history.',
|
||||
'cloudSync.localBackups.lockedTitle': 'Master key required',
|
||||
'cloudSync.localBackups.lockedDesc': 'Set up or unlock your master key before restoring a backup, so restored credentials remain encrypted.',
|
||||
'cloudSync.revisionHistory.viewButton': 'History',
|
||||
'cloudSync.revisionHistory.title': 'Vault Version History',
|
||||
'cloudSync.revisionHistory.description': 'Browse and restore previous versions of your vault from the Gist revision history.',
|
||||
|
||||
@@ -262,10 +262,15 @@ const zhCN: Messages = {
|
||||
'sync.toast.completedMessage': '同步完成',
|
||||
'sync.toast.errorTitle': '同步错误',
|
||||
'sync.autoSync.failedTitle': '同步失败',
|
||||
'sync.autoSync.inspectFailedTitle': '同步已暂停',
|
||||
'sync.autoSync.inspectFailedMessage': '无法访问云端以检查变更。数据改动或下次启动时会自动重试。',
|
||||
'sync.autoSync.syncedTitle': '已从云端同步',
|
||||
'sync.autoSync.syncedMessage': '你的数据已从云端更新。',
|
||||
'sync.autoSync.noProvider': '未连接云同步 provider。请打开 设置 → Sync & Cloud 进行连接。',
|
||||
'sync.autoSync.alreadySyncing': '同步正在进行中。',
|
||||
'sync.autoSync.restoreInProgress': '另一个窗口中的本地备份恢复正在进行中,请等待其完成。',
|
||||
'sync.autoSync.interruptedApplyTitle': '同步已暂停 — 上次恢复未完成',
|
||||
'sync.autoSync.interruptedApplyMessage': '上次本地恢复过程未正常结束,本地数据可能处于半应用状态。请打开「设置 → Sync & Cloud → 恢复」,从保护性备份中恢复后再让自动同步继续。',
|
||||
'sync.autoSync.vaultLocked': 'Vault 处于锁定状态。请打开 设置 → Sync & Cloud 解锁。',
|
||||
'sync.autoSync.conflictDetected': '检测到同步冲突。请打开 设置 → Sync & Cloud 处理。',
|
||||
'sync.autoSync.syncFailed': '同步失败',
|
||||
@@ -825,6 +830,7 @@ const zhCN: Messages = {
|
||||
'terminal.search.nextMatch': '下一个匹配 (Enter)',
|
||||
'terminal.menu.copy': '复制',
|
||||
'terminal.menu.paste': '粘贴',
|
||||
'terminal.menu.pasteSelection': '粘贴选中文本',
|
||||
'terminal.menu.selectAll': '全选',
|
||||
'terminal.menu.splitHorizontal': '水平分屏',
|
||||
'terminal.menu.splitVertical': '垂直分屏',
|
||||
@@ -1003,6 +1009,31 @@ const zhCN: Messages = {
|
||||
'cloudSync.history.download': '下载',
|
||||
'cloudSync.history.resolved': '已解决',
|
||||
'cloudSync.history.error': '错误',
|
||||
'cloudSync.localBackups.title': '本地备份历史',
|
||||
'cloudSync.localBackups.desc': 'Netcatty 会在版本变化前,以及恢复主机库前,自动留下一份本地恢复点。',
|
||||
'cloudSync.localBackups.retentionTitle': '备份保留数量',
|
||||
'cloudSync.localBackups.retentionDesc': '设置 Netcatty 最多保留多少份本地备份。',
|
||||
'cloudSync.localBackups.maxCount': '最多保留',
|
||||
'cloudSync.localBackups.maxSaved': '已保存保留数量:{count}',
|
||||
'cloudSync.localBackups.maxInvalid': '请输入 1 到 100 之间的数字。',
|
||||
'cloudSync.localBackups.empty': '还没有本地备份。',
|
||||
'cloudSync.localBackups.reason.appVersionChange': '版本变化前',
|
||||
'cloudSync.localBackups.reason.beforeRestore': '恢复前',
|
||||
'cloudSync.localBackups.versionChange': '{from} -> {to}',
|
||||
'cloudSync.localBackups.counts': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'cloudSync.localBackups.restore': '恢复',
|
||||
'cloudSync.localBackups.restoreSuccess': '已恢复本地备份。',
|
||||
'cloudSync.localBackups.restoreFailedTitle': '恢复失败',
|
||||
'cloudSync.localBackups.restoreMissing': '找不到这份备份。',
|
||||
'cloudSync.localBackups.protectiveBackupFailed': '无法创建保护性备份,已中止恢复以避免覆盖当前数据。请先解决底层问题(例如钥匙串访问)后重试。详情:{message}',
|
||||
'cloudSync.localBackups.restoreConfirmTitle': '确认恢复此备份?',
|
||||
'cloudSync.localBackups.restoreConfirmDesc': '当前的主机、密钥、代码片段与设置将被替换为此备份中的内容。系统会先自动创建一个保护性快照,便于撤销。',
|
||||
'cloudSync.localBackups.restoreConfirmButton': '恢复',
|
||||
'cloudSync.localBackups.restoreConfirmCancel': '取消',
|
||||
'cloudSync.localBackups.unavailableTitle': '无法使用本地备份',
|
||||
'cloudSync.localBackups.unavailableDesc': '当前平台未提供受支持的安全密钥库,Netcatty 无法安全地写入本地备份。请在支持系统钥匙串的环境中运行,或改用云同步保留恢复点。',
|
||||
'cloudSync.localBackups.lockedTitle': '需要主密钥',
|
||||
'cloudSync.localBackups.lockedDesc': '请先配置或解锁主密钥再恢复备份,以确保恢复后的凭据仍保持加密。',
|
||||
'cloudSync.revisionHistory.viewButton': '历史版本',
|
||||
'cloudSync.revisionHistory.title': '主机库版本历史',
|
||||
'cloudSync.revisionHistory.description': '浏览并恢复 Gist 修订历史中的旧版主机库数据。',
|
||||
|
||||
467
application/localVaultBackups.ts
Normal file
467
application/localVaultBackups.ts
Normal file
@@ -0,0 +1,467 @@
|
||||
import type { SyncPayload } from '../domain/sync';
|
||||
import {
|
||||
STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION,
|
||||
STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
|
||||
import { hasMeaningfulSyncData } from './syncPayload';
|
||||
|
||||
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
|
||||
|
||||
export interface LocalVaultBackupPreview {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: LocalVaultBackupReason;
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LocalVaultBackupDetails {
|
||||
backup: LocalVaultBackupPreview;
|
||||
payload: SyncPayload;
|
||||
}
|
||||
|
||||
export const DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT = 20;
|
||||
export const MIN_LOCAL_VAULT_BACKUP_MAX_COUNT = 1;
|
||||
export const MAX_LOCAL_VAULT_BACKUP_MAX_COUNT = 100;
|
||||
|
||||
export const sanitizeLocalVaultBackupMaxCount = (value: number): number => {
|
||||
if (!Number.isFinite(value)) return DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT;
|
||||
return Math.max(
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
Math.min(MAX_LOCAL_VAULT_BACKUP_MAX_COUNT, Math.round(value)),
|
||||
);
|
||||
};
|
||||
|
||||
export const getLocalVaultBackupMaxCount = (): number => {
|
||||
const stored = localStorageAdapter.readNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT);
|
||||
return sanitizeLocalVaultBackupMaxCount(
|
||||
stored ?? DEFAULT_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
);
|
||||
};
|
||||
|
||||
export const setLocalVaultBackupMaxCount = (value: number): number => {
|
||||
const sanitized = sanitizeLocalVaultBackupMaxCount(value);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT, sanitized);
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
export async function trimLocalVaultBackups(maxCount = getLocalVaultBackupMaxCount()): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.trimVaultBackups?.({ maxCount });
|
||||
}
|
||||
|
||||
export async function getLocalVaultBackupCapabilities(): Promise<{
|
||||
encryptionAvailable: boolean;
|
||||
}> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const caps = await bridge?.getVaultBackupCapabilities?.();
|
||||
// Conservatively treat a missing bridge (non-Electron environments, early
|
||||
// boot) as unavailable so callers fall back to the locked-down UI path
|
||||
// instead of assuming capabilities they can't verify.
|
||||
return { encryptionAvailable: Boolean(caps?.encryptionAvailable) };
|
||||
}
|
||||
|
||||
export async function listLocalVaultBackups(): Promise<LocalVaultBackupPreview[]> {
|
||||
const bridge = netcattyBridge.get();
|
||||
const entries = await bridge?.listVaultBackups?.();
|
||||
return Array.isArray(entries) ? entries : [];
|
||||
}
|
||||
|
||||
export async function readLocalVaultBackup(id: string): Promise<LocalVaultBackupDetails | null> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readVaultBackup) return null;
|
||||
return bridge.readVaultBackup({ id });
|
||||
}
|
||||
|
||||
export async function openLocalVaultBackupDir(): Promise<void> {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.openVaultBackupDir?.();
|
||||
}
|
||||
|
||||
export async function createLocalVaultBackup(
|
||||
payload: SyncPayload,
|
||||
options: {
|
||||
reason: LocalVaultBackupReason;
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
maxCount?: number;
|
||||
},
|
||||
): Promise<LocalVaultBackupPreview | null> {
|
||||
// Intentional: an empty-vault backup has nothing to restore from, so we
|
||||
// early-return instead of writing a zero-entry record. Callers that rely
|
||||
// on a backup (protective-before-restore, version-change on first run)
|
||||
// must treat `null` as "no safety net this time" and continue — blocking
|
||||
// the user's flow on a missing backup would be worse than allowing the
|
||||
// apply to proceed without one.
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.createVaultBackup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridge.createVaultBackup({
|
||||
payload,
|
||||
reason: options.reason,
|
||||
sourceAppVersion: options.sourceAppVersion,
|
||||
targetAppVersion: options.targetAppVersion,
|
||||
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),
|
||||
});
|
||||
return result?.backup ?? null;
|
||||
} catch (error) {
|
||||
// The main-process bridge refuses to write backups when safeStorage is
|
||||
// unavailable (VAULT_BACKUP_ENCRYPTION_UNAVAILABLE) because SyncPayload
|
||||
// carries plaintext credentials that must never touch disk unencrypted.
|
||||
// Callers (startup version-change, protective-before-restore) intentionally
|
||||
// continue without a backup rather than blocking the user's flow, so we
|
||||
// log and return null here.
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
console.warn('[localVaultBackups] Backup skipped:', message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when a caller requires a protective backup and the backup
|
||||
* couldn't be written — safeStorage unavailable, bridge missing,
|
||||
* main-process rejection, disk error.
|
||||
*
|
||||
* Callers should surface this as a user-visible abort rather than
|
||||
* proceeding with the destructive apply. Separate from "nothing to
|
||||
* back up" (empty vault) which is returned as `null`.
|
||||
*/
|
||||
export class ProtectiveBackupUnavailableError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'ProtectiveBackupUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a protective local backup before a destructive apply (restore
|
||||
* from backup list, restore from Gist revision, cloud download applied
|
||||
* over meaningful local state).
|
||||
*
|
||||
* Returns `null` when there is nothing meaningful to back up — in that
|
||||
* case the caller can safely proceed with the apply, because there is
|
||||
* no local data to lose.
|
||||
*
|
||||
* Throws `ProtectiveBackupUnavailableError` when pre-apply state IS
|
||||
* meaningful but the backup attempt failed. Callers MUST abort the
|
||||
* destructive apply in that case and surface the error to the user,
|
||||
* otherwise we regress the exact safety contract the backup system
|
||||
* was added to enforce (the `console.error`-and-proceed pattern that
|
||||
* previously swallowed safeStorage/keychain failures and continued).
|
||||
*/
|
||||
export async function createRequiredProtectiveLocalVaultBackup(
|
||||
payload: SyncPayload,
|
||||
): Promise<LocalVaultBackupPreview | null> {
|
||||
if (!hasMeaningfulSyncData(payload)) {
|
||||
// Nothing to protect — an empty-vault backup would produce a
|
||||
// useless record, not a safety net.
|
||||
return null;
|
||||
}
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.createVaultBackup) {
|
||||
throw new ProtectiveBackupUnavailableError(
|
||||
'Vault backup bridge is not available in this environment.',
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await bridge.createVaultBackup({
|
||||
payload,
|
||||
reason: 'before_restore',
|
||||
maxCount: getLocalVaultBackupMaxCount(),
|
||||
});
|
||||
return result?.backup ?? null;
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
throw new ProtectiveBackupUnavailableError(message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* How long each heartbeat extends the cross-window restore barrier.
|
||||
* Short enough that an abandoned lock (crashed window, hung task)
|
||||
* clears itself quickly without user intervention. The heartbeat
|
||||
* interval below refreshes the deadline as long as the caller's task
|
||||
* is still running, so large vaults or slow keychain unlocks cannot
|
||||
* expose a mid-apply window to concurrent auto-sync even when the
|
||||
* total apply time exceeds this value.
|
||||
*/
|
||||
const RESTORE_BARRIER_HOLD_MS = 60_000;
|
||||
|
||||
/**
|
||||
* How often the heartbeat refreshes the barrier. Picked to ensure at
|
||||
* least two refreshes land before the current deadline would expire,
|
||||
* so a single missed tick (event-loop stall, GC pause) cannot drop
|
||||
* the barrier prematurely.
|
||||
*/
|
||||
const RESTORE_BARRIER_HEARTBEAT_MS = Math.max(1_000, Math.floor(RESTORE_BARRIER_HOLD_MS / 3));
|
||||
|
||||
/**
|
||||
* Run `task` while holding a cross-window "restore in progress" barrier.
|
||||
*
|
||||
* The barrier is a localStorage key readable by every window of the same
|
||||
* origin. useAutoSync reads it on each auto-sync and on each data-change
|
||||
* debounce tick, refusing to push while the deadline is still in the
|
||||
* future. We write a time-bounded deadline (rather than a boolean) so a
|
||||
* crashed window can never leave sync permanently wedged.
|
||||
*
|
||||
* While the task runs, a heartbeat timer re-writes the deadline so a
|
||||
* slow apply (large vault, slow keychain) keeps the barrier held rather
|
||||
* than exposing a post-deadline window to concurrent auto-sync. The
|
||||
* heartbeat is cleared and the barrier is released in a finally block
|
||||
* so success, throw, and unexpected early-return all converge on the
|
||||
* same cleanup.
|
||||
*/
|
||||
export async function withRestoreBarrier<T>(
|
||||
task: () => Promise<T>,
|
||||
holdMs: number = RESTORE_BARRIER_HOLD_MS,
|
||||
): Promise<T> {
|
||||
const writeDeadline = () => {
|
||||
try {
|
||||
localStorageAdapter.writeNumber(
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
Date.now() + holdMs,
|
||||
);
|
||||
} catch (error) {
|
||||
// If we can't write the barrier we still proceed — the UI-side
|
||||
// `isSyncBusy` guard and same-window debounce cancellation are a
|
||||
// secondary defense. Better to complete the restore than refuse on
|
||||
// a broken localStorage.
|
||||
console.warn('[localVaultBackups] Failed to set restore barrier:', error);
|
||||
}
|
||||
};
|
||||
|
||||
writeDeadline();
|
||||
const heartbeat = setInterval(
|
||||
writeDeadline,
|
||||
Math.max(1_000, Math.min(holdMs / 3, RESTORE_BARRIER_HEARTBEAT_MS)),
|
||||
);
|
||||
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
try {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL, 0);
|
||||
} catch {
|
||||
/* ignore — the deadline will expire naturally */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shape of the apply-in-progress sentinel record. Persisted as JSON in
|
||||
* `STORAGE_KEY_VAULT_APPLY_IN_PROGRESS` so the next session can
|
||||
* distinguish "the last apply completed cleanly" from "the last apply
|
||||
* crashed mid-way and the local vault is a partial mix of states."
|
||||
*/
|
||||
export interface VaultApplyInProgressRecord {
|
||||
startedAt: number;
|
||||
protectiveBackupId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the persisted apply-in-progress record if a previous apply
|
||||
* was interrupted before clearing it. Callers (notably auto-sync) use
|
||||
* this to refuse to push a partial-apply local state over an intact
|
||||
* cloud copy. See `applyProtectedSyncPayload` for the write side.
|
||||
*
|
||||
* `null` here means "no interrupted apply detected" — either nothing
|
||||
* was ever applied, or the last apply finished cleanly.
|
||||
*/
|
||||
export function readInterruptedVaultApply(): VaultApplyInProgressRecord | null {
|
||||
try {
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== 'object') return null;
|
||||
const startedAt = typeof parsed.startedAt === 'number' ? parsed.startedAt : 0;
|
||||
const protectiveBackupId =
|
||||
typeof parsed.protectiveBackupId === 'string' ? parsed.protectiveBackupId : null;
|
||||
if (!startedAt) return null;
|
||||
return { startedAt, protectiveBackupId };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the apply-in-progress sentinel. The normal completion path
|
||||
* inside `applyProtectedSyncPayload` clears it automatically; this
|
||||
* export exists so the user's explicit recovery action ("I've restored
|
||||
* from a backup, resume sync") can acknowledge the interrupted state
|
||||
* from the UI without re-running an apply.
|
||||
*/
|
||||
export function clearInterruptedVaultApply(): void {
|
||||
try {
|
||||
localStorageAdapter.remove(STORAGE_KEY_VAULT_APPLY_IN_PROGRESS);
|
||||
} catch {
|
||||
/* ignore — next clean apply will overwrite */
|
||||
}
|
||||
}
|
||||
|
||||
function writeApplyInProgressSentinel(record: VaultApplyInProgressRecord): void {
|
||||
try {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_VAULT_APPLY_IN_PROGRESS,
|
||||
JSON.stringify(record),
|
||||
);
|
||||
} catch (error) {
|
||||
// Sentinel write is best-effort: a failure here means a later crash
|
||||
// won't be detected, but does NOT compromise the apply itself.
|
||||
// Log so a systematic storage outage is diagnosable.
|
||||
console.warn('[localVaultBackups] Failed to set apply-in-progress sentinel:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "apply a remote-sourced payload safely" helper.
|
||||
*
|
||||
* Holds the cross-window restore barrier, snapshots the pre-apply vault
|
||||
* into a protective backup, persists an apply-in-progress sentinel, and
|
||||
* only then runs the supplied `applyPayload` callback. Every destructive
|
||||
* apply path (startup merge, conflict resolution, empty-vault restore,
|
||||
* manual Gist-revision restore) must go through this so the protections
|
||||
* can't drift out of sync between the main window and the settings
|
||||
* window.
|
||||
*
|
||||
* The sentinel closes the partial-apply-then-crash window: `applyPayload`
|
||||
* writes to several localStorage keys non-atomically (hosts, keys, port-
|
||||
* forwarding rules, settings). A crash mid-sequence leaves the vault in
|
||||
* a state that is neither pre-apply nor post-apply, and the next
|
||||
* auto-sync would otherwise push that partial state over an intact cloud
|
||||
* copy. The sentinel flags "local may be inconsistent" for the next
|
||||
* session; `readInterruptedVaultApply` exposes that to callers that
|
||||
* enforce "don't auto-push a half-applied vault."
|
||||
*
|
||||
* `buildPreApplyPayload` is invoked *before* the apply to snapshot the
|
||||
* current vault. Callers pass their own React-closure builder (hosts,
|
||||
* keys, port-forwarding rules) because the caller owns that state.
|
||||
*
|
||||
* `translateProtectiveBackupFailure` converts the
|
||||
* `ProtectiveBackupUnavailableError` into a user-visible message in the
|
||||
* caller's locale. It runs only on the thrown-and-caught path.
|
||||
*/
|
||||
export function applyProtectedSyncPayload(options: {
|
||||
buildPreApplyPayload: () => SyncPayload;
|
||||
applyPayload: () => void | Promise<void>;
|
||||
translateProtectiveBackupFailure: (message: string) => string;
|
||||
}): Promise<void> {
|
||||
const { buildPreApplyPayload, applyPayload, translateProtectiveBackupFailure } = options;
|
||||
return withRestoreBarrier(async () => {
|
||||
const pre = buildPreApplyPayload();
|
||||
let protectiveBackupId: string | null = null;
|
||||
try {
|
||||
const backup = await createRequiredProtectiveLocalVaultBackup(pre);
|
||||
protectiveBackupId = backup?.id ?? null;
|
||||
} catch (error) {
|
||||
// Destructive apply without a working safety net is exactly the
|
||||
// overwrite-without-recovery regression this module was added to
|
||||
// prevent. Surface the failure to the caller; every call site
|
||||
// currently aborts the apply and shows a user-visible error.
|
||||
if (error instanceof ProtectiveBackupUnavailableError) {
|
||||
throw new Error(translateProtectiveBackupFailure(error.message));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Mark the apply as in-progress. If the renderer crashes between
|
||||
// the first localStorage write inside `applyPayload` and the
|
||||
// successful completion below, the next session will observe this
|
||||
// sentinel and refuse to auto-sync the partial state.
|
||||
writeApplyInProgressSentinel({
|
||||
startedAt: Date.now(),
|
||||
protectiveBackupId,
|
||||
});
|
||||
|
||||
// Only clear the sentinel on successful completion. A throw from
|
||||
// `applyPayload` deliberately leaves the sentinel set: the partial
|
||||
// write is still on disk, and the next session must observe the
|
||||
// flag so auto-sync refuses to push the half-applied state.
|
||||
await applyPayload();
|
||||
clearInterruptedVaultApply();
|
||||
});
|
||||
}
|
||||
|
||||
export async function ensureVersionChangeBackup(
|
||||
payload: SyncPayload,
|
||||
currentAppVersion: string | null | undefined,
|
||||
): Promise<{ created: boolean; backup: LocalVaultBackupPreview | null }> {
|
||||
const normalizedVersion = currentAppVersion?.trim() || '';
|
||||
if (!normalizedVersion) {
|
||||
return { created: false, backup: null };
|
||||
}
|
||||
|
||||
const previousVersion =
|
||||
localStorageAdapter.readString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION)?.trim() || '';
|
||||
|
||||
if (!previousVersion) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
|
||||
return { created: false, backup: null };
|
||||
}
|
||||
|
||||
if (previousVersion === normalizedVersion) {
|
||||
return { created: false, backup: null };
|
||||
}
|
||||
|
||||
let backup: LocalVaultBackupPreview | null = null;
|
||||
const payloadIsMeaningful = hasMeaningfulSyncData(payload);
|
||||
if (payloadIsMeaningful) {
|
||||
backup = await createLocalVaultBackup(payload, {
|
||||
reason: 'app_version_change',
|
||||
sourceAppVersion: previousVersion,
|
||||
targetAppVersion: normalizedVersion,
|
||||
});
|
||||
}
|
||||
|
||||
// Only advance the stored version stamp when we actually wrote a
|
||||
// backup. Two failure modes we must NOT collapse into "advance":
|
||||
//
|
||||
// 1. Meaningful payload + backup failed (transient keychain lock,
|
||||
// disk error) — leaving the stamp unchanged means the next
|
||||
// launch retries, instead of turning a transient error into a
|
||||
// permanent "the version-change backup never happened" hole.
|
||||
//
|
||||
// 2. Non-meaningful payload at the moment we checked — on startup
|
||||
// the async vault rehydrate may not have finished yet, so
|
||||
// `hasMeaningfulSyncData` can return false transiently even
|
||||
// though the user has real data. Advancing in that window would
|
||||
// burn the one-shot upgrade opportunity; holding keeps the
|
||||
// retry available on the next launch when rehydrate has
|
||||
// completed (or when the user genuinely starts from empty and
|
||||
// the next migration-boundary arrives).
|
||||
//
|
||||
// Trade-off: a user who truly starts empty and never adds data will
|
||||
// hit this branch on every launch until they do. That's cheap (a
|
||||
// single meaningful-data check) and strictly safer than silently
|
||||
// skipping the first real upgrade backup.
|
||||
const shouldAdvanceVersion = payloadIsMeaningful && backup !== null;
|
||||
if (shouldAdvanceVersion) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION, normalizedVersion);
|
||||
}
|
||||
|
||||
return {
|
||||
created: Boolean(backup),
|
||||
backup,
|
||||
};
|
||||
}
|
||||
307
application/state/aiDraftState.test.ts
Normal file
307
application/state/aiDraftState.test.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
createEmptyDraft,
|
||||
ensureDraftForScopeState,
|
||||
getDraftMutationVersionState,
|
||||
getDraftUploadGenerationState,
|
||||
pruneTerminalScopeState,
|
||||
pruneTerminalTransientState,
|
||||
resolvePanelView,
|
||||
setDraftView,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from "./aiDraftState.ts";
|
||||
|
||||
test("createEmptyDraft seeds selected agent and empty inputs", () => {
|
||||
const draft = createEmptyDraft("agent-alpha");
|
||||
|
||||
assert.equal(draft.agentId, "agent-alpha");
|
||||
assert.equal(draft.text, "");
|
||||
assert.deepEqual(draft.attachments, []);
|
||||
assert.deepEqual(draft.selectedUserSkillSlugs, []);
|
||||
assert.equal(typeof draft.updatedAt, "number");
|
||||
});
|
||||
|
||||
test("resolvePanelView defaults to draft when no explicit view exists", () => {
|
||||
assert.deepEqual(resolvePanelView({}, "terminal:123"), { mode: "draft" });
|
||||
});
|
||||
|
||||
test("setDraftView records draft mode", () => {
|
||||
assert.deepEqual(setDraftView({}, "terminal:123"), {
|
||||
"terminal:123": { mode: "draft" },
|
||||
});
|
||||
});
|
||||
|
||||
test("activateDraftView clears the terminal scope's active session owner", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:123": "session-123",
|
||||
"workspace:abc": "session-workspace",
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:123": { mode: "session", sessionId: "session-123" },
|
||||
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = activateDraftView(
|
||||
activeSessionIdMap,
|
||||
panelViewByScope,
|
||||
"terminal:123",
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"workspace:abc": "session-workspace",
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:123": { mode: "draft" },
|
||||
"workspace:abc": panelViewByScope["workspace:abc"],
|
||||
});
|
||||
});
|
||||
|
||||
test("activateDraftView is a no-op when the scope already has explicit draft view", () => {
|
||||
const activeSessionIdMap = {
|
||||
"workspace:abc": "session-workspace",
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:123": { mode: "draft" },
|
||||
"workspace:abc": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = activateDraftView(
|
||||
activeSessionIdMap,
|
||||
panelViewByScope,
|
||||
"terminal:123",
|
||||
);
|
||||
|
||||
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
|
||||
test("setSessionView records target session id", () => {
|
||||
assert.deepEqual(setSessionView({}, "workspace:abc", "session-123"), {
|
||||
"workspace:abc": { mode: "session", sessionId: "session-123" },
|
||||
});
|
||||
});
|
||||
|
||||
test("clearScopeDraftState removes both the draft and current panel view", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:1": { mode: "session", sessionId: "session-123" },
|
||||
"workspace:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:1");
|
||||
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"workspace:2": draftsByScope["workspace:2"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"workspace:2": panelViewByScope["workspace:2"],
|
||||
});
|
||||
});
|
||||
|
||||
test("clearScopeDraftState is a no-op when the scope is already cleared", () => {
|
||||
const draftsByScope = {
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"workspace:2": { mode: "draft" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = clearScopeDraftState(draftsByScope, panelViewByScope, "terminal:closed");
|
||||
|
||||
assert.equal(next.draftsByScope, draftsByScope);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
|
||||
test("updateDraftForScope creates a draft on first write and keeps other scopes untouched", () => {
|
||||
const draftsByScope = {
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
|
||||
const next = updateDraftForScope(
|
||||
draftsByScope,
|
||||
"terminal:1",
|
||||
"agent-alpha",
|
||||
(draft) => ({
|
||||
...draft,
|
||||
text: "hello world",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(next["terminal:1"].agentId, "agent-alpha");
|
||||
assert.equal(next["terminal:1"].text, "hello world");
|
||||
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
|
||||
});
|
||||
|
||||
test("ensureDraftForScopeState adds the missing scope without dropping siblings", () => {
|
||||
const draftsByScope = {
|
||||
"workspace:2": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
|
||||
const next = ensureDraftForScopeState(
|
||||
draftsByScope,
|
||||
"terminal:1",
|
||||
"agent-alpha",
|
||||
);
|
||||
|
||||
assert.equal(next["terminal:1"].agentId, "agent-alpha");
|
||||
assert.equal(next["terminal:1"].text, "");
|
||||
assert.equal(next["workspace:2"], draftsByScope["workspace:2"]);
|
||||
});
|
||||
|
||||
test("ensureDraftForScopeState returns the original ref when the scope already exists", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:1": createEmptyDraft("agent-alpha"),
|
||||
};
|
||||
|
||||
const next = ensureDraftForScopeState(
|
||||
draftsByScope,
|
||||
"terminal:1",
|
||||
"agent-beta",
|
||||
);
|
||||
|
||||
assert.equal(next, draftsByScope);
|
||||
});
|
||||
|
||||
test("draft mutation version increments on every mutation for the same scope", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialVersion = getDraftMutationVersionState({}, scopeKey);
|
||||
const nextVersions = bumpDraftMutationVersionState({}, scopeKey);
|
||||
const finalVersions = bumpDraftMutationVersionState(nextVersions, scopeKey);
|
||||
|
||||
assert.equal(initialVersion, 0);
|
||||
assert.equal(getDraftMutationVersionState(nextVersions, scopeKey), 1);
|
||||
assert.equal(getDraftMutationVersionState(finalVersions, scopeKey), 2);
|
||||
});
|
||||
|
||||
test("draft upload generation only increments when the draft lifecycle rolls over", () => {
|
||||
const scopeKey = "terminal:1";
|
||||
const initialGeneration = getDraftUploadGenerationState({}, scopeKey);
|
||||
const nextGenerations = bumpDraftUploadGenerationState({}, scopeKey);
|
||||
const finalGenerations = bumpDraftUploadGenerationState(nextGenerations, scopeKey);
|
||||
|
||||
assert.equal(initialGeneration, 0);
|
||||
assert.equal(getDraftUploadGenerationState(nextGenerations, scopeKey), 1);
|
||||
assert.equal(getDraftUploadGenerationState(finalGenerations, scopeKey), 2);
|
||||
});
|
||||
|
||||
test("pruneTerminalScopeState removes closed terminal drafts and views only", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:closed": createEmptyDraft("agent-alpha"),
|
||||
"terminal:open": createEmptyDraft("agent-beta"),
|
||||
"workspace:keep": createEmptyDraft("agent-gamma"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:closed": { mode: "draft" },
|
||||
"terminal:open": { mode: "session", sessionId: "session-open" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalScopeState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open": draftsByScope["terminal:open"],
|
||||
"workspace:keep": draftsByScope["workspace:keep"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open": panelViewByScope["terminal:open"],
|
||||
"workspace:keep": panelViewByScope["workspace:keep"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneTerminalScopeState returns original refs when nothing is pruned", () => {
|
||||
const draftsByScope = {
|
||||
"terminal:open": createEmptyDraft("agent-alpha"),
|
||||
"workspace:keep": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open": { mode: "draft" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-1" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalScopeState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.equal(next.draftsByScope, draftsByScope);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
|
||||
test("pruneTerminalTransientState clears closed terminal active session, draft, and view state only", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:closed": "session-closed",
|
||||
"terminal:open": "session-open",
|
||||
"workspace:keep": "session-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:closed": createEmptyDraft("agent-alpha"),
|
||||
"terminal:open": createEmptyDraft("agent-beta"),
|
||||
"workspace:keep": createEmptyDraft("agent-gamma"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:closed": { mode: "draft" },
|
||||
"terminal:open": { mode: "session", sessionId: "session-open" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open": "session-open",
|
||||
"workspace:keep": "session-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open": draftsByScope["terminal:open"],
|
||||
"workspace:keep": draftsByScope["workspace:keep"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open": panelViewByScope["terminal:open"],
|
||||
"workspace:keep": panelViewByScope["workspace:keep"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneTerminalTransientState returns original refs when no terminal scopes close", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open": "session-open",
|
||||
"workspace:keep": "session-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open": createEmptyDraft("agent-alpha"),
|
||||
"workspace:keep": createEmptyDraft("agent-beta"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open": { mode: "draft" },
|
||||
"workspace:keep": { mode: "session", sessionId: "session-workspace" },
|
||||
} satisfies Record<string, { mode: "draft" } | { mode: "session"; sessionId: string }>;
|
||||
|
||||
const next = pruneTerminalTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open"]),
|
||||
);
|
||||
|
||||
assert.equal(next.activeSessionIdMap, activeSessionIdMap);
|
||||
assert.equal(next.draftsByScope, draftsByScope);
|
||||
assert.equal(next.panelViewByScope, panelViewByScope);
|
||||
});
|
||||
257
application/state/aiDraftState.ts
Normal file
257
application/state/aiDraftState.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
} from '../../infrastructure/ai/types';
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
type DraftMutationVersionByScope = Record<string, number>;
|
||||
type DraftUploadGenerationByScope = Record<string, number>;
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: 'draft' };
|
||||
|
||||
export function createEmptyDraft(agentId: string): AIDraft {
|
||||
return {
|
||||
text: '',
|
||||
agentId,
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export function getDraftMutationVersionState(
|
||||
versionsByScope: DraftMutationVersionByScope,
|
||||
scopeKey: string,
|
||||
): number {
|
||||
return versionsByScope[scopeKey] ?? 0;
|
||||
}
|
||||
|
||||
export function bumpDraftMutationVersionState(
|
||||
versionsByScope: DraftMutationVersionByScope,
|
||||
scopeKey: string,
|
||||
): DraftMutationVersionByScope {
|
||||
return {
|
||||
...versionsByScope,
|
||||
[scopeKey]: getDraftMutationVersionState(versionsByScope, scopeKey) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function getDraftUploadGenerationState(
|
||||
generationsByScope: DraftUploadGenerationByScope,
|
||||
scopeKey: string,
|
||||
): number {
|
||||
return generationsByScope[scopeKey] ?? 0;
|
||||
}
|
||||
|
||||
export function bumpDraftUploadGenerationState(
|
||||
generationsByScope: DraftUploadGenerationByScope,
|
||||
scopeKey: string,
|
||||
): DraftUploadGenerationByScope {
|
||||
return {
|
||||
...generationsByScope,
|
||||
[scopeKey]: getDraftUploadGenerationState(generationsByScope, scopeKey) + 1,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePanelView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): AIPanelView {
|
||||
return panelViewByScope[scopeKey] ?? DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function setDraftView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): PanelViewByScope {
|
||||
const currentPanelView = panelViewByScope[scopeKey];
|
||||
if (currentPanelView?.mode === 'draft') {
|
||||
return panelViewByScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...panelViewByScope,
|
||||
[scopeKey]: DEFAULT_PANEL_VIEW,
|
||||
};
|
||||
}
|
||||
|
||||
export function activateDraftView(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const nextPanelViewByScope = setDraftView(panelViewByScope, scopeKey);
|
||||
const hasActiveSession = activeSessionIdMap[scopeKey] != null;
|
||||
|
||||
if (!hasActiveSession) {
|
||||
return {
|
||||
activeSessionIdMap,
|
||||
panelViewByScope: nextPanelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
delete nextActiveSessionIdMap[scopeKey];
|
||||
|
||||
return {
|
||||
activeSessionIdMap: nextActiveSessionIdMap,
|
||||
panelViewByScope: nextPanelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function setSessionView(
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
sessionId: string,
|
||||
): PanelViewByScope {
|
||||
return {
|
||||
...panelViewByScope,
|
||||
[scopeKey]: { mode: 'session', sessionId },
|
||||
};
|
||||
}
|
||||
|
||||
export function updateDraftForScope(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): DraftsByScope {
|
||||
const currentDraft = draftsByScope[scopeKey] ?? createEmptyDraft(fallbackAgentId);
|
||||
const nextDraft = updater(currentDraft);
|
||||
|
||||
return {
|
||||
...draftsByScope,
|
||||
[scopeKey]: nextDraft,
|
||||
};
|
||||
}
|
||||
|
||||
export function ensureDraftForScopeState(
|
||||
draftsByScope: DraftsByScope,
|
||||
scopeKey: string,
|
||||
agentId: string,
|
||||
): DraftsByScope {
|
||||
if (draftsByScope[scopeKey]) {
|
||||
return draftsByScope;
|
||||
}
|
||||
|
||||
return {
|
||||
...draftsByScope,
|
||||
[scopeKey]: createEmptyDraft(agentId),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearScopeDraftState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
scopeKey: string,
|
||||
): {
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const hasDraft = Object.prototype.hasOwnProperty.call(draftsByScope, scopeKey);
|
||||
const hasPanelView = Object.prototype.hasOwnProperty.call(panelViewByScope, scopeKey);
|
||||
|
||||
if (!hasDraft && !hasPanelView) {
|
||||
return {
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
draftsByScope: hasDraft
|
||||
? (() => {
|
||||
const nextDrafts = { ...draftsByScope };
|
||||
delete nextDrafts[scopeKey];
|
||||
return nextDrafts;
|
||||
})()
|
||||
: draftsByScope,
|
||||
panelViewByScope: hasPanelView
|
||||
? (() => {
|
||||
const nextPanelViews = { ...panelViewByScope };
|
||||
delete nextPanelViews[scopeKey];
|
||||
return nextPanelViews;
|
||||
})()
|
||||
: panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
function isClosedTerminalScope(scopeKey: string, activeTerminalTargetIds: Set<string>) {
|
||||
if (!scopeKey.startsWith('terminal:')) return false;
|
||||
|
||||
const targetId = scopeKey.slice('terminal:'.length);
|
||||
if (!targetId) return false;
|
||||
|
||||
return !activeTerminalTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function pruneTerminalScopeState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTerminalTargetIds: Set<string>,
|
||||
): {
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const nextDraftsByScope = { ...draftsByScope };
|
||||
const nextPanelViewByScope = { ...panelViewByScope };
|
||||
let draftsChanged = false;
|
||||
let panelViewsChanged = false;
|
||||
|
||||
for (const scopeKey of Object.keys(nextDraftsByScope)) {
|
||||
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
|
||||
delete nextDraftsByScope[scopeKey];
|
||||
draftsChanged = true;
|
||||
}
|
||||
|
||||
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
|
||||
if (!isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) continue;
|
||||
delete nextPanelViewByScope[scopeKey];
|
||||
panelViewsChanged = true;
|
||||
}
|
||||
|
||||
return {
|
||||
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
|
||||
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneTerminalTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTerminalTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isClosedTerminalScope(scopeKey, activeTerminalTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextTerminalScopeState = pruneTerminalScopeState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTerminalTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextTerminalScopeState.draftsByScope,
|
||||
panelViewByScope: nextTerminalScopeState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
163
application/state/aiScopeCleanup.test.ts
Normal file
163
application/state/aiScopeCleanup.test.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import { createEmptyDraft } from "./aiDraftState.ts";
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from "./aiScopeCleanup.ts";
|
||||
|
||||
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
agentId: "catty",
|
||||
scope,
|
||||
messages: [],
|
||||
externalSessionId,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
}
|
||||
|
||||
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
|
||||
const activeSessionIdMap = {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"terminal:closed-terminal": "session-closed-terminal",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
"workspace:closed-workspace": "session-closed-workspace",
|
||||
};
|
||||
const draftsByScope = {
|
||||
"terminal:open-terminal": createEmptyDraft("catty"),
|
||||
"terminal:closed-terminal": createEmptyDraft("catty"),
|
||||
"workspace:open-workspace": createEmptyDraft("catty"),
|
||||
"workspace:closed-workspace": createEmptyDraft("catty"),
|
||||
};
|
||||
const panelViewByScope = {
|
||||
"terminal:open-terminal": { mode: "draft" },
|
||||
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
|
||||
"workspace:open-workspace": { mode: "draft" },
|
||||
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
|
||||
} satisfies Record<string, AIPanelView>;
|
||||
|
||||
const next = pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
new Set(["open-terminal", "open-workspace"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.activeSessionIdMap, {
|
||||
"terminal:open-terminal": "session-open",
|
||||
"workspace:open-workspace": "session-open-workspace",
|
||||
});
|
||||
assert.deepEqual(next.draftsByScope, {
|
||||
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
|
||||
});
|
||||
assert.deepEqual(next.panelViewByScope, {
|
||||
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
|
||||
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
|
||||
});
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions removes non-restorable terminal chats and closed workspaces", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "closed-restorable",
|
||||
hostIds: ["host-1"],
|
||||
}, "ext-1"),
|
||||
createSession("terminal-local", {
|
||||
type: "terminal",
|
||||
targetId: "closed-local",
|
||||
hostIds: ["local-shell"],
|
||||
}, "ext-2"),
|
||||
createSession("workspace-closed", {
|
||||
type: "workspace",
|
||||
targetId: "closed-workspace",
|
||||
}, "ext-3"),
|
||||
createSession("terminal-open", {
|
||||
type: "terminal",
|
||||
targetId: "open-terminal",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-4"),
|
||||
];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["open-terminal"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.orphanedSessionIds, [
|
||||
"terminal-restorable",
|
||||
"terminal-local",
|
||||
"workspace-closed",
|
||||
]);
|
||||
assert.deepEqual(next.sessions, [
|
||||
{
|
||||
...sessions[0],
|
||||
externalSessionId: undefined,
|
||||
},
|
||||
sessions[3],
|
||||
]);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions preserves original sessions when orphaned restorable chats are already detached", () => {
|
||||
const sessions = [
|
||||
createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "closed-restorable",
|
||||
hostIds: ["host-1"],
|
||||
}),
|
||||
createSession("terminal-open", {
|
||||
type: "terminal",
|
||||
targetId: "open-terminal",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-4"),
|
||||
];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["open-terminal"]),
|
||||
);
|
||||
|
||||
assert.deepEqual(next.orphanedSessionIds, ["terminal-restorable"]);
|
||||
assert.equal(next.sessions, sessions);
|
||||
});
|
||||
|
||||
test("pruneInactiveScopedSessions treats sessions displayed elsewhere as in-use, not orphaned", () => {
|
||||
// terminal-restorable's original scope (terminal-closed-A) is gone, but
|
||||
// the user resumed it into terminal-open-B from history. The session's
|
||||
// externalSessionId must be preserved and it must not appear in the
|
||||
// orphaned list, otherwise the active chat loses ACP continuity.
|
||||
const resumedElsewhere = createSession("terminal-restorable", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-A",
|
||||
hostIds: ["host-1"],
|
||||
}, "ext-resumed");
|
||||
|
||||
const trulyOrphaned = createSession("terminal-stale", {
|
||||
type: "terminal",
|
||||
targetId: "terminal-closed-C",
|
||||
hostIds: ["host-2"],
|
||||
}, "ext-stale");
|
||||
|
||||
const sessions = [resumedElsewhere, trulyOrphaned];
|
||||
|
||||
const next = pruneInactiveScopedSessions(
|
||||
sessions,
|
||||
new Set(["terminal-open-B"]),
|
||||
new Set(["terminal-restorable"]),
|
||||
);
|
||||
|
||||
// Only the one not being displayed anywhere should show up as orphaned.
|
||||
assert.deepEqual(next.orphanedSessionIds, ["terminal-stale"]);
|
||||
// The resumed session must retain its externalSessionId.
|
||||
const resumedNext = next.sessions.find((s) => s.id === "terminal-restorable");
|
||||
assert.equal(resumedNext?.externalSessionId, "ext-resumed");
|
||||
});
|
||||
153
application/state/aiScopeCleanup.ts
Normal file
153
application/state/aiScopeCleanup.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types";
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
type ActiveSessionIdMap = Record<string, string | null>;
|
||||
|
||||
function isInactiveScopedTarget(
|
||||
scopeKey: string,
|
||||
activeTargetIds: Set<string>,
|
||||
): boolean {
|
||||
const separatorIndex = scopeKey.indexOf(":");
|
||||
if (separatorIndex === -1) return false;
|
||||
|
||||
const scopeType = scopeKey.slice(0, separatorIndex);
|
||||
if (scopeType !== "terminal" && scopeType !== "workspace") return false;
|
||||
|
||||
const targetId = scopeKey.slice(separatorIndex + 1);
|
||||
if (!targetId) return false;
|
||||
|
||||
return !activeTargetIds.has(targetId);
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedState(
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
const nextDraftsByScope = { ...draftsByScope };
|
||||
const nextPanelViewByScope = { ...panelViewByScope };
|
||||
let draftsChanged = false;
|
||||
let panelViewsChanged = false;
|
||||
|
||||
for (const scopeKey of Object.keys(nextDraftsByScope)) {
|
||||
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
|
||||
delete nextDraftsByScope[scopeKey];
|
||||
draftsChanged = true;
|
||||
}
|
||||
|
||||
for (const scopeKey of Object.keys(nextPanelViewByScope)) {
|
||||
if (!isInactiveScopedTarget(scopeKey, activeTargetIds)) continue;
|
||||
delete nextPanelViewByScope[scopeKey];
|
||||
panelViewsChanged = true;
|
||||
}
|
||||
|
||||
return {
|
||||
draftsByScope: draftsChanged ? nextDraftsByScope : draftsByScope,
|
||||
panelViewByScope: panelViewsChanged ? nextPanelViewByScope : panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedTransientState(
|
||||
activeSessionIdMap: ActiveSessionIdMap,
|
||||
draftsByScope: DraftsByScope,
|
||||
panelViewByScope: PanelViewByScope,
|
||||
activeTargetIds: Set<string>,
|
||||
): {
|
||||
activeSessionIdMap: ActiveSessionIdMap;
|
||||
draftsByScope: DraftsByScope;
|
||||
panelViewByScope: PanelViewByScope;
|
||||
} {
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
|
||||
activeSessionMapChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextActiveSessionIdMap[scopeKey] = sessionId;
|
||||
}
|
||||
|
||||
const nextScopedState = pruneInactiveScopedState(
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
return {
|
||||
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
|
||||
draftsByScope: nextScopedState.draftsByScope,
|
||||
panelViewByScope: nextScopedState.panelViewByScope,
|
||||
};
|
||||
}
|
||||
|
||||
function isRestorableTerminalSession(session: AISession): boolean {
|
||||
return session.scope.type === "terminal"
|
||||
&& !!session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith("local-") && !id.startsWith("serial-"));
|
||||
}
|
||||
|
||||
export function pruneInactiveScopedSessions(
|
||||
sessions: AISession[],
|
||||
activeTargetIds: Set<string>,
|
||||
/**
|
||||
* Session ids currently displayed by any live scope. A session whose
|
||||
* `scope.targetId` is inactive but whose id is still in use somewhere
|
||||
* (e.g. resumed from history into a different terminal) must not be
|
||||
* treated as orphaned — clearing its `externalSessionId` or deleting
|
||||
* it outright would break the chat the user is actively continuing.
|
||||
*/
|
||||
activeSessionIds: Set<string> = new Set(),
|
||||
): {
|
||||
sessions: AISession[];
|
||||
orphanedSessionIds: string[];
|
||||
} {
|
||||
const orphanedSessionIds = sessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.filter((session) => !activeSessionIds.has(session.id))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (orphanedSessionIds.length === 0) {
|
||||
return {
|
||||
sessions,
|
||||
orphanedSessionIds,
|
||||
};
|
||||
}
|
||||
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
let sessionsChanged = false;
|
||||
|
||||
const nextSessions = sessions.flatMap((session) => {
|
||||
if (!orphanedSessionIdSet.has(session.id)) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
if (!isRestorableTerminalSession(session)) {
|
||||
sessionsChanged = true;
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!session.externalSessionId) {
|
||||
return [session];
|
||||
}
|
||||
|
||||
sessionsChanged = true;
|
||||
return [
|
||||
{ ...session, externalSessionId: undefined },
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
sessions: sessionsChanged ? nextSessions : sessions,
|
||||
orphanedSessionIds,
|
||||
};
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AISession,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
@@ -29,6 +31,21 @@ import type {
|
||||
WebSearchConfig,
|
||||
} from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_COMMAND_BLOCKLIST } from '../../infrastructure/ai/types';
|
||||
import {
|
||||
activateDraftView,
|
||||
bumpDraftMutationVersionState,
|
||||
bumpDraftUploadGenerationState,
|
||||
clearScopeDraftState,
|
||||
ensureDraftForScopeState,
|
||||
getDraftUploadGenerationState,
|
||||
setSessionView,
|
||||
updateDraftForScope,
|
||||
} from './aiDraftState';
|
||||
import {
|
||||
pruneInactiveScopedSessions,
|
||||
pruneInactiveScopedTransientState,
|
||||
} from './aiScopeCleanup';
|
||||
import { convertFilesToUploads } from './useFileUpload';
|
||||
|
||||
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
|
||||
interface AIBridge {
|
||||
@@ -45,6 +62,11 @@ function getAIBridge() {
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
const AI_STATE_CHANGED_DRAFTS_BY_SCOPE = 'netcatty:ai-drafts-by-scope';
|
||||
const AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE = 'netcatty:ai-panel-view-by-scope';
|
||||
|
||||
type DraftsByScope = Partial<Record<string, AIDraft>>;
|
||||
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
@@ -72,53 +94,42 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const orphanedSessionIds = currentSessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (orphanedSessionIds.length > 0) {
|
||||
const orphanedSessionIdSet = new Set(orphanedSessionIds);
|
||||
|
||||
// Determine which sessions can be restored via host-based matching
|
||||
const preservedIds = new Set<string>();
|
||||
for (const session of currentSessions) {
|
||||
if (!orphanedSessionIdSet.has(session.id)) continue;
|
||||
// Only preserve remote terminal sessions with real hostIds
|
||||
const isRestorable = session.scope.type === 'terminal'
|
||||
&& session.scope.hostIds?.length
|
||||
&& session.scope.hostIds.some((id) => !id.startsWith('local-') && !id.startsWith('serial-'));
|
||||
if (isRestorable) {
|
||||
preservedIds.add(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup ACP sessions for all orphans (both deleted and preserved).
|
||||
// Preserved sessions will get a new externalSessionId on next use,
|
||||
// so cleaning the old one is safe and prevents subprocess leaks.
|
||||
cleanupAcpSessions(orphanedSessionIds);
|
||||
|
||||
const nextSessions = currentSessions
|
||||
.filter((session) => !orphanedSessionIdSet.has(session.id) || preservedIds.has(session.id))
|
||||
.map((session) => {
|
||||
if (!preservedIds.has(session.id) || !session.externalSessionId) {
|
||||
return session;
|
||||
}
|
||||
// Drop transient ACP session handles so the next turn starts cleanly.
|
||||
return { ...session, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
const sessionsChanged = nextSessions.length !== currentSessions.length
|
||||
|| nextSessions.some((session, index) => session !== currentSessions[index]);
|
||||
if (sessionsChanged) {
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
}
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
// Sessions shown by a still-live scope must be protected from cleanup
|
||||
// even when their own `scope.targetId` points at a closed terminal —
|
||||
// history can be resumed into a different terminal and we must not
|
||||
// clear its `externalSessionId` (or delete it outright) while it's
|
||||
// actively being used.
|
||||
const preCleanupActiveSessionMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
const activeSessionIds = new Set<string>();
|
||||
for (const [scopeKey, sessionId] of Object.entries(preCleanupActiveSessionMap)) {
|
||||
if (!sessionId) continue;
|
||||
if (!isScopeKeyActive(scopeKey, activeTargetIds)) continue;
|
||||
activeSessionIds.add(sessionId);
|
||||
}
|
||||
|
||||
const nextSessionCleanup = pruneInactiveScopedSessions(
|
||||
currentSessions,
|
||||
activeTargetIds,
|
||||
activeSessionIds,
|
||||
);
|
||||
|
||||
if (nextSessionCleanup.orphanedSessionIds.length > 0) {
|
||||
cleanupAcpSessions(nextSessionCleanup.orphanedSessionIds);
|
||||
}
|
||||
|
||||
if (nextSessionCleanup.sessions !== currentSessions) {
|
||||
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
pruneSessionsForStorage(nextSessionCleanup.sessions),
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
}
|
||||
|
||||
const activeSessionIdMap = preCleanupActiveSessionMap;
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
@@ -133,6 +144,46 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
const currentActiveSessionIdMap = activeSessionMapChanged
|
||||
? nextActiveSessionIdMap
|
||||
: activeSessionIdMap;
|
||||
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
|
||||
const prunedScopedTransientState = pruneInactiveScopedTransientState(
|
||||
currentActiveSessionIdMap,
|
||||
currentDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
activeTargetIds,
|
||||
);
|
||||
|
||||
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
|
||||
localStorageAdapter.write(
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
prunedScopedTransientState.activeSessionIdMap,
|
||||
);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
|
||||
for (const scopeKey of Object.keys(currentDraftsByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
}
|
||||
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
|
||||
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
|
||||
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -163,6 +214,10 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
|
||||
let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
let latestAIDraftsByScopeSnapshot: DraftsByScope | null = null;
|
||||
let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
|
||||
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
|
||||
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
|
||||
|
||||
function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
@@ -172,17 +227,33 @@ function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string,
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
function buildScopeKey(scope: AISessionScope) {
|
||||
return `${scope.type}:${scope.targetId ?? ''}`;
|
||||
function setLatestAIDraftsByScopeSnapshot(draftsByScope: DraftsByScope) {
|
||||
latestAIDraftsByScopeSnapshot = draftsByScope;
|
||||
}
|
||||
|
||||
function areHostIdsEqual(left?: string[], right?: string[]) {
|
||||
const leftIds = left ?? [];
|
||||
const rightIds = right ?? [];
|
||||
if (leftIds.length !== rightIds.length) return false;
|
||||
function setLatestAIPanelViewByScopeSnapshot(panelViewByScope: PanelViewByScope) {
|
||||
latestAIPanelViewByScopeSnapshot = panelViewByScope;
|
||||
}
|
||||
|
||||
const rightSet = new Set(rightIds);
|
||||
return leftIds.every((hostId) => rightSet.has(hostId));
|
||||
function bumpDraftMutationVersion(scopeKey: string) {
|
||||
latestAIDraftMutationVersionByScopeSnapshot = bumpDraftMutationVersionState(
|
||||
latestAIDraftMutationVersionByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function getDraftUploadGeneration(scopeKey: string) {
|
||||
return getDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
function bumpDraftUploadGeneration(scopeKey: string) {
|
||||
latestAIDraftUploadGenerationByScopeSnapshot = bumpDraftUploadGenerationState(
|
||||
latestAIDraftUploadGenerationByScopeSnapshot,
|
||||
scopeKey,
|
||||
);
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
@@ -243,6 +314,14 @@ export function useAIState() {
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
|
||||
);
|
||||
// Per-scope draft/view state is intentionally memory-only so a relaunch
|
||||
// does not restore stale composer input or panel intent against new history.
|
||||
const [draftsByScope, setDraftsByScopeRaw] = useState<DraftsByScope>(() =>
|
||||
latestAIDraftsByScopeSnapshot ?? {}
|
||||
);
|
||||
const [panelViewByScope, setPanelViewByScopeRaw] = useState<PanelViewByScope>(() =>
|
||||
latestAIPanelViewByScopeSnapshot ?? {}
|
||||
);
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
@@ -262,6 +341,14 @@ export function useAIState() {
|
||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||
}, [activeSessionIdMap]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIDraftsByScopeSnapshot(draftsByScope);
|
||||
}, [draftsByScope]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIPanelViewByScopeSnapshot(panelViewByScope);
|
||||
}, [panelViewByScope]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
@@ -284,13 +371,39 @@ export function useAIState() {
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === id) {
|
||||
return prev;
|
||||
}
|
||||
|
||||
const next = { ...prev, [scopeKey]: id };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
nextActiveSessionIdMap = next;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextActiveSessionIdMap) return;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}, []);
|
||||
|
||||
const setPanelViewByScope = useCallback((value: PanelViewByScope | ((prev: PanelViewByScope) => PanelViewByScope)) => {
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
|
||||
setPanelViewByScopeRaw((prev) => {
|
||||
const next = typeof value === 'function' ? value(prev) : value;
|
||||
if (next === prev) return prev;
|
||||
nextPanelViewByScope = next;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextPanelViewByScope) return;
|
||||
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
@@ -522,6 +635,12 @@ export function useAIState() {
|
||||
?? {},
|
||||
);
|
||||
return;
|
||||
case AI_STATE_CHANGED_DRAFTS_BY_SCOPE:
|
||||
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
|
||||
return;
|
||||
case AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE:
|
||||
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
||||
return;
|
||||
default:
|
||||
handleStorage({ key } as StorageEvent);
|
||||
}
|
||||
@@ -686,61 +805,6 @@ export function useAIState() {
|
||||
});
|
||||
}, [debouncedPersistSessions]);
|
||||
|
||||
const retargetSessionScope = useCallback((sessionId: string, scope: AISessionScope) => {
|
||||
const currentSession = sessionsRef.current.find((session) => session.id === sessionId);
|
||||
if (!currentSession) return;
|
||||
|
||||
const currentScope = currentSession.scope;
|
||||
const scopeChanged =
|
||||
currentScope.type !== scope.type
|
||||
|| currentScope.targetId !== scope.targetId
|
||||
|| !areHostIdsEqual(currentScope.hostIds, scope.hostIds);
|
||||
|
||||
const nextScopeKey = buildScopeKey(scope);
|
||||
const currentScopeKey = buildScopeKey(currentScope);
|
||||
|
||||
if (scopeChanged) {
|
||||
setSessionsRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = prev.map((session) => {
|
||||
if (session.id !== sessionId) return session;
|
||||
changed = true;
|
||||
// Clear stale ACP handle — retarget may run before orphan cleanup
|
||||
return { ...session, scope, externalSessionId: undefined };
|
||||
});
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
sessionsRef.current = next;
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
setActiveSessionIdMapRaw((prev) => {
|
||||
let changed = false;
|
||||
const next = { ...prev };
|
||||
|
||||
if (currentScopeKey !== nextScopeKey && next[currentScopeKey] === sessionId) {
|
||||
delete next[currentScopeKey];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (next[nextScopeKey] !== sessionId) {
|
||||
next[nextScopeKey] = sessionId;
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed) return prev;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
// Maximum messages per session to prevent unbounded memory growth
|
||||
const MAX_MESSAGES_PER_SESSION = 500;
|
||||
|
||||
@@ -808,14 +872,193 @@ export function useAIState() {
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const ensureDraftForScope = useCallback((scopeKey: string, agentId: string): void => {
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const next = ensureDraftForScopeState(prev, scopeKey, agentId);
|
||||
if (next === prev) return prev;
|
||||
nextDraftsByScope = next;
|
||||
return next;
|
||||
});
|
||||
|
||||
if (!nextDraftsByScope) return;
|
||||
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}, []);
|
||||
|
||||
const updateDraft = useCallback((
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): void => {
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const next = updateDraftForScope(
|
||||
prev,
|
||||
scopeKey,
|
||||
fallbackAgentId,
|
||||
(draft) => {
|
||||
return {
|
||||
...updater(draft),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
},
|
||||
);
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}, []);
|
||||
|
||||
const updateDraftIfPresent = useCallback((
|
||||
scopeKey: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
): void => {
|
||||
let updated = false;
|
||||
|
||||
setDraftsByScopeRaw((prev) => {
|
||||
const currentDraft = prev[scopeKey];
|
||||
if (!currentDraft) return prev;
|
||||
|
||||
const nextDraft = {
|
||||
...updater(currentDraft),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
const next = {
|
||||
...prev,
|
||||
[scopeKey]: nextDraft,
|
||||
};
|
||||
updated = true;
|
||||
setLatestAIDraftsByScopeSnapshot(next);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
return next;
|
||||
});
|
||||
|
||||
if (updated) {
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showDraftView = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
let nextActiveSessionIdMap: Record<string, string | null> | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let activeSessionMapChanged = false;
|
||||
let panelViewChanged = false;
|
||||
|
||||
setActiveSessionIdMapRaw((prevActiveSessionIdMap) => {
|
||||
const next = activateDraftView(
|
||||
prevActiveSessionIdMap,
|
||||
currentPanelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
activeSessionMapChanged = next.activeSessionIdMap !== prevActiveSessionIdMap;
|
||||
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
||||
nextActiveSessionIdMap = next.activeSessionIdMap;
|
||||
nextPanelViewByScope = next.panelViewByScope;
|
||||
return activeSessionMapChanged ? next.activeSessionIdMap : prevActiveSessionIdMap;
|
||||
});
|
||||
|
||||
if (activeSessionMapChanged && nextActiveSessionIdMap) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
|
||||
if (panelViewChanged && nextPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
setPanelViewByScopeRaw(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}, [panelViewByScope]);
|
||||
|
||||
const showSessionView = useCallback((scopeKey: string, sessionId: string) => {
|
||||
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
|
||||
}, [setPanelViewByScope]);
|
||||
|
||||
const clearDraftForScope = useCallback((scopeKey: string) => {
|
||||
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? panelViewByScope;
|
||||
let nextDraftsByScope: DraftsByScope | null = null;
|
||||
let nextPanelViewByScope: PanelViewByScope | null = null;
|
||||
let draftsChanged = false;
|
||||
let panelViewChanged = false;
|
||||
|
||||
setDraftsByScopeRaw((prevDraftsByScope) => {
|
||||
const next = clearScopeDraftState(
|
||||
prevDraftsByScope,
|
||||
currentPanelViewByScope,
|
||||
scopeKey,
|
||||
);
|
||||
draftsChanged = next.draftsByScope !== prevDraftsByScope;
|
||||
panelViewChanged = next.panelViewByScope !== currentPanelViewByScope;
|
||||
nextDraftsByScope = next.draftsByScope;
|
||||
nextPanelViewByScope = next.panelViewByScope;
|
||||
return draftsChanged ? next.draftsByScope : prevDraftsByScope;
|
||||
});
|
||||
|
||||
if (!draftsChanged && !panelViewChanged) return;
|
||||
|
||||
bumpDraftMutationVersion(scopeKey);
|
||||
bumpDraftUploadGeneration(scopeKey);
|
||||
|
||||
if (draftsChanged && nextDraftsByScope) {
|
||||
setLatestAIDraftsByScopeSnapshot(nextDraftsByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
|
||||
}
|
||||
|
||||
if (panelViewChanged && nextPanelViewByScope) {
|
||||
setLatestAIPanelViewByScopeSnapshot(nextPanelViewByScope);
|
||||
setPanelViewByScopeRaw(nextPanelViewByScope);
|
||||
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
|
||||
}
|
||||
}, [panelViewByScope]);
|
||||
|
||||
const addDraftFiles = useCallback(async (
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
inputFiles: File[],
|
||||
) => {
|
||||
ensureDraftForScope(scopeKey, fallbackAgentId);
|
||||
const initialUploadGeneration = getDraftUploadGeneration(scopeKey);
|
||||
const uploads = await convertFilesToUploads(inputFiles);
|
||||
if (uploads.length === 0) return;
|
||||
|
||||
if (getDraftUploadGeneration(scopeKey) !== initialUploadGeneration) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateDraftIfPresent(scopeKey, (draft) => ({
|
||||
...draft,
|
||||
attachments: [...draft.attachments, ...uploads],
|
||||
}));
|
||||
}, [ensureDraftForScope, updateDraftIfPresent]);
|
||||
|
||||
const removeDraftFile = useCallback((scopeKey: string, fallbackAgentId: string, fileId: string) => {
|
||||
updateDraft(scopeKey, fallbackAgentId, (draft) => ({
|
||||
...draft,
|
||||
attachments: draft.attachments.filter((file) => file.id !== fileId),
|
||||
}));
|
||||
}, [updateDraft]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
cleanupOrphanedAISessions(activeTargetIds);
|
||||
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
|
||||
|
||||
const nextSessions =
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
sessionsRef.current = nextSessions;
|
||||
setSessionsRaw(nextSessions);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
setDraftsByScopeRaw(latestAIDraftsByScopeSnapshot ?? {});
|
||||
setPanelViewByScopeRaw(latestAIPanelViewByScopeSnapshot ?? {});
|
||||
}, []);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
@@ -889,13 +1132,21 @@ export function useAIState() {
|
||||
// Sessions (per-scope active session)
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
setActiveSessionId,
|
||||
ensureDraftForScope,
|
||||
updateDraft,
|
||||
showDraftView,
|
||||
showSessionView,
|
||||
clearDraftForScope,
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
deleteSession,
|
||||
deleteSessionsByTarget,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
|
||||
@@ -16,38 +16,16 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { collectSyncableSettings, hasMeaningfulSyncData } from '../syncPayload';
|
||||
import { readInterruptedVaultApply } from '../localVaultBackups';
|
||||
import {
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
import { notify } from '../notification';
|
||||
|
||||
/**
|
||||
* Check whether a sync payload has any meaningful user data. Covers all
|
||||
* synced entity arrays so that edge cases (e.g. user has 0 hosts but 1
|
||||
* port forwarding rule) are not mistakenly treated as "empty".
|
||||
*/
|
||||
function isPayloadEffectivelyEmpty(payload: SyncPayload): boolean {
|
||||
// Check all synced entity arrays.
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
if (hasEntities) return false;
|
||||
// Also consider settings: if any key has a defined value, the user has
|
||||
// customized something worth preserving.
|
||||
if (payload.settings && Object.values(payload.settings).some((v) => v !== undefined)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
interface AutoSyncConfig {
|
||||
// Data to sync
|
||||
hosts: SyncPayload['hosts'];
|
||||
@@ -61,15 +39,24 @@ interface AutoSyncConfig {
|
||||
groupConfigs?: SyncPayload['groupConfigs'];
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
startupReady?: boolean;
|
||||
|
||||
// Callbacks
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
}
|
||||
|
||||
// Get manager singleton for direct state access
|
||||
const manager = getCloudSyncManager();
|
||||
const AUTO_SYNC_PROVIDER_ORDER: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
|
||||
// Cross-window restore barrier: stored as an epoch-ms deadline. Any value
|
||||
// in the future means a restore is applying in some window and auto-sync
|
||||
// must not push concurrently.
|
||||
const isRestoreInProgress = (): boolean => {
|
||||
const raw = localStorageAdapter.readNumber(STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL);
|
||||
return typeof raw === 'number' && raw > Date.now();
|
||||
};
|
||||
|
||||
type SyncTrigger = 'auto' | 'manual';
|
||||
|
||||
interface SyncNowOptions {
|
||||
@@ -190,6 +177,50 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
throw new Error(t('sync.autoSync.alreadySyncing'));
|
||||
}
|
||||
|
||||
// Cross-window guard: another window may be in the middle of
|
||||
// applying a local vault restore. If we push right now we'd upload
|
||||
// the pre-restore snapshot (the main window's React state hasn't
|
||||
// observed the localStorage writes yet), clobbering the just-
|
||||
// restored cloud copy. Skip silently on auto triggers and fail
|
||||
// loudly on manual ones so the user understands why their click
|
||||
// did nothing.
|
||||
//
|
||||
// Pairs with `withRestoreBarrier` in application/localVaultBackups.ts
|
||||
// (the writer) and with the matching early-return in the
|
||||
// debounced-sync effect below (the other reader, which prevents
|
||||
// scheduling a push while the barrier is held).
|
||||
if (isRestoreInProgress()) {
|
||||
if (trigger === 'auto') {
|
||||
console.info('[AutoSync] Skipping: a vault restore is in progress in another window.');
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.restoreInProgress'));
|
||||
}
|
||||
|
||||
// Refuse to auto-push when a previous apply crashed mid-way and
|
||||
// left the vault in a partial state. `applyProtectedSyncPayload`
|
||||
// sets a sentinel before its non-atomic localStorage writes and
|
||||
// clears it on successful completion; the sentinel's presence
|
||||
// here means the renderer crashed between a first write and the
|
||||
// clean-up, so the in-memory payload is a mix of pre-apply and
|
||||
// post-apply entries. Pushing that would silently overwrite an
|
||||
// intact cloud copy with corrupted data.
|
||||
//
|
||||
// Manual triggers surface a user-visible error that points the
|
||||
// user at the Restore UI; auto triggers return quietly (the
|
||||
// next startup toast below flags the state).
|
||||
const interruptedApply = readInterruptedVaultApply();
|
||||
if (interruptedApply) {
|
||||
if (trigger === 'auto') {
|
||||
console.warn(
|
||||
'[AutoSync] Skipping: previous apply was interrupted — refusing to push partial state.',
|
||||
interruptedApply,
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error(t('sync.autoSync.interruptedApplyMessage'));
|
||||
}
|
||||
|
||||
// If another window unlocked, reuse the in-memory session password from main process.
|
||||
if (state.securityState !== 'UNLOCKED') {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -221,7 +252,12 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// 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.
|
||||
if (isPayloadEffectivelyEmpty(payload) && trigger === 'auto') {
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
@@ -232,7 +268,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
// state gets updated even when some providers failed
|
||||
for (const result of results.values()) {
|
||||
if (result.mergedPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
skipNextSyncRef.current = true;
|
||||
break; // All providers share the same merged payload
|
||||
}
|
||||
@@ -248,6 +284,18 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
}
|
||||
|
||||
lastSyncedDataRef.current = dataHash;
|
||||
|
||||
// Successful sync implies a successful per-provider
|
||||
// `checkProviderConflict` (which inspects remote) — equivalent
|
||||
// to a successful startup reconciliation from the auto-sync
|
||||
// gate's point of view. Opening the gate here is the escape
|
||||
// hatch when a network outage exhausted the startup retry
|
||||
// timer: a user-triggered manual sync (or any first successful
|
||||
// auto sync that somehow ran anyway) resumes auto-sync for the
|
||||
// rest of the session. Without this, a degraded-startup session
|
||||
// would require the user to manually sync after every edit.
|
||||
hasCheckedRemoteRef.current = true;
|
||||
remoteCheckDoneRef.current = true;
|
||||
} catch (error) {
|
||||
if (trigger === 'manual') {
|
||||
throw error;
|
||||
@@ -261,81 +309,219 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
isSyncRunningRef.current = false;
|
||||
}
|
||||
}, [sync, buildPayload, getDataHash, onApplyPayload, t]);
|
||||
|
||||
|
||||
// One-shot toast per mount when a previous apply was interrupted, so the
|
||||
// user understands why auto-sync is silently paused and where to go to
|
||||
// recover. `applyProtectedSyncPayload` clears the sentinel on a clean
|
||||
// apply, so this only fires once per genuine crash and naturally stops
|
||||
// after the user completes a recovery.
|
||||
const interruptedApplyNotifiedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (interruptedApplyNotifiedRef.current) return;
|
||||
if (!sync.isUnlocked) return;
|
||||
const interrupted = readInterruptedVaultApply();
|
||||
if (!interrupted) return;
|
||||
interruptedApplyNotifiedRef.current = true;
|
||||
notify.error(
|
||||
t('sync.autoSync.interruptedApplyMessage'),
|
||||
t('sync.autoSync.interruptedApplyTitle'),
|
||||
);
|
||||
}, [sync.isUnlocked, t]);
|
||||
|
||||
// Stabilize the fields `checkRemoteVersion` reads from `config`.
|
||||
// AutoSyncConfig is a fresh object literal on every App render, so a
|
||||
// naive `config` dep would rebuild `checkRemoteVersion`'s identity on
|
||||
// every unrelated state change — re-firing the retry effect with
|
||||
// `attempt=0` and spawning overlapping in-flight inspections. The
|
||||
// refs below let `checkRemoteVersion` read the latest callback and
|
||||
// readiness flag without pulling the object identity into deps.
|
||||
const onApplyPayloadRef = useRef(config.onApplyPayload);
|
||||
useEffect(() => {
|
||||
onApplyPayloadRef.current = config.onApplyPayload;
|
||||
}, [config.onApplyPayload]);
|
||||
const startupReadyRef = useRef(config.startupReady);
|
||||
useEffect(() => {
|
||||
startupReadyRef.current = config.startupReady;
|
||||
}, [config.startupReady]);
|
||||
// `buildPayload` closes over live React state so its identity flips
|
||||
// on every vault edit; route it through a ref so `checkRemoteVersion`
|
||||
// can read the latest builder without churning its memo identity.
|
||||
const buildPayloadRef = useRef(buildPayload);
|
||||
useEffect(() => {
|
||||
buildPayloadRef.current = buildPayload;
|
||||
}, [buildPayload]);
|
||||
|
||||
// Serialize `checkRemoteVersion` invocations. Overlapping runs would
|
||||
// race on `commitRemoteInspection` + `onApplyPayload`: two merges
|
||||
// could both write-then-clear the apply-in-progress sentinel around
|
||||
// interleaved applies, and both could push post-merge snapshots to
|
||||
// remote. The cross-window `withRestoreBarrier` protects other
|
||||
// windows but does NOT serialize same-window re-entry, so this
|
||||
// in-flight guard closes that gap at the top of the call.
|
||||
const checkRemoteInFlightRef = useRef(false);
|
||||
|
||||
// Check remote version and pull if newer (on startup)
|
||||
const checkRemoteVersion = useCallback(async () => {
|
||||
if (checkRemoteInFlightRef.current) {
|
||||
return;
|
||||
}
|
||||
const state = manager.getState();
|
||||
const hasProvider = Object.values(state.providers).some((provider) => isProviderReadyForSync(provider));
|
||||
const unlocked = state.securityState === 'UNLOCKED';
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current) {
|
||||
|
||||
if (!hasProvider || !unlocked || hasCheckedRemoteRef.current || startupReadyRef.current === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
hasCheckedRemoteRef.current = true;
|
||||
|
||||
// Find connected provider
|
||||
|
||||
// Find connected provider BEFORE acquiring the in-flight lock so the
|
||||
// "nothing to check" early return doesn't leak the lock and wedge
|
||||
// the retry timer. Any path that takes the lock MUST reach the
|
||||
// finally-release below.
|
||||
const connectedProvider = AUTO_SYNC_PROVIDER_ORDER.find((provider) =>
|
||||
isProviderReadyForSync(state.providers[provider]),
|
||||
) ?? null;
|
||||
|
||||
if (!connectedProvider) return;
|
||||
|
||||
|
||||
if (!connectedProvider) {
|
||||
// Nothing to check — mark as done so the auto-sync gate opens.
|
||||
remoteCheckDoneRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
checkRemoteInFlightRef.current = true;
|
||||
|
||||
// Track whether the startup path completed in a state where the anchor/base
|
||||
// are consistent with the local vault. Only then should we latch
|
||||
// hasCheckedRemoteRef so that transient failures are retryable.
|
||||
let startupConsistent = false;
|
||||
try {
|
||||
// Load base BEFORE downloading (downloadFromProvider overwrites the base)
|
||||
// Load base BEFORE observing the remote payload (commitRemoteInspection overwrites the base).
|
||||
const base = await manager.loadSyncBase(connectedProvider);
|
||||
const remotePayload = await sync.downloadFromProvider(connectedProvider);
|
||||
const inspection = await manager.inspectProviderRemote(connectedProvider);
|
||||
|
||||
if (remotePayload && remotePayload.syncedAt > state.localUpdatedAt) {
|
||||
const localPayload = buildPayload();
|
||||
const localIsEmpty = isPayloadEffectivelyEmpty(localPayload);
|
||||
const remoteHasData = !isPayloadEffectivelyEmpty(remotePayload);
|
||||
if (!inspection.payload || !inspection.remoteChanged || !inspection.remoteFile) {
|
||||
// Remote unchanged (or empty) — no local mutation needed; anchor/base
|
||||
// are already in sync with remote from a previous run.
|
||||
startupConsistent = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (localIsEmpty && remoteHasData) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
const remoteFile = inspection.remoteFile;
|
||||
const remotePayload = inspection.payload;
|
||||
const localPayload = buildPayloadRef.current();
|
||||
const localIsEmpty = !hasMeaningfulSyncData(localPayload);
|
||||
const remoteHasData = hasMeaningfulSyncData(remotePayload);
|
||||
|
||||
// If local vault is empty but cloud has data, this almost certainly
|
||||
// means the user's data was lost (update, storage corruption, etc.).
|
||||
// Pause and ask the user what to do instead of silently merging.
|
||||
if (localIsEmpty && remoteHasData) {
|
||||
const userAction = await new Promise<'restore' | 'keep-empty'>((resolve) => {
|
||||
emptyVaultResolveRef.current = resolve;
|
||||
setEmptyVaultConflict({
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
});
|
||||
setEmptyVaultConflict(null);
|
||||
emptyVaultResolveRef.current = null;
|
||||
|
||||
if (userAction === 'restore') {
|
||||
config.onApplyPayload(remotePayload);
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Don't apply remote data.
|
||||
// The next auto-sync will eventually push the empty state if
|
||||
// the user makes another edit.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
if (userAction === 'restore') {
|
||||
// Apply remote FIRST; only commit anchor/base after the UI-side
|
||||
// state has accepted the remote payload, otherwise a failure
|
||||
// between commit and apply would leave the anchor pointing at
|
||||
// remote while local is still empty — the exact overwrite window
|
||||
// we're trying to close.
|
||||
await Promise.resolve(onApplyPayloadRef.current(remotePayload));
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
skipNextSyncRef.current = true;
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.restoredMessage'), t('sync.autoSync.restoredTitle'));
|
||||
} else {
|
||||
// User chose to keep the empty vault. Deliberately do NOT advance
|
||||
// the anchor or base — the next sync must still treat remote as
|
||||
// "unseen" so the empty-vault-push guard (`hasMeaningfulSyncData`)
|
||||
// keeps protecting the cloud copy. startupConsistent stays false
|
||||
// so hasCheckedRemoteRef is not latched and the next startup will
|
||||
// re-prompt if the user still has not added anything.
|
||||
notify.info(t('sync.autoSync.keptLocalMessage'), t('sync.autoSync.keptLocalTitle'));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
const { mergeSyncPayloads } = await import('../../domain/syncMerge');
|
||||
const mergeResult = mergeSyncPayloads(base, localPayload, remotePayload);
|
||||
|
||||
config.onApplyPayload(mergeResult.payload);
|
||||
// Prevent the data-change effect from immediately re-uploading the
|
||||
// merged payload — the merge already incorporated both sides. The
|
||||
// next deliberate edit by the user will trigger a normal sync.
|
||||
skipNextSyncRef.current = true;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
// Apply merged payload to local state BEFORE committing. If the apply
|
||||
// throws, the next startup will re-run the merge with fresh data.
|
||||
await Promise.resolve(onApplyPayloadRef.current(mergeResult.payload));
|
||||
// Base is the last-agreed remote snapshot; `commitRemoteInspection`
|
||||
// stores remotePayload as the base so the next diff is computed
|
||||
// against what the cloud actually has, not against the merged
|
||||
// local-only state.
|
||||
await manager.commitRemoteInspection(connectedProvider, remoteFile, remotePayload);
|
||||
startupConsistent = true;
|
||||
notify.success(t('sync.autoSync.syncedMessage'), t('sync.autoSync.syncedTitle'));
|
||||
|
||||
// If the three-way merge introduced any local-only additions that the
|
||||
// remote does not yet have, we MUST round-trip those to the cloud.
|
||||
// Previously this branch stopped after applying merge locally, so the
|
||||
// merged-in additions lived only on the device that ran the merge
|
||||
// until the user's next edit.
|
||||
//
|
||||
// We push the merged payload *directly* through the manager rather
|
||||
// than going through the React-state-driven `syncNow`. syncNow
|
||||
// rebuilds the payload from hooks state, which may not yet reflect
|
||||
// the onApplyPayload we awaited above (React commit phase is async
|
||||
// relative to the awaited promise resolution). Passing mergeResult
|
||||
// in explicitly removes the race entirely and avoids a setTimeout(0)
|
||||
// that only approximated the correct ordering.
|
||||
if (mergeResult.payload) {
|
||||
try {
|
||||
await manager.syncAllProviders(mergeResult.payload);
|
||||
// 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.
|
||||
skipNextSyncRef.current = true;
|
||||
} catch (error) {
|
||||
// Non-fatal: the next user edit will drive another sync cycle.
|
||||
console.warn('[AutoSync] Post-merge round-trip push failed:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AutoSync] Failed to check remote version:', error);
|
||||
// Surface a degraded-sync hint to the user rather than silently
|
||||
// opening the auto-sync gate. Auto-sync will still retry on next
|
||||
// data change (see finally block), but without this toast the user
|
||||
// has no visible signal that startup reconciliation failed.
|
||||
notify.error(
|
||||
t('sync.autoSync.inspectFailedMessage'),
|
||||
t('sync.autoSync.inspectFailedTitle'),
|
||||
);
|
||||
// Leave hasCheckedRemoteRef=false so the next startup (or the next
|
||||
// provider/unlock transition) can retry.
|
||||
} finally {
|
||||
remoteCheckDoneRef.current = true;
|
||||
if (startupConsistent) {
|
||||
hasCheckedRemoteRef.current = true;
|
||||
// Only open the auto-sync gate when the inspect actually
|
||||
// validated the remote state. Leaving the gate closed on
|
||||
// inspect failure is intentional: an edit made during a
|
||||
// degraded startup must not race ahead and push a partially-
|
||||
// hydrated vault over an intact remote. The retry effect
|
||||
// below re-fires checkRemoteVersion on the next provider/
|
||||
// unlock/startupReady transition, and a manual sync from
|
||||
// Settings remains available as an escape hatch.
|
||||
remoteCheckDoneRef.current = true;
|
||||
}
|
||||
checkRemoteInFlightRef.current = false;
|
||||
}
|
||||
}, [sync, config, buildPayload, t]);
|
||||
// Intentionally minimal deps: `buildPayload`, `config.onApplyPayload`,
|
||||
// and `config.startupReady` are read through refs above so their
|
||||
// identity flips (every vault edit produces a fresh `buildPayload`
|
||||
// and a fresh AutoSyncConfig literal) cannot re-memoize this
|
||||
// callback and restart the retry-timer's exponential backoff.
|
||||
}, [t]);
|
||||
|
||||
// Debounced auto-sync when data changes
|
||||
useEffect(() => {
|
||||
@@ -379,6 +565,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
if (sync.isSyncing || isSyncRunningRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Hold off on scheduling a new push while another window is applying
|
||||
// a restore — the restore is about to land via localStorage and the
|
||||
// debounce-fired syncNow would otherwise race it. The next data-
|
||||
// change tick after the restore barrier clears will re-enter here.
|
||||
if (isRestoreInProgress()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't even schedule a push while the apply-in-progress sentinel
|
||||
// is held. The syncNow path re-checks and refuses too, but dropping
|
||||
// the debounced schedule here avoids spinning a 3-second timer for
|
||||
// every keystroke while the user is in the Restore UI working
|
||||
// through recovery.
|
||||
if (readInterruptedVaultApply()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Clear existing timeout
|
||||
if (syncTimeoutRef.current) {
|
||||
@@ -397,17 +600,65 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion, bookmarksVersion]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
// Check remote version on startup/unlock, then retry with backoff
|
||||
// while the inspect keeps failing. Without the timer-based retry,
|
||||
// a failure that doesn't coincide with a dep change would wedge the
|
||||
// auto-sync gate closed until the user restarts or manually triggers
|
||||
// sync from Settings — the 30s/60s/90s cadence below lets a short
|
||||
// outage (network blip, provider rate-limit) self-heal.
|
||||
useEffect(() => {
|
||||
if (sync.hasAnyConnectedProvider && sync.isUnlocked && !hasCheckedRemoteRef.current) {
|
||||
// Delay check to ensure everything is loaded
|
||||
const timer = setTimeout(() => {
|
||||
checkRemoteVersion();
|
||||
}, 1000);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
if (
|
||||
!sync.hasAnyConnectedProvider ||
|
||||
!sync.isUnlocked ||
|
||||
hasCheckedRemoteRef.current ||
|
||||
config.startupReady === false
|
||||
) {
|
||||
return;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, checkRemoteVersion]);
|
||||
|
||||
let cancelled = false;
|
||||
let attempt = 0;
|
||||
let timerId: NodeJS.Timeout | null = null;
|
||||
|
||||
const tick = () => {
|
||||
if (cancelled) return;
|
||||
void (async () => {
|
||||
await checkRemoteVersion();
|
||||
if (cancelled || hasCheckedRemoteRef.current) return;
|
||||
// Cap retries at ~5 minutes total (30s + 60s + 120s + 240s). A
|
||||
// persistent failure beyond that is almost certainly a
|
||||
// misconfiguration that needs user action rather than more
|
||||
// auto-retries.
|
||||
//
|
||||
// When retries exhaust we deliberately leave the auto-sync gate
|
||||
// CLOSED. Opening it here would allow a partially-lost local
|
||||
// vault to silently clobber an unchanged remote: anchor still
|
||||
// matches, `checkProviderConflict` sees no remote change,
|
||||
// `hasMeaningfulSyncData` doesn't flag non-empty-but-partial
|
||||
// local, and the empty-vault prompt never fires.
|
||||
//
|
||||
// Escape hatch: a successful manual sync from Settings opens
|
||||
// the gate via `syncNow`'s success path. That path runs the
|
||||
// same per-provider inspect we use here, so a successful
|
||||
// manual sync is equivalent to a successful startup inspect
|
||||
// from the gate's point of view — the user's explicit click
|
||||
// authorizes both the push and the subsequent auto-sync
|
||||
// resumption. Until then, auto-sync stays paused and the
|
||||
// "sync paused" toast is the user's signal to act.
|
||||
if (attempt >= 4) return;
|
||||
const delayMs = Math.min(240_000, 30_000 * 2 ** attempt);
|
||||
attempt += 1;
|
||||
timerId = setTimeout(tick, delayMs);
|
||||
})();
|
||||
};
|
||||
|
||||
tick();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (timerId) clearTimeout(timerId);
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.isUnlocked, config.startupReady, checkRemoteVersion]);
|
||||
|
||||
// Reset check flags when provider disconnects
|
||||
useEffect(() => {
|
||||
@@ -416,6 +667,25 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
remoteCheckDoneRef.current = false;
|
||||
}
|
||||
}, [sync.hasAnyConnectedProvider]);
|
||||
|
||||
// On unmount, release any pending empty-vault confirmation. Without
|
||||
// this, an unmount mid-dialog (window close, workspace switch) leaves
|
||||
// the resolver promise dangling forever and the `checkRemoteVersion`
|
||||
// finally block never sets remoteCheckDoneRef — in practice React
|
||||
// tears down the hook first, but leaking the resolve callback and
|
||||
// referenced remotePayload keeps them pinned by the awaiter until
|
||||
// the next reload. Resolving with 'keep-empty' is the safe default:
|
||||
// it mirrors the "don't touch remote" choice and leaves the version
|
||||
// stamp untouched so the next mount re-prompts.
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
const resolve = emptyVaultResolveRef.current;
|
||||
if (resolve) {
|
||||
emptyVaultResolveRef.current = null;
|
||||
resolve('keep-empty');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolveEmptyVaultConflict = useCallback((action: 'restore' | 'keep-empty') => {
|
||||
// Guard: resolve only once (prevents double-click from entering an
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
/**
|
||||
* useFileUpload - Handle file paste/drop with base64 conversion
|
||||
* File upload conversion helpers for AI draft attachments.
|
||||
*
|
||||
* Supports images, PDFs, and other document types.
|
||||
* Ported from 1code's use-agents-file-upload.ts
|
||||
*/
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { getPathForFile } from '../../lib/sftpFileUtils';
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string; // data:...;base64,... for preview
|
||||
base64Data: string; // raw base64 for API
|
||||
mediaType: string; // MIME type e.g. "image/png", "application/pdf"
|
||||
filePath?: string; // original filesystem path (Electron only)
|
||||
}
|
||||
export type { UploadedFile } from '../../infrastructure/ai/types';
|
||||
|
||||
/** Reject only known binary blobs that AI models can't process */
|
||||
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
||||
@@ -38,42 +31,32 @@ async function fileToDataUrl(file: File): Promise<{ dataUrl: string; base64: str
|
||||
});
|
||||
}
|
||||
|
||||
export function useFileUpload() {
|
||||
const [files, setFiles] = useState<UploadedFile[]>([]);
|
||||
export async function convertFilesToUploads(inputFiles: File[]): Promise<UploadedFile[]> {
|
||||
const supported = inputFiles.filter(isSupportedFile);
|
||||
if (supported.length === 0) return [];
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
const supported = inputFiles.filter(isSupportedFile);
|
||||
if (supported.length === 0) return;
|
||||
|
||||
const newFiles: UploadedFile[] = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
let dataUrl = '';
|
||||
let base64Data = '';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
dataUrl = result.dataUrl;
|
||||
base64Data = result.base64;
|
||||
} catch (err) {
|
||||
console.error('[useFileUpload] Failed to convert:', err);
|
||||
}
|
||||
const uploads: Array<UploadedFile | null> = await Promise.all(
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
const filePath = getPathForFile(file);
|
||||
return { id, filename, dataUrl, base64Data, mediaType, filePath };
|
||||
}),
|
||||
);
|
||||
return {
|
||||
id,
|
||||
filename,
|
||||
dataUrl: result.dataUrl,
|
||||
base64Data: result.base64,
|
||||
mediaType,
|
||||
filePath,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error('[useFileUpload] Failed to convert:', err);
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
setFiles((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const removeFile = useCallback((id: string) => {
|
||||
setFiles((prev) => prev.filter((f) => f.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearFiles = useCallback(() => {
|
||||
setFiles([]);
|
||||
}, []);
|
||||
|
||||
return { files, addFiles, removeFile, clearFiles };
|
||||
return uploads.filter((upload): upload is UploadedFile => upload !== null);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ export const getTerminalPassthroughActions = (): Set<string> => {
|
||||
return new Set([
|
||||
'copy',
|
||||
'paste',
|
||||
'pasteSelection',
|
||||
'selectAll',
|
||||
'clearBuffer',
|
||||
'searchTerminal',
|
||||
|
||||
95
application/state/useLocalVaultBackups.ts
Normal file
95
application/state/useLocalVaultBackups.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import {
|
||||
type LocalVaultBackupPreview,
|
||||
getLocalVaultBackupCapabilities,
|
||||
getLocalVaultBackupMaxCount,
|
||||
listLocalVaultBackups,
|
||||
openLocalVaultBackupDir,
|
||||
readLocalVaultBackup,
|
||||
setLocalVaultBackupMaxCount,
|
||||
trimLocalVaultBackups,
|
||||
} from '../localVaultBackups';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export function useLocalVaultBackups() {
|
||||
const [backups, setBackups] = useState<LocalVaultBackupPreview[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [maxBackups, setMaxBackupsState] = useState(() => getLocalVaultBackupMaxCount());
|
||||
// `null` while we're still asking the main process. The UI should treat
|
||||
// `null` as "unknown, don't render restore controls yet" so we never expose
|
||||
// a destructive action that might later be disabled.
|
||||
const [encryptionAvailable, setEncryptionAvailable] = useState<boolean | null>(null);
|
||||
|
||||
const refreshBackups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const next = await listLocalVaultBackups();
|
||||
setBackups(next);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const caps = await getLocalVaultBackupCapabilities();
|
||||
if (!cancelled) {
|
||||
setEncryptionAvailable(caps.encryptionAvailable);
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setEncryptionAvailable(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
void refreshBackups();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [refreshBackups]);
|
||||
|
||||
// Cross-window live refresh: the main process broadcasts when any
|
||||
// renderer's createBackup or trimBackups actually mutated the on-disk
|
||||
// set. Without this subscription, a protective backup written by the
|
||||
// main window wouldn't show up in the Settings window's list until
|
||||
// the user manually navigated away and back, silently under-reporting
|
||||
// the most recent recovery points.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
const subscribe = bridge?.onVaultBackupsChanged;
|
||||
if (typeof subscribe !== 'function') return undefined;
|
||||
const unsubscribe = subscribe(() => {
|
||||
void refreshBackups();
|
||||
});
|
||||
return () => {
|
||||
try { unsubscribe?.(); } catch { /* ignore */ }
|
||||
};
|
||||
}, [refreshBackups]);
|
||||
|
||||
const updateMaxBackups = useCallback(async (value: number) => {
|
||||
const sanitized = setLocalVaultBackupMaxCount(value);
|
||||
setMaxBackupsState(sanitized);
|
||||
await trimLocalVaultBackups(sanitized);
|
||||
await refreshBackups();
|
||||
return sanitized;
|
||||
}, [refreshBackups]);
|
||||
|
||||
const openBackupDirectory = useCallback(async () => {
|
||||
await openLocalVaultBackupDir();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
backups,
|
||||
isLoading,
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
readBackup: readLocalVaultBackup,
|
||||
setMaxBackups: updateMaxBackups,
|
||||
openBackupDirectory,
|
||||
};
|
||||
}
|
||||
|
||||
export default useLocalVaultBackups;
|
||||
@@ -102,6 +102,7 @@ const safeParse = <T,>(value: string | null): T | null => {
|
||||
};
|
||||
|
||||
export const useVaultState = () => {
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [identities, setIdentities] = useState<Identity[]>([]);
|
||||
@@ -339,129 +340,133 @@ export const useVaultState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
try {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
setGroupConfigs(decryptedGC);
|
||||
encryptGroupConfigs(decryptedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
|
||||
// Load group configs
|
||||
const savedGroupConfigs = localStorageAdapter.read<GroupConfig[]>(STORAGE_KEY_GROUP_CONFIGS);
|
||||
if (savedGroupConfigs) {
|
||||
const gcVer = ++groupConfigsWriteVersion.current;
|
||||
const decryptedGC = await decryptGroupConfigs(savedGroupConfigs);
|
||||
if (gcVer === groupConfigsWriteVersion.current) {
|
||||
setGroupConfigs(decryptedGC);
|
||||
encryptGroupConfigs(decryptedGC).then((enc) => {
|
||||
if (gcVer === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setIsInitialized(true);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -657,6 +662,7 @@ export const useVaultState = () => {
|
||||
);
|
||||
|
||||
return {
|
||||
isInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
|
||||
@@ -63,6 +63,29 @@ export interface SyncableVaultData {
|
||||
groupConfigs?: GroupConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the payload contains any meaningful user data worth
|
||||
* protecting or syncing.
|
||||
*/
|
||||
export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
const hasEntities =
|
||||
(payload.hosts?.length ?? 0) > 0 ||
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
(payload.knownHosts?.length ?? 0) > 0 ||
|
||||
(payload.groupConfigs?.length ?? 0) > 0;
|
||||
|
||||
if (hasEntities) return true;
|
||||
|
||||
return Boolean(
|
||||
payload.settings && Object.values(payload.settings).some((value) => value !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data (hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts). */
|
||||
|
||||
@@ -19,8 +19,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { cn } from '../lib/utils';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useWindowControls } from '../application/state/useWindowControls';
|
||||
import { useFileUpload } from '../application/state/useFileUpload';
|
||||
import type {
|
||||
AIDraft,
|
||||
AIPanelView,
|
||||
AIPermissionMode,
|
||||
AIToolIntegrationMode,
|
||||
AISession,
|
||||
@@ -45,6 +46,19 @@ import {
|
||||
getNextSelectedUserSkillSlugsMap,
|
||||
type UserSkillOption,
|
||||
} from './ai/userSkillsState';
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
} from './ai/aiPanelViewState';
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from './ai/draftSendGate';
|
||||
import { getSessionScopeMatchRank } from './ai/sessionScopeMatch';
|
||||
import { SESSION_HISTORY_ROW_CLASSNAMES } from './ai/sessionHistoryLayout';
|
||||
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
|
||||
import {
|
||||
useAIChatStreaming,
|
||||
getNetcattyBridge,
|
||||
@@ -76,12 +90,24 @@ interface AIChatSidePanelProps {
|
||||
// Session state (per-scope)
|
||||
sessions: AISession[];
|
||||
activeSessionIdMap: Record<string, string | null>;
|
||||
draftsByScope: Partial<Record<string, AIDraft>>;
|
||||
panelViewByScope: Partial<Record<string, AIPanelView>>;
|
||||
setActiveSessionId: (scopeKey: string, id: string | null) => void;
|
||||
ensureDraftForScope: (scopeKey: string, agentId: string) => void;
|
||||
updateDraft: (
|
||||
scopeKey: string,
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
) => void;
|
||||
showDraftView: (scopeKey: string) => void;
|
||||
showSessionView: (scopeKey: string, sessionId: string) => void;
|
||||
clearDraftForScope: (scopeKey: string) => void;
|
||||
addDraftFiles: (scopeKey: string, fallbackAgentId: string, inputFiles: File[]) => Promise<void>;
|
||||
removeDraftFile: (scopeKey: string, fallbackAgentId: string, fileId: string) => void;
|
||||
createSession: (scope: AISessionScope, agentId?: string) => AISession;
|
||||
deleteSession: (sessionId: string, scopeKey?: string) => void;
|
||||
updateSessionTitle: (sessionId: string, title: string) => void;
|
||||
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
|
||||
retargetSessionScope: (sessionId: string, scope: AISessionScope) => void;
|
||||
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
|
||||
updateLastMessage: (
|
||||
sessionId: string,
|
||||
@@ -180,27 +206,6 @@ function buildAcpHistoryMessages(messages: ChatMessage[]): Array<{ role: 'user'
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionScopeMatchRank(
|
||||
session: AISession,
|
||||
scopeType: 'terminal' | 'workspace',
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
activeTerminalTargetIds?: Set<string>,
|
||||
): number {
|
||||
if (session.scope.type !== scopeType) return 0;
|
||||
if (session.scope.targetId === scopeTargetId) return 2;
|
||||
|
||||
if (scopeType !== 'terminal' || !scopeHostIds?.length || !session.scope.hostIds?.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (session.scope.targetId && activeTerminalTargetIds?.has(session.scope.targetId)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Component
|
||||
// -------------------------------------------------------------------
|
||||
@@ -208,12 +213,20 @@ function getSessionScopeMatchRank(
|
||||
const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
sessions,
|
||||
activeSessionIdMap,
|
||||
draftsByScope,
|
||||
panelViewByScope,
|
||||
setActiveSessionId: setActiveSessionIdForScope,
|
||||
ensureDraftForScope,
|
||||
updateDraft,
|
||||
showDraftView,
|
||||
showSessionView,
|
||||
clearDraftForScope,
|
||||
addDraftFiles,
|
||||
removeDraftFile,
|
||||
createSession,
|
||||
deleteSession,
|
||||
updateSessionTitle,
|
||||
updateSessionExternalSessionId,
|
||||
retargetSessionScope,
|
||||
addMessageToSession,
|
||||
updateLastMessage,
|
||||
updateMessageById,
|
||||
@@ -244,20 +257,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
// Derive scope key for per-scope isolation
|
||||
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
|
||||
|
||||
// Per-scope input values
|
||||
const [inputValueMap, setInputValueMap] = useState<Record<string, string>>({});
|
||||
const inputValue = inputValueMap[scopeKey] ?? '';
|
||||
const setInputValue = useCallback((val: string) => {
|
||||
setInputValueMap(prev => ({ ...prev, [scopeKey]: val }));
|
||||
}, [scopeKey]);
|
||||
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [currentAgentId, setCurrentAgentId] = useState(defaultAgentId);
|
||||
const [runtimeAgentModelPresets, setRuntimeAgentModelPresets] = useState<Record<string, ReturnType<typeof getAgentModelPresets>>>({});
|
||||
const [userSkillOptions, setUserSkillOptions] = useState<UserSkillOption[]>([]);
|
||||
const [selectedUserSkillSlugsMap, setSelectedUserSkillSlugsMap] = useState<Record<string, string[]>>({});
|
||||
|
||||
const { files, addFiles, removeFile, clearFiles } = useFileUpload();
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
const terminalSessionsRef = useRef(terminalSessions);
|
||||
terminalSessionsRef.current = terminalSessions;
|
||||
@@ -279,46 +281,63 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
updateMessageById,
|
||||
});
|
||||
|
||||
|
||||
// Per-scope active session ID
|
||||
const activeSessionIdForScope = activeSessionIdMap[scopeKey] ?? null;
|
||||
const setActiveSessionId = useCallback((id: string | null) => {
|
||||
setActiveSessionIdForScope(scopeKey, id);
|
||||
}, [scopeKey, setActiveSessionIdForScope]);
|
||||
|
||||
const activeTerminalTargetIds = useMemo(() => {
|
||||
const targetIds = new Set<string>();
|
||||
for (const [sessionScopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
const activeTerminalSessionIds = useMemo(() => {
|
||||
const sessionIds = new Set<string>();
|
||||
const entries = Object.entries(activeSessionIdMap) as Array<[string, string | null]>;
|
||||
for (const [sessionScopeKey, sessionId] of entries) {
|
||||
if (!sessionScopeKey.startsWith('terminal:') || !sessionId) continue;
|
||||
const targetId = sessionScopeKey.slice('terminal:'.length);
|
||||
if (!targetId || targetId === scopeTargetId) continue;
|
||||
targetIds.add(targetId);
|
||||
if (sessionScopeKey === scopeKey) continue;
|
||||
sessionIds.add(sessionId);
|
||||
}
|
||||
return targetIds;
|
||||
}, [activeSessionIdMap, scopeTargetId]);
|
||||
return sessionIds;
|
||||
}, [activeSessionIdMap, scopeKey]);
|
||||
|
||||
const historySessions = useMemo(
|
||||
() =>
|
||||
sessions
|
||||
.map((session) => ({
|
||||
session,
|
||||
matchRank: getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds),
|
||||
matchRank: getSessionScopeMatchRank(
|
||||
session,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
activeTerminalSessionIds,
|
||||
),
|
||||
}))
|
||||
.filter(({ matchRank }) => matchRank > 0)
|
||||
.sort((a, b) => b.matchRank - a.matchRank || b.session.updatedAt - a.session.updatedAt)
|
||||
.map(({ session }) => session),
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds],
|
||||
[sessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalSessionIds],
|
||||
);
|
||||
|
||||
const activeSession = useMemo(() => {
|
||||
if (activeSessionIdForScope) {
|
||||
const session = sessions.find((s) => s.id === activeSessionIdForScope);
|
||||
if (session && getSessionScopeMatchRank(session, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds) > 0) {
|
||||
return session;
|
||||
}
|
||||
}
|
||||
return historySessions[0] ?? null;
|
||||
}, [sessions, activeSessionIdForScope, historySessions, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
const explicitPanelView = panelViewByScope[scopeKey];
|
||||
const currentDraft = draftsByScope[scopeKey] ?? null;
|
||||
const persistedSessionId = activeSessionIdMap[scopeKey] ?? null;
|
||||
const normalizedPanelView = useMemo<AIPanelView>(
|
||||
() => resolveDisplayedPanelView(explicitPanelView, currentDraft != null, historySessions, persistedSessionId, scopeType),
|
||||
[explicitPanelView, currentDraft, historySessions, persistedSessionId, scopeType],
|
||||
);
|
||||
const activeSession = useMemo(
|
||||
() => resolveDisplayedSession(normalizedPanelView, historySessions),
|
||||
[normalizedPanelView, historySessions],
|
||||
);
|
||||
const activeSessionId = normalizedPanelView.mode === 'session' ? normalizedPanelView.sessionId : null;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
const currentAgentId = activeSession?.agentId ?? currentDraft?.agentId ?? defaultAgentId;
|
||||
const inputValue = currentDraft?.text ?? '';
|
||||
const files = currentDraft?.attachments ?? [];
|
||||
const panelViewRef = useRef(normalizedPanelView);
|
||||
panelViewRef.current = normalizedPanelView;
|
||||
const currentDraftRef = useRef(currentDraft);
|
||||
currentDraftRef.current = currentDraft;
|
||||
const activeSessionRef = useRef(activeSession);
|
||||
activeSessionRef.current = activeSession;
|
||||
const draftSendInFlightRef = useRef(false);
|
||||
|
||||
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
|
||||
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
|
||||
@@ -343,77 +362,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return undefined;
|
||||
}, [terminalSessions, scopeType, scopeTargetId]);
|
||||
|
||||
const activeSessionId = activeSession?.id ?? activeSessionIdForScope;
|
||||
const isStreaming = activeSessionId ? streamingSessionIds.has(activeSessionId) : false;
|
||||
|
||||
const shouldRetargetActiveSession = useMemo(() => {
|
||||
if (!activeSession || scopeType !== 'terminal' || !scopeTargetId || !scopeHostIds?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (activeSession.scope.type !== scopeType || activeSession.scope.targetId === scopeTargetId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Don't retarget sessions that are actively owned by another terminal
|
||||
if (activeSession.scope.targetId && activeTerminalTargetIds.has(activeSession.scope.targetId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return activeSession.scope.hostIds?.some((hostId) => scopeHostIds.includes(hostId)) ?? false;
|
||||
}, [activeSession, scopeType, scopeTargetId, scopeHostIds, activeTerminalTargetIds]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (shouldRetargetActiveSession && isVisible) {
|
||||
// Full cleanup of any in-flight work — the session came from a disconnected
|
||||
// terminal, so any active response, pending approvals, or exec is dead.
|
||||
if (streamingSessionIds.has(activeSession.id)) {
|
||||
const controller = abortControllersRef.current.get(activeSession.id);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
abortControllersRef.current.delete(activeSession.id);
|
||||
}
|
||||
setStreamingForScope(activeSession.id, false);
|
||||
clearAllPendingApprovals(activeSession.id);
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSession.id);
|
||||
bridge?.aiAcpCancel?.('', activeSession.id);
|
||||
}
|
||||
retargetSessionScope(activeSession.id, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVisible && activeSessionIdForScope !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
}, [
|
||||
activeSession,
|
||||
activeSessionIdForScope,
|
||||
retargetSessionScope,
|
||||
isVisible,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
setStreamingForScope,
|
||||
shouldRetargetActiveSession,
|
||||
streamingSessionIds,
|
||||
abortControllersRef,
|
||||
]);
|
||||
|
||||
// Restore agent selector from active session when scope changes
|
||||
useEffect(() => {
|
||||
if (activeSession) {
|
||||
setCurrentAgentId(activeSession.agentId);
|
||||
}
|
||||
}, [scopeKey, activeSession]);
|
||||
|
||||
// Proactively sync terminal session metadata to main process whenever scope or sessions change
|
||||
useEffect(() => {
|
||||
const bridge = getNetcattyBridge();
|
||||
@@ -422,6 +370,85 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}
|
||||
}, [terminalSessions, scopeKey, activeSessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!explicitPanelView || normalizedPanelView === explicitPanelView) return;
|
||||
showDraftView(scopeKey);
|
||||
}, [normalizedPanelView, explicitPanelView, scopeKey, showDraftView]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeSession) return;
|
||||
|
||||
if (isVisible && activeSessionIdMap[scopeKey] !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
}, [
|
||||
activeSession,
|
||||
activeSessionIdMap,
|
||||
scopeKey,
|
||||
isVisible,
|
||||
setActiveSessionId,
|
||||
]);
|
||||
|
||||
// When the resolved view is draft but activeSessionIdMap still points at a
|
||||
// previously-shown session, clear that stale entry. Otherwise
|
||||
// activeTerminalTargetIds keeps claiming ownership of the old session's
|
||||
// target and getSessionScopeMatchRank suppresses matching history from
|
||||
// other terminals until another action rewrites the map.
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
if (normalizedPanelView.mode !== 'draft') return;
|
||||
if (persistedSessionId == null) return;
|
||||
setActiveSessionId(null);
|
||||
}, [isVisible, normalizedPanelView.mode, persistedSessionId, setActiveSessionId]);
|
||||
|
||||
const ensureScopeDraft = useCallback((agentId: string) => {
|
||||
ensureDraftForScope(scopeKey, agentId);
|
||||
}, [ensureDraftForScope, scopeKey]);
|
||||
|
||||
const updateScopeDraft = useCallback((
|
||||
fallbackAgentId: string,
|
||||
updater: (draft: AIDraft) => AIDraft,
|
||||
) => {
|
||||
updateDraft(scopeKey, fallbackAgentId, updater);
|
||||
}, [scopeKey, updateDraft]);
|
||||
|
||||
const showScopeDraftView = useCallback(() => {
|
||||
showDraftView(scopeKey);
|
||||
}, [scopeKey, showDraftView]);
|
||||
|
||||
const showScopeSessionView = useCallback((sessionId: string) => {
|
||||
showSessionView(scopeKey, sessionId);
|
||||
}, [scopeKey, showSessionView]);
|
||||
|
||||
const clearScopeDraft = useCallback(() => {
|
||||
clearDraftForScope(scopeKey);
|
||||
}, [clearDraftForScope, scopeKey]);
|
||||
|
||||
const enterScopeDraftMode = useCallback((agentId: string, preserveSessionView = false) => {
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => ensureScopeDraft(agentId),
|
||||
showDraftView: showScopeDraftView,
|
||||
preserveSessionView,
|
||||
});
|
||||
}, [ensureScopeDraft, showScopeDraftView]);
|
||||
|
||||
const setInputValue = useCallback((value: string) => {
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => ({
|
||||
...draft,
|
||||
text: value,
|
||||
}));
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
const addFiles = useCallback(async (inputFiles: File[]) => {
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
await addDraftFiles(scopeKey, currentAgentId, inputFiles);
|
||||
}, [addDraftFiles, currentAgentId, enterScopeDraftMode, scopeKey]);
|
||||
|
||||
const removeFile = useCallback((fileId: string) => {
|
||||
removeDraftFile(scopeKey, currentAgentId, fileId);
|
||||
}, [removeDraftFile, scopeKey, currentAgentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isVisible) return;
|
||||
|
||||
@@ -435,7 +462,30 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}> } | null | undefined) => {
|
||||
const nextOptions = getReadyUserSkillOptions(result);
|
||||
setUserSkillOptions(nextOptions);
|
||||
setSelectedUserSkillSlugsMap((prev) => getNextSelectedUserSkillSlugsMap(prev, result));
|
||||
|
||||
const draft = currentDraftRef.current;
|
||||
if (!draft) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextSelectedUserSkillSlugs =
|
||||
getNextSelectedUserSkillSlugsMap(
|
||||
{ [scopeKey]: draft.selectedUserSkillSlugs },
|
||||
result,
|
||||
)[scopeKey] ?? [];
|
||||
|
||||
const selectedUserSkillsChanged =
|
||||
nextSelectedUserSkillSlugs.length !== draft.selectedUserSkillSlugs.length
|
||||
|| nextSelectedUserSkillSlugs.some((slug, index) => slug !== draft.selectedUserSkillSlugs[index]);
|
||||
|
||||
if (!selectedUserSkillsChanged) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateScopeDraft(draft.agentId, (currentScopeDraft) => ({
|
||||
...currentScopeDraft,
|
||||
selectedUserSkillSlugs: nextSelectedUserSkillSlugs,
|
||||
}));
|
||||
};
|
||||
|
||||
const bridge = getNetcattyBridge();
|
||||
@@ -457,7 +507,7 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [activeSessionIdForScope, isVisible, toolIntegrationMode, scopeKey]);
|
||||
}, [isVisible, scopeKey, toolIntegrationMode, updateScopeDraft]);
|
||||
|
||||
// Sync provider configs to main process so it can decrypt API keys server-side.
|
||||
// Keys stay encrypted in transit; main process decrypts only when making HTTP requests.
|
||||
@@ -504,8 +554,8 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const messages = activeSession?.messages ?? [];
|
||||
const selectedUserSkillSlugs = useMemo(
|
||||
() => selectedUserSkillSlugsMap[scopeKey] ?? [],
|
||||
[selectedUserSkillSlugsMap, scopeKey],
|
||||
() => currentDraft?.selectedUserSkillSlugs ?? [],
|
||||
[currentDraft],
|
||||
);
|
||||
const selectedUserSkills = useMemo(
|
||||
() =>
|
||||
@@ -561,7 +611,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const bridge = getNetcattyBridge();
|
||||
if (!bridge?.aiCodexGetIntegration) return;
|
||||
let cancelled = false;
|
||||
void bridge.aiCodexGetIntegration().then((info) => {
|
||||
void Promise.resolve(
|
||||
bridge.aiCodexGetIntegration() as Promise<CodexIntegrationStatus>,
|
||||
).then((info) => {
|
||||
if (cancelled) return;
|
||||
const hasCustom = info?.state === 'connected_custom_config';
|
||||
setCodexConfigModel(info?.customConfig?.model ?? null);
|
||||
@@ -682,31 +734,17 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleNewChat = useCallback(() => {
|
||||
const scope: AISessionScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
};
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
clearScopeDraft();
|
||||
updateScopeDraft(currentAgentId, () => ({
|
||||
text: '',
|
||||
agentId: currentAgentId,
|
||||
attachments: [],
|
||||
selectedUserSkillSlugs: [],
|
||||
updatedAt: Date.now(),
|
||||
}));
|
||||
showScopeDraftView();
|
||||
setShowHistory(false);
|
||||
setInputValue('');
|
||||
setSelectedUserSkillSlugsMap((prev) => {
|
||||
if (!(scopeKey in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[scopeKey];
|
||||
return next;
|
||||
});
|
||||
}, [
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeHostIds,
|
||||
currentAgentId,
|
||||
createSession,
|
||||
setActiveSessionId,
|
||||
setInputValue,
|
||||
scopeKey,
|
||||
]);
|
||||
}, [clearScopeDraft, currentAgentId, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
void openSettingsWindow();
|
||||
@@ -720,12 +758,6 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const sessionsRef = useRef(sessions);
|
||||
sessionsRef.current = sessions;
|
||||
|
||||
/** Refs to avoid re-creating handleSend on every keystroke / image change. */
|
||||
const inputValueRef = useRef(inputValue);
|
||||
inputValueRef.current = inputValue;
|
||||
const filesRef = useRef(files);
|
||||
filesRef.current = files;
|
||||
|
||||
/** Auto-title a session from the first user message if untitled. */
|
||||
const autoTitleSession = useCallback((sessionId: string, text: string) => {
|
||||
const s = sessionsRef.current.find(x => x.id === sessionId);
|
||||
@@ -751,179 +783,182 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
const addSelectedUserSkill = useCallback((slug: string) => {
|
||||
const normalizedSlug = String(slug || '').trim().toLowerCase();
|
||||
if (!normalizedSlug) return;
|
||||
setSelectedUserSkillSlugsMap((prev) => {
|
||||
const current = prev[scopeKey] ?? [];
|
||||
if (current.includes(normalizedSlug)) return prev;
|
||||
return { ...prev, [scopeKey]: [...current, normalizedSlug] };
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => {
|
||||
if (draft.selectedUserSkillSlugs.includes(normalizedSlug)) {
|
||||
return draft;
|
||||
}
|
||||
return {
|
||||
...draft,
|
||||
selectedUserSkillSlugs: [...draft.selectedUserSkillSlugs, normalizedSlug],
|
||||
};
|
||||
});
|
||||
}, [scopeKey]);
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
const removeSelectedUserSkill = useCallback((slug: string) => {
|
||||
const normalizedSlug = String(slug || '').trim().toLowerCase();
|
||||
if (!normalizedSlug) return;
|
||||
setSelectedUserSkillSlugsMap((prev) => {
|
||||
const current = prev[scopeKey] ?? [];
|
||||
const nextSkills = current.filter((entry) => entry !== normalizedSlug);
|
||||
if (nextSkills.length === current.length) return prev;
|
||||
if (nextSkills.length === 0) {
|
||||
const next = { ...prev };
|
||||
delete next[scopeKey];
|
||||
return next;
|
||||
enterScopeDraftMode(currentAgentId, panelViewRef.current.mode === 'session');
|
||||
updateScopeDraft(currentAgentId, (draft) => {
|
||||
const nextSelectedUserSkillSlugs = draft.selectedUserSkillSlugs.filter(
|
||||
(entry) => entry !== normalizedSlug,
|
||||
);
|
||||
if (nextSelectedUserSkillSlugs.length === draft.selectedUserSkillSlugs.length) {
|
||||
return draft;
|
||||
}
|
||||
return { ...prev, [scopeKey]: nextSkills };
|
||||
return {
|
||||
...draft,
|
||||
selectedUserSkillSlugs: nextSelectedUserSkillSlugs,
|
||||
};
|
||||
});
|
||||
}, [scopeKey]);
|
||||
|
||||
const clearSelectedUserSkills = useCallback(() => {
|
||||
setSelectedUserSkillSlugsMap((prev) => {
|
||||
if (!(scopeKey in prev)) return prev;
|
||||
const next = { ...prev };
|
||||
delete next[scopeKey];
|
||||
return next;
|
||||
});
|
||||
}, [scopeKey]);
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSession && sessionsRef.current.some((session) => session.id === activeSession.id)) {
|
||||
if (shouldRetargetActiveSession) {
|
||||
retargetSessionScope(activeSession.id, {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
hostIds: scopeHostIds,
|
||||
});
|
||||
} else if (activeSessionIdForScope !== activeSession.id) {
|
||||
setActiveSessionId(activeSession.id);
|
||||
}
|
||||
return activeSession.id;
|
||||
}
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
return session.id;
|
||||
}, [
|
||||
activeSession,
|
||||
activeSessionIdForScope,
|
||||
createSession,
|
||||
currentAgentId,
|
||||
retargetSessionScope,
|
||||
scopeHostIds,
|
||||
scopeTargetId,
|
||||
scopeType,
|
||||
setActiveSessionId,
|
||||
shouldRetargetActiveSession,
|
||||
]);
|
||||
}, [currentAgentId, enterScopeDraftMode, updateScopeDraft]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Main send handler (thin orchestrator)
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
const handleSend = useCallback(async () => {
|
||||
const trimmed = inputValueRef.current.trim();
|
||||
const draft = currentDraftRef.current;
|
||||
const currentPanelView = panelViewRef.current;
|
||||
const currentSessionView = activeSessionRef.current;
|
||||
const trimmed = draft?.text.trim() ?? '';
|
||||
const sendScopeKey = scopeKey;
|
||||
// Double-submit protection currently relies on the draft being cleared
|
||||
// immediately after the first send path starts; `isStreaming` alone does
|
||||
// not protect the initial draft->session transition.
|
||||
if (!trimmed || isStreaming) return;
|
||||
const selectedSkillSlugs = selectedUserSkillSlugs;
|
||||
const selectedSkillSlugs = draft?.selectedUserSkillSlugs ?? [];
|
||||
const attachments = (draft?.attachments ?? []).map((file) => ({
|
||||
base64Data: file.base64Data,
|
||||
mediaType: file.mediaType,
|
||||
filename: file.filename,
|
||||
filePath: file.filePath,
|
||||
}));
|
||||
const isDraftMode = currentPanelView.mode === 'draft';
|
||||
|
||||
const isExternalAgent = currentAgentId !== 'catty';
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
const errSessionId = ensureSession();
|
||||
addMessageToSession(errSessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(errSessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
setInputValue('');
|
||||
if (isDraftMode && !tryBeginDraftSend(draftSendInFlightRef)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure session exists
|
||||
const sessionId = ensureSession();
|
||||
try {
|
||||
let sessionId = currentSessionView?.id ?? null;
|
||||
let currentSession = currentSessionView ?? null;
|
||||
const sendAgentId = currentSessionView?.agentId ?? draft?.agentId ?? currentAgentId;
|
||||
|
||||
// Capture images before clearing
|
||||
const attachments = filesRef.current.map(f => ({ base64Data: f.base64Data, mediaType: f.mediaType, filename: f.filename, filePath: f.filePath }));
|
||||
if (isDraftMode) {
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const createdSession = createSession(scope, sendAgentId);
|
||||
sessionId = createdSession.id;
|
||||
currentSession = createdSession;
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(createdSession.id);
|
||||
setActiveSessionId(createdSession.id);
|
||||
}
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
setInputValue('');
|
||||
clearFiles();
|
||||
clearSelectedUserSkills();
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find(a => a.id === currentAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, abortController);
|
||||
const currentSession = sessionsRef.current.find(s => s.id === sessionId);
|
||||
|
||||
if (isExternalAgent) {
|
||||
if (!agentConfig) {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
|
||||
setStreamingForScope(sessionId, false);
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
|
||||
const isExternalAgent = sendAgentId !== 'catty';
|
||||
|
||||
// No provider configured for built-in agent
|
||||
if (!isExternalAgent && !activeProvider) {
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'user', content: trimmed, timestamp: Date.now() });
|
||||
addMessageToSession(sessionId, { id: generateId(), role: 'assistant', content: t('ai.chat.noProvider'), timestamp: Date.now() });
|
||||
if (currentPanelView.mode === 'session') {
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Add user message
|
||||
addMessageToSession(sessionId, {
|
||||
id: generateId(), role: 'user', content: trimmed,
|
||||
...(attachments.length > 0 ? { attachments } : {}),
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
clearScopeDraft();
|
||||
showScopeSessionView(sessionId);
|
||||
setActiveSessionId(sessionId);
|
||||
setStreamingForScope(sessionId, true);
|
||||
|
||||
// Create assistant message placeholder with a tracked ID
|
||||
const agentConfig = isExternalAgent ? externalAgents.find((agent) => agent.id === sendAgentId) : undefined;
|
||||
const assistantMsgId = generateId();
|
||||
addMessageToSession(sessionId, {
|
||||
id: assistantMsgId, role: 'assistant', content: '', timestamp: Date.now(),
|
||||
model: isExternalAgent
|
||||
? (selectedAgentModel || agentConfig?.name || 'external')
|
||||
: (activeModelId || activeProvider?.defaultModel || ''),
|
||||
providerId: isExternalAgent ? undefined : activeProvider?.providerId,
|
||||
});
|
||||
|
||||
const abortController = new AbortController();
|
||||
abortControllersRef.current.set(sessionId, abortController);
|
||||
currentSession = currentSession ?? sessionsRef.current.find((session) => session.id === sessionId) ?? null;
|
||||
|
||||
if (isExternalAgent) {
|
||||
if (!agentConfig) {
|
||||
updateMessageById(sessionId, assistantMsgId, msg => ({ ...msg, content: 'External agent not found. Please check settings.', executionStatus: 'failed' }));
|
||||
setStreamingForScope(sessionId, false);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sendToExternalAgent(sessionId, trimmed, agentConfig, abortController, attachments, {
|
||||
existingSessionId: currentSession?.externalSessionId,
|
||||
updateExternalSessionId: updateSessionExternalSessionId,
|
||||
historyMessages: buildAcpHistoryMessages(currentSession?.messages ?? []),
|
||||
terminalSessions,
|
||||
defaultTargetSession,
|
||||
providers,
|
||||
selectedAgentModel,
|
||||
toolIntegrationMode,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
});
|
||||
} catch (err) {
|
||||
reportStreamError(sessionId, abortController.signal, err);
|
||||
}
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
} finally {
|
||||
if (isDraftMode) {
|
||||
endDraftSend(draftSendInFlightRef);
|
||||
}
|
||||
// Clear any lingering statusText when the external agent stream finishes
|
||||
updateLastMessage(sessionId, msg => msg.statusText ? { ...msg, statusText: '' } : msg);
|
||||
setStreamingForScope(sessionId, false);
|
||||
abortControllersRef.current.delete(sessionId);
|
||||
autoTitleSession(sessionId, trimmed);
|
||||
} else {
|
||||
const toolScope = {
|
||||
type: scopeType,
|
||||
targetId: scopeTargetId,
|
||||
label: scopeLabel,
|
||||
} as const;
|
||||
await sendToCattyAgent(sessionId, sendScopeKey, trimmed, abortController, currentSession ?? undefined, assistantMsgId, {
|
||||
activeProvider,
|
||||
activeModelId,
|
||||
scopeType,
|
||||
scopeTargetId,
|
||||
scopeLabel,
|
||||
globalPermissionMode,
|
||||
commandBlocklist,
|
||||
terminalSessions,
|
||||
webSearchConfig,
|
||||
getExecutorContext: () => buildExecutorContextForScope(toolScope),
|
||||
autoTitleSession,
|
||||
selectedUserSkillSlugs: selectedSkillSlugs,
|
||||
}, attachments.length > 0 ? attachments : undefined);
|
||||
}
|
||||
}, [
|
||||
isStreaming, activeProvider, scopeKey, currentAgentId,
|
||||
activeModelId, externalAgents,
|
||||
ensureSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope, setInputValue, clearFiles,
|
||||
createSession, addMessageToSession, updateMessageById, updateLastMessage,
|
||||
setStreamingForScope,
|
||||
sendToExternalAgent, sendToCattyAgent, reportStreamError, autoTitleSession, t,
|
||||
abortControllersRef, terminalSessions, defaultTargetSession, providers, selectedAgentModel, updateSessionExternalSessionId,
|
||||
scopeType, scopeTargetId, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
|
||||
scopeType, scopeTargetId, scopeHostIds, scopeLabel, globalPermissionMode, commandBlocklist, webSearchConfig, buildExecutorContextForScope,
|
||||
toolIntegrationMode,
|
||||
selectedUserSkillSlugs, clearSelectedUserSkills,
|
||||
clearScopeDraft, showScopeSessionView, setActiveSessionId,
|
||||
]);
|
||||
|
||||
const handleStop = useCallback(() => {
|
||||
@@ -948,15 +983,13 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
(sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
// Restore agent selector to match the session's bound agent
|
||||
const session = sessions.find((s) => s.id === sessionId);
|
||||
if (session) {
|
||||
setCurrentAgentId(session.agentId);
|
||||
}
|
||||
setShowHistory(false);
|
||||
applyHistorySessionSelection(sessionId, {
|
||||
showSessionView: showScopeSessionView,
|
||||
setActiveSessionId,
|
||||
closeHistory: () => setShowHistory(false),
|
||||
});
|
||||
},
|
||||
[setActiveSessionId, sessions],
|
||||
[setActiveSessionId, showScopeSessionView],
|
||||
);
|
||||
|
||||
const handleDeleteSession = useCallback(
|
||||
@@ -969,12 +1002,14 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
);
|
||||
|
||||
const handleAgentChange = useCallback((agentId: string) => {
|
||||
setCurrentAgentId(agentId);
|
||||
// Preserve the current session in history and start a new one with the selected agent
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, agentId);
|
||||
setActiveSessionId(session.id);
|
||||
}, [scopeType, scopeTargetId, scopeHostIds, createSession, setActiveSessionId]);
|
||||
ensureScopeDraft(agentId);
|
||||
updateScopeDraft(agentId, (draft) => ({
|
||||
...draft,
|
||||
agentId,
|
||||
}));
|
||||
showScopeDraftView();
|
||||
setShowHistory(false);
|
||||
}, [ensureScopeDraft, showScopeDraftView, updateScopeDraft]);
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Render
|
||||
@@ -1153,20 +1188,20 @@ const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
|
||||
onClick={() => onSelect(session.id)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') onSelect(session.id); }}
|
||||
className={cn(
|
||||
'w-full flex items-center justify-between py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
SESSION_HISTORY_ROW_CLASSNAMES.row,
|
||||
isActive ? 'text-foreground' : 'text-foreground/70 hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className="text-[13px] truncate pr-3 flex-1 min-w-0">
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
|
||||
{session.title || t('ai.chat.untitled')}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<span className="text-[12px] text-muted-foreground/50">
|
||||
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
|
||||
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
|
||||
{timeStr}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => onDelete(e, session.id)}
|
||||
className="opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer"
|
||||
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Download,
|
||||
Database,
|
||||
ExternalLink,
|
||||
FolderOpen,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Github,
|
||||
@@ -32,6 +33,12 @@ import {
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { useCloudSync } from '../application/state/useCloudSync';
|
||||
import { useLocalVaultBackups } from '../application/state/useLocalVaultBackups';
|
||||
import {
|
||||
MAX_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
MIN_LOCAL_VAULT_BACKUP_MAX_COUNT,
|
||||
withRestoreBarrier,
|
||||
} from '../application/localVaultBackups';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
@@ -628,10 +635,395 @@ const ConflictModal: React.FC<ConflictModalProps> = ({
|
||||
|
||||
interface SyncDashboardProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
interface LocalBackupsPanelProps {
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
/**
|
||||
* When true, the panel hides the Restore button entirely — e.g. while the
|
||||
* master key has not been configured yet, a restore would land credentials
|
||||
* on disk in plaintext (I3). Listing is still allowed so users can see that
|
||||
* their history exists.
|
||||
*/
|
||||
restoreDisabledReason?: 'no-master-key' | null;
|
||||
}
|
||||
|
||||
const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
|
||||
onApplyPayload,
|
||||
restoreDisabledReason = null,
|
||||
}) => {
|
||||
const { t, resolvedLocale } = useI18n();
|
||||
const {
|
||||
backups,
|
||||
isLoading,
|
||||
maxBackups,
|
||||
encryptionAvailable,
|
||||
refreshBackups,
|
||||
readBackup,
|
||||
setMaxBackups,
|
||||
openBackupDirectory,
|
||||
} = useLocalVaultBackups();
|
||||
const [maxBackupsInput, setMaxBackupsInput] = useState(String(maxBackups));
|
||||
const [isSavingMaxBackups, setIsSavingMaxBackups] = useState(false);
|
||||
const [restoringBackupId, setRestoringBackupId] = useState<string | null>(null);
|
||||
// Backup chosen in the list but not yet confirmed. A two-step flow keeps
|
||||
// users from wiping their vault with a single accidental click (I2).
|
||||
const [pendingRestoreBackup, setPendingRestoreBackup] = useState<
|
||||
(typeof backups)[number] | null
|
||||
>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setMaxBackupsInput(String(maxBackups));
|
||||
}, [maxBackups]);
|
||||
|
||||
const formatTimestamp = (timestamp: number) =>
|
||||
new Date(timestamp).toLocaleString(resolvedLocale || undefined);
|
||||
|
||||
const getReasonLabel = (reason: 'app_version_change' | 'before_restore') =>
|
||||
reason === 'app_version_change'
|
||||
? t('cloudSync.localBackups.reason.appVersionChange')
|
||||
: t('cloudSync.localBackups.reason.beforeRestore');
|
||||
|
||||
const handleSaveMaxBackups = async () => {
|
||||
// Validate BEFORE calling setMaxBackups, which hands off to the
|
||||
// renderer's `sanitizeLocalVaultBackupMaxCount` clamp. Two failure
|
||||
// modes must be surfaced rather than silently clamped, because
|
||||
// both produce a misleading "saved" toast:
|
||||
//
|
||||
// 1. Empty / non-numeric input — `Number("")` coerces to 0 and
|
||||
// sanitize clamps to the default (20). A user who meant to
|
||||
// clear the field then re-type would see their retention
|
||||
// silently reset to 20 with a success message.
|
||||
//
|
||||
// 2. Out-of-range input (e.g. 500) — sanitize clamps to 100 and
|
||||
// still reports success, but the visible error string says
|
||||
// "between 1 and 100", so the user has no idea their value
|
||||
// was changed. Reject explicitly instead.
|
||||
//
|
||||
// The 1..MAX range check mirrors the main-process `sanitizeMaxCount`
|
||||
// in vaultBackupBridge.cjs so renderer and bridge agree.
|
||||
const parsed = Number(maxBackupsInput);
|
||||
const inRange =
|
||||
Number.isFinite(parsed) &&
|
||||
parsed >= MIN_LOCAL_VAULT_BACKUP_MAX_COUNT &&
|
||||
parsed <= MAX_LOCAL_VAULT_BACKUP_MAX_COUNT;
|
||||
if (!inRange || maxBackupsInput.trim() === '') {
|
||||
toast.error(
|
||||
t('cloudSync.localBackups.maxInvalid'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsSavingMaxBackups(true);
|
||||
try {
|
||||
const next = await setMaxBackups(parsed);
|
||||
setMaxBackupsInput(String(next));
|
||||
toast.success(t('cloudSync.localBackups.maxSaved', { count: String(next) }));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
} finally {
|
||||
setIsSavingMaxBackups(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenBackupDirectory = async () => {
|
||||
try {
|
||||
await openBackupDirectory();
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('sync.toast.errorTitle'),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const performRestore = async (backupId: string) => {
|
||||
setRestoringBackupId(backupId);
|
||||
try {
|
||||
// Hold the cross-window restore barrier around both the load
|
||||
// and the apply so another window's auto-sync cannot push a
|
||||
// pre-restore snapshot concurrently. See `withRestoreBarrier`
|
||||
// in application/localVaultBackups.ts for the read-side in
|
||||
// useAutoSync.
|
||||
//
|
||||
// In-memory React state refresh is implicit: `onApplyPayload`
|
||||
// (supplied by the hosting screen) routes through
|
||||
// `applySyncPayload` → `importDataFromString` → store writes
|
||||
// → the hook-store listeners in `useVaultState` /
|
||||
// `useCustomThemes` / etc. We do NOT explicitly re-pull host
|
||||
// lists here because a future refactor that decouples those
|
||||
// stores from the apply path would silently break the UI
|
||||
// refresh in a way that's only visible after a manual
|
||||
// restart. Any change to that chain must either preserve
|
||||
// store-listener notification OR add an explicit
|
||||
// `rehydrateAllFromStorage` call here — do not assume
|
||||
// restore is "just" a payload swap.
|
||||
await withRestoreBarrier(async () => {
|
||||
const detail = await readBackup(backupId);
|
||||
if (!detail) {
|
||||
throw new Error(t('cloudSync.localBackups.restoreMissing'));
|
||||
}
|
||||
await Promise.resolve(onApplyPayload(detail.payload));
|
||||
});
|
||||
await refreshBackups();
|
||||
toast.success(t('cloudSync.localBackups.restoreSuccess'));
|
||||
} catch (error) {
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : t('common.unknownError'),
|
||||
t('cloudSync.localBackups.restoreFailedTitle'),
|
||||
);
|
||||
} finally {
|
||||
setRestoringBackupId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const restoreAllowed = restoreDisabledReason === null;
|
||||
// While encryptionAvailable is still `null` we're mid-probe — render the
|
||||
// restore button as disabled so the user never sees a path they can't
|
||||
// actually take (I1 surface). Once resolved, `false` hides the panel body
|
||||
// via the unavailable banner below.
|
||||
const encryptionResolved = encryptionAvailable !== null;
|
||||
const encryptionUsable = encryptionAvailable === true;
|
||||
|
||||
// safeStorage probe finished and returned "not available" → disable the
|
||||
// panel entirely; the main process refuses to write in this state (I1).
|
||||
if (encryptionResolved && !encryptionUsable) {
|
||||
return (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle size={16} />
|
||||
<span className="text-sm font-medium">
|
||||
{t('cloudSync.localBackups.unavailableTitle')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.unavailableDesc')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border bg-card p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||||
<div className="max-w-lg">
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.retentionTitle')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.retentionDesc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 md:min-w-[260px] md:shrink-0">
|
||||
<div className="flex items-end gap-2 md:justify-end">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={100}
|
||||
value={maxBackupsInput}
|
||||
onChange={(e) => setMaxBackupsInput(e.target.value)}
|
||||
className="w-28"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handleSaveMaxBackups()}
|
||||
disabled={isSavingMaxBackups}
|
||||
className="gap-2"
|
||||
>
|
||||
{isSavingMaxBackups && <Loader2 size={14} className="animate-spin" />}
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!restoreAllowed && (
|
||||
<div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 text-xs text-muted-foreground">
|
||||
<div className="flex items-center gap-2 text-amber-600 dark:text-amber-400 mb-1">
|
||||
<AlertTriangle size={14} />
|
||||
<span className="font-medium">
|
||||
{t('cloudSync.localBackups.lockedTitle')}
|
||||
</span>
|
||||
</div>
|
||||
{t('cloudSync.localBackups.lockedDesc')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border bg-card p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-medium">{t('cloudSync.localBackups.title')}</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.desc')}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void refreshBackups()}
|
||||
disabled={isLoading}
|
||||
className="gap-1"
|
||||
>
|
||||
<RefreshCw size={14} className={cn(isLoading && 'animate-spin')} />
|
||||
{t('settings.system.refresh')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => void handleOpenBackupDirectory()}
|
||||
className="gap-1"
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
{t('settings.system.openFolder')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{backups.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed border-border/60 p-4 text-sm text-muted-foreground">
|
||||
{t('cloudSync.localBackups.empty')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{backups.map((backup) => (
|
||||
<div
|
||||
key={backup.id}
|
||||
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>
|
||||
{backup.sourceAppVersion && backup.targetAppVersion && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('cloudSync.localBackups.versionChange', {
|
||||
from: backup.sourceAppVersion,
|
||||
to: backup.targetAppVersion,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
hosts: String(backup.preview.hostCount),
|
||||
keys: String(backup.preview.keyCount),
|
||||
snippets: String(backup.preview.snippetCount),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{restoreAllowed && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(backup)}
|
||||
// Disable every row while ANY restore is in
|
||||
// flight. Each restore runs a full
|
||||
// `applyProtectedSyncPayload` — multiple
|
||||
// localStorage writes + the apply-in-progress
|
||||
// sentinel. `withRestoreBarrier` serializes
|
||||
// across windows but does NOT serialize
|
||||
// same-window re-entry, so two overlapping
|
||||
// clicks here would interleave destructive
|
||||
// writes and the second run's sentinel-clear
|
||||
// could mask a still-partial first apply.
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId === backup.id ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restore')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Restore confirmation dialog (I2). Keeps the destructive action
|
||||
gated behind an explicit second click, mirroring the clear-local
|
||||
dialog elsewhere in this screen. */}
|
||||
<Dialog
|
||||
open={pendingRestoreBackup !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingRestoreBackup(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[440px] z-[70]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-destructive">
|
||||
<AlertTriangle size={20} />
|
||||
{t('cloudSync.localBackups.restoreConfirmTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('cloudSync.localBackups.restoreConfirmDesc')}
|
||||
</DialogDescription>
|
||||
</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>
|
||||
<div className="text-muted-foreground">
|
||||
{t('cloudSync.localBackups.counts', {
|
||||
hosts: String(pendingRestoreBackup.preview.hostCount),
|
||||
keys: String(pendingRestoreBackup.preview.keyCount),
|
||||
snippets: String(pendingRestoreBackup.preview.snippetCount),
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setPendingRestoreBackup(null)}
|
||||
disabled={restoringBackupId !== null}
|
||||
>
|
||||
{t('cloudSync.localBackups.restoreConfirmCancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
const target = pendingRestoreBackup;
|
||||
if (!target) return;
|
||||
setPendingRestoreBackup(null);
|
||||
await performRestore(target.id);
|
||||
}}
|
||||
disabled={restoringBackupId !== null}
|
||||
className="gap-2"
|
||||
>
|
||||
{restoringBackupId !== null ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Download size={14} />
|
||||
)}
|
||||
{t('cloudSync.localBackups.restoreConfirmButton')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
onBuildPayload,
|
||||
onApplyPayload,
|
||||
@@ -1012,7 +1404,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
if (result.success) {
|
||||
// Apply merged data if a three-way merge happened
|
||||
if (result.mergedPayload && onApplyPayload) {
|
||||
onApplyPayload(result.mergedPayload);
|
||||
await Promise.resolve(onApplyPayload(result.mergedPayload));
|
||||
}
|
||||
toast.success(t('cloudSync.sync.success', { provider }));
|
||||
} else if (result.conflictDetected) {
|
||||
@@ -1030,13 +1422,28 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
try {
|
||||
const payload = await sync.resolveConflict(resolution);
|
||||
if (payload && resolution === 'USE_REMOTE') {
|
||||
onApplyPayload(payload);
|
||||
// USE_REMOTE applies cloud data over local — same data-loss
|
||||
// shape as a local backup restore, so gate auto-sync in
|
||||
// every other window the same way.
|
||||
await withRestoreBarrier(async () => {
|
||||
await Promise.resolve(onApplyPayload(payload));
|
||||
});
|
||||
toast.success(t('cloudSync.resolve.downloaded'));
|
||||
} else if (resolution === 'USE_LOCAL') {
|
||||
// Re-sync with local data
|
||||
// Re-sync with local data. Hold the same cross-window
|
||||
// restore barrier that USE_REMOTE uses: without it, a
|
||||
// concurrent auto-sync tick in another window can slip
|
||||
// between our conflict resolution and the upload,
|
||||
// producing a second upload path with stale state that
|
||||
// races against this push. USE_LOCAL doesn't mutate the
|
||||
// renderer's in-memory state (no onApplyPayload call), so
|
||||
// the barrier is belt-and-suspenders against the other
|
||||
// window's push, not ours.
|
||||
const localPayload = onBuildPayload();
|
||||
if (!ensureSyncablePayload(localPayload)) return;
|
||||
await sync.syncNow(localPayload);
|
||||
await withRestoreBarrier(async () => {
|
||||
await sync.syncNow(localPayload);
|
||||
});
|
||||
toast.success(t('cloudSync.resolve.uploaded'));
|
||||
}
|
||||
setShowConflictModal(false);
|
||||
@@ -1094,9 +1501,14 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreRevision = () => {
|
||||
const handleRestoreRevision = async () => {
|
||||
if (!historyPreview) return;
|
||||
onApplyPayload(historyPreview.payload);
|
||||
// Gist revision restore is a destructive "replace local with cloud
|
||||
// snapshot" op — same shape as a local backup restore, same
|
||||
// cross-window race to block.
|
||||
await withRestoreBarrier(async () => {
|
||||
await Promise.resolve(onApplyPayload(historyPreview.payload));
|
||||
});
|
||||
toast.success(t('cloudSync.revisionHistory.restored'));
|
||||
setShowHistoryModal(false);
|
||||
setHistoryPreview(null);
|
||||
@@ -1327,6 +1739,10 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={onApplyPayload}
|
||||
/>
|
||||
|
||||
{/* Clear Local Data */}
|
||||
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -1955,7 +2371,7 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
|
||||
|
||||
interface CloudSyncSettingsProps {
|
||||
onBuildPayload: () => SyncPayload;
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
onApplyPayload: (payload: SyncPayload) => void | Promise<void>;
|
||||
onClearLocalData?: () => void;
|
||||
}
|
||||
|
||||
@@ -1965,7 +2381,19 @@ export const CloudSyncSettings: React.FC<CloudSyncSettingsProps> = (props) => {
|
||||
// Simplified UX: once a master key is configured, we auto-unlock via safeStorage
|
||||
// so users don't have to manage a separate LOCKED screen.
|
||||
if (securityState === 'NO_KEY') {
|
||||
return <GatekeeperScreen onSetupComplete={() => { }} />;
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<GatekeeperScreen onSetupComplete={() => { }} />
|
||||
{/* The master key is not configured yet. Expose the backup
|
||||
history for diagnostic purposes but refuse restores: the
|
||||
vault encryption layer can't re-protect the restored
|
||||
credentials until the user finishes master-key setup (I3). */}
|
||||
<LocalBackupsPanel
|
||||
onApplyPayload={props.onApplyPayload}
|
||||
restoreDisabledReason="no-master-key"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return <SyncDashboard {...props} />;
|
||||
|
||||
@@ -1663,6 +1663,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
isAlternateScreen={hasMouseTracking}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onPasteSelection={terminalContextActions.onPasteSelection}
|
||||
onSelectAll={terminalContextActions.onSelectAll}
|
||||
onClear={terminalContextActions.onClear}
|
||||
onSelectWord={terminalContextActions.onSelectWord}
|
||||
|
||||
@@ -41,7 +41,7 @@ import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { cleanupOrphanedAISessions, useAIState } from '../application/state/useAIState';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
@@ -260,6 +260,10 @@ interface AIChatPanelsHostProps {
|
||||
}) => ExecutorContext;
|
||||
}
|
||||
|
||||
interface AIStateMaintenanceHostProps {
|
||||
validAIScopeTargetIds: Set<string>;
|
||||
}
|
||||
|
||||
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const aiState = useAIState();
|
||||
return (
|
||||
@@ -272,6 +276,27 @@ const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
const AIStateProvider = memo(AIStateProviderInner);
|
||||
AIStateProvider.displayName = 'AIStateProvider';
|
||||
|
||||
const AIStateMaintenanceHostInner: React.FC<AIStateMaintenanceHostProps> = ({
|
||||
validAIScopeTargetIds,
|
||||
}) => {
|
||||
const aiState = useContext(AIStateContext);
|
||||
|
||||
if (!aiState) {
|
||||
throw new Error('AIStateMaintenanceHost must be rendered inside AIStateProvider');
|
||||
}
|
||||
|
||||
const { cleanupOrphanedSessions } = aiState;
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOrphanedSessions(validAIScopeTargetIds);
|
||||
}, [cleanupOrphanedSessions, validAIScopeTargetIds]);
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const AIStateMaintenanceHost = memo(AIStateMaintenanceHostInner);
|
||||
AIStateMaintenanceHost.displayName = 'AIStateMaintenanceHost';
|
||||
|
||||
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
mountedTabIds,
|
||||
activeTabId,
|
||||
@@ -301,12 +326,20 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
draftsByScope={aiState.draftsByScope}
|
||||
panelViewByScope={aiState.panelViewByScope}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
ensureDraftForScope={aiState.ensureDraftForScope}
|
||||
updateDraft={aiState.updateDraft}
|
||||
showDraftView={aiState.showDraftView}
|
||||
showSessionView={aiState.showSessionView}
|
||||
clearDraftForScope={aiState.clearDraftForScope}
|
||||
addDraftFiles={aiState.addDraftFiles}
|
||||
removeDraftFile={aiState.removeDraftFile}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
retargetSessionScope={aiState.retargetSessionScope}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
@@ -853,7 +886,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return map;
|
||||
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
|
||||
|
||||
const validTerminalTabIds = useMemo(() => {
|
||||
const validAIScopeTargetIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
for (const session of sessions) ids.add(session.id);
|
||||
for (const workspace of workspaces) ids.add(workspace.id);
|
||||
@@ -941,16 +974,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}, [workspaces]);
|
||||
|
||||
useEffect(() => {
|
||||
setSidePanelOpenTabs(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
setSidePanelOpenTabs(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpHostForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpInitialLocationForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validAIScopeTargetIds));
|
||||
sessionActivityStore.prune(validSessionActivityIds);
|
||||
}, [validSessionActivityIds, validTerminalTabIds]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOrphanedAISessions(validTerminalTabIds);
|
||||
}, [validTerminalTabIds]);
|
||||
}, [validSessionActivityIds, validAIScopeTargetIds]);
|
||||
|
||||
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
|
||||
if (!workspace) return {} as Record<string, WorkspaceRect>;
|
||||
@@ -1920,6 +1949,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
return (
|
||||
<AIStateProvider>
|
||||
<AIStateMaintenanceHost validAIScopeTargetIds={validAIScopeTargetIds} />
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
@@ -1974,7 +2004,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="sftp"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'sftp' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'sftp'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
@@ -1988,7 +2021,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="scripts"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'scripts' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'scripts'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
@@ -2002,7 +2038,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="theme"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'theme' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'theme'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
@@ -2016,7 +2055,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
data-tab-id="ai"
|
||||
data-tab-type="sidepanel"
|
||||
data-state={activeSidePanelTab === 'ai' ? 'active' : 'inactive'}
|
||||
className="netcatty-tab h-7 w-7 rounded-md p-0 hover:bg-transparent"
|
||||
style={{
|
||||
color: activeSidePanelTab === 'ai'
|
||||
? 'var(--terminal-sidepanel-fg)'
|
||||
|
||||
@@ -500,6 +500,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={session.id}
|
||||
data-tab-type="session"
|
||||
data-state={activeTabId === session.id ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(session.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, session.id)}
|
||||
@@ -508,7 +510,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, session.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -599,6 +601,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
data-tab-id={workspace.id}
|
||||
data-tab-type="workspace"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(workspace.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, workspace.id)}
|
||||
@@ -607,7 +611,7 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onDragLeave={handleTabDragLeave}
|
||||
onDrop={(e) => handleTabDrop(e, workspace.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[150px] max-w-[260px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"transition-transform duration-150",
|
||||
isBeingDragged && isDraggingForReorder ? "opacity-40 scale-95" : ""
|
||||
)}
|
||||
@@ -697,9 +701,11 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
<div
|
||||
key={logView.id}
|
||||
data-tab-id={logView.id}
|
||||
data-tab-type="logView"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab(logView.id)}
|
||||
className={cn(
|
||||
"relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
"netcatty-tab relative h-7 pl-3 pr-2 min-w-[140px] max-w-[240px] rounded-none text-xs font-semibold cursor-pointer flex items-center justify-between gap-2 app-no-drag flex-shrink-0",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isActive
|
||||
@@ -787,9 +793,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-end gap-0 flex-shrink-0 app-drag">
|
||||
<div
|
||||
data-tab-id="vault"
|
||||
data-tab-type="root"
|
||||
data-state={isVaultActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab('vault')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"netcatty-tab relative h-7 px-3 rounded text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isVaultActive
|
||||
@@ -816,9 +825,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</div>
|
||||
{showSftpTab && (
|
||||
<div
|
||||
data-tab-id="sftp"
|
||||
data-tab-type="root"
|
||||
data-state={isSftpActive ? 'active' : 'inactive'}
|
||||
onClick={() => onSelectTab('sftp')}
|
||||
className={cn(
|
||||
"relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
"netcatty-tab relative h-7 px-3 rounded-none text-xs font-semibold cursor-pointer flex items-center gap-2 app-no-drag",
|
||||
)}
|
||||
style={{
|
||||
backgroundColor: isSftpActive
|
||||
|
||||
@@ -7,11 +7,10 @@
|
||||
*/
|
||||
|
||||
import { AtSign, Check, ChevronDown, ChevronRight, Cpu, Expand, Eye, FileText, ImageIcon, Package, Plus, ShieldCheck, X, Zap } from 'lucide-react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { UploadedFile } from '../../application/state/useFileUpload';
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputFooter,
|
||||
@@ -21,7 +20,7 @@ import {
|
||||
} from '../ai-elements/prompt-input';
|
||||
import type { PromptInputStatus } from '../ai-elements/prompt-input';
|
||||
import { formatThinkingLabel } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import type { AgentModelPreset, AIPermissionMode, UploadedFile } from '../../infrastructure/ai/types';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
|
||||
// Keep in sync with the popover's Tailwind max-width below.
|
||||
@@ -101,6 +100,8 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
const [hoveredModelId, setHoveredModelId] = useState<string | null>(null);
|
||||
const [slashQuery, setSlashQuery] = useState('');
|
||||
const [slashRange, setSlashRange] = useState<{ start: number; end: number } | null>(null);
|
||||
// Active highlight index for @ mention / slash skill keyboard navigation
|
||||
const [activeMenuIndex, setActiveMenuIndex] = useState(0);
|
||||
|
||||
// Derived booleans for readability
|
||||
const showModelPicker = activeMenu === 'model';
|
||||
@@ -204,11 +205,11 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
setActiveMenu(menu);
|
||||
}, [getInputPanelMenuPos]);
|
||||
|
||||
const filteredUserSkills = userSkills.filter((skill) => {
|
||||
const filteredUserSkills = useMemo(() => userSkills.filter((skill) => {
|
||||
if (!slashQuery) return true;
|
||||
const lowerQuery = slashQuery.toLowerCase();
|
||||
return skill.slug.toLowerCase().startsWith(lowerQuery) || skill.name.toLowerCase().includes(lowerQuery);
|
||||
});
|
||||
}), [userSkills, slashQuery]);
|
||||
|
||||
const removeSlashQueryFromInput = useCallback(() => {
|
||||
if (!slashRange) return value;
|
||||
@@ -228,6 +229,78 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
closeAllMenus();
|
||||
}, [closeAllMenus, onAddUserSkill, onChange, removeSlashQueryFromInput, slashRange]);
|
||||
|
||||
// Reset active highlight when a menu opens or when the *identity* of the
|
||||
// visible items changes. Watching only `.length` misses cases where the
|
||||
// filter produces a different set with the same count (e.g. user types
|
||||
// another character into the slash query) — Enter would then commit an
|
||||
// unexpected item. Derive a stable key from the visible ids instead.
|
||||
const atMentionKey = useMemo(
|
||||
() => hosts.map((h) => h.sessionId).join('|'),
|
||||
[hosts],
|
||||
);
|
||||
const slashSkillKey = useMemo(
|
||||
() => filteredUserSkills.map((s) => s.id).join('|'),
|
||||
[filteredUserSkills],
|
||||
);
|
||||
useEffect(() => {
|
||||
if (showAtMention) setActiveMenuIndex(0);
|
||||
}, [showAtMention, atMentionKey]);
|
||||
useEffect(() => {
|
||||
if (showSlashSkillPicker) setActiveMenuIndex(0);
|
||||
}, [showSlashSkillPicker, slashSkillKey]);
|
||||
|
||||
const handleTextareaKeyDown = useCallback((e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.nativeEvent.isComposing) return;
|
||||
// @ mention popover keyboard navigation
|
||||
if (showAtMention && hosts.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % hosts.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + hosts.length) % hosts.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const host = hosts[Math.min(activeMenuIndex, hosts.length - 1)];
|
||||
if (host) handleSelectAtMention(host);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
// / skill popover keyboard navigation
|
||||
if (showSlashSkillPicker && filteredUserSkills.length > 0) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i + 1) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
setActiveMenuIndex((i) => (i - 1 + filteredUserSkills.length) % filteredUserSkills.length);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
const skill = filteredUserSkills[Math.min(activeMenuIndex, filteredUserSkills.length - 1)];
|
||||
if (skill) insertUserSkillToken(skill);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
closeAllMenus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, [showAtMention, hosts, showSlashSkillPicker, filteredUserSkills, activeMenuIndex, handleSelectAtMention, insertUserSkillToken, closeAllMenus]);
|
||||
|
||||
const handlePaste = useCallback((e: React.ClipboardEvent) => {
|
||||
const pastedFiles = Array.from(e.clipboardData.items)
|
||||
.map((item: DataTransferItem) => item.getAsFile())
|
||||
@@ -368,6 +441,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => handleInputChange(e.target.value)}
|
||||
onKeyDown={handleTextareaKeyDown}
|
||||
placeholder={placeholder || defaultPlaceholder}
|
||||
disabled={disabled}
|
||||
className={[
|
||||
@@ -393,31 +467,40 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Mention host"
|
||||
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
|
||||
aria-activedescendant={hosts[activeMenuIndex] ? `at-mention-${hosts[activeMenuIndex].sessionId}` : undefined}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuHosts')}</div>
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="px-2.5 pb-2.5">
|
||||
{hosts.map(host => (
|
||||
<button
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="truncate">{host.label || host.hostname}</span>
|
||||
</div>
|
||||
{host.label && host.hostname !== host.label ? (
|
||||
<div className="mt-0.5 pl-3.5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{host.hostname}
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{hosts.map((host, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
const showHostnameLine = host.label
|
||||
&& host.hostname !== host.label
|
||||
&& !host.label.includes(host.hostname);
|
||||
return (
|
||||
<button
|
||||
id={`at-mention-${host.sessionId}`}
|
||||
key={host.sessionId}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => handleSelectAtMention(host)}
|
||||
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px] text-foreground/90">
|
||||
<span className={`h-1.5 w-1.5 rounded-full shrink-0 ${host.connected ? 'bg-green-500' : 'bg-muted-foreground/30'}`} />
|
||||
<span className="truncate">{host.label || host.hostname}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{showHostnameLine ? (
|
||||
<div className="pl-3.5 text-[10px] text-muted-foreground/60 truncate">
|
||||
{host.hostname}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
@@ -432,31 +515,37 @@ const ChatInput: React.FC<ChatInputProps> = ({
|
||||
<div
|
||||
role="listbox"
|
||||
aria-label="Insert user skill"
|
||||
className="fixed z-[1000] overflow-hidden rounded-[20px] border border-border/60 bg-popover shadow-2xl"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: inputPanelPos.width }}
|
||||
aria-activedescendant={filteredUserSkills[activeMenuIndex] ? `slash-skill-${filteredUserSkills[activeMenuIndex].id}` : undefined}
|
||||
className="fixed z-[1000] overflow-hidden rounded-lg border border-border/50 bg-popover shadow-lg"
|
||||
style={{ left: inputPanelPos.left, bottom: inputPanelPos.bottom, width: 'auto', minWidth: Math.min(200, inputPanelPos.width), maxWidth: inputPanelPos.width }}
|
||||
>
|
||||
<div className="px-4 pt-3 pb-1.5 text-[10px] font-medium text-muted-foreground/62 tracking-wide">{t('ai.chat.menuUserSkills')}</div>
|
||||
<ScrollArea className="max-h-[300px]">
|
||||
<div className="px-2.5 pb-2.5">
|
||||
{filteredUserSkills.map((skill) => (
|
||||
<button
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
onClick={() => insertUserSkillToken(skill)}
|
||||
className="w-full rounded-[16px] px-3 py-1.5 text-left hover:bg-muted/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
{skill.description ? (
|
||||
<div className="mt-0.5 pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
<ScrollArea className="max-h-[280px]">
|
||||
<div className="p-1">
|
||||
{filteredUserSkills.map((skill, idx) => {
|
||||
const isActive = idx === activeMenuIndex;
|
||||
return (
|
||||
<button
|
||||
id={`slash-skill-${skill.id}`}
|
||||
key={skill.id}
|
||||
type="button"
|
||||
role="option"
|
||||
aria-selected={isActive}
|
||||
onMouseEnter={() => setActiveMenuIndex(idx)}
|
||||
onClick={() => insertUserSkillToken(skill)}
|
||||
className={`w-full rounded-md px-2 py-1 text-left transition-colors cursor-pointer ${isActive ? 'bg-muted/40' : 'hover:bg-muted/30'}`}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<Package size={12} className="text-muted-foreground/55 shrink-0" />
|
||||
<span className="text-foreground/90">/{skill.slug}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
))}
|
||||
{skill.description ? (
|
||||
<div className="pl-5 text-[10px] leading-4.5 text-muted-foreground/62 line-clamp-2">
|
||||
{skill.description}
|
||||
</div>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
177
components/ai/aiPanelViewState.test.ts
Normal file
177
components/ai/aiPanelViewState.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
import {
|
||||
applyDraftEntrySelection,
|
||||
applyHistorySessionSelection,
|
||||
normalizePanelView,
|
||||
resolveDisplayedPanelView,
|
||||
resolveDisplayedSession,
|
||||
} from "./aiPanelViewState.ts";
|
||||
|
||||
function createSession(id: string): AISession {
|
||||
return {
|
||||
id,
|
||||
title: `Session ${id}`,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "catty",
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId: "terminal-1",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("draft view never falls back to most recent history", () => {
|
||||
const panelView: AIPanelView = { mode: "draft" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), null);
|
||||
});
|
||||
|
||||
test("session view returns the selected session", () => {
|
||||
const selectedSession = createSession("session-2");
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: selectedSession.id };
|
||||
const sessions = [createSession("session-1"), selectedSession];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), selectedSession);
|
||||
});
|
||||
|
||||
test("missing session target resolves to null instead of history fallback", () => {
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.equal(resolveDisplayedSession(panelView, sessions), null);
|
||||
});
|
||||
|
||||
test("missing session target normalizes back to draft view", () => {
|
||||
const panelView: AIPanelView = { mode: "session", sessionId: "missing-session" };
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(normalizePanelView(panelView, sessions), { mode: "draft" });
|
||||
});
|
||||
|
||||
test("missing explicit panel view resumes the most recent matching history when no draft exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, undefined, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view restores the persisted active session instead of the newest", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "workspace"),
|
||||
{ mode: "session", sessionId: "session-1" },
|
||||
);
|
||||
});
|
||||
|
||||
test("persisted session id that no longer exists in history falls back to newest", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "deleted-session", "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("null persisted session id falls back to newest history entry", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, null, "workspace"),
|
||||
{ mode: "session", sessionId: "session-2" },
|
||||
);
|
||||
});
|
||||
|
||||
test("terminal scope without explicit view always starts from draft even when history exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, false, sessions, "session-1", "terminal"),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("missing explicit panel view prefers the draft when unsent input exists", () => {
|
||||
const sessions = [createSession("session-2"), createSession("session-1")];
|
||||
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, sessions),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("draft state is used when there is no implicit history to resume", () => {
|
||||
assert.deepEqual(
|
||||
resolveDisplayedPanelView(undefined, true, []),
|
||||
{ mode: "draft" },
|
||||
);
|
||||
});
|
||||
|
||||
test("history selection switches to the chosen session without touching draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyHistorySessionSelection("session-2", {
|
||||
showSessionView: (sessionId) => {
|
||||
calls.push(`view:${sessionId}`);
|
||||
},
|
||||
setActiveSessionId: (sessionId) => {
|
||||
calls.push(`active:${sessionId}`);
|
||||
},
|
||||
closeHistory: () => {
|
||||
calls.push("close-history");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"view:session-2",
|
||||
"active:session-2",
|
||||
"close-history",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry ensures a draft exists before switching the panel to draft mode", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
"show-draft",
|
||||
]);
|
||||
});
|
||||
|
||||
test("draft entry can preserve the current session view while ensuring draft state", () => {
|
||||
const calls: string[] = [];
|
||||
|
||||
applyDraftEntrySelection({
|
||||
ensureDraft: () => {
|
||||
calls.push("ensure-draft");
|
||||
},
|
||||
showDraftView: () => {
|
||||
calls.push("show-draft");
|
||||
},
|
||||
preserveSessionView: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"ensure-draft",
|
||||
]);
|
||||
});
|
||||
94
components/ai/aiPanelViewState.ts
Normal file
94
components/ai/aiPanelViewState.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
AIPanelView,
|
||||
AISession,
|
||||
} from "../../infrastructure/ai/types.ts";
|
||||
|
||||
const DEFAULT_PANEL_VIEW: AIPanelView = { mode: "draft" };
|
||||
|
||||
interface HistorySessionSelectionActions {
|
||||
showSessionView: (sessionId: string) => void;
|
||||
setActiveSessionId: (sessionId: string) => void;
|
||||
closeHistory?: () => void;
|
||||
}
|
||||
|
||||
interface DraftEntrySelectionActions {
|
||||
ensureDraft: () => void;
|
||||
showDraftView: () => void;
|
||||
preserveSessionView?: boolean;
|
||||
}
|
||||
|
||||
export function resolveDisplayedPanelView(
|
||||
panelView: AIPanelView | undefined,
|
||||
hasDraft: boolean,
|
||||
sessions: AISession[],
|
||||
persistedSessionId?: string | null,
|
||||
scopeType: "terminal" | "workspace" = "workspace",
|
||||
): AIPanelView {
|
||||
if (panelView) {
|
||||
return normalizePanelView(panelView, sessions);
|
||||
}
|
||||
|
||||
if (hasDraft) {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// New terminal sessions should always start from a blank draft. History is
|
||||
// still available in the drawer, but never auto-resumed into a fresh SSH tab.
|
||||
if (scopeType === "terminal") {
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
// Honour the persisted active-session selection (survives cold mount)
|
||||
// before falling back to the newest history entry.
|
||||
if (persistedSessionId && sessions.some((s) => s.id === persistedSessionId)) {
|
||||
return { mode: "session", sessionId: persistedSessionId };
|
||||
}
|
||||
|
||||
if (sessions[0]) {
|
||||
return { mode: "session", sessionId: sessions[0].id };
|
||||
}
|
||||
|
||||
return DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function normalizePanelView(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
): AIPanelView {
|
||||
if (panelView.mode !== "session") {
|
||||
return panelView;
|
||||
}
|
||||
|
||||
return sessions.some((session) => session.id === panelView.sessionId)
|
||||
? panelView
|
||||
: DEFAULT_PANEL_VIEW;
|
||||
}
|
||||
|
||||
export function resolveDisplayedSession(
|
||||
panelView: AIPanelView,
|
||||
sessions: AISession[],
|
||||
): AISession | null {
|
||||
if (panelView.mode !== "session") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return sessions.find((session) => session.id === panelView.sessionId) ?? null;
|
||||
}
|
||||
|
||||
export function applyHistorySessionSelection(
|
||||
sessionId: string,
|
||||
actions: HistorySessionSelectionActions,
|
||||
): void {
|
||||
actions.showSessionView(sessionId);
|
||||
actions.setActiveSessionId(sessionId);
|
||||
actions.closeHistory?.();
|
||||
}
|
||||
|
||||
export function applyDraftEntrySelection(
|
||||
actions: DraftEntrySelectionActions,
|
||||
): void {
|
||||
actions.ensureDraft();
|
||||
if (!actions.preserveSessionView) {
|
||||
actions.showDraftView();
|
||||
}
|
||||
}
|
||||
18
components/ai/draftSendGate.test.ts
Normal file
18
components/ai/draftSendGate.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
endDraftSend,
|
||||
tryBeginDraftSend,
|
||||
} from "./draftSendGate.ts";
|
||||
|
||||
test("draft send gate allows only one in-flight draft send at a time", () => {
|
||||
const gate = { current: false };
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
assert.equal(tryBeginDraftSend(gate), false);
|
||||
|
||||
endDraftSend(gate);
|
||||
|
||||
assert.equal(tryBeginDraftSend(gate), true);
|
||||
});
|
||||
12
components/ai/draftSendGate.ts
Normal file
12
components/ai/draftSendGate.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export function tryBeginDraftSend(gate: { current: boolean }): boolean {
|
||||
if (gate.current) {
|
||||
return false;
|
||||
}
|
||||
|
||||
gate.current = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function endDraftSend(gate: { current: boolean }): void {
|
||||
gate.current = false;
|
||||
}
|
||||
15
components/ai/sessionHistoryLayout.test.ts
Normal file
15
components/ai/sessionHistoryLayout.test.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
SESSION_HISTORY_ROW_CLASSNAMES,
|
||||
} from "./sessionHistoryLayout.ts";
|
||||
|
||||
test("session history row keeps metadata pinned to the end while title truncates", () => {
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.row, /\bgrid\b/);
|
||||
assert.ok(SESSION_HISTORY_ROW_CLASSNAMES.row.includes('grid-cols-[minmax(0,1fr)_auto]'));
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\btruncate\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.title, /\bmin-w-0\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bjustify-self-end\b/);
|
||||
assert.match(SESSION_HISTORY_ROW_CLASSNAMES.meta, /\bshrink-0\b/);
|
||||
});
|
||||
7
components/ai/sessionHistoryLayout.ts
Normal file
7
components/ai/sessionHistoryLayout.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export const SESSION_HISTORY_ROW_CLASSNAMES = {
|
||||
row: 'w-full grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3 py-2.5 border-b border-border/20 text-left transition-colors cursor-pointer group',
|
||||
title: 'text-[13px] truncate min-w-0',
|
||||
meta: 'flex items-center gap-2 justify-self-end shrink-0',
|
||||
time: 'text-[12px] text-muted-foreground/50 whitespace-nowrap',
|
||||
deleteButton: 'opacity-0 group-hover:opacity-100 p-0.5 hover:text-destructive transition-all cursor-pointer shrink-0',
|
||||
} as const;
|
||||
101
components/ai/sessionScopeMatch.test.ts
Normal file
101
components/ai/sessionScopeMatch.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import type { AISession } from "../../infrastructure/ai/types.ts";
|
||||
import { getSessionScopeMatchRank } from "./sessionScopeMatch.ts";
|
||||
|
||||
function createSession(id: string, targetId: string, hostIds: string[]): AISession {
|
||||
return {
|
||||
id,
|
||||
title: id,
|
||||
messages: [],
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
agentId: "catty",
|
||||
scope: {
|
||||
type: "terminal",
|
||||
targetId,
|
||||
hostIds,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("host-matched terminal session is excluded when another active terminal already displays it", () => {
|
||||
const session = createSession("session-1", "terminal-other", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(["session-1"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("host-matched terminal session remains resumable when no terminal is displaying it", () => {
|
||||
const session = createSession("session-1", "terminal-closed", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(["session-other"]),
|
||||
),
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
test("ownership is tracked by session id, not scope.targetId", () => {
|
||||
// Session was created in terminal-A but a different terminal (B) is now
|
||||
// displaying it after the user resumed it from history. Opening a third
|
||||
// terminal (C) should not see this session as owned, because the new
|
||||
// ownership check is keyed on session id, not the stale targetId.
|
||||
const session = createSession("session-1", "terminal-A", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-C",
|
||||
["host-a"],
|
||||
// terminal-B is displaying session-1; pass session-1 as an
|
||||
// active-id so C sees it as in-use
|
||||
new Set(["session-1"]),
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
test("session targeting the current scope is an exact match (rank 2)", () => {
|
||||
const session = createSession("session-1", "terminal-current", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"terminal",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
new Set(),
|
||||
),
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("scope type mismatch returns 0 regardless of target or hosts", () => {
|
||||
const session = createSession("session-1", "terminal-current", ["host-a"]);
|
||||
|
||||
assert.equal(
|
||||
getSessionScopeMatchRank(
|
||||
session,
|
||||
"workspace",
|
||||
"terminal-current",
|
||||
["host-a"],
|
||||
),
|
||||
0,
|
||||
);
|
||||
});
|
||||
28
components/ai/sessionScopeMatch.ts
Normal file
28
components/ai/sessionScopeMatch.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { AISession } from "../../infrastructure/ai/types";
|
||||
|
||||
export function getSessionScopeMatchRank(
|
||||
session: AISession,
|
||||
scopeType: "terminal" | "workspace",
|
||||
scopeTargetId?: string,
|
||||
scopeHostIds?: string[],
|
||||
/**
|
||||
* Session ids currently displayed by other terminal scopes. Tracked by
|
||||
* session id rather than `scope.targetId` so that a host-matched session
|
||||
* resumed from a different terminal is still recognised as in-use and
|
||||
* not offered (or cleaned) as if it were orphaned.
|
||||
*/
|
||||
activeTerminalSessionIds?: Set<string>,
|
||||
): number {
|
||||
if (session.scope.type !== scopeType) return 0;
|
||||
if (session.scope.targetId === scopeTargetId) return 2;
|
||||
|
||||
if (scopeType !== "terminal" || !scopeHostIds?.length || !session.scope.hostIds?.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (activeTerminalSessionIds?.has(session.id)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return session.scope.hostIds.some((hostId) => scopeHostIds.includes(hostId)) ? 1 : 0;
|
||||
}
|
||||
@@ -2,7 +2,9 @@ import React, { useCallback } from "react";
|
||||
import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../application/syncPayload";
|
||||
import { applyProtectedSyncPayload } from "../../../application/localVaultBackups";
|
||||
import type { SyncableVaultData } from "../../../application/syncPayload";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { getEffectiveKnownHosts } from "../../../infrastructure/syncHelpers";
|
||||
@@ -25,6 +27,7 @@ export default function SettingsSyncTab(props: {
|
||||
clearVaultData,
|
||||
onSettingsApplied,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
// If hook state is empty but localStorage has data, the async store
|
||||
@@ -54,14 +57,19 @@ export default function SettingsSyncTab(props: {
|
||||
}, [vault, portForwardingRules]);
|
||||
|
||||
const onApplyPayload = useCallback(
|
||||
(payload: SyncPayload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
});
|
||||
},
|
||||
[importDataFromString, importPortForwardingRules, onSettingsApplied],
|
||||
(payload: SyncPayload) =>
|
||||
applyProtectedSyncPayload({
|
||||
buildPreApplyPayload: onBuildPayload,
|
||||
applyPayload: () =>
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
}),
|
||||
translateProtectiveBackupFailure: (message) =>
|
||||
t("cloudSync.localBackups.protectiveBackupFailed", { message }),
|
||||
}),
|
||||
[importDataFromString, importPortForwardingRules, onBuildPayload, onSettingsApplied, t],
|
||||
);
|
||||
|
||||
const clearAllLocalData = useCallback(() => {
|
||||
|
||||
@@ -318,6 +318,8 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
<div
|
||||
key={tab.id}
|
||||
data-tab-id={tab.id}
|
||||
data-tab-type="sftp"
|
||||
data-state={isActive ? 'active' : 'inactive'}
|
||||
onClick={(e) => handleSelectTabClick(e, tab.id)}
|
||||
draggable
|
||||
onDragStart={(e) => handleTabDragStart(e, tab.id)}
|
||||
@@ -325,7 +327,7 @@ const SftpTabBarInner: React.FC<SftpTabBarProps> = ({
|
||||
onDragOver={(e) => handleTabDragOver(e, tab.id)}
|
||||
onDrop={(e) => handleTabDrop(e, tab.id)}
|
||||
className={cn(
|
||||
"relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
|
||||
"netcatty-tab relative px-3 min-w-[100px] max-w-[180px] text-xs font-medium cursor-pointer flex items-center justify-between gap-2 flex-shrink-0 border-r border-border/40",
|
||||
"transition-[color,opacity,transform] duration-100 ease-out",
|
||||
isActive
|
||||
? "text-foreground border-b-2"
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface TerminalContextMenuProps {
|
||||
isAlternateScreen?: boolean;
|
||||
onCopy?: () => void;
|
||||
onPaste?: () => void;
|
||||
onPasteSelection?: () => void;
|
||||
onSelectAll?: () => void;
|
||||
onClear?: () => void;
|
||||
onSplitHorizontal?: () => void;
|
||||
@@ -48,6 +49,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
isAlternateScreen = false,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onPasteSelection,
|
||||
onSelectAll,
|
||||
onClear,
|
||||
onSplitHorizontal,
|
||||
@@ -70,6 +72,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
|
||||
const copyShortcut = getShortcut('copy');
|
||||
const pasteShortcut = getShortcut('paste');
|
||||
const pasteSelectionShortcut = getShortcut('paste-selection');
|
||||
const selectAllShortcut = getShortcut('select-all');
|
||||
const splitHShortcut = getShortcut('split-horizontal');
|
||||
const splitVShortcut = getShortcut('split-vertical');
|
||||
@@ -123,6 +126,13 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
{t('terminal.menu.paste')}
|
||||
<ContextMenuShortcut>{pasteShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
{onPasteSelection && (
|
||||
<ContextMenuItem onClick={onPasteSelection} disabled={!hasSelection}>
|
||||
<ClipboardPaste size={14} className="mr-2" />
|
||||
{t('terminal.menu.pasteSelection')}
|
||||
<ContextMenuShortcut>{pasteSelectionShortcut}</ContextMenuShortcut>
|
||||
</ContextMenuItem>
|
||||
)}
|
||||
<ContextMenuItem onClick={onSelectAll}>
|
||||
<TerminalIcon size={14} className="mr-2" />
|
||||
{t('terminal.menu.selectAll')}
|
||||
|
||||
@@ -56,6 +56,24 @@ export const useTerminalContextActions = ({
|
||||
}
|
||||
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
|
||||
|
||||
const onPasteSelection = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const selection = term.getSelection();
|
||||
if (!selection || !sessionRef.current) return;
|
||||
let data = normalizeLineEndings(selection);
|
||||
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
if (scrollOnPasteRef?.current) {
|
||||
term.scrollToBottom();
|
||||
if (typeof requestAnimationFrame === "function") {
|
||||
requestAnimationFrame(() => {
|
||||
term.scrollToBottom();
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
@@ -76,5 +94,5 @@ export const useTerminalContextActions = ({
|
||||
onHasSelectionChange?.(true);
|
||||
}, [onHasSelectionChange, termRef]);
|
||||
|
||||
return { onCopy, onPaste, onSelectAll, onClear, onSelectWord };
|
||||
return { onCopy, onPaste, onPasteSelection, onSelectAll, onClear, onSelectWord };
|
||||
};
|
||||
|
||||
@@ -497,6 +497,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "pasteSelection": {
|
||||
const selection = term.getSelection();
|
||||
const id = ctx.sessionRef.current;
|
||||
if (selection && id) {
|
||||
let data = normalizeLineEndings(selection);
|
||||
if (term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterPaste();
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "selectAll": {
|
||||
term.selectAll();
|
||||
break;
|
||||
|
||||
@@ -394,6 +394,7 @@ export const DEFAULT_KEY_BINDINGS: KeyBinding[] = [
|
||||
// Terminal Operations
|
||||
{ id: 'copy', action: 'copy', label: 'Copy from Terminal', mac: '⌘ + C', pc: 'Ctrl + Shift + C', category: 'terminal' },
|
||||
{ id: 'paste', action: 'paste', label: 'Paste to Terminal', mac: '⌘ + V', pc: 'Ctrl + Shift + V', category: 'terminal' },
|
||||
{ id: 'paste-selection', action: 'pasteSelection', label: 'Paste Selection to Terminal', mac: '⌘ + Shift + X', pc: 'Ctrl + Shift + X', category: 'terminal' },
|
||||
{ id: 'select-all', action: 'selectAll', label: 'Select All in Terminal', mac: '⌘ + A', pc: 'Ctrl + Shift + A', category: 'terminal' },
|
||||
{ id: 'clear-buffer', action: 'clearBuffer', label: 'Clear Terminal Buffer', mac: '⌘ + ⌃ + K', pc: 'Ctrl + Shift + K', category: 'terminal' },
|
||||
{ id: 'search-terminal', action: 'searchTerminal', label: 'Open Terminal Search', mac: '⌘ + F', pc: 'Ctrl + F', category: 'terminal' },
|
||||
|
||||
@@ -88,7 +88,7 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
||||
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; ` +
|
||||
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval -- \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
|
||||
);
|
||||
|
||||
|
||||
@@ -34,11 +34,138 @@ let trayMenuData = {
|
||||
let trayPanelWindow = null;
|
||||
|
||||
let trayPanelRefreshTimer = null;
|
||||
// Watchdog: if `leave-full-screen` never arrives (edge case / stuck transition)
|
||||
// we eventually give up and force a hide attempt. Better a visible window than
|
||||
// a hung close-to-tray path.
|
||||
const FULLSCREEN_LEAVE_WATCHDOG_MS = 5000;
|
||||
// After `leave-full-screen` fires, macOS emits a trailing `show` event while
|
||||
// the native space transition finishes. Calling `win.hide()` before that show
|
||||
// causes the window to pop back on screen. We wait for the trailing show, or
|
||||
// fall back on this timeout — whichever comes first.
|
||||
const FULLSCREEN_TRAILING_SHOW_FALLBACK_MS = 300;
|
||||
const pendingFullscreenHideByWindow = new WeakMap();
|
||||
|
||||
function clearPendingFullscreenHide(win) {
|
||||
if (!win || typeof win !== "object") return;
|
||||
const pending = pendingFullscreenHideByWindow.get(win);
|
||||
if (!pending) return;
|
||||
|
||||
if (pending.watchdogTimer) {
|
||||
clearTimeout(pending.watchdogTimer);
|
||||
pending.watchdogTimer = null;
|
||||
}
|
||||
if (pending.trailingShowTimer) {
|
||||
clearTimeout(pending.trailingShowTimer);
|
||||
pending.trailingShowTimer = null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (pending.onLeaveFullScreen) {
|
||||
win.removeListener?.("leave-full-screen", pending.onLeaveFullScreen);
|
||||
}
|
||||
if (pending.onClosed) {
|
||||
win.removeListener?.("closed", pending.onClosed);
|
||||
}
|
||||
if (pending.onTrailingShow) {
|
||||
win.removeListener?.("show", pending.onTrailingShow);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
pendingFullscreenHideByWindow.delete(win);
|
||||
}
|
||||
|
||||
function performPendingFullscreenHide(win) {
|
||||
const pending = pendingFullscreenHideByWindow.get(win);
|
||||
if (!pending) return "cancelled";
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
clearPendingFullscreenHide(win);
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
clearPendingFullscreenHide(win);
|
||||
|
||||
try {
|
||||
win.hide();
|
||||
return "hidden";
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error hiding window after leaving fullscreen:", err);
|
||||
return "failed";
|
||||
}
|
||||
}
|
||||
|
||||
function handleLeaveFullScreenForPendingHide(win) {
|
||||
const pending = pendingFullscreenHideByWindow.get(win);
|
||||
if (!pending) return;
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
clearPendingFullscreenHide(win);
|
||||
return;
|
||||
}
|
||||
|
||||
pending.leaveFullScreenFired = true;
|
||||
|
||||
if (pending.watchdogTimer) {
|
||||
clearTimeout(pending.watchdogTimer);
|
||||
pending.watchdogTimer = null;
|
||||
}
|
||||
|
||||
// Wait for the trailing `show` that macOS emits as the space transition
|
||||
// finishes, then hide on top of it. If it never fires within the fallback
|
||||
// window, hide anyway.
|
||||
pending.onTrailingShow = () => {
|
||||
pending.onTrailingShow = null;
|
||||
if (pending.trailingShowTimer) {
|
||||
clearTimeout(pending.trailingShowTimer);
|
||||
pending.trailingShowTimer = null;
|
||||
}
|
||||
performPendingFullscreenHide(win);
|
||||
};
|
||||
try {
|
||||
win.once?.("show", pending.onTrailingShow);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
pending.trailingShowTimer = setTimeout(() => {
|
||||
pending.trailingShowTimer = null;
|
||||
if (pending.onTrailingShow) {
|
||||
try {
|
||||
win.removeListener?.("show", pending.onTrailingShow);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
pending.onTrailingShow = null;
|
||||
}
|
||||
performPendingFullscreenHide(win);
|
||||
}, FULLSCREEN_TRAILING_SHOW_FALLBACK_MS);
|
||||
}
|
||||
|
||||
function startPendingFullscreenHideWatchdog(win) {
|
||||
const pending = pendingFullscreenHideByWindow.get(win);
|
||||
if (!pending) return;
|
||||
|
||||
pending.watchdogTimer = setTimeout(() => {
|
||||
pending.watchdogTimer = null;
|
||||
if (!pendingFullscreenHideByWindow.has(win)) return;
|
||||
if (!win || win.isDestroyed?.()) {
|
||||
clearPendingFullscreenHide(win);
|
||||
return;
|
||||
}
|
||||
if (pending.leaveFullScreenFired) return;
|
||||
|
||||
console.warn("[GlobalShortcut] Timed out waiting for leave-full-screen before hiding to tray; forcing hide");
|
||||
// Give up and hide anyway. Simulate the leave path so the trailing-show
|
||||
// wait still applies (defence in depth against spurious show events).
|
||||
handleLeaveFullScreenForPendingHide(win);
|
||||
}, FULLSCREEN_LEAVE_WATCHDOG_MS);
|
||||
}
|
||||
|
||||
function openMainWindow() {
|
||||
const { app } = electronModule;
|
||||
const win = getMainWindow();
|
||||
if (!win) return;
|
||||
clearPendingFullscreenHide(win);
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
@@ -218,6 +345,65 @@ function getMainWindow() {
|
||||
return mainWins && mainWins.length ? mainWins[0] : null;
|
||||
}
|
||||
|
||||
function hideWindowRespectingMacFullscreen(win) {
|
||||
if (!win || win.isDestroyed?.()) return false;
|
||||
|
||||
clearPendingFullscreenHide(win);
|
||||
|
||||
if (process.platform === "darwin" && win.isFullScreen?.()) {
|
||||
// Close-to-tray on a native-fullscreen window on macOS has two traps:
|
||||
//
|
||||
// 1. `isFullScreen()` can flip to false BEFORE the exit animation
|
||||
// completes. Polling it and calling `win.hide()` at that moment
|
||||
// hides the window mid-transition, which macOS then undoes when
|
||||
// the animation finishes.
|
||||
// 2. Right after the real `leave-full-screen` event, macOS emits an
|
||||
// internal `show` event as part of finalizing the space transition
|
||||
// — this show undoes any earlier hide.
|
||||
//
|
||||
// Strategy: wait for `leave-full-screen`, then wait for the trailing
|
||||
// `show` that follows it (or a short timeout), and only then hide.
|
||||
// All legitimate "bring the window back" entry points (openMainWindow,
|
||||
// toggleWindowVisibility, setCloseToTray(false), app.on("activate"),
|
||||
// closed) explicitly call clearPendingFullscreenHide so we never race
|
||||
// with genuine user intent.
|
||||
const pending = {
|
||||
watchdogTimer: null,
|
||||
trailingShowTimer: null,
|
||||
leaveFullScreenFired: false,
|
||||
onLeaveFullScreen: null,
|
||||
onClosed: null,
|
||||
onTrailingShow: null,
|
||||
};
|
||||
pending.onLeaveFullScreen = () => {
|
||||
handleLeaveFullScreenForPendingHide(win);
|
||||
};
|
||||
pending.onClosed = () => {
|
||||
clearPendingFullscreenHide(win);
|
||||
};
|
||||
|
||||
try {
|
||||
pendingFullscreenHideByWindow.set(win, pending);
|
||||
win.once?.("leave-full-screen", pending.onLeaveFullScreen);
|
||||
win.once?.("closed", pending.onClosed);
|
||||
startPendingFullscreenHideWatchdog(win);
|
||||
win.setFullScreen(false);
|
||||
return true;
|
||||
} catch (err) {
|
||||
clearPendingFullscreenHide(win);
|
||||
console.warn("[GlobalShortcut] Error leaving fullscreen before hiding window:", err);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
win.hide();
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn("[GlobalShortcut] Error hiding window:", err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a hotkey string from frontend format to Electron accelerator format
|
||||
* e.g., "⌘ + Space" -> "CommandOrControl+Space"
|
||||
@@ -283,6 +469,7 @@ function toggleWindowVisibility() {
|
||||
try {
|
||||
// Check if window is minimized first - minimized windows may still report isVisible() = true
|
||||
if (win.isMinimized()) {
|
||||
clearPendingFullscreenHide(win);
|
||||
win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
@@ -295,9 +482,10 @@ function toggleWindowVisibility() {
|
||||
} else if (win.isVisible()) {
|
||||
if (win.isFocused()) {
|
||||
// Window is visible and focused - hide it
|
||||
win.hide();
|
||||
hideWindowRespectingMacFullscreen(win);
|
||||
} else {
|
||||
// Window is visible but not focused - focus it
|
||||
clearPendingFullscreenHide(win);
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
try {
|
||||
@@ -308,6 +496,7 @@ function toggleWindowVisibility() {
|
||||
}
|
||||
} else {
|
||||
// Window is hidden - show and focus it
|
||||
clearPendingFullscreenHide(win);
|
||||
win.show();
|
||||
win.focus();
|
||||
const { app } = electronModule;
|
||||
@@ -437,17 +626,7 @@ function buildTrayMenuTemplate() {
|
||||
menuTemplate.push({
|
||||
label: "Open Main Window",
|
||||
click: () => {
|
||||
const win = getMainWindow();
|
||||
if (win) {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
try {
|
||||
app.focus({ steal: true });
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
openMainWindow();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -587,6 +766,7 @@ function setCloseToTray(enabled) {
|
||||
createTray();
|
||||
}
|
||||
} else {
|
||||
clearPendingFullscreenHide(getMainWindow());
|
||||
// Destroy tray if it exists
|
||||
destroyTray();
|
||||
}
|
||||
@@ -617,7 +797,7 @@ function getHotkeyStatus() {
|
||||
function handleWindowClose(event, win) {
|
||||
if (closeToTray && tray) {
|
||||
event.preventDefault();
|
||||
win.hide();
|
||||
hideWindowRespectingMacFullscreen(win);
|
||||
return true; // Prevented close
|
||||
}
|
||||
return false; // Allow close
|
||||
@@ -727,5 +907,6 @@ module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
handleWindowClose,
|
||||
clearPendingFullscreenHide,
|
||||
cleanup,
|
||||
};
|
||||
|
||||
492
electron/bridges/globalShortcutBridge.test.cjs
Normal file
492
electron/bridges/globalShortcutBridge.test.cjs
Normal file
@@ -0,0 +1,492 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
|
||||
function withPatchedTimers(run) {
|
||||
const originalSetTimeout = global.setTimeout;
|
||||
const originalClearTimeout = global.clearTimeout;
|
||||
let nextTimerId = 1;
|
||||
const timers = new Map();
|
||||
|
||||
global.setTimeout = (fn, _delay, ...args) => {
|
||||
const id = nextTimerId++;
|
||||
timers.set(id, () => fn(...args));
|
||||
return id;
|
||||
};
|
||||
|
||||
global.clearTimeout = (id) => {
|
||||
timers.delete(id);
|
||||
};
|
||||
|
||||
const flushNextTimer = () => {
|
||||
const nextEntry = timers.entries().next().value;
|
||||
if (!nextEntry) return false;
|
||||
const [id, fn] = nextEntry;
|
||||
timers.delete(id);
|
||||
fn();
|
||||
return true;
|
||||
};
|
||||
|
||||
const getPendingTimerCount = () => timers.size;
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() => run({ flushNextTimer, getPendingTimerCount }))
|
||||
.finally(() => {
|
||||
global.setTimeout = originalSetTimeout;
|
||||
global.clearTimeout = originalClearTimeout;
|
||||
});
|
||||
}
|
||||
|
||||
function withPatchedDateNow(initialValue, run) {
|
||||
const originalDateNow = Date.now;
|
||||
let currentValue = initialValue;
|
||||
|
||||
Date.now = () => currentValue;
|
||||
|
||||
return Promise.resolve()
|
||||
.then(() =>
|
||||
run({
|
||||
setNow(nextValue) {
|
||||
currentValue = nextValue;
|
||||
},
|
||||
}))
|
||||
.finally(() => {
|
||||
Date.now = originalDateNow;
|
||||
});
|
||||
}
|
||||
|
||||
function loadBridge() {
|
||||
const bridgePath = require.resolve("./globalShortcutBridge.cjs");
|
||||
delete require.cache[bridgePath];
|
||||
return require("./globalShortcutBridge.cjs");
|
||||
}
|
||||
|
||||
function createElectronStub() {
|
||||
class FakeTray {
|
||||
constructor() {
|
||||
this.handlers = new Map();
|
||||
}
|
||||
|
||||
setToolTip() {}
|
||||
setContextMenu() {}
|
||||
destroy() {}
|
||||
|
||||
on(eventName, handler) {
|
||||
this.handlers.set(eventName, handler);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
Tray: FakeTray,
|
||||
Menu: {},
|
||||
BrowserWindow: {
|
||||
getAllWindows() {
|
||||
return [];
|
||||
},
|
||||
},
|
||||
globalShortcut: {
|
||||
register() {
|
||||
return true;
|
||||
},
|
||||
unregister() {},
|
||||
},
|
||||
nativeImage: {
|
||||
createFromPath() {
|
||||
return {
|
||||
resize() {
|
||||
return this;
|
||||
},
|
||||
setTemplateImage() {},
|
||||
};
|
||||
},
|
||||
createEmpty() {
|
||||
return {};
|
||||
},
|
||||
},
|
||||
app: {
|
||||
getAppPath() {
|
||||
return process.cwd();
|
||||
},
|
||||
quit() {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createIpcMainStub() {
|
||||
const handlers = new Map();
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
handlers.set(channel, handler);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
class FakeWindow extends EventEmitter {
|
||||
constructor({ fullscreen = false } = {}) {
|
||||
super();
|
||||
this.fullscreen = fullscreen;
|
||||
this.hideCalls = 0;
|
||||
this.showCalls = 0;
|
||||
this.focusCalls = 0;
|
||||
this.restoreCalls = 0;
|
||||
this.setFullScreenCalls = [];
|
||||
this.destroyed = false;
|
||||
this.minimized = false;
|
||||
this.visible = true;
|
||||
this.focused = true;
|
||||
}
|
||||
|
||||
isDestroyed() {
|
||||
return this.destroyed;
|
||||
}
|
||||
|
||||
isFullScreen() {
|
||||
return this.fullscreen;
|
||||
}
|
||||
|
||||
setFullScreen(nextValue) {
|
||||
this.setFullScreenCalls.push(nextValue);
|
||||
if (nextValue) {
|
||||
this.fullscreen = true;
|
||||
}
|
||||
}
|
||||
|
||||
isMinimized() {
|
||||
return this.minimized;
|
||||
}
|
||||
|
||||
restore() {
|
||||
this.restoreCalls += 1;
|
||||
this.minimized = false;
|
||||
}
|
||||
|
||||
isVisible() {
|
||||
return this.visible;
|
||||
}
|
||||
|
||||
isFocused() {
|
||||
return this.focused;
|
||||
}
|
||||
|
||||
hide() {
|
||||
this.hideCalls += 1;
|
||||
this.visible = false;
|
||||
this.focused = false;
|
||||
}
|
||||
|
||||
show() {
|
||||
this.showCalls += 1;
|
||||
this.visible = true;
|
||||
this.emit("show");
|
||||
}
|
||||
|
||||
focus() {
|
||||
this.focusCalls += 1;
|
||||
this.focused = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function withPlatform(platform, run) {
|
||||
const original = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
Object.defineProperty(process, "platform", { configurable: true, value: platform });
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
Object.defineProperty(process, "platform", original);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableCloseToTray(bridge, electronModule = createElectronStub()) {
|
||||
bridge.init({ electronModule });
|
||||
const ipcMain = createIpcMainStub();
|
||||
bridge.registerHandlers(ipcMain);
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: true });
|
||||
return { ipcMain, electronModule };
|
||||
}
|
||||
|
||||
test("handleWindowClose allows normal close when close-to-tray is disabled", () => {
|
||||
const bridge = loadBridge();
|
||||
const win = new FakeWindow();
|
||||
let prevented = false;
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
|
||||
|
||||
assert.equal(result, false);
|
||||
assert.equal(prevented, false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
|
||||
test("close-to-tray on a mac fullscreen window defers hide until after leave-full-screen and the trailing show", async () => {
|
||||
// Observed macOS sequence after the red close on a fullscreen window:
|
||||
// setFullScreen(false) → (animation) → leave-full-screen → trailing show
|
||||
// Hiding before the trailing show causes macOS to pop the window back
|
||||
// during the final space transition. The fix waits for the trailing show
|
||||
// (or a fallback timer) before calling win.hide().
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
let prevented = false;
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(win.setFullScreenCalls, [false]);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
// Watchdog timer is pending. No show listener yet — macOS's
|
||||
// pre-leave-full-screen internal `show` events must not trigger hide.
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.listenerCount("show"), 0);
|
||||
|
||||
// Spurious early show (mid-animation) does nothing.
|
||||
win.emit("show");
|
||||
assert.equal(win.hideCalls, 0);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
// leave-full-screen arrives. Watchdog cancelled; now we arm a `show`
|
||||
// listener + trailing-show fallback timer. Still no hide.
|
||||
win.fullscreen = false;
|
||||
win.emit("leave-full-screen");
|
||||
assert.equal(win.hideCalls, 0);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.listenerCount("show"), 1);
|
||||
|
||||
// Trailing show from macOS finalizing the space transition runs the hide.
|
||||
win.emit("show");
|
||||
assert.equal(win.hideCalls, 1);
|
||||
assert.equal(win.listenerCount("show"), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("fallback timer hides the window when the trailing show never arrives", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
|
||||
bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
win.fullscreen = false;
|
||||
win.emit("leave-full-screen");
|
||||
|
||||
// Watchdog cleared; trailing-show fallback timer is pending.
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
assert.equal(win.listenerCount("show"), 1);
|
||||
|
||||
// No show ever arrives. Fallback timer runs.
|
||||
flushNextTimer();
|
||||
|
||||
assert.equal(win.hideCalls, 1);
|
||||
assert.equal(win.listenerCount("show"), 0);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("watchdog forces the hide path if leave-full-screen never arrives", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
|
||||
bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
// Watchdog fires (simulates 5s with no leave-full-screen). It forces
|
||||
// the leave path — which arms the trailing-show listener + fallback.
|
||||
flushNextTimer();
|
||||
assert.equal(win.hideCalls, 0);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.listenerCount("show"), 1);
|
||||
|
||||
// Trailing-show fallback fires → hide.
|
||||
flushNextTimer();
|
||||
assert.equal(win.hideCalls, 1);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("app activate clears a pending fullscreen hide", async () => {
|
||||
// Regression for the close-to-tray + fullscreen bug where the internal
|
||||
// `show` emitted during the fullscreen exit animation was cancelling the
|
||||
// hide. main.cjs's app.on("activate") handler now calls into this bridge
|
||||
// to cancel the pending hide when the user actually re-activates the app.
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
bridge.clearPendingFullscreenHide(win);
|
||||
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
assert.equal(flushNextTimer(), false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("focusing a visible window cancels a pending fullscreen hide", async () => {
|
||||
await withPatchedTimers(async ({ getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
win.focused = false;
|
||||
electronModule.BrowserWindow.getAllWindows = () => [win];
|
||||
let toggleWindow = null;
|
||||
electronModule.globalShortcut.register = (_accelerator, handler) => {
|
||||
toggleWindow = handler;
|
||||
return true;
|
||||
};
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
await ipcMain.handlers.get("netcatty:globalHotkey:register")(null, { hotkey: "Ctrl + `" });
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
toggleWindow();
|
||||
|
||||
assert.equal(win.focusCalls, 1);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("openMainWindow cancels a pending fullscreen hide before showing the window", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
win.show = function showWithoutEmit() {
|
||||
this.showCalls += 1;
|
||||
this.visible = true;
|
||||
};
|
||||
electronModule.BrowserWindow.getAllWindows = () => [win];
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
await ipcMain.handlers.get("netcatty:trayPanel:openMainWindow")();
|
||||
|
||||
assert.equal(win.showCalls, 1);
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
|
||||
const flushed = flushNextTimer();
|
||||
assert.equal(flushed, false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("closing the window clears a pending fullscreen hide", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 1);
|
||||
assert.equal(win.listenerCount("closed"), 1);
|
||||
|
||||
win.destroyed = true;
|
||||
win.emit("closed");
|
||||
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
assert.equal(flushNextTimer(), false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("disabling close-to-tray clears a pending fullscreen hide", async () => {
|
||||
await withPatchedTimers(async ({ flushNextTimer, getPendingTimerCount }) => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
const electronModule = createElectronStub();
|
||||
const win = new FakeWindow({ fullscreen: true });
|
||||
electronModule.BrowserWindow.getAllWindows = () => [win];
|
||||
const { ipcMain } = await enableCloseToTray(bridge, electronModule);
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() {} }, win);
|
||||
assert.equal(result, true);
|
||||
assert.equal(getPendingTimerCount(), 1);
|
||||
|
||||
await ipcMain.handlers.get("netcatty:tray:setCloseToTray")(null, { enabled: false });
|
||||
|
||||
assert.equal(getPendingTimerCount(), 0);
|
||||
assert.equal(win.listenerCount("leave-full-screen"), 0);
|
||||
assert.equal(win.listenerCount("closed"), 0);
|
||||
assert.equal(flushNextTimer(), false);
|
||||
assert.equal(win.hideCalls, 0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("handleWindowClose hides immediately when tray close is used outside fullscreen", async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: false });
|
||||
let prevented = false;
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(prevented, true);
|
||||
assert.deepEqual(win.setFullScreenCalls, []);
|
||||
assert.equal(win.hideCalls, 1);
|
||||
});
|
||||
});
|
||||
|
||||
test("handleWindowClose stays in close-to-tray mode even if hide fails", async () => {
|
||||
await withPlatform("darwin", async () => {
|
||||
const bridge = loadBridge();
|
||||
await enableCloseToTray(bridge);
|
||||
|
||||
const win = new FakeWindow({ fullscreen: false });
|
||||
win.hide = function failingHide() {
|
||||
throw new Error("hide failed");
|
||||
};
|
||||
let prevented = false;
|
||||
|
||||
const result = bridge.handleWindowClose({ preventDefault() { prevented = true; } }, win);
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.equal(prevented, true);
|
||||
assert.equal(win.visible, true);
|
||||
});
|
||||
});
|
||||
584
electron/bridges/vaultBackupBridge.cjs
Normal file
584
electron/bridges/vaultBackupBridge.cjs
Normal file
@@ -0,0 +1,584 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const BACKUP_DIR_NAME = "vault-backups";
|
||||
const BACKUP_FILE_PREFIX = "vault-backup-";
|
||||
const BACKUP_FILE_EXT = ".json";
|
||||
|
||||
// The renderer is the untrusted input boundary for this bridge, so every
|
||||
// piece of user-controlled data is validated before it reaches disk or
|
||||
// propagates back into the UI. Keep these limits in sync with the
|
||||
// renderer's `sanitizeLocalVaultBackupMaxCount` constants.
|
||||
const MIN_MAX_COUNT = 1;
|
||||
const MAX_MAX_COUNT = 100;
|
||||
const DEFAULT_MAX_COUNT = 20;
|
||||
// 25 MiB — two orders of magnitude above any realistic vault. A payload
|
||||
// exceeding this is either a runaway test harness or a misbehaving/compromised
|
||||
// renderer; refusing here prevents disk-fill DoS. The vault proper is capped
|
||||
// at a much smaller size elsewhere in the app, so legitimate users never hit
|
||||
// this limit.
|
||||
const MAX_PAYLOAD_BYTES = 25 * 1024 * 1024;
|
||||
const ALLOWED_REASONS = new Set(["app_version_change", "before_restore"]);
|
||||
// Version strings are persisted and surfaced in the Settings UI, so they
|
||||
// must not carry control chars that would break logs, parsing, or
|
||||
// display. Keep alphanumerics + a handful of punctuation that covers
|
||||
// SemVer-ish and prerelease tags.
|
||||
const VERSION_STRING_PATTERN = /^[A-Za-z0-9._+\-]{1,64}$/;
|
||||
|
||||
function isPlainObject(value) {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
// Normalize a payload into a form that hashes stably across runs:
|
||||
// - object keys sorted so JSON.stringify output is deterministic
|
||||
// - undefined values dropped (they'd stringify as gaps anyway)
|
||||
// - the TOP-LEVEL `syncedAt` timestamp is zeroed so semantically-equal
|
||||
// payloads produced seconds apart still dedupe. Nested `syncedAt`
|
||||
// fields (e.g. a future per-record mtime) are preserved — zeroing
|
||||
// them would silently collide two semantically-different payloads
|
||||
// into the same fingerprint and cause the version-change / protective
|
||||
// backup dedupe to drop a backup that should have been written.
|
||||
//
|
||||
// INVARIANT: array order is treated as semantically meaningful and is
|
||||
// NOT canonicalized. Every domain array that flows through SyncPayload
|
||||
// (hosts, keys, snippets, identities, portForwardingRules, …) is
|
||||
// produced by a store that iterates its internal `Map`/`Set` in a
|
||||
// stable, insertion-ordered way, so two semantically-equal payloads
|
||||
// built in the same renderer session produce identical orderings. If a
|
||||
// future refactor introduces a non-deterministic iteration source,
|
||||
// fingerprints will flap and the dedupe will miss — sort at the
|
||||
// producer, not here. Sorting inside the hash function would require
|
||||
// choosing a stable key per array type and would silently hide
|
||||
// intentionally-reordered payloads (user dragged a host in the list)
|
||||
// as "the same backup," which would be a safety regression.
|
||||
function normalizePayloadForHash(value, isRoot = true) {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map((item) => normalizePayloadForHash(item, false));
|
||||
}
|
||||
if (isPlainObject(value)) {
|
||||
const entries = Object.entries(value)
|
||||
.filter(([, item]) => item !== undefined)
|
||||
.sort(([a], [b]) => a.localeCompare(b));
|
||||
return entries.reduce((acc, [entryKey, entryValue]) => {
|
||||
acc[entryKey] =
|
||||
isRoot && entryKey === "syncedAt"
|
||||
? 0
|
||||
: normalizePayloadForHash(entryValue, false);
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stableStringify(value) {
|
||||
return JSON.stringify(normalizePayloadForHash(value));
|
||||
}
|
||||
|
||||
function computePayloadFingerprint(payload) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(stableStringify(payload))
|
||||
.digest("hex");
|
||||
}
|
||||
|
||||
function buildPreview(payload) {
|
||||
return {
|
||||
hostCount: Array.isArray(payload?.hosts) ? payload.hosts.length : 0,
|
||||
keyCount: Array.isArray(payload?.keys) ? payload.keys.length : 0,
|
||||
snippetCount: Array.isArray(payload?.snippets) ? payload.snippets.length : 0,
|
||||
identityCount: Array.isArray(payload?.identities) ? payload.identities.length : 0,
|
||||
portForwardingRuleCount: Array.isArray(payload?.portForwardingRules) ? payload.portForwardingRules.length : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function toBackupSummary(record) {
|
||||
return {
|
||||
id: record.id,
|
||||
createdAt: record.createdAt,
|
||||
reason: record.reason,
|
||||
sourceAppVersion: record.sourceAppVersion,
|
||||
targetAppVersion: record.targetAppVersion,
|
||||
preview: record.preview,
|
||||
fingerprint: record.fingerprint,
|
||||
};
|
||||
}
|
||||
|
||||
// Clamp an unvalidated maxCount to the supported range. Returns
|
||||
// DEFAULT_MAX_COUNT for anything non-finite or non-numeric so callers
|
||||
// without a configured retention still get a sane cap.
|
||||
function sanitizeMaxCount(rawMaxCount) {
|
||||
const numeric = Number(rawMaxCount);
|
||||
if (!Number.isFinite(numeric) || numeric <= 0) return DEFAULT_MAX_COUNT;
|
||||
return Math.max(MIN_MAX_COUNT, Math.min(MAX_MAX_COUNT, Math.floor(numeric)));
|
||||
}
|
||||
|
||||
function sanitizeReason(rawReason) {
|
||||
// Fall back to the "before_restore" default rather than throwing — the
|
||||
// default is the safer label for an unknown-cause backup, since it
|
||||
// implies "this was taken defensively" in the UI.
|
||||
if (typeof rawReason === "string" && ALLOWED_REASONS.has(rawReason)) {
|
||||
return rawReason;
|
||||
}
|
||||
return "before_restore";
|
||||
}
|
||||
|
||||
function sanitizeOptionalVersionString(value) {
|
||||
if (typeof value !== "string") return undefined;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return undefined;
|
||||
if (!VERSION_STRING_PATTERN.test(trimmed)) return undefined;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
// 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
|
||||
// labels would report ~12.5 MiB against the 25 MiB cap when the on-wire
|
||||
// size was actually 25+ MiB. `Buffer.byteLength(..., 'utf8')` gives the
|
||||
// true bytes-on-disk figure.
|
||||
function estimatePayloadSize(payload) {
|
||||
try {
|
||||
return Buffer.byteLength(JSON.stringify(payload), "utf8");
|
||||
} catch {
|
||||
return Infinity;
|
||||
}
|
||||
}
|
||||
|
||||
// Error thrown when the platform has no secure storage available. Backups
|
||||
// would contain plaintext credentials (passwords, private keys, passphrases)
|
||||
// in fields that SyncPayload carries unencrypted, so falling back to a
|
||||
// plain-json file on disk would regress the vault's security posture below
|
||||
// what the normal encrypted localStorage vault provides. We refuse rather
|
||||
// than silently weaken the user's protection.
|
||||
class VaultBackupEncryptionUnavailableError extends Error {
|
||||
constructor() {
|
||||
super(
|
||||
"Secure storage is unavailable on this platform; vault backups cannot be created or read safely.",
|
||||
);
|
||||
this.name = "VaultBackupEncryptionUnavailableError";
|
||||
this.code = "VAULT_BACKUP_ENCRYPTION_UNAVAILABLE";
|
||||
}
|
||||
}
|
||||
|
||||
class VaultBackupTooLargeError extends Error {
|
||||
constructor(size) {
|
||||
super(
|
||||
`Vault backup payload exceeds maximum allowed size (${size} > ${MAX_PAYLOAD_BYTES}).`,
|
||||
);
|
||||
this.name = "VaultBackupTooLargeError";
|
||||
this.code = "VAULT_BACKUP_TOO_LARGE";
|
||||
}
|
||||
}
|
||||
|
||||
function isSafeStorageAvailable(safeStorage) {
|
||||
return Boolean(safeStorage?.isEncryptionAvailable?.());
|
||||
}
|
||||
|
||||
function encodePayload(payload, safeStorage) {
|
||||
if (!isSafeStorageAvailable(safeStorage)) {
|
||||
throw new VaultBackupEncryptionUnavailableError();
|
||||
}
|
||||
const raw = JSON.stringify(payload);
|
||||
return {
|
||||
encoding: "safeStorage-v1",
|
||||
data: safeStorage.encryptString(raw).toString("base64"),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePayload(record, safeStorage) {
|
||||
if (record.payloadEncoding === "safeStorage-v1") {
|
||||
if (!safeStorage?.decryptString || !isSafeStorageAvailable(safeStorage)) {
|
||||
throw new VaultBackupEncryptionUnavailableError();
|
||||
}
|
||||
const decrypted = safeStorage.decryptString(Buffer.from(record.payloadData, "base64"));
|
||||
return JSON.parse(decrypted);
|
||||
}
|
||||
|
||||
// Legacy "plain-json-v1" records may exist from an earlier build; read
|
||||
// them once so users can migrate their data, but never write new ones.
|
||||
if (record.payloadEncoding === "plain-json-v1") {
|
||||
return JSON.parse(record.payloadData);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported vault backup encoding: ${record.payloadEncoding}`);
|
||||
}
|
||||
|
||||
// Upper bound for a backup file on disk. The plaintext payload is capped
|
||||
// at MAX_PAYLOAD_BYTES on write; the encrypted-and-base64-encoded record
|
||||
// plus JSON envelope inflates that by ~2x worst case (base64 adds ~33%,
|
||||
// JSON formatting adds some, and the record metadata rounds up). A 2x
|
||||
// multiplier leaves comfortable headroom for legitimate backups while
|
||||
// still rejecting a 100+ MiB file that a user (or attacker) dropped
|
||||
// into the backup directory manually.
|
||||
const MAX_BACKUP_FILE_BYTES = MAX_PAYLOAD_BYTES * 2;
|
||||
|
||||
async function readBackupRecord(filePath) {
|
||||
// Refuse oversized files BEFORE readFile. `fs.readFile` buffers the
|
||||
// whole file into memory, so an attacker (or a corrupted state) that
|
||||
// places a huge file in the backup dir could OOM the renderer during
|
||||
// listBackups enumeration. Stat-then-read keeps the failure mode to
|
||||
// a cheap rejection.
|
||||
let stat;
|
||||
try {
|
||||
stat = await fs.promises.stat(filePath);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to stat vault backup ${filePath}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (stat.size > MAX_BACKUP_FILE_BYTES) {
|
||||
throw new VaultBackupTooLargeError(stat.size);
|
||||
}
|
||||
const raw = await fs.promises.readFile(filePath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!parsed || typeof parsed !== "object" || typeof parsed.id !== "string") {
|
||||
throw new Error(`Invalid vault backup record: ${filePath}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
async function listBackupRecords(dirPath) {
|
||||
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
const entries = await fs.promises.readdir(dirPath, { withFileTypes: true });
|
||||
const records = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile()) continue;
|
||||
if (!entry.name.startsWith(BACKUP_FILE_PREFIX) || !entry.name.endsWith(BACKUP_FILE_EXT)) continue;
|
||||
const fullPath = path.join(dirPath, entry.name);
|
||||
try {
|
||||
const record = await readBackupRecord(fullPath);
|
||||
records.push({ record, filePath: fullPath });
|
||||
} catch (error) {
|
||||
console.warn("[vaultBackupBridge] Failed to parse backup:", fullPath, error);
|
||||
}
|
||||
}
|
||||
|
||||
records.sort((a, b) => {
|
||||
const aTime = Number(a.record.createdAt || 0);
|
||||
const bTime = Number(b.record.createdAt || 0);
|
||||
if (aTime !== bTime) return bTime - aTime;
|
||||
// Stable, deterministic tiebreak when two backups share a millisecond
|
||||
// (rapid successive creates, clock quantization). Without this the
|
||||
// retention trimmer's "delete the oldest" pass is order-dependent and
|
||||
// can drop a different record across list() → prune() passes.
|
||||
const aId = String(a.record.id || '');
|
||||
const bId = String(b.record.id || '');
|
||||
return bId.localeCompare(aId);
|
||||
});
|
||||
|
||||
return records;
|
||||
}
|
||||
|
||||
// Delete old backups, trusting the caller-provided `records` list when
|
||||
// supplied to avoid a redundant directory scan. `createBackup` has just
|
||||
// scanned + written, so it passes its freshly-enumerated records through
|
||||
// here. External callers (retention-change UI, trim IPC) rescan.
|
||||
async function pruneBackupRecords(dirPath, maxCount, records = null) {
|
||||
const sanitizedMaxCount = sanitizeMaxCount(maxCount);
|
||||
const sourceRecords = records ?? (await listBackupRecords(dirPath));
|
||||
const toDelete = sourceRecords.slice(sanitizedMaxCount);
|
||||
let deletedCount = 0;
|
||||
|
||||
for (const entry of toDelete) {
|
||||
try {
|
||||
await fs.promises.unlink(entry.filePath);
|
||||
deletedCount += 1;
|
||||
} catch (error) {
|
||||
console.warn("[vaultBackupBridge] Failed to delete old backup:", entry.filePath, error);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deletedCount,
|
||||
keptCount: Math.min(sourceRecords.length, sanitizedMaxCount),
|
||||
};
|
||||
}
|
||||
|
||||
function createVaultBackupService({ app, safeStorage, shell }) {
|
||||
if (!app?.getPath) {
|
||||
throw new Error("Electron app is unavailable.");
|
||||
}
|
||||
|
||||
const getBackupDir = () => path.join(app.getPath("userData"), BACKUP_DIR_NAME);
|
||||
|
||||
// Serialize createBackup so two concurrent calls (version-change backup
|
||||
// running at startup + an explicit protective-before-restore triggered
|
||||
// by the user's click, etc.) observe each other's writes. Without this,
|
||||
// both observers would see an empty directory, compute the same
|
||||
// fingerprint, skip the dedupe, and write two identical files.
|
||||
let createBackupLock = Promise.resolve();
|
||||
// Monotonically increasing `createdAt` per service instance. `Date.now()`
|
||||
// has 1ms resolution and back-to-back async calls (version-change backup
|
||||
// followed immediately by a protective backup) can land in the same
|
||||
// millisecond, producing ties that `listBackupRecords` cannot resolve
|
||||
// (the sort has no tiebreaker). Bumping ensures strict ordering so
|
||||
// callers always see the true newest record first.
|
||||
let lastCreatedAt = 0;
|
||||
|
||||
return {
|
||||
isEncryptionAvailable() {
|
||||
return isSafeStorageAvailable(safeStorage);
|
||||
},
|
||||
|
||||
async createBackup(options = {}) {
|
||||
const next = createBackupLock.then(() => doCreateBackup(options));
|
||||
// Swallow the rejection on the lock chain so one caller's error
|
||||
// does not poison subsequent calls; each individual await sees its
|
||||
// own rejection via the `next` return.
|
||||
createBackupLock = next.catch(() => undefined);
|
||||
return next;
|
||||
},
|
||||
|
||||
async listBackups() {
|
||||
const records = await listBackupRecords(getBackupDir());
|
||||
return records.map(({ record }) => toBackupSummary(record));
|
||||
},
|
||||
|
||||
async readBackup(options = {}) {
|
||||
const backupId = typeof options.id === "string" ? options.id : "";
|
||||
if (!backupId) {
|
||||
throw new Error("Missing vault backup id.");
|
||||
}
|
||||
|
||||
const records = await listBackupRecords(getBackupDir());
|
||||
const match = records.find(({ record }) => record.id === backupId);
|
||||
if (!match) {
|
||||
throw new Error("Vault backup not found.");
|
||||
}
|
||||
|
||||
return {
|
||||
backup: toBackupSummary(match.record),
|
||||
payload: decodePayload(match.record, safeStorage),
|
||||
};
|
||||
},
|
||||
|
||||
async trimBackups(options = {}) {
|
||||
return pruneBackupRecords(getBackupDir(), options.maxCount);
|
||||
},
|
||||
|
||||
async openBackupDir() {
|
||||
const dirPath = getBackupDir();
|
||||
await fs.promises.mkdir(dirPath, { recursive: true, mode: 0o700 });
|
||||
if (shell?.openPath) {
|
||||
const errorMessage = await shell.openPath(dirPath);
|
||||
if (errorMessage) {
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
path: dirPath,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
async function doCreateBackup(options) {
|
||||
const payload = options.payload;
|
||||
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
|
||||
throw new Error("Missing vault backup payload.");
|
||||
}
|
||||
|
||||
// Refuse early when the payload is too large to prevent a
|
||||
// misbehaving or compromised renderer from filling the disk. The
|
||||
// check runs before any side effect so callers see a deterministic
|
||||
// failure rather than a partial write.
|
||||
const estimatedSize = estimatePayloadSize(payload);
|
||||
if (estimatedSize > MAX_PAYLOAD_BYTES) {
|
||||
throw new VaultBackupTooLargeError(estimatedSize);
|
||||
}
|
||||
|
||||
// Refuse before doing anything side-effectful so callers get a clear
|
||||
// error rather than a silently-weakened plaintext backup.
|
||||
if (!isSafeStorageAvailable(safeStorage)) {
|
||||
throw new VaultBackupEncryptionUnavailableError();
|
||||
}
|
||||
|
||||
const dirPath = getBackupDir();
|
||||
const existingRecords = await listBackupRecords(dirPath);
|
||||
const fingerprint = computePayloadFingerprint(payload);
|
||||
const latest = existingRecords[0]?.record ?? null;
|
||||
if (latest?.fingerprint === fingerprint) {
|
||||
return {
|
||||
created: false,
|
||||
backup: toBackupSummary(latest),
|
||||
};
|
||||
}
|
||||
|
||||
let createdAt = Date.now();
|
||||
if (createdAt <= lastCreatedAt) createdAt = lastCreatedAt + 1;
|
||||
lastCreatedAt = createdAt;
|
||||
const id = crypto.randomUUID();
|
||||
const preview = buildPreview(payload);
|
||||
const encoded = encodePayload(payload, safeStorage);
|
||||
const record = {
|
||||
formatVersion: 1,
|
||||
id,
|
||||
createdAt,
|
||||
reason: sanitizeReason(options.reason),
|
||||
sourceAppVersion: sanitizeOptionalVersionString(options.sourceAppVersion),
|
||||
targetAppVersion: sanitizeOptionalVersionString(options.targetAppVersion),
|
||||
fingerprint,
|
||||
preview,
|
||||
payloadEncoding: encoded.encoding,
|
||||
payloadData: encoded.data,
|
||||
};
|
||||
|
||||
const filePath = path.join(
|
||||
dirPath,
|
||||
`${BACKUP_FILE_PREFIX}${createdAt}-${id}${BACKUP_FILE_EXT}`,
|
||||
);
|
||||
// Durable atomic write: serialize to a sibling tmp file, fsync the
|
||||
// file's data+metadata to stable storage, rename into place, then
|
||||
// fsync the directory entry itself. Without the file fsync a system
|
||||
// crash between writeFile and rename can leave the OS with a
|
||||
// successfully-renamed entry whose data blocks are still only in
|
||||
// page cache — the file is visible but reads back as zeros or torn
|
||||
// content. Without the directory fsync the rename itself may not be
|
||||
// durable: on recovery listBackups sees an empty directory even
|
||||
// though the file's blocks made it to disk. Both matter for the
|
||||
// protective-before-restore case, where the user is about to
|
||||
// overwrite their vault and the safety net MUST survive a crash
|
||||
// between backup and restore.
|
||||
const tmpPath = `${filePath}.tmp-${crypto.randomUUID()}`;
|
||||
let tmpHandle;
|
||||
try {
|
||||
tmpHandle = await fs.promises.open(tmpPath, 'w', 0o600);
|
||||
await tmpHandle.writeFile(`${JSON.stringify(record, null, 2)}\n`);
|
||||
await tmpHandle.sync();
|
||||
} finally {
|
||||
if (tmpHandle) {
|
||||
try {
|
||||
await tmpHandle.close();
|
||||
} catch {
|
||||
/* ignore — close failure after successful sync still leaves
|
||||
data durable on disk */
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
await fs.promises.rename(tmpPath, filePath);
|
||||
} catch (renameError) {
|
||||
// Best-effort cleanup; swallow unlink errors so the rename error
|
||||
// surfaces to the caller.
|
||||
try {
|
||||
await fs.promises.unlink(tmpPath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
throw renameError;
|
||||
}
|
||||
// fsync the directory so the rename itself is durably recorded.
|
||||
// On Linux this is required; on macOS it is a no-op at the FS
|
||||
// layer but still safe and portable. On Windows fs.open on a
|
||||
// directory is not supported — the rename is durable as part of
|
||||
// NTFS's journal, so skip the sync there.
|
||||
if (process.platform !== 'win32') {
|
||||
let dirHandle;
|
||||
try {
|
||||
dirHandle = await fs.promises.open(dirPath, 'r');
|
||||
await dirHandle.sync();
|
||||
} catch (dirSyncError) {
|
||||
// Directory fsync is a defense-in-depth hardening step — if
|
||||
// the filesystem refuses (tmpfs, some network mounts) the
|
||||
// rename already happened and the file is reachable, so a
|
||||
// failure here should not abort the backup. Log so a
|
||||
// systematic issue is diagnosable.
|
||||
console.warn('[vaultBackupBridge] Directory fsync failed:', dirSyncError);
|
||||
} finally {
|
||||
if (dirHandle) {
|
||||
try {
|
||||
await dirHandle.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse the enumeration we already did for dedupe, prepending the
|
||||
// newly-written record so pruneBackupRecords can trim without
|
||||
// re-scanning the directory. Records are ordered newest-first.
|
||||
const nextRecords = [{ record, filePath }, ...existingRecords];
|
||||
await pruneBackupRecords(dirPath, options.maxCount, nextRecords);
|
||||
|
||||
return {
|
||||
created: true,
|
||||
backup: toBackupSummary(record),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain, electronModule) {
|
||||
const service = createVaultBackupService({
|
||||
app: electronModule?.app,
|
||||
safeStorage: electronModule?.safeStorage,
|
||||
shell: electronModule?.shell,
|
||||
});
|
||||
|
||||
const BrowserWindow = electronModule?.BrowserWindow;
|
||||
|
||||
// Broadcast a backup-changed event to every renderer so other windows
|
||||
// (notably the Settings window's backup list) can refresh without the
|
||||
// user manually navigating. Any successful create / trim path calls
|
||||
// this. Failures fall through silently — a dropped notification is
|
||||
// recoverable on the next manual refresh, while re-throwing here
|
||||
// would turn a harmless broadcast failure into a user-visible error.
|
||||
const broadcastBackupsChanged = () => {
|
||||
if (!BrowserWindow?.getAllWindows) return;
|
||||
try {
|
||||
for (const win of BrowserWindow.getAllWindows()) {
|
||||
if (win.isDestroyed?.()) continue;
|
||||
try {
|
||||
win.webContents?.send?.("netcatty:vaultBackups:changed");
|
||||
} catch (error) {
|
||||
console.warn("[vaultBackupBridge] Failed to notify window:", error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("[vaultBackupBridge] Broadcast failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
ipcMain.handle("netcatty:vaultBackups:capabilities", async () => {
|
||||
return { encryptionAvailable: service.isEncryptionAvailable() };
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:create", async (_event, payload) => {
|
||||
const result = await service.createBackup(payload || {});
|
||||
// Only broadcast when a new record was actually written; a
|
||||
// deduped (created=false) return means the on-disk state did not
|
||||
// change, so other windows already show the latest backup.
|
||||
if (result?.created) {
|
||||
broadcastBackupsChanged();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:list", async () => {
|
||||
return service.listBackups();
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:read", async (_event, payload) => {
|
||||
return service.readBackup(payload || {});
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:trim", async (_event, payload) => {
|
||||
const result = await service.trimBackups(payload || {});
|
||||
if (result?.deletedCount) {
|
||||
broadcastBackupsChanged();
|
||||
}
|
||||
return result;
|
||||
});
|
||||
ipcMain.handle("netcatty:vaultBackups:openDir", async () => {
|
||||
return service.openBackupDir();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BACKUP_DIR_NAME,
|
||||
BACKUP_FILE_EXT,
|
||||
BACKUP_FILE_PREFIX,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
VaultBackupEncryptionUnavailableError,
|
||||
VaultBackupTooLargeError,
|
||||
buildPreview,
|
||||
computePayloadFingerprint,
|
||||
createVaultBackupService,
|
||||
registerHandlers,
|
||||
};
|
||||
626
electron/bridges/vaultBackupBridge.test.cjs
Normal file
626
electron/bridges/vaultBackupBridge.test.cjs
Normal file
@@ -0,0 +1,626 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
BACKUP_DIR_NAME,
|
||||
MAX_PAYLOAD_BYTES,
|
||||
VaultBackupEncryptionUnavailableError,
|
||||
VaultBackupTooLargeError,
|
||||
createVaultBackupService,
|
||||
} = require("./vaultBackupBridge.cjs");
|
||||
|
||||
function createTempRoot() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-vault-backup-"));
|
||||
}
|
||||
|
||||
// All tests default to encrypted=true because the bridge now refuses to
|
||||
// write plaintext backups (I1). Individual tests opt out to verify the
|
||||
// refusal path.
|
||||
function createService(rootDir, { encrypted = true } = {}) {
|
||||
const app = {
|
||||
getPath(key) {
|
||||
if (key !== "userData") throw new Error(`Unexpected path key: ${key}`);
|
||||
return rootDir;
|
||||
},
|
||||
};
|
||||
|
||||
const safeStorage = encrypted
|
||||
? {
|
||||
isEncryptionAvailable() {
|
||||
return true;
|
||||
},
|
||||
encryptString(value) {
|
||||
return Buffer.from(`enc:${value}`, "utf8");
|
||||
},
|
||||
decryptString(buffer) {
|
||||
const decoded = Buffer.from(buffer).toString("utf8");
|
||||
if (!decoded.startsWith("enc:")) throw new Error("Bad payload");
|
||||
return decoded.slice(4);
|
||||
},
|
||||
}
|
||||
: {
|
||||
isEncryptionAvailable() {
|
||||
return false;
|
||||
},
|
||||
};
|
||||
|
||||
return createVaultBackupService({
|
||||
app,
|
||||
safeStorage,
|
||||
shell: {
|
||||
openPath: async () => "",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function samplePayload(overrides = {}) {
|
||||
return {
|
||||
hosts: [
|
||||
{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod",
|
||||
username: "root",
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
},
|
||||
],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: Date.now(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("vault backups round-trip and dedupe identical payloads", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const payload = samplePayload();
|
||||
|
||||
try {
|
||||
const first = await service.createBackup({
|
||||
payload,
|
||||
reason: "app_version_change",
|
||||
sourceAppVersion: "1.0.89",
|
||||
targetAppVersion: "1.0.90",
|
||||
maxCount: 5,
|
||||
});
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(first.backup.reason, "app_version_change");
|
||||
|
||||
const duplicate = await service.createBackup({
|
||||
payload: { ...payload, syncedAt: Date.now() + 1000 },
|
||||
reason: "before_restore",
|
||||
maxCount: 5,
|
||||
});
|
||||
assert.equal(duplicate.created, false);
|
||||
assert.equal(duplicate.backup.id, first.backup.id);
|
||||
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].preview.hostCount, 1);
|
||||
|
||||
const restored = await service.readBackup({ id: first.backup.id });
|
||||
assert.equal(restored.backup.id, first.backup.id);
|
||||
assert.equal(restored.payload.hosts[0].label, "prod");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("vault backups honor retention trimming and can use encrypted payload storage", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir, { encrypted: true });
|
||||
|
||||
try {
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await service.createBackup({
|
||||
payload: {
|
||||
hosts: [{ id: `h${index}`, label: `host-${index}`, hostname: `host-${index}`, username: "root", port: 22, os: "linux", group: "", tags: [], protocol: "ssh" }],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: Date.now() + index,
|
||||
},
|
||||
reason: "before_restore",
|
||||
maxCount: 2,
|
||||
});
|
||||
}
|
||||
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 2);
|
||||
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const fileNames = fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"));
|
||||
assert.equal(fileNames.length, 2);
|
||||
|
||||
const newest = listed[0];
|
||||
const restored = await service.readBackup({ id: newest.id });
|
||||
assert.equal(restored.payload.hosts[0].id, "h2");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// I1 — plaintext refusal when safeStorage is unavailable
|
||||
// ============================================================================
|
||||
|
||||
test("createBackup refuses when safeStorage is unavailable (I1)", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir, { encrypted: false });
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: samplePayload() }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof VaultBackupEncryptionUnavailableError);
|
||||
assert.equal(err.code, "VAULT_BACKUP_ENCRYPTION_UNAVAILABLE");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
// Critical: nothing should have been written to disk. Earlier versions
|
||||
// silently wrote a plain-json-v1 record here, leaking plaintext
|
||||
// credentials (see review I1).
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const files = fs.existsSync(backupDir)
|
||||
? fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"))
|
||||
: [];
|
||||
assert.equal(files.length, 0);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("isEncryptionAvailable reports safeStorage state accurately", () => {
|
||||
const rootDir = createTempRoot();
|
||||
try {
|
||||
assert.equal(createService(rootDir, { encrypted: true }).isEncryptionAvailable(), true);
|
||||
assert.equal(createService(rootDir, { encrypted: false }).isEncryptionAvailable(), false);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Atomic writes and listBackups resilience
|
||||
// ============================================================================
|
||||
|
||||
test("listBackups ignores .tmp files left by an interrupted write", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
await service.createBackup({ payload: samplePayload() });
|
||||
|
||||
// Simulate a crash mid-write: drop a dangling .tmp file matching the
|
||||
// backup naming convention but with the atomic-write suffix.
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const tmpPath = path.join(
|
||||
backupDir,
|
||||
`vault-backup-${Date.now()}-abc.json.tmp-deadbeef`,
|
||||
);
|
||||
fs.writeFileSync(tmpPath, "{ half written", { mode: 0o600 });
|
||||
|
||||
const listed = await service.listBackups();
|
||||
// The legitimate backup is still there; the .tmp file is ignored
|
||||
// because it does not end in ".json".
|
||||
assert.equal(listed.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("listBackups tolerates a corrupted backup file by skipping it", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const ok = await service.createBackup({ payload: samplePayload() });
|
||||
assert.ok(ok.created);
|
||||
|
||||
// Drop a syntactically-invalid backup alongside the real one.
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const bogusPath = path.join(backupDir, `vault-backup-${Date.now() + 1}-bad.json`);
|
||||
fs.writeFileSync(bogusPath, "{ this is not json", { mode: 0o600 });
|
||||
|
||||
// Must not throw — the bad file is logged-and-skipped.
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 1, "corrupted file should be skipped, valid remains");
|
||||
assert.equal(listed[0].id, ok.backup.id);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Legacy plain-json-v1 migration path
|
||||
// ============================================================================
|
||||
|
||||
test("readBackup can still read legacy plain-json-v1 records for migration", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
try {
|
||||
// Hand-craft a legacy record that would have been produced by the
|
||||
// pre-I1 code path. Users on that build must still be able to read
|
||||
// and migrate off of these files.
|
||||
const createdAt = Date.now();
|
||||
const id = "legacy-record-id";
|
||||
const payload = samplePayload();
|
||||
const record = {
|
||||
formatVersion: 1,
|
||||
id,
|
||||
createdAt,
|
||||
reason: "before_restore",
|
||||
fingerprint: "legacy",
|
||||
preview: {
|
||||
hostCount: 1,
|
||||
keyCount: 0,
|
||||
snippetCount: 0,
|
||||
identityCount: 0,
|
||||
portForwardingRuleCount: 0,
|
||||
},
|
||||
payloadEncoding: "plain-json-v1",
|
||||
payloadData: JSON.stringify(payload),
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(backupDir, `vault-backup-${createdAt}-${id}.json`),
|
||||
JSON.stringify(record, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
const restored = await service.readBackup({ id });
|
||||
assert.equal(restored.payload.hosts[0].id, "h1");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readBackup throws a clear error for unknown payloadEncoding", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
fs.mkdirSync(backupDir, { recursive: true, mode: 0o700 });
|
||||
|
||||
try {
|
||||
const record = {
|
||||
formatVersion: 1,
|
||||
id: "future-record",
|
||||
createdAt: Date.now(),
|
||||
reason: "before_restore",
|
||||
fingerprint: "future",
|
||||
preview: { hostCount: 0, keyCount: 0, snippetCount: 0, identityCount: 0, portForwardingRuleCount: 0 },
|
||||
payloadEncoding: "future-algo-v9",
|
||||
payloadData: "unreadable",
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(backupDir, `vault-backup-${record.createdAt}-future.json`),
|
||||
JSON.stringify(record),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
|
||||
await assert.rejects(
|
||||
() => service.readBackup({ id: "future-record" }),
|
||||
/Unsupported vault backup encoding/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Hash normalization (I8)
|
||||
// ============================================================================
|
||||
|
||||
// ============================================================================
|
||||
// Input validation (review Important #4)
|
||||
// ============================================================================
|
||||
|
||||
test("createBackup rejects a payload larger than MAX_PAYLOAD_BYTES", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// Build a payload whose JSON serialization exceeds the cap. A single
|
||||
// large string field is the cheapest way to push past the limit without
|
||||
// an actual 25MB in-memory blob per field.
|
||||
const giant = "x".repeat(MAX_PAYLOAD_BYTES + 1);
|
||||
const oversized = samplePayload({ __bloat: giant });
|
||||
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: oversized }),
|
||||
(err) => {
|
||||
assert.ok(err instanceof VaultBackupTooLargeError);
|
||||
assert.equal(err.code, "VAULT_BACKUP_TOO_LARGE");
|
||||
return true;
|
||||
},
|
||||
);
|
||||
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const files = fs.existsSync(backupDir)
|
||||
? fs.readdirSync(backupDir).filter((name) => name.endsWith(".json"))
|
||||
: [];
|
||||
assert.equal(files.length, 0, "oversized payload must not land on disk");
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup normalizes an out-of-range reason to 'before_restore'", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const first = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "__INJECTED__\r\nlog-spoofed",
|
||||
});
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(
|
||||
first.backup.reason,
|
||||
"before_restore",
|
||||
"unknown reason must fall back to the safe enum default",
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup strips version strings with control chars or weird punctuation", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "app_version_change",
|
||||
sourceAppVersion: "1.0.0\nrm -rf /",
|
||||
targetAppVersion: " ",
|
||||
});
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backup.sourceAppVersion, undefined);
|
||||
assert.equal(result.backup.targetAppVersion, undefined);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("createBackup accepts a legitimate SemVer-ish version string", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const result = await service.createBackup({
|
||||
payload: samplePayload(),
|
||||
reason: "app_version_change",
|
||||
sourceAppVersion: "1.0.89",
|
||||
targetAppVersion: "2.0.0-rc.1",
|
||||
});
|
||||
assert.equal(result.created, true);
|
||||
assert.equal(result.backup.sourceAppVersion, "1.0.89");
|
||||
assert.equal(result.backup.targetAppVersion, "2.0.0-rc.1");
|
||||
} 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);
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: [] }),
|
||||
/Missing vault backup payload/,
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("trimBackups clamps out-of-range maxCount instead of silently defaulting", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// Seed several backups.
|
||||
for (let i = 0; i < 3; i += 1) {
|
||||
await service.createBackup({
|
||||
payload: samplePayload({ hosts: [{ id: `h${i}`, label: `h${i}`, hostname: `h${i}`, username: "u", port: 22, os: "linux", group: "", tags: [], protocol: "ssh" }] }),
|
||||
});
|
||||
}
|
||||
|
||||
// maxCount = 0 is out of range → clamped to DEFAULT (20), nothing deleted.
|
||||
const zeroResult = await service.trimBackups({ maxCount: 0 });
|
||||
assert.equal(zeroResult.deletedCount, 0);
|
||||
assert.equal((await service.listBackups()).length, 3);
|
||||
|
||||
// maxCount = 200 clamps to 100, no-op on a 3-entry set.
|
||||
const hugeResult = await service.trimBackups({ maxCount: 200 });
|
||||
assert.equal(hugeResult.deletedCount, 0);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Concurrency (review Important #5)
|
||||
// ============================================================================
|
||||
|
||||
test("concurrent createBackup calls with identical payloads dedupe via the mutex", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
const payload = samplePayload();
|
||||
|
||||
try {
|
||||
// Fire N parallel requests with the same payload. Without the mutex,
|
||||
// each call would observe an empty directory in its own tick, skip
|
||||
// dedupe, and write a distinct file. With the mutex, the first call
|
||||
// writes and each subsequent call observes the previous write and
|
||||
// dedupes.
|
||||
const results = await Promise.all(
|
||||
Array.from({ length: 5 }, () =>
|
||||
service.createBackup({ payload, reason: "before_restore" }),
|
||||
),
|
||||
);
|
||||
|
||||
const created = results.filter((r) => r.created);
|
||||
const deduped = results.filter((r) => !r.created);
|
||||
assert.equal(created.length, 1, "exactly one concurrent call should create a new backup");
|
||||
assert.equal(deduped.length, 4);
|
||||
// All results point at the same id — the first one's.
|
||||
const canonicalId = created[0].backup.id;
|
||||
for (const r of deduped) {
|
||||
assert.equal(r.backup.id, canonicalId);
|
||||
}
|
||||
|
||||
// Disk state confirms only one file landed.
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(listed.length, 1);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("a failing createBackup does not poison the mutex for subsequent calls", async () => {
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// First call rejects (invalid payload).
|
||||
await assert.rejects(
|
||||
() => service.createBackup({ payload: null }),
|
||||
/Missing vault backup payload/,
|
||||
);
|
||||
|
||||
// Next call must still succeed — the mutex chain kept moving.
|
||||
const ok = await service.createBackup({ payload: samplePayload() });
|
||||
assert.equal(ok.created, true);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fingerprint is stable when top-level syncedAt drifts", async () => {
|
||||
// The bridge zeros top-level syncedAt inside normalizePayloadForHash
|
||||
// so semantically-equal payloads dedupe. This guards the dedupe path
|
||||
// the createBackup test already covers, from the reverse direction.
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const base = samplePayload({ syncedAt: 0 });
|
||||
const first = await service.createBackup({ payload: { ...base, syncedAt: 1 } });
|
||||
const second = await service.createBackup({ payload: { ...base, syncedAt: 9_999_999 } });
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(second.created, false, "differs only by top-level syncedAt → dedupe");
|
||||
assert.equal(second.backup.id, first.backup.id);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fingerprint treats nested syncedAt as load-bearing (C1)", async () => {
|
||||
// The top-level `syncedAt` is zeroed so two payloads that differ only in
|
||||
// when-they-were-packaged still dedupe. But that zeroing must NOT cascade
|
||||
// into nested objects — a future schema where any child record carries
|
||||
// its own `syncedAt` could otherwise collide into a false dedupe, and
|
||||
// the version-change / protective backup would be silently skipped.
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
const makeNested = (nestedSyncedAt) =>
|
||||
samplePayload({
|
||||
syncedAt: 0,
|
||||
hosts: [
|
||||
{
|
||||
id: "h1",
|
||||
label: "prod",
|
||||
hostname: "prod",
|
||||
username: "root",
|
||||
port: 22,
|
||||
os: "linux",
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
syncedAt: nestedSyncedAt,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const first = await service.createBackup({ payload: makeNested(111) });
|
||||
const second = await service.createBackup({ payload: makeNested(222) });
|
||||
assert.equal(first.created, true);
|
||||
assert.equal(
|
||||
second.created,
|
||||
true,
|
||||
"nested syncedAt must NOT be zeroed — payloads are semantically different",
|
||||
);
|
||||
assert.notEqual(second.backup.id, first.backup.id);
|
||||
assert.notEqual(second.backup.fingerprint, first.backup.fingerprint);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("readBackupRecord rejects oversized files before buffering them", async () => {
|
||||
// Write-path already caps at MAX_PAYLOAD_BYTES; this guards the READ
|
||||
// path against a pre-existing or externally-placed file larger than
|
||||
// the bound, which would otherwise be slurped into memory by
|
||||
// fs.readFile inside listBackups/readBackup and risk OOMing the
|
||||
// renderer. The cap is 2x the write cap to allow for the base64 +
|
||||
// JSON-envelope inflation of legitimate records.
|
||||
const rootDir = createTempRoot();
|
||||
const service = createService(rootDir);
|
||||
|
||||
try {
|
||||
// Seed a legitimate backup so the directory exists and listBackups
|
||||
// has something to iterate past.
|
||||
const ok = await service.createBackup({ payload: samplePayload() });
|
||||
assert.ok(ok.created);
|
||||
|
||||
const backupDir = path.join(rootDir, BACKUP_DIR_NAME);
|
||||
const hugePath = path.join(
|
||||
backupDir,
|
||||
`vault-backup-${Date.now() + 1}-huge.json`,
|
||||
);
|
||||
// MAX_PAYLOAD_BYTES * 2 = 50 MiB; we write one byte past that.
|
||||
const hugeSize = MAX_PAYLOAD_BYTES * 2 + 1;
|
||||
// Pre-allocate the file without actually writing 50 MiB of content:
|
||||
// `ftruncate` produces a sparse file of the requested size on every
|
||||
// supported filesystem, so the test stays fast and uses minimal disk.
|
||||
const fd = fs.openSync(hugePath, "w", 0o600);
|
||||
try {
|
||||
fs.ftruncateSync(fd, hugeSize);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
|
||||
// listBackups now enumerates both files; the huge one should be
|
||||
// skipped with a warning (matching the corrupted-file behavior) and
|
||||
// the valid one must still come back.
|
||||
const listed = await service.listBackups();
|
||||
assert.equal(
|
||||
listed.length,
|
||||
1,
|
||||
"oversized file should be skipped during enumeration",
|
||||
);
|
||||
assert.equal(listed[0].id, ok.backup.id);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
@@ -164,6 +164,7 @@ const getCredentialBridge = createLazyModule("./bridges/credentialBridge.cjs");
|
||||
const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
|
||||
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
|
||||
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
|
||||
const getVaultBackupBridge = createLazyModule("./bridges/vaultBackupBridge.cjs");
|
||||
|
||||
// GPU settings
|
||||
// NOTE: Do not disable Chromium sandbox by default.
|
||||
@@ -332,6 +333,12 @@ function focusMainWindow() {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Cancel any in-flight close-to-tray hide so second-instance / dock-click
|
||||
// re-entry beats a pending leave-full-screen → hide sequence.
|
||||
try {
|
||||
getGlobalShortcutBridge().clearPendingFullscreenHide?.(win);
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (win.isMinimized && win.isMinimized()) win.restore();
|
||||
} catch {}
|
||||
@@ -408,6 +415,7 @@ const registerBridges = (win) => {
|
||||
const credentialBridge = getCredentialBridge();
|
||||
const autoUpdateBridge = getAutoUpdateBridge();
|
||||
const aiBridge = getAiBridge();
|
||||
const vaultBackupBridge = getVaultBackupBridge();
|
||||
|
||||
const getCloudSyncPasswordPath = () => {
|
||||
try {
|
||||
@@ -507,6 +515,7 @@ const registerBridges = (win) => {
|
||||
autoUpdateBridge.registerHandlers(ipcMain);
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
crashLogBridge.registerHandlers(ipcMain);
|
||||
vaultBackupBridge.registerHandlers(ipcMain, electronModule);
|
||||
|
||||
// ZMODEM cancel handler
|
||||
ipcMain.on("netcatty:zmodem:cancel", (_event, payload) => {
|
||||
@@ -1068,6 +1077,12 @@ if (!gotLock) {
|
||||
try {
|
||||
const mainWin = getWindowManager().getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
// If a close-to-tray hide is still pending (fullscreen exit animation
|
||||
// not finished yet), cancel it — user intent to bring the window
|
||||
// back overrides the pending hide.
|
||||
try {
|
||||
getGlobalShortcutBridge().clearPendingFullscreenHide?.(mainWin);
|
||||
} catch {}
|
||||
if (mainWin.isMinimized?.()) mainWin.restore();
|
||||
mainWin.show?.();
|
||||
mainWin.focus?.();
|
||||
|
||||
@@ -858,6 +858,35 @@ const api = {
|
||||
|
||||
// App info
|
||||
getAppInfo: () => ipcRenderer.invoke("netcatty:app:getInfo"),
|
||||
getVaultBackupCapabilities: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:capabilities"),
|
||||
createVaultBackup: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:create", payload),
|
||||
listVaultBackups: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:list"),
|
||||
readVaultBackup: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:read", payload),
|
||||
trimVaultBackups: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:trim", payload),
|
||||
openVaultBackupDir: () =>
|
||||
ipcRenderer.invoke("netcatty:vaultBackups:openDir"),
|
||||
// Subscribe to cross-window "backups changed" events emitted by the
|
||||
// main process whenever a create/trim actually mutated the on-disk
|
||||
// set. Returns an unsubscribe function so React-style consumers can
|
||||
// release the listener on unmount without leaking IPC handlers.
|
||||
onVaultBackupsChanged: (handler) => {
|
||||
if (typeof handler !== "function") return () => {};
|
||||
const listener = () => {
|
||||
try { handler(); } catch (error) {
|
||||
console.warn("[preload] onVaultBackupsChanged handler threw:", error);
|
||||
}
|
||||
};
|
||||
ipcRenderer.on("netcatty:vaultBackups:changed", listener);
|
||||
return () => {
|
||||
try { ipcRenderer.removeListener("netcatty:vaultBackups:changed", listener); }
|
||||
catch { /* ignore */ }
|
||||
};
|
||||
},
|
||||
|
||||
// Tell main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady: () => ipcRenderer.send("netcatty:renderer:ready"),
|
||||
|
||||
63
global.d.ts
vendored
63
global.d.ts
vendored
@@ -512,6 +512,69 @@ declare global {
|
||||
|
||||
// App info (name/version/platform) for About screens
|
||||
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
|
||||
getVaultBackupCapabilities?(): Promise<{ encryptionAvailable: boolean }>;
|
||||
createVaultBackup?(payload: {
|
||||
payload: import('./domain/sync').SyncPayload;
|
||||
reason: 'app_version_change' | 'before_restore';
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
maxCount?: number;
|
||||
}): Promise<{
|
||||
created: boolean;
|
||||
backup: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: 'app_version_change' | 'before_restore';
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
} | null;
|
||||
}>;
|
||||
listVaultBackups?(): Promise<Array<{
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: 'app_version_change' | 'before_restore';
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
}>>;
|
||||
readVaultBackup?(payload: { id: string }): Promise<{
|
||||
backup: {
|
||||
id: string;
|
||||
createdAt: number;
|
||||
reason: 'app_version_change' | 'before_restore';
|
||||
sourceAppVersion?: string;
|
||||
targetAppVersion?: string;
|
||||
fingerprint: string;
|
||||
preview: {
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
snippetCount: number;
|
||||
identityCount: number;
|
||||
portForwardingRuleCount: number;
|
||||
};
|
||||
};
|
||||
payload: import('./domain/sync').SyncPayload;
|
||||
}>;
|
||||
trimVaultBackups?(payload: { maxCount: number }): Promise<{ deletedCount: number; keptCount: number }>;
|
||||
openVaultBackupDir?(): Promise<{ success: boolean; path: string }>;
|
||||
// Subscribe to main-process-driven "vault backups changed" events.
|
||||
// Returns an unsubscribe callback. Undefined in non-Electron builds.
|
||||
onVaultBackupsChanged?(handler: () => void): () => void;
|
||||
|
||||
// Notify main process the renderer has mounted/painted (used to avoid initial blank screen).
|
||||
rendererReady?(): void;
|
||||
|
||||
@@ -39,6 +39,27 @@ export interface ChatMessageAttachment {
|
||||
filePath?: string; // original filesystem path (for ACP agents to read directly)
|
||||
}
|
||||
|
||||
export interface UploadedFile {
|
||||
id: string;
|
||||
filename: string;
|
||||
dataUrl: string;
|
||||
base64Data: string;
|
||||
mediaType: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export interface AIDraft {
|
||||
text: string;
|
||||
agentId: string;
|
||||
attachments: UploadedFile[];
|
||||
selectedUserSkillSlugs: string[];
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export type AIPanelView =
|
||||
| { mode: 'draft' }
|
||||
| { mode: 'session'; sessionId: string };
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system' | 'tool';
|
||||
|
||||
@@ -40,6 +40,33 @@ export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
|
||||
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
|
||||
export const STORAGE_KEY_UPDATE_LATEST_RELEASE = 'netcatty_update_latest_release_v1';
|
||||
export const STORAGE_KEY_AUTO_UPDATE_ENABLED = 'netcatty_auto_update_enabled_v1';
|
||||
export const STORAGE_KEY_LOCAL_VAULT_BACKUP_MAX_COUNT = 'netcatty_local_vault_backup_max_count_v1';
|
||||
export const STORAGE_KEY_LOCAL_VAULT_BACKUP_LAST_APP_VERSION = 'netcatty_local_vault_backup_last_app_version_v1';
|
||||
|
||||
/**
|
||||
* Cross-window barrier: set while a local vault restore is applying so
|
||||
* auto-sync in another window doesn't upload a pre-restore snapshot
|
||||
* concurrently. The value is an epoch-ms deadline — auto-sync treats any
|
||||
* value in the future as "restore in progress" and any value in the past
|
||||
* as a stale lock that can be ignored. See useAutoSync and
|
||||
* CloudSyncSettings for readers/writers.
|
||||
*/
|
||||
export const STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL = 'netcatty_vault_restore_in_progress_until_v1';
|
||||
|
||||
/**
|
||||
* Apply-in-progress sentinel. Set before a destructive applySyncPayload
|
||||
* starts writing and cleared after it completes successfully. If this
|
||||
* value is present on a later startup, the previous apply was
|
||||
* interrupted mid-way (renderer crash, power loss, IPC failure) and the
|
||||
* local vault is a partial mix of pre-apply and post-apply state.
|
||||
* Auto-sync must refuse to push in that window — otherwise the partial
|
||||
* state would silently overwrite an intact cloud copy — until the user
|
||||
* manually restores from a protective backup or completes a full merge.
|
||||
* The value is a JSON-encoded record (startedAt, protectiveBackupId,
|
||||
* source) so the UI can surface a specific recovery hint rather than a
|
||||
* generic "something broke" warning.
|
||||
*/
|
||||
export const STORAGE_KEY_VAULT_APPLY_IN_PROGRESS = 'netcatty_vault_apply_in_progress_v1';
|
||||
|
||||
// SFTP File Opener Associations
|
||||
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
|
||||
|
||||
@@ -45,8 +45,16 @@ import {
|
||||
encryptProviderSecrets,
|
||||
} from '../persistence/secureFieldAdapter';
|
||||
import { mergeSyncPayloads } from '../../domain/syncMerge';
|
||||
// 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
|
||||
// trivially forgeable by a misbehaving adapter; v2 hashes the full meta
|
||||
// plus a prefix of the ciphertext.
|
||||
import { createSyncedFileSignature as createSyncedFileSignatureImpl } from './syncSignature.js';
|
||||
import { decideRemoteChanged } from './syncAnchorDecision.js';
|
||||
|
||||
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
|
||||
const SYNC_REMOTE_ANCHOR_STORAGE_KEY = 'netcatty_sync_remote_anchor_v1';
|
||||
|
||||
// ============================================================================
|
||||
// Types
|
||||
@@ -73,6 +81,15 @@ export interface SyncManagerState {
|
||||
|
||||
export type SyncEventCallback = (event: SyncEvent) => void;
|
||||
|
||||
interface ProviderSyncAnchor {
|
||||
signature: string | null;
|
||||
version: number;
|
||||
updatedAt: number;
|
||||
deviceId?: string;
|
||||
resourceId?: string | null;
|
||||
observedAt: number;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CloudSyncManager Class
|
||||
// ============================================================================
|
||||
@@ -754,6 +771,7 @@ 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');
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: 'github',
|
||||
@@ -809,6 +827,7 @@ 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);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -847,6 +866,7 @@ export class CloudSyncManager {
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
// Clear merge base when (re)configuring to a different endpoint/bucket
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -891,6 +911,7 @@ export class CloudSyncManager {
|
||||
// Clear the merge base for this provider so reconnecting to a different
|
||||
// account/resource doesn't reuse an unrelated snapshot
|
||||
this.removeFromStorage(this.syncBaseKey(provider));
|
||||
this.clearSyncAnchor(provider);
|
||||
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
||||
}
|
||||
|
||||
@@ -925,44 +946,187 @@ export class CloudSyncManager {
|
||||
// Sync Operations
|
||||
// ==========================================================================
|
||||
|
||||
/**
|
||||
* Helper: Check for conflicts with a specific provider
|
||||
*/
|
||||
private async checkProviderConflict(
|
||||
adapter: CloudAdapter
|
||||
private syncAnchorKey(provider: CloudProvider): string {
|
||||
return `${SYNC_REMOTE_ANCHOR_STORAGE_KEY}_${provider}`;
|
||||
}
|
||||
|
||||
private createSyncedFileSignature(syncedFile: SyncedFile | null): Promise<string | null> {
|
||||
return createSyncedFileSignatureImpl(syncedFile);
|
||||
}
|
||||
|
||||
private loadSyncAnchor(provider: CloudProvider): ProviderSyncAnchor | null {
|
||||
return this.loadFromStorage<ProviderSyncAnchor>(this.syncAnchorKey(provider));
|
||||
}
|
||||
|
||||
private async saveSyncAnchor(
|
||||
provider: CloudProvider,
|
||||
syncedFile: SyncedFile | null,
|
||||
resourceId?: string | null,
|
||||
): Promise<void> {
|
||||
this.saveToStorage(this.syncAnchorKey(provider), {
|
||||
signature: await this.createSyncedFileSignature(syncedFile),
|
||||
version: syncedFile?.meta.version ?? 0,
|
||||
updatedAt: syncedFile?.meta.updatedAt ?? 0,
|
||||
deviceId: syncedFile?.meta.deviceId,
|
||||
resourceId: resourceId ?? this.state.providers[provider].resourceId ?? null,
|
||||
observedAt: Date.now(),
|
||||
} satisfies ProviderSyncAnchor);
|
||||
}
|
||||
|
||||
private clearSyncAnchor(provider?: CloudProvider): void {
|
||||
if (provider) {
|
||||
this.removeFromStorage(this.syncAnchorKey(provider));
|
||||
return;
|
||||
}
|
||||
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
|
||||
this.removeFromStorage(this.syncAnchorKey(p));
|
||||
}
|
||||
}
|
||||
|
||||
private async inspectProviderRemoteState(
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter,
|
||||
): Promise<{
|
||||
conflict: boolean;
|
||||
remoteChanged: boolean;
|
||||
remoteFile: SyncedFile | null;
|
||||
error?: string;
|
||||
remoteFile?: SyncedFile;
|
||||
}> {
|
||||
try {
|
||||
const remoteFile = await adapter.download();
|
||||
const currentSignature = await this.createSyncedFileSignature(remoteFile);
|
||||
const anchor = this.loadSyncAnchor(provider);
|
||||
const currentResourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
|
||||
|
||||
if (remoteFile) {
|
||||
// Compare versions
|
||||
if (remoteFile.meta.updatedAt > this.state.localUpdatedAt) {
|
||||
return {
|
||||
conflict: true,
|
||||
remoteFile,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { conflict: false };
|
||||
const decision = decideRemoteChanged({
|
||||
currentSignature,
|
||||
currentResourceId,
|
||||
anchor,
|
||||
hasRemoteFile: Boolean(remoteFile),
|
||||
});
|
||||
|
||||
return {
|
||||
remoteChanged: decision.remoteChanged,
|
||||
remoteFile,
|
||||
};
|
||||
} catch (error) {
|
||||
return { conflict: false, error: String(error) };
|
||||
return {
|
||||
remoteChanged: false,
|
||||
remoteFile: null,
|
||||
error: String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Check for conflicts with a specific provider
|
||||
*
|
||||
* Fails closed on inspection error: throws rather than returning a
|
||||
* `{conflict: false, error}` tuple. The previous return-shape let
|
||||
* `syncAll`'s `validUploads` filter — which checks `!r.error` (the
|
||||
* outer per-provider try/catch error) and `!r.check?.conflict` but
|
||||
* NOT `r.check?.error` — admit this provider into the upload batch
|
||||
* with `conflict: false`, which then proceeded to upload stale local
|
||||
* data over the remote (the exact #711/#719 failure mode on a
|
||||
* transient download 5xx). Throwing surfaces the failure through the
|
||||
* same per-provider try/catch that already handles connection errors.
|
||||
*/
|
||||
private async checkProviderConflict(
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter
|
||||
): Promise<{
|
||||
conflict: boolean;
|
||||
remoteFile?: SyncedFile;
|
||||
}> {
|
||||
const inspection = await this.inspectProviderRemoteState(provider, adapter);
|
||||
if (inspection.error) {
|
||||
throw new Error(inspection.error);
|
||||
}
|
||||
return {
|
||||
conflict: inspection.remoteChanged && Boolean(inspection.remoteFile),
|
||||
remoteFile: inspection.remoteFile ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
async inspectProviderRemote(provider: CloudProvider): Promise<{
|
||||
remoteChanged: boolean;
|
||||
remoteFile: SyncedFile | null;
|
||||
payload: SyncPayload | null;
|
||||
}> {
|
||||
if (this.state.securityState !== 'UNLOCKED' || !this.masterPassword) {
|
||||
throw new Error('Vault is locked');
|
||||
}
|
||||
|
||||
const adapter = await this.getConnectedAdapter(provider);
|
||||
const inspection = await this.inspectProviderRemoteState(provider, adapter);
|
||||
if (inspection.error) {
|
||||
throw new Error(inspection.error);
|
||||
}
|
||||
|
||||
if (!inspection.remoteFile) {
|
||||
return {
|
||||
remoteChanged: inspection.remoteChanged,
|
||||
remoteFile: null,
|
||||
payload: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
remoteChanged: inspection.remoteChanged,
|
||||
remoteFile: inspection.remoteFile,
|
||||
payload: await EncryptionService.decryptPayload(inspection.remoteFile, this.masterPassword),
|
||||
};
|
||||
}
|
||||
|
||||
async commitRemoteInspection(
|
||||
provider: CloudProvider,
|
||||
remoteFile: SyncedFile,
|
||||
payload: SyncPayload,
|
||||
): Promise<void> {
|
||||
const adapter = await this.getConnectedAdapter(provider);
|
||||
const resourceId = adapter.resourceId || this.state.providers[provider].resourceId || null;
|
||||
if (resourceId && this.state.providers[provider].resourceId !== resourceId) {
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
resourceId,
|
||||
};
|
||||
}
|
||||
|
||||
this.state.localVersion = remoteFile.meta.version;
|
||||
this.state.localUpdatedAt = remoteFile.meta.updatedAt;
|
||||
this.state.remoteVersion = remoteFile.meta.version;
|
||||
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
|
||||
this.state.providers[provider].lastSync = Date.now();
|
||||
this.state.providers[provider].lastSyncVersion = remoteFile.meta.version;
|
||||
|
||||
this.saveSyncConfig();
|
||||
await this.saveSyncAnchor(provider, remoteFile, resourceId);
|
||||
await this.saveSyncBase(payload, provider);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Upload encrypted file to a provider
|
||||
*
|
||||
* `payloadForBase`, when supplied, is persisted as the new sync base
|
||||
* BEFORE the anchor is advanced. Ordering matters: if the renderer
|
||||
* crashes between the two writes, the next startup's inspect must
|
||||
* either (a) see no anchor advance and re-merge against the fresh
|
||||
* base, or (b) see both advanced consistently. The previous ordering
|
||||
* (anchor before base) allowed a crash window where the next run
|
||||
* saw "remote unchanged" (anchor matched) but silently kept a stale
|
||||
* base, so a subsequent 3-way merge could misclassify entries that
|
||||
* landed in this upload.
|
||||
*/
|
||||
private async uploadToProvider(
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter,
|
||||
syncedFile: SyncedFile
|
||||
syncedFile: SyncedFile,
|
||||
payloadForBase?: SyncPayload,
|
||||
): Promise<SyncResult> {
|
||||
try {
|
||||
await adapter.upload(syncedFile);
|
||||
const resourceId = await adapter.upload(syncedFile);
|
||||
this.state.lastError = null;
|
||||
|
||||
// Update local state (safe to do multiple times if values are same)
|
||||
@@ -973,10 +1137,21 @@ export class CloudSyncManager {
|
||||
// Invalidate any pending provider decrypt so it cannot overwrite
|
||||
// the lastSync/lastSyncVersion we are about to set.
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider].lastSync = Date.now();
|
||||
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
resourceId: resourceId || this.state.providers[provider].resourceId,
|
||||
lastSync: Date.now(),
|
||||
lastSyncVersion: syncedFile.meta.version,
|
||||
};
|
||||
|
||||
this.saveSyncConfig();
|
||||
// Persist base BEFORE anchor so a crash between them degrades
|
||||
// safely: the stale anchor forces re-inspection next run, which
|
||||
// merges against the fresh base and cannot silently drift.
|
||||
if (payloadForBase) {
|
||||
await this.saveSyncBase(payloadForBase, provider);
|
||||
}
|
||||
await this.saveSyncAnchor(provider, syncedFile, resourceId);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange();
|
||||
|
||||
@@ -1090,12 +1265,11 @@ export class CloudSyncManager {
|
||||
this.emit({ type: 'SYNC_STARTED', provider });
|
||||
|
||||
try {
|
||||
// 1. Check for conflict
|
||||
const checkResult = await this.checkProviderConflict(adapter);
|
||||
|
||||
if (checkResult.error) {
|
||||
throw new Error(checkResult.error);
|
||||
}
|
||||
// 1. Check for conflict. `checkProviderConflict` throws on
|
||||
// inspect failure, which the outer try/catch routes to the
|
||||
// SYNC_ERROR path — so we never reach the upload branch with an
|
||||
// unknown remote state.
|
||||
const checkResult = await this.checkProviderConflict(provider, adapter);
|
||||
|
||||
if (checkResult.conflict && checkResult.remoteFile) {
|
||||
// Remote is newer — attempt three-way merge instead of blocking
|
||||
@@ -1112,7 +1286,7 @@ export class CloudSyncManager {
|
||||
const base = await this.loadSyncBase(provider);
|
||||
const mergeResult = mergeSyncPayloads(base, payload, remotePayload);
|
||||
|
||||
console.log('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
|
||||
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
|
||||
|
||||
// Encrypt and upload merged payload
|
||||
const mergedSyncedFile = await EncryptionService.encryptPayload(
|
||||
@@ -1124,10 +1298,17 @@ export class CloudSyncManager {
|
||||
checkResult.remoteFile.meta.version, // base on remote version
|
||||
);
|
||||
|
||||
const uploadResult = await this.uploadToProvider(provider, adapter, mergedSyncedFile);
|
||||
const uploadResult = await this.uploadToProvider(
|
||||
provider,
|
||||
adapter,
|
||||
mergedSyncedFile,
|
||||
mergeResult.payload,
|
||||
);
|
||||
|
||||
if (uploadResult.success) {
|
||||
await this.saveSyncBase(mergeResult.payload, provider);
|
||||
// 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.state.syncState = 'IDLE';
|
||||
|
||||
this.addSyncHistoryEntry({
|
||||
@@ -1190,11 +1371,12 @@ export class CloudSyncManager {
|
||||
this.state.localVersion
|
||||
);
|
||||
|
||||
// 3. Upload
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile);
|
||||
// 3. Upload — base is persisted inside uploadToProvider before
|
||||
// the anchor advances so a crash between them cannot leave the
|
||||
// base pointing at a pre-upload snapshot.
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile, payload);
|
||||
|
||||
if (result.success) {
|
||||
await this.saveSyncBase(payload, provider);
|
||||
this.state.syncState = 'IDLE';
|
||||
} else {
|
||||
this.state.syncState = 'ERROR';
|
||||
@@ -1260,14 +1442,7 @@ export class CloudSyncManager {
|
||||
throw new Error(`Decryption failed (master password may differ between devices): ${decryptError instanceof Error ? decryptError.message : String(decryptError)}`);
|
||||
}
|
||||
|
||||
// Update local tracking
|
||||
this.state.localVersion = remoteFile.meta.version;
|
||||
this.state.localUpdatedAt = remoteFile.meta.updatedAt;
|
||||
this.state.remoteVersion = remoteFile.meta.version;
|
||||
this.state.remoteUpdatedAt = remoteFile.meta.updatedAt;
|
||||
this.saveSyncConfig();
|
||||
await this.saveSyncBase(payload, provider);
|
||||
this.notifyStateChange(); // Notify UI of state change
|
||||
await this.commitRemoteInspection(provider, remoteFile, payload);
|
||||
|
||||
// Add to sync history
|
||||
this.addSyncHistoryEntry({
|
||||
@@ -1436,7 +1611,7 @@ export class CloudSyncManager {
|
||||
this.updateProviderStatus(provider, 'syncing');
|
||||
this.emit({ type: 'SYNC_STARTED', provider });
|
||||
|
||||
const check = await this.checkProviderConflict(adapter);
|
||||
const check = await this.checkProviderConflict(provider, adapter);
|
||||
return { provider, adapter, check };
|
||||
} catch (error) {
|
||||
return { provider, error: String(error) };
|
||||
@@ -1446,8 +1621,50 @@ export class CloudSyncManager {
|
||||
const checkResults = await Promise.all(checkTasks);
|
||||
|
||||
// 2. Analyze Results & Handle Conflicts — merge ALL conflicting providers
|
||||
//
|
||||
// Contract: every connected provider is assumed to mirror the *same*
|
||||
// logical vault. When providers hold divergent content (e.g. user
|
||||
// intentionally points GitHub and OneDrive at separate accounts with
|
||||
// different data), uploading the conflict-merged payload below will
|
||||
// overwrite provider-unique content on non-conflicting providers. A
|
||||
// proper fix requires per-provider compare-and-swap (follow-up work,
|
||||
// see I-1 and `docs/`). Until then, we log a diagnostic warning when
|
||||
// we detect cross-provider base divergence so the issue is visible in
|
||||
// support logs.
|
||||
const conflicts = checkResults.filter((r) => !r.error && r.check?.conflict && r.check?.remoteFile);
|
||||
|
||||
// Instrumentation only — detect divergent provider bases (an
|
||||
// unsupported configuration). Cheap: bases are already persisted
|
||||
// and we only read their aggregate counts.
|
||||
if (checkResults.filter((r) => !r.error).length > 1) {
|
||||
try {
|
||||
const summaries = await Promise.all(
|
||||
checkResults
|
||||
.filter((r) => !r.error)
|
||||
.map(async (r) => {
|
||||
const base = await this.loadSyncBase(r.provider as CloudProvider);
|
||||
return {
|
||||
provider: r.provider,
|
||||
hosts: base?.hosts?.length ?? 0,
|
||||
keys: base?.keys?.length ?? 0,
|
||||
snippets: base?.snippets?.length ?? 0,
|
||||
};
|
||||
}),
|
||||
);
|
||||
const signatures = summaries.map((s) => `${s.hosts}/${s.keys}/${s.snippets}`);
|
||||
const allSame = signatures.every((sig) => sig === signatures[0]);
|
||||
if (!allSame) {
|
||||
console.warn(
|
||||
'[CloudSyncManager] syncAll: connected providers hold divergent bases (multi-account setup?). Uploading the conflict-merged payload will replace each provider\'s current remote. See I-7 in PR #720 for context.',
|
||||
summaries,
|
||||
);
|
||||
}
|
||||
} catch (diagError) {
|
||||
// Non-fatal diagnostic; never let it block the sync.
|
||||
console.warn('[CloudSyncManager] syncAll: base-divergence check failed:', diagError);
|
||||
}
|
||||
}
|
||||
|
||||
if (conflicts.length > 0) {
|
||||
// Three-way merge: incorporate remote data from every conflicting provider
|
||||
try {
|
||||
@@ -1463,7 +1680,7 @@ export class CloudSyncManager {
|
||||
}
|
||||
const mergeResult = { payload: merged };
|
||||
|
||||
console.log('[CloudSyncManager] syncAll: three-way merge completed');
|
||||
console.info('[CloudSyncManager] syncAll: three-way merge completed');
|
||||
|
||||
// Replace payload with merged payload for upload to all providers
|
||||
payload = mergeResult.payload;
|
||||
@@ -1587,9 +1804,13 @@ export class CloudSyncManager {
|
||||
return results;
|
||||
}
|
||||
|
||||
// 4. Parallel Uploads
|
||||
// 4. Parallel Uploads — pass the payload so base is persisted
|
||||
// inside uploadToProvider BEFORE the per-provider anchor advances.
|
||||
// Ordering matters: a crash between the two writes must leave the
|
||||
// stale anchor re-triggering inspection on next startup, not a
|
||||
// fresh anchor paired with a stale base.
|
||||
const uploadTasks = validUploads.map(async ({ provider, adapter }) => {
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile);
|
||||
const result = await this.uploadToProvider(provider, adapter, syncedFile, payload);
|
||||
results.set(provider, result);
|
||||
});
|
||||
|
||||
@@ -1599,12 +1820,6 @@ export class CloudSyncManager {
|
||||
const hasSuccess = Array.from(results.values()).some((r) => r.success);
|
||||
if (hasSuccess) {
|
||||
this.state.syncState = 'IDLE';
|
||||
// Save base per provider that successfully uploaded
|
||||
if (payload) {
|
||||
for (const [p, r] of results) {
|
||||
if (r.success) await this.saveSyncBase(payload, p);
|
||||
}
|
||||
}
|
||||
|
||||
// If a merge happened, attach the merged payload to successful results
|
||||
// so callers can apply remote additions to local state
|
||||
@@ -1750,6 +1965,7 @@ export class CloudSyncManager {
|
||||
for (const p of ['github', 'google', 'onedrive', 'webdav', 's3'] as const) {
|
||||
this.removeFromStorage(this.syncBaseKey(p));
|
||||
}
|
||||
this.clearSyncAnchor();
|
||||
}
|
||||
|
||||
private addSyncHistoryEntry(entry: Omit<SyncHistoryEntry, 'id'>): void {
|
||||
@@ -1780,6 +1996,7 @@ export class CloudSyncManager {
|
||||
this.saveSyncConfig();
|
||||
this.saveToStorage(SYNC_HISTORY_STORAGE_KEY, []);
|
||||
this.clearSyncBase();
|
||||
this.clearSyncAnchor();
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
|
||||
73
infrastructure/services/syncAnchorDecision.js
Normal file
73
infrastructure/services/syncAnchorDecision.js
Normal file
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* syncAnchorDecision — pure "has the remote changed since we last saw it?"
|
||||
* logic extracted from CloudSyncManager so it can be exercised by
|
||||
* `node --test` without standing up the full manager harness.
|
||||
*
|
||||
* Called from CloudSyncManager.inspectProviderRemoteState after the
|
||||
* remote has been downloaded and its signature computed. Given the
|
||||
* previous anchor and the current state, decides whether the remote
|
||||
* looks different enough to warrant re-merging.
|
||||
*
|
||||
* Four decisions matter for data integrity:
|
||||
*
|
||||
* 1. Anchor missing + remote empty → not changed (first sync, nothing
|
||||
* to merge from). Callers MUST still guard against pushing an empty
|
||||
* local vault (see useAutoSync `hasMeaningfulSyncData`) — that guard
|
||||
* is orthogonal to this decision.
|
||||
* 2. Anchor missing + remote non-empty → changed (first sync, remote
|
||||
* has data we've never observed → three-way merge with empty base).
|
||||
* 3. Anchor present + resourceId drift → changed (provider created a
|
||||
* fresh file; reuse of the old anchor would be meaningless).
|
||||
* 4. Anchor present + signature mismatch → changed (same resource, new
|
||||
* ciphertext — standard drift).
|
||||
*
|
||||
* Any other state is "unchanged", and callers short-circuit the merge.
|
||||
*
|
||||
* @param {{
|
||||
* currentSignature: string | null,
|
||||
* currentResourceId: string | null,
|
||||
* anchor: { signature?: string | null, resourceId?: string | null } | null,
|
||||
* hasRemoteFile: boolean,
|
||||
* }} input
|
||||
* @returns {{ remoteChanged: boolean, reason: string }}
|
||||
*/
|
||||
export function decideRemoteChanged(input) {
|
||||
const { currentSignature, currentResourceId, anchor, hasRemoteFile } = input;
|
||||
|
||||
if (!anchor) {
|
||||
// No anchor means we've never observed this provider.
|
||||
if (!hasRemoteFile) {
|
||||
// Remote has no file at all → nothing to merge.
|
||||
return { remoteChanged: false, reason: 'no-anchor-no-remote' };
|
||||
}
|
||||
if (currentSignature === null) {
|
||||
// hasRemoteFile=true but the signature computed to null — the
|
||||
// file exists but we can't hash its meta (malformed shape, newer
|
||||
// schema, partial download). Treat as CHANGED so the caller
|
||||
// routes through the three-way merge / decrypt path rather than
|
||||
// silently short-circuiting and letting the next upload overwrite
|
||||
// an unreadable-but-extant remote file. If the payload is
|
||||
// decryptable the merge will succeed; if it isn't, the decrypt
|
||||
// error surfaces to the user, which is strictly safer than a
|
||||
// silent stomp.
|
||||
return { remoteChanged: true, reason: 'unreadable-remote' };
|
||||
}
|
||||
return { remoteChanged: true, reason: 'no-anchor-remote-has-data' };
|
||||
}
|
||||
|
||||
// Resource identity drift: provider returned a different resource
|
||||
// (e.g. a freshly-created gist, or the user reconnected and the
|
||||
// adapter picked a new file). The previous anchor's signature is
|
||||
// meaningless once the resource id changes.
|
||||
const anchorResourceId = anchor.resourceId ?? null;
|
||||
if (anchorResourceId !== currentResourceId) {
|
||||
return { remoteChanged: true, reason: 'resource-id-changed' };
|
||||
}
|
||||
|
||||
// Same resource, different signature → new ciphertext/meta.
|
||||
if ((anchor.signature ?? null) !== currentSignature) {
|
||||
return { remoteChanged: true, reason: 'signature-mismatch' };
|
||||
}
|
||||
|
||||
return { remoteChanged: false, reason: 'anchor-matches' };
|
||||
}
|
||||
213
infrastructure/services/syncAnchorDecision.test.mjs
Normal file
213
infrastructure/services/syncAnchorDecision.test.mjs
Normal file
@@ -0,0 +1,213 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { decideRemoteChanged } from './syncAnchorDecision.js';
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Anchor-missing branches
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('no anchor + empty remote → not changed (first sync with empty cloud)', () => {
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: null,
|
||||
currentResourceId: null,
|
||||
anchor: null,
|
||||
hasRemoteFile: false,
|
||||
});
|
||||
assert.equal(result.remoteChanged, false);
|
||||
assert.equal(result.reason, 'no-anchor-no-remote');
|
||||
});
|
||||
|
||||
test('no anchor + non-empty remote → changed (first sync with data in cloud)', () => {
|
||||
// Critical: this is the "new device with existing cloud vault" path.
|
||||
// Returning not-changed here would silently skip the three-way merge
|
||||
// and let an empty local push clobber remote.
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: 'v3:sig-remote',
|
||||
currentResourceId: 'gist-1',
|
||||
anchor: null,
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, true);
|
||||
assert.equal(result.reason, 'no-anchor-remote-has-data');
|
||||
});
|
||||
|
||||
test('no anchor + hasRemoteFile true but null signature → changed (unreadable remote, C3)', () => {
|
||||
// Previously this returned `remoteChanged: false`, which silently
|
||||
// routed callers down the "nothing to merge" short-circuit and then
|
||||
// let the upload path stomp the malformed-but-extant remote file on
|
||||
// the next push. Treating an unreadable remote as "changed" forces the
|
||||
// three-way-merge branch — if the payload is decryptable the merge
|
||||
// succeeds, and if it isn't the decrypt error surfaces to the user
|
||||
// instead of being silently papered over by an overwrite.
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: null,
|
||||
currentResourceId: 'gist-1',
|
||||
anchor: null,
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, true);
|
||||
assert.equal(result.reason, 'unreadable-remote');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Anchor-matches branches
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('anchor matches signature and resourceId → not changed', () => {
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: 'v3:sig-A',
|
||||
currentResourceId: 'gist-1',
|
||||
anchor: { signature: 'v3:sig-A', resourceId: 'gist-1' },
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, false);
|
||||
assert.equal(result.reason, 'anchor-matches');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Anchor-stale branches
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('anchor signature mismatch → changed', () => {
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: 'v3:sig-NEW',
|
||||
currentResourceId: 'gist-1',
|
||||
anchor: { signature: 'v3:sig-OLD', resourceId: 'gist-1' },
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, true);
|
||||
assert.equal(result.reason, 'signature-mismatch');
|
||||
});
|
||||
|
||||
test('anchor resourceId mismatch → changed (even when signatures happen to match)', () => {
|
||||
// Provider created a fresh file (gist recreated, Drive file recreated).
|
||||
// The old anchor's signature is meaningless once the resource id drifts.
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: 'v3:sig-SAME',
|
||||
currentResourceId: 'gist-NEW',
|
||||
anchor: { signature: 'v3:sig-SAME', resourceId: 'gist-OLD' },
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, true);
|
||||
assert.equal(result.reason, 'resource-id-changed');
|
||||
});
|
||||
|
||||
test('anchor resourceId was null, now has value → changed', () => {
|
||||
// Before: user connected but first-sync had no resource yet.
|
||||
// Now: provider returned a concrete id. Treat as changed so the
|
||||
// follow-up re-inspects correctly.
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: 'v3:sig-A',
|
||||
currentResourceId: 'gist-1',
|
||||
anchor: { signature: 'v3:sig-A', resourceId: null },
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, true);
|
||||
assert.equal(result.reason, 'resource-id-changed');
|
||||
});
|
||||
|
||||
test('anchor resourceId had value, now null → changed', () => {
|
||||
// Adapter lost the resource id somehow (disconnect, re-login). The
|
||||
// old signature-based comparison is not trustworthy here.
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: 'v3:sig-A',
|
||||
currentResourceId: null,
|
||||
anchor: { signature: 'v3:sig-A', resourceId: 'gist-1' },
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, true);
|
||||
assert.equal(result.reason, 'resource-id-changed');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Defensive shapes
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('anchor with undefined signature → changed unless current is also null', () => {
|
||||
// `anchor.signature` missing (pre-v2 persisted record, say) and
|
||||
// `currentSignature` non-null → must not treat as match.
|
||||
const changed = decideRemoteChanged({
|
||||
currentSignature: 'v3:sig',
|
||||
currentResourceId: 'id-1',
|
||||
anchor: { resourceId: 'id-1' },
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(changed.remoteChanged, true);
|
||||
assert.equal(changed.reason, 'signature-mismatch');
|
||||
});
|
||||
|
||||
test('anchor signature null and current signature null with same resourceId → not changed', () => {
|
||||
// The legitimate "empty-on-both-sides already observed" case.
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: null,
|
||||
currentResourceId: 'id-1',
|
||||
anchor: { signature: null, resourceId: 'id-1' },
|
||||
hasRemoteFile: false,
|
||||
});
|
||||
assert.equal(result.remoteChanged, false);
|
||||
assert.equal(result.reason, 'anchor-matches');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Migration: stored v2 anchor → fresh v3 signature from this build
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('v2 anchor persisted from older build → signature-mismatch against v3 (migration)', () => {
|
||||
// A user upgrading from a build that persisted `v2:<prefix-hash>` must
|
||||
// see the next startup inspection treat the remote as "changed". The
|
||||
// v3 signature format is `v3:{...meta}|len=...|sha256=...`; the two
|
||||
// strings can never compare equal, so the decision routes through
|
||||
// three-way merge and re-observes the remote. Without this property
|
||||
// a stale v2 anchor would be treated as authoritative, skipping the
|
||||
// merge and letting local-only state overwrite remote — the very
|
||||
// #711/#719 failure path.
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: 'v3:{"appVersion":"1.0.0"}|len=80|sha256=' + 'a'.repeat(64),
|
||||
currentResourceId: 'gist-1',
|
||||
anchor: { signature: 'v2:abcdef1234567890', resourceId: 'gist-1' },
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, true);
|
||||
assert.equal(result.reason, 'signature-mismatch');
|
||||
});
|
||||
|
||||
// -----------------------------------------------------------------------
|
||||
// Regression: issues #711 / #719 — stale-device-overwrites-newer-remote
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
test('stale device sees fresh remote → triggers merge, not overwrite (#711/#719)', () => {
|
||||
// Scenario: Device A syncs at T0, anchor records signature sigA.
|
||||
// User edits on Device B at T1 → remote signature becomes sigB.
|
||||
// Device A then wakes up with a stale anchor (sigA) and the fresh
|
||||
// remote (sigB). The decision MUST say "remote changed" so the
|
||||
// sync path three-way merges Device A's local into remote instead
|
||||
// of short-circuiting to "no change" and overwriting Device B's edit.
|
||||
const sigA = 'v3:{"updatedAt":1700000000000}|len=80|sha256=' + 'a'.repeat(64);
|
||||
const sigB = 'v3:{"updatedAt":1700000300000}|len=80|sha256=' + 'b'.repeat(64);
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: sigB,
|
||||
currentResourceId: 'gist-1',
|
||||
anchor: { signature: sigA, resourceId: 'gist-1' },
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, true);
|
||||
assert.equal(result.reason, 'signature-mismatch');
|
||||
});
|
||||
|
||||
test('fresh device, same-signature anchor → no spurious merge (#711/#719 inverse)', () => {
|
||||
// Inverse guard: a device whose anchor matches the current remote
|
||||
// signature must NOT be dragged through a merge round-trip, which
|
||||
// would cause the "everyone re-uploads on every startup" thrash seen
|
||||
// in the pre-anchor implementation. This locks in that the anchor
|
||||
// logic correctly short-circuits the common case.
|
||||
const sig = 'v3:{"updatedAt":1700000000000}|len=80|sha256=' + 'a'.repeat(64);
|
||||
const result = decideRemoteChanged({
|
||||
currentSignature: sig,
|
||||
currentResourceId: 'gist-1',
|
||||
anchor: { signature: sig, resourceId: 'gist-1' },
|
||||
hasRemoteFile: true,
|
||||
});
|
||||
assert.equal(result.remoteChanged, false);
|
||||
assert.equal(result.reason, 'anchor-matches');
|
||||
});
|
||||
130
infrastructure/services/syncSignature.js
Normal file
130
infrastructure/services/syncSignature.js
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* syncSignature - Provider-agnostic remote snapshot fingerprint.
|
||||
*
|
||||
* Stable, order-independent signature of a SyncedFile used by
|
||||
* CloudSyncManager to decide whether a remote has changed since we last
|
||||
* observed it. Must produce the same value for semantically-identical
|
||||
* remotes and a different value for any ciphertext/metadata change.
|
||||
*
|
||||
* Kept as a plain ESM .js file (JSDoc-typed) so it works seamlessly with
|
||||
* both Vite's bundler in the renderer AND Node's `node --test` harness
|
||||
* without needing a TypeScript test runner. CloudSyncManager.ts imports
|
||||
* it via a normal ESM import.
|
||||
*
|
||||
* The previous implementation in CloudSyncManager only hashed
|
||||
* `[version, updatedAt, deviceId, iv, salt]`. That meant:
|
||||
* - a misbehaving adapter could replay those five fields while
|
||||
* mutating algorithm/kdf/appVersion and the anchor would treat the
|
||||
* remote as unchanged;
|
||||
* - deviceId (a field the remote controls) was weighted as strongly
|
||||
* as iv/salt;
|
||||
* - ciphertext changes with metadata held constant could slip past.
|
||||
*
|
||||
* v3 hashes the full meta object (sorted for stability) plus the
|
||||
* SHA-256 of the full payload ciphertext so any of those mutations flip
|
||||
* the anchor. v2 used only a 64-char prefix of the ciphertext, which is
|
||||
* easily defeated by an adversary that controls the remote and can
|
||||
* tail-mutate while preserving the prefix. v3 is resistant to any
|
||||
* ciphertext mutation.
|
||||
*
|
||||
* Version prefixes are part of the signature string itself (`v3:`) so
|
||||
* an older anchor persisted from a previous build will simply never
|
||||
* compare equal to a fresh signature from this build, forcing a
|
||||
* single-cycle safe re-detection (treated as "remote changed" which
|
||||
* triggers three-way merge) rather than a silent mismatch.
|
||||
*
|
||||
* INVARIANT: `meta` values must be primitives (strings, numbers,
|
||||
* booleans, null/undefined). Nested objects or arrays in meta would
|
||||
* serialize via JSON.stringify, which does NOT sort keys — breaking
|
||||
* signature stability. All current SyncedFile meta fields satisfy this.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Sentinel error for a missing WebCrypto subtle digest — see
|
||||
* `sha256Hex` and `createSyncedFileSignature` for the fail-closed
|
||||
* handling.
|
||||
*/
|
||||
class SyncSignatureUnavailableError extends Error {
|
||||
constructor() {
|
||||
super('WebCrypto subtle.digest is unavailable; signature cannot be computed safely.');
|
||||
this.name = 'SyncSignatureUnavailableError';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-256 of a UTF-8 string, returning lowercase hex.
|
||||
*
|
||||
* Uses `globalThis.crypto.subtle` (Web Crypto API) which is available in
|
||||
* both the Electron renderer and Node.js ≥ 19 (the repo's runtime targets
|
||||
* both, and CI/tests run under Node). Keeping to the Web Crypto API also
|
||||
* avoids pulling `node:crypto` into the renderer bundle.
|
||||
*
|
||||
* Throws `SyncSignatureUnavailableError` when subtle.digest is missing.
|
||||
* Earlier revisions returned a length-only fallback string (`nosha-N`),
|
||||
* which would produce a short, truncation-trivial pseudo-signature that
|
||||
* an attacker controlling the remote could alias against a legitimate
|
||||
* v3 signature of the same length. Failing loudly here lets the caller
|
||||
* in `createSyncedFileSignature` return `null`, which routes through
|
||||
* the "unreadable remote → treat as changed → three-way merge or
|
||||
* surface decrypt error" path — strictly safer than a weak signature.
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function sha256Hex(input) {
|
||||
const subtle = globalThis.crypto?.subtle;
|
||||
if (!subtle?.digest) {
|
||||
throw new SyncSignatureUnavailableError();
|
||||
}
|
||||
const bytes = new globalThis.TextEncoder().encode(input);
|
||||
const buf = await subtle.digest('SHA-256', bytes);
|
||||
const arr = new Uint8Array(buf);
|
||||
let hex = '';
|
||||
for (let i = 0; i < arr.length; i += 1) {
|
||||
hex += arr[i].toString(16).padStart(2, '0');
|
||||
}
|
||||
return hex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import('../../domain/sync').SyncedFile | null} syncedFile
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function createSyncedFileSignature(syncedFile) {
|
||||
if (!syncedFile) return null;
|
||||
const { meta, payload } = syncedFile;
|
||||
if (!meta || typeof meta !== 'object') return null;
|
||||
|
||||
// Serialize meta as a canonical JSON object with keys sorted. Earlier
|
||||
// versions joined `${key}=${JSON.stringify(...)}` with `|`, which left
|
||||
// the `=` separator unescaped: a future meta key containing `=` in its
|
||||
// name (or a string value that mimics the separator syntax) could
|
||||
// alias with a different key/value pair. JSON.stringify of a sorted
|
||||
// plain object is injection-proof because string values are quoted
|
||||
// and escaped by the serializer.
|
||||
const metaKeys = Object.keys(meta).sort();
|
||||
const canonicalMeta = {};
|
||||
for (const key of metaKeys) {
|
||||
canonicalMeta[key] = meta[key] ?? null;
|
||||
}
|
||||
const metaSerialized = JSON.stringify(canonicalMeta);
|
||||
|
||||
const payloadStr = typeof payload === 'string' ? payload : '';
|
||||
const payloadLen = payloadStr.length;
|
||||
let payloadHash;
|
||||
try {
|
||||
payloadHash = payloadStr ? await sha256Hex(payloadStr) : 'empty';
|
||||
} catch (error) {
|
||||
if (error instanceof SyncSignatureUnavailableError) {
|
||||
// Fail closed: no signature → decideRemoteChanged's
|
||||
// `currentSignature === null` branch treats the remote as
|
||||
// "unreadable" and routes through three-way merge. That is the
|
||||
// safe behavior vs. a weak pseudo-signature that could silently
|
||||
// alias against another payload of the same length.
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return `v3:${metaSerialized}|len=${payloadLen}|sha256=${payloadHash}`;
|
||||
}
|
||||
212
infrastructure/services/syncSignature.test.mjs
Normal file
212
infrastructure/services/syncSignature.test.mjs
Normal file
@@ -0,0 +1,212 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { createSyncedFileSignature } from './syncSignature.js';
|
||||
|
||||
function makeSyncedFile(overrides = {}) {
|
||||
const meta = {
|
||||
version: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
deviceId: 'device-a',
|
||||
deviceName: 'Device A',
|
||||
appVersion: '1.0.0',
|
||||
iv: 'BASE64_IV',
|
||||
salt: 'BASE64_SALT',
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
kdfIterations: 600000,
|
||||
...(overrides.meta || {}),
|
||||
};
|
||||
return {
|
||||
meta,
|
||||
payload: overrides.payload ?? 'CIPHERTEXTxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx',
|
||||
};
|
||||
}
|
||||
|
||||
test('null file produces null signature', async () => {
|
||||
assert.equal(await createSyncedFileSignature(null), null);
|
||||
});
|
||||
|
||||
test('two identical files produce identical signatures', async () => {
|
||||
const a = makeSyncedFile();
|
||||
const b = makeSyncedFile();
|
||||
assert.equal(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('signature is stable across meta key-insertion order', async () => {
|
||||
const canonical = makeSyncedFile();
|
||||
const shuffled = {
|
||||
meta: {
|
||||
kdf: 'PBKDF2',
|
||||
salt: 'BASE64_SALT',
|
||||
iv: 'BASE64_IV',
|
||||
appVersion: '1.0.0',
|
||||
deviceName: 'Device A',
|
||||
deviceId: 'device-a',
|
||||
updatedAt: 1_700_000_000_000,
|
||||
version: 1,
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdfIterations: 600000,
|
||||
},
|
||||
payload: canonical.payload,
|
||||
};
|
||||
assert.equal(await createSyncedFileSignature(canonical), await createSyncedFileSignature(shuffled));
|
||||
});
|
||||
|
||||
test('changing iv flips the signature', async () => {
|
||||
const a = makeSyncedFile();
|
||||
const b = makeSyncedFile({ meta: { iv: 'DIFFERENT_IV' } });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('changing salt flips the signature', async () => {
|
||||
const a = makeSyncedFile();
|
||||
const b = makeSyncedFile({ meta: { salt: 'DIFFERENT_SALT' } });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('changing updatedAt flips the signature', async () => {
|
||||
const a = makeSyncedFile();
|
||||
const b = makeSyncedFile({ meta: { updatedAt: 1_700_000_000_001 } });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('changing algorithm flips the signature (v1 regression guard)', async () => {
|
||||
// The old signature only hashed version/updatedAt/deviceId/iv/salt — an
|
||||
// adapter could have changed algorithm/kdf while holding those constant.
|
||||
// v2+ must reject that.
|
||||
const a = makeSyncedFile({ meta: { algorithm: 'AES-256-GCM' } });
|
||||
const b = makeSyncedFile({ meta: { algorithm: 'ChaCha20-Poly1305' } });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('changing kdf flips the signature (v1 regression guard)', async () => {
|
||||
const a = makeSyncedFile({ meta: { kdf: 'PBKDF2' } });
|
||||
const b = makeSyncedFile({ meta: { kdf: 'Argon2id' } });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('changing appVersion flips the signature (v1 regression guard)', async () => {
|
||||
const a = makeSyncedFile({ meta: { appVersion: '1.0.0' } });
|
||||
const b = makeSyncedFile({ meta: { appVersion: '2.0.0' } });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('changing payload ciphertext flips the signature even when meta matches', async () => {
|
||||
// Critical: a malicious or buggy adapter could replay meta while swapping
|
||||
// the ciphertext. v2+ must treat the payload as load-bearing.
|
||||
const a = makeSyncedFile({ payload: 'AAA' + 'x'.repeat(60) });
|
||||
const b = makeSyncedFile({ payload: 'BBB' + 'x'.repeat(60) });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('changing payload length flips the signature (truncation guard)', async () => {
|
||||
// v3 hashes the full ciphertext — any length difference flips the signature.
|
||||
const prefix = 'x'.repeat(64);
|
||||
const a = makeSyncedFile({ payload: prefix });
|
||||
const b = makeSyncedFile({ payload: `${prefix}extra` });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('tail-mutation of a long ciphertext flips the signature (v2 prefix-replay guard)', async () => {
|
||||
// v2 only hashed the first 64 chars of the ciphertext. An adversary with
|
||||
// write access to the remote could preserve the prefix and mutate only the
|
||||
// tail, producing a signature collision. v3 hashes the full ciphertext and
|
||||
// must catch tail mutations even when prefix + length are preserved.
|
||||
const prefix = 'x'.repeat(64);
|
||||
const tailA = 'AAAAAAAAAAAAAAAA';
|
||||
const tailB = 'BBBBBBBBBBBBBBBB';
|
||||
const a = makeSyncedFile({ payload: `${prefix}${tailA}` });
|
||||
const b = makeSyncedFile({ payload: `${prefix}${tailB}` });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('deviceId alone is not sufficient to match (metadata weighted properly)', async () => {
|
||||
// Both share deviceId but differ on iv — must not alias.
|
||||
const a = makeSyncedFile({ meta: { deviceId: 'same', iv: 'IV_A' } });
|
||||
const b = makeSyncedFile({ meta: { deviceId: 'same', iv: 'IV_B' } });
|
||||
assert.notEqual(await createSyncedFileSignature(a), await createSyncedFileSignature(b));
|
||||
});
|
||||
|
||||
test('missing optional meta fields hash as null rather than throwing', async () => {
|
||||
const partial = {
|
||||
meta: {
|
||||
version: 1,
|
||||
updatedAt: 1_700_000_000_000,
|
||||
deviceId: 'device',
|
||||
appVersion: '1.0.0',
|
||||
iv: 'IV',
|
||||
salt: 'S',
|
||||
algorithm: 'AES-256-GCM',
|
||||
kdf: 'PBKDF2',
|
||||
// deviceName and kdfIterations omitted intentionally
|
||||
},
|
||||
payload: 'short',
|
||||
};
|
||||
const sig = await createSyncedFileSignature(partial);
|
||||
assert.equal(typeof sig, 'string');
|
||||
assert.ok(sig.startsWith('v3:'));
|
||||
});
|
||||
|
||||
test('file with non-string payload produces signature with len=0', async () => {
|
||||
// Defensive: if an adapter somehow yields a non-string payload, we still
|
||||
// generate a well-formed signature rather than crashing.
|
||||
const weird = { meta: makeSyncedFile().meta, payload: null };
|
||||
const sig = await createSyncedFileSignature(weird);
|
||||
assert.ok(sig);
|
||||
assert.ok(sig.includes('len=0'));
|
||||
assert.ok(sig.includes('sha256='));
|
||||
});
|
||||
|
||||
test('signature contains a 64-char hex SHA-256 segment', async () => {
|
||||
// Lock in the hash algorithm choice so a future regression to prefix-hashing
|
||||
// is caught by this unit test.
|
||||
const file = makeSyncedFile();
|
||||
const sig = await createSyncedFileSignature(file);
|
||||
assert.ok(sig);
|
||||
const match = sig.match(/sha256=([a-f0-9]+)/);
|
||||
assert.ok(match, `expected sha256=<hex> in signature, got ${sig}`);
|
||||
assert.equal(match[1].length, 64);
|
||||
});
|
||||
|
||||
test('v2-format anchor string does not equal a v3 signature', async () => {
|
||||
// Migration guard: if a user's localStorage carries a v2-prefixed anchor
|
||||
// from a previous build, comparing against a fresh v3 signature must flip
|
||||
// to "remote changed" so we re-observe rather than treating a stale anchor
|
||||
// as authoritative.
|
||||
const file = makeSyncedFile();
|
||||
const v3 = await createSyncedFileSignature(file);
|
||||
const v2Like = String(v3).replace(/^v3:/, 'v2:').replace(/sha256=[a-f0-9]+$/, 'head=xxxxxxxxxxxxxxxx');
|
||||
assert.notEqual(v3, v2Like);
|
||||
});
|
||||
|
||||
test('missing WebCrypto subtle → signature is null (fail-closed, no weak fallback)', async () => {
|
||||
// Earlier revisions returned `nosha-<length>` when subtle.digest was
|
||||
// unavailable. That fallback was length-only, so an adversary
|
||||
// controlling the remote could trivially produce a payload whose
|
||||
// weak pseudo-signature equals a legitimate v3 signature of the
|
||||
// same length. Failing to `null` routes decideRemoteChanged into the
|
||||
// "unreadable remote → treat as changed → three-way merge" path,
|
||||
// which is strictly safer.
|
||||
//
|
||||
// `globalThis.crypto` is a read-only getter in Node, so we override
|
||||
// the `subtle` property on the existing object rather than
|
||||
// reassigning the whole binding.
|
||||
const subtleDescriptor = Object.getOwnPropertyDescriptor(globalThis.crypto, 'subtle');
|
||||
Object.defineProperty(globalThis.crypto, 'subtle', {
|
||||
configurable: true,
|
||||
get() {
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
try {
|
||||
const sig = await createSyncedFileSignature(makeSyncedFile());
|
||||
assert.equal(sig, null, 'missing subtle must not produce a weak fallback string');
|
||||
} finally {
|
||||
if (subtleDescriptor) {
|
||||
Object.defineProperty(globalThis.crypto, 'subtle', subtleDescriptor);
|
||||
} else {
|
||||
delete globalThis.crypto.subtle;
|
||||
}
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user