[Init] Initial commit - NetMesh terminal manager
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
This commit is contained in:
427
application/state/useManagedSourceSync.ts
Normal file
427
application/state/useManagedSourceSync.ts
Normal file
@@ -0,0 +1,427 @@
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Host, ManagedSource } from "../../domain/models";
|
||||
import {
|
||||
serializeHostsToSshConfig,
|
||||
mergeWithExistingSshConfig,
|
||||
toSafeSshHostAlias,
|
||||
isSafeSshHostMatchLiteral,
|
||||
} from "../../domain/sshConfigSerializer";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
import { STORAGE_KEY_MANAGED_SOURCES } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import { withVaultImportLock } from "./vaultManagedImportLock";
|
||||
|
||||
const MANAGED_BLOCK_BEGIN = "# BEGIN NETCATTY MANAGED - DO NOT EDIT THIS BLOCK";
|
||||
const MANAGED_BLOCK_END = "# END NETCATTY MANAGED";
|
||||
|
||||
export interface UseManagedSourceSyncOptions {
|
||||
hosts: Host[];
|
||||
managedSources: ManagedSource[];
|
||||
onUpdateManagedSources: (sources: ManagedSource[]) => void;
|
||||
onReadPersistedHosts: () => Promise<Host[]>;
|
||||
}
|
||||
|
||||
export const haveSameManagedSshAgentFields = (previous: Host, current: Host): boolean => (
|
||||
previous.useSshAgent === current.useSshAgent
|
||||
&& previous.identityAgent === current.identityAgent
|
||||
&& previous.identitiesOnly === current.identitiesOnly
|
||||
&& previous.addKeysToAgent === current.addKeysToAgent
|
||||
&& previous.useKeychain === current.useKeychain
|
||||
&& (previous.identityFilePaths?.length ?? 0) === (current.identityFilePaths?.length ?? 0)
|
||||
&& (previous.identityFilePaths ?? []).every(
|
||||
(path, index) => path === current.identityFilePaths?.[index],
|
||||
)
|
||||
);
|
||||
|
||||
export const useManagedSourceSync = ({
|
||||
hosts,
|
||||
managedSources,
|
||||
onUpdateManagedSources,
|
||||
onReadPersistedHosts,
|
||||
}: UseManagedSourceSyncOptions) => {
|
||||
const previousHostsRef = useRef<Host[]>([]);
|
||||
const hostsRef = useRef(hosts);
|
||||
hostsRef.current = hosts;
|
||||
const syncInProgressRef = useRef(false);
|
||||
// Keep a ref to the latest managedSources to avoid stale closure issues
|
||||
const managedSourcesRef = useRef(managedSources);
|
||||
managedSourcesRef.current = managedSources;
|
||||
|
||||
const getManagedHostsForSource = useCallback(
|
||||
(sourceId: string) => {
|
||||
return hostsRef.current.filter((h) => h.managedSourceId === sourceId);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const readExistingFileContent = useCallback(
|
||||
async (filePath: string): Promise<string | null> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readLocalFile) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const buffer = await bridge.readLocalFile(filePath);
|
||||
const decoder = new TextDecoder();
|
||||
return decoder.decode(buffer);
|
||||
} catch {
|
||||
// File might not exist yet
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const mergeWithExistingContent = useCallback(
|
||||
(
|
||||
existingContent: string | null,
|
||||
managedHosts: Host[],
|
||||
allHosts: Host[],
|
||||
): string => {
|
||||
// Serialize the managed hosts
|
||||
const managedContent = serializeHostsToSshConfig(managedHosts, allHosts);
|
||||
|
||||
if (!existingContent) {
|
||||
// No existing file, just wrap the managed content
|
||||
return `${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}\n`;
|
||||
}
|
||||
|
||||
const beginIndex = existingContent.indexOf(MANAGED_BLOCK_BEGIN);
|
||||
const endIndex = existingContent.indexOf(MANAGED_BLOCK_END);
|
||||
|
||||
if (beginIndex === -1 || endIndex === -1 || endIndex < beginIndex) {
|
||||
// No existing managed block - need to remove duplicate Host entries
|
||||
// Build a set of hostnames/aliases that will be managed
|
||||
const managedHostnameSet = new Set<string>();
|
||||
for (const host of managedHosts) {
|
||||
if (!host.protocol || host.protocol === "ssh") {
|
||||
// Add both hostname and sanitized label (alias) for matching
|
||||
if (isSafeSshHostMatchLiteral(host.hostname)) {
|
||||
managedHostnameSet.add(host.hostname.toLowerCase());
|
||||
}
|
||||
if (host.label) {
|
||||
managedHostnameSet.add(toSafeSshHostAlias(host.label, host.hostname).toLowerCase());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use mergeWithExistingSshConfig to filter out existing Host blocks
|
||||
// that match our managed hosts, keeping preserved content outside markers
|
||||
const mergedContent = mergeWithExistingSshConfig(
|
||||
existingContent,
|
||||
managedHosts,
|
||||
managedHostnameSet,
|
||||
allHosts,
|
||||
);
|
||||
return mergedContent;
|
||||
}
|
||||
|
||||
// Replace the existing managed block
|
||||
const before = existingContent.substring(0, beginIndex);
|
||||
const after = existingContent.substring(endIndex + MANAGED_BLOCK_END.length);
|
||||
return `${before}${MANAGED_BLOCK_BEGIN}\n${managedContent}${MANAGED_BLOCK_END}${after}`;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const writeSshConfigToFile = useCallback(
|
||||
async (source: ManagedSource, managedHosts: Host[], allHosts = hostsRef.current) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.writeLocalFile) {
|
||||
console.warn("[ManagedSourceSync] writeLocalFile not available");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
// Read existing file content to preserve non-managed parts
|
||||
const existingContent = await readExistingFileContent(source.filePath);
|
||||
|
||||
// Merge with existing content, preserving non-managed parts and removing duplicates
|
||||
const finalContent = mergeWithExistingContent(
|
||||
existingContent,
|
||||
managedHosts,
|
||||
allHosts,
|
||||
);
|
||||
const encoder = new TextEncoder();
|
||||
const buffer = encoder.encode(finalContent);
|
||||
await bridge.writeLocalFile(source.filePath, buffer.buffer as ArrayBuffer);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[ManagedSourceSync] Failed to write SSH config:", err);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
[readExistingFileContent, mergeWithExistingContent],
|
||||
);
|
||||
|
||||
const syncManagedSource = useCallback(
|
||||
async (source: ManagedSource): Promise<{ sourceId: string; success: boolean }> => {
|
||||
// Drain any in-flight Vault host write BEFORE taking the vault lock.
|
||||
// updateHosts commits its encrypted write under the vault lock, so if this
|
||||
// sync acquires the lock first, readPersistedHosts runs while the lock is
|
||||
// held and only waits for the encrypt phase — returning the previous host
|
||||
// snapshot and writing the stale alias back to the file (lagging one edit
|
||||
// behind, see issue #3259). Waiting outside the lock also waits for the
|
||||
// queued disk write, so the read below sees the just-edited hosts.
|
||||
await onReadPersistedHosts();
|
||||
return withVaultImportLock("vault", async () => {
|
||||
const persistedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
) ?? managedSourcesRef.current;
|
||||
if (!persistedSources.some((candidate) => candidate.id === source.id)) {
|
||||
return { sourceId: source.id, success: false };
|
||||
}
|
||||
const latestHosts = await onReadPersistedHosts();
|
||||
const managedHosts = latestHosts.filter((host) => host.managedSourceId === source.id);
|
||||
const success = await writeSshConfigToFile(source, managedHosts, latestHosts);
|
||||
return { sourceId: source.id, success };
|
||||
});
|
||||
},
|
||||
[onReadPersistedHosts, writeSshConfigToFile],
|
||||
);
|
||||
|
||||
const unmanageSource = useCallback(
|
||||
(sourceId: string) => {
|
||||
const updatedSources = managedSourcesRef.current.filter((s) => s.id !== sourceId);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
},
|
||||
[onUpdateManagedSources],
|
||||
);
|
||||
|
||||
// Clear the managed block before the caller atomically removes the Vault
|
||||
// source and hosts. Keeping state unchanged here lets a failed clear abort
|
||||
// the deletion without leaving a half-removed source record.
|
||||
const clearAndRemoveSource = useCallback(
|
||||
async (source: ManagedSource) => {
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
if (!success) throw new Error("Could not clear managed SSH config source");
|
||||
return async () => {
|
||||
const latestHosts = await onReadPersistedHosts();
|
||||
const restored = await writeSshConfigToFile(
|
||||
source,
|
||||
latestHosts.filter((host) => host.managedSourceId === source.id),
|
||||
latestHosts,
|
||||
);
|
||||
if (!restored) throw new Error("Could not restore managed SSH config source");
|
||||
};
|
||||
},
|
||||
[onReadPersistedHosts, writeSshConfigToFile],
|
||||
);
|
||||
|
||||
// Clear and remove multiple sources atomically to avoid race conditions
|
||||
// when multiple sources are removed concurrently
|
||||
const clearAndRemoveSources = useCallback(
|
||||
async (sources: ManagedSource[]) => {
|
||||
const clearedSources: ManagedSource[] = [];
|
||||
for (const source of sources) {
|
||||
const success = await writeSshConfigToFile(source, []);
|
||||
if (!success) {
|
||||
const latestHosts = await onReadPersistedHosts();
|
||||
const restored = await Promise.all(clearedSources.map((clearedSource) => writeSshConfigToFile(
|
||||
clearedSource,
|
||||
latestHosts.filter((host) => host.managedSourceId === clearedSource.id),
|
||||
latestHosts,
|
||||
)));
|
||||
if (restored.some((result) => !result)) {
|
||||
throw new Error("Could not clear or restore every managed SSH config source");
|
||||
}
|
||||
throw new Error("Could not clear every managed SSH config source");
|
||||
}
|
||||
clearedSources.push(source);
|
||||
}
|
||||
return async () => {
|
||||
const latestHosts = await onReadPersistedHosts();
|
||||
const results = await Promise.all(clearedSources.map((source) => writeSshConfigToFile(
|
||||
source,
|
||||
latestHosts.filter((host) => host.managedSourceId === source.id),
|
||||
latestHosts,
|
||||
)));
|
||||
if (results.some((success) => !success)) {
|
||||
throw new Error("Could not restore every managed SSH config source");
|
||||
}
|
||||
};
|
||||
},
|
||||
[onReadPersistedHosts, writeSshConfigToFile],
|
||||
);
|
||||
|
||||
const pendingSyncRef = useRef(false);
|
||||
const checkAndSyncRef = useRef<() => void>(() => {});
|
||||
|
||||
const checkAndSync = useCallback(() => {
|
||||
if (managedSources.length === 0) {
|
||||
// Still update previousHostsRef so we have a baseline when sources are added
|
||||
previousHostsRef.current = hosts;
|
||||
return;
|
||||
}
|
||||
|
||||
const prevHosts = previousHostsRef.current;
|
||||
previousHostsRef.current = hosts;
|
||||
|
||||
// On initial sync (prevHosts empty), sync all sources that have managed hosts
|
||||
const isInitialSync = prevHosts.length === 0;
|
||||
|
||||
const changedSourceIds = new Set<string>();
|
||||
|
||||
if (isInitialSync) {
|
||||
// Initial sync: sync all sources that have hosts
|
||||
for (const source of managedSources) {
|
||||
const currManaged = hosts.filter((h) => h.managedSourceId === source.id);
|
||||
if (currManaged.length > 0) {
|
||||
changedSourceIds.add(source.id);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Build maps for all hosts (for jump host lookup)
|
||||
const prevHostMap = new Map<string, Host>(prevHosts.map((h) => [h.id, h]));
|
||||
const currHostMap = new Map<string, Host>(hosts.map((h) => [h.id, h]));
|
||||
|
||||
// Index hosts by managedSourceId to avoid O(N*M) lookups
|
||||
const prevHostsBySource = new Map<string, Host[]>();
|
||||
for (const h of prevHosts) {
|
||||
if (h.managedSourceId) {
|
||||
let list = prevHostsBySource.get(h.managedSourceId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
prevHostsBySource.set(h.managedSourceId, list);
|
||||
}
|
||||
list.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
const currHostsBySource = new Map<string, Host[]>();
|
||||
for (const h of hosts) {
|
||||
if (h.managedSourceId) {
|
||||
let list = currHostsBySource.get(h.managedSourceId);
|
||||
if (!list) {
|
||||
list = [];
|
||||
currHostsBySource.set(h.managedSourceId, list);
|
||||
}
|
||||
list.push(h);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper to check if a host's SSH-relevant fields changed
|
||||
const hostChanged = (prevHost: Host | undefined, currHost: Host | undefined): boolean => {
|
||||
if (!prevHost || !currHost) return prevHost !== currHost;
|
||||
return (
|
||||
prevHost.hostname !== currHost.hostname ||
|
||||
prevHost.port !== currHost.port ||
|
||||
prevHost.username !== currHost.username ||
|
||||
prevHost.label !== currHost.label
|
||||
);
|
||||
};
|
||||
|
||||
for (const source of managedSources) {
|
||||
const prevManaged = prevHostsBySource.get(source.id) || [];
|
||||
const currManaged = currHostsBySource.get(source.id) || [];
|
||||
|
||||
if (prevManaged.length !== currManaged.length) {
|
||||
changedSourceIds.add(source.id);
|
||||
continue;
|
||||
}
|
||||
|
||||
const prevManagedMap = new Map<string, Host>(prevManaged.map((h) => [h.id, h]));
|
||||
let sourceChanged = false;
|
||||
|
||||
for (const curr of currManaged) {
|
||||
const prev = prevManagedMap.get(curr.id);
|
||||
if (!prev) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
// Compare hostChain arrays for ProxyJump changes
|
||||
const prevChain = prev.hostChain?.hostIds || [];
|
||||
const currChain = curr.hostChain?.hostIds || [];
|
||||
const chainChanged =
|
||||
prevChain.length !== currChain.length ||
|
||||
prevChain.some((id, i) => id !== currChain[i]);
|
||||
|
||||
const hasChanged =
|
||||
prev.hostname !== curr.hostname ||
|
||||
prev.port !== curr.port ||
|
||||
prev.username !== curr.username ||
|
||||
prev.label !== curr.label ||
|
||||
prev.group !== curr.group ||
|
||||
prev.protocol !== curr.protocol ||
|
||||
!haveSameManagedSshAgentFields(prev, curr) ||
|
||||
chainChanged;
|
||||
if (hasChanged) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
|
||||
// Check if any referenced jump hosts changed (even if outside this managed source)
|
||||
for (const jumpHostId of currChain) {
|
||||
const prevJumpHost = prevHostMap.get(jumpHostId);
|
||||
const currJumpHost = currHostMap.get(jumpHostId);
|
||||
if (hostChanged(prevJumpHost, currJumpHost)) {
|
||||
sourceChanged = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (sourceChanged) break;
|
||||
}
|
||||
|
||||
if (sourceChanged) {
|
||||
changedSourceIds.add(source.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (changedSourceIds.size > 0) {
|
||||
syncInProgressRef.current = true;
|
||||
|
||||
Promise.all(
|
||||
managedSources
|
||||
.filter((s) => changedSourceIds.has(s.id))
|
||||
.map((source) => syncManagedSource(source)),
|
||||
).then(async (results) => {
|
||||
// Batch update lastSyncedAt for all successful syncs to avoid race conditions
|
||||
const successfulSourceIds = new Set(
|
||||
results.filter(r => r.success).map(r => r.sourceId)
|
||||
);
|
||||
|
||||
if (successfulSourceIds.size > 0) {
|
||||
await withVaultImportLock("vault", async () => {
|
||||
const currentSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
) ?? managedSourcesRef.current;
|
||||
const now = Date.now();
|
||||
const updatedSources = currentSources.map((s) =>
|
||||
successfulSourceIds.has(s.id) ? { ...s, lastSyncedAt: now } : s,
|
||||
);
|
||||
onUpdateManagedSources(updatedSources);
|
||||
});
|
||||
}
|
||||
}).finally(() => {
|
||||
syncInProgressRef.current = false;
|
||||
// Check if there were changes during sync that need to be processed
|
||||
// Use ref to get the latest checkAndSync to avoid stale closure
|
||||
if (pendingSyncRef.current) {
|
||||
pendingSyncRef.current = false;
|
||||
checkAndSyncRef.current();
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [hosts, managedSources, syncManagedSource, onUpdateManagedSources]);
|
||||
|
||||
// Keep ref updated with the latest checkAndSync
|
||||
checkAndSyncRef.current = checkAndSync;
|
||||
|
||||
useEffect(() => {
|
||||
if (syncInProgressRef.current) {
|
||||
// Mark that we need to re-sync after current sync completes
|
||||
pendingSyncRef.current = true;
|
||||
return;
|
||||
}
|
||||
checkAndSync();
|
||||
}, [hosts, managedSources, checkAndSync]);
|
||||
|
||||
return {
|
||||
syncManagedSource,
|
||||
unmanageSource,
|
||||
clearAndRemoveSource,
|
||||
clearAndRemoveSources,
|
||||
getManagedHostsForSource,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user