Compare commits

...

8 Commits

Author SHA1 Message Date
陈大猫
98e3a6b952 Let single Tab fall through to shell when only ghost text is shown (#745)
Some checks failed
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
Closes #741. Bash/zsh use Tab for native completion, but our ghost-text
accept on single Tab was swallowing the keystroke before it reached the
PTY. Ghost text is still accepted with →; Tab in popup-menu mode is
unchanged (popup is an explicit UI so intent is clear).

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 02:44:57 +08:00
陈大猫
f6f3147afb Tab bar: duplicate-adjacent insertion + wheel-to-horizontal scroll (#743)
* Improve tab UX: insert duplicated tabs adjacent to source, enable wheel scroll on tab bar

Addresses #737.

- Duplicating a tab now inserts the new tab immediately after the source
  in the tab order, instead of appending it to the far right where it
  was hard to find with many tabs open.
- The top tab strip now translates vertical mouse-wheel deltas into
  horizontal scrolling, so users with many tabs can reach the ends of
  the strip without dragging. Trackpad gestures that already carry
  horizontal delta are left alone to preserve native two-finger swiping.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review: read source session inside functional updater

Codex flagged that reading `session` from the closure broke the atomicity
guarantee of the previous implementation — rapid repeated duplicates could
miss freshly queued state.

- Pre-allocate the new session id outside both setters so it stays stable
  across StrictMode double-invocations.
- Move the source lookup back into `setSessions`' functional updater so it
  always reads the freshest committed/queued state.
- Drop `sessions` from the useCallback dependency list now that we no
  longer read it.
- Fast-path tabOrder insertion when the source is already in tabOrder to
  avoid re-deriving the full effective order in the common case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address Codex review: gate active-tab and tab-order updates on successful create

Codex flagged that `setActiveTabId(newSessionId)` and `setTabOrder(...)` ran
unconditionally even when `setSessions` bailed out (source tab was closed
before the duplicate handler ran). That left activeTabId pointing at an id
that was never appended to sessions, putting the terminal layer into an
invalid "no matching tab" state.

Move both nested setState calls inside the `setSessions` functional updater
so they only fire when the source is actually present. Mirrors the original
pre-PR pattern; nested updates are idempotent so StrictMode's
double-invocation is harmless.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 00:41:31 +08:00
陈大猫
54b26511a1 Cloud sync data-loss prevention (4-layer defense) (#742)
* feat(sync-guard): extend SyncState with BLOCKED + add shrink event variants

* feat(sync-guard): add detectSuspiciousShrink pure function with 12 unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* polish(sync-guard): drop unnecessary cast, sharpen test naming, pin priority invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(test): include domain/*.test.ts in npm test glob

* feat(sync-guard): gate syncToProvider with shrink detection + force-push override

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): reset overrideShrinkOnce before early return for invariant strictness

* fix(sync-guard): extend shrink guard to syncAllProviders (the actual sync entry point)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): apply empty-vault guard uniformly to auto and manual sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): preserve merge base on same-account re-auth

Adds providerAccountId persistence; completePKCEAuth and completeGitHubAuth
now only clear syncBase/anchor when the authenticated account id differs from
the previously stored one, preventing zombie-entry resurrection on token
refresh. disconnectProvider clears the stored id so a reconnect starts fresh.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): add i18n strings for sync-blocked banner + force-push modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): add SyncBlockedBanner showing shrink findings with restore/force-push actions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): stable subscribeToEvents reference + type-safe finding narrowing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(sync-guard): force-push confirmation modal + scroll restore button into view

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* ux(local-backups): show version as title, demote reason+timestamp to meta line

* feat(local-backups): record + display sync data version (v5/v6...) on each backup

Each backup now captures the live CloudSyncManager.localVersion at creation
time. UI shows it as title (v5, v6, ...) with timestamp + reason demoted to
the meta line. Backups created before this field existed (or before any
successful cloud sync) fall back to timestamp as title.

Replaces the earlier app-version-transition title which conflated app
version with sync data version.

* fix(sync-guard): consume override flag at sync entry + restore provider status on block

- Snapshot+clear overrideShrinkOnce at top of syncToProvider and
  syncAllProviders so an early-return cannot leak the flag to a later
  unrelated sync (Codex P1).
- Restore provider status to 'connected' when shrink-block returns from
  syncToProvider; previously left provider stuck on 'syncing' in the
  UI (Codex P2).
- Process pre-existing check errors before returning from the
  shouldBlockAll branch in syncAllProviders so a check-failed provider
  isn't dropped from results (Codex P2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): refactor force-push to parameter passing + add credential-availability guard

The previous design used a one-shot boolean flag on CloudSyncManager set
by forcePushOverrideShrink(). Even with snapshot+clear at sync entry
points, the renderer wrapper's await ensureUnlocked() could throw before
the flag was consumed, leaving it armed for the next unrelated sync.

Fix: pass overrideShrink as a call-time parameter through the chain.
Eliminates the persistent flag and its leak surface.

Also: force-push now runs the same ensureSyncablePayload(...) guard the
other manual sync entry points use, so a vault with encrypted-credential
placeholders won't be uploaded via the force path either.

Addresses the latest two Codex P1/P2 findings on #742.

* fix(sync-guard): backfill account id from in-memory state for upgrade-path re-auth

Users upgrading to this PR have no netcatty.sync.accountId.* persisted yet.
On their first re-auth the guard saw previousId=null and cleared the
merge base anyway, defeating the point of the same-account preservation.

Snapshot the in-memory account id BEFORE overwriting providers[provider]
and use it as a fallback when the persisted id is missing. New users
(no prior connection at all) still get the clear-on-first-auth path.

Addresses Codex P1 on #742.

* fix(sync-guard): inspect force-push results + mark blocked single-provider as error

- Force-push handler now inspects syncNow result entries: applies any
  mergedPayload to local state, only clears the banner when all providers
  report success, surfaces a toast error otherwise. Previously the banner
  cleared unconditionally regardless of network/auth failures (Codex P1).

- syncToProvider shrink-block branches now mark provider status as
  'error' with a 'Sync blocked: would delete too much' message instead
  of 'connected'. Status aggregators treat 'connected' as healthy, so
  the blocked upload was surfacing as 'synced' in the UI (Codex P2).
  syncAllProviders already used this pattern; this brings the
  single-provider path in line.

* fix(sync-guard): exempt USE_LOCAL conflict + clear post-merge BLOCKED + expose 'blocked' status

- USE_LOCAL conflict resolution now passes { overrideShrink: true }: the
  conflict modal already served as user confirmation, and shrink-blocking
  it left users with a closed modal and an opaque banner (Review C-1).

- Post-merge round-trip in useAutoSync now detects shrink-blocked results
  and resets syncState to IDLE via new manager.clearShrinkBlockedState().
  The merged data is already applied locally; the next user-triggered
  sync will re-check, and we don't wedge the manager in BLOCKED with no
  visible banner outside the Settings tab (Review I-1).

- overallSyncStatus now reports 'blocked' as a distinct value from
  'error', so downstream UI (status icon, future badges) can offer
  shrink-block-specific affordances (Review I-2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): stabilize banner subscription dep + map 'blocked' status to error indicator

- The SyncBlockedBanner subscription useEffect depended on [sync] (the
  whole hook return object), which gets a new reference every render.
  This caused the listener to be unsubscribed+resubscribed on every
  render, opening a tiny race window where a SYNC_BLOCKED_SHRINK event
  could be missed and the banner would never appear. Destructure
  subscribeToEvents (already useCallback-stable) and depend on it
  directly, so the effect runs exactly once on mount.

- SyncStatusButton's status mapping had no arm for the new 'blocked'
  value, falling through to 'none' (idle). The global status indicator
  said healthy while the in-page banner said paused. Map 'blocked' to
  the same error indicator used for 'conflict' so the UI is consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): only clear banner on actual success + hydrate from manager state

- Banner subscription now clears only on SYNC_COMPLETED with result.success.
  SYNC_STARTED (auto-sync timer ticks) and SYNC_FORCED (fires BEFORE upload)
  could clear the banner prematurely, removing the user's recovery affordance
  while the underlying issue was unresolved (Codex P2).

- Manager now persists the last shrink finding in state.lastShrinkFinding
  alongside the SYNC_BLOCKED_SHRINK emission. New public getter
  getShrinkBlockedFinding() returns it when syncState is BLOCKED. Renderer
  hydrates the banner on mount so a block that happened off-screen
  (auto-sync while user was on another tab) is still visible when they
  open Sync Settings (Codex P2).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): unified BLOCKED-cleared event + USE_LOCAL inspects results

- USE_LOCAL conflict resolution now inspects syncNow() results, applies
  any mergedPayload to local state, surfaces a toast error and KEEPS the
  modal open on failure (so user can switch to USE_REMOTE). Mirrors the
  force-push handler pattern. Without this, USE_LOCAL silently 'succeeded'
  even when providers failed (Codex CLI P1).

- New SYNC_BLOCKED_CLEARED event emitted on every BLOCKED -> non-BLOCKED
  transition via a private exitBlockedState() helper. Banner subscribes to
  this single signal instead of guessing from per-provider SYNC_COMPLETED
  events. Fixes:
    - Multi-provider scenarios where first SYNC_COMPLETED clears the banner
      while a later provider was still going to fail (Codex CLI P1).
    - clearShrinkBlockedState() (post-merge self-heal) silently leaving
      the banner stuck because no event was emitted (Codex CLI P2).

- disconnectProvider() now also exits BLOCKED state. Disconnecting
  implicitly resolves any pending shrink-block warning, otherwise the
  stale alert carried over to the next-account reconnect (Codex CLI P2).

- All BLOCKED -> non-BLOCKED transitions consolidated through
  exitBlockedState() so lastShrinkFinding cleanup + event emission are
  always paired (Codex CLI P3 #6 covered).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(sync-guard): only clear BLOCKED on actual success, not on transient ERROR/SYNCING/CONFLICT

Previous patch called exitBlockedState() at every BLOCKED -> non-BLOCKED
transition, but this clears the banner on transitions that don't actually
resolve the shrink concern:

- SYNCING (sync just started — about to try, may fail)
- ERROR (transient transport failure, shrink concern still real)
- CONFLICT (separate concern; doesn't resolve the shrink)

If a user was in BLOCKED then triggered a sync that failed for an unrelated
reason (network, auth), the banner cleared and they lost the warning.

Restrict exitBlockedState() to terminal-success transitions:
- IDLE on successful upload (data made it to cloud — concern resolved)
- explicit clears (disconnectProvider, clearShrinkBlockedState)
- conflict resolution (USE_REMOTE/USE_LOCAL also end in IDLE)

Found by Codex CLI review of commit 12d7fa7b.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 22:43:19 +08:00
陈大猫
8ef91e1266 Ctrl+W close priority + local shell busy confirmation (#739)
* feat(ctrl-w): add ps-node + windows-process-tree + tsx deps for close-priority feature

* fix(ctrl-w): drop ps-node dep and add windows-process-tree to asarUnpack

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add ptyProcessTree bridge with per-platform child-process enumeration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): ptyProcessTree uses args= for full command + warns on pid overwrite

- Replace `comm=` with `args=` in defaultListPosix so the full command
  line is captured on both macOS (BSD ps) and Linux (GNU ps), avoiding
  the 15-char TASK_COMM_LEN truncation.
- Add console.warn in registerPid when the same sessionId is overwritten
  with a different pid, making the race condition visible in logs.
- Add test: registerPid warns exactly once on a pid change, not on a
  same-pid re-registration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): register local PTY pid with ptyProcessTree on spawn/exit

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): unregister pids in cleanupAllSessions to match per-delete invariant

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add IPC handlers for pty child processes and confirm-close dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): guard BrowserWindow.fromWebContents null and document dialog dismiss contract

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): expose ptyGetChildProcesses and confirmCloseBusy on window.netcatty

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add i18n strings for close-busy-terminal dialog

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): add resolveCloseIntent pure function with 8 unit tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): expose handleCloseSidePanel via ref to App.tsx

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): wire resolveCloseIntent + local-shell busy confirmation into closeTab hotkey

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(ctrl-w): add re-entrancy guard, aggregate busy count, sync sidebar ref, dedupe intent branches

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat(ctrl-w): auto-close workspace when its last session is closed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): sidebar close wins over focused terminal in priority chain

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): sidebar priority applies to single-session tabs too, not just workspaces

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(ctrl-w): compute empty-workspace auto-close outside setSessions updater

Addresses Codex P2 on #739: React 18+ does not guarantee updater
execution timing under concurrent scheduling. Moving the decision
outside the updater makes the microtask queue deterministic.

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-16 17:30:11 +08:00
Eric Chan
b2689f96a4 Clarify Netcatty CLI launcher guidance (#738) 2026-04-16 14:59:24 +08:00
陈大猫
1b23bdcf15 [codex] Preserve terminal focus when clicking the toolbar overlay (#734)
* fix terminal toolbar focus loss

* restore focus after closing side panels

* fix terminal side panel focus helper order
2026-04-16 11:08:09 +08:00
陈大猫
2e63848e0e fix empty ssh identification banners (#733) 2026-04-16 10:34:51 +08:00
陈大猫
3a748aa1aa fix serial duplicate host save (#732) 2026-04-16 10:15:37 +08:00
39 changed files with 1904 additions and 145 deletions

101
App.tsx
View File

@@ -18,6 +18,7 @@ import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
import { resolveHostAuth } from './domain/sshAuth';
import { resolveHostTerminalThemeId } from './domain/terminalAppearance';
import { collectSessionIds } from './domain/workspace';
import { resolveCloseIntent } from './application/state/resolveCloseIntent';
import { TERMINAL_THEMES } from './infrastructure/config/terminalThemes';
import { useCustomThemes } from './application/state/customThemeStore';
import type { SyncPayload } from './domain/sync';
@@ -992,6 +993,10 @@ function App({ settings }: { settings: SettingsState }) {
const addConnectionLogRef = useRef(addConnectionLog);
addConnectionLogRef.current = addConnectionLog;
const closeSidePanelRef = useRef<(() => void) | null>(null);
const activeSidePanelTabRef = useRef<string | null>(null);
const closeTabInFlightRef = useRef(false);
const createLocalTerminalWithCurrentShell = useCallback(() => {
const resolved = resolveShellSetting(terminalSettings.localShell, discoveredShells);
const matchedShell = discoveredShells.find(s => s.id === terminalSettings.localShell);
@@ -1025,6 +1030,44 @@ function App({ settings }: { settings: SettingsState }) {
return hotkeyScheme === 'mac' ? closeTabBinding.mac : closeTabBinding.pc;
}, [hotkeyScheme, keyBindings]);
const confirmIfBusyLocalTerminal = useCallback(
async (sessionIds: string[]): Promise<boolean> => {
const bridge = netcattyBridge.get();
const localIds = sessionIds.filter((id) => {
const s = sessions.find((x) => x.id === id);
return s?.protocol === 'local';
});
const busyCommands: string[] = [];
for (const id of localIds) {
const children = (await bridge?.ptyGetChildProcesses?.(id)) ?? [];
if (children.length > 0) {
busyCommands.push(children[0].command);
}
}
if (busyCommands.length === 0) return true;
const primary = busyCommands[0];
const extraCount = busyCommands.length - 1;
const message =
extraCount > 0
? t('confirm.closeBusyTerminal.messageWithMore', {
command: primary,
count: extraCount,
})
: t('confirm.closeBusyTerminal.message', { command: primary });
const ok = await bridge?.confirmCloseBusy?.({
command: primary,
title: t('confirm.closeBusyTerminal.title'),
message,
cancelLabel: t('confirm.closeBusyTerminal.cancel'),
closeLabel: t('confirm.closeBusyTerminal.close'),
});
return ok === true;
},
[sessions, t],
);
// Shared hotkey action handler - used by both global handler and terminal callback
const executeHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
// Build complete tab list: vault + (sftp when visible) + sessions/workspaces.
@@ -1069,18 +1112,52 @@ function App({ settings }: { settings: SettingsState }) {
}
case 'closeTab': {
const currentId = activeTabStore.getActiveTabId();
if (currentId !== 'vault' && currentId !== 'sftp') {
// Find if it's a session or workspace
const session = sessions.find(s => s.id === currentId);
if (session) {
closeSession(currentId);
} else {
const workspace = workspaces.find(w => w.id === currentId);
if (workspace) {
closeWorkspace(currentId);
if (!currentId || currentId === 'vault' || currentId === 'sftp') break;
if (closeTabInFlightRef.current) break;
const session = sessions.find((s) => s.id === currentId) ?? null;
const workspace = workspaces.find((w) => w.id === currentId) ?? null;
const focusIsInsideTerminal = !!document.activeElement?.closest('[data-session-id]');
const activeSidePanel = activeSidePanelTabRef.current;
const intent = resolveCloseIntent({
activeTabId: currentId,
workspace: workspace ? { id: workspace.id, focusedSessionId: workspace.focusedSessionId } : null,
sessionForTab: session,
activeSidePanelTab: activeSidePanel,
focusIsInsideTerminal,
});
closeTabInFlightRef.current = true;
(async () => {
try {
switch (intent.kind) {
case 'closeTerminal':
case 'closeSingleTab': {
const ok = await confirmIfBusyLocalTerminal([intent.sessionId]);
if (ok) closeSession(intent.sessionId);
return;
}
case 'closeSidePanel': {
closeSidePanelRef.current?.();
return;
}
case 'closeWorkspace': {
const ids = sessions.filter((s) => s.workspaceId === intent.workspaceId).map((s) => s.id);
const ok = await confirmIfBusyLocalTerminal(ids);
if (ok) closeWorkspace(intent.workspaceId);
return;
}
case 'noop':
default:
return;
}
} finally {
closeTabInFlightRef.current = false;
}
}
})();
break;
}
case 'newTab':
@@ -1193,7 +1270,7 @@ function App({ settings }: { settings: SettingsState }) {
break;
}
}
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab]);
}, [orderedTabs, sessions, workspaces, setActiveTabId, closeSession, closeWorkspace, createLocalTerminalWithCurrentShell, splitSessionWithCurrentShell, moveFocusInWorkspace, toggleBroadcast, settings.showSftpTab, confirmIfBusyLocalTerminal]);
// Callback for terminal to invoke app-level hotkey actions
const handleHotkeyAction = useCallback((action: string, e: KeyboardEvent) => {
@@ -1684,6 +1761,8 @@ function App({ settings }: { settings: SettingsState }) {
sessionLogsEnabled={sessionLogsEnabled}
sessionLogsDir={sessionLogsDir}
sessionLogsFormat={sessionLogsFormat}
closeSidePanelRef={closeSidePanelRef}
activeSidePanelTabRef={activeSidePanelTabRef}
/>
{/* Log Views - readonly terminal replays */}

View File

@@ -56,6 +56,11 @@ const en: Messages = {
'confirm.deleteHost': 'Delete Host "{name}"?',
'confirm.deleteIdentity': 'Delete Identity "{name}"?',
'confirm.removeProvider': 'Remove provider "{name}"?',
'confirm.closeBusyTerminal.title': 'Confirm close',
'confirm.closeBusyTerminal.message': 'Process "{command}" is still running and will be terminated.',
'confirm.closeBusyTerminal.messageWithMore': 'Process "{command}" and {count} other running process(es) will be terminated.',
'confirm.closeBusyTerminal.cancel': 'Cancel',
'confirm.closeBusyTerminal.close': 'Close',
'dialog.createWorkspace.title': 'Create Workspace',
'dialog.renameWorkspace.title': 'Rename workspace',
'dialog.renameSession.title': 'Rename session',
@@ -467,6 +472,30 @@ const en: Messages = {
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
'sync.blocked.title': 'Sync paused',
'sync.blocked.reason.bulkShrink': 'Would delete {lost} of {baseCount} {entityType} from cloud ({percent}% reduction).',
'sync.blocked.reason.largeShrink': 'Would delete {lost} {entityType} from cloud.',
'sync.blocked.detail': 'This is usually caused by a degraded local state (keychain failure, partial data load). Restore from a local backup, or force-push if you truly meant to remove these entries.',
'sync.blocked.restoreButton': 'Restore from local backup',
'sync.blocked.forcePushButton': 'Force push anyway',
'sync.forcePush.title': 'Confirm force push',
'sync.forcePush.body': 'You are about to remove {lost} {entityType} from the cloud. This cannot be undone. Proceed?',
'sync.forcePush.confirm': 'Yes, push anyway',
'sync.forcePush.cancel': 'Cancel',
'sync.entityType.hosts': 'hosts',
'sync.entityType.keys': 'keys',
'sync.entityType.identities': 'identities',
'sync.entityType.snippets': 'snippets',
'sync.entityType.customGroups': 'groups',
'sync.entityType.snippetPackages': 'snippet packages',
'sync.entityType.knownHosts': 'known-host entries',
'sync.entityType.portForwardingRules': 'port-forwarding rules',
'sync.entityType.groupConfigs': 'group configs',
'sync.credentialsUnavailable': 'This device cannot decrypt some saved credentials. Re-enter credentials locally before syncing.',
'time.never': 'Never',
'time.justNow': 'Just now',

View File

@@ -43,6 +43,11 @@ const zhCN: Messages = {
'confirm.deleteHost': '删除主机 "{name}"',
'confirm.deleteIdentity': '删除身份 "{name}"',
'confirm.removeProvider': '移除提供商 "{name}"',
'confirm.closeBusyTerminal.title': '确认关闭',
'confirm.closeBusyTerminal.message': '进程 "{command}" 仍在运行,关闭后会被终止。',
'confirm.closeBusyTerminal.messageWithMore': '进程 "{command}" 及其他 {count} 个正在运行的进程将被终止。',
'confirm.closeBusyTerminal.cancel': '取消',
'confirm.closeBusyTerminal.close': '关闭',
'dialog.renameWorkspace.title': '重命名工作区',
'dialog.renameSession.title': '重命名会话',
'field.name': '名称',
@@ -286,6 +291,30 @@ const zhCN: Messages = {
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
'sync.blocked.title': '同步已暂停',
'sync.blocked.reason.bulkShrink': '即将从云端删除 {baseCount} 条 {entityType} 中的 {lost} 条(缩减 {percent}%)。',
'sync.blocked.reason.largeShrink': '即将从云端删除 {lost} 条 {entityType}。',
'sync.blocked.detail': '通常是本地状态异常(钥匙串故障、数据加载不全)导致。请从本地备份恢复,如果确实要删这些条目请使用强制推送。',
'sync.blocked.restoreButton': '从本地备份恢复',
'sync.blocked.forcePushButton': '强制推送',
'sync.forcePush.title': '确认强制推送',
'sync.forcePush.body': '你将从云端移除 {lost} 条 {entityType},此操作不可撤销。继续?',
'sync.forcePush.confirm': '确认推送',
'sync.forcePush.cancel': '取消',
'sync.entityType.hosts': '主机',
'sync.entityType.keys': '密钥',
'sync.entityType.identities': '身份',
'sync.entityType.snippets': '代码片段',
'sync.entityType.customGroups': '分组',
'sync.entityType.snippetPackages': '片段包',
'sync.entityType.knownHosts': '主机密钥记录',
'sync.entityType.portForwardingRules': '端口转发规则',
'sync.entityType.groupConfigs': '分组配置',
'sync.credentialsUnavailable': '当前设备无法解密部分已保存凭据。请先在本地重新输入凭据后再同步。',
'time.never': '从未',
'time.justNow': '刚刚',

View File

@@ -6,15 +6,38 @@ import {
STORAGE_KEY_VAULT_RESTORE_IN_PROGRESS_UNTIL,
} from '../infrastructure/config/storageKeys';
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
import { getCloudSyncManager } from '../infrastructure/services/CloudSyncManager';
import { netcattyBridge } from '../infrastructure/services/netcattyBridge';
import { hasMeaningfulSyncData } from './syncPayload';
/**
* Snapshot the current sync data version (the integer that increments
* on each successful cloud sync). Returns undefined when the value is
* 0 (never synced) or unavailable, so the UI can fall back to timestamp.
*/
function captureCurrentSyncDataVersion(): number | undefined {
try {
const state = getCloudSyncManager().getState();
const v = state.localVersion;
return typeof v === 'number' && v > 0 ? v : undefined;
} catch {
return undefined;
}
}
export type LocalVaultBackupReason = 'app_version_change' | 'before_restore';
export interface LocalVaultBackupPreview {
id: string;
createdAt: number;
reason: LocalVaultBackupReason;
/** Sync-data version at the time the snapshot was taken (the integer
* that the CloudSyncManager increments on each successful cloud sync).
* Undefined when the user had never synced yet, or for legacy backups
* persisted before this field was added. */
syncDataVersion?: number;
/** App version transition fields, only for `app_version_change` records.
* Kept for backward compatibility with already-persisted backups. */
sourceAppVersion?: string;
targetAppVersion?: string;
fingerprint: string;
@@ -94,6 +117,7 @@ export async function createLocalVaultBackup(
payload: SyncPayload,
options: {
reason: LocalVaultBackupReason;
syncDataVersion?: number;
sourceAppVersion?: string;
targetAppVersion?: string;
maxCount?: number;
@@ -118,6 +142,10 @@ export async function createLocalVaultBackup(
const result = await bridge.createVaultBackup({
payload,
reason: options.reason,
// Default to the live cloud-sync version so every new backup carries
// it even when the caller didn't pass one explicitly. Bridge sanitizer
// drops invalid values (non-positive / non-finite), so this is safe.
syncDataVersion: options.syncDataVersion ?? captureCurrentSyncDataVersion(),
sourceAppVersion: options.sourceAppVersion,
targetAppVersion: options.targetAppVersion,
maxCount: options.maxCount ?? getLocalVaultBackupMaxCount(),

View File

@@ -0,0 +1,110 @@
import test from "node:test";
import assert from "node:assert/strict";
import { resolveCloseIntent } from "./resolveCloseIntent.ts";
const baseWorkspace = {
id: "w1",
focusedSessionId: "s1",
};
const baseSession = { id: "s1" };
test("non-workspace tab → closeSingleTab with session id", () => {
const result = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: baseSession,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(result, { kind: "closeSingleTab", sessionId: "s1" });
});
test("non-workspace session tab + sidebar open → closeSidePanel (sidebar beats session close)", () => {
const r = resolveCloseIntent({
activeTabId: "s1",
workspace: null,
sessionForTab: { id: "s1" },
activeSidePanelTab: "ai",
focusIsInsideTerminal: true, // focus IS in terminal, but sidebar wins
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("vault/sftp tab → noop", () => {
const r = resolveCloseIntent({
activeTabId: "vault",
workspace: null,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "noop" });
});
test("workspace + focus in terminal + sidebar open → closeSidePanel wins (sidebar beats focus)", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + focus NOT in terminal + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: "sftp",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});
test("workspace + sidebar closed + focus in terminal → closeTerminal", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true,
});
assert.deepEqual(r, { kind: "closeTerminal", sessionId: "s1" });
});
test("workspace + sidebar closed + focus NOT in terminal → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: baseWorkspace,
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar closed → closeWorkspace", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: null,
focusIsInsideTerminal: true, // even if flag true, no focused id → cannot closeTerminal
});
assert.deepEqual(r, { kind: "closeWorkspace", workspaceId: "w1" });
});
test("workspace with no focused session + sidebar open → closeSidePanel", () => {
const r = resolveCloseIntent({
activeTabId: "w1",
workspace: { id: "w1", focusedSessionId: undefined },
sessionForTab: null,
activeSidePanelTab: "ai",
focusIsInsideTerminal: false,
});
assert.deepEqual(r, { kind: "closeSidePanel" });
});

View File

@@ -0,0 +1,43 @@
export type CloseIntent =
| { kind: 'closeTerminal'; sessionId: string }
| { kind: 'closeSidePanel' }
| { kind: 'closeWorkspace'; workspaceId: string }
| { kind: 'closeSingleTab'; sessionId: string }
| { kind: 'noop' };
export interface ResolveCloseInput {
activeTabId: string | null;
workspace: { id: string; focusedSessionId?: string } | null;
sessionForTab: { id: string } | null;
activeSidePanelTab: string | null;
focusIsInsideTerminal: boolean;
}
export function resolveCloseIntent(input: ResolveCloseInput): CloseIntent {
const { activeTabId, workspace, sessionForTab, activeSidePanelTab, focusIsInsideTerminal } = input;
if (!activeTabId) return { kind: 'noop' };
// Sidebar always wins — applies to any tab type (workspace, single-session, etc.).
// Modals take priority over this but are intercepted upstream in App.tsx before the
// hotkey reaches resolveCloseIntent.
if (activeSidePanelTab !== null) {
return { kind: 'closeSidePanel' };
}
if (sessionForTab && !workspace) {
return { kind: 'closeSingleTab', sessionId: sessionForTab.id };
}
if (!workspace) {
// e.g. 'vault', 'sftp', or any non-closable pinned tab
return { kind: 'noop' };
}
const focusedSessionId = workspace.focusedSessionId;
if (focusedSessionId && focusIsInsideTerminal) {
return { kind: 'closeTerminal', sessionId: focusedSessionId };
}
return { kind: 'closeWorkspace', workspaceId: workspace.id };
}

View File

@@ -247,19 +247,23 @@ export const useAutoSync = (config: AutoSyncConfig) => {
throw new Error(t('sync.credentialsUnavailable'));
}
// Prevent pushing an empty vault to cloud. This is almost always
// Refuse to push an empty vault to cloud. This is almost always
// a sign that the local state was lost (update, import failure,
// storage corruption) rather than a deliberate "delete everything".
// We only block auto-sync — manual trigger from Settings can still
// push if the user explicitly wants to.
// Both auto and manual triggers are blocked; the user can still
// use Force Push from the SyncBlocked banner if they genuinely
// want to wipe the cloud.
//
// This pairs with the inspect-failure "fail open" behavior in
// checkRemoteVersion below: if inspect transiently errors we still
// let auto-sync run, trusting this guard to refuse if local is
// truly empty rather than letting an empty state clobber remote.
if (!hasMeaningfulSyncData(payload) && trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
if (!hasMeaningfulSyncData(payload)) {
if (trigger === 'auto') {
console.warn('[AutoSync] Blocked: refusing to auto-sync an empty vault to cloud');
return;
}
throw new Error(t('sync.autoSync.emptyVaultManual'));
}
const results = await sync.syncNow(payload);
@@ -479,7 +483,20 @@ export const useAutoSync = (config: AutoSyncConfig) => {
// that only approximated the correct ordering.
if (mergeResult.payload) {
try {
await manager.syncAllProviders(mergeResult.payload);
const roundTripResults = await manager.syncAllProviders(mergeResult.payload);
const wasShrinkBlocked = Array.from(roundTripResults.values()).some(
(r) => r.shrinkBlocked === true,
);
if (wasShrinkBlocked) {
// The merged payload is already applied locally and is the source of truth
// for THIS device. The blocking only prevents pushing it to cloud, which
// is acceptable here — the next user-edit-triggered sync will re-check
// (and the user can also force-push from the Settings banner if they
// navigate there). Reset syncState so we don't leave the manager wedged
// in BLOCKED with no banner visible.
console.warn('[AutoSync] Post-merge round-trip was shrink-blocked; merged data applied locally, reset syncState to IDLE for next attempt.');
manager.clearShrinkBlockedState();
}
// Suppress the debounced follow-up tick that otherwise fires
// once React commits the applied state, since we've just
// already pushed that exact payload upstream.

View File

@@ -26,7 +26,9 @@ import {
import {
getCloudSyncManager,
type SyncManagerState,
type SyncEventCallback,
} from '../../infrastructure/services/CloudSyncManager';
import type { ShrinkFinding } from '../../domain/syncGuards';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import type { DeviceFlowState } from '../../infrastructure/services/adapters/GitHubAdapter';
@@ -55,7 +57,7 @@ export interface CloudSyncHook {
// Computed
hasAnyConnectedProvider: boolean;
connectedProviderCount: number;
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict';
overallSyncStatus: 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked';
// Master Key Actions
setupMasterKey: (password: string, confirmPassword: string) => Promise<void>;
@@ -86,8 +88,8 @@ export interface CloudSyncHook {
resetProviderStatus: (provider: CloudProvider) => void;
// Sync Actions
syncNow: (payload: SyncPayload) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload) => Promise<SyncResult>;
syncNow: (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<Map<CloudProvider, SyncResult>>;
syncToProvider: (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => Promise<SyncResult>;
downloadFromProvider: (provider: CloudProvider) => Promise<SyncPayload | null>;
resolveConflict: (resolution: ConflictResolution) => Promise<SyncPayload | null>;
@@ -116,6 +118,12 @@ export interface CloudSyncHook {
formatLastSync: (timestamp?: number) => string;
getProviderDotColor: (provider: CloudProvider) => string;
refresh: () => void;
// Event subscription (for non-state events like SYNC_BLOCKED_SHRINK)
subscribeToEvents: (callback: SyncEventCallback) => () => void;
// Shrink-block state query (for banner hydration on mount)
getShrinkBlockedFinding: () => Extract<ShrinkFinding, { suspicious: true }> | null;
}
// ============================================================================
@@ -190,7 +198,8 @@ export const useCloudSync = (): CloudSyncHook => {
).length;
}, [state.providers]);
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' => {
const overallSyncStatus = useMemo((): 'none' | 'synced' | 'syncing' | 'error' | 'conflict' | 'blocked' => {
if (state.syncState === 'BLOCKED') return 'blocked';
if (state.syncState === 'CONFLICT') return 'conflict';
if (state.syncState === 'ERROR') return 'error';
if (state.syncState === 'SYNCING') return 'syncing';
@@ -422,14 +431,14 @@ export const useCloudSync = (): CloudSyncHook => {
throw new Error('Vault is locked');
}, []);
const syncNowWithUnlock = useCallback(async (payload: SyncPayload) => {
const syncNowWithUnlock = useCallback(async (payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
await ensureUnlocked();
return await manager.syncAllProviders(payload);
return await manager.syncAllProviders(payload, opts);
}, [ensureUnlocked]);
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload) => {
const syncToProviderWithUnlock = useCallback(async (provider: CloudProvider, payload: SyncPayload, opts?: { overrideShrink?: boolean }) => {
await ensureUnlocked();
return await manager.syncToProvider(provider, payload);
return await manager.syncToProvider(provider, payload, opts);
}, [ensureUnlocked]);
const downloadFromProviderWithUnlock = useCallback(async (provider: CloudProvider) => {
@@ -437,6 +446,16 @@ export const useCloudSync = (): CloudSyncHook => {
return await manager.downloadFromProvider(provider);
}, [ensureUnlocked]);
const subscribeToEvents = useCallback(
(callback: SyncEventCallback) => manager.subscribe(callback),
[],
);
const getShrinkBlockedFinding = useCallback(
() => manager.getShrinkBlockedFinding(),
[],
);
const resolveConflictWithUnlock = useCallback(async (resolution: ConflictResolution) => {
await ensureUnlocked();
return await manager.resolveConflict(resolution);
@@ -505,6 +524,12 @@ export const useCloudSync = (): CloudSyncHook => {
formatLastSync,
getProviderDotColor,
refresh,
// Event subscription
subscribeToEvents,
// Shrink-block state query
getShrinkBlockedFinding,
};
};

View File

@@ -141,19 +141,48 @@ export const useSessionState = () => {
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
}, []);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
const closeSession = useCallback((sessionId: string, e?: MouseEvent) => {
e?.stopPropagation();
// Pre-compute outside the setSessions updater so we don't depend on React
// having run the updater by the time we queue the microtask. React 18+ does
// not guarantee updater execution timing under concurrent scheduling.
const sessionBeingClosed = sessions.find(s => s.id === sessionId);
const workspaceIdToMaybeClose =
sessionBeingClosed?.workspaceId &&
sessions.every(s => s.id === sessionId || s.workspaceId !== sessionBeingClosed.workspaceId)
? sessionBeingClosed.workspaceId
: undefined;
setSessions(prevSessions => {
const targetSession = prevSessions.find(s => s.id === sessionId);
const wsId = targetSession?.workspaceId;
setWorkspaces(prevWorkspaces => {
let removedWorkspaceId: string | null = null;
let nextWorkspaces = prevWorkspaces;
let dissolvedWorkspaceId: string | null = null;
let lastRemainingSessionId: string | null = null;
if (wsId) {
nextWorkspaces = prevWorkspaces
.map(ws => {
@@ -163,7 +192,7 @@ export const useSessionState = () => {
removedWorkspaceId = ws.id;
return null;
}
// Check if only 1 session remains - dissolve workspace
const remainingSessionIds = collectSessionIds(pruned);
if (remainingSessionIds.length === 1) {
@@ -171,12 +200,12 @@ export const useSessionState = () => {
lastRemainingSessionId = remainingSessionIds[0];
return null;
}
return { ...ws, root: pruned };
})
.filter((ws): ws is Workspace => Boolean(ws));
}
const remainingSessions = prevSessions.filter(s => s.id !== sessionId);
const fallbackWorkspace = nextWorkspaces[nextWorkspaces.length - 1];
const fallbackSolo = remainingSessions.filter(s => !s.workspaceId).slice(-1)[0];
@@ -198,10 +227,10 @@ export const useSessionState = () => {
} else if (wsId && currentActiveTabId === wsId && !nextWorkspaces.find(w => w.id === wsId)) {
setActiveTabId(getFallback());
}
return nextWorkspaces;
});
// Check if we need to dissolve a workspace (convert remaining session to orphan)
if (targetSession?.workspaceId) {
const ws = workspaces.find(w => w.id === targetSession.workspaceId);
@@ -218,29 +247,14 @@ export const useSessionState = () => {
}
}
}
return prevSessions.filter(s => s.id !== sessionId);
});
}, [workspaces, setActiveTabId]);
const closeWorkspace = useCallback((workspaceId: string) => {
setWorkspaces(prevWorkspaces => {
const remainingWorkspaces = prevWorkspaces.filter(w => w.id !== workspaceId);
setSessions(prevSessions => prevSessions.filter(s => s.workspaceId !== workspaceId));
const currentActiveTabId = activeTabStore.getActiveTabId();
if (currentActiveTabId === workspaceId) {
if (remainingWorkspaces.length > 0) {
setActiveTabId(remainingWorkspaces[remainingWorkspaces.length - 1].id);
} else {
setActiveTabId('vault');
}
}
return remainingWorkspaces;
});
}, [setActiveTabId]);
return prevSessions.filter(s => s.id !== sessionId);
});
if (workspaceIdToMaybeClose) {
queueMicrotask(() => closeWorkspace(workspaceIdToMaybeClose!));
}
}, [sessions, workspaces, setActiveTabId, closeWorkspace]);
const startSessionRename = useCallback((sessionId: string) => {
setSessions(prevSessions => {
@@ -654,16 +668,22 @@ export const useSessionState = () => {
const copySession = useCallback((sessionId: string, options?: {
localShellType?: TerminalSession['shellType'];
}) => {
// Pre-allocate the new id outside the updater so StrictMode's
// double-invocation of the functional updater doesn't mint two ids.
const newSessionId = crypto.randomUUID();
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
// Source may have been closed between the user's action and this
// update running; in that case skip entirely — do NOT switch the
// active tab or insert into tabOrder, which would leave dangling ids.
if (!session) return prevSessions;
const nextShellType = session.protocol === 'local'
? options?.localShellType
: session.shellType;
// Create a new session with the same connection info
const newSession: TerminalSession = {
id: crypto.randomUUID(),
id: newSessionId,
hostId: session.hostId,
hostLabel: session.hostLabel,
hostname: session.hostname,
@@ -681,10 +701,40 @@ export const useSessionState = () => {
localShellIcon: session.localShellIcon,
};
setActiveTabId(newSession.id);
// Schedule the activeTab + tabOrder updates only when creation
// actually happens. These nested setStates are idempotent, so
// StrictMode's double-invocation is harmless.
setActiveTabId(newSessionId);
setTabOrder(prevTabOrder => {
// Fast path: source is already tracked in tabOrder — splice directly.
const directIdx = prevTabOrder.indexOf(sessionId);
if (directIdx !== -1) {
const next = [...prevTabOrder];
next.splice(directIdx + 1, 0, newSessionId);
return next;
}
// Fallback: source is only in the derived tab collections. Rebuild the
// effective order (same pattern as reorderTabs) to locate its position.
const allTabIds = [
...orphanSessions.map(s => s.id),
...workspaces.map(w => w.id),
...logViews.map(lv => lv.id),
];
const allTabIdSet = new Set(allTabIds);
const orderedIds = prevTabOrder.filter(id => allTabIdSet.has(id));
const orderedIdSet = new Set(orderedIds);
const newIds = allTabIds.filter(id => !orderedIdSet.has(id));
const currentOrder = [...orderedIds, ...newIds];
const sourceIdx = currentOrder.indexOf(sessionId);
if (sourceIdx === -1) return [...prevTabOrder, newSessionId];
const next = [...currentOrder];
next.splice(sourceIdx + 1, 0, newSessionId);
return next;
});
return [...prevSessions, newSession];
});
}, [setActiveTabId]);
}, [orphanSessions, workspaces, logViews, setActiveTabId]);
// Toggle broadcast mode for a workspace
const toggleBroadcast = useCallback((workspaceId: string) => {

View File

@@ -7,7 +7,7 @@
* - Sync status and conflict resolution
*/
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import {
AlertTriangle,
Check,
@@ -43,7 +43,9 @@ import { useI18n } from '../application/i18n/I18nProvider';
import {
findSyncPayloadEncryptedCredentialPaths,
} from '../domain/credentials';
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
import { isProviderReadyForSync, type CloudProvider, type ConflictInfo, type SyncPayload, type SyncResult, type WebDAVAuthType, type WebDAVConfig, type S3Config } from '../domain/sync';
import type { ShrinkFinding } from '../domain/syncGuards';
import { SyncBlockedBanner } from './sync/SyncBlockedBanner';
import { cn } from '../lib/utils';
import { Button } from './ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from './ui/dialog';
@@ -897,20 +899,29 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
className="flex items-center gap-3 rounded-lg border border-border/60 p-3"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium">
{getReasonLabel(backup.reason)}
</span>
<span className="text-xs text-muted-foreground">
{formatTimestamp(backup.createdAt)}
</span>
<div className="text-sm font-medium">
{backup.syncDataVersion
? `v${backup.syncDataVersion}`
: formatTimestamp(backup.createdAt)}
</div>
<div className="text-xs text-muted-foreground mt-1 flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(backup.reason)}</span>
{backup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(backup.createdAt)}</span>
</>
)}
{backup.sourceAppVersion && backup.targetAppVersion && (
<span className="text-xs text-muted-foreground">
{t('cloudSync.localBackups.versionChange', {
from: backup.sourceAppVersion,
to: backup.targetAppVersion,
})}
</span>
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: backup.sourceAppVersion,
to: backup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
@@ -974,13 +985,30 @@ const LocalBackupsPanel: React.FC<LocalBackupsPanelProps> = ({
</DialogHeader>
{pendingRestoreBackup && (
<div className="rounded-lg border border-border/60 bg-muted/30 p-3 text-xs space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">
{getReasonLabel(pendingRestoreBackup.reason)}
</span>
<span className="text-muted-foreground">
{formatTimestamp(pendingRestoreBackup.createdAt)}
</span>
<div className="font-medium">
{pendingRestoreBackup.syncDataVersion
? `v${pendingRestoreBackup.syncDataVersion}`
: formatTimestamp(pendingRestoreBackup.createdAt)}
</div>
<div className="text-muted-foreground flex items-center gap-1 flex-wrap">
<span>{getReasonLabel(pendingRestoreBackup.reason)}</span>
{pendingRestoreBackup.syncDataVersion && (
<>
<span aria-hidden="true">·</span>
<span>{formatTimestamp(pendingRestoreBackup.createdAt)}</span>
</>
)}
{pendingRestoreBackup.sourceAppVersion && pendingRestoreBackup.targetAppVersion && (
<>
<span aria-hidden="true">·</span>
<span>
{t('cloudSync.localBackups.versionChange', {
from: pendingRestoreBackup.sourceAppVersion,
to: pendingRestoreBackup.targetAppVersion,
})}
</span>
</>
)}
</div>
<div className="text-muted-foreground">
{t('cloudSync.localBackups.counts', {
@@ -1172,6 +1200,17 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// Clear local data dialog
const [showClearLocalDialog, setShowClearLocalDialog] = useState(false);
// Sync-blocked banner (Task 7) + force-push confirmation modal (Task 8)
const [blockedFinding, setBlockedFinding] = useState<Extract<ShrinkFinding, { suspicious: true }> | null>(null);
const [showForcePushConfirm, setShowForcePushConfirm] = useState(false);
// Ref for scrolling to LocalBackupsPanel when the banner's Restore button is clicked
const localBackupsRef = useRef<HTMLDivElement>(null);
// Active tab state — lets the banner's "Restore" button switch to the
// local-backups tab without a separate DOM query.
const [activeTab, setActiveTab] = useState<'providers' | 'status'>('providers');
const ensureSyncablePayload = useCallback(
(payload: SyncPayload): boolean => {
const encryptedCredentialPaths = findSyncPayloadEncryptedCredentialPaths(payload);
@@ -1190,6 +1229,35 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
}
}, [sync.currentConflict]);
// Subscribe to sync events to show/clear the blocked-shrink banner.
// Destructure the stable useCallback reference so the effect runs once on
// mount rather than re-subscribing on every render when `sync` object ref changes.
const { subscribeToEvents, getShrinkBlockedFinding } = sync;
// Hydrate from current manager state in case a shrink-block happened
// before this component mounted (e.g., auto-sync ran while the user
// was on a different tab). Without this, the banner only shows
// blocks that occur after Settings is open.
useEffect(() => {
const existing = getShrinkBlockedFinding();
if (existing) {
setBlockedFinding(existing);
}
}, [getShrinkBlockedFinding]);
useEffect(() => {
const unsub = subscribeToEvents((event) => {
if (event.type === 'SYNC_BLOCKED_SHRINK') {
if (event.finding.suspicious) {
setBlockedFinding(event.finding);
}
} else if (event.type === 'SYNC_BLOCKED_CLEARED') {
setBlockedFinding(null);
}
});
return unsub;
}, [subscribeToEvents]);
// If we have a master key but we're still locked (e.g. older installs),
// prompt once and persist the password via safeStorage.
useEffect(() => {
@@ -1441,9 +1509,30 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
// window's push, not ours.
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) return;
let results: Map<CloudProvider, SyncResult> | null = null;
await withRestoreBarrier(async () => {
await sync.syncNow(localPayload);
results = await sync.syncNow(localPayload, { overrideShrink: true });
});
if (results) {
// Apply any merged payload BEFORE closing the modal so local state
// reflects what's now on cloud (in case remote changed during the merge).
for (const result of (results as Map<CloudProvider, SyncResult>).values()) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
break;
}
}
const allOk = Array.from((results as Map<CloudProvider, SyncResult>).values()).every((r) => r.success);
if (!allOk) {
const firstError = Array.from((results as Map<CloudProvider, SyncResult>).values())
.find((r) => !r.success)?.error
?? t('common.unknownError');
toast.error(firstError, t('cloudSync.resolve.failedTitle'));
return; // KEEP the modal open so user can retry / pick USE_REMOTE
}
}
toast.success(t('cloudSync.resolve.uploaded'));
}
setShowConflictModal(false);
@@ -1554,7 +1643,20 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div>
</div>
<Tabs defaultValue="providers" className="space-y-4">
{blockedFinding && (
<SyncBlockedBanner
finding={blockedFinding}
onRestore={() => {
setActiveTab('status');
requestAnimationFrame(() => {
localBackupsRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
}}
onForcePush={() => setShowForcePushConfirm(true)}
/>
)}
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as 'providers' | 'status')} className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="providers">{t('cloudSync.providers.title')}</TabsTrigger>
<TabsTrigger value="status">{t('cloudSync.status.title')}</TabsTrigger>
@@ -1739,9 +1841,11 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</div>
)}
<LocalBackupsPanel
onApplyPayload={onApplyPayload}
/>
<div ref={localBackupsRef}>
<LocalBackupsPanel
onApplyPayload={onApplyPayload}
/>
</div>
{/* Clear Local Data */}
<div className="p-4 rounded-lg border border-destructive/30 bg-destructive/5">
@@ -2361,6 +2465,69 @@ const SyncDashboard: React.FC<SyncDashboardProps> = ({
</DialogFooter>
</DialogContent>
</Dialog>
{/* Force-push confirmation modal (Task 8) */}
{showForcePushConfirm && blockedFinding && (
<Dialog open onOpenChange={(open) => !open && setShowForcePushConfirm(false)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('sync.forcePush.title')}</DialogTitle>
</DialogHeader>
<p className="text-sm">
{t('sync.forcePush.body', {
lost: blockedFinding.lost,
entityType: t(`sync.entityType.${blockedFinding.entityType}`),
})}
</p>
<DialogFooter>
<Button variant="outline" onClick={() => setShowForcePushConfirm(false)}>
{t('sync.forcePush.cancel')}
</Button>
<Button
variant="destructive"
onClick={async () => {
const localPayload = onBuildPayload();
if (!ensureSyncablePayload(localPayload)) {
setShowForcePushConfirm(false);
return;
}
setShowForcePushConfirm(false);
try {
const results = await sync.syncNow(localPayload, { overrideShrink: true });
// Apply any merged payload BEFORE clearing the banner. If a merge happened
// during force-push (remote changed), the merged result is what the cloud
// now has — applying it to local state prevents the next sync from
// re-deleting the remote additions we just merged in.
for (const result of results.values()) {
if (result.mergedPayload) {
await Promise.resolve(onApplyPayload(result.mergedPayload));
break; // All providers share the same merged payload
}
}
const allOk = Array.from(results.values()).every((r) => r.success);
if (allOk) {
setBlockedFinding(null);
} else {
// Surface the failure but KEEP the banner so the user can retry or
// restore. Find the first error string to display.
const firstError = Array.from(results.values())
.find((r) => !r.success)
?.error ?? t('sync.toast.errorTitle');
toast.error(firstError, t('sync.toast.errorTitle'));
}
} catch (err) {
toast.error(String(err), t('sync.toast.errorTitle'));
}
}}
>
{t('sync.forcePush.confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
</div>
);
};

View File

@@ -136,7 +136,13 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
// Determine overall status for the button indicator
const getOverallStatus = (): StatusIndicatorProps['status'] => {
if (sync.overallSyncStatus === 'syncing') return 'syncing';
if (sync.overallSyncStatus === 'error' || sync.overallSyncStatus === 'conflict') return 'error';
if (
sync.overallSyncStatus === 'error' ||
sync.overallSyncStatus === 'conflict' ||
sync.overallSyncStatus === 'blocked'
) {
return 'error';
}
if (sync.overallSyncStatus === 'synced') return 'synced';
return 'none';
};

View File

@@ -49,6 +49,7 @@ import { ZmodemProgressIndicator } from "./terminal/ZmodemProgressIndicator";
import { useZmodemTransfer } from "./terminal/hooks/useZmodemTransfer";
import { createTerminalSessionStarters, type PendingAuth } from "./terminal/runtime/createTerminalSessionStarters";
import { createXTermRuntime, primaryFontFamily, type XTermRuntime } from "./terminal/runtime/createXTermRuntime";
import { shouldPreserveTerminalFocusOnMouseDown } from "./terminal/toolbarFocus";
import { preserveTerminalViewportInScrollback } from "./terminal/clearTerminalViewport";
import { XTERM_PERFORMANCE_CONFIG } from "../infrastructure/config/xtermPerformance";
import { useTerminalSearch } from "./terminal/hooks/useTerminalSearch";
@@ -620,6 +621,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
termRef.current?.focus();
}, []);
const handleTopOverlayMouseDownCapture = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
if (!shouldPreserveTerminalFocusOnMouseDown(e.target)) return;
e.preventDefault();
}, []);
// Subscribe to custom theme changes so editing triggers re-render
const customThemes = useCustomThemes();
const hasFontSizeOverride = host.fontSizeOverride === true || (host.fontSizeOverride === undefined && host.fontSize != null);
@@ -1706,6 +1713,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
<div className="absolute left-0 right-0 top-0 z-20 pointer-events-none">
<div
className="flex items-center gap-1 px-2 py-0.5 backdrop-blur-md pointer-events-auto min-w-0"
onMouseDownCapture={handleTopOverlayMouseDownCapture}
style={{
backgroundColor: 'var(--terminal-ui-bg)',
color: 'var(--terminal-ui-fg)',

View File

@@ -429,6 +429,8 @@ interface TerminalLayerProps {
sessionLogsEnabled?: boolean;
sessionLogsDir?: string;
sessionLogsFormat?: string;
closeSidePanelRef?: React.MutableRefObject<(() => void) | null>;
activeSidePanelTabRef?: React.MutableRefObject<string | null>;
}
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
@@ -482,6 +484,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
sessionLogsEnabled,
sessionLogsDir,
sessionLogsFormat,
closeSidePanelRef,
activeSidePanelTabRef,
}) => {
// Subscribe to activeTabId from external store
const activeTabId = useActiveTabId();
@@ -662,6 +666,9 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
// Whether side panel is open for the currently active tab and which sub-panel
const isSidePanelOpenForCurrentTab = activeTabId ? sidePanelOpenTabs.has(activeTabId) : false;
const activeSidePanelTab = activeTabId ? sidePanelOpenTabs.get(activeTabId) ?? null : null;
if (activeSidePanelTabRef) {
activeSidePanelTabRef.current = activeSidePanelTab;
}
// Legacy compatibility helpers for SFTP-specific logic
const isSftpOpenForCurrentTab = activeSidePanelTab === 'sftp';
@@ -1258,9 +1265,25 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
}
}, [activeWorkspace?.focusedSessionId, activeSession?.id, terminalBackend]);
const refocusTerminalSession = useCallback((sessionId?: string | null) => {
if (!sessionId) return;
const focusTarget = () => {
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
};
requestAnimationFrame(() => {
focusTarget();
setTimeout(focusTarget, 50);
});
}, []);
// Close the entire side panel for the current tab
const handleCloseSidePanel = useCallback(() => {
if (!activeTabId) return;
const sessionIdToRefocus = activeWorkspace?.focusedSessionId ?? activeSession?.id;
setSidePanelOpenTabs(prev => {
const next = new Map(prev);
next.delete(activeTabId);
@@ -1283,7 +1306,16 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
next.delete(activeTabId);
return next;
});
}, [activeTabId]);
refocusTerminalSession(sessionIdToRefocus);
}, [activeTabId, activeWorkspace?.focusedSessionId, activeSession?.id, refocusTerminalSession]);
useEffect(() => {
if (!closeSidePanelRef) return;
closeSidePanelRef.current = handleCloseSidePanel;
return () => {
closeSidePanelRef.current = null;
};
}, [closeSidePanelRef, handleCloseSidePanel]);
// Switch side panel to a specific tab (or toggle if already on that tab)
const handleSwitchSidePanelTab = useCallback((tab: SidePanelTab) => {
@@ -2402,14 +2434,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
onSend={handleComposeSend}
onClose={() => {
setIsComposeBarOpen(false);
// Refocus the terminal pane (matching solo-session behavior)
if (focusedSessionId) {
requestAnimationFrame(() => {
const pane = document.querySelector(`[data-session-id="${focusedSessionId}"]`);
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
textarea?.focus();
});
}
refocusTerminalSession(focusedSessionId);
}}
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
themeColors={composeBarThemeColors}

View File

@@ -304,11 +304,23 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
updateScrollState();
const container = tabsContainerRef.current;
if (container) {
// Translate vertical wheel to horizontal scroll so users can reach
// off-screen tabs with a standard mouse wheel. Trackpad gestures that
// already carry horizontal delta are left alone so native two-finger
// swiping still works.
const handleWheel = (e: WheelEvent) => {
if (e.deltaY !== 0 && e.deltaX === 0) {
e.preventDefault();
container.scrollLeft += e.deltaY;
}
};
container.addEventListener('scroll', updateScrollState);
container.addEventListener('wheel', handleWheel, { passive: false });
const resizeObserver = new ResizeObserver(updateScrollState);
resizeObserver.observe(container);
return () => {
container.removeEventListener('scroll', updateScrollState);
container.removeEventListener('wheel', handleWheel);
resizeObserver.disconnect();
};
}

View File

@@ -36,7 +36,7 @@ import { useStoredViewMode } from "../application/state/useStoredViewMode";
import { useStoredBoolean } from "../application/state/useStoredBoolean";
import { useTreeExpandedState } from "../application/state/useTreeExpandedState";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { getEffectiveHostDistro, sanitizeHost } from "../domain/host";
import { getEffectiveHostDistro, sanitizeHost, upsertHostById } from "../domain/host";
import { importVaultHostsFromText, exportHostsToCsvWithStats } from "../domain/vaultImport";
import type { VaultImportFormat } from "../domain/vaultImport";
import {
@@ -2941,13 +2941,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
groupDefaults={editingHostGroupDefaults}
groupConfigs={groupConfigs}
onSave={(host) => {
// Check if host already exists in the list (for updates vs. new/duplicate)
const hostExists = hosts.some((h) => h.id === host.id);
onUpdateHosts(
hostExists
? hosts.map((h) => (h.id === host.id ? host : h))
: [...hosts, host],
);
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
@@ -2973,15 +2967,15 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
allTags={allTags}
groups={allGroupPaths}
onSave={(host) => {
onUpdateHosts(
hosts.map((h) => (h.id === host.id ? host : h)),
);
onUpdateHosts(upsertHostById(hosts, host));
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
onCancel={() => {
setIsHostPanelOpen(false);
setEditingHost(null);
setNewHostGroupPath(null);
}}
layout="inline"
/>

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { AlertTriangle } from 'lucide-react';
import type { ShrinkFinding } from '../../domain/syncGuards';
import { Button } from '../ui/button';
import { useI18n } from '../../application/i18n/I18nProvider';
interface Props {
finding: Extract<ShrinkFinding, { suspicious: true }>;
onRestore: () => void;
onForcePush: () => void;
}
export const SyncBlockedBanner: React.FC<Props> = ({ finding, onRestore, onForcePush }) => {
const { t } = useI18n();
const entityLabel = t(`sync.entityType.${finding.entityType}`);
const percent = finding.baseCount > 0 ? Math.round((finding.lost / finding.baseCount) * 100) : 0;
const reasonText = finding.reason === 'bulk-shrink'
? t('sync.blocked.reason.bulkShrink', {
lost: finding.lost,
baseCount: finding.baseCount,
entityType: entityLabel,
percent,
})
: t('sync.blocked.reason.largeShrink', {
lost: finding.lost,
entityType: entityLabel,
});
return (
<div
role="alert"
className="flex flex-col gap-2 rounded-md border border-amber-500/40 bg-amber-500/10 p-4"
>
<div className="flex items-center gap-2 font-semibold">
<AlertTriangle className="h-4 w-4 text-amber-500" />
<span>{t('sync.blocked.title')}</span>
</div>
<p className="text-sm">{reasonText}</p>
<p className="text-xs opacity-70">{t('sync.blocked.detail')}</p>
<div className="flex gap-2">
<Button variant="default" size="sm" onClick={onRestore}>
{t('sync.blocked.restoreButton')}
</Button>
<Button variant="outline" size="sm" onClick={onForcePush}>
{t('sync.blocked.forcePushButton')}
</Button>
</div>
</div>
);
};

View File

@@ -690,7 +690,9 @@ export function useTerminalAutocomplete(
}
}
// Tab: accept selected popup suggestion, or accept ghost text
// Tab: accept selected popup suggestion. Ghost text is accepted via → only —
// letting Tab pass through lets the shell's native completion (bash/zsh) run,
// which is otherwise shadowed by our single-Tab ghost accept.
if (e.key === "Tab" && !e.ctrlKey && !e.metaKey && !e.altKey && s.subDirFocusLevel < 0) {
if (s.popupVisible && s.suggestions.length > 0) {
e.preventDefault();
@@ -698,16 +700,10 @@ export function useTerminalAutocomplete(
if (selected) insertSuggestion(selected, false);
return false;
}
// Hide stale ghost text before Tab reaches the shell — the shell's
// completion will rewrite the line and the old ghost would mislead.
if (ghost?.isVisible()) {
e.preventDefault();
const ghostText = ghost.getGhostText();
if (ghostText) {
writeToTerminal(ghostText);
lastAcceptedCommandRef.current = ghost.getSuggestion();
ghost.hide();
clearState();
}
return false;
ghost.hide();
}
}

View File

@@ -0,0 +1,64 @@
import test from "node:test";
import assert from "node:assert/strict";
import { shouldPreserveTerminalFocusOnMouseDown } from "./toolbarFocus.ts";
test("preserves terminal focus for non-editable overlay clicks", () => {
const buttonLikeTarget = {
tagName: "button",
isContentEditable: false,
closest() {
return null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(buttonLikeTarget as unknown as EventTarget), true);
});
test("allows native focus for direct editable targets", () => {
const inputTarget = {
tagName: "input",
isContentEditable: false,
closest() {
return null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(inputTarget as unknown as EventTarget), false);
});
test("allows native focus for descendants inside editable controls", () => {
const nestedTarget = {
tagName: "span",
isContentEditable: false,
closest(selector: string) {
return selector.includes("input") ? { tagName: "INPUT" } : null;
},
getAttribute() {
return null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(nestedTarget as unknown as EventTarget), false);
});
test("allows native focus for contenteditable regions", () => {
const editableTarget = {
tagName: "div",
isContentEditable: false,
closest() {
return null;
},
getAttribute(name: string) {
return name === "contenteditable" ? "true" : null;
},
};
assert.equal(shouldPreserveTerminalFocusOnMouseDown(editableTarget as unknown as EventTarget), false);
});

View File

@@ -0,0 +1,44 @@
type FocusTargetLike = {
tagName?: string | null;
isContentEditable?: boolean;
closest?: (selector: string) => unknown;
getAttribute?: (name: string) => string | null;
};
const EDITABLE_SELECTOR = 'input, textarea, select, [contenteditable=""], [contenteditable="true"], [role="textbox"]';
/**
* The terminal's top overlay sits above the xterm textarea. Pointer clicks on
* that layer should usually keep focus in the terminal so typing can continue.
* Only allow native focus changes for genuinely editable controls.
*/
export const shouldPreserveTerminalFocusOnMouseDown = (target: EventTarget | null): boolean => {
if (!target || typeof target !== "object") return true;
const candidate = target as FocusTargetLike;
const tagName = typeof candidate.tagName === "string"
? candidate.tagName.toUpperCase()
: "";
if (tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT") {
return false;
}
if (candidate.isContentEditable) {
return false;
}
if (typeof candidate.getAttribute === "function") {
const contentEditable = candidate.getAttribute("contenteditable");
const role = candidate.getAttribute("role");
if (contentEditable === "" || contentEditable === "true" || role === "textbox") {
return false;
}
}
if (typeof candidate.closest === "function" && candidate.closest(EDITABLE_SELECTOR)) {
return false;
}
return true;
};

51
domain/host.test.ts Normal file
View File

@@ -0,0 +1,51 @@
import test from "node:test";
import assert from "node:assert/strict";
import type { Host } from "./models.ts";
import { upsertHostById } from "./host.ts";
const makeHost = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "Primary Host",
hostname: "127.0.0.1",
port: 22,
username: "root",
authType: "password",
createdAt: 1,
protocol: "ssh",
...overrides,
});
test("upsertHostById updates an existing host in place", () => {
const existing = makeHost();
const updated = makeHost({ label: "Updated Host" });
assert.deepEqual(upsertHostById([existing], updated), [updated]);
});
test("upsertHostById appends a duplicated host with a fresh id", () => {
const existing = makeHost({
id: "serial-original",
label: "Serial Config",
protocol: "serial",
hostname: "/dev/ttyUSB0",
port: 115200,
serialConfig: {
path: "/dev/ttyUSB0",
baudRate: 115200,
dataBits: 8,
stopBits: 1,
parity: "none",
flowControl: "none",
localEcho: false,
lineMode: false,
},
});
const duplicate = makeHost({
...existing,
id: "serial-duplicate",
label: "Serial Config (copy)",
});
assert.deepEqual(upsertHostById([existing], duplicate), [existing, duplicate]);
});

View File

@@ -153,6 +153,13 @@ export const formatHostPort = (hostname: string, port?: number | null): string =
return `${display}:${port}`;
};
export const upsertHostById = (hosts: Host[], host: Host): Host[] => {
const hostExists = hosts.some((entry) => entry.id === host.id);
return hostExists
? hosts.map((entry) => (entry.id === host.id ? host : entry))
: [...hosts, host];
};
export const sanitizeHost = (host: Host): Host => {
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
const cleanDistro = normalizeDistroId(host.distro);

View File

@@ -1,10 +1,12 @@
/**
* Cloud Sync Domain Types & Interfaces
*
*
* Zero-Knowledge Encrypted Multi-Cloud Sync System
* Supports: GitHub Gist, Google Drive, Microsoft OneDrive, WebDAV, S3 Compatible
*/
import type { ShrinkFinding } from './syncGuards';
// ============================================================================
// Security State Machine
// ============================================================================
@@ -22,10 +24,11 @@ export type SecurityState =
* Sync Operation State Machine
* Tracks the current sync operation status
*/
export type SyncState =
export type SyncState =
| 'IDLE' // Waiting for sync trigger
| 'SYNCING' // Active sync operation in progress
| 'CONFLICT' // Version conflict detected - needs resolution
| 'BLOCKED' // Outgoing payload would delete too much — user must choose restore or force-push
| 'ERROR'; // Operation failed - needs attention
/**
@@ -284,6 +287,10 @@ export interface SyncResult {
conflictDetected?: boolean;
/** Present when action === 'merge'; caller should apply this to update local state */
mergedPayload?: import('./sync').SyncPayload;
/** True when a shrink-detection guard blocked the upload */
shrinkBlocked?: boolean;
/** The finding that triggered the shrink block or force-push */
finding?: ShrinkFinding;
}
/**
@@ -351,10 +358,13 @@ export type SyncEvent =
| { type: 'SYNC_COMPLETED'; provider: CloudProvider; result: SyncResult }
| { type: 'SYNC_ERROR'; provider: CloudProvider; error: string }
| { type: 'CONFLICT_DETECTED'; conflict: ConflictInfo }
| { type: 'SYNC_BLOCKED_SHRINK'; provider: CloudProvider; finding: ShrinkFinding }
| { type: 'SYNC_FORCED'; provider: CloudProvider; finding: ShrinkFinding }
| { type: 'CONFLICT_RESOLVED'; resolution: ConflictResolution }
| { type: 'AUTH_REQUIRED'; provider: CloudProvider }
| { type: 'AUTH_COMPLETED'; provider: CloudProvider; account: ProviderAccount }
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState };
| { type: 'SECURITY_STATE_CHANGED'; state: SecurityState }
| { type: 'SYNC_BLOCKED_CLEARED' };
// ============================================================================
// Storage Keys

139
domain/syncGuards.test.ts Normal file
View File

@@ -0,0 +1,139 @@
import test from "node:test";
import assert from "node:assert/strict";
import { detectSuspiciousShrink } from "./syncGuards.ts";
import type { SyncPayload } from "./sync.ts";
function payload(overrides: Partial<SyncPayload> = {}): SyncPayload {
return {
hosts: [],
keys: [],
identities: [],
snippets: [],
customGroups: [],
snippetPackages: [],
knownHosts: [],
portForwardingRules: [],
groupConfigs: [],
settings: undefined,
syncedAt: 0,
...overrides,
};
}
function hosts(n: number): SyncPayload["hosts"] {
return Array.from({ length: n }, (_, i) => ({
id: `h${i}`,
label: `h${i}`,
hostname: `h${i}.example`,
port: 22,
username: "root",
protocol: "ssh",
})) as SyncPayload["hosts"];
}
test("null base → not suspicious (first sync / null after re-auth)", () => {
const result = detectSuspiciousShrink(payload({ hosts: hosts(1) }), null);
assert.deepEqual(result, { suspicious: false });
});
test("no shrink — same counts → not suspicious", () => {
const base = payload({ hosts: hosts(5) });
const out = payload({ hosts: hosts(5) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("growth only → not suspicious", () => {
const base = payload({ hosts: hosts(5) });
const out = payload({ hosts: hosts(10) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("shrink under both thresholds → not suspicious (delete 2 of 4)", () => {
const base = payload({ hosts: hosts(4) });
const out = payload({ hosts: hosts(2) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("bulk-shrink 50% AND absolute 3 — exactly at threshold → suspicious", () => {
const base = payload({ hosts: hosts(6) });
const out = payload({ hosts: hosts(3) });
assert.deepEqual(detectSuspiciousShrink(out, base), {
suspicious: true,
reason: "bulk-shrink",
entityType: "hosts",
baseCount: 6,
outgoingCount: 3,
lost: 3,
});
});
test("bulk-shrink 50% but absolute 2 → not suspicious (absolute gate)", () => {
const base = payload({ hosts: hosts(4) });
const out = payload({ hosts: hosts(2) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("bulk-shrink 40% absolute 4 → not suspicious (ratio gate)", () => {
const base = payload({ hosts: hosts(10) });
const out = payload({ hosts: hosts(6) });
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});
test("large-shrink absolute 10 regardless of ratio → suspicious", () => {
const base = payload({ hosts: hosts(100) });
const out = payload({ hosts: hosts(90) });
assert.deepEqual(detectSuspiciousShrink(out, base), {
suspicious: true,
reason: "large-shrink",
entityType: "hosts",
baseCount: 100,
outgoingCount: 90,
lost: 10,
});
});
test("dual-trigger (large-shrink AND bulk-shrink both satisfied) → reason is 'large-shrink'", () => {
// base=20, lost=10: satisfies large-shrink (>=10) AND bulk-shrink (50%, >=3)
const base = payload({ hosts: hosts(20) });
const out = payload({ hosts: hosts(10) });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) assert.equal(result.reason, "large-shrink");
});
test("multiple entity types shrinking — returns first in declaration order (hosts before keys)", () => {
const base = payload({ hosts: hosts(6), keys: Array.from({ length: 6 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
const out = payload({ hosts: hosts(3), keys: Array.from({ length: 3 }, (_, i) => ({ id: `k${i}`, label: `k${i}`, privateKey: "x" })) as SyncPayload["keys"] });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) assert.equal(result.entityType, "hosts");
});
test("only non-hosts entity shrinks → reports that entity", () => {
const snippets = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `s${i}`, label: `s${i}`, command: "" })) as SyncPayload["snippets"];
const base = payload({ snippets: snippets(10) });
const out = payload({ snippets: snippets(0) });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) {
assert.equal(result.entityType, "snippets");
assert.equal(result.reason, "large-shrink");
}
});
test("knownHosts shrink triggers (security-sensitive)", () => {
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
const base = payload({ knownHosts: kh(12) });
const out = payload({ knownHosts: kh(2) });
const result = detectSuspiciousShrink(out, base);
assert.equal(result.suspicious, true);
if (result.suspicious) assert.equal(result.entityType, "knownHosts");
});
test("empty base (all zeros) — no shrink possible, returns not suspicious", () => {
const base = payload();
const out = payload({ hosts: hosts(5) });
// All base counts are 0; no shrink possible
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
});

85
domain/syncGuards.ts Normal file
View File

@@ -0,0 +1,85 @@
import type { SyncPayload } from './sync';
export type ShrinkFinding =
| { suspicious: false }
| {
suspicious: true;
reason: 'bulk-shrink' | 'large-shrink';
entityType:
| 'hosts'
| 'keys'
| 'identities'
| 'snippets'
| 'customGroups'
| 'snippetPackages'
| 'knownHosts'
| 'portForwardingRules'
| 'groupConfigs';
baseCount: number;
outgoingCount: number;
lost: number;
};
// Keep in sync with all array-typed fields of SyncPayload. When a new
// array entity type is added there, add it here too — there is no
// compile-time check enforcing this.
const CHECKED_ENTITIES = [
'hosts',
'keys',
'identities',
'snippets',
'customGroups',
'snippetPackages',
'knownHosts',
'portForwardingRules',
'groupConfigs',
] as const;
type CheckedEntityType = typeof CHECKED_ENTITIES[number];
const BULK_SHRINK_RATIO = 0.5;
const BULK_SHRINK_MIN_ABSOLUTE = 3;
const LARGE_SHRINK_ABSOLUTE = 10;
function countOf(p: SyncPayload, key: CheckedEntityType): number {
const v = p[key];
return Array.isArray(v) ? v.length : 0;
}
export function detectSuspiciousShrink(
outgoing: SyncPayload,
base: SyncPayload | null,
): ShrinkFinding {
if (!base) return { suspicious: false };
for (const entityType of CHECKED_ENTITIES) {
const baseCount = countOf(base, entityType);
const outgoingCount = countOf(outgoing, entityType);
const lost = baseCount - outgoingCount;
if (lost <= 0) continue;
if (lost >= LARGE_SHRINK_ABSOLUTE) {
return {
suspicious: true,
reason: 'large-shrink',
entityType,
baseCount,
outgoingCount,
lost,
};
}
if (baseCount > 0 && lost / baseCount >= BULK_SHRINK_RATIO && lost >= BULK_SHRINK_MIN_ABSOLUTE) {
return {
suspicious: true,
reason: 'bulk-shrink',
entityType,
baseCount,
outgoingCount,
lost,
};
}
}
return { suspicious: false };
}

View File

@@ -29,6 +29,7 @@ module.exports = {
'node_modules/node-pty/**/*',
'node_modules/ssh2/**/*',
'node_modules/cpu-features/**/*',
'node_modules/@vscode/windows-process-tree/**/*',
'node_modules/@zed-industries/claude-agent-acp/**/*',
'node_modules/@agentclientprotocol/sdk/**/*',
'node_modules/@anthropic-ai/claude-agent-sdk/**/*',

View File

@@ -0,0 +1,89 @@
const { execFile } = require("node:child_process");
function createProcessTree({ platform, listPosix, listWindows } = {}) {
const sessionPidMap = new Map();
function registerPid(sessionId, pid) {
if (!sessionId || typeof pid !== "number") return;
if (sessionPidMap.has(sessionId) && sessionPidMap.get(sessionId) !== pid) {
console.warn(
`[ptyProcessTree] sessionId "${sessionId}" already registered with pid ${sessionPidMap.get(sessionId)}; overwriting with ${pid}.`,
);
}
sessionPidMap.set(sessionId, pid);
}
function unregisterPid(sessionId) {
sessionPidMap.delete(sessionId);
}
async function getChildProcesses(sessionId) {
const pid = sessionPidMap.get(sessionId);
if (!pid) return [];
if (platform === "win32") {
return listWindows ? listWindows(pid) : [];
}
return listPosix ? listPosix(pid) : [];
}
return { registerPid, unregisterPid, getChildProcesses };
}
function defaultListPosix(ppid) {
return new Promise((resolve) => {
// `ps -A -o pid=,ppid=,args=` works on both BSD (macOS) and GNU (Linux).
// `args=` shows the full command line (not truncated like `comm=`).
// The trailing `=` on each column suppresses the header row.
execFile("ps", ["-A", "-o", "pid=,ppid=,args="], (err, stdout) => {
if (err || typeof stdout !== "string") return resolve([]);
const out = [];
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
const m = trimmed.match(/^(\d+)\s+(\d+)\s+(.+)$/);
if (!m) continue;
if (Number(m[2]) !== ppid) continue;
out.push({ pid: Number(m[1]), command: m[3].trim() });
}
resolve(out);
});
});
}
function defaultListWindows(ppid) {
return new Promise((resolve) => {
let wpt;
try {
wpt = require("@vscode/windows-process-tree");
} catch {
return resolve([]);
}
try {
wpt.getProcessTree(ppid, (tree) => {
if (!tree || !Array.isArray(tree.children)) return resolve([]);
resolve(tree.children.map((c) => ({ pid: c.pid, command: c.name })));
});
} catch {
resolve([]);
}
});
}
function createDefaultProcessTree() {
const platform = process.platform;
return createProcessTree({
platform,
listPosix: platform === "win32" ? undefined : defaultListPosix,
listWindows: platform === "win32" ? defaultListWindows : undefined,
});
}
const defaultTree = createDefaultProcessTree();
module.exports = {
createProcessTree,
processTree: defaultTree,
registerPid: (id, pid) => defaultTree.registerPid(id, pid),
unregisterPid: (id) => defaultTree.unregisterPid(id),
getChildProcesses: (id) => defaultTree.getChildProcesses(id),
};

View File

@@ -0,0 +1,79 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createProcessTree } = require("./ptyProcessTree.cjs");
test("getChildProcesses returns [] when session has no registered pid", async () => {
const tree = createProcessTree({ platform: "darwin", listPosix: async () => [] });
assert.deepEqual(await tree.getChildProcesses("unknown-session"), []);
});
test("getChildProcesses calls listPosix with the registered ppid and returns its result", async () => {
const calls = [];
const listPosix = async (ppid) => {
calls.push(ppid);
return [
{ pid: 2001, command: "sleep 100" },
{ pid: 2002, command: "node server.js" },
];
};
const tree = createProcessTree({ platform: "linux", listPosix });
tree.registerPid("s1", 1234);
assert.deepEqual(await tree.getChildProcesses("s1"), [
{ pid: 2001, command: "sleep 100" },
{ pid: 2002, command: "node server.js" },
]);
assert.deepEqual(calls, [1234]);
});
test("unregisterPid clears mapping", async () => {
const tree = createProcessTree({
platform: "darwin",
listPosix: async () => [{ pid: 9, command: "x" }],
});
tree.registerPid("s1", 1234);
tree.unregisterPid("s1");
assert.deepEqual(await tree.getChildProcesses("s1"), []);
});
test("getChildProcesses on windows uses listWindows", async () => {
const calls = [];
const listWindows = async (pid) => {
calls.push(pid);
return [{ pid: 55, command: "python.exe" }];
};
const tree = createProcessTree({ platform: "win32", listWindows });
tree.registerPid("s1", 3000);
assert.deepEqual(await tree.getChildProcesses("s1"), [{ pid: 55, command: "python.exe" }]);
assert.deepEqual(calls, [3000]);
});
test("getChildProcesses returns [] when listPosix missing on posix", async () => {
const tree = createProcessTree({ platform: "darwin" });
tree.registerPid("s1", 1234);
assert.deepEqual(await tree.getChildProcesses("s1"), []);
});
test("getChildProcesses returns [] when listWindows missing on windows", async () => {
const tree = createProcessTree({ platform: "win32" });
tree.registerPid("s1", 3000);
assert.deepEqual(await tree.getChildProcesses("s1"), []);
});
test("registerPid warns when overwriting an existing sessionId with a different pid", async () => {
const warnCalls = [];
const origWarn = console.warn;
console.warn = (...args) => warnCalls.push(args);
try {
const tree = createProcessTree({ platform: "darwin", listPosix: async () => [] });
tree.registerPid("s1", 1234);
tree.registerPid("s1", 1234); // same pid — no warn
tree.registerPid("s1", 5678); // different — should warn
assert.equal(warnCalls.length, 1);
assert.match(warnCalls[0][0], /s1/);
assert.match(warnCalls[0][0], /1234/);
assert.match(warnCalls[0][0], /5678/);
} finally {
console.warn = origWarn;
}
});

View File

@@ -0,0 +1,46 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const Protocol = require("ssh2/lib/protocol/Protocol");
function parseIdentification(line) {
let header;
const protocol = new Protocol({
onWrite() {},
onError(err) {
throw err;
},
onHeader(nextHeader) {
header = nextHeader;
},
});
const data = Buffer.from(`${line}\r\n`, "latin1");
protocol.parse(data, 0, data.length);
assert.ok(header, "expected SSH header to be parsed");
return header;
}
test("ssh2 accepts an empty softwareversion for compatibility", () => {
const header = parseIdentification("SSH-2.0-");
assert.equal(header.versions.protocol, "2.0");
assert.equal(header.versions.software, "");
assert.equal(header.comments, undefined);
});
test("ssh2 still accepts standard identification strings", () => {
const header = parseIdentification("SSH-2.0-OpenSSH_9.9 Netcatty");
assert.equal(header.versions.protocol, "2.0");
assert.equal(header.versions.software, "OpenSSH_9.9");
assert.equal(header.comments, "Netcatty");
});
test("ssh2 still rejects malformed identification strings", () => {
assert.throws(
() => parseIdentification("SSH-2.0"),
/Invalid identification string/,
);
});

View File

@@ -10,6 +10,7 @@ const path = require("node:path");
const { StringDecoder } = require("node:string_decoder");
const pty = require("node-pty");
const { SerialPort } = require("serialport");
const ptyProcessTree = require("./ptyProcessTree.cjs");
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
const { detectShellKind } = require("./ai/ptyExec.cjs");
@@ -326,6 +327,7 @@ function startLocalSession(event, payload) {
_promptTrackTail: "",
};
sessions.set(sessionId, session);
ptyProcessTree.registerPid(sessionId, proc.pid);
// Start real-time session log stream if configured
if (payload?.sessionLog?.enabled && payload?.sessionLog?.directory) {
@@ -382,6 +384,7 @@ function startLocalSession(event, payload) {
proc.onExit((evt) => {
flushLocal();
sessionLogStreamManager.stopStream(sessionId);
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
// Signal present = killed externally (show disconnected UI).
@@ -648,6 +651,7 @@ async function startTelnetSession(event, options) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
}
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
}
});
@@ -664,6 +668,7 @@ async function startTelnetSession(event, options) {
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: hadError ? 1 : 0, reason: hadError ? "error" : "closed" });
}
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
});
@@ -802,6 +807,7 @@ async function startMoshSession(event, options) {
proc.onExit((evt) => {
flushMosh();
sessionLogStreamManager.stopStream(sessionId);
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
// Mosh non-zero exit typically means connection/auth failure — show error UI
@@ -931,6 +937,7 @@ async function startSerialSession(event, options) {
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
});
@@ -940,6 +947,7 @@ async function startSerialSession(event, options) {
sessionLogStreamManager.stopStream(sessionId);
const contents = electronModule.webContents.fromId(session.webContentsId);
contents?.send("netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
ptyProcessTree.unregisterPid(sessionId);
sessions.delete(sessionId);
});
@@ -1043,6 +1051,7 @@ function closeSession(event, payload) {
} catch (err) {
console.warn("Close failed", err);
}
ptyProcessTree.unregisterPid(payload.sessionId);
sessions.delete(payload.sessionId);
}
@@ -1166,6 +1175,9 @@ function cleanupAllSessions() {
// Ignore cleanup errors
}
}
for (const [sessionId] of sessions) {
ptyProcessTree.unregisterPid(sessionId);
}
sessions.clear();
}

View File

@@ -97,6 +97,7 @@ function toBackupSummary(record) {
id: record.id,
createdAt: record.createdAt,
reason: record.reason,
syncDataVersion: record.syncDataVersion,
sourceAppVersion: record.sourceAppVersion,
targetAppVersion: record.targetAppVersion,
preview: record.preview,
@@ -131,6 +132,15 @@ function sanitizeOptionalVersionString(value) {
return trimmed;
}
// Sync data version is the integer that the CloudSyncManager increments
// on each successful cloud sync. Reject anything non-finite, non-positive,
// or non-integer so the persisted record only carries meaningful values.
function sanitizeOptionalSyncDataVersion(value) {
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
if (value < 1) return undefined;
return Math.floor(value);
}
// UTF-8 byte length of a payload's JSON serialization. Earlier revisions
// returned `JSON.stringify(payload).length` (UTF-16 code units), which
// under-counted by ~3x for non-ASCII vaults — a deck full of CJK snippet
@@ -415,6 +425,7 @@ function createVaultBackupService({ app, safeStorage, shell }) {
id,
createdAt,
reason: sanitizeReason(options.reason),
syncDataVersion: sanitizeOptionalSyncDataVersion(options.syncDataVersion),
sourceAppVersion: sanitizeOptionalVersionString(options.sourceAppVersion),
targetAppVersion: sanitizeOptionalVersionString(options.targetAppVersion),
fingerprint,

View File

@@ -417,6 +417,70 @@ test("createBackup accepts a legitimate SemVer-ish version string", async () =>
}
});
test("createBackup persists syncDataVersion when given a positive integer", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const result = await service.createBackup({
payload: samplePayload(),
reason: "before_restore",
syncDataVersion: 5,
});
assert.equal(result.created, true);
assert.equal(result.backup.syncDataVersion, 5);
// Round-trip via list
const listed = await service.listBackups();
assert.equal(listed[0].syncDataVersion, 5);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup drops invalid syncDataVersion values (zero, negative, non-finite, non-numeric)", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const cases = [0, -1, NaN, Infinity, "5", null, undefined];
let idx = 0;
for (const syncDataVersion of cases) {
// Vary an actual content-bearing field to avoid fingerprint dedupe
// (top-level syncedAt is normalized away in the fingerprint).
const payload = samplePayload({
hosts: [{ ...samplePayload().hosts[0], id: `h-case-${idx}` }],
});
const result = await service.createBackup({
payload,
reason: "before_restore",
syncDataVersion,
});
assert.equal(result.created, true, `iteration ${idx}: created should be true`);
assert.equal(result.backup.syncDataVersion, undefined, `value ${String(syncDataVersion)} should be dropped`);
idx += 1;
}
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup floors a fractional syncDataVersion", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);
try {
const result = await service.createBackup({
payload: samplePayload(),
reason: "before_restore",
syncDataVersion: 7.9,
});
assert.equal(result.backup.syncDataVersion, 7);
} finally {
fs.rmSync(rootDir, { recursive: true, force: true });
}
});
test("createBackup rejects an array payload (not an object)", async () => {
const rootDir = createTempRoot();
const service = createService(rootDir);

View File

@@ -165,6 +165,7 @@ const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
const getVaultBackupBridge = createLazyModule("./bridges/vaultBackupBridge.cjs");
const ptyProcessTree = require("./bridges/ptyProcessTree.cjs");
// GPU settings
// NOTE: Do not disable Chromium sandbox by default.
@@ -683,6 +684,40 @@ const registerBridges = (win) => {
};
});
// PTY child process list for busy-check before close
ipcMain.handle("netcatty:pty:childProcesses", async (_event, sessionId) => {
if (typeof sessionId !== "string") return [];
return ptyProcessTree.getChildProcesses(sessionId);
});
// Native confirmation dialog when closing a session with a running process
// Returns true only if the user explicitly clicks "Close". ESC/dialog-dismiss
// resolves as cancelId (0) → false, which is the safe default (do not close).
ipcMain.handle(
"netcatty:dialog:confirmCloseBusy",
async (event, payload) => {
const command = typeof payload?.command === "string" ? payload.command : "unknown";
const title = typeof payload?.title === "string" ? payload.title : "Confirm close";
const message = typeof payload?.message === "string"
? payload.message
: `Process "${command}" is still running and will be terminated.`;
const cancelLabel = typeof payload?.cancelLabel === "string" ? payload.cancelLabel : "Cancel";
const closeLabel = typeof payload?.closeLabel === "string" ? payload.closeLabel : "Close";
const { dialog } = electronModule;
const win = BrowserWindow.fromWebContents(event.sender);
const { response } = await dialog.showMessageBox(win || undefined, {
type: "warning",
title,
message,
buttons: [cancelLabel, closeLabel],
defaultId: 0,
cancelId: 0,
noLink: true,
});
return response === 1; // true = user picked Close
},
);
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
ipcMain.handle("netcatty:clipboard:readText", async () => {
try {

View File

@@ -858,6 +858,10 @@ const api = {
// App info
getAppInfo: () => ipcRenderer.invoke("netcatty:app:getInfo"),
ptyGetChildProcesses: (sessionId) =>
ipcRenderer.invoke("netcatty:pty:childProcesses", sessionId),
confirmCloseBusy: (payload) =>
ipcRenderer.invoke("netcatty:dialog:confirmCloseBusy", payload),
getVaultBackupCapabilities: () =>
ipcRenderer.invoke("netcatty:vaultBackups:capabilities"),
createVaultBackup: (payload) =>

8
global.d.ts vendored
View File

@@ -512,6 +512,14 @@ declare global {
// App info (name/version/platform) for About screens
getAppInfo?(): Promise<{ name: string; version: string; platform: string }>;
ptyGetChildProcesses?(sessionId: string): Promise<Array<{ pid: number; command: string }>>;
confirmCloseBusy?(payload: {
command: string;
title?: string;
message?: string;
cancelLabel?: string;
closeLabel?: string;
}): Promise<boolean>;
getVaultBackupCapabilities?(): Promise<{ encryptionAvailable: boolean }>;
createVaultBackup?(payload: {
payload: import('./domain/sync').SyncPayload;

View File

@@ -45,6 +45,7 @@ import {
encryptProviderSecrets,
} from '../persistence/secureFieldAdapter';
import { mergeSyncPayloads } from '../../domain/syncMerge';
import { detectSuspiciousShrink, type ShrinkFinding } from '../../domain/syncGuards';
// Extracted into a plain ESM module so the signature logic is covered by
// the node --test harness (see syncSignature.test.mjs). The previous
// inline implementation only hashed a handful of meta fields and was
@@ -77,6 +78,12 @@ export interface SyncManagerState {
autoSyncEnabled: boolean;
autoSyncInterval: number;
syncHistory: SyncHistoryEntry[];
/** Last shrink finding that put us into BLOCKED state, retained until
* a sync actually succeeds (SYNC_COMPLETED with result.success) or
* `clearShrinkBlockedState()` is called. Renderer hydrates the banner
* from this on mount so a block that happened off-screen is still
* visible to the user. */
lastShrinkFinding?: Extract<ShrinkFinding, { suspicious: true }>;
}
export type SyncEventCallback = (event: SyncEvent) => void;
@@ -752,6 +759,12 @@ export class CloudSyncManager {
const ghAdapter = adapter as GitHubAdapter;
try {
// Snapshot the prior account BEFORE we overwrite providers[provider].
// Used as a fallback for the same-account comparison when the persisted
// accountId key is absent (e.g., first re-auth after upgrading to this
// version, where the key didn't exist yet).
const previousAccount = this.state.providers.github?.account;
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
++this.providerDecryptSeq.github;
@@ -769,9 +782,20 @@ export class CloudSyncManager {
}
await this.saveProviderConnection('github', this.state.providers.github);
// Clear merge base when (re)authenticating to a potentially different account
this.removeFromStorage(this.syncBaseKey('github'));
this.clearSyncAnchor('github');
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. See notes in completePKCEAuth.
const newId = ghAdapter.accountInfo?.id ?? null;
const previousId = this.loadProviderAccountId('github') ?? previousAccount?.id ?? null;
const sameAccount = newId !== null && previousId !== null && newId === previousId;
if (!sameAccount) {
this.removeFromStorage(this.syncBaseKey('github'));
this.clearSyncAnchor('github');
}
if (newId) {
this.saveProviderAccountId('github', newId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider: 'github',
@@ -797,6 +821,12 @@ export class CloudSyncManager {
}
try {
// Snapshot the prior account BEFORE we overwrite providers[provider].
// Used as a fallback for the same-account comparison when the persisted
// accountId key is absent (e.g., first re-auth after upgrading to this
// version, where the key didn't exist yet).
const previousAccount = this.state.providers[provider]?.account;
let tokens: OAuthTokens;
let account;
@@ -825,9 +855,22 @@ export class CloudSyncManager {
}
await this.saveProviderConnection(provider, this.state.providers[provider]);
// Clear merge base when (re)authenticating to a potentially different account
this.removeFromStorage(this.syncBaseKey(provider));
this.clearSyncAnchor(provider);
// Only clear the merge base if the authenticated account identity differs
// from the previously-stored one. Same-account re-auth preserves the base
// so the next sync computes correct local-deletions instead of treating
// it as "first sync" and resurrecting zombie entries via null-base union.
const newId = account?.id ?? null;
const previousId = this.loadProviderAccountId(provider) ?? previousAccount?.id ?? null;
const sameAccount = newId !== null && previousId !== null && newId === previousId;
if (!sameAccount) {
this.removeFromStorage(this.syncBaseKey(provider));
this.clearSyncAnchor(provider);
}
if (newId) {
this.saveProviderAccountId(provider, newId);
}
this.emit({
type: 'AUTH_COMPLETED',
provider,
@@ -912,6 +955,13 @@ export class CloudSyncManager {
// account/resource doesn't reuse an unrelated snapshot
this.removeFromStorage(this.syncBaseKey(provider));
this.clearSyncAnchor(provider);
this.removeFromStorage(this.providerAccountIdKey(provider));
// Reset BLOCKED state if it was present — disconnect implicitly resolves
// any pending shrink-block warning since there's no provider to push to.
this.exitBlockedState();
if (this.state.syncState === 'BLOCKED') {
this.state.syncState = 'IDLE';
}
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
}
@@ -1227,7 +1277,8 @@ export class CloudSyncManager {
*/
async syncToProvider(
provider: CloudProvider,
payload: SyncPayload
payload: SyncPayload,
opts: { overrideShrink?: boolean } = {},
): Promise<SyncResult> {
if (this.state.securityState !== 'UNLOCKED') {
return {
@@ -1247,6 +1298,8 @@ export class CloudSyncManager {
};
}
const overrideShrinkRequested = opts.overrideShrink === true;
let adapter: CloudAdapter;
try {
adapter = await this.getConnectedAdapter(provider);
@@ -1288,6 +1341,30 @@ export class CloudSyncManager {
console.info('[CloudSyncManager] Three-way merge completed', mergeResult.summary);
// Shrink guard: refuse to push a merged payload that silently deletes
// entities we still have in base. The merge itself is correct if local
// state is trustworthy — but a degraded local (keychain failure,
// partial load) can make merge produce a smaller-than-expected result.
const mergedShrink = detectSuspiciousShrink(mergeResult.payload, base);
const shouldBlockMerged = mergedShrink.suspicious && !overrideShrinkRequested;
const shouldForceMerged = mergedShrink.suspicious && overrideShrinkRequested;
if (shouldBlockMerged) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = mergedShrink;
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: mergedShrink });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
return {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding: mergedShrink,
};
}
if (shouldForceMerged) {
this.emit({ type: 'SYNC_FORCED', provider, finding: mergedShrink });
}
// Encrypt and upload merged payload
const mergedSyncedFile = await EncryptionService.encryptPayload(
mergeResult.payload,
@@ -1309,6 +1386,7 @@ export class CloudSyncManager {
// Base was persisted inside uploadToProvider before the
// anchor advanced, so a crash between them cannot leave a
// stale base pointing at pre-merge state.
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.addSyncHistoryEntry({
@@ -1361,6 +1439,29 @@ export class CloudSyncManager {
}
}
// Shrink guard (no-conflict path): same rationale as the merge branch —
// refuse a payload that drops entities versus the stored base.
const directBase = await this.loadSyncBase(provider);
const directShrink = detectSuspiciousShrink(payload, directBase);
const shouldBlockDirect = directShrink.suspicious && !overrideShrinkRequested;
const shouldForceDirect = directShrink.suspicious && overrideShrinkRequested;
if (shouldBlockDirect) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = directShrink;
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding: directShrink });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
return {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding: directShrink,
};
}
if (shouldForceDirect) {
this.emit({ type: 'SYNC_FORCED', provider, finding: directShrink });
}
// 2. Encrypt
const syncedFile = await EncryptionService.encryptPayload(
payload,
@@ -1377,7 +1478,9 @@ export class CloudSyncManager {
const result = await this.uploadToProvider(provider, adapter, syncedFile, payload);
if (result.success) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
} else {
this.state.syncState = 'ERROR';
if (result.error) {
@@ -1550,26 +1653,73 @@ export class CloudSyncManager {
// Download and return remote data
const payload = await this.downloadFromProvider(provider);
this.state.currentConflict = null;
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.notifyStateChange(); // Notify UI of conflict resolution
return payload;
} else {
// USE_LOCAL - just clear conflict, caller will re-sync
this.state.currentConflict = null;
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.notifyStateChange(); // Notify UI of conflict resolution
return null;
}
}
/**
* Side-effect helper: called BEFORE any syncState assignment that transitions
* away from BLOCKED. Clears lastShrinkFinding and emits SYNC_BLOCKED_CLEARED
* so the UI banner (and any other subscriber) gets a single, authoritative
* "block resolved" signal. The guard on syncState === 'BLOCKED' makes it safe
* to call unconditionally at every non-BLOCKED assignment site — it no-ops
* when the state was already non-BLOCKED.
*/
private exitBlockedState(): void {
if (this.state.syncState === 'BLOCKED') {
this.state.lastShrinkFinding = undefined;
this.emit({ type: 'SYNC_BLOCKED_CLEARED' });
}
}
/**
* Reset BLOCKED back to IDLE without going through a successful sync.
* Used by post-merge round-trip to avoid wedging the manager in BLOCKED
* when the merge already produced safe local state and the round-trip
* push is just an optimization.
*/
clearShrinkBlockedState(): void {
if (this.state.syncState === 'BLOCKED') {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.notifyStateChange();
}
}
/**
* Returns the last shrink finding that triggered BLOCKED state, or
* null if not currently blocked. Used by the renderer to hydrate the
* SyncBlockedBanner when opening Settings after a block happened
* off-screen.
*/
getShrinkBlockedFinding(): Extract<ShrinkFinding, { suspicious: true }> | null {
if (this.state.syncState !== 'BLOCKED') return null;
return this.state.lastShrinkFinding ?? null;
}
/**
* Sync to all connected providers
*/
async syncAllProviders(inputPayload?: SyncPayload): Promise<Map<CloudProvider, SyncResult>> {
async syncAllProviders(
inputPayload?: SyncPayload,
opts: { overrideShrink?: boolean } = {},
): Promise<Map<CloudProvider, SyncResult>> {
const results = new Map<CloudProvider, SyncResult>();
let payload = inputPayload;
let wasMerged = false;
const overrideShrinkRequested = opts.overrideShrink === true;
if (!payload) {
// Caller should provide payload from app state
return results;
@@ -1743,6 +1893,80 @@ export class CloudSyncManager {
}
}
// Shrink guard (multi-provider): check the final outgoing payload against
// each provider's stored base. If ANY provider would suffer a suspicious
// shrink, block ALL uploads — the same payload goes to every provider, so
// any one provider's "would lose too much" is a global block. Override flag
// is one-shot and clears regardless of outcome.
const shrinkSuspectByProvider: Array<{
provider: CloudProvider;
finding: Extract<ShrinkFinding, { suspicious: true }>;
}> = [];
const candidateProviders = checkResults
.filter((r) => !r.error && !r.check?.conflict && r.adapter)
.map((r) => r.provider as CloudProvider);
for (const provider of candidateProviders) {
const providerBase = await this.loadSyncBase(provider);
const finding = detectSuspiciousShrink(payload, providerBase);
if (finding.suspicious) {
shrinkSuspectByProvider.push({ provider, finding });
}
}
const shouldBlockAll = shrinkSuspectByProvider.length > 0 && !overrideShrinkRequested;
const shouldForceAll = shrinkSuspectByProvider.length > 0 && overrideShrinkRequested;
if (shouldBlockAll) {
this.state.syncState = 'BLOCKED';
this.state.lastShrinkFinding = shrinkSuspectByProvider[0].finding;
for (const { provider, finding } of shrinkSuspectByProvider) {
this.emit({ type: 'SYNC_BLOCKED_SHRINK', provider, finding });
this.updateProviderStatus(provider, 'error', 'Sync blocked: would delete too much');
results.set(provider, {
success: false,
provider,
action: 'none',
shrinkBlocked: true,
finding,
});
}
// Process check errors from the parallel check phase so a provider that
// failed during checkProviderConflict is not silently dropped from results.
checkResults.forEach((r) => {
if (r.error) {
results.set(r.provider as CloudProvider, {
success: false,
provider: r.provider as CloudProvider,
action: 'none',
error: r.error,
});
this.updateProviderStatus(r.provider as CloudProvider, 'error', r.error);
this.emit({ type: 'SYNC_ERROR', provider: r.provider as CloudProvider, error: r.error });
}
});
// Providers in candidateProviders that didn't trip the shrink check still
// share the same payload — mark them as not-uploaded so the caller doesn't
// think a "successful" no-op happened.
const blockedProviders = new Set(shrinkSuspectByProvider.map((e) => e.provider));
for (const provider of candidateProviders) {
if (!results.has(provider) && !blockedProviders.has(provider)) {
results.set(provider, {
success: false,
provider,
action: 'none',
error: 'Sync blocked: another provider would lose too much data',
});
this.updateProviderStatus(provider, 'error', 'Sync blocked due to peer provider');
}
}
return results;
}
if (shouldForceAll) {
for (const { provider, finding } of shrinkSuspectByProvider) {
this.emit({ type: 'SYNC_FORCED', provider, finding });
}
}
// 3. Encrypt Once
const validUploads = checkResults.filter(
(r) => !r.error && !r.check?.conflict && r.adapter
@@ -1819,7 +2043,9 @@ export class CloudSyncManager {
// 5. Final State Update
const hasSuccess = Array.from(results.values()).some((r) => r.success);
if (hasSuccess) {
this.exitBlockedState();
this.state.syncState = 'IDLE';
this.state.lastShrinkFinding = undefined;
// If a merge happened, attach the merged payload to successful results
// so callers can apply remote additions to local state
@@ -1922,6 +2148,18 @@ export class CloudSyncManager {
return `${SYNC_STORAGE_KEYS.SYNC_BASE_PAYLOAD}${suffix}`;
}
private providerAccountIdKey(provider: CloudProvider): string {
return `netcatty.sync.accountId.${provider}`;
}
private loadProviderAccountId(provider: CloudProvider): string | null {
return this.loadFromStorage<string>(this.providerAccountIdKey(provider)) ?? null;
}
private saveProviderAccountId(provider: CloudProvider, id: string): void {
this.saveToStorage(this.providerAccountIdKey(provider), id);
}
async saveSyncBase(payload: SyncPayload, provider?: CloudProvider): Promise<void> {
const key = this.state.unlockedKey?.derivedKey;
if (!key) return;

107
package-lock.json generated
View File

@@ -81,9 +81,13 @@
"eslint-plugin-unused-imports": "^4.3.0",
"patch-package": "^8.0.0",
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.2.7",
"wait-on": "^9.0.3"
},
"optionalDependencies": {
"@vscode/windows-process-tree": "^0.7.0"
}
},
"node_modules/@agentclientprotocol/sdk": {
@@ -1154,6 +1158,7 @@
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.29.0",
"@babel/generator": "^7.29.0",
@@ -1799,7 +1804,6 @@
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"dependencies": {
"cross-dirname": "^0.1.0",
"debug": "^4.3.4",
@@ -1821,7 +1825,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -1838,7 +1841,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1853,7 +1855,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -3309,6 +3310,7 @@
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.27.1.tgz",
"integrity": "sha512-sr6GbP+4edBwFndLbM60gf07z0FQ79gaExpnsjMGePXqFcSSb7t6iscpjk9DhFhwd+mTEQrzNafGP8/iGGFYaA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@hono/node-server": "^1.19.9",
"ajv": "^8.17.1",
@@ -6297,6 +6299,7 @@
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "*"
}
@@ -6377,6 +6380,7 @@
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/regexpp": "^4.12.2",
"@typescript-eslint/scope-manager": "8.54.0",
@@ -6406,6 +6410,7 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -6640,6 +6645,27 @@
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
}
},
"node_modules/@vscode/windows-process-tree": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/@vscode/windows-process-tree/-/windows-process-tree-0.7.0.tgz",
"integrity": "sha512-uH58Fofu1bgiulsY2svyGOLLKAYNJ0Req4PioPd7BZzHRziuiBRw1SxyT7wvsYHvm7eKWwzwo6mZpExDfwX9iA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"node-addon-api": "7.1.0"
}
},
"node_modules/@vscode/windows-process-tree/node_modules/node-addon-api": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.0.tgz",
"integrity": "sha512-mNcltoe1R8o7STTegSOHdnJNN7s5EUvhoS7ShnTHDyOSd+8H+UdWODq6qSv67PjC8Zc5JRT8+oLAMCr0SIXw7g==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^16 || ^18 || >= 20"
}
},
"node_modules/@withfig/autocomplete": {
"version": "2.692.3",
"resolved": "https://registry.npmjs.org/@withfig/autocomplete/-/autocomplete-2.692.3.tgz",
@@ -6935,6 +6961,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -6985,6 +7012,7 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0",
@@ -7545,6 +7573,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -8287,8 +8316,7 @@
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"optional": true
},
"node_modules/cross-env": {
"version": "10.1.0",
@@ -8572,6 +8600,7 @@
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"app-builder-lib": "26.7.0",
"builder-util": "26.4.1",
@@ -8953,7 +8982,6 @@
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@electron/asar": "^3.2.1",
"debug": "^4.1.1",
@@ -8974,7 +9002,6 @@
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"graceful-fs": "^4.1.2",
"jsonfile": "^4.0.0",
@@ -9204,6 +9231,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -10102,6 +10130,19 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/get-tsconfig": {
"version": "4.14.0",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
"integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
"dependencies": {
"resolve-pkg-maps": "^1.0.0"
},
"funding": {
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
@@ -10552,6 +10593,7 @@
"resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz",
"integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=16.9.0"
}
@@ -12041,6 +12083,7 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"@types/debug": "^4.0.0",
"debug": "^4.0.0",
@@ -12658,7 +12701,8 @@
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
"license": "MIT",
"peer": true
},
"node_modules/micromatch": {
"version": "4.0.8",
@@ -12913,7 +12957,6 @@
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"minimist": "^1.2.6"
},
@@ -12926,6 +12969,7 @@
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"peer": true,
"dependencies": {
"dompurify": "3.2.7",
"marked": "14.0.0"
@@ -13685,7 +13729,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"commander": "^9.4.0"
},
@@ -13703,7 +13746,6 @@
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": "^12.20.0 || >=14"
}
@@ -13894,6 +13936,7 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -13903,6 +13946,7 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -14252,6 +14296,16 @@
"node": ">=4"
}
},
"node_modules/resolve-pkg-maps": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/responselike": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/responselike/-/responselike-2.0.1.tgz",
@@ -15223,7 +15277,6 @@
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"mkdirp": "^0.5.1",
"rimraf": "~2.6.2"
@@ -15288,7 +15341,6 @@
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"dev": true,
"license": "ISC",
"peer": true,
"dependencies": {
"glob": "^7.1.3"
},
@@ -15363,6 +15415,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -15471,6 +15524,27 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "~0.27.0",
"get-tsconfig": "^4.7.5"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/tweetnacl": {
"version": "0.14.5",
"resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz",
@@ -15555,6 +15629,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -15575,6 +15650,7 @@
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
"integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@types/unist": "^3.0.0",
"bail": "^2.0.0",
@@ -15913,6 +15989,7 @@
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"esbuild": "^0.27.0",
"fdir": "^6.5.0",
@@ -16006,6 +16083,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -16284,6 +16362,7 @@
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}

View File

@@ -29,7 +29,8 @@
"rebuild": "electron-builder install-app-deps",
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
"lint": "eslint .",
"lint:fix": "eslint . --fix"
"lint:fix": "eslint . --fix",
"test": "node --test --import tsx electron/bridges/*.test.cjs application/state/*.test.ts domain/*.test.ts"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.58",
@@ -100,10 +101,14 @@
"eslint-plugin-unused-imports": "^4.3.0",
"patch-package": "^8.0.0",
"tailwindcss": "^4.1.17",
"tsx": "^4.21.0",
"typescript": "~5.9.3",
"vite": "^7.2.7",
"wait-on": "^9.0.3"
},
"optionalDependencies": {
"@vscode/windows-process-tree": "^0.7.0"
},
"overrides": {
"cpu-features": "npm:empty-npm-package@1.0.0",
"axios": "1.13.5"

View File

@@ -33,7 +33,7 @@ index 7291c2c..8943c9a 100644
}
diff --git a/node_modules/ssh2/lib/protocol/Protocol.js b/node_modules/ssh2/lib/protocol/Protocol.js
index 7302488..95584c5 100644
index 7302488..634acdd 100644
--- a/node_modules/ssh2/lib/protocol/Protocol.js
+++ b/node_modules/ssh2/lib/protocol/Protocol.js
@@ -701,11 +701,19 @@ class Protocol {
@@ -107,6 +107,18 @@ index 7302488..95584c5 100644
packet.set(signature, p += 4);
this._authsQueue.push('hostbased');
@@ -1916,7 +1932,10 @@ class Protocol {
}
// SSH-protoversion-softwareversion (SP comments) CR LF
-const RE_IDENT = /^SSH-(2\.0|1\.99)-([^ ]+)(?: (.*))?$/;
+// RFC 4253 requires a non-empty softwareversion, but some embedded SSH
+// daemons send "SSH-2.0-" with an empty token. Accept that specific
+// compatibility case while still rejecting whitespace in the token itself.
+const RE_IDENT = /^SSH-(2\.0|1\.99)-([^ ]*)(?: (.*))?$/;
// TODO: optimize this by starting n bytes from the end of this._buffer instead
// of the beginning
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
index 9f33c02..9751164 100644
--- a/node_modules/ssh2/lib/protocol/SFTP.js

View File

@@ -25,12 +25,19 @@ For routine tasks, the host prompt is usually enough. Read only the reference th
## Core Rules
- Treat the host-provided CLI prefix as the only supported entrypoint for this session.
- If a command launcher is needed, prefer the operating system's built-in launcher for the current environment; do not require optional shells that may not be installed.
- Run Netcatty CLI commands strictly serially.
- Treat Netcatty CLI errors as authoritative.
- Never ask the user for SSH credentials, key paths, proxy settings, or jump-host details when Netcatty session access already exists.
- Do not pause to explain the plan, re-read this skill, or design scripts before trying that shortest path.
- When presenting structured results, prefer a concise table if it fits clearly.
Examples:
- On Windows, if a literal shell command line is required, use the host-provided prefix with the system launcher available in the environment, such as `cmd.exe` or Windows PowerShell; do not assume PowerShell 7 `pwsh.exe` exists.
- On macOS or Linux, use the host-provided prefix directly, or the system shell already available in that environment when a shell command line is unavoidable.
- When the execution surface accepts argv-style calls, use the Netcatty launcher path as the executable and pass subcommands and flags as separate arguments instead of wrapping it in another shell.
## References
- Exec and session workflow: `references/exec.md`