Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35194036cb | ||
|
|
6a077a3855 | ||
|
|
43f4687bb9 | ||
|
|
bbb888ae1e | ||
|
|
c74b78a49d | ||
|
|
a7f42ec93e | ||
|
|
a886d509f8 | ||
|
|
d6fea6c328 | ||
|
|
b6169f1735 | ||
|
|
c97470a085 | ||
|
|
98cb9d09df | ||
|
|
9deb39dec2 | ||
|
|
bb45279d4e | ||
|
|
6b1d9ee409 | ||
|
|
c0c0378df0 | ||
|
|
093951150c | ||
|
|
a0418039c4 | ||
|
|
559e71cfcc | ||
|
|
a0a2567fa5 | ||
|
|
d080a43ae6 | ||
|
|
2c551cf5e8 | ||
|
|
c54aa52191 | ||
|
|
b8c838059a | ||
|
|
007b4bd389 | ||
|
|
13fd198243 | ||
|
|
2c562463c4 | ||
|
|
859d4b8156 | ||
|
|
c6e07cf149 | ||
|
|
0ab18ce186 | ||
|
|
f814719b32 | ||
|
|
ee6b05892d | ||
|
|
0f98ffd4f7 | ||
|
|
7ca5d0c832 | ||
|
|
1a76d34696 | ||
|
|
0b2d1b613b | ||
|
|
ded989b374 | ||
|
|
04c6348bc0 | ||
|
|
54297859e3 | ||
|
|
d236adcd48 | ||
|
|
4971f18bbe | ||
|
|
15687bd56e | ||
|
|
76675ec515 | ||
|
|
7c6304c355 | ||
|
|
8fdcbf87c2 | ||
|
|
0326ba7556 | ||
|
|
964230a737 | ||
|
|
5d551ee8e9 | ||
|
|
ec4e209972 | ||
|
|
c141fbc11e | ||
|
|
8e61ccac91 | ||
|
|
7c5047f22e | ||
|
|
c10100a314 | ||
|
|
5a294aa306 | ||
|
|
54b3ba2c01 | ||
|
|
f25822fdae | ||
|
|
69f433c161 | ||
|
|
6087343203 | ||
|
|
bb63de2658 | ||
|
|
fd938a84e4 | ||
|
|
c2e629ad61 | ||
|
|
4bf61c02a0 | ||
|
|
4747217929 | ||
|
|
fb3cdd0661 | ||
|
|
11ca8fba87 | ||
|
|
7ffc4b4c7f | ||
|
|
fe27dd8a9d | ||
|
|
eca11e9d2a | ||
|
|
779aa31ef8 | ||
|
|
2c8670a6c6 | ||
|
|
a94293d31e | ||
|
|
04b62f7ba3 | ||
|
|
45794b7f6f | ||
|
|
314072a631 | ||
|
|
c9f1951e28 | ||
|
|
7f83b22c95 | ||
|
|
b7082ab198 | ||
|
|
9369495e22 | ||
|
|
e3fdb1f7ff | ||
|
|
b9bc6b95e5 | ||
|
|
5cbaae8d2f | ||
|
|
915e571c63 | ||
|
|
86a43655e1 | ||
|
|
e47d86874f | ||
|
|
369de6fff2 | ||
|
|
3aa414ad05 | ||
|
|
356c27d0fb | ||
|
|
ae94e7e529 | ||
|
|
5828503ffc | ||
|
|
1c0f45e410 | ||
|
|
5c791cebe5 | ||
|
|
0ce6b0f777 | ||
|
|
6fca38a209 | ||
|
|
52541a6066 | ||
|
|
6d35301436 | ||
|
|
5d29c8d91a | ||
|
|
196b1f8dbb | ||
|
|
f1065745bc | ||
|
|
c67befa0e9 | ||
|
|
cea83d6cb1 | ||
|
|
293ee46b26 | ||
|
|
a6af1dffed | ||
|
|
0a3e61af4b | ||
|
|
9e4a79acd7 | ||
|
|
a62353bb41 | ||
|
|
d2ab27ab92 | ||
|
|
65f62983b6 | ||
|
|
56d3109d23 | ||
|
|
34ab6c0e98 | ||
|
|
3db9b0aa26 | ||
|
|
fe49ea74e2 | ||
|
|
be91740582 | ||
|
|
ad15d8ceb5 | ||
|
|
c37fe8f9e0 | ||
|
|
b0924c14b1 | ||
|
|
774c25086e | ||
|
|
05c0d43bc4 | ||
|
|
baac8670d3 | ||
|
|
c84bf497f2 | ||
|
|
ac5f708eba | ||
|
|
ecba2560c9 | ||
|
|
ff638c64cd | ||
|
|
3db6465340 | ||
|
|
2b4f8d33c9 | ||
|
|
bc6c0a2ef6 | ||
|
|
9cccc943ff | ||
|
|
cecda50ce2 |
35
.github/workflows/build.yml
vendored
35
.github/workflows/build.yml
vendored
@@ -230,6 +230,7 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
permissions:
|
||||
contents: write
|
||||
actions: read
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -243,6 +244,40 @@ jobs:
|
||||
- name: List artifacts
|
||||
run: ls -la artifacts/
|
||||
|
||||
- name: Verify update metadata files
|
||||
run: |
|
||||
missing=0
|
||||
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
|
||||
if [ ! -f "artifacts/$f" ]; then
|
||||
echo "::warning::Missing $f in merged artifacts, attempting recovery..."
|
||||
missing=1
|
||||
fi
|
||||
done
|
||||
if [ "$missing" = "1" ]; then
|
||||
echo "Re-downloading individual artifacts to recover missing files..."
|
||||
for name in netcatty-macos netcatty-windows netcatty-linux-x64 netcatty-linux-arm64; do
|
||||
tmpdir="/tmp/artifact-${name}"
|
||||
gh run download ${{ github.run_id }} --name "${name}" --dir "${tmpdir}" 2>/dev/null || true
|
||||
if [ -d "${tmpdir}" ]; then
|
||||
for yml in "${tmpdir}"/latest*.yml; do
|
||||
[ -f "$yml" ] && cp -v "$yml" artifacts/
|
||||
done
|
||||
fi
|
||||
done
|
||||
echo "After recovery:"
|
||||
ls -la artifacts/*.yml
|
||||
fi
|
||||
# Final check — fail if any update yml is still missing
|
||||
for f in latest-mac.yml latest.yml latest-linux.yml latest-linux-arm64.yml; do
|
||||
if [ ! -f "artifacts/$f" ]; then
|
||||
echo "::error::$f is still missing after recovery attempt"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "All update metadata files present."
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Verify downloaded Linux amd64 deb artifact
|
||||
run: |
|
||||
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-amd64.deb' -print | sort | head -n 1)"
|
||||
|
||||
14
App.tsx
14
App.tsx
@@ -381,16 +381,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}
|
||||
}, [updateState.autoDownloadStatus, updateState.latestRelease?.version, t, installUpdate, openReleasePage]);
|
||||
|
||||
// Memoize keys for port forwarding to prevent unnecessary re-renders
|
||||
const portForwardingKeys = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase, })),
|
||||
[keys]
|
||||
);
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
hosts,
|
||||
keys: portForwardingKeys,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
|
||||
// Sync tray menu data + handle tray actions
|
||||
@@ -452,9 +447,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const keysForPf = keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase }));
|
||||
if (start) {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
} else {
|
||||
@@ -466,7 +460,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
unsubscribeFocus?.();
|
||||
unsubscribeToggle?.();
|
||||
};
|
||||
}, [hosts, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
}, [hosts, identities, keys, portForwardingRules, sessions, setActiveTabId, setWorkspaceFocusedSession, startTunnel, stopTunnel, t]);
|
||||
|
||||
// Tray panel actions (from main process)
|
||||
useEffect(() => {
|
||||
|
||||
@@ -99,6 +99,21 @@ const en: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': 'Crash Logs',
|
||||
'settings.system.crashLogs.description': 'View error logs from the main process to help diagnose unexpected behavior.',
|
||||
'settings.system.crashLogs.noLogs': 'No crash logs found.',
|
||||
'settings.system.crashLogs.entries': '{count} entries',
|
||||
'settings.system.crashLogs.clear': 'Clear all logs',
|
||||
'settings.system.crashLogs.cleared': 'Cleared {count} log file(s).',
|
||||
'settings.system.crashLogs.source': 'Source',
|
||||
'settings.system.crashLogs.time': 'Time',
|
||||
'settings.system.crashLogs.message': 'Message',
|
||||
'settings.system.crashLogs.stack': 'Stack Trace',
|
||||
'settings.system.crashLogs.hint': 'Crash logs are retained for 30 days and automatically rotated.',
|
||||
'settings.system.crashLogs.collapse': 'Collapse',
|
||||
'settings.system.crashLogs.expand': 'Show details',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': 'Software Update',
|
||||
'settings.update.currentVersion': 'Current version',
|
||||
@@ -296,6 +311,9 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.scrollOnPaste': 'Scroll on paste',
|
||||
'settings.terminal.behavior.scrollOnPaste.desc':
|
||||
'Scroll terminal to bottom when pasting text',
|
||||
'settings.terminal.behavior.smoothScrolling': 'Smooth scrolling',
|
||||
'settings.terminal.behavior.smoothScrolling.desc':
|
||||
'Animate terminal viewport scrolling instead of jumping instantly',
|
||||
'settings.terminal.behavior.linkModifier': 'Link modifier key',
|
||||
'settings.terminal.behavior.linkModifier.desc': 'Hold this key to click on links in terminal',
|
||||
'settings.terminal.behavior.linkModifier.none': 'None (click directly)',
|
||||
@@ -876,9 +894,12 @@ const en: Messages = {
|
||||
'hostDetails.password.save': 'Save password',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate',
|
||||
'hostDetails.credential.keyCertificate': 'Key, Certificate, Local Key File',
|
||||
'hostDetails.credential.key': 'Key',
|
||||
'hostDetails.credential.certificate': 'Certificate',
|
||||
'hostDetails.credential.localKeyFile': 'Local Key File',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': 'Browse...',
|
||||
'hostDetails.credential.missing': 'Credential not found',
|
||||
'hostDetails.keys.search': 'Search keys...',
|
||||
'hostDetails.keys.empty': 'No keys available',
|
||||
|
||||
@@ -83,6 +83,21 @@ const zhCN: Messages = {
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > System > Crash Logs
|
||||
'settings.system.crashLogs.title': '崩溃日志',
|
||||
'settings.system.crashLogs.description': '查看主进程错误日志,帮助诊断异常行为。',
|
||||
'settings.system.crashLogs.noLogs': '未找到崩溃日志。',
|
||||
'settings.system.crashLogs.entries': '{count} 条记录',
|
||||
'settings.system.crashLogs.clear': '清除所有日志',
|
||||
'settings.system.crashLogs.cleared': '已清除 {count} 个日志文件。',
|
||||
'settings.system.crashLogs.source': '来源',
|
||||
'settings.system.crashLogs.time': '时间',
|
||||
'settings.system.crashLogs.message': '消息',
|
||||
'settings.system.crashLogs.stack': '堆栈跟踪',
|
||||
'settings.system.crashLogs.hint': '崩溃日志保留 30 天,超期自动清理。',
|
||||
'settings.system.crashLogs.collapse': '收起',
|
||||
'settings.system.crashLogs.expand': '查看详情',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': '软件更新',
|
||||
'settings.update.currentVersion': '当前版本',
|
||||
@@ -568,9 +583,12 @@ const zhCN: Messages = {
|
||||
'hostDetails.password.save': '保存密码',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书',
|
||||
'hostDetails.credential.keyCertificate': '密钥 / 证书 / 本地密钥',
|
||||
'hostDetails.credential.key': '密钥',
|
||||
'hostDetails.credential.certificate': '证书',
|
||||
'hostDetails.credential.localKeyFile': '本地密钥文件',
|
||||
'hostDetails.credential.localKeyFilePlaceholder': '~/.ssh/id_ed25519',
|
||||
'hostDetails.credential.browseKeyFile': '浏览…',
|
||||
'hostDetails.credential.missing': '凭据不存在',
|
||||
'hostDetails.keys.search': '搜索密钥…',
|
||||
'hostDetails.keys.empty': '暂无密钥',
|
||||
@@ -1204,6 +1222,8 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.scrollOnKeyPress.desc': '按键(例如 Enter)时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnPaste': '粘贴时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnPaste.desc': '粘贴文本时将终端滚动到底部',
|
||||
'settings.terminal.behavior.smoothScrolling': '平滑滚动',
|
||||
'settings.terminal.behavior.smoothScrolling.desc': '滚动终端视口时使用平滑动画',
|
||||
'settings.terminal.behavior.linkModifier': '链接修饰键',
|
||||
'settings.terminal.behavior.linkModifier.desc': '按住此键再点击终端中的链接',
|
||||
'settings.terminal.behavior.linkModifier.none': '无(直接点击)',
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface SftpPane {
|
||||
loading: boolean;
|
||||
reconnecting: boolean;
|
||||
error: string | null;
|
||||
connectionLogs: string[];
|
||||
selectedFiles: Set<string>;
|
||||
filter: string;
|
||||
filenameEncoding: SftpFilenameEncoding;
|
||||
@@ -33,6 +34,7 @@ export const createEmptyPane = (
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
selectedFiles: new Set(),
|
||||
filter: "",
|
||||
filenameEncoding: "auto",
|
||||
|
||||
@@ -159,6 +159,7 @@ export const useSftpConnections = ({
|
||||
loading: true,
|
||||
reconnecting: false,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
@@ -213,13 +214,57 @@ export const useSftpConnections = ({
|
||||
loading: true,
|
||||
reconnecting: prev.reconnecting,
|
||||
error: null,
|
||||
connectionLogs: [],
|
||||
files: prev.reconnecting ? prev.files : (sharedHostCache?.files ?? []),
|
||||
filenameEncoding, // Reset encoding for new connection
|
||||
}));
|
||||
|
||||
// Subscribe to SFTP connection progress events for auth logging
|
||||
const sftpSessionId = `sftp-${connectionId}`;
|
||||
let unsubSftpProgress: (() => void) | undefined;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.onSftpConnectionProgress) {
|
||||
unsubSftpProgress = bridge.onSftpConnectionProgress((sid, label, status, detail) => {
|
||||
if (sid !== sftpSessionId) return;
|
||||
let logLine: string;
|
||||
switch (status) {
|
||||
case 'connecting':
|
||||
logLine = `Connecting to ${label}...`;
|
||||
break;
|
||||
case 'authenticating':
|
||||
logLine = `${label} - Key exchange complete`;
|
||||
break;
|
||||
case 'auth-attempt':
|
||||
if (detail?.endsWith('rejected')) {
|
||||
logLine = `${label} - ✗ ${detail}`;
|
||||
} else if (detail === 'all methods exhausted') {
|
||||
logLine = `${label} - ✗ All authentication methods exhausted`;
|
||||
} else if (detail === 'waiting for user input...' || detail === 'user responded') {
|
||||
logLine = `${label} - ${detail}`;
|
||||
} else {
|
||||
logLine = `${label} - Trying ${detail}...`;
|
||||
}
|
||||
break;
|
||||
case 'connected':
|
||||
logLine = `${label} - Connected`;
|
||||
break;
|
||||
case 'error':
|
||||
logLine = `${label} - Error${detail ? `: ${detail}` : ''}`;
|
||||
break;
|
||||
default:
|
||||
logLine = `${label} - ${status}${detail ? `: ${detail}` : ''}`;
|
||||
}
|
||||
// Only update if this is still the active request (avoids stale logs leaking)
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
updateTab(side, activeTabId, (prev) => ({
|
||||
...prev,
|
||||
connectionLogs: [...prev.connectionLogs, logLine],
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const credentials = getHostCredentials(host);
|
||||
const bridge = netcattyBridge.get();
|
||||
const openSftp = bridge?.openSftp;
|
||||
if (!openSftp) throw new Error("SFTP bridge unavailable");
|
||||
|
||||
@@ -278,8 +323,24 @@ export const useSftpConnections = ({
|
||||
let homeDir = sharedHostCache?.homeDir ?? startPath;
|
||||
|
||||
if (!sharedHostCache) {
|
||||
const statSftp = netcattyBridge.get()?.statSftp;
|
||||
if (statSftp) {
|
||||
// Detect home directory: SSH exec `echo ~` → SFTP realpath('.') → hardcoded fallback
|
||||
const bridge = netcattyBridge.get();
|
||||
let detected = false;
|
||||
|
||||
if (bridge?.getSftpHomeDir) {
|
||||
try {
|
||||
const result = await bridge.getSftpHomeDir(sftpId);
|
||||
if (result?.success && result.homeDir) {
|
||||
startPath = result.homeDir;
|
||||
homeDir = result.homeDir;
|
||||
detected = true;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to hardcoded candidates
|
||||
}
|
||||
}
|
||||
|
||||
if (!detected) {
|
||||
const candidates: string[] = [];
|
||||
if (credentials.username === "root") {
|
||||
candidates.push("/root");
|
||||
@@ -289,63 +350,33 @@ export const useSftpConnections = ({
|
||||
} else {
|
||||
candidates.push("/root");
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const stat = await statSftp(sftpId, candidate, filenameEncoding);
|
||||
if (stat?.type === "directory") {
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (credentials.username === "root") {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/root";
|
||||
}
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
} else if (credentials.username) {
|
||||
try {
|
||||
const homeFiles = await netcattyBridge.get()?.listSftp(
|
||||
sftpId,
|
||||
`/home/${credentials.username}`,
|
||||
filenameEncoding,
|
||||
);
|
||||
if (homeFiles) {
|
||||
startPath = `/home/${credentials.username}`;
|
||||
homeDir = startPath;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to /root check
|
||||
}
|
||||
if (startPath === "/") {
|
||||
const statSftp = bridge?.statSftp;
|
||||
if (statSftp) {
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/root";
|
||||
const stat = await statSftp(sftpId, candidate, filenameEncoding);
|
||||
if (stat?.type === "directory") {
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const rootFiles = await netcattyBridge.get()?.listSftp(sftpId, "/root", filenameEncoding);
|
||||
if (rootFiles) {
|
||||
startPath = "/root";
|
||||
homeDir = "/root";
|
||||
// Fallback: probe candidates via listSftp when statSftp is unavailable
|
||||
for (const candidate of candidates) {
|
||||
try {
|
||||
const files = await bridge?.listSftp(sftpId, candidate, filenameEncoding);
|
||||
if (files) {
|
||||
startPath = candidate;
|
||||
homeDir = candidate;
|
||||
break;
|
||||
}
|
||||
} catch {
|
||||
// Ignore missing/permission errors
|
||||
}
|
||||
} catch {
|
||||
// Fallback path not available
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,6 +452,7 @@ export const useSftpConnections = ({
|
||||
files,
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
connectionLogs: [], // Clear after successful connect to avoid replay during navigation
|
||||
}));
|
||||
} catch (err) {
|
||||
if (navSeqRef.current[side] !== connectRequestId) return;
|
||||
@@ -438,6 +470,8 @@ export const useSftpConnections = ({
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
} finally {
|
||||
unsubSftpProgress?.();
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@ export const useSftpDirectoryListing = () => {
|
||||
sizeFormatted: formatFileSize(size),
|
||||
lastModified,
|
||||
lastModifiedFormatted: formatDate(lastModified),
|
||||
permissions: f.permissions,
|
||||
linkTarget: f.linkTarget as "file" | "directory" | null | undefined,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback } from "react";
|
||||
import type { Host, Identity, SSHKey } from "../../../domain/models";
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
interface UseSftpHostCredentialsParams {
|
||||
@@ -24,22 +25,32 @@ export const useSftpHostCredentials = ({
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: host.proxyConfig.password,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost) => {
|
||||
.map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
@@ -52,9 +63,23 @@ export const useSftpHostCredentials = ({
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
@@ -70,6 +95,7 @@ export const useSftpHostCredentials = ({
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
};
|
||||
},
|
||||
[hosts, identities, keys],
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
STORAGE_KEY_AI_COMMAND_TIMEOUT,
|
||||
STORAGE_KEY_AI_MAX_ITERATIONS,
|
||||
STORAGE_KEY_AI_SESSIONS,
|
||||
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
|
||||
STORAGE_KEY_AI_AGENT_MODEL_MAP,
|
||||
STORAGE_KEY_AI_WEB_SEARCH,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
@@ -32,6 +33,12 @@ function getAIBridge() {
|
||||
return (window as unknown as { netcatty?: Record<string, (...args: unknown[]) => unknown> }).netcatty;
|
||||
}
|
||||
|
||||
const AI_STATE_CHANGED_EVENT = 'netcatty:ai-state-changed';
|
||||
|
||||
function emitAIStateChanged(key: string) {
|
||||
window.dispatchEvent(new CustomEvent<{ key: string }>(AI_STATE_CHANGED_EVENT, { detail: { key } }));
|
||||
}
|
||||
|
||||
function cleanupAcpSessions(sessionIds: string[]) {
|
||||
const bridge = getAIBridge();
|
||||
if (!bridge?.aiAcpCleanup || sessionIds.length === 0) return;
|
||||
@@ -40,6 +47,48 @@ function cleanupAcpSessions(sessionIds: string[]) {
|
||||
}
|
||||
}
|
||||
|
||||
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
|
||||
const currentSessions = latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [];
|
||||
const removedSessionIds = currentSessions
|
||||
.filter((session) => session.scope.targetId && !activeTargetIds.has(session.scope.targetId))
|
||||
.map((session) => session.id);
|
||||
|
||||
if (removedSessionIds.length === 0) return;
|
||||
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
|
||||
const removedSessionIdSet = new Set(removedSessionIds);
|
||||
|
||||
const nextSessions = currentSessions.filter((session) => {
|
||||
if (!session.scope.targetId) return true;
|
||||
return activeTargetIds.has(session.scope.targetId);
|
||||
});
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, pruneSessionsForStorage(nextSessions));
|
||||
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
|
||||
|
||||
const activeSessionIdMap = latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {};
|
||||
let activeSessionMapChanged = false;
|
||||
const nextActiveSessionIdMap = { ...activeSessionIdMap };
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
if (sessionId && removedSessionIdSet.has(sessionId)) {
|
||||
nextActiveSessionIdMap[scopeKey] = null;
|
||||
activeSessionMapChanged = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (activeSessionMapChanged) {
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/** Maximum number of sessions to keep in localStorage. */
|
||||
const MAX_STORED_SESSIONS = 50;
|
||||
@@ -66,6 +115,17 @@ function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
|
||||
});
|
||||
}
|
||||
|
||||
let latestAISessionsSnapshot: AISession[] | null = null;
|
||||
let latestAIActiveSessionMapSnapshot: Record<string, string | null> | null = null;
|
||||
|
||||
function setLatestAISessionsSnapshot(sessions: AISession[]) {
|
||||
latestAISessionsSnapshot = sessions;
|
||||
}
|
||||
|
||||
function setLatestAIActiveSessionMapSnapshot(activeSessionIdMap: Record<string, string | null>) {
|
||||
latestAIActiveSessionMapSnapshot = activeSessionIdMap;
|
||||
}
|
||||
|
||||
export function useAIState() {
|
||||
// ── Provider Config ──
|
||||
const [providers, setProvidersRaw] = useState<ProviderConfig[]>(() =>
|
||||
@@ -117,7 +177,9 @@ export function useAIState() {
|
||||
sessionsRef.current = sessions;
|
||||
}, [sessions]);
|
||||
// Per-scope active session: keyed by `${scopeType}:${scopeTargetId}`
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>({});
|
||||
const [activeSessionIdMap, setActiveSessionIdMapRaw] = useState<Record<string, string | null>>(() =>
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {}
|
||||
);
|
||||
|
||||
// Per-agent model selection: remembers last selected model per agent
|
||||
const [agentModelMap, setAgentModelMapRaw] = useState<Record<string, string>>(() =>
|
||||
@@ -129,8 +191,43 @@ export function useAIState() {
|
||||
localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
}, [sessions]);
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAIActiveSessionMapSnapshot(activeSessionIdMap);
|
||||
}, [activeSessionIdMap]);
|
||||
|
||||
useEffect(() => {
|
||||
const validSessionIds = new Set(sessions.map((session) => session.id));
|
||||
let changed = false;
|
||||
const nextActiveSessionIdMap: Record<string, string | null> = {};
|
||||
|
||||
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
|
||||
const nextSessionId = sessionId && validSessionIds.has(sessionId) ? sessionId : null;
|
||||
nextActiveSessionIdMap[scopeKey] = nextSessionId;
|
||||
if (nextSessionId !== sessionId) {
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!changed) return;
|
||||
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
}, [sessions, activeSessionIdMap]);
|
||||
|
||||
const setActiveSessionId = useCallback((scopeKey: string, id: string | null) => {
|
||||
setActiveSessionIdMapRaw(prev => ({ ...prev, [scopeKey]: id }));
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
const next = { ...prev, [scopeKey]: id };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const setAgentModel = useCallback((agentId: string, modelId: string) => {
|
||||
@@ -303,9 +400,22 @@ export function useAIState() {
|
||||
setHostPermissionsRaw(perms ?? []);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_SESSIONS: {
|
||||
const nextSessions = localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? [];
|
||||
setLatestAISessionsSnapshot(nextSessions);
|
||||
setSessionsRaw(nextSessions);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_AGENT_MODEL_MAP:
|
||||
setAgentModelMapRaw(localStorageAdapter.read<Record<string, string>>(STORAGE_KEY_AI_AGENT_MODEL_MAP) ?? {});
|
||||
break;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP: {
|
||||
const nextActiveSessionIdMap =
|
||||
localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP) ?? {};
|
||||
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
|
||||
setActiveSessionIdMapRaw(nextActiveSessionIdMap);
|
||||
break;
|
||||
}
|
||||
case STORAGE_KEY_AI_WEB_SEARCH:
|
||||
setWebSearchConfigRaw(localStorageAdapter.read<WebSearchConfig>(STORAGE_KEY_AI_WEB_SEARCH) ?? null);
|
||||
break;
|
||||
@@ -315,7 +425,33 @@ export function useAIState() {
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handleStorage);
|
||||
return () => window.removeEventListener('storage', handleStorage);
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (!key) return;
|
||||
switch (key) {
|
||||
case STORAGE_KEY_AI_SESSIONS:
|
||||
setSessionsRaw(
|
||||
latestAISessionsSnapshot
|
||||
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
|
||||
?? [],
|
||||
);
|
||||
return;
|
||||
case STORAGE_KEY_AI_ACTIVE_SESSION_MAP:
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
return;
|
||||
default:
|
||||
handleStorage({ key } as StorageEvent);
|
||||
}
|
||||
};
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
return () => {
|
||||
window.removeEventListener('storage', handleStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, handleLocalStateChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Sync initial safety settings to MCP Server on mount ──
|
||||
@@ -375,6 +511,7 @@ export function useAIState() {
|
||||
};
|
||||
setSessionsRaw(prev => {
|
||||
const next = [session, ...prev];
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
@@ -391,12 +528,19 @@ export function useAIState() {
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => s.id !== sessionId);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
if (scopeKey) {
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] === sessionId) return { ...prev, [scopeKey]: null };
|
||||
if (prev[scopeKey] === sessionId) {
|
||||
const next = { ...prev, [scopeKey]: null };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
@@ -415,12 +559,19 @@ export function useAIState() {
|
||||
const next = prev.filter(s => {
|
||||
return !(s.scope.type === scopeType && s.scope.targetId === targetId);
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
const scopeKey = `${scopeType}:${targetId}`;
|
||||
setActiveSessionIdMapRaw(prev => {
|
||||
if (prev[scopeKey] != null) return { ...prev, [scopeKey]: null };
|
||||
if (prev[scopeKey] != null) {
|
||||
const next = { ...prev, [scopeKey]: null };
|
||||
setLatestAIActiveSessionMapSnapshot(next);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
|
||||
return next;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
@@ -428,6 +579,7 @@ export function useAIState() {
|
||||
const updateSessionTitle = useCallback((sessionId: string, title: string) => {
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, title, updatedAt: Date.now() } : s);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
@@ -440,6 +592,7 @@ export function useAIState() {
|
||||
? { ...s, externalSessionId, updatedAt: Date.now() }
|
||||
: s
|
||||
));
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -463,6 +616,7 @@ export function useAIState() {
|
||||
}
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -476,6 +630,7 @@ export function useAIState() {
|
||||
msgs[msgs.length - 1] = updater(msgs[msgs.length - 1]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -491,6 +646,7 @@ export function useAIState() {
|
||||
msgs[idx] = updater(msgs[idx]);
|
||||
return { ...s, messages: msgs, updatedAt: Date.now() };
|
||||
});
|
||||
setLatestAISessionsSnapshot(next);
|
||||
debouncedPersistSessions();
|
||||
return next;
|
||||
});
|
||||
@@ -503,29 +659,21 @@ export function useAIState() {
|
||||
}
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.map(s => s.id === sessionId ? { ...s, messages: [], updatedAt: Date.now() } : s);
|
||||
setLatestAISessionsSnapshot(next);
|
||||
persistSessions(next);
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
|
||||
const cleanupOrphanedSessions = useCallback((activeTargetIds: Set<string>) => {
|
||||
const removedSessionIds = sessionsRef.current
|
||||
.filter(s => s.scope.targetId && !activeTargetIds.has(s.scope.targetId))
|
||||
.map(s => s.id);
|
||||
cleanupAcpSessions(removedSessionIds);
|
||||
setSessionsRaw(prev => {
|
||||
const next = prev.filter(s => {
|
||||
// Keep sessions without a targetId (global scope)
|
||||
if (!s.scope.targetId) return true;
|
||||
// Keep sessions whose target still exists
|
||||
return activeTargetIds.has(s.scope.targetId);
|
||||
});
|
||||
if (next.length !== prev.length) {
|
||||
persistSessions(next);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [persistSessions]);
|
||||
cleanupOrphanedAISessions(activeTargetIds);
|
||||
setSessionsRaw(latestAISessionsSnapshot ?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS) ?? []);
|
||||
setActiveSessionIdMapRaw(
|
||||
latestAIActiveSessionMapSnapshot
|
||||
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
|
||||
?? {},
|
||||
);
|
||||
}, []);
|
||||
|
||||
// ── Provider CRUD helpers ──
|
||||
const addProvider = useCallback((provider: ProviderConfig) => {
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* This should be used at the App level to ensure auto-start happens
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
@@ -17,7 +17,8 @@ import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
hosts: Host[];
|
||||
keys: { id: string; privateKey: string; passphrase: string }[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,10 +28,37 @@ export interface UsePortForwardingAutoStartOptions {
|
||||
export const usePortForwardingAutoStart = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<{ id: string; privateKey: string; passphrase: string }[]>(keys);
|
||||
const keysRef = useRef<SSHKey[]>(keys);
|
||||
const identitiesRef = useRef<Identity[]>(identities);
|
||||
|
||||
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
seen.add(host.id);
|
||||
|
||||
if (host.identityId) {
|
||||
const identity = identitiesRef.current.find((candidate) => candidate.id === host.identityId);
|
||||
if (!identity) return false;
|
||||
if (identity.keyId && !keysRef.current.some((key) => key.id === identity.keyId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (host.identityFileId && !keysRef.current.some((key) => key.id === host.identityFileId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chainIds = host.hostChain?.hostIds || [];
|
||||
for (const chainId of chainIds) {
|
||||
const chainHost = hostsRef.current.find((candidate) => candidate.id === chainId);
|
||||
if (!chainHost) return false;
|
||||
if (!isHostAuthReady(chainHost, seen)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
// Keep refs in sync
|
||||
useEffect(() => {
|
||||
@@ -41,6 +69,10 @@ export const usePortForwardingAutoStart = ({
|
||||
keysRef.current = keys;
|
||||
}, [keys]);
|
||||
|
||||
useEffect(() => {
|
||||
identitiesRef.current = identities;
|
||||
}, [identities]);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
@@ -62,7 +94,7 @@ export const usePortForwardingAutoStart = ({
|
||||
return { success: false, error: "Host not found" };
|
||||
}
|
||||
|
||||
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
|
||||
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
@@ -76,6 +108,17 @@ export const usePortForwardingAutoStart = ({
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
|
||||
if (pendingAutoStartRules.some((rule) => {
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
return !host || !isHostAuthReady(host);
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark as executed immediately to prevent duplicate runs
|
||||
// (React StrictMode or dependency changes could cause re-runs)
|
||||
autoStartExecutedRef.current = true;
|
||||
@@ -108,7 +151,9 @@ export const usePortForwardingAutoStart = ({
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Update the rule status in storage
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
@@ -135,5 +180,5 @@ export const usePortForwardingAutoStart = ({
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, keys]);
|
||||
}, [hosts, identities, isHostAuthReady, keys]);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Host, PortForwardingRule } from "../../domain/models";
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import {
|
||||
STORAGE_KEY_PF_PREFER_FORM_MODE,
|
||||
STORAGE_KEY_PF_VIEW_MODE,
|
||||
@@ -63,7 +63,9 @@ export interface UsePortForwardingStateResult {
|
||||
startTunnel: (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
|
||||
enableReconnect?: boolean,
|
||||
) => Promise<{ success: boolean; error?: string }>;
|
||||
@@ -377,14 +379,16 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
async (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange?: (
|
||||
status: PortForwardingRule["status"],
|
||||
error?: string,
|
||||
) => void,
|
||||
enableReconnect = false,
|
||||
) => {
|
||||
return startPortForward(rule, host, keys, (status, error) => {
|
||||
return startPortForward(rule, host, hosts, keys, identities, (status, error) => {
|
||||
setRuleStatus(rule.id, status, error);
|
||||
onStatusChange?.(status, error ?? undefined);
|
||||
}, enableReconnect);
|
||||
|
||||
@@ -38,7 +38,6 @@ import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
@@ -155,7 +154,6 @@ const applyThemeTokens = (
|
||||
};
|
||||
|
||||
export const useSettingsState = () => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
@@ -287,6 +285,10 @@ export const useSettingsState = () => {
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
const persistMountedRef = useRef(false);
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
@@ -334,6 +336,17 @@ export const useSettingsState = () => {
|
||||
const storedAccent = readStoredString(STORAGE_KEY_COLOR);
|
||||
const nextAccent = storedAccent && isValidHslToken(storedAccent) ? storedAccent.trim() : customAccent;
|
||||
|
||||
// Fix 2: Skip expensive DOM operations if nothing actually changed
|
||||
if (
|
||||
nextTheme === theme &&
|
||||
nextLightId === lightUiThemeId &&
|
||||
nextDarkId === darkUiThemeId &&
|
||||
nextAccentMode === accentMode &&
|
||||
nextAccent === customAccent
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTheme(nextTheme);
|
||||
setLightUiThemeId(nextLightId);
|
||||
setDarkUiThemeId(nextDarkId);
|
||||
@@ -414,12 +427,11 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_COLOR, customAccent);
|
||||
// Notify other windows
|
||||
// Fix 1: Skip IPC broadcast on initial mount (values already match localStorage)
|
||||
if (!persistMountedRef.current) return;
|
||||
// Fix 3: Send a single IPC instead of 5 — the receiver calls syncAppearanceFromStorage()
|
||||
// which re-reads ALL appearance values from localStorage.
|
||||
notifySettingsChanged(STORAGE_KEY_THEME, theme);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
@@ -437,7 +449,10 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
document.documentElement.lang = uiLanguage;
|
||||
netcattyBridge.get()?.setLanguage?.(uiLanguage);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
// Fix 1: Skip IPC broadcast on initial mount
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
}
|
||||
}, [uiLanguage, notifySettingsChanged]);
|
||||
|
||||
// Apply and persist UI font family
|
||||
@@ -446,7 +461,10 @@ export const useSettingsState = () => {
|
||||
const font = uiFontStore.getFontById(uiFontFamilyId);
|
||||
document.documentElement.style.setProperty('--font-sans', font.family);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
// Fix 1: Skip IPC broadcast on initial mount
|
||||
if (persistMountedRef.current) {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_FONT_FAMILY, uiFontFamilyId);
|
||||
}
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
@@ -567,53 +585,76 @@ export const useSettingsState = () => {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
// can compare without capturing 25+ state variables in its closure / dep array.
|
||||
// This avoids constant listener detach/reattach on every state change.
|
||||
const settingsSnapshotRef = useRef({
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
useEffect(() => {
|
||||
const handleStorageChange = (e: StorageEvent) => {
|
||||
const s = settingsSnapshotRef.current;
|
||||
if (e.key === STORAGE_KEY_THEME && e.newValue) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== theme) {
|
||||
if (isValidTheme(e.newValue) && e.newValue !== s.theme) {
|
||||
setTheme(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_LIGHT && e.newValue) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== lightUiThemeId) {
|
||||
if (isValidUiThemeId('light', e.newValue) && e.newValue !== s.lightUiThemeId) {
|
||||
setLightUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_THEME_DARK && e.newValue) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== darkUiThemeId) {
|
||||
if (isValidUiThemeId('dark', e.newValue) && e.newValue !== s.darkUiThemeId) {
|
||||
setDarkUiThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_ACCENT_MODE && e.newValue) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== accentMode) {
|
||||
if ((e.newValue === 'theme' || e.newValue === 'custom') && e.newValue !== s.accentMode) {
|
||||
setAccentMode(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_COLOR && e.newValue) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== customAccent) {
|
||||
if (isValidHslToken(e.newValue) && e.newValue !== s.customAccent) {
|
||||
setCustomAccent(e.newValue.trim());
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_CUSTOM_CSS && e.newValue !== null) {
|
||||
if (e.newValue !== customCSS) {
|
||||
if (e.newValue !== s.customCSS) {
|
||||
setCustomCSS(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_FONT_FAMILY && e.newValue) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== uiFontFamilyId) {
|
||||
if (isValidUiFontId(e.newValue) && e.newValue !== s.uiFontFamilyId) {
|
||||
setUiFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_HOTKEY_SCHEME && e.newValue) {
|
||||
const newScheme = e.newValue as HotkeyScheme;
|
||||
if (newScheme !== hotkeyScheme) {
|
||||
if (newScheme !== s.hotkeyScheme) {
|
||||
setHotkeyScheme(newScheme);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_UI_LANGUAGE && e.newValue) {
|
||||
const next = resolveSupportedLocale(e.newValue);
|
||||
if (next !== uiLanguage) {
|
||||
if (next !== s.uiLanguage) {
|
||||
setUiLanguage(next as UILanguage);
|
||||
}
|
||||
}
|
||||
@@ -636,64 +677,64 @@ export const useSettingsState = () => {
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== terminalThemeId) {
|
||||
if (e.newValue !== s.terminalThemeId) {
|
||||
setTerminalThemeId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync terminal font family from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_FAMILY && e.newValue) {
|
||||
if (e.newValue !== terminalFontFamilyId) {
|
||||
if (e.newValue !== s.terminalFontFamilyId) {
|
||||
setTerminalFontFamilyId(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync terminal font size from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_FONT_SIZE && e.newValue) {
|
||||
const newSize = parseInt(e.newValue, 10);
|
||||
if (!isNaN(newSize) && newSize !== terminalFontSize) {
|
||||
if (!isNaN(newSize) && newSize !== s.terminalFontSize) {
|
||||
setTerminalFontSize(newSize);
|
||||
}
|
||||
}
|
||||
// Sync SFTP double-click behavior from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR && e.newValue) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== sftpDoubleClickBehavior) {
|
||||
if ((e.newValue === 'open' || e.newValue === 'transfer') && e.newValue !== s.sftpDoubleClickBehavior) {
|
||||
setSftpDoubleClickBehavior(e.newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-sync setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_SYNC && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoSync) {
|
||||
if (newValue !== s.sftpAutoSync) {
|
||||
setSftpAutoSync(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP show hidden files setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpShowHiddenFiles) {
|
||||
if (newValue !== s.sftpShowHiddenFiles) {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== editorWordWrap) {
|
||||
if (newValue !== s.editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sessionLogsEnabled) {
|
||||
if (newValue !== s.sessionLogsEnabled) {
|
||||
setSessionLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
|
||||
if (e.newValue !== sessionLogsDir) {
|
||||
if (e.newValue !== s.sessionLogsDir) {
|
||||
setSessionLogsDir(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
|
||||
if (
|
||||
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
|
||||
e.newValue !== sessionLogsFormat
|
||||
e.newValue !== s.sessionLogsFormat
|
||||
) {
|
||||
setSessionLogsFormat(e.newValue);
|
||||
}
|
||||
@@ -701,28 +742,28 @@ export const useSettingsState = () => {
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
if (newValue !== sftpUseCompressedUpload) {
|
||||
if (newValue !== s.sftpUseCompressedUpload) {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP auto-open sidebar setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sftpAutoOpenSidebar) {
|
||||
if (newValue !== s.sftpAutoOpenSidebar) {
|
||||
setSftpAutoOpenSidebar(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== globalHotkeyEnabled) {
|
||||
if (newValue !== s.globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== autoUpdateEnabled) {
|
||||
if (newValue !== s.autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
@@ -730,25 +771,29 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpAutoOpenSidebar, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
}, [mergeIncomingTerminalSettings]); // Fix 4: stable deps only — state comparisons use settingsSnapshotRef
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
}, [terminalThemeId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_FAMILY, terminalFontFamilyId);
|
||||
}, [terminalFontFamilyId, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_FONT_SIZE, terminalFontSize);
|
||||
}, [terminalFontSize, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
if (!persistMountedRef.current) return;
|
||||
const currentSignature = serializeTerminalSettings(terminalSettings);
|
||||
const hasPendingUnbroadcastLocalChanges =
|
||||
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
|
||||
@@ -763,11 +808,13 @@ export const useSettingsState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
}, [hotkeyScheme, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_KEY_BINDINGS, customKeyBindings);
|
||||
}, [customKeyBindings, notifySettingsChanged]);
|
||||
|
||||
@@ -778,10 +825,7 @@ export const useSettingsState = () => {
|
||||
|
||||
// Apply and persist custom CSS
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
|
||||
// Apply custom CSS to document
|
||||
// Always apply CSS to document (needed on mount)
|
||||
let styleEl = document.getElementById('netcatty-custom-css') as HTMLStyleElement | null;
|
||||
if (!styleEl) {
|
||||
styleEl = document.createElement('style');
|
||||
@@ -789,59 +833,69 @@ export const useSettingsState = () => {
|
||||
document.head.appendChild(styleEl);
|
||||
}
|
||||
styleEl.textContent = customCSS;
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CUSTOM_CSS, customCSS);
|
||||
}, [customCSS, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP double-click behavior
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, sftpDoubleClickBehavior);
|
||||
}, [sftpDoubleClickBehavior, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-sync setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_SYNC, sftpAutoSync);
|
||||
}, [sftpAutoSync, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP show hidden files setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, sftpShowHiddenFiles);
|
||||
}, [sftpShowHiddenFiles, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP compressed upload setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, sftpUseCompressedUpload);
|
||||
}, [sftpUseCompressedUpload, notifySettingsChanged]);
|
||||
|
||||
// Persist SFTP auto-open sidebar setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR, sftpAutoOpenSidebar);
|
||||
}, [sftpAutoOpenSidebar, notifySettingsChanged]);
|
||||
|
||||
// Persist Session Logs settings
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_ENABLED, sessionLogsEnabled);
|
||||
}, [sessionLogsEnabled, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_DIR, sessionLogsDir);
|
||||
}, [sessionLogsDir, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_SESSION_LOGS_FORMAT, sessionLogsFormat);
|
||||
}, [sessionLogsFormat, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync toggle window hotkey setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Register/unregister the global hotkey in main process
|
||||
// Register/unregister the global hotkey in main process (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
@@ -865,25 +919,32 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
|
||||
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
// Update main process tray behavior
|
||||
// Update main process tray behavior (needed on mount)
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.setCloseToTray) {
|
||||
bridge.setCloseToTray(closeToTray).catch((err) => {
|
||||
console.warn('[SystemTray] Failed to set close-to-tray:', err);
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
|
||||
// Skip IPC on initial mount
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
@@ -904,16 +965,11 @@ export const useSettingsState = () => {
|
||||
}, []);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Skip IPC on initial mount to avoid overwriting the main-process preference
|
||||
// file when localStorage has been cleared (where the default is true).
|
||||
const autoUpdateMountedRef = useRef(false);
|
||||
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
if (!autoUpdateMountedRef.current) {
|
||||
autoUpdateMountedRef.current = true;
|
||||
return; // Skip IPC on initial mount
|
||||
}
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
@@ -921,6 +977,13 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged]);
|
||||
|
||||
// Fix 1: Mark all persist effects as mounted.
|
||||
// This MUST be declared AFTER all persist useEffects so that React runs it last
|
||||
// during the initial mount cycle (effects fire in declaration order).
|
||||
useEffect(() => {
|
||||
persistMountedRef.current = true;
|
||||
}, []);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -983,11 +1046,6 @@ export const useSettingsState = () => {
|
||||
[terminalThemeId, customThemes]
|
||||
);
|
||||
|
||||
const currentTerminalFont = useMemo(
|
||||
() => availableFonts.find(f => f.id === terminalFontFamilyId) || availableFonts[0],
|
||||
[terminalFontFamilyId, availableFonts]
|
||||
);
|
||||
|
||||
const updateTerminalSetting = useCallback(<K extends keyof TerminalSettings>(
|
||||
key: K,
|
||||
value: TerminalSettings[K]
|
||||
@@ -1018,7 +1076,6 @@ export const useSettingsState = () => {
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
setTerminalFontFamilyId,
|
||||
currentTerminalFont,
|
||||
terminalFontSize,
|
||||
setTerminalFontSize,
|
||||
terminalSettings,
|
||||
@@ -1052,7 +1109,6 @@ export const useSettingsState = () => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
|
||||
}, [notifySettingsChanged]),
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
setSessionLogsEnabled,
|
||||
|
||||
@@ -96,7 +96,7 @@ export const useTerminalBackend = () => {
|
||||
return bridge.onSessionExit(sessionId, cb);
|
||||
}, []);
|
||||
|
||||
const onChainProgress = useCallback((cb: (hop: number, total: number, label: string, status: string) => void) => {
|
||||
const onChainProgress = useCallback((cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
return bridge?.onChainProgress?.(cb);
|
||||
}, []);
|
||||
|
||||
@@ -420,7 +420,9 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
|
||||
/** Ensure a session exists for the current scope and return its ID. */
|
||||
const ensureSession = useCallback((): string => {
|
||||
if (activeSessionId) return activeSessionId;
|
||||
if (activeSessionId && sessionsRef.current.some((session) => session.id === activeSessionId)) {
|
||||
return activeSessionId;
|
||||
}
|
||||
const scope: AISessionScope = { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds };
|
||||
const session = createSession(scope, currentAgentId);
|
||||
setActiveSessionId(session.id);
|
||||
@@ -543,6 +545,10 @@ const AIChatSidePanelInner: React.FC<AIChatSidePanelProps> = ({
|
||||
}));
|
||||
// Clear pending approvals for this session (so tool execute functions don't hang)
|
||||
clearAllPendingApprovals(activeSessionId);
|
||||
// Cancel in-flight command executions (Catty Agent + ACP Agent)
|
||||
const bridge = getNetcattyBridge();
|
||||
bridge?.aiCattyCancelExec?.(activeSessionId);
|
||||
bridge?.aiAcpCancel?.('', activeSessionId);
|
||||
}, [activeSessionId, setStreamingForScope, updateLastMessage, abortControllersRef]);
|
||||
|
||||
const handleSelectSession = useCallback(
|
||||
|
||||
@@ -20,6 +20,9 @@ import {
|
||||
Tag,
|
||||
TerminalSquare,
|
||||
User,
|
||||
FileKey,
|
||||
FolderOpen,
|
||||
Trash2,
|
||||
Variable,
|
||||
Wifi,
|
||||
X,
|
||||
@@ -69,7 +72,7 @@ import {
|
||||
ProxyPanel,
|
||||
} from "./host-details";
|
||||
|
||||
type CredentialType = "sshid" | "key" | "certificate" | null;
|
||||
type CredentialType = "sshid" | "key" | "certificate" | "localKeyFile" | null;
|
||||
type SubPanel =
|
||||
| "none"
|
||||
| "create-group"
|
||||
@@ -147,6 +150,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
// Password visibility state
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
// Local key file path input state
|
||||
const [newKeyFilePath, setNewKeyFilePath] = useState("");
|
||||
|
||||
// New group creation state
|
||||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [newGroupParent, setNewGroupParent] = useState("");
|
||||
@@ -469,6 +475,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
authMethod: identity.authMethod,
|
||||
password: undefined,
|
||||
identityFileId: undefined,
|
||||
identityFilePaths: undefined,
|
||||
}));
|
||||
setSelectedCredentialType(null);
|
||||
setCredentialPopoverOpen(false);
|
||||
@@ -969,6 +976,31 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{form.identityFilePaths.map((keyPath, idx) => (
|
||||
<div key={idx} className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
<FileKey size={14} className="text-primary shrink-0" />
|
||||
<span className="text-xs flex-1 truncate font-mono" title={keyPath}>
|
||||
{keyPath}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
const paths = form.identityFilePaths?.filter((_, i) => i !== idx) || [];
|
||||
update("identityFilePaths", paths.length > 0 ? paths : undefined);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selected credential display */}
|
||||
{!selectedIdentity && form.identityFileId && (
|
||||
<div className="flex items-center gap-2 p-2 rounded-md bg-secondary/50 border border-border/60">
|
||||
@@ -1046,6 +1078,20 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
{t("hostDetails.credential.certificate")}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-md hover:bg-secondary/80 transition-colors text-left"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType("localKeyFile");
|
||||
setCredentialPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<FileKey size={16} className="text-muted-foreground" />
|
||||
<span className="text-sm font-medium">
|
||||
{t("hostDetails.credential.localKeyFile")}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
@@ -1067,6 +1113,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "key");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.keys.search")}
|
||||
@@ -1102,6 +1149,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
onValueChange={(val) => {
|
||||
update("identityFileId", val);
|
||||
update("authMethod", "certificate");
|
||||
update("identityFilePaths", undefined);
|
||||
setSelectedCredentialType(null);
|
||||
}}
|
||||
placeholder={t("hostDetails.certs.search")}
|
||||
@@ -1121,6 +1169,67 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local key file path input - appears after selecting "Local Key File" type */}
|
||||
{!selectedIdentity &&
|
||||
selectedCredentialType === "localKeyFile" &&
|
||||
!form.identityFileId && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 h-8 px-2 text-xs font-mono bg-background border border-border/60 rounded-md focus:outline-none focus:ring-1 focus:ring-ring"
|
||||
placeholder={t("hostDetails.credential.localKeyFilePlaceholder")}
|
||||
value={newKeyFilePath}
|
||||
onChange={(e) => setNewKeyFilePath(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && newKeyFilePath.trim()) {
|
||||
e.preventDefault();
|
||||
const paths = [...(form.identityFilePaths || []), newKeyFilePath.trim()];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
setNewKeyFilePath("");
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
title={t("hostDetails.credential.browseKeyFile")}
|
||||
onClick={async () => {
|
||||
const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty;
|
||||
if (!bridge?.selectFile) return;
|
||||
const filePath = await bridge.selectFile(
|
||||
"Select SSH Private Key",
|
||||
undefined,
|
||||
[{ name: "All Files", extensions: ["*"] }]
|
||||
);
|
||||
if (filePath) {
|
||||
const paths = [...(form.identityFilePaths || []), filePath];
|
||||
update("identityFilePaths", paths);
|
||||
update("identityFileId", undefined);
|
||||
update("authMethod", "key");
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FolderOpen size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => {
|
||||
setSelectedCredentialType(null);
|
||||
setNewKeyFilePath("");
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
|
||||
@@ -130,7 +130,9 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const result = await startTunnel(
|
||||
rule,
|
||||
_host,
|
||||
keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Show toast on error (only once)
|
||||
if (status === "error" && error && !errorShown) {
|
||||
@@ -159,7 +161,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[hosts, keys, setRuleStatus, startTunnel, t],
|
||||
[hosts, identities, keys, setRuleStatus, startTunnel, t],
|
||||
);
|
||||
|
||||
// Stop a port forwarding tunnel
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -531,11 +532,11 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
case "protocol":
|
||||
return target.hostname;
|
||||
case "username":
|
||||
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
|
||||
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
|
||||
case "knownhost":
|
||||
return `${protocol.toUpperCase()} ${effectiveUsername}@${target.hostname}:${port}`;
|
||||
return `${protocol.toUpperCase()} ${effectiveUsername}@${formatHostPort(target.hostname, port)}`;
|
||||
case "auth":
|
||||
return `${protocol.toUpperCase()} ${target.hostname}:${port}`;
|
||||
return `${protocol.toUpperCase()} ${formatHostPort(target.hostname, port)}`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, Sparkles, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
@@ -19,7 +20,6 @@ import SettingsTerminalTab from "./settings/tabs/SettingsTerminalTab";
|
||||
import SettingsSystemTab from "./settings/tabs/SettingsSystemTab";
|
||||
const SettingsAITab = React.lazy(() => import("./settings/tabs/SettingsAITab"));
|
||||
import { Tabs, TabsList, TabsTrigger } from "./ui/tabs";
|
||||
import type { TerminalFont } from "../infrastructure/config/fonts";
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/.test(navigator.platform);
|
||||
|
||||
@@ -45,12 +45,63 @@ class AITabErrorBoundary extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
availableFonts: TerminalFont[];
|
||||
};
|
||||
type SettingsState = ReturnType<typeof useSettingsState>;
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const SettingsTerminalTabContainer: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
return (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={availableFonts}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsAITabContainer: React.FC = () => {
|
||||
const aiState = useAIState();
|
||||
|
||||
return (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={<div className="flex-1 px-6 py-5 text-sm text-muted-foreground">Loading AI settings...</div>}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
);
|
||||
};
|
||||
|
||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
||||
const {
|
||||
hosts,
|
||||
@@ -99,7 +150,6 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const aiState = useAIState();
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
|
||||
@@ -231,17 +281,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
)}
|
||||
|
||||
{mountedTabs.has("terminal") && (
|
||||
<SettingsTerminalTab
|
||||
terminalThemeId={settings.terminalThemeId}
|
||||
setTerminalThemeId={settings.setTerminalThemeId}
|
||||
terminalFontFamilyId={settings.terminalFontFamilyId}
|
||||
setTerminalFontFamilyId={settings.setTerminalFontFamilyId}
|
||||
terminalFontSize={settings.terminalFontSize}
|
||||
setTerminalFontSize={settings.setTerminalFontSize}
|
||||
terminalSettings={settings.terminalSettings}
|
||||
updateTerminalSetting={settings.updateTerminalSetting}
|
||||
availableFonts={settings.availableFonts}
|
||||
/>
|
||||
<SettingsTerminalTabContainer settings={settings} />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("shortcuts") && (
|
||||
@@ -261,34 +301,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
)}
|
||||
|
||||
{mountedTabs.has("ai") && (
|
||||
<AITabErrorBoundary>
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsAITab
|
||||
providers={aiState.providers}
|
||||
addProvider={aiState.addProvider}
|
||||
updateProvider={aiState.updateProvider}
|
||||
removeProvider={aiState.removeProvider}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
setActiveProviderId={aiState.setActiveProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
setActiveModelId={aiState.setActiveModelId}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
setDefaultAgentId={aiState.setDefaultAgentId}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
setCommandBlocklist={aiState.setCommandBlocklist}
|
||||
commandTimeout={aiState.commandTimeout}
|
||||
setCommandTimeout={aiState.setCommandTimeout}
|
||||
maxIterations={aiState.maxIterations}
|
||||
setMaxIterations={aiState.setMaxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
setWebSearchConfig={aiState.setWebSearchConfig}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</AITabErrorBoundary>
|
||||
<SettingsAITabContainer />
|
||||
)}
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
*/
|
||||
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useSftpState } from "../application/state/useSftpState";
|
||||
import { useSftpBackend } from "../application/state/useSftpBackend";
|
||||
@@ -518,7 +519,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
/>
|
||||
<div
|
||||
className="min-w-0 flex-1 max-w-[calc(100%-1.75rem)] text-[11px] leading-5 truncate"
|
||||
title={`${displayHost.label} · ${(displayHost.username || "root")}@${displayHost.hostname}:${displayHost.port || 22}`}
|
||||
title={`${displayHost.label} · ${(displayHost.username || "root")}@${formatHostPort(displayHost.hostname, displayHost.port || 22)}`}
|
||||
>
|
||||
<span className="font-medium">
|
||||
{displayHost.label}
|
||||
|
||||
@@ -8,7 +8,7 @@ import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "
|
||||
// flushSync removed - no longer needed
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { cn, normalizeLineEndings, wrapBracketedPaste } from "../lib/utils";
|
||||
import {
|
||||
Host,
|
||||
Identity,
|
||||
@@ -157,6 +157,10 @@ interface TerminalProps {
|
||||
onToggleComposeBar?: () => void;
|
||||
isWorkspaceComposeBarOpen?: boolean;
|
||||
onBroadcastInput?: (data: string, sourceSessionId: string) => void;
|
||||
onSnippetExecutorChange?: (
|
||||
sessionId: string,
|
||||
executor: ((command: string, noAutoRun?: boolean) => void) | null,
|
||||
) => void;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
}
|
||||
@@ -216,6 +220,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleComposeBar,
|
||||
isWorkspaceComposeBarOpen,
|
||||
onBroadcastInput,
|
||||
onSnippetExecutorChange,
|
||||
sessionLog,
|
||||
}) => {
|
||||
// Timeout for connection - increased to 120s to allow time for keyboard-interactive (2FA) authentication
|
||||
@@ -346,12 +351,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const isLocalConnection = host.protocol === "local";
|
||||
const isSerialConnection = host.protocol === "serial";
|
||||
|
||||
// Server stats (CPU, Memory, Disk) for Linux servers
|
||||
// Server stats (CPU, Memory, Disk) — only for Linux/macOS
|
||||
const { stats: serverStats } = useServerStats({
|
||||
sessionId,
|
||||
enabled: terminalSettings?.showServerStats ?? true,
|
||||
refreshInterval: terminalSettings?.serverStatsRefreshInterval ?? 5,
|
||||
isLinux: host.os === 'linux',
|
||||
isSupportedOs: host.os === 'linux' || host.os === 'macos',
|
||||
isConnected: status === 'connected',
|
||||
});
|
||||
|
||||
@@ -635,28 +640,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
// Local terminal and serial connections don't need timeout/progress UI
|
||||
if (isLocalConnection || isSerialConnection) return;
|
||||
|
||||
// Only show SSH-specific scripted logs for SSH connections
|
||||
const isSSH = host.protocol !== "telnet";
|
||||
|
||||
let stepTimer: ReturnType<typeof setInterval> | undefined;
|
||||
if (isSSH) {
|
||||
const scripted = [
|
||||
"Resolving host and keys...",
|
||||
"Negotiating ciphers...",
|
||||
"Exchanging keys...",
|
||||
"Authenticating user...",
|
||||
"Waiting for server greeting...",
|
||||
];
|
||||
let idx = 0;
|
||||
stepTimer = setInterval(() => {
|
||||
setProgressLogs((prev) => {
|
||||
if (idx >= scripted.length) return prev;
|
||||
const next = scripted[idx++];
|
||||
return prev.includes(next) ? prev : [...prev, next];
|
||||
});
|
||||
}, 900);
|
||||
}
|
||||
|
||||
setTimeLeft(CONNECTION_TIMEOUT / 1000);
|
||||
const countdown = setInterval(() => {
|
||||
setTimeLeft((prev) => (prev > 0 ? prev - 1 : 0));
|
||||
@@ -679,7 +662,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (stepTimer) clearInterval(stepTimer);
|
||||
clearInterval(countdown);
|
||||
clearTimeout(timeout);
|
||||
clearInterval(prog);
|
||||
@@ -787,6 +769,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
terminalSettings.drawBoldInBrightColors;
|
||||
termRef.current.options.minimumContrastRatio =
|
||||
terminalSettings.minimumContrastRatio;
|
||||
termRef.current.options.smoothScrollDuration =
|
||||
terminalSettings.smoothScrolling
|
||||
? XTERM_PERFORMANCE_CONFIG.rendering.smoothScrollDuration
|
||||
: 0;
|
||||
termRef.current.options.scrollOnUserInput =
|
||||
shouldEnableNativeUserInputAutoScroll(terminalSettings);
|
||||
termRef.current.options.altClickMovesCursor = !terminalSettings.altAsMeta;
|
||||
@@ -1061,11 +1047,43 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
const scrollToBottomAfterProgrammaticInput = (data: string) => {
|
||||
const scrollToBottomAfterProgrammaticInput = useCallback((data: string) => {
|
||||
if (termRef.current && shouldScrollOnTerminalInput(terminalSettingsRef.current, data)) {
|
||||
termRef.current.scrollToBottom();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const executeSnippetCommand = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const term = termRef.current;
|
||||
const id = sessionRef.current;
|
||||
if (!term || !id) return;
|
||||
|
||||
let data = normalizeLineEndings(command);
|
||||
const isMultiLine = data.includes('\n');
|
||||
// Wrap in bracketed paste BEFORE appending \r so the Enter is sent
|
||||
// outside the paste markers — otherwise shells treat it as pasted text
|
||||
// instead of a submit action.
|
||||
if (isMultiLine && term.modes.bracketedPasteMode && !disableBracketedPasteRef.current) {
|
||||
data = wrapBracketedPaste(data);
|
||||
}
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
|
||||
terminalBackend.writeToSession(id, data);
|
||||
scrollToBottomAfterProgrammaticInput(data);
|
||||
term.focus();
|
||||
}, [scrollToBottomAfterProgrammaticInput, terminalBackend]);
|
||||
|
||||
// Only register the snippet executor once the terminal session is ready.
|
||||
// Before that, TerminalLayer falls back to raw writeToSession which is the
|
||||
// correct path for sessions that are still connecting.
|
||||
useEffect(() => {
|
||||
if (status !== "connected") {
|
||||
onSnippetExecutorChange?.(sessionId, null);
|
||||
return;
|
||||
}
|
||||
onSnippetExecutorChange?.(sessionId, executeSnippetCommand);
|
||||
return () => onSnippetExecutorChange?.(sessionId, null);
|
||||
}, [executeSnippetCommand, onSnippetExecutorChange, sessionId, status]);
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
@@ -1375,8 +1393,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Server Stats Display - Linux only */}
|
||||
{host.os === 'linux' && terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
{/* Server Stats Display */}
|
||||
{terminalSettings?.showServerStats && status === 'connected' && serverStats.lastUpdated && (
|
||||
<div className="flex items-center gap-2.5 ml-2 text-[10px] opacity-80 flex-nowrap overflow-hidden min-w-0">
|
||||
{/* CPU with HoverCard for per-core details */}
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
@@ -1423,6 +1441,24 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : serverStats.cpu !== null ? (
|
||||
<div className="flex flex-col gap-1.5 min-w-[160px]">
|
||||
<div className="w-full h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={cn(
|
||||
"h-full rounded-full transition-all",
|
||||
serverStats.cpu >= 90 ? "bg-red-500" : serverStats.cpu >= 70 ? "bg-amber-500" : "bg-emerald-500"
|
||||
)}
|
||||
style={{ width: `${serverStats.cpu}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className={cn(
|
||||
"text-center text-[11px] font-medium",
|
||||
serverStats.cpu >= 90 ? "text-red-400" : serverStats.cpu >= 70 ? "text-amber-400" : "text-emerald-400"
|
||||
)}>
|
||||
{serverStats.cpu}% · {serverStats.cpuCores ?? '?'} cores
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground">{t("terminal.serverStats.noData")}</div>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Circle, FolderTree, LayoutGrid, MessageSquare, PanelLeft, PanelRight, Palette, Server, X, Zap } from 'lucide-react';
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import React, { createContext, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useActiveTabId } from '../application/state/activeTabStore';
|
||||
import { useTerminalBackend } from '../application/state/useTerminalBackend';
|
||||
import { collectSessionIds } from '../domain/workspace';
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
resolveHostTerminalFontSize,
|
||||
resolveHostTerminalThemeId,
|
||||
} from '../domain/terminalAppearance';
|
||||
import { cn } from '../lib/utils';
|
||||
import { cn, normalizeLineEndings } from '../lib/utils';
|
||||
import { detectLocalOs } from '../lib/localShell';
|
||||
import { useStoredString } from '../application/state/useStoredString';
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
@@ -28,7 +28,7 @@ import { SftpSidePanel } from './SftpSidePanel';
|
||||
import { ScriptsSidePanel } from './ScriptsSidePanel';
|
||||
import { ThemeSidePanel } from './terminal/ThemeSidePanel';
|
||||
import { AIChatSidePanel } from './AIChatSidePanel';
|
||||
import { useAIState } from '../application/state/useAIState';
|
||||
import { cleanupOrphanedAISessions, useAIState } from '../application/state/useAIState';
|
||||
import { TerminalComposeBar } from './terminal/TerminalComposeBar';
|
||||
import { TERMINAL_THEMES } from '../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../application/state/customThemeStore';
|
||||
@@ -65,6 +65,8 @@ type PendingSftpUpload = {
|
||||
entries: DropEntry[];
|
||||
};
|
||||
|
||||
type SnippetExecutor = (command: string, noAutoRun?: boolean) => void;
|
||||
|
||||
const filterTabsMap = <T,>(source: Map<string, T>, validIds: Set<string>): Map<string, T> => {
|
||||
let changed = false;
|
||||
const next = new Map<string, T>();
|
||||
@@ -90,6 +92,18 @@ type AITerminalSessionInfo = {
|
||||
connected: boolean;
|
||||
};
|
||||
|
||||
type AIPanelContext = {
|
||||
scopeType: 'terminal' | 'workspace';
|
||||
scopeTargetId?: string;
|
||||
scopeHostIds: string[];
|
||||
scopeLabel: string;
|
||||
terminalSessions: AITerminalSessionInfo[];
|
||||
};
|
||||
|
||||
type AIStateValue = ReturnType<typeof useAIState>;
|
||||
|
||||
const AIStateContext = createContext<AIStateValue | null>(null);
|
||||
|
||||
const buildAITerminalSessionInfo = (
|
||||
session: TerminalSession | undefined,
|
||||
host: Host | undefined,
|
||||
@@ -110,6 +124,98 @@ const buildAITerminalSessionInfo = (
|
||||
};
|
||||
};
|
||||
|
||||
interface AIChatPanelsHostProps {
|
||||
mountedTabIds: string[];
|
||||
activeTabId: string | null;
|
||||
activeSidePanelTab: SidePanelTab | null;
|
||||
contextsByTabId: Map<string, AIPanelContext>;
|
||||
resolveExecutorContext: (scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
targetId?: string;
|
||||
label?: string;
|
||||
}) => ExecutorContext;
|
||||
}
|
||||
|
||||
const AIStateProviderInner: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const aiState = useAIState();
|
||||
return (
|
||||
<AIStateContext.Provider value={aiState}>
|
||||
{children}
|
||||
</AIStateContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const AIStateProvider = memo(AIStateProviderInner);
|
||||
AIStateProvider.displayName = 'AIStateProvider';
|
||||
|
||||
const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
|
||||
mountedTabIds,
|
||||
activeTabId,
|
||||
activeSidePanelTab,
|
||||
contextsByTabId,
|
||||
resolveExecutorContext,
|
||||
}) => {
|
||||
const aiState = useContext(AIStateContext);
|
||||
|
||||
if (!aiState) {
|
||||
throw new Error('AIChatPanelsHost must be rendered inside AIStateProvider');
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{mountedTabIds.map((tabId) => {
|
||||
const context = contextsByTabId.get(tabId);
|
||||
if (!context) return null;
|
||||
|
||||
const isVisible = activeTabId === tabId && activeSidePanelTab === 'ai';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={tabId}
|
||||
className={cn("absolute inset-0 z-10", !isVisible && "hidden")}
|
||||
>
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
providers={aiState.providers}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
setAgentModel={aiState.setAgentModel}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
scopeType={context.scopeType}
|
||||
scopeTargetId={context.scopeTargetId}
|
||||
scopeHostIds={context.scopeHostIds}
|
||||
scopeLabel={context.scopeLabel}
|
||||
terminalSessions={context.terminalSessions}
|
||||
resolveExecutorContext={resolveExecutorContext}
|
||||
isVisible={isVisible}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const AIChatPanelsHost = memo(AIChatPanelsHostInner);
|
||||
AIChatPanelsHost.displayName = 'AIChatPanelsHost';
|
||||
|
||||
interface TerminalLayerProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
@@ -306,6 +412,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
// Terminal backend for broadcast writes
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const snippetExecutorsRef = useRef<Map<string, SnippetExecutor>>(new Map());
|
||||
|
||||
const handleSnippetExecutorChange = useCallback((sessionId: string, executor: SnippetExecutor | null) => {
|
||||
if (executor) {
|
||||
snippetExecutorsRef.current.set(sessionId, executor);
|
||||
return;
|
||||
}
|
||||
snippetExecutorsRef.current.delete(sessionId);
|
||||
}, []);
|
||||
|
||||
const [workspaceArea, setWorkspaceArea] = useState<{ width: number; height: number }>({ width: 0, height: 0 });
|
||||
const workspaceOuterRef = useRef<HTMLDivElement>(null);
|
||||
@@ -633,6 +748,10 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
setSftpPendingUploadsForTab(prev => filterTabsMap(prev, validTerminalTabIds));
|
||||
}, [validTerminalTabIds]);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOrphanedAISessions(validTerminalTabIds);
|
||||
}, [validTerminalTabIds]);
|
||||
|
||||
const computeWorkspaceRects = useCallback((workspace?: Workspace, size?: { width: number; height: number }): Record<string, WorkspaceRect> => {
|
||||
if (!workspace) return {} as Record<string, WorkspaceRect>;
|
||||
const wTotal = size?.width || 1;
|
||||
@@ -876,6 +995,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
() => Array.from(sftpHostForTab.keys()),
|
||||
[sftpHostForTab],
|
||||
);
|
||||
const mountedAiTabIds = useMemo(
|
||||
() =>
|
||||
Array.from(sidePanelOpenTabs.entries())
|
||||
.filter(([, panel]) => panel === 'ai')
|
||||
.map(([tabId]) => tabId),
|
||||
[sidePanelOpenTabs],
|
||||
);
|
||||
|
||||
// Get the focused terminal's current working directory
|
||||
const getTerminalCwd = useCallback(async (): Promise<string | null> => {
|
||||
@@ -982,8 +1108,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const handleSnippetClickForFocusedSession = useCallback((command: string, noAutoRun?: boolean) => {
|
||||
const sessionId = activeWorkspace?.focusedSessionId ?? activeSession?.id;
|
||||
if (!sessionId) return;
|
||||
const payload = noAutoRun ? command : `${command}\r`;
|
||||
terminalBackend.writeToSession(sessionId, payload);
|
||||
const executor = snippetExecutorsRef.current.get(sessionId);
|
||||
if (executor) {
|
||||
executor(command, noAutoRun);
|
||||
return;
|
||||
}
|
||||
|
||||
let data = normalizeLineEndings(command);
|
||||
if (!noAutoRun) data = `${data}\r`;
|
||||
terminalBackend.writeToSession(sessionId, data);
|
||||
// Re-focus the terminal so the user can interact immediately
|
||||
const pane = document.querySelector(`[data-session-id="${sessionId}"]`);
|
||||
const textarea = pane?.querySelector('textarea.xterm-helper-textarea') as HTMLTextAreaElement | null;
|
||||
@@ -1058,38 +1191,66 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const focusedFontFamilyOverridden = hasHostFontFamilyOverride(focusedHost);
|
||||
const focusedFontSizeOverridden = hasHostFontSizeOverride(focusedHost);
|
||||
|
||||
// AI Chat state
|
||||
const aiState = useAIState();
|
||||
const { cleanupOrphanedSessions } = aiState;
|
||||
|
||||
useEffect(() => {
|
||||
const activeIds = new Set<string>();
|
||||
for (const s of sessions) activeIds.add(s.id);
|
||||
for (const w of workspaces) activeIds.add(w.id);
|
||||
cleanupOrphanedSessions(activeIds);
|
||||
}, [sessions, workspaces, cleanupOrphanedSessions]);
|
||||
|
||||
// Keep MCP/ACP approval IPC listener alive for the entire terminal lifecycle.
|
||||
// Must live here (TerminalLayer), NOT in AIChatSidePanel (unmounts on tab switch)
|
||||
// or ChatMessageList (unmounts on panel hide).
|
||||
// Must live here (TerminalLayer), not inside the AI panel subtree, so closing
|
||||
// or hiding the panel never tears down approval handling mid-execution.
|
||||
useEffect(() => {
|
||||
return setupMcpApprovalBridge();
|
||||
}, []);
|
||||
|
||||
// Build terminal session context for the AI chat panel
|
||||
const aiTerminalSessions = useMemo(() => {
|
||||
// Build per-tab AI contexts so hidden panels can stay mounted without
|
||||
// recomputing scope resolution from scratch on every tab switch.
|
||||
const aiContextsByTabId = useMemo(() => {
|
||||
const localOs = detectLocalOs(navigator.userAgent || navigator.platform);
|
||||
const sessionIds = activeWorkspace?.root
|
||||
? collectSessionIds(activeWorkspace.root)
|
||||
: activeSession ? [activeSession.id] : [];
|
||||
const sessionById = new Map(sessions.map((session) => [session.id, session]));
|
||||
const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace]));
|
||||
const tabIds = new Set<string>(mountedAiTabIds);
|
||||
if (activeTabId) tabIds.add(activeTabId);
|
||||
|
||||
const result = sessionIds.map(sid => {
|
||||
const s = sessions.find(s => s.id === sid);
|
||||
const host = s?.hostId ? hosts.find(h => h.id === s.hostId) : undefined;
|
||||
return buildAITerminalSessionInfo(s, host, localOs);
|
||||
});
|
||||
return result;
|
||||
}, [sessions, hosts, activeWorkspace, activeSession]);
|
||||
const contexts = new Map<string, AIPanelContext>();
|
||||
|
||||
for (const tabId of tabIds) {
|
||||
const workspace = workspaceById.get(tabId);
|
||||
if (workspace) {
|
||||
const sessionIds = collectSessionIds(workspace.root);
|
||||
contexts.set(tabId, {
|
||||
scopeType: 'workspace',
|
||||
scopeTargetId: workspace.id,
|
||||
scopeHostIds: sessionIds
|
||||
.map((sessionId) => sessionById.get(sessionId)?.hostId)
|
||||
.filter((hostId): hostId is string => !!hostId),
|
||||
scopeLabel: workspace.title,
|
||||
terminalSessions: sessionIds.map((sessionId) =>
|
||||
buildAITerminalSessionInfo(
|
||||
sessionById.get(sessionId),
|
||||
sessionHostsMap.get(sessionId),
|
||||
localOs,
|
||||
),
|
||||
),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const session = sessionById.get(tabId);
|
||||
if (!session) continue;
|
||||
|
||||
contexts.set(tabId, {
|
||||
scopeType: 'terminal',
|
||||
scopeTargetId: session.id,
|
||||
scopeHostIds: session.hostId ? [session.hostId] : [],
|
||||
scopeLabel: session.hostLabel ?? '',
|
||||
terminalSessions: [
|
||||
buildAITerminalSessionInfo(
|
||||
session,
|
||||
sessionHostsMap.get(session.id),
|
||||
localOs,
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return contexts;
|
||||
}, [sessions, workspaces, mountedAiTabIds, activeTabId, sessionHostsMap]);
|
||||
|
||||
const resolveAIExecutorContext = useCallback((scope: {
|
||||
type: 'terminal' | 'workspace';
|
||||
@@ -1302,14 +1463,15 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
|
||||
>
|
||||
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
|
||||
<AIStateProvider>
|
||||
<div
|
||||
ref={workspaceOuterRef}
|
||||
className="absolute inset-0 bg-background flex flex-col"
|
||||
style={{ display: isTerminalLayerVisible ? 'flex' : 'none', zIndex: isTerminalLayerVisible ? 10 : 0 }}
|
||||
>
|
||||
<div className={cn("flex-1 flex min-h-0 relative", sidePanelPosition === 'right' && "flex-row-reverse")}>
|
||||
{/* Side panel with tab header + content (SFTP / Scripts / Theme) */}
|
||||
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0) && (
|
||||
{(isSidePanelOpenForCurrentTab || mountedSftpTabIds.length > 0 || mountedAiTabIds.length > 0) && (
|
||||
<>
|
||||
<div
|
||||
style={{ width: isSidePanelOpenForCurrentTab ? sidePanelWidth : 0 }}
|
||||
@@ -1487,48 +1649,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Chat sub-panel */}
|
||||
{activeSidePanelTab === 'ai' && (
|
||||
<div className="absolute inset-0 z-10">
|
||||
<AIChatSidePanel
|
||||
sessions={aiState.sessions}
|
||||
activeSessionIdMap={aiState.activeSessionIdMap}
|
||||
setActiveSessionId={aiState.setActiveSessionId}
|
||||
createSession={aiState.createSession}
|
||||
deleteSession={aiState.deleteSession}
|
||||
updateSessionTitle={aiState.updateSessionTitle}
|
||||
updateSessionExternalSessionId={aiState.updateSessionExternalSessionId}
|
||||
addMessageToSession={aiState.addMessageToSession}
|
||||
updateLastMessage={aiState.updateLastMessage}
|
||||
updateMessageById={aiState.updateMessageById}
|
||||
providers={aiState.providers}
|
||||
activeProviderId={aiState.activeProviderId}
|
||||
activeModelId={aiState.activeModelId}
|
||||
defaultAgentId={aiState.defaultAgentId}
|
||||
externalAgents={aiState.externalAgents}
|
||||
setExternalAgents={aiState.setExternalAgents}
|
||||
agentModelMap={aiState.agentModelMap}
|
||||
setAgentModel={aiState.setAgentModel}
|
||||
globalPermissionMode={aiState.globalPermissionMode}
|
||||
setGlobalPermissionMode={aiState.setGlobalPermissionMode}
|
||||
commandBlocklist={aiState.commandBlocklist}
|
||||
maxIterations={aiState.maxIterations}
|
||||
webSearchConfig={aiState.webSearchConfig}
|
||||
scopeType={activeWorkspace ? 'workspace' : 'terminal'}
|
||||
scopeTargetId={activeWorkspace?.id ?? activeSession?.id}
|
||||
scopeHostIds={activeWorkspace?.root
|
||||
? collectSessionIds(activeWorkspace.root).map(sid => {
|
||||
const s = sessions.find(s => s.id === sid);
|
||||
return s?.hostId;
|
||||
}).filter((id): id is string => !!id)
|
||||
: activeSession?.hostId ? [activeSession.hostId] : []
|
||||
}
|
||||
scopeLabel={activeWorkspace?.title ?? activeSession?.hostLabel ?? ''}
|
||||
terminalSessions={aiTerminalSessions}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<AIChatPanelsHost
|
||||
mountedTabIds={mountedAiTabIds}
|
||||
activeTabId={activeTabId}
|
||||
activeSidePanelTab={activeSidePanelTab}
|
||||
contextsByTabId={aiContextsByTabId}
|
||||
resolveExecutorContext={resolveAIExecutorContext}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1678,6 +1806,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
onToggleComposeBar={inActiveWorkspace ? handleToggleWorkspaceComposeBar : undefined}
|
||||
isWorkspaceComposeBarOpen={inActiveWorkspace ? isComposeBarOpen : undefined}
|
||||
onBroadcastInput={inActiveWorkspace && activeWorkspace && isBroadcastEnabled?.(activeWorkspace.id) ? handleBroadcastInput : undefined}
|
||||
onSnippetExecutorChange={handleSnippetExecutorChange}
|
||||
sessionLog={sessionLogsEnabled && sessionLogsDir ? { enabled: true, directory: sessionLogsDir, format: sessionLogsFormat || 'txt' } : undefined}
|
||||
/>
|
||||
</div>
|
||||
@@ -1739,25 +1868,26 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Global compose bar for workspace mode */}
|
||||
{activeWorkspace && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
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();
|
||||
});
|
||||
}
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{activeWorkspace && isComposeBarOpen && (
|
||||
<TerminalComposeBar
|
||||
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();
|
||||
});
|
||||
}
|
||||
}}
|
||||
isBroadcastEnabled={isBroadcastEnabled?.(activeWorkspace.id)}
|
||||
themeColors={composeBarThemeColors}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</AIStateProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -116,7 +116,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys } = useVaultState();
|
||||
const { hosts, keys, identities } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
@@ -151,11 +151,6 @@ const TrayPanelContent: React.FC = () => {
|
||||
return () => unsubscribe?.();
|
||||
}, [onTrayPanelRefresh]);
|
||||
|
||||
const keysForPf = useMemo(
|
||||
() => keys.map((k) => ({ id: k.id, privateKey: k.privateKey, passphrase: k.passphrase })),
|
||||
[keys],
|
||||
);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
void hideTrayPanel();
|
||||
}, [hideTrayPanel]);
|
||||
@@ -339,7 +334,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
void startTunnel(rule, host, keysForPf, (status, error) => {
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
|
||||
@@ -1213,6 +1213,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
return new Set(managedSources.map(s => s.groupName));
|
||||
}, [managedSources]);
|
||||
|
||||
const isHostsSectionActive = currentSection === "hosts";
|
||||
|
||||
const moveHostToGroup = (hostId: string, groupPath: string | null) => {
|
||||
const targetGroup = groupPath || "";
|
||||
// Find the most specific (deepest) managed source that matches the target group
|
||||
@@ -1440,463 +1442,454 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
|
||||
{/* Main Area */}
|
||||
<div className="flex-1 flex flex-col min-h-0 relative">
|
||||
{currentSection === "hosts" && (
|
||||
<header className="border-b border-border/50 bg-secondary/80 backdrop-blur app-drag">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
<div className="relative flex-1 app-no-drag">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("vault.hosts.search.placeholder")}
|
||||
className={cn(
|
||||
"pl-9 h-10 bg-secondary border-border/60 text-sm",
|
||||
isSearchQuickConnect &&
|
||||
"border-primary/50 ring-1 ring-primary/20",
|
||||
)}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
{isSearchQuickConnect && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Zap size={14} className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={isSearchQuickConnect ? "default" : "secondary"}
|
||||
<header
|
||||
className={cn(
|
||||
"border-b border-border/50 bg-secondary/80 backdrop-blur app-drag",
|
||||
!isHostsSectionActive && "hidden",
|
||||
)}
|
||||
>
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
<div className="relative flex-1 app-no-drag">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t("vault.hosts.search.placeholder")}
|
||||
className={cn(
|
||||
"h-10 px-4 app-no-drag",
|
||||
!isSearchQuickConnect &&
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
"pl-9 h-10 bg-secondary border-border/60 text-sm",
|
||||
isSearchQuickConnect &&
|
||||
"border-primary/50 ring-1 ring-primary/20",
|
||||
)}
|
||||
onClick={handleConnectClick}
|
||||
>
|
||||
{t("vault.hosts.connect")}
|
||||
</Button>
|
||||
{/* View mode, tag filter, and sort controls */}
|
||||
<div className="flex items-center gap-1 app-no-drag">
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 app-no-drag">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : viewMode === "list" ? (
|
||||
<List size={16} />
|
||||
) : (
|
||||
<Network size={16} />
|
||||
)}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid size={14} /> {t("vault.view.grid")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<List size={14} /> {t("vault.view.list")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "tree" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("tree")}
|
||||
>
|
||||
<Network size={14} /> {t("vault.view.tree")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<TagFilterDropdown
|
||||
allTags={allTags}
|
||||
selectedTags={selectedTags}
|
||||
onChange={setSelectedTags}
|
||||
onEditTag={handleEditTag}
|
||||
onDeleteTag={handleDeleteTag}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Button
|
||||
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearHostSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
title={t("vault.hosts.multiSelect")}
|
||||
>
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{/* New Host split button */}
|
||||
<div className="flex items-center app-no-drag">
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
|
||||
onClick={handleNewHost}
|
||||
>
|
||||
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setTargetParentPath(selectedGroupPath);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setIsImportOpen(true);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> {t("vault.hosts.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleExportHosts}
|
||||
>
|
||||
<Download size={14} /> {t("vault.hosts.export")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={handleSearchKeyDown}
|
||||
/>
|
||||
{isSearchQuickConnect && (
|
||||
<div className="absolute right-3 top-1/2 -translate-y-1/2">
|
||||
<Zap size={14} className="text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant={isSearchQuickConnect ? "default" : "secondary"}
|
||||
className={cn(
|
||||
"h-10 px-4 app-no-drag",
|
||||
!isSearchQuickConnect &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
)}
|
||||
onClick={handleConnectClick}
|
||||
>
|
||||
{t("vault.hosts.connect")}
|
||||
</Button>
|
||||
{/* View mode, tag filter, and sort controls */}
|
||||
<div className="flex items-center gap-1 app-no-drag">
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-10 w-10 app-no-drag">
|
||||
{viewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : viewMode === "list" ? (
|
||||
<List size={16} />
|
||||
) : (
|
||||
<Network size={16} />
|
||||
)}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid size={14} /> {t("vault.view.grid")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<List size={14} /> {t("vault.view.list")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === "tree" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("tree")}
|
||||
>
|
||||
<Network size={14} /> {t("vault.view.tree")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
<TagFilterDropdown
|
||||
allTags={allTags}
|
||||
selectedTags={selectedTags}
|
||||
onChange={setSelectedTags}
|
||||
onEditTag={handleEditTag}
|
||||
onDeleteTag={handleDeleteTag}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<SortDropdown
|
||||
value={sortMode}
|
||||
onChange={setSortMode}
|
||||
className="h-10 w-10"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-10 px-3 app-no-drag",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
)}
|
||||
onClick={onCreateLocalTerminal}
|
||||
variant={isMultiSelectMode ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
className="h-10 w-10"
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode) {
|
||||
clearHostSelection();
|
||||
} else {
|
||||
setIsMultiSelectMode(true);
|
||||
}
|
||||
}}
|
||||
title={t("vault.hosts.multiSelect")}
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"h-10 px-3 app-no-drag",
|
||||
currentSection === "hosts" &&
|
||||
"bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40",
|
||||
)}
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
<CheckSquare size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
)}
|
||||
{/* New Host split button */}
|
||||
<div className="flex items-center app-no-drag">
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-3 rounded-r-none bg-transparent hover:bg-white/10 shadow-none app-no-drag"
|
||||
onClick={handleNewHost}
|
||||
>
|
||||
<Plus size={14} className="mr-2" /> {t("vault.hosts.newHost")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
className="h-10 px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none app-no-drag"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44" align="end" alignToParent>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setTargetParentPath(selectedGroupPath);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderTree size={14} /> {t("vault.hosts.newGroup")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={() => {
|
||||
setIsImportOpen(true);
|
||||
}}
|
||||
>
|
||||
<Upload size={14} /> {t("vault.hosts.import")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="w-full justify-start gap-2"
|
||||
onClick={handleExportHosts}
|
||||
>
|
||||
<Download size={14} /> {t("vault.hosts.export")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={onCreateLocalTerminal}
|
||||
>
|
||||
<TerminalSquare size={14} className="mr-2" /> {t("common.terminal")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-10 px-3 app-no-drag bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
onClick={() => setIsSerialModalOpen(true)}
|
||||
>
|
||||
<Usb size={14} className="mr-2" /> {t("serial.button")}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{currentSection !== "port" &&
|
||||
currentSection !== "keys" &&
|
||||
currentSection !== "knownhosts" &&
|
||||
currentSection !== "snippets" &&
|
||||
currentSection !== "logs" && (
|
||||
<div className="flex-1 overflow-auto px-4 py-4 space-y-6">
|
||||
{currentSection === "hosts" && (
|
||||
<>
|
||||
<section className="space-y-2">
|
||||
{viewMode !== "tree" && (
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
>
|
||||
{t("vault.hosts.allHosts")}
|
||||
</button>
|
||||
{selectedGroupPath &&
|
||||
selectedGroupPath
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part, idx, arr) => {
|
||||
const crumbPath = arr.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === arr.length - 1;
|
||||
return (
|
||||
<span
|
||||
key={crumbPath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<button
|
||||
className={cn(
|
||||
isLast
|
||||
? "text-foreground font-semibold"
|
||||
: "text-primary hover:underline",
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedGroupPath(crumbPath)
|
||||
}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== "tree" && displayedGroups.length > 0 && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{t("vault.groups.title")}
|
||||
</h3>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vault.groups.total", { count: displayedGroups.length })}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{viewMode !== "tree" && (
|
||||
<div
|
||||
className={cn(
|
||||
displayedGroups.length === 0 ? "hidden" : "",
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
|
||||
if (groupPath && selectedGroupPath !== null)
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
{/* Keep hosts mounted so switching sections does not reset scroll or remount the list. */}
|
||||
<div
|
||||
className={cn(
|
||||
"flex-1 overflow-auto px-4 py-4 space-y-6",
|
||||
!isHostsSectionActive && "hidden",
|
||||
)}
|
||||
>
|
||||
<section className="space-y-2">
|
||||
{viewMode !== "tree" && (
|
||||
<div className="flex items-center gap-2 text-sm font-semibold">
|
||||
<button
|
||||
className="text-primary hover:underline"
|
||||
onClick={() => setSelectedGroupPath(null)}
|
||||
>
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
e.dataTransfer.setData("group-path", node.path)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
setSelectedGroupPath(node.path)
|
||||
}
|
||||
onClick={() => setSelectedGroupPath(node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId =
|
||||
e.dataTransfer.getData("host-id");
|
||||
const groupPath =
|
||||
e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
{t("vault.hosts.allHosts")}
|
||||
</button>
|
||||
{selectedGroupPath &&
|
||||
selectedGroupPath
|
||||
.split("/")
|
||||
.filter(Boolean)
|
||||
.map((part, idx, arr) => {
|
||||
const crumbPath = arr.slice(0, idx + 1).join("/");
|
||||
const isLast = idx === arr.length - 1;
|
||||
return (
|
||||
<span
|
||||
key={crumbPath}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<FolderTree size={20} />
|
||||
<span className="text-muted-foreground">›</span>
|
||||
<button
|
||||
className={cn(
|
||||
isLast
|
||||
? "text-foreground font-semibold"
|
||||
: "text-primary hover:underline",
|
||||
)}
|
||||
onClick={() =>
|
||||
setSelectedGroupPath(crumbPath)
|
||||
}
|
||||
>
|
||||
{part}
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== "tree" && displayedGroups.length > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{t("vault.groups.title")}
|
||||
</h3>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("vault.groups.total", { count: displayedGroups.length })}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{viewMode !== "tree" && (
|
||||
<div
|
||||
className={cn(
|
||||
displayedGroups.length === 0 ? "hidden" : "",
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, selectedGroupPath);
|
||||
if (groupPath && selectedGroupPath !== null)
|
||||
moveGroup(groupPath, selectedGroupPath);
|
||||
}}
|
||||
>
|
||||
{displayedGroups.map((node) => (
|
||||
<ContextMenu key={node.path}>
|
||||
<ContextMenuTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"group cursor-pointer",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
draggable
|
||||
onDragStart={(e) =>
|
||||
e.dataTransfer.setData("group-path", node.path)
|
||||
}
|
||||
onDoubleClick={() =>
|
||||
setSelectedGroupPath(node.path)
|
||||
}
|
||||
onClick={() => setSelectedGroupPath(node.path)}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const hostId =
|
||||
e.dataTransfer.getData("host-id");
|
||||
const groupPath =
|
||||
e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath) moveGroup(groupPath, node.path);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<FolderTree size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate flex items-center gap-2">
|
||||
{node.name}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate flex items-center gap-2">
|
||||
{node.name}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
|
||||
<FileSymlink size={10} />
|
||||
Managed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{t("vault.groups.hostsCount", { count: node.totalHostCount ?? node.hosts.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setTargetParentPath(node.path);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameTargetPath(node.path);
|
||||
setRenameGroupName(node.name);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetPath(node.path);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setTargetParentPath(node.path);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("vault.groups.newSubgroup")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
setRenameTargetPath(node.path);
|
||||
setRenameGroupName(node.name);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit2 className="mr-2 h-4 w-4" /> {t("vault.groups.rename")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetPath(node.path);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> {t("vault.groups.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{t("vault.nav.hosts")}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
</div>
|
||||
<section className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
{t("vault.nav.hosts")}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{t("vault.hosts.header.entries", { count: viewMode === "tree" ? treeViewHosts.length : displayedHosts.length })}
|
||||
</span>
|
||||
<div className="bg-secondary/80 border border-border/70 rounded-md px-2 py-1 text-[11px]">
|
||||
{t("vault.hosts.header.live", { count: sessions.length })}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isMultiSelectMode && (
|
||||
<div className="flex items-center gap-2 p-2 bg-secondary/60 rounded-lg border border-border/40">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t("vault.hosts.selected", { count: selectedHostIds.size })}
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allIds = new Set(displayedHosts.map(h => h.id));
|
||||
setSelectedHostIds(allIds);
|
||||
}}
|
||||
>
|
||||
{t("vault.hosts.selectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
{t("vault.hosts.deselectAll")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
disabled={selectedHostIds.size === 0}
|
||||
onClick={deleteSelectedHosts}
|
||||
>
|
||||
<Trash2 size={14} className="mr-1" />
|
||||
{t("vault.hosts.deleteSelected", { count: selectedHostIds.size })}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={clearHostSelection}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
hosts={treeViewHosts} // Use filtered and sorted hosts for tree view
|
||||
sortMode={sortMode}
|
||||
expandedPaths={treeExpandedState.expandedPaths}
|
||||
onTogglePath={treeExpandedState.togglePath}
|
||||
onExpandAll={treeExpandedState.expandAll}
|
||||
onCollapseAll={treeExpandedState.collapseAll}
|
||||
onConnect={handleHostConnect}
|
||||
onEditHost={handleEditHost}
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onDeleteHost={(host) => onDeleteHost(host.id)}
|
||||
onCopyCredentials={handleCopyCredentials}
|
||||
onNewHost={(groupPath) => {
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(groupPath || null);
|
||||
setIsHostPanelOpen(true);
|
||||
}}
|
||||
onNewGroup={(parentPath) => {
|
||||
setTargetParentPath(parentPath || null);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
onEditGroup={(groupPath) => {
|
||||
setRenameTargetPath(groupPath);
|
||||
const groupName = groupPath.split('/').pop() || '';
|
||||
setRenameGroupName(groupName);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
onDeleteGroup={(groupPath) => {
|
||||
setDeleteTargetPath(groupPath);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
{viewMode === "tree" ? (
|
||||
<HostTreeView
|
||||
groupTree={treeViewGroupTree}
|
||||
hosts={treeViewHosts} // Use filtered and sorted hosts for tree view
|
||||
sortMode={sortMode}
|
||||
expandedPaths={treeExpandedState.expandedPaths}
|
||||
onTogglePath={treeExpandedState.togglePath}
|
||||
onExpandAll={treeExpandedState.expandAll}
|
||||
onCollapseAll={treeExpandedState.collapseAll}
|
||||
onConnect={handleHostConnect}
|
||||
onEditHost={handleEditHost}
|
||||
onDuplicateHost={handleDuplicateHost}
|
||||
onDeleteHost={(host) => onDeleteHost(host.id)}
|
||||
onCopyCredentials={handleCopyCredentials}
|
||||
onNewHost={(groupPath) => {
|
||||
setEditingHost(null);
|
||||
setNewHostGroupPath(groupPath || null);
|
||||
setIsHostPanelOpen(true);
|
||||
}}
|
||||
onNewGroup={(parentPath) => {
|
||||
setTargetParentPath(parentPath || null);
|
||||
setNewFolderName("");
|
||||
setIsNewFolderOpen(true);
|
||||
}}
|
||||
onEditGroup={(groupPath) => {
|
||||
setRenameTargetPath(groupPath);
|
||||
const groupName = groupPath.split('/').pop() || '';
|
||||
setRenameGroupName(groupName);
|
||||
setRenameGroupError(null);
|
||||
setIsRenameGroupOpen(true);
|
||||
}}
|
||||
onDeleteGroup={(groupPath) => {
|
||||
setDeleteTargetPath(groupPath);
|
||||
setIsDeleteGroupOpen(true);
|
||||
}}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
{groupedDisplayHosts.map((group) => (
|
||||
<div key={group.name || "__ungrouped__"}>
|
||||
<div className="flex items-center gap-2 mb-3 pb-2 border-b border-border/40">
|
||||
@@ -2045,16 +2038,16 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0",
|
||||
)}
|
||||
>
|
||||
{displayedHosts.map((host) => {
|
||||
const safeHost = sanitizeHost(host);
|
||||
const effectiveDistro = getEffectiveHostDistro(safeHost);
|
||||
const distroBadge = {
|
||||
@@ -2167,27 +2160,24 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
})}
|
||||
{displayedHosts.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
})}
|
||||
{displayedHosts.length === 0 && (
|
||||
<div className="col-span-full flex flex-col items-center justify-center py-24 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{currentSection === "snippets" && (
|
||||
<SnippetsManager
|
||||
|
||||
@@ -75,17 +75,18 @@ export const ToolCall = ({
|
||||
: approvalStatus === 'denied'
|
||||
? 'border-red-500/20 bg-red-500/[0.03]'
|
||||
: 'border-border/25 bg-muted/10';
|
||||
const statusIconClass = 'shrink-0';
|
||||
|
||||
const statusIcon = approvalStatus === 'pending' ? (
|
||||
<ShieldAlert size={12} className="text-yellow-500/70 shrink-0" />
|
||||
<ShieldAlert size={12} className={cn('text-yellow-500/70', statusIconClass)} />
|
||||
) : isLoading ? (
|
||||
<Loader2 size={12} className="animate-spin text-blue-400/70" />
|
||||
<Loader2 size={12} className={cn('animate-spin text-blue-400/70', statusIconClass)} />
|
||||
) : isInterrupted ? (
|
||||
<Slash size={12} className="text-muted-foreground/55" />
|
||||
<Slash size={12} className={cn('text-muted-foreground/55', statusIconClass)} />
|
||||
) : isError ? (
|
||||
<XCircle size={12} className="text-red-400/70" />
|
||||
<XCircle size={12} className={cn('text-red-400/70', statusIconClass)} />
|
||||
) : result !== undefined ? (
|
||||
<CheckCircle2 size={12} className="text-green-400/70" />
|
||||
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
|
||||
) : null;
|
||||
|
||||
return (
|
||||
@@ -105,7 +106,13 @@ export const ToolCall = ({
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
{name === 'terminal_execute' && args?.command ? (
|
||||
<span className="font-mono text-muted-foreground/70 truncate" title={String(args.command)}>
|
||||
<span className="text-muted-foreground/40">$ </span>{String(args.command)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
|
||||
@@ -144,12 +144,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
.flatMap((m) => m.toolResults?.map((tr) => tr.toolCallId) ?? []),
|
||||
);
|
||||
|
||||
// Build a map from toolCallId → toolName for display
|
||||
// Build maps from toolCallId → toolName / toolArgs for display
|
||||
const toolCallNames = new Map<string, string>();
|
||||
const toolCallArgs = new Map<string, Record<string, unknown>>();
|
||||
for (const m of visibleMessages) {
|
||||
if (m.role === 'assistant' && m.toolCalls) {
|
||||
for (const tc of m.toolCalls) {
|
||||
toolCallNames.set(tc.id, tc.name);
|
||||
if (tc.arguments) toolCallArgs.set(tc.id, tc.arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -178,6 +180,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({ messages, isStreaming
|
||||
<ToolCall
|
||||
key={tr.toolCallId}
|
||||
name={toolCallNames.get(tr.toolCallId) || tr.toolCallId}
|
||||
args={toolCallArgs.get(tr.toolCallId)}
|
||||
result={tr.content}
|
||||
isError={tr.isError}
|
||||
/>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Settings System Tab - System information, temp file management, session logs, and global hotkey
|
||||
*/
|
||||
import { Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { AlertTriangle, ChevronDown, ChevronRight, Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||
@@ -13,6 +13,31 @@ import { Button } from "../../ui/button";
|
||||
import { Toggle, Select, SettingRow } from "../settings-ui";
|
||||
import { cn } from "../../../lib/utils";
|
||||
|
||||
interface CrashLogFile {
|
||||
fileName: string;
|
||||
date: string;
|
||||
size: number;
|
||||
entryCount: number;
|
||||
}
|
||||
|
||||
interface CrashLogEntry {
|
||||
timestamp: string;
|
||||
source: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
errorMeta?: Record<string, unknown>;
|
||||
extra?: Record<string, unknown>;
|
||||
pid?: number;
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
version?: string;
|
||||
electronVersion?: string;
|
||||
osVersion?: string;
|
||||
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
|
||||
activeSessionCount?: number;
|
||||
uptimeSeconds?: number;
|
||||
}
|
||||
|
||||
interface TempDirInfo {
|
||||
path: string;
|
||||
fileCount: number;
|
||||
@@ -98,6 +123,12 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [hotkeyError, setHotkeyError] = useState<string | null>(null);
|
||||
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
|
||||
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
|
||||
const [crashLogs, setCrashLogs] = useState<CrashLogFile[]>([]);
|
||||
const [isLoadingCrashLogs, setIsLoadingCrashLogs] = useState(false);
|
||||
const [expandedLog, setExpandedLog] = useState<string | null>(null);
|
||||
const [logEntries, setLogEntries] = useState<CrashLogEntry[]>([]);
|
||||
const [isClearingCrashLogs, setIsClearingCrashLogs] = useState(false);
|
||||
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
|
||||
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
|
||||
@@ -144,6 +175,73 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
void loadCredentialProtectionStatus();
|
||||
}, [loadCredentialProtectionStatus]);
|
||||
|
||||
const loadCrashLogs = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getCrashLogs) return;
|
||||
setIsLoadingCrashLogs(true);
|
||||
try {
|
||||
const logs = await bridge.getCrashLogs();
|
||||
setCrashLogs(logs);
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to load crash logs:", err);
|
||||
} finally {
|
||||
setIsLoadingCrashLogs(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadCrashLogs();
|
||||
}, [loadCrashLogs]);
|
||||
|
||||
const expandRequestRef = React.useRef(0);
|
||||
const handleExpandCrashLog = useCallback(async (fileName: string) => {
|
||||
if (expandedLog === fileName) {
|
||||
setExpandedLog(null);
|
||||
setLogEntries([]);
|
||||
return;
|
||||
}
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readCrashLog) return;
|
||||
const requestId = ++expandRequestRef.current;
|
||||
// Optimistically show expanded state while loading
|
||||
setExpandedLog(fileName);
|
||||
setLogEntries([]);
|
||||
try {
|
||||
const entries = await bridge.readCrashLog(fileName);
|
||||
// Discard if user clicked a different file while awaiting
|
||||
if (expandRequestRef.current !== requestId) return;
|
||||
setLogEntries(entries);
|
||||
} catch (err) {
|
||||
if (expandRequestRef.current !== requestId) return;
|
||||
console.error("[SettingsSystemTab] Failed to read crash log:", err);
|
||||
}
|
||||
}, [expandedLog]);
|
||||
|
||||
const handleClearCrashLogs = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearCrashLogs) return;
|
||||
setIsClearingCrashLogs(true);
|
||||
setCrashLogClearResult(null);
|
||||
try {
|
||||
const result = await bridge.clearCrashLogs();
|
||||
setCrashLogClearResult(result);
|
||||
setExpandedLog(null);
|
||||
setLogEntries([]);
|
||||
// Reload the list so partial failures still show remaining files
|
||||
await loadCrashLogs();
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to clear crash logs:", err);
|
||||
} finally {
|
||||
setIsClearingCrashLogs(false);
|
||||
}
|
||||
}, [loadCrashLogs]);
|
||||
|
||||
const handleOpenCrashLogsDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openCrashLogsDir) return;
|
||||
await bridge.openCrashLogsDir();
|
||||
}, []);
|
||||
|
||||
const handleClearTempFiles = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.clearTempDir) return;
|
||||
@@ -449,6 +547,148 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Crash Logs Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t("settings.system.crashLogs.title")}</h3>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.crashLogs.description")}
|
||||
</p>
|
||||
|
||||
{crashLogs.length === 0 && !isLoadingCrashLogs && (
|
||||
<p className="text-sm text-muted-foreground italic">
|
||||
{t("settings.system.crashLogs.noLogs")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{crashLogs.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{crashLogs.map((log) => (
|
||||
<div key={log.fileName} className="border border-border/60 rounded-md overflow-hidden">
|
||||
<button
|
||||
onClick={() => handleExpandCrashLog(log.fileName)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 text-sm hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{expandedLog === log.fileName ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
<span className="font-mono">{log.date}</span>
|
||||
<span className="text-muted-foreground">
|
||||
({t("settings.system.crashLogs.entries").replace("{count}", String(log.entryCount))})
|
||||
</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">{formatBytes(log.size)}</span>
|
||||
</button>
|
||||
|
||||
{expandedLog === log.fileName && logEntries.length > 0 && (
|
||||
<div className="border-t border-border/60 max-h-64 overflow-y-auto">
|
||||
{logEntries.map((entry, idx) => (
|
||||
<div key={idx} className="px-3 py-2 text-xs border-b border-border/30 last:border-b-0 space-y-1">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="font-mono text-muted-foreground">
|
||||
{new Date(entry.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
<span className="px-1.5 py-0.5 rounded bg-destructive/10 text-destructive font-medium">
|
||||
{entry.source}
|
||||
</span>
|
||||
</div>
|
||||
<p className="font-mono break-all">{entry.message}</p>
|
||||
{entry.errorMeta && Object.keys(entry.errorMeta).length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{Object.entries(entry.errorMeta).map(([k, v]) => (
|
||||
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
|
||||
{k}={String(v)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{entry.extra && Object.keys(entry.extra).length > 0 && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{Object.entries(entry.extra).map(([k, v]) => (
|
||||
<span key={k} className="px-1.5 py-0.5 rounded bg-muted font-mono">
|
||||
{k}={String(v)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{(() => {
|
||||
const parts: string[] = [];
|
||||
if (entry.version) parts.push(`v${entry.version}`);
|
||||
if (entry.electronVersion) parts.push(`Electron ${entry.electronVersion}`);
|
||||
if (entry.platform) parts.push(`${entry.platform}/${entry.arch}`);
|
||||
if (entry.osVersion) parts.push(`OS ${entry.osVersion}`);
|
||||
if (entry.pid) parts.push(`PID ${entry.pid}`);
|
||||
if (entry.activeSessionCount != null && entry.activeSessionCount >= 0) parts.push(`Sessions: ${entry.activeSessionCount}`);
|
||||
if (entry.memoryMB) parts.push(`RAM: ${entry.memoryMB.rss}MB`);
|
||||
if (entry.uptimeSeconds != null) parts.push(`Uptime: ${entry.uptimeSeconds}s`);
|
||||
const text = parts.join(' ');
|
||||
return text ? (
|
||||
<div className="text-muted-foreground truncate" title={text}>
|
||||
{text}
|
||||
</div>
|
||||
) : null;
|
||||
})()}
|
||||
{entry.stack && (
|
||||
<pre className="mt-1 p-2 bg-muted rounded text-[11px] leading-relaxed overflow-x-auto whitespace-pre-wrap break-all text-muted-foreground">
|
||||
{entry.stack}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={loadCrashLogs}
|
||||
disabled={isLoadingCrashLogs}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<RefreshCw size={14} className={isLoadingCrashLogs ? "animate-spin" : ""} />
|
||||
{t("settings.system.refresh")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearCrashLogs}
|
||||
disabled={isClearingCrashLogs || crashLogs.length === 0}
|
||||
className="gap-1.5 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("settings.system.crashLogs.clear")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleOpenCrashLogsDir}
|
||||
title={t("settings.system.openFolder")}
|
||||
>
|
||||
<FolderOpen size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{crashLogClearResult && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.crashLogs.cleared").replace("{count}", String(crashLogClearResult.deletedCount))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.system.crashLogs.hint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Temp Directory Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -616,6 +616,13 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={terminalSettings.scrollOnPaste} onChange={(v) => updateTerminalSetting("scrollOnPaste", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.smoothScrolling")}
|
||||
description={t("settings.terminal.behavior.smoothScrolling.desc")}
|
||||
>
|
||||
<Toggle checked={terminalSettings.smoothScrolling} onChange={(v) => updateTerminalSetting("smoothScrolling", v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.linkModifier")}
|
||||
description={t("settings.terminal.behavior.linkModifier.desc")}
|
||||
|
||||
@@ -88,7 +88,7 @@ const SftpFileRowInner: React.FC<SftpFileRowProps> = ({
|
||||
<Link size={8} className="absolute -bottom-0.5 -right-0.5 text-muted-foreground" aria-hidden="true" />
|
||||
)}
|
||||
</div>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")}>
|
||||
<span className={cn("truncate", entry.type === 'symlink' && "italic pr-1")} title={entry.name}>
|
||||
{entry.name}
|
||||
{entry.type === 'symlink' && <span className="sr-only"> (symbolic link)</span>}
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback, useMemo } from "react";
|
||||
import { AlertCircle, ArrowDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { AlertCircle, ArrowDown, ChevronDown, Copy, Download, Edit2, ExternalLink, FilePlus, Folder, FolderPlus, Loader2, Pencil, RefreshCw, Shield, Trash2 } from "lucide-react";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
ContextMenu,
|
||||
@@ -58,6 +58,46 @@ interface SftpPaneFileListProps {
|
||||
visibleRows: { entry: SftpFileEntry; index: number; top: number }[];
|
||||
}
|
||||
|
||||
const SftpErrorWithLogs: React.FC<{
|
||||
error: string;
|
||||
connectionLogs: string[];
|
||||
onRetry: () => void;
|
||||
t: (key: string) => string;
|
||||
}> = ({ error, connectionLogs, onRetry, t }) => {
|
||||
const [showLogs, setShowLogs] = useState(false);
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm text-center px-4">{t(error)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={onRetry}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
{connectionLogs.length > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => setShowLogs(!showLogs)}
|
||||
>
|
||||
<ChevronDown size={14} className={`mr-1 transition-transform ${showLogs ? 'rotate-180' : ''}`} />
|
||||
{showLogs ? "Hide logs" : "Show logs"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{showLogs && connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-1 p-2 rounded-md bg-secondary/50 border border-border/60 space-y-0.5 max-h-40 overflow-y-auto">
|
||||
{connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate font-mono">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
t,
|
||||
pane,
|
||||
@@ -340,17 +380,25 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
onScroll={handleFileListScroll}
|
||||
>
|
||||
{pane.loading && sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
{pane.connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
|
||||
{pane.connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : pane.error && !pane.reconnecting ? (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-2 text-destructive">
|
||||
<AlertCircle size={24} />
|
||||
<span className="text-sm">{t(pane.error)}</span>
|
||||
<Button variant="outline" size="sm" onClick={onRefresh}>
|
||||
{t("sftp.retry")}
|
||||
</Button>
|
||||
</div>
|
||||
<SftpErrorWithLogs
|
||||
error={pane.error}
|
||||
connectionLogs={pane.connectionLogs}
|
||||
onRetry={onRefresh}
|
||||
t={t}
|
||||
/>
|
||||
) : sortedDisplayFiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
|
||||
<Folder size={32} className="mb-2 opacity-50" />
|
||||
@@ -410,10 +458,19 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = ({
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Loading overlay - covers entire pane when navigating directories */}
|
||||
{/* Loading overlay - covers entire pane when navigating or reconnecting */}
|
||||
{pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-background/40 backdrop-blur-[1px] z-10">
|
||||
<Loader2 size={24} className="animate-spin text-muted-foreground" />
|
||||
{pane.connectionLogs.length > 0 && (
|
||||
<div className="w-full max-w-sm mt-2 space-y-0.5 px-4">
|
||||
{pane.connectionLogs.map((log, i) => (
|
||||
<div key={i} className="text-[11px] text-muted-foreground truncate">
|
||||
{log}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Host, SSHKey } from '../../types';
|
||||
import { formatHostPort } from '../../domain/host';
|
||||
import { DistroAvatar } from '../DistroAvatar';
|
||||
import { Button } from '../ui/button';
|
||||
import { TerminalAuthDialog, TerminalAuthDialogProps } from './TerminalAuthDialog';
|
||||
@@ -85,12 +86,12 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
)}>
|
||||
<div className="w-[560px] max-w-[90vw] bg-background/95 border border-border/60 rounded-xl shadow-xl p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg" />
|
||||
<div>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<DistroAvatar host={host} fallback={host.label.slice(0, 2).toUpperCase()} className="h-10 w-10 rounded-lg shrink-0" />
|
||||
<div className="min-w-0">
|
||||
{chainProgress ? (
|
||||
<>
|
||||
<div className="text-sm font-semibold">
|
||||
<div className="text-sm font-semibold truncate">
|
||||
<span className="text-muted-foreground">
|
||||
{t('terminal.connection.chainOf', {
|
||||
current: chainProgress.currentHop,
|
||||
@@ -100,21 +101,21 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
</span>
|
||||
<span>{chainProgress.currentHostLabel}</span>
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate">
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-lg font-semibold">{host.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono">
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? `${host.hostname}:${protocolInfo.port}` : host.hostname}
|
||||
<div className="text-lg font-semibold truncate">{host.label}</div>
|
||||
<div className="text-[11px] text-muted-foreground font-mono truncate">
|
||||
{t(protocolInfo.i18nKey)} {protocolInfo.showPort ? formatHostPort(host.hostname, protocolInfo.port) : host.hostname}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 shrink-0 ml-3">
|
||||
{!needsAuth && (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -48,7 +48,7 @@ interface UseServerStatsOptions {
|
||||
sessionId: string;
|
||||
enabled: boolean; // Whether stats collection is enabled (from settings)
|
||||
refreshInterval: number; // Refresh interval in seconds
|
||||
isLinux: boolean; // Only collect stats for Linux servers
|
||||
isSupportedOs: boolean; // Only collect stats for Linux/macOS servers
|
||||
isConnected: boolean; // Only collect when connected
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ export function useServerStats({
|
||||
sessionId,
|
||||
enabled,
|
||||
refreshInterval,
|
||||
isLinux,
|
||||
isSupportedOs,
|
||||
isConnected,
|
||||
}: UseServerStatsOptions) {
|
||||
const [stats, setStats] = useState<ServerStats>({
|
||||
@@ -86,7 +86,7 @@ export function useServerStats({
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
const fetchStats = useCallback(async () => {
|
||||
if (!enabled || !isLinux || !isConnected || !sessionId) {
|
||||
if (!enabled || !isSupportedOs || !isConnected || !sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ export function useServerStats({
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
}, [sessionId, enabled, isLinux, isConnected]);
|
||||
}, [sessionId, enabled, isSupportedOs, isConnected]);
|
||||
|
||||
// Initial fetch and periodic refresh
|
||||
useEffect(() => {
|
||||
@@ -149,8 +149,7 @@ export function useServerStats({
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// Don't run if not enabled or not a Linux system
|
||||
if (!enabled || !isLinux || !isConnected) {
|
||||
if (!enabled || !isSupportedOs || !isConnected) {
|
||||
// Reset stats when disabled or not connected
|
||||
setStats({
|
||||
cpu: null,
|
||||
@@ -193,7 +192,7 @@ export function useServerStats({
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [enabled, isLinux, isConnected, refreshInterval, fetchStats]);
|
||||
}, [enabled, isSupportedOs, isConnected, refreshInterval, fetchStats]);
|
||||
|
||||
// Manual refresh function
|
||||
const refresh = useCallback(() => {
|
||||
|
||||
@@ -10,6 +10,19 @@ interface CompiledRule {
|
||||
color: string;
|
||||
}
|
||||
|
||||
interface CachedDecorationRange {
|
||||
x: number;
|
||||
width: number;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** Shared empty array for non-matching lines to avoid per-call allocations. */
|
||||
const EMPTY_RANGES: readonly CachedDecorationRange[] = Object.freeze([]);
|
||||
|
||||
/** ASCII-only test — when true, string indices equal cell columns. */
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const RE_ASCII_ONLY = /^[\x00-\x7f]*$/;
|
||||
|
||||
/**
|
||||
* Manages terminal decorations for keyword highlighting.
|
||||
* Uses xterm.js Decoration API to overlay styles without modifying the data stream.
|
||||
@@ -20,6 +33,9 @@ export class KeywordHighlighter implements IDisposable {
|
||||
private compiledRules: CompiledRule[] = [];
|
||||
private decorations: { decoration: IDecoration; marker: IMarker }[] = [];
|
||||
private debounceTimer: NodeJS.Timeout | null = null;
|
||||
private animationFrameId: number | null = null;
|
||||
private lastRefreshTime: number = 0;
|
||||
private matchCache = new Map<string, CachedDecorationRange[]>();
|
||||
private enabled: boolean = false;
|
||||
private disposables: IDisposable[] = [];
|
||||
private lastViewportY: number = -1;
|
||||
@@ -31,23 +47,22 @@ export class KeywordHighlighter implements IDisposable {
|
||||
this.disposables.push(
|
||||
// When user scrolls, refresh visible area
|
||||
this.term.onScroll(() => {
|
||||
// console.log('[KeywordHighlighter] onScroll');
|
||||
this.triggerRefresh();
|
||||
this.triggerRefresh("debounced");
|
||||
}),
|
||||
// When new data is written, refresh
|
||||
// When new data is written, refresh on the next frame so highlights land
|
||||
// with the freshly rendered content instead of trailing behind it.
|
||||
this.term.onWriteParsed(() => {
|
||||
// console.log('[KeywordHighlighter] onWriteParsed');
|
||||
this.triggerRefresh();
|
||||
this.triggerRefresh("immediate");
|
||||
}),
|
||||
// Also refresh on resize as viewport content changes
|
||||
this.term.onResize(() => this.triggerRefresh()),
|
||||
this.term.onResize(() => this.triggerRefresh("debounced")),
|
||||
// onRender fires after each render cycle - catch scrolls that onScroll might miss
|
||||
this.term.onRender(() => {
|
||||
// Only trigger refresh if viewport position changed
|
||||
const currentViewportY = this.term.buffer.active?.viewportY ?? 0;
|
||||
if (currentViewportY !== this.lastViewportY) {
|
||||
this.lastViewportY = currentViewportY;
|
||||
this.triggerRefresh();
|
||||
this.triggerRefresh("debounced");
|
||||
}
|
||||
})
|
||||
);
|
||||
@@ -55,6 +70,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
|
||||
public setRules(rules: KeywordHighlightRule[], enabled: boolean) {
|
||||
this.enabled = enabled;
|
||||
this.matchCache.clear();
|
||||
|
||||
// Pre-compile all patterns into regexes for better performance
|
||||
// This avoids creating new RegExp objects on every viewport refresh
|
||||
@@ -76,7 +92,7 @@ export class KeywordHighlighter implements IDisposable {
|
||||
// Clear existing and force an immediate refresh if enabling
|
||||
this.clearDecorations();
|
||||
if (this.enabled && this.compiledRules.length > 0) {
|
||||
this.triggerRefresh();
|
||||
this.triggerRefresh("immediate");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +103,14 @@ export class KeywordHighlighter implements IDisposable {
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
this.matchCache.clear();
|
||||
}
|
||||
|
||||
private triggerRefresh() {
|
||||
private triggerRefresh(mode: "immediate" | "debounced") {
|
||||
if (!this.enabled || this.compiledRules.length === 0) return;
|
||||
|
||||
// Optimization: Disable highlighting in Alternate Buffer (e.g. Vim, Htop)
|
||||
@@ -101,12 +122,72 @@ export class KeywordHighlighter implements IDisposable {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mode === "immediate") {
|
||||
// Throttle: skip if a rAF is already pending.
|
||||
// Don't clear the debounce timer here — in a hidden tab rAF never
|
||||
// fires, so the fallback timer is the only path that will run.
|
||||
if (this.animationFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
const minInterval = XTERM_PERFORMANCE_CONFIG.highlighting.immediateMinIntervalMs;
|
||||
if (now - this.lastRefreshTime < minInterval) {
|
||||
// Too soon — fall through to debounced path instead of dropping
|
||||
this.triggerRefresh("debounced");
|
||||
return;
|
||||
}
|
||||
this.animationFrameId = requestAnimationFrame(() => {
|
||||
this.animationFrameId = null;
|
||||
// rAF fired — cancel the fallback timer to avoid a redundant refresh
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
this.executeRefresh();
|
||||
});
|
||||
// Arm a debounced fallback: rAF does not fire in background/hidden
|
||||
// tabs (Chromium throttles it), so the timer ensures highlights
|
||||
// still update for ongoing output. If rAF fires first it cancels
|
||||
// this timer (see above), preventing a double refresh.
|
||||
if (!this.debounceTimer) {
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
this.executeRefresh();
|
||||
}, XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.animationFrameId !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.debounceTimer) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
}
|
||||
|
||||
const delay = XTERM_PERFORMANCE_CONFIG.highlighting.debounceMs;
|
||||
this.debounceTimer = setTimeout(() => this.refreshViewport(), delay);
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
this.executeRefresh();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Shared refresh execution for both rAF and timer callbacks. */
|
||||
private executeRefresh() {
|
||||
// Cancel any stale rAF that will never fire (e.g. hidden tab)
|
||||
if (this.animationFrameId !== null) {
|
||||
cancelAnimationFrame(this.animationFrameId);
|
||||
this.animationFrameId = null;
|
||||
}
|
||||
// Re-check state: may have changed since the refresh was scheduled
|
||||
if (!this.enabled || this.compiledRules.length === 0) return;
|
||||
if (this.term.buffer.active.type === 'alternate') {
|
||||
if (this.decorations.length > 0) this.clearDecorations();
|
||||
return;
|
||||
}
|
||||
this.lastRefreshTime = performance.now();
|
||||
this.refreshViewport();
|
||||
}
|
||||
|
||||
private clearDecorations() {
|
||||
@@ -140,8 +221,14 @@ export class KeywordHighlighter implements IDisposable {
|
||||
// Skip continuation cells (width 0) - these are the 2nd cell of wide characters
|
||||
if (width === 0) continue;
|
||||
|
||||
// Map each character in this cell to the current cell column
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
if (chars.length > 0) {
|
||||
// Map each character in this cell to the current cell column
|
||||
for (let i = 0; i < chars.length; i++) {
|
||||
map.push(cellCol);
|
||||
}
|
||||
} else {
|
||||
// Empty cell (codepoint 0) — translateToString() outputs a space
|
||||
// for it, so we must push one entry to keep the map aligned.
|
||||
map.push(cellCol);
|
||||
}
|
||||
|
||||
@@ -177,49 +264,106 @@ export class KeywordHighlighter implements IDisposable {
|
||||
const lineText = line.translateToString(true); // true = trim right whitespace
|
||||
if (!lineText) continue;
|
||||
|
||||
// Build mapping from string index to cell column for wide char support
|
||||
const cellMap = this.buildStringToCellMap(line);
|
||||
const cachedRanges = this.getCachedRanges(line, lineText);
|
||||
if (cachedRanges.length === 0) continue;
|
||||
|
||||
// Process each pre-compiled rule
|
||||
for (const { regex, color } of this.compiledRules) {
|
||||
// Reset regex state for reuse (global flag maintains lastIndex)
|
||||
regex.lastIndex = 0;
|
||||
let match;
|
||||
// Calculate offset relative to the absolute cursor position
|
||||
// offset = targetLineAbs - (baseY + cursorY)
|
||||
const offset = lineY - cursorAbsoluteY;
|
||||
|
||||
while ((match = regex.exec(lineText)) !== null) {
|
||||
const strStart = match.index;
|
||||
const strEnd = strStart + match[0].length;
|
||||
for (const range of cachedRanges) {
|
||||
const marker = this.term.registerMarker(offset);
|
||||
|
||||
// Map string indices to cell columns
|
||||
const cellStartCol = cellMap[strStart] ?? strStart;
|
||||
const cellEndCol = cellMap[strEnd] ?? strEnd;
|
||||
const cellWidth = cellEndCol - cellStartCol;
|
||||
if (marker) {
|
||||
const deco = this.term.registerDecoration({
|
||||
marker,
|
||||
x: range.x,
|
||||
width: range.width,
|
||||
foregroundColor: range.color,
|
||||
});
|
||||
|
||||
// Skip if width is 0 or negative (shouldn't happen, but be safe)
|
||||
if (cellWidth <= 0) continue;
|
||||
|
||||
// Calculate offset relative to the absolute cursor position
|
||||
// offset = targetLineAbs - (baseY + cursorY)
|
||||
const offset = lineY - cursorAbsoluteY;
|
||||
const marker = this.term.registerMarker(offset);
|
||||
|
||||
if (marker) {
|
||||
const deco = this.term.registerDecoration({
|
||||
marker,
|
||||
x: cellStartCol,
|
||||
width: cellWidth,
|
||||
foregroundColor: color,
|
||||
});
|
||||
|
||||
if (deco) {
|
||||
this.decorations.push({ decoration: deco, marker });
|
||||
} else {
|
||||
// If decoration failed, cleanup marker
|
||||
marker.dispose();
|
||||
}
|
||||
if (deco) {
|
||||
this.decorations.push({ decoration: deco, marker });
|
||||
} else {
|
||||
// If decoration failed, cleanup marker
|
||||
marker.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getCachedRanges(line: IBufferLine, lineText: string): CachedDecorationRange[] {
|
||||
const cached = this.matchCache.get(lineText);
|
||||
if (cached) {
|
||||
// LRU: move to end
|
||||
this.matchCache.delete(lineText);
|
||||
this.matchCache.set(lineText, cached);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const ranges = this.scanLine(line, lineText);
|
||||
this.matchCache.set(lineText, ranges);
|
||||
|
||||
const maxEntries = XTERM_PERFORMANCE_CONFIG.highlighting.cacheEntries;
|
||||
if (this.matchCache.size > maxEntries) {
|
||||
const oldestKey = this.matchCache.keys().next().value;
|
||||
if (oldestKey !== undefined) {
|
||||
this.matchCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private scanLine(line: IBufferLine, lineText: string): CachedDecorationRange[] {
|
||||
// ASCII-only lines have a 1:1 string-index-to-cell-column mapping,
|
||||
// so we can skip the expensive buildStringToCellMap call entirely.
|
||||
const asciiOnly = RE_ASCII_ONLY.test(lineText);
|
||||
let cellMap: number[] | null = null;
|
||||
let ranges: CachedDecorationRange[] | null = null;
|
||||
|
||||
// Process each pre-compiled rule
|
||||
for (const { regex, color } of this.compiledRules) {
|
||||
// Reset regex state for reuse (global flag maintains lastIndex)
|
||||
regex.lastIndex = 0;
|
||||
let match;
|
||||
|
||||
while ((match = regex.exec(lineText)) !== null) {
|
||||
const strStart = match.index;
|
||||
const strEnd = strStart + match[0].length;
|
||||
|
||||
let cellStartCol: number;
|
||||
let cellEndCol: number;
|
||||
|
||||
if (asciiOnly) {
|
||||
cellStartCol = strStart;
|
||||
cellEndCol = strEnd;
|
||||
} else {
|
||||
// Lazily build cellMap only when a match is found
|
||||
if (cellMap === null) {
|
||||
cellMap = this.buildStringToCellMap(line);
|
||||
}
|
||||
cellStartCol = cellMap[strStart] ?? strStart;
|
||||
cellEndCol = cellMap[strEnd] ?? strEnd;
|
||||
}
|
||||
|
||||
const cellWidth = cellEndCol - cellStartCol;
|
||||
|
||||
// Skip if width is 0 or negative (shouldn't happen, but be safe)
|
||||
if (cellWidth <= 0) continue;
|
||||
|
||||
if (ranges === null) {
|
||||
ranges = [];
|
||||
}
|
||||
ranges.push({
|
||||
x: cellStartCol,
|
||||
width: cellWidth,
|
||||
color,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return ranges ?? (EMPTY_RANGES as CachedDecorationRange[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ type TerminalBackendApi = {
|
||||
cb: (evt: { exitCode?: number; signal?: number; error?: string; reason?: "exited" | "error" | "timeout" | "closed" }) => void,
|
||||
) => () => void;
|
||||
onChainProgress: (
|
||||
cb: (hop: number, total: number, label: string, status: string) => void,
|
||||
cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void,
|
||||
) => (() => void) | undefined;
|
||||
writeToSession: (sessionId: string, data: string) => void;
|
||||
resizeSession: (sessionId: string, cols: number, rows: number) => void;
|
||||
@@ -323,7 +323,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
: undefined;
|
||||
|
||||
const jumpHostsWithUnavailableCredentials: string[] = [];
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys: ctx.keys,
|
||||
@@ -336,13 +336,20 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
|
||||
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
|
||||
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
const hasEncryptedJumpProxyCredential =
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
Boolean(jumpHost.proxyConfig?.username) &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig?.password);
|
||||
|
||||
const hasEncryptedJumpCredential =
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
|
||||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
|
||||
|
||||
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey) {
|
||||
if (hasEncryptedJumpProxyCredential || (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey)) {
|
||||
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
|
||||
}
|
||||
|
||||
@@ -358,10 +365,21 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
|
||||
if (hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && hasEncryptedProxyPassword && !proxyConfig?.password && proxyConfig?.username) {
|
||||
const message = tr(
|
||||
"terminal.auth.proxyCredentialsUnavailable",
|
||||
"Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.",
|
||||
@@ -403,21 +421,64 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
currentHostLabel:
|
||||
jumpHosts[0]?.label || jumpHosts[0]?.hostname || ctx.host.hostname,
|
||||
});
|
||||
ctx.setProgressLogs((prev) => [
|
||||
...prev,
|
||||
`Starting chain connection (${totalHops} hops)...`,
|
||||
]);
|
||||
}
|
||||
|
||||
const unsub = ctx.terminalBackend.onChainProgress((hop, total, label, status) => {
|
||||
ctx.setChainProgress({
|
||||
currentHop: hop,
|
||||
totalHops: total,
|
||||
currentHostLabel: label,
|
||||
});
|
||||
ctx.setProgressLogs((prev) => [
|
||||
...prev,
|
||||
`Chain ${hop} of ${total}: ${label} - ${status}`,
|
||||
]);
|
||||
{
|
||||
const unsub = ctx.terminalBackend.onChainProgress((sid, hop, total, label, status, error) => {
|
||||
// P1: Only process events for this session
|
||||
if (sid !== ctx.sessionId) return;
|
||||
|
||||
// P3: Only show chain progress UI for multi-hop connections
|
||||
if (total > 1) {
|
||||
ctx.setChainProgress({
|
||||
currentHop: hop,
|
||||
totalHops: total,
|
||||
currentHostLabel: label,
|
||||
});
|
||||
}
|
||||
|
||||
// Build human-readable log line
|
||||
let logLine: string;
|
||||
const prefix = total > 1 ? `[${hop}/${total}] ` : '';
|
||||
|
||||
switch (status) {
|
||||
case 'connecting':
|
||||
logLine = `${prefix}${tr("terminal.progress.connecting", "Connecting to")} ${label}...`;
|
||||
break;
|
||||
case 'authenticating':
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.keyExchangeComplete", "Key exchange complete")}`;
|
||||
break;
|
||||
case 'auth-attempt':
|
||||
if (error?.endsWith('rejected')) {
|
||||
logLine = `${prefix}${label} - ✗ ${error}`;
|
||||
} else if (error === 'all methods exhausted') {
|
||||
logLine = `${prefix}${label} - ✗ All authentication methods exhausted`;
|
||||
} else if (error === 'waiting for user input...' || error === 'user responded') {
|
||||
logLine = `${prefix}${label} - ${error}`;
|
||||
} else {
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.trying", "Trying")} ${error}...`;
|
||||
}
|
||||
break;
|
||||
case 'authenticated':
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.authenticated", "Authenticated")}`;
|
||||
break;
|
||||
case 'connected':
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.connected", "Connected")}`;
|
||||
break;
|
||||
case 'forwarding':
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.forwarding", "Forwarding")}...`;
|
||||
break;
|
||||
case 'shell':
|
||||
logLine = `${prefix}${tr("terminal.progress.openingShell", "Opening shell")}...`;
|
||||
break;
|
||||
case 'error':
|
||||
logLine = `${prefix}${label} - ${tr("terminal.progress.error", "Error")}${error ? `: ${error}` : ''}`;
|
||||
break;
|
||||
default:
|
||||
logLine = `${prefix}${label} - ${status}${error ? `: ${error}` : ''}`;
|
||||
}
|
||||
|
||||
ctx.setProgressLogs((prev) => [...prev, logLine]);
|
||||
const hopProgress = (hop / total) * 80 + 10;
|
||||
ctx.setProgressValue(Math.min(95, hopProgress));
|
||||
});
|
||||
@@ -456,6 +517,8 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
keepaliveInterval: ctx.terminalSettings?.keepaliveInterval,
|
||||
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
|
||||
// Only pass local key paths if no vault key is explicitly configured
|
||||
identityFilePaths: attempt.key ? undefined : ctx.host.identityFilePaths,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -547,6 +610,18 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
}, 600);
|
||||
}
|
||||
|
||||
// Run OS detection only after successful connection
|
||||
setTimeout(
|
||||
() =>
|
||||
void runDistroDetection(ctx, {
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
const authError = isAuthError(err);
|
||||
@@ -572,17 +647,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
ctx.setChainProgress(null);
|
||||
if (unsubscribeChainProgress) unsubscribeChainProgress();
|
||||
}
|
||||
|
||||
setTimeout(
|
||||
() =>
|
||||
void runDistroDetection(ctx, {
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
};
|
||||
|
||||
const startTelnet = async (term: XTerm) => {
|
||||
|
||||
@@ -161,6 +161,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const lineHeight = 1 + (settings?.linePadding ?? 0) / 10;
|
||||
const minimumContrastRatio = settings?.minimumContrastRatio ?? 1;
|
||||
const scrollOnUserInput = shouldEnableNativeUserInputAutoScroll(settings);
|
||||
const smoothScrollDuration = settings?.smoothScrolling
|
||||
? performanceConfig.options.smoothScrollDuration
|
||||
: 0;
|
||||
const altIsMeta = settings?.altAsMeta ?? false;
|
||||
const wordSeparator = settings?.wordSeparators ?? " ()[]{}'\"";
|
||||
const keywordHighlightRules = settings?.keywordHighlightRules ?? [];
|
||||
@@ -213,6 +216,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
allowProposedApi: true,
|
||||
drawBoldTextInBrightColors,
|
||||
minimumContrastRatio,
|
||||
smoothScrollDuration,
|
||||
scrollOnUserInput,
|
||||
macOptionClickForcesSelection: true,
|
||||
altClickMovesCursor: !altIsMeta,
|
||||
@@ -391,13 +395,17 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Send the snippet command to the terminal
|
||||
const payload = snippet.noAutoRun
|
||||
? normalizeLineEndings(snippet.command)
|
||||
: `${normalizeLineEndings(snippet.command)}\r`;
|
||||
ctx.terminalBackend.writeToSession(id, payload);
|
||||
let snippetData = normalizeLineEndings(snippet.command);
|
||||
if (!snippet.noAutoRun) snippetData = `${snippetData}\r`;
|
||||
// Broadcast the normalized (un-wrapped) data so each target
|
||||
// session can apply its own bracket paste state
|
||||
if (ctx.isBroadcastEnabledRef.current && ctx.onBroadcastInputRef.current) {
|
||||
ctx.onBroadcastInputRef.current(payload, ctx.sessionId);
|
||||
ctx.onBroadcastInputRef.current(snippetData, ctx.sessionId);
|
||||
}
|
||||
// Wrap for this terminal only, after broadcasting
|
||||
const snippetIsMultiLine = snippetData.includes("\n");
|
||||
if (snippetIsMultiLine && term.modes.bracketedPasteMode && !ctx.terminalSettingsRef.current?.disableBracketedPaste) snippetData = wrapBracketedPaste(snippetData);
|
||||
ctx.terminalBackend.writeToSession(id, snippetData);
|
||||
if (!snippet.noAutoRun && ctx.onCommandExecuted) {
|
||||
const cmd = snippet.command.trim();
|
||||
if (cmd) ctx.onCommandExecuted(cmd, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
|
||||
@@ -48,6 +48,14 @@ export const getEffectiveHostDistro = (
|
||||
return detected;
|
||||
};
|
||||
|
||||
/** Format hostname:port for display, wrapping IPv6 addresses in brackets. */
|
||||
export const formatHostPort = (hostname: string, port?: number | null): string => {
|
||||
if (port == null) return hostname;
|
||||
const isIPv6 = hostname.includes(':') && !hostname.startsWith('[');
|
||||
const display = isIPv6 ? `[${hostname}]` : hostname;
|
||||
return `${display}:${port}`;
|
||||
};
|
||||
|
||||
export const sanitizeHost = (host: Host): Host => {
|
||||
const cleanHostname = (host.hostname || '').split(/\s+/)[0];
|
||||
const cleanDistro = normalizeDistroId(host.distro);
|
||||
|
||||
@@ -113,6 +113,9 @@ export interface Host {
|
||||
keywordHighlightEnabled?: boolean;
|
||||
// Legacy SSH algorithm support for older network equipment (switches, routers)
|
||||
legacyAlgorithms?: boolean;
|
||||
// Local SSH key file paths (from SSH config IdentityFile or user-added)
|
||||
// Resolved at connection time — the app reads the file content when connecting.
|
||||
identityFilePaths?: string[];
|
||||
}
|
||||
|
||||
export type KeyType = 'RSA' | 'ECDSA' | 'ED25519';
|
||||
@@ -410,6 +413,8 @@ export interface TerminalSettings {
|
||||
scrollOnKeyPress: boolean; // Scroll terminal to bottom on key press
|
||||
scrollOnPaste: boolean; // Scroll terminal to bottom on paste
|
||||
|
||||
smoothScrolling: boolean; // Animate viewport scrolling instead of jumping instantly
|
||||
|
||||
// Mouse
|
||||
rightClickBehavior: RightClickBehavior;
|
||||
copyOnSelect: boolean; // Automatically copy selected text
|
||||
@@ -532,6 +537,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
scrollOnPaste: true,
|
||||
smoothScrolling: false,
|
||||
rightClickBehavior: 'context-menu',
|
||||
copyOnSelect: false,
|
||||
middleClickPaste: true,
|
||||
|
||||
@@ -9,15 +9,45 @@ interface QuickConnectParseResult {
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
/** Test whether a string looks like a bare (un-bracketed) IPv6 address.
|
||||
* Must have only hex digits and colons, with either:
|
||||
* - A "::" shorthand (unambiguously IPv6), or
|
||||
* - Exactly 7 colons (full 8-group notation like 2607:f130:0:179:0:0:b0df:eec4)
|
||||
* This avoids false positives on MAC addresses (6 groups, 5 colons). */
|
||||
const BARE_IPV6_RE = /^[a-fA-F0-9:]+$/;
|
||||
const isBareIPv6 = (s: string): boolean => {
|
||||
if (!BARE_IPV6_RE.test(s)) return false;
|
||||
if (s.includes('::')) return true;
|
||||
return (s.match(/:/g) || []).length === 7;
|
||||
};
|
||||
|
||||
const parseDirectTarget = (input: string): QuickConnectTarget | null => {
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
// Pattern: [user@]hostname[:port]
|
||||
// Hostname can be IP (v4 or v6) or domain name
|
||||
// Hostname can be IP (v4 or v6 in brackets) or domain name
|
||||
const regex = /^(?:([^@]+)@)?([^\s:]+|\[[^\]]+\])(?::(\d+))?$/;
|
||||
const match = trimmed.match(regex);
|
||||
if (!match) return null;
|
||||
|
||||
// If the main regex fails, try bare IPv6: [user@]ipv6_address
|
||||
// Bare IPv6 contains colons so the main regex can't distinguish host:port.
|
||||
// Port must be specified via brackets: [ipv6]:port
|
||||
if (!match) {
|
||||
const bareIpv6Regex = /^(?:([^@]+)@)?([a-fA-F0-9:]+)$/;
|
||||
const bareMatch = trimmed.match(bareIpv6Regex);
|
||||
if (bareMatch) {
|
||||
const [, bareUser, bareHost] = bareMatch;
|
||||
if (isBareIPv6(bareHost)) {
|
||||
return {
|
||||
hostname: bareHost,
|
||||
username: bareUser || undefined,
|
||||
port: undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const [, username, hostname, portStr] = match;
|
||||
|
||||
|
||||
@@ -113,6 +113,15 @@ export const serializeHostsToSshConfig = (hosts: Host[], allHosts?: Host[]): str
|
||||
lines.push(` Port ${host.port}`);
|
||||
}
|
||||
|
||||
// Serialize IdentityFile paths
|
||||
if (host.identityFilePaths && host.identityFilePaths.length > 0) {
|
||||
for (const keyPath of host.identityFilePaths) {
|
||||
// Quote paths that contain spaces
|
||||
const formatted = keyPath.includes(" ") ? `"${keyPath}"` : keyPath;
|
||||
lines.push(` IdentityFile ${formatted}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Serialize ProxyJump if host has a chain
|
||||
const proxyJumpValue = buildProxyJumpValue(host, hostsForLookup, managedHostIds);
|
||||
if (proxyJumpValue) {
|
||||
|
||||
@@ -74,6 +74,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'smoothScrolling',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
|
||||
@@ -519,6 +519,7 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
username?: string;
|
||||
port?: number;
|
||||
proxyJump?: string;
|
||||
identityFiles?: string[];
|
||||
};
|
||||
|
||||
const blocks: Block[] = [];
|
||||
@@ -557,6 +558,12 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
else if (keyword === "user") current.username = value;
|
||||
else if (keyword === "port") current.port = parsePort(value);
|
||||
else if (keyword === "proxyjump") current.proxyJump = value;
|
||||
else if (keyword === "identityfile") {
|
||||
if (!current.identityFiles) current.identityFiles = [];
|
||||
// Remove surrounding quotes (ssh_config allows quoted paths with spaces)
|
||||
const unquoted = value.replace(/^["']|["']$/g, "");
|
||||
current.identityFiles.push(unquoted);
|
||||
}
|
||||
}
|
||||
|
||||
flush();
|
||||
@@ -597,6 +604,11 @@ const importFromSshConfig = (text: string): VaultImportResult => {
|
||||
protocol: "ssh",
|
||||
});
|
||||
|
||||
// Attach IdentityFile paths if present
|
||||
if (block.identityFiles && block.identityFiles.length > 0) {
|
||||
host.identityFilePaths = [...block.identityFiles];
|
||||
}
|
||||
|
||||
parsedHosts.push(host);
|
||||
|
||||
// Store ProxyJump using hostname key (survives deduplication)
|
||||
|
||||
@@ -6,7 +6,12 @@ module.exports = {
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
icon: 'public/icon.png',
|
||||
npmRebuild: false,
|
||||
// npmRebuild must stay enabled for macOS and Windows builds — without it,
|
||||
// node-pty's native module is not recompiled for the Electron ABI, causing
|
||||
// "posix_spawnp failed" on macOS. Linux builds set npm_config_arch in CI
|
||||
// and run ensure-node-pty-linux.sh before packaging, so the rebuild is
|
||||
// redundant but harmless there.
|
||||
npmRebuild: true,
|
||||
directories: {
|
||||
buildResources: 'build',
|
||||
output: 'release'
|
||||
|
||||
@@ -43,53 +43,89 @@ function subscribeToPtyData(ptyStream, onData) {
|
||||
throw new Error("PTY stream does not support data subscriptions");
|
||||
}
|
||||
|
||||
function hasExpectedPromptSuffix(text, expectedPrompt) {
|
||||
if (!expectedPrompt) return false;
|
||||
const normalizedText = stripAnsi(String(text || "")).replace(/\r/g, "");
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
return !!normalizedPrompt && normalizedText.endsWith(normalizedPrompt);
|
||||
}
|
||||
|
||||
function escapePosixSingleQuoted(text) {
|
||||
return String(text || "").replace(/'/g, "'\\''");
|
||||
}
|
||||
|
||||
function escapePowerShellSingleQuoted(text) {
|
||||
return String(text || "").replace(/'/g, "''");
|
||||
}
|
||||
|
||||
function escapeFishSingleQuoted(text) {
|
||||
return String(text || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function escapeCmdForNestedShell(text) {
|
||||
return String(text || "").replace(/"/g, '""').replace(/%/g, "%%");
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker) {
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
// Combine into 2 PTY lines (like posix) to minimise prompt echo duplication:
|
||||
// Line 1: start marker + pager env + user command
|
||||
// Line 2: capture exit code + end marker
|
||||
// __NCMCP_ prefix ensures the echo line is buffered/filtered even if
|
||||
// the PTY delivers it in small chunks (the marker must appear early).
|
||||
const psPager = "$env:PAGER='cat'; $env:SYSTEMD_PAGER=''; $env:GIT_PAGER='cat'; $env:LESS=''; ";
|
||||
const psEscaped = escapePowerShellSingleQuoted(command);
|
||||
return (
|
||||
`Write-Output '${marker}_S'; ${psPager}${command}\r\n` +
|
||||
`Write-Output "${marker}_E:$LASTEXITCODE"\r\n`
|
||||
`$${marker}=0; $${marker}_cmd='${psEscaped}'; Write-Host '> ${psEscaped}'; & { Write-Output '${marker}_S'; ${psPager}$LASTEXITCODE=$null; try { Invoke-Expression $${marker}_cmd; $${marker}_rc = if ($LASTEXITCODE -ne $null) { $LASTEXITCODE } elseif ($?) { 0 } else { 1 } } catch { $${marker}_rc = 1 }; Write-Output "${marker}_E:$${marker}_rc" }\r\n`
|
||||
);
|
||||
}
|
||||
|
||||
case "cmd":
|
||||
return [
|
||||
'set "PAGER=cat"',
|
||||
'set "SYSTEMD_PAGER="',
|
||||
'set "GIT_PAGER=cat"',
|
||||
'set "LESS="',
|
||||
`echo ${marker}_S`,
|
||||
command,
|
||||
`echo ${marker}_E:%errorlevel%`,
|
||||
"",
|
||||
].join("\r\n");
|
||||
case "cmd": {
|
||||
const cmdEscaped = escapeCmdForNestedShell(command);
|
||||
return (
|
||||
`set "${marker}=0" & set "${marker}_CMD=${cmdEscaped}" & call <nul set /p "=^> %%${marker}_CMD%%" & echo( & (echo ${marker}_S & set "PAGER=cat" & set "SYSTEMD_PAGER=" & set "GIT_PAGER=cat" & set "LESS=" & call cmd /d /s /c "%%${marker}_CMD%%" & call echo ${marker}_E:^%errorlevel^%)\r\n`
|
||||
);
|
||||
}
|
||||
|
||||
case "fish":
|
||||
return [
|
||||
"set -gx PAGER cat",
|
||||
"set -gx SYSTEMD_PAGER ''",
|
||||
"set -gx GIT_PAGER cat",
|
||||
"set -gx LESS ''",
|
||||
`printf '%s\\n' '${marker}_S'`,
|
||||
command,
|
||||
"set __NCMCP_rc $status",
|
||||
`printf '%s\\n' '${marker}_E:'$__NCMCP_rc`,
|
||||
"",
|
||||
].join("\n");
|
||||
// set __NCMCP_... at the start ensures early marker presence in echo.
|
||||
return (
|
||||
`set ${marker} 0; function __ncmcp_int --on-signal INT; printf '%s\\n' '${marker}_E:130'; functions -e __ncmcp_int; end; ` +
|
||||
// Clear the current terminal row before the user-visible echo.
|
||||
`set -l ${marker}_cmd '${escapeFishSingleQuoted(command)}'; printf '\\r\\033[2K> %s\\n' '${escapeFishSingleQuoted(command)}'; ` +
|
||||
`begin; set -gx PAGER cat; set -gx SYSTEMD_PAGER ''; set -gx GIT_PAGER cat; set -gx LESS ''; ` +
|
||||
`printf '%s\\n' '${marker}_S'; eval -- \$${marker}_cmd; set __NCMCP_rc $status; ` +
|
||||
`functions -e __ncmcp_int; printf '%s\\n' '${marker}_E:'\$__NCMCP_rc; end\n`
|
||||
);
|
||||
|
||||
case "posix":
|
||||
default: {
|
||||
// Combine into 2 PTY lines to minimise prompt echo duplication:
|
||||
// Line 1: start marker + pager env + user command
|
||||
// Line 2: capture exit code + end marker + restore exit code
|
||||
// Single-line compound command with early marker & visible command echo.
|
||||
//
|
||||
// Layout: __NCMCP_xxx=0; printf echo; { ... MARKER_S; eval command; MARKER_E; }
|
||||
//
|
||||
// Key design decisions:
|
||||
//
|
||||
// 1) __NCMCP_xxx=0 at the VERY START ensures the PTY echo line
|
||||
// contains __NCMCP_ in its first few bytes. This is critical:
|
||||
// preload.cjs filters chunks by buffering incomplete lines that
|
||||
// contain __NCMCP_. Without this prefix, the first chunk of a
|
||||
// long echo line might not contain the marker and would leak
|
||||
// through to the terminal as garbage.
|
||||
//
|
||||
// 2) printf clears the current row and outputs "> command\n"
|
||||
// (no marker) → visible to user without prompt residue.
|
||||
//
|
||||
// 3) The user command is executed via eval on a quoted string. This
|
||||
// keeps shell syntax errors inside the eval call so the wrapper
|
||||
// can still emit the end marker and return a non-zero exit code.
|
||||
//
|
||||
// 4) Single-line { ... } is parsed fully before execution, so SIGINT
|
||||
// cannot cause bash to flush the end marker from the input buffer.
|
||||
// trap ':' INT lets child processes receive SIGINT normally while
|
||||
// preventing the shell from aborting the compound command.
|
||||
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
|
||||
const escaped = escapePosixSingleQuoted(command);
|
||||
return (
|
||||
`printf '%s\\n' '${marker}_S';${noPager}${command}\n` +
|
||||
`__NCMCP_rc=$?;printf '%s\\n' '${marker}_E:'"$__NCMCP_rc";(exit $__NCMCP_rc)\n`
|
||||
`${marker}=0; ${marker}_cmd='${escaped}'; printf '\\r\\033[2K> %s\\n' '${escaped}'; { printf '%s\\n' '${marker}_S'; trap ':' INT; ${noPager}eval "$${marker}_cmd"; __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"; (exit $__NCMCP_rc); }\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -106,6 +142,9 @@ function buildWrappedCommand(command, shellKind, marker) {
|
||||
* @param {boolean} [options.stripMarkers=false] - Strip leaked MCP markers from output
|
||||
* @param {Map} [options.trackForCancellation] - Map to register this execution in for cancellation
|
||||
* @param {number} [options.timeoutMs=60000] - Command timeout in milliseconds
|
||||
* @param {string} [options.chatSessionId] - Chat session ID for scoped cancellation
|
||||
* @param {AbortSignal} [options.abortSignal] - AbortSignal to cancel execution
|
||||
* @param {string} [options.expectedPrompt] - Last observed idle prompt for exact fallback matching
|
||||
*/
|
||||
function execViaPty(ptyStream, command, options) {
|
||||
const {
|
||||
@@ -113,33 +152,51 @@ function execViaPty(ptyStream, command, options) {
|
||||
trackForCancellation = null,
|
||||
timeoutMs = 60000,
|
||||
shellKind,
|
||||
chatSessionId,
|
||||
abortSignal,
|
||||
expectedPrompt,
|
||||
} = options || {};
|
||||
|
||||
const marker = `__NCMCP_${Date.now().toString(36)}_${crypto.randomBytes(16).toString('hex')}__`;
|
||||
const resolvedShellKind = shellKind || "posix";
|
||||
|
||||
// Fast-path: already aborted before we even start
|
||||
if (abortSignal?.aborted) {
|
||||
return Promise.resolve({ ok: false, stdout: "", stderr: "", exitCode: -1, error: "Cancelled" });
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
let output = "";
|
||||
let foundStart = false;
|
||||
let timeoutId = null;
|
||||
let promptFallbackTimer = null;
|
||||
let finished = false;
|
||||
let unsubscribe = null;
|
||||
const cleanupFns = [];
|
||||
|
||||
// Buffer for incomplete line data when searching for start marker.
|
||||
// SSH channels can split data at arbitrary byte boundaries, so the
|
||||
// start marker may arrive across two chunks. We keep the content
|
||||
// after the last \n (i.e. the current incomplete line) and prepend
|
||||
// it to the next chunk so indexOf can match the full marker.
|
||||
let pendingStart = "";
|
||||
|
||||
const onData = (data) => {
|
||||
const text = data.toString();
|
||||
|
||||
if (!foundStart) {
|
||||
// Look for the start marker at a line boundary (actual printf output),
|
||||
// not inside the echo of the printf command argument.
|
||||
const combined = pendingStart + text;
|
||||
pendingStart = "";
|
||||
const startMarker = marker + "_S";
|
||||
let matched = false;
|
||||
let pos = 0;
|
||||
while (pos < text.length) {
|
||||
const idx = text.indexOf(startMarker, pos);
|
||||
while (pos < combined.length) {
|
||||
const idx = combined.indexOf(startMarker, pos);
|
||||
if (idx === -1) break;
|
||||
// Accept if at start of text, or preceded by \n or \r (line boundary)
|
||||
if (idx === 0 || text[idx - 1] === '\n' || text[idx - 1] === '\r') {
|
||||
if (idx === 0 || combined[idx - 1] === '\n' || combined[idx - 1] === '\r') {
|
||||
foundStart = true;
|
||||
const afterMarker = text.slice(idx);
|
||||
matched = true;
|
||||
const afterMarker = combined.slice(idx);
|
||||
const nlIdx = afterMarker.indexOf("\n");
|
||||
if (nlIdx !== -1) {
|
||||
output += afterMarker.slice(nlIdx + 1);
|
||||
@@ -148,14 +205,42 @@ function execViaPty(ptyStream, command, options) {
|
||||
}
|
||||
pos = idx + 1;
|
||||
}
|
||||
if (foundStart) checkEnd();
|
||||
if (!matched) {
|
||||
// Keep the last incomplete line for cross-chunk matching
|
||||
const lastNl = combined.lastIndexOf("\n");
|
||||
pendingStart = lastNl === -1 ? combined : combined.slice(lastNl + 1);
|
||||
}
|
||||
if (foundStart) {
|
||||
schedulePromptFallback();
|
||||
checkEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
output += text;
|
||||
schedulePromptFallback();
|
||||
checkEnd();
|
||||
};
|
||||
|
||||
function clearPromptFallback() {
|
||||
if (promptFallbackTimer) {
|
||||
clearTimeout(promptFallbackTimer);
|
||||
promptFallbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePromptFallback() {
|
||||
clearPromptFallback();
|
||||
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
|
||||
|
||||
// Fallback for shells that visibly return to the same idle prompt but
|
||||
// never emit the wrapped end marker line.
|
||||
promptFallbackTimer = setTimeout(() => {
|
||||
if (!hasExpectedPromptSuffix(output, expectedPrompt)) return;
|
||||
finish(output, null, null);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function checkEnd() {
|
||||
// Look for the end marker at a line boundary (actual printf output),
|
||||
// not inside the echo of the printf command argument.
|
||||
@@ -179,39 +264,43 @@ function execViaPty(ptyStream, command, options) {
|
||||
}
|
||||
}
|
||||
|
||||
function finish(stdout, exitCode) {
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
clearTimeout(timeoutId);
|
||||
clearPromptFallback();
|
||||
unsubscribe?.();
|
||||
for (const fn of cleanupFns) { try { fn(); } catch { /* ignore */ } }
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
|
||||
let cleaned = stripAnsi(stdout || "").trim();
|
||||
let cleaned = stripAnsi(stdout || "").replace(/\r/g, "");
|
||||
if (stripMarkers) {
|
||||
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "").trim();
|
||||
cleaned = cleaned.replace(/^[^\r\n]*__NCMCP_[^\r\n]*[\r\n]*/gm, "");
|
||||
}
|
||||
const normalizedPrompt = stripAnsi(String(expectedPrompt || "")).replace(/\r/g, "");
|
||||
if (normalizedPrompt && cleaned.endsWith(normalizedPrompt)) {
|
||||
cleaned = cleaned.slice(0, cleaned.length - normalizedPrompt.length);
|
||||
}
|
||||
cleaned = cleaned.trim();
|
||||
if (error) {
|
||||
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: exitCode ?? -1, error });
|
||||
} else {
|
||||
resolve({
|
||||
ok: exitCode === 0 || exitCode === null,
|
||||
stdout: cleaned,
|
||||
stderr: "",
|
||||
exitCode: exitCode ?? 0,
|
||||
});
|
||||
}
|
||||
resolve({
|
||||
ok: exitCode === 0 || exitCode === null,
|
||||
stdout: cleaned,
|
||||
stderr: "",
|
||||
exitCode: exitCode ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
unsubscribe?.();
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.delete(marker);
|
||||
}
|
||||
// Send Ctrl+C to kill the timed-out command
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
const cleaned = stripAnsi(output).trim();
|
||||
const timeoutSec = Math.round(timeoutMs / 1000);
|
||||
resolve({ ok: false, stdout: cleaned, stderr: "", exitCode: -1, error: `Command timed out (${timeoutSec}s)` });
|
||||
finish(output, -1, `Command timed out (${timeoutSec}s)`);
|
||||
}, timeoutMs);
|
||||
|
||||
unsubscribe = subscribeToPtyData(ptyStream, onData);
|
||||
@@ -220,6 +309,11 @@ function execViaPty(ptyStream, command, options) {
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
ptyStream,
|
||||
chatSessionId: chatSessionId || null,
|
||||
cancel: () => {
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
finish(output, -1, "Cancelled");
|
||||
},
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
unsubscribe?.();
|
||||
@@ -227,6 +321,35 @@ function execViaPty(ptyStream, command, options) {
|
||||
});
|
||||
}
|
||||
|
||||
// Stream close/error detection — resolve immediately instead of waiting for timeout
|
||||
if (typeof ptyStream.on === "function") {
|
||||
const onClose = () => finish(output, null, "Stream closed unexpectedly");
|
||||
const onError = (err) => finish(output, -1, `Stream error: ${err?.message || err}`);
|
||||
ptyStream.on("close", onClose);
|
||||
ptyStream.on("end", onClose);
|
||||
ptyStream.on("error", onError);
|
||||
cleanupFns.push(() => {
|
||||
try { ptyStream.removeListener("close", onClose); } catch { /* */ }
|
||||
try { ptyStream.removeListener("end", onClose); } catch { /* */ }
|
||||
try { ptyStream.removeListener("error", onError); } catch { /* */ }
|
||||
});
|
||||
}
|
||||
// node-pty uses onExit instead of close/end
|
||||
if (typeof ptyStream.onExit === "function") {
|
||||
const disposable = ptyStream.onExit(() => finish(output, null, "Process exited"));
|
||||
cleanupFns.push(() => { try { disposable?.dispose?.(); } catch { /* */ } });
|
||||
}
|
||||
|
||||
// AbortSignal handling — send Ctrl+C and resolve when aborted
|
||||
if (abortSignal) {
|
||||
const onAbort = () => {
|
||||
if (typeof ptyStream.write === "function") ptyStream.write("\x03");
|
||||
finish(output, -1, "Cancelled");
|
||||
};
|
||||
abortSignal.addEventListener("abort", onAbort, { once: true });
|
||||
cleanupFns.push(() => abortSignal.removeEventListener("abort", onAbort));
|
||||
}
|
||||
|
||||
// Markers are filtered from terminal display by preload.cjs (MCP_MARKER_RE).
|
||||
ptyStream.write(buildWrappedCommand(command, resolvedShellKind, marker));
|
||||
});
|
||||
@@ -244,6 +367,7 @@ function execViaChannel(sshClient, command, options) {
|
||||
const {
|
||||
timeoutMs = 60000,
|
||||
trackForCancellation = null,
|
||||
chatSessionId,
|
||||
} = options || {};
|
||||
|
||||
return new Promise((resolve) => {
|
||||
@@ -276,6 +400,11 @@ function execViaChannel(sshClient, command, options) {
|
||||
}, timeoutMs);
|
||||
if (trackForCancellation) {
|
||||
trackForCancellation.set(marker, {
|
||||
chatSessionId: chatSessionId || null,
|
||||
cancel: () => {
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
finish({ ok: false, stdout, stderr, exitCode: -1, error: "Cancelled" });
|
||||
},
|
||||
cleanup: () => {
|
||||
clearTimeout(timeoutId);
|
||||
try { execStream.close(); } catch { /* ignore */ }
|
||||
|
||||
@@ -16,6 +16,7 @@ const ANSI_ESCAPE_REGEX = /\u001B\[[0-?]*[ -/]*[@-~]/g;
|
||||
const ANSI_OSC_REGEX = /\u001B\][^\u0007]*(?:\u0007|\u001B\\)/g;
|
||||
const URL_CANDIDATE_REGEX = /https?:\/\/[^\s]+/g;
|
||||
const WINDOWS_RUNNABLE_EXTENSIONS = [".exe", ".cmd", ".bat", ".com"];
|
||||
const MAX_PROMPT_TRACK_TAIL = 4096;
|
||||
|
||||
// ── ANSI stripping ──
|
||||
|
||||
@@ -23,6 +24,36 @@ function stripAnsi(input) {
|
||||
return String(input || "").replace(ANSI_OSC_REGEX, "").replace(ANSI_ESCAPE_REGEX, "");
|
||||
}
|
||||
|
||||
function extractTrailingIdlePrompt(output) {
|
||||
const normalized = stripAnsi(output).replace(/\r/g, "");
|
||||
if (!normalized || normalized.endsWith("\n")) return "";
|
||||
|
||||
const lastLine = normalized.split("\n").pop() || "";
|
||||
const rightTrimmed = lastLine.replace(/\s+$/, "");
|
||||
if (!rightTrimmed) return "";
|
||||
|
||||
if (/^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/.test(rightTrimmed)) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function trackSessionIdlePrompt(session, chunk) {
|
||||
if (!session || typeof chunk !== "string" || !chunk) return "";
|
||||
|
||||
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
|
||||
session._promptTrackTail = nextTail;
|
||||
|
||||
const prompt = extractTrailingIdlePrompt(nextTail);
|
||||
if (prompt) {
|
||||
session.lastIdlePrompt = prompt;
|
||||
session.lastIdlePromptAt = Date.now();
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
// ── URL helpers ──
|
||||
|
||||
function isLocalhostHostname(hostname) {
|
||||
@@ -271,6 +302,8 @@ function serializeStreamChunk(chunk) {
|
||||
|
||||
module.exports = {
|
||||
stripAnsi,
|
||||
extractTrailingIdlePrompt,
|
||||
trackSessionIdlePrompt,
|
||||
isLocalhostHostname,
|
||||
extractFirstNonLocalhostUrl,
|
||||
normalizeCliPathForPlatform,
|
||||
|
||||
@@ -60,6 +60,7 @@ const MAX_CONCURRENT_AGENTS = 5;
|
||||
const acpProviders = new Map();
|
||||
const acpActiveStreams = new Map();
|
||||
const acpRequestSessions = new Map();
|
||||
const acpPendingCancelRequests = new Set();
|
||||
const acpForceProviderReset = new Set();
|
||||
const acpChatRuns = new Map();
|
||||
|
||||
@@ -881,7 +882,7 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Execute a command on a terminal session (for Catty Agent)
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command }) => {
|
||||
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
@@ -915,8 +916,11 @@ function registerHandlers(ipcMain) {
|
||||
const timeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaPty(ptyStream, command, {
|
||||
stripMarkers: true,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
timeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
chatSessionId,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -925,7 +929,11 @@ function registerHandlers(ipcMain) {
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
const { execViaChannel } = require("./ai/ptyExec.cjs");
|
||||
const channelTimeoutMs = mcpServerBridge.getCommandTimeoutMs ? mcpServerBridge.getCommandTimeoutMs() : 60000;
|
||||
return execViaChannel(sshClient, command, { timeoutMs: channelTimeoutMs });
|
||||
return execViaChannel(sshClient, command, {
|
||||
timeoutMs: channelTimeoutMs,
|
||||
trackForCancellation: mcpServerBridge.activePtyExecs,
|
||||
chatSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: false, error: "No terminal stream or SSH client available for this session" };
|
||||
@@ -934,6 +942,15 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
});
|
||||
|
||||
// Cancel in-flight Catty Agent command executions for a chat session
|
||||
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) {
|
||||
return { ok: false, error: "Unauthorized IPC sender" };
|
||||
}
|
||||
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Write to terminal session (send input like a user typing)
|
||||
ipcMain.handle("netcatty:ai:terminal:write", async (event, { sessionId, data }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
@@ -1715,11 +1732,39 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
let abortController = null;
|
||||
try {
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
existingController.abort();
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
|
||||
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
|
||||
abortController = new AbortController();
|
||||
acpActiveStreams.set(requestId, abortController);
|
||||
acpRequestSessions.set(requestId, chatSessionId);
|
||||
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
|
||||
|
||||
const consumePendingStartupCancel = () => {
|
||||
if (!acpPendingCancelRequests.has(requestId)) return false;
|
||||
acpPendingCancelRequests.delete(requestId);
|
||||
abortController?.abort();
|
||||
return true;
|
||||
};
|
||||
|
||||
const shouldAbortStartup = () =>
|
||||
Boolean(abortController?.signal?.aborted || consumePendingStartupCancel());
|
||||
|
||||
const { createACPProvider } = require("@mcpc-tech/acp-ai-provider");
|
||||
const { streamText, stepCountIs } = require("ai");
|
||||
|
||||
const shellEnv = await getShellEnv();
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
const sessionCwd = cwd || process.cwd();
|
||||
const isCodexAgent = acpCommand === "codex-acp";
|
||||
const isClaudeAgent = acpCommand === "claude-agent-acp";
|
||||
@@ -1730,6 +1775,7 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
if (isCodexAgent && !apiKey) {
|
||||
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000 });
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
if (!validation.ok) {
|
||||
if (isCodexAuthError(validation)) {
|
||||
try {
|
||||
@@ -1752,6 +1798,7 @@ function registerHandlers(ipcMain) {
|
||||
const mcpSnapshot = isCodexAgent
|
||||
? await resolveCodexMcpSnapshot(sessionCwd)
|
||||
: { mcpServers: [], fingerprint: getCodexMcpFingerprint([]) };
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
|
||||
// Inject Netcatty MCP server for scoped terminal-session access
|
||||
try {
|
||||
@@ -1762,23 +1809,12 @@ function registerHandlers(ipcMain) {
|
||||
} catch (err) {
|
||||
console.error("[ACP] Failed to inject Netcatty MCP server:", err?.message || err);
|
||||
}
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
|
||||
// Recalculate fingerprint after injection
|
||||
mcpSnapshot.fingerprint = getCodexMcpFingerprint(mcpSnapshot.mcpServers);
|
||||
|
||||
const currentPermissionMode = mcpServerBridge.getPermissionMode();
|
||||
const existingRun = acpChatRuns.get(chatSessionId);
|
||||
if (existingRun && existingRun.requestId !== requestId) {
|
||||
existingRun.cancelRequested = true;
|
||||
const existingController = acpActiveStreams.get(existingRun.requestId);
|
||||
if (existingController) {
|
||||
existingController.abort();
|
||||
acpActiveStreams.delete(existingRun.requestId);
|
||||
}
|
||||
acpRequestSessions.delete(existingRun.requestId);
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
}
|
||||
|
||||
let providerEntry = acpProviders.get(chatSessionId);
|
||||
const shouldForceProviderReset = acpForceProviderReset.has(chatSessionId);
|
||||
const shouldReuseProvider = Boolean(
|
||||
@@ -1841,6 +1877,7 @@ function registerHandlers(ipcMain) {
|
||||
let modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
try {
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
} catch (err) {
|
||||
const attemptedResumeSessionId = providerEntry.provider?.getSessionId?.() || existingSessionId;
|
||||
if (!attemptedResumeSessionId || !isUnsupportedLoadSessionError(err)) {
|
||||
@@ -1882,6 +1919,7 @@ function registerHandlers(ipcMain) {
|
||||
acpProviders.set(chatSessionId, providerEntry);
|
||||
modelInstance = providerEntry.provider.languageModel(model || undefined);
|
||||
await providerEntry.provider.initSession(providerEntry.provider.tools);
|
||||
if (shouldAbortStartup()) return { ok: true };
|
||||
}
|
||||
const activeProviderSessionId = providerEntry.provider.getSessionId?.() || null;
|
||||
if (activeProviderSessionId) {
|
||||
@@ -1891,11 +1929,6 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
}
|
||||
|
||||
abortController = new AbortController();
|
||||
acpActiveStreams.set(requestId, abortController);
|
||||
acpRequestSessions.set(requestId, chatSessionId);
|
||||
acpChatRuns.set(chatSessionId, { requestId, cancelRequested: false });
|
||||
|
||||
// Prepend context hint so the agent uses Netcatty MCP tools for the scoped sessions
|
||||
const contextualPrompt =
|
||||
`[Context: You are inside Netcatty, a multi-session terminal manager. ` +
|
||||
@@ -2055,6 +2088,7 @@ function registerHandlers(ipcMain) {
|
||||
} finally {
|
||||
acpActiveStreams.delete(requestId);
|
||||
acpRequestSessions.delete(requestId);
|
||||
acpPendingCancelRequests.delete(requestId);
|
||||
const activeRun = acpChatRuns.get(chatSessionId);
|
||||
if (activeRun?.requestId === requestId) {
|
||||
if (abortController?.signal?.aborted || activeRun.cancelRequested) {
|
||||
@@ -2069,20 +2103,24 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId, chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// Cancel any active PTY executions (send Ctrl+C)
|
||||
mcpServerBridge.cancelAllPtyExecs();
|
||||
const effectiveChatSessionId = chatSessionId || acpRequestSessions.get(requestId);
|
||||
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
|
||||
const effectiveRequestId = requestId || activeRun?.requestId || "";
|
||||
// Cancel PTY executions scoped to this chat session (send Ctrl+C)
|
||||
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
|
||||
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
|
||||
mcpServerBridge.clearPendingApprovals(effectiveChatSessionId);
|
||||
const activeRun = effectiveChatSessionId ? acpChatRuns.get(effectiveChatSessionId) : null;
|
||||
if (activeRun && activeRun.requestId === requestId) {
|
||||
if (activeRun && activeRun.requestId === effectiveRequestId) {
|
||||
activeRun.cancelRequested = true;
|
||||
}
|
||||
const controller = acpActiveStreams.get(requestId);
|
||||
const controller = acpActiveStreams.get(effectiveRequestId);
|
||||
let cancelled = false;
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
acpActiveStreams.delete(requestId);
|
||||
acpActiveStreams.delete(effectiveRequestId);
|
||||
cancelled = true;
|
||||
} else if (effectiveRequestId) {
|
||||
acpPendingCancelRequests.add(effectiveRequestId);
|
||||
cancelled = true;
|
||||
}
|
||||
if (effectiveChatSessionId) {
|
||||
@@ -2093,7 +2131,7 @@ function registerHandlers(ipcMain) {
|
||||
// continue within the same persisted conversation context. Full provider
|
||||
// cleanup is handled by netcatty:ai:acp:cleanup when the chat is deleted.
|
||||
if (effectiveChatSessionId) cancelled = true;
|
||||
acpRequestSessions.delete(requestId);
|
||||
if (effectiveRequestId) acpRequestSessions.delete(effectiveRequestId);
|
||||
return cancelled ? { ok: true } : { ok: false, error: "Stream not found" };
|
||||
});
|
||||
|
||||
|
||||
326
electron/bridges/crashLogBridge.cjs
Normal file
326
electron/bridges/crashLogBridge.cjs
Normal file
@@ -0,0 +1,326 @@
|
||||
/**
|
||||
* Crash Log Bridge - Captures main-process errors and writes them to local log files.
|
||||
*
|
||||
* Log files are stored as JSONL (one JSON object per line) under
|
||||
* {userData}/crash-logs/crash-YYYY-MM-DD.log so that appending is cheap and
|
||||
* atomic. Files older than 30 days are pruned on startup.
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// State
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let logDir = null;
|
||||
let electronApp = null;
|
||||
let electronShell = null;
|
||||
let sessionsMap = null;
|
||||
|
||||
const LOG_RETENTION_DAYS = 30;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureLogDir() {
|
||||
if (logDir) return logDir;
|
||||
|
||||
try {
|
||||
// Try the stored app reference first, then fall back to requiring electron
|
||||
// directly so crash logging works even before init() is called.
|
||||
let userDataPath = null;
|
||||
if (electronApp) {
|
||||
userDataPath = electronApp.getPath("userData");
|
||||
} else {
|
||||
try {
|
||||
const { app } = require("node:electron");
|
||||
userDataPath = app?.getPath?.("userData") ?? null;
|
||||
} catch {
|
||||
try {
|
||||
const { app } = require("electron");
|
||||
userDataPath = app?.getPath?.("userData") ?? null;
|
||||
} catch {
|
||||
// Electron not available yet
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!userDataPath) return null;
|
||||
|
||||
logDir = path.join(userDataPath, "crash-logs");
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
return logDir;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function todayFileName() {
|
||||
const d = new Date();
|
||||
const ymd = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
||||
return `crash-${ymd}.log`;
|
||||
}
|
||||
|
||||
function buildEntry(source, err, extra) {
|
||||
const error = err instanceof Error ? err : new Error(String(err ?? "unknown"));
|
||||
|
||||
let mem;
|
||||
try {
|
||||
const m = process.memoryUsage();
|
||||
mem = {
|
||||
rss: Math.round(m.rss / 1048576),
|
||||
heapUsed: Math.round(m.heapUsed / 1048576),
|
||||
heapTotal: Math.round(m.heapTotal / 1048576),
|
||||
};
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Extract extra properties from the error object (code, errno, syscall, etc.)
|
||||
const errorMeta = {};
|
||||
for (const key of ["code", "errno", "syscall", "hostname", "port", "signal", "level"]) {
|
||||
if (error[key] !== undefined) {
|
||||
errorMeta[key] = error[key];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timestamp: new Date().toISOString(),
|
||||
source,
|
||||
message: error.message || String(err),
|
||||
stack: error.stack || undefined,
|
||||
errorMeta: Object.keys(errorMeta).length > 0 ? errorMeta : undefined,
|
||||
extra: extra || undefined,
|
||||
pid: process.pid,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
version: electronApp?.getVersion?.() ?? "unknown",
|
||||
electronVersion: process.versions?.electron ?? "unknown",
|
||||
osVersion: os.release(),
|
||||
memoryMB: mem,
|
||||
activeSessionCount: sessionsMap?.size ?? -1,
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Write a crash/error entry to today's log file (sync, safe for use in
|
||||
* uncaughtException handlers).
|
||||
*/
|
||||
function captureError(source, err, extra) {
|
||||
try {
|
||||
const dir = ensureLogDir();
|
||||
if (!dir) return;
|
||||
|
||||
const entry = buildEntry(source, err, extra);
|
||||
const filePath = path.join(dir, todayFileName());
|
||||
fs.appendFileSync(filePath, JSON.stringify(entry) + "\n", "utf-8");
|
||||
} catch {
|
||||
// Never throw from the crash logger itself.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete log files older than LOG_RETENTION_DAYS.
|
||||
*/
|
||||
function pruneOldLogs() {
|
||||
try {
|
||||
const dir = ensureLogDir();
|
||||
if (!dir) return;
|
||||
|
||||
const cutoff = Date.now() - LOG_RETENTION_DAYS * 86400000;
|
||||
const files = fs.readdirSync(dir);
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
|
||||
try {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = fs.statSync(filePath);
|
||||
if (stat.mtimeMs < cutoff) {
|
||||
fs.unlinkSync(filePath);
|
||||
console.log(`[CrashLog] Pruned old log: ${file}`);
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// IPC handlers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Count newlines in a file by streaming instead of reading entire content.
|
||||
*/
|
||||
async function countLines(filePath) {
|
||||
return new Promise((resolve) => {
|
||||
let count = 0;
|
||||
const stream = fs.createReadStream(filePath, { encoding: "utf-8" });
|
||||
stream.on("data", (chunk) => {
|
||||
for (let i = 0; i < chunk.length; i++) {
|
||||
if (chunk[i] === "\n") count++;
|
||||
}
|
||||
});
|
||||
stream.on("end", () => resolve(count));
|
||||
stream.on("error", () => resolve(0));
|
||||
});
|
||||
}
|
||||
|
||||
async function listLogs() {
|
||||
const dir = ensureLogDir();
|
||||
if (!dir) return [];
|
||||
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
const results = [];
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
|
||||
try {
|
||||
const filePath = path.join(dir, file);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const entryCount = await countLines(filePath);
|
||||
results.push({
|
||||
fileName: file,
|
||||
date: file.replace("crash-", "").replace(".log", ""),
|
||||
size: stat.size,
|
||||
entryCount,
|
||||
});
|
||||
} catch {
|
||||
// skip unreadable files
|
||||
}
|
||||
}
|
||||
|
||||
// Sort newest first
|
||||
results.sort((a, b) => b.date.localeCompare(a.date));
|
||||
return results;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_READ_ENTRIES = 500;
|
||||
// Read up to ~256KB from the tail of the file to cap memory/CPU usage
|
||||
const MAX_TAIL_BYTES = 256 * 1024;
|
||||
|
||||
async function readLog(fileName) {
|
||||
const dir = ensureLogDir();
|
||||
if (!dir) return [];
|
||||
|
||||
// Validate fileName to prevent path traversal
|
||||
if (!/^crash-\d{4}-\d{2}-\d{2}\.log$/.test(fileName)) return [];
|
||||
|
||||
try {
|
||||
const filePath = path.join(dir, fileName);
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
|
||||
let content;
|
||||
if (stat.size > MAX_TAIL_BYTES) {
|
||||
// Only read the tail of the file
|
||||
const buf = Buffer.alloc(MAX_TAIL_BYTES);
|
||||
const fd = await fs.promises.open(filePath, "r");
|
||||
try {
|
||||
await fd.read(buf, 0, MAX_TAIL_BYTES, stat.size - MAX_TAIL_BYTES);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
const raw = buf.toString("utf-8");
|
||||
// Drop the first partial line
|
||||
const firstNewline = raw.indexOf("\n");
|
||||
content = firstNewline >= 0 ? raw.slice(firstNewline + 1) : raw;
|
||||
} else {
|
||||
content = await fs.promises.readFile(filePath, "utf-8");
|
||||
}
|
||||
|
||||
const lines = content.split("\n").filter(Boolean);
|
||||
// Only parse the last MAX_READ_ENTRIES lines
|
||||
const tail = lines.slice(-MAX_READ_ENTRIES);
|
||||
const entries = [];
|
||||
for (const line of tail) {
|
||||
try {
|
||||
entries.push(JSON.parse(line));
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function clearLogs() {
|
||||
const dir = ensureLogDir();
|
||||
if (!dir) return { deletedCount: 0 };
|
||||
|
||||
let deletedCount = 0;
|
||||
try {
|
||||
const files = await fs.promises.readdir(dir);
|
||||
for (const file of files) {
|
||||
if (!file.startsWith("crash-") || !file.endsWith(".log")) continue;
|
||||
try {
|
||||
await fs.promises.unlink(path.join(dir, file));
|
||||
deletedCount++;
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// skip
|
||||
}
|
||||
return { deletedCount };
|
||||
}
|
||||
|
||||
async function openDir() {
|
||||
const dir = ensureLogDir();
|
||||
if (!dir || !electronShell?.openPath) return { success: false };
|
||||
try {
|
||||
const errorMessage = await electronShell.openPath(dir);
|
||||
// shell.openPath resolves to an error string on failure, empty string on success
|
||||
return { success: !errorMessage };
|
||||
} catch {
|
||||
return { success: false };
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lifecycle
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function init(deps) {
|
||||
const { electronModule, sessions } = deps;
|
||||
const { app, shell } = electronModule || {};
|
||||
electronApp = app;
|
||||
electronShell = shell;
|
||||
sessionsMap = sessions || null;
|
||||
|
||||
ensureLogDir();
|
||||
pruneOldLogs();
|
||||
|
||||
console.log(`[CrashLog] Crash log directory: ${logDir}`);
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:crashLogs:list", async () => listLogs());
|
||||
ipcMain.handle("netcatty:crashLogs:read", async (_event, { fileName }) => readLog(fileName));
|
||||
ipcMain.handle("netcatty:crashLogs:clear", async () => clearLogs());
|
||||
ipcMain.handle("netcatty:crashLogs:openDir", async () => openDir());
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init,
|
||||
captureError,
|
||||
registerHandlers,
|
||||
};
|
||||
@@ -145,16 +145,30 @@ function clearPendingApprovals(chatSessionId) {
|
||||
function cancelAllPtyExecs() {
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
try {
|
||||
entry.cleanup();
|
||||
// Send Ctrl+C to kill the running command
|
||||
if (entry.ptyStream && typeof entry.ptyStream.write === "function") {
|
||||
entry.ptyStream.write("\x03");
|
||||
}
|
||||
if (typeof entry.cancel === "function") entry.cancel();
|
||||
else entry.cleanup();
|
||||
} catch { /* ignore */ }
|
||||
activePtyExecs.delete(marker);
|
||||
}
|
||||
activePtyExecs.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel PTY executions scoped to a specific chat session.
|
||||
* Only affects entries whose chatSessionId matches.
|
||||
*/
|
||||
function cancelPtyExecsForSession(chatSessionId) {
|
||||
if (!chatSessionId) return;
|
||||
for (const [marker, entry] of activePtyExecs) {
|
||||
if (entry.chatSessionId !== chatSessionId) continue;
|
||||
try {
|
||||
if (typeof entry.cancel === "function") entry.cancel();
|
||||
else entry.cleanup();
|
||||
} catch { /* ignore */ }
|
||||
activePtyExecs.delete(marker);
|
||||
}
|
||||
}
|
||||
|
||||
function init(deps) {
|
||||
sessions = deps.sessions;
|
||||
sftpClients = deps.sftpClients;
|
||||
@@ -598,6 +612,7 @@ function handleExec(params) {
|
||||
trackForCancellation: activePtyExecs,
|
||||
timeoutMs: commandTimeoutMs,
|
||||
shellKind: session.shellKind,
|
||||
expectedPrompt: session.lastIdlePrompt || "",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -966,7 +981,9 @@ module.exports = {
|
||||
getScopedSessionIds,
|
||||
getOrCreateHost,
|
||||
buildMcpServerConfig,
|
||||
activePtyExecs,
|
||||
cancelAllPtyExecs,
|
||||
cancelPtyExecsForSession,
|
||||
cleanupScopedMetadata,
|
||||
cleanup,
|
||||
setMainWindowGetter,
|
||||
|
||||
@@ -3,19 +3,38 @@
|
||||
* Extracted from main.cjs for single responsibility
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const net = require("node:net");
|
||||
const { Client: SSHClient } = require("ssh2");
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const { connectThroughChain } = require("./sshBridge.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
isKeyEncrypted,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Active port forwarding tunnels
|
||||
const portForwardingTunnels = new Map();
|
||||
|
||||
function cleanupChainConnections(connections) {
|
||||
if (!Array.isArray(connections)) return;
|
||||
for (const chainConn of connections) {
|
||||
try { chainConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
function isTunnelCancelled(tunnelState) {
|
||||
return Boolean(tunnelState?.cancelled);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
@@ -44,11 +63,30 @@ async function startPortForward(event, payload) {
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
certificate,
|
||||
keyId,
|
||||
passphrase,
|
||||
proxy,
|
||||
jumpHosts = [],
|
||||
identityFilePaths,
|
||||
} = payload;
|
||||
|
||||
const conn = new SSHClient();
|
||||
const sender = event.sender;
|
||||
const hasJumpHosts = jumpHosts.length > 0;
|
||||
const hasProxy = !!proxy;
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
const tunnelState = {
|
||||
type,
|
||||
conn,
|
||||
pendingConn: null,
|
||||
server: null,
|
||||
chainConnections,
|
||||
status: 'connecting',
|
||||
webContentsId: sender.id,
|
||||
cancelled: false,
|
||||
};
|
||||
|
||||
const sendStatus = (status, error = null) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
@@ -66,9 +104,53 @@ async function startPortForward(event, payload) {
|
||||
tryKeyboard: true,
|
||||
};
|
||||
|
||||
if (privateKey) {
|
||||
const hasCertificate = typeof certificate === "string" && certificate.trim().length > 0;
|
||||
|
||||
if (hasCertificate) {
|
||||
connectOpts.agent = new NetcattyAgent({
|
||||
mode: "certificate",
|
||||
webContents: sender,
|
||||
meta: {
|
||||
label: keyId || username || "",
|
||||
certificate,
|
||||
privateKey,
|
||||
passphrase,
|
||||
},
|
||||
});
|
||||
} else if (privateKey) {
|
||||
connectOpts.privateKey = privateKey;
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. SSH config IdentityFile)
|
||||
// when no explicit key/certificate was already configured.
|
||||
if (!connectOpts.privateKey && !connectOpts.agent && identityFilePaths?.length > 0) {
|
||||
for (const keyPath of identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connectOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
hostname,
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connectOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[PortForward] Failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (passphrase) {
|
||||
connectOpts.passphrase = passphrase;
|
||||
}
|
||||
@@ -76,19 +158,101 @@ async function startPortForward(event, payload) {
|
||||
connectOpts.password = password;
|
||||
}
|
||||
|
||||
// Get default keys
|
||||
const defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
sendStatus('connecting');
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey,
|
||||
password,
|
||||
passphrase,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
let defaultKeys = [];
|
||||
try {
|
||||
// Get default keys
|
||||
defaultKeys = await findAllDefaultPrivateKeysFromHelper();
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
|
||||
// Build auth handler using shared helper
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connectOpts.privateKey,
|
||||
password,
|
||||
passphrase: connectOpts.passphrase,
|
||||
agent: connectOpts.agent,
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[PortForward]",
|
||||
defaultKeys,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
|
||||
if (hasJumpHosts) {
|
||||
const chainResult = await connectThroughChain(
|
||||
event,
|
||||
{
|
||||
hostname,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
privateKey,
|
||||
passphrase,
|
||||
proxy,
|
||||
jumpHosts,
|
||||
_defaultKeys: defaultKeys,
|
||||
_connectionsRef: chainConnections,
|
||||
_tunnelRef: tunnelState,
|
||||
},
|
||||
jumpHosts,
|
||||
hostname,
|
||||
port,
|
||||
tunnelId,
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
cleanupChainConnections(chainConnections);
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
} else if (hasProxy) {
|
||||
connectionSocket = await createProxySocket(proxy, hostname, port, {
|
||||
onSocket: (socket) => {
|
||||
tunnelState.pendingConn = socket;
|
||||
},
|
||||
});
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
try { connectionSocket?.end?.(); } catch { /* ignore */ }
|
||||
try { connectionSocket?.destroy?.(); } catch { /* ignore */ }
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
tunnelState.pendingConn = null;
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
}
|
||||
} catch (err) {
|
||||
if (isTunnelCancelled(tunnelState)) {
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
return { tunnelId, success: false, cancelled: true };
|
||||
}
|
||||
tunnelState.cancelled = true;
|
||||
if (tunnelState.pendingConn) {
|
||||
try { tunnelState.pendingConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
cleanupChainConnections(tunnelState.chainConnections);
|
||||
if (connectionSocket) {
|
||||
try { connectionSocket.end?.(); } catch { /* ignore */ }
|
||||
try { connectionSocket.destroy?.(); } catch { /* ignore */ }
|
||||
}
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
sendStatus('error', err?.message || String(err));
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
conn.on("keyboard-interactive", createKeyboardInteractiveHandler({
|
||||
@@ -133,20 +297,20 @@ async function startPortForward(event, payload) {
|
||||
console.error(`[PortForward] Server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
conn.end();
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Local forwarding active: ${bindAddress}:${localPort} -> ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'local',
|
||||
conn,
|
||||
server,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
tunnelState.type = 'local';
|
||||
tunnelState.conn = conn;
|
||||
tunnelState.server = server;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
tunnelState.status = 'active';
|
||||
tunnelState.webContentsId = sender.id;
|
||||
tunnelState.pendingConn = null;
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -165,12 +329,14 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
|
||||
console.log(`[PortForward] Remote forwarding active: remote ${bindAddress}:${localPort} -> local ${remoteHost}:${remotePort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'remote',
|
||||
conn,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
tunnelState.type = 'remote';
|
||||
tunnelState.conn = conn;
|
||||
tunnelState.server = null;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
tunnelState.status = 'active';
|
||||
tunnelState.webContentsId = sender.id;
|
||||
tunnelState.pendingConn = null;
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -273,20 +439,20 @@ async function startPortForward(event, payload) {
|
||||
console.error(`[PortForward] SOCKS server error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
conn.end();
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
server.listen(localPort, bindAddress, () => {
|
||||
console.log(`[PortForward] Dynamic SOCKS5 proxy active on ${bindAddress}:${localPort}`);
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'dynamic',
|
||||
conn,
|
||||
server,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
tunnelState.type = 'dynamic';
|
||||
tunnelState.conn = conn;
|
||||
tunnelState.server = server;
|
||||
tunnelState.chainConnections = chainConnections;
|
||||
tunnelState.status = 'active';
|
||||
tunnelState.webContentsId = sender.id;
|
||||
tunnelState.pendingConn = null;
|
||||
portForwardingTunnels.set(tunnelId, tunnelState);
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
@@ -297,10 +463,11 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
});
|
||||
|
||||
conn.once('error', (err) => {
|
||||
conn.on('error', (err) => {
|
||||
console.error(`[PortForward] SSH error:`, err.message);
|
||||
if (settled) return;
|
||||
sendStatus('error', err.message);
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
cleanupChainConnections(chainConnections);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
@@ -314,6 +481,12 @@ async function startPortForward(event, payload) {
|
||||
if (tunnel.server) {
|
||||
try { tunnel.server.close(); } catch { }
|
||||
}
|
||||
if (Array.isArray(tunnel.chainConnections)) {
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
}
|
||||
if (tunnel.pendingConn) {
|
||||
try { tunnel.pendingConn.end(); } catch { /* ignore */ }
|
||||
}
|
||||
sendStatus('inactive');
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
}
|
||||
@@ -329,18 +502,6 @@ async function startPortForward(event, payload) {
|
||||
}
|
||||
});
|
||||
|
||||
sendStatus('connecting');
|
||||
// Register the connection BEFORE the handshake starts so that
|
||||
// stopPortForwardByRuleId can find and kill it at any point,
|
||||
// including during the SSH handshake window. The conn.on('ready')
|
||||
// handler updates the entry to include the server object later.
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type,
|
||||
conn,
|
||||
server: null,
|
||||
status: 'connecting',
|
||||
webContentsId: sender.id,
|
||||
});
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
}
|
||||
@@ -363,6 +524,10 @@ async function stopPortForward(event, payload) {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.pendingConn) {
|
||||
tunnel.pendingConn.end();
|
||||
}
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
@@ -417,6 +582,10 @@ function stopAllPortForwards() {
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.pendingConn) {
|
||||
tunnel.pendingConn.end();
|
||||
}
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
@@ -446,6 +615,8 @@ function stopPortForwardByRuleId(_event, { ruleId }) {
|
||||
// close handler resolves gracefully instead of rejecting.
|
||||
tunnel.cancelled = true;
|
||||
if (tunnel.server) tunnel.server.close();
|
||||
if (tunnel.pendingConn) tunnel.pendingConn.end();
|
||||
cleanupChainConnections(tunnel.chainConnections);
|
||||
if (tunnel.conn) tunnel.conn.end();
|
||||
// Don't delete here — let the conn.on('close') handler delete
|
||||
// the entry so it can read tunnel.cancelled first.
|
||||
|
||||
@@ -15,9 +15,12 @@ const net = require("node:net");
|
||||
* @param {string} [proxy.password] - Optional password for auth
|
||||
* @param {string} targetHost - Target host to connect through proxy
|
||||
* @param {number} targetPort - Target port to connect through proxy
|
||||
* @param {Object} [options]
|
||||
* @param {(socket: net.Socket) => void} [options.onSocket] - Called immediately with the underlying socket
|
||||
* @returns {Promise<net.Socket>} Connected socket through proxy
|
||||
*/
|
||||
function createProxySocket(proxy, targetHost, targetPort) {
|
||||
function createProxySocket(proxy, targetHost, targetPort, options = {}) {
|
||||
const { onSocket } = options;
|
||||
return new Promise((resolve, reject) => {
|
||||
if (proxy.type === 'http') {
|
||||
// HTTP CONNECT proxy
|
||||
@@ -45,6 +48,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
};
|
||||
socket.on('data', onData);
|
||||
});
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
socket.on('error', reject);
|
||||
} else if (proxy.type === 'socks5') {
|
||||
// SOCKS5 proxy
|
||||
@@ -123,6 +127,7 @@ function createProxySocket(proxy, targetHost, targetPort) {
|
||||
|
||||
socket.on('data', onData);
|
||||
});
|
||||
try { onSocket?.(socket); } catch { /* ignore */ }
|
||||
socket.on('error', reject);
|
||||
} else {
|
||||
reject(new Error(`Unknown proxy type: ${proxy.type}`));
|
||||
|
||||
@@ -22,12 +22,14 @@ try {
|
||||
const { NetcattyAgent } = require("./netcattyAgent.cjs");
|
||||
const fileWatcherBridge = require("./fileWatcherBridge.cjs");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
const { createProxySocket } = require("./proxyUtils.cjs");
|
||||
const {
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
isKeyEncrypted,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
getAvailableAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
@@ -225,6 +227,11 @@ const requireSftpChannel = async (client) => {
|
||||
return sftp;
|
||||
};
|
||||
|
||||
const realpathAsync = (sftp, targetPath) =>
|
||||
new Promise((resolve, reject) => {
|
||||
sftp.realpath(targetPath, (err, absPath) => (err ? reject(err) : resolve(absPath)));
|
||||
});
|
||||
|
||||
const statAsync = (sftp, targetPath) =>
|
||||
new Promise((resolve, reject) => {
|
||||
sftp.stat(targetPath, (err, stats) => (err ? reject(err) : resolve(stats)));
|
||||
@@ -425,6 +432,18 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send SFTP connection progress to the renderer for user-visible logging
|
||||
*/
|
||||
function sendSftpProgress(sender, sessionId, label, status, detail) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send("netcatty:sftp:connection-progress", { sessionId, label, status, detail });
|
||||
} catch {
|
||||
// Ignore destroyed webContents
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
@@ -439,9 +458,10 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
const jump = jumpHosts[i];
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === jumpHosts.length - 1;
|
||||
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
|
||||
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
|
||||
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: Connecting to ${hopLabel}...`);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'connecting');
|
||||
|
||||
const conn = new SSHClient();
|
||||
// Increase max listeners to prevent Node.js warning
|
||||
@@ -480,7 +500,59 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
connOpts.agent = authAgent;
|
||||
} else if (jump.privateKey) {
|
||||
connOpts.privateKey = jump.privateKey;
|
||||
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
|
||||
if (jump.passphrase) {
|
||||
connOpts.passphrase = jump.passphrase;
|
||||
} else if (isKeyEncrypted(jump.privateKey)) {
|
||||
// Key is encrypted but no passphrase provided — prompt the user
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
|
||||
const keyLabel = jump.label || hopLabel;
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
`SSH key for ${keyLabel}`,
|
||||
keyLabel,
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connOpts.privateKey;
|
||||
if (result?.cancelled) {
|
||||
throw new Error(`Passphrase entry cancelled for ${hopLabel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
if (!connOpts.privateKey && !connOpts.agent && jump.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of jump.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}: identity file ${resolvedPath} is encrypted, requesting passphrase`);
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}: loaded identity file ${resolvedPath}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[SFTP Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
@@ -500,12 +572,17 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
onAuthAttempt: (method) => {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', method);
|
||||
},
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
const hasUsableJumpProxy = !!(jump.proxy?.host && jump.proxy?.port);
|
||||
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
|
||||
if (effectiveHopProxy) {
|
||||
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22);
|
||||
connOpts.sock = currentSocket;
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
@@ -518,8 +595,12 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
|
||||
// Connect this hop
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.once('handshake', () => {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'authenticating');
|
||||
});
|
||||
conn.once('ready', () => {
|
||||
console.log(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} connected`);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'connected');
|
||||
resolve();
|
||||
});
|
||||
conn.on('error', (err) => {
|
||||
@@ -529,6 +610,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
return;
|
||||
}
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
|
||||
sendSftpProgress(sender, connId, hopLabel, 'error', err.message);
|
||||
reject(err);
|
||||
});
|
||||
conn.once('timeout', () => {
|
||||
@@ -536,13 +618,23 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
const sftpChainKiHandler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId: connId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}`,
|
||||
}));
|
||||
});
|
||||
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
||||
if (prompts && prompts.length > 0) {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'waiting for user input...');
|
||||
}
|
||||
const wrappedFinish = (...args) => {
|
||||
sendSftpProgress(sender, connId, hopLabel, 'auth-attempt', 'user responded');
|
||||
finish(...args);
|
||||
};
|
||||
sftpChainKiHandler(name, instructions, lang, prompts, wrappedFinish);
|
||||
});
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
|
||||
@@ -901,7 +993,69 @@ async function openSftp(event, options) {
|
||||
connectOpts.agent = authAgent;
|
||||
} else if (options.privateKey) {
|
||||
connectOpts.privateKey = options.privateKey;
|
||||
if (options.passphrase) connectOpts.passphrase = options.passphrase;
|
||||
if (options.passphrase) {
|
||||
connectOpts.passphrase = options.passphrase;
|
||||
} else if (isKeyEncrypted(options.privateKey)) {
|
||||
// Key is encrypted but no passphrase provided — prompt the user
|
||||
console.log(`[SFTP] Key is encrypted, requesting passphrase for ${options.hostname}`);
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
event.sender,
|
||||
`SSH key for ${options.hostname}`,
|
||||
options.hostname,
|
||||
options.hostname
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connectOpts.privateKey;
|
||||
if (result?.cancelled) {
|
||||
// Clean up any chain/proxy connections and proxy socket opened earlier
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch {}
|
||||
}
|
||||
if (connectionSocket) {
|
||||
try { connectionSocket.destroy(); } catch {}
|
||||
}
|
||||
// Use "authentication" in the message so the SFTP frontend's
|
||||
// isAuthError() check recognizes this and falls back to password.
|
||||
const err = new Error(`Authentication cancelled — passphrase not provided for ${options.hostname}`);
|
||||
err.level = 'client-authentication';
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
if (!connectOpts.privateKey && !connectOpts.agent && options.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of options.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connectOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
console.log(`[SFTP] Identity file ${resolvedPath} is encrypted, requesting passphrase`);
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
event.sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
options.hostname
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
delete connectOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`[SFTP] Loaded identity file ${resolvedPath}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[SFTP] Failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.password) connectOpts.password = options.password;
|
||||
@@ -917,6 +1071,9 @@ async function openSftp(event, options) {
|
||||
logPrefix: "[SFTP]",
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
onAuthAttempt: (method) => {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', method);
|
||||
},
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
@@ -930,7 +1087,17 @@ async function openSftp(event, options) {
|
||||
});
|
||||
|
||||
// Add keyboard-interactive listener BEFORE connecting
|
||||
client.on("keyboard-interactive", kiHandler);
|
||||
// Wrap to emit progress events for the SFTP connection log
|
||||
client.on("keyboard-interactive", (name, instructions, lang, prompts, finish) => {
|
||||
if (prompts && prompts.length > 0) {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'waiting for user input...');
|
||||
}
|
||||
const wrappedFinish = (...args) => {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'auth-attempt', 'user responded');
|
||||
finish(...args);
|
||||
};
|
||||
kiHandler(name, instructions, lang, prompts, wrappedFinish);
|
||||
});
|
||||
|
||||
// Increase timeout to allow for keyboard-interactive auth
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
@@ -978,14 +1145,24 @@ async function openSftp(event, options) {
|
||||
sshClient.removeListener('error', onError);
|
||||
sshClient.removeListener('end', onEnd);
|
||||
sshClient.removeListener('close', onClose);
|
||||
// Keep a catch-all error listener so post-ready errors (e.g. connection
|
||||
// drops during an active SFTP session) don't become uncaught exceptions.
|
||||
sshClient.on('error', (err) => {
|
||||
console.error(`[SFTP] Post-ready SSH error for ${connId}:`, err.message);
|
||||
});
|
||||
};
|
||||
|
||||
sshClient.on('error', onError);
|
||||
sshClient.on('end', onEnd);
|
||||
sshClient.on('close', onClose);
|
||||
|
||||
sshClient.once('handshake', () => {
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'authenticating');
|
||||
});
|
||||
|
||||
sshClient.once('ready', () => {
|
||||
cleanup();
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'connected');
|
||||
|
||||
if (options.sudo) {
|
||||
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
|
||||
@@ -1028,6 +1205,7 @@ async function openSftp(event, options) {
|
||||
}
|
||||
});
|
||||
|
||||
sendSftpProgress(event.sender, connId, options.hostname, 'connecting');
|
||||
try {
|
||||
sshClient.connect(connectOpts);
|
||||
} catch (e) {
|
||||
@@ -1586,6 +1764,62 @@ async function chmodSftp(event, payload) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the remote user's home directory.
|
||||
* Strategy: exec `echo ~` via SSH, fallback to SFTP realpath('.').
|
||||
*/
|
||||
async function getSftpHomeDir(_event, payload) {
|
||||
const { sftpId } = payload;
|
||||
const client = sftpClients.get(sftpId);
|
||||
if (!client) return { success: false, error: "SFTP session not found" };
|
||||
|
||||
// Method 1: SSH exec `echo ~` (with 5s timeout to avoid hanging on
|
||||
// hosts with blocking shell init scripts or forced commands)
|
||||
const sshClient = client.client;
|
||||
if (sshClient && typeof sshClient.exec === "function") {
|
||||
let execStream = null;
|
||||
try {
|
||||
const execPromise = new Promise((resolve, reject) => {
|
||||
sshClient.exec("echo ~", (err, stream) => {
|
||||
if (err) return reject(err);
|
||||
execStream = stream;
|
||||
let stdout = "";
|
||||
stream.on("close", (code) => resolve({ stdout, code }));
|
||||
stream.on("data", (data) => { stdout += data.toString(); });
|
||||
stream.stderr.on("data", () => {});
|
||||
});
|
||||
});
|
||||
const result = await Promise.race([
|
||||
execPromise,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), 5000)),
|
||||
]);
|
||||
const home = result.stdout?.trim();
|
||||
if (home && home.startsWith("/")) {
|
||||
return { success: true, homeDir: home };
|
||||
}
|
||||
} catch {
|
||||
// Timeout or error — kill the exec channel if still open
|
||||
try { execStream?.close?.(); } catch {}
|
||||
try { execStream?.destroy?.(); } catch {}
|
||||
// Fall through to SFTP realpath
|
||||
}
|
||||
}
|
||||
|
||||
// Method 2: SFTP realpath('.') — skip if result is '/' for non-root users
|
||||
// because some SFTP servers start in '/' rather than the user's home
|
||||
try {
|
||||
const sftp = await requireSftpChannel(client);
|
||||
const absPath = await realpathAsync(sftp, ".");
|
||||
if (absPath && absPath !== "/") {
|
||||
return { success: true, homeDir: absPath };
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
return { success: false, error: "Could not determine home directory" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for SFTP operations
|
||||
*/
|
||||
@@ -1604,6 +1838,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:sftp:rename", renameSftp);
|
||||
ipcMain.handle("netcatty:sftp:stat", statSftp);
|
||||
ipcMain.handle("netcatty:sftp:chmod", chmodSftp);
|
||||
ipcMain.handle("netcatty:sftp:homeDir", getSftpHomeDir);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,7 +11,20 @@ const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
|
||||
|
||||
/**
|
||||
* Quick check if file content looks like an SSH private key.
|
||||
* Rejects non-key files that happen to match the id_* filename pattern.
|
||||
*/
|
||||
function looksLikePrivateKey(content) {
|
||||
if (!content || typeof content !== "string") return false;
|
||||
const trimmed = content.trimStart();
|
||||
return trimmed.startsWith("-----BEGIN") ||
|
||||
trimmed.startsWith("openssh-key-v1") ||
|
||||
trimmed.startsWith("PuTTY-User-Key-File");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
@@ -21,6 +34,13 @@ const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
// Check for PuTTY PPK encrypted format (Encryption: aes256-cbc, etc.)
|
||||
// PPK keys with "Encryption: none" are unencrypted
|
||||
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
|
||||
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
@@ -73,14 +93,25 @@ function isKeyEncrypted(keyContent) {
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
|
||||
for (const name of sorted) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) continue; // Skip directories, FIFOs, sockets, etc.
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
if (!looksLikePrivateKey(privateKey)) continue;
|
||||
if (isKeyEncrypted(privateKey)) continue;
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
@@ -99,11 +130,24 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
|
||||
const promises = sorted.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) return null;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (!looksLikePrivateKey(privateKey)) return null;
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
@@ -213,7 +257,7 @@ async function getAvailableAgentSocket() {
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride, onAuthAttempt } = options;
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
@@ -259,7 +303,7 @@ function buildAuthHandler(options) {
|
||||
|
||||
// If only simple auth methods and no fallback keys needed, use array-based handler
|
||||
if (hasExplicitAuth && !hasFallbackOptions) {
|
||||
const authMethods = [];
|
||||
const authMethods = ["none"]; // Always try none first per RFC 4252
|
||||
if (effectiveAgent) authMethods.push("agent");
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
@@ -380,11 +424,29 @@ function buildAuthHandler(options) {
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
let lastAttemptedLabel = null;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
let triedNone = false;
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
// Per RFC 4252, always try "none" first to discover available methods
|
||||
// and to support passwordless login (e.g. embedded devices).
|
||||
// This matches the behavior of OpenSSH and Tabby.
|
||||
if (methodsLeft === null && !triedNone) {
|
||||
triedNone = true;
|
||||
lastAttemptedLabel = "none (no credentials)";
|
||||
onAuthAttempt?.("none (no credentials)");
|
||||
return callback("none");
|
||||
}
|
||||
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
// Log rejection of previous method (authHandler is called again when server rejects)
|
||||
if (lastAttemptedLabel && !partialSuccess) {
|
||||
onAuthAttempt?.(`${lastAttemptedLabel} rejected`);
|
||||
}
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
@@ -394,9 +456,21 @@ function buildAuthHandler(options) {
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
lastAttemptedLabel = "SSH agent";
|
||||
onAuthAttempt?.("SSH agent");
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
// Build a readable label for the key
|
||||
const keyLabel = method.id.startsWith("publickey-default-")
|
||||
? `key ${method.id.replace("publickey-default-", "")}`
|
||||
: method.id.startsWith("publickey-encrypted-")
|
||||
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
|
||||
: method.id === "publickey-user"
|
||||
? "configured key"
|
||||
: method.id;
|
||||
lastAttemptedLabel = keyLabel;
|
||||
onAuthAttempt?.(keyLabel);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
@@ -408,15 +482,20 @@ function buildAuthHandler(options) {
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
lastAttemptedLabel = "password";
|
||||
onAuthAttempt?.("password");
|
||||
return callback({
|
||||
type: "password",
|
||||
username,
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
lastAttemptedLabel = "keyboard-interactive";
|
||||
onAuthAttempt?.("keyboard-interactive");
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
onAuthAttempt?.("all methods exhausted");
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
@@ -577,7 +656,9 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
PREFERRED_KEY_NAMES,
|
||||
SSH_KEY_PATTERN,
|
||||
looksLikePrivateKey,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
|
||||
@@ -23,9 +23,24 @@ const {
|
||||
getSshAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
// Default SSH key names in priority order (preferred keys tried first)
|
||||
const PREFERRED_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
// Match any private key file: id_* but not *.pub
|
||||
const SSH_KEY_PATTERN = /^id_[\w-]+$/;
|
||||
|
||||
/**
|
||||
* Quick check if file content looks like an SSH private key.
|
||||
* Rejects non-key files that happen to match the id_* filename pattern.
|
||||
*/
|
||||
function looksLikePrivateKey(content) {
|
||||
if (!content || typeof content !== "string") return false;
|
||||
const trimmed = content.trimStart();
|
||||
return trimmed.startsWith("-----BEGIN") ||
|
||||
trimmed.startsWith("openssh-key-v1") ||
|
||||
trimmed.startsWith("PuTTY-User-Key-File");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
@@ -33,6 +48,12 @@ const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
// Check for PuTTY PPK encrypted format
|
||||
const ppkEncMatch = keyContent.match(/^Encryption:\s*(.+)$/m);
|
||||
if (ppkEncMatch && ppkEncMatch[1].trim() !== "none") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
@@ -82,14 +103,31 @@ function isKeyEncrypted(keyContent) {
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
log("Searching for default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
// Scan ~/.ssh/ for all files matching id_* (same as Tabby/OpenSSH),
|
||||
// with preferred key types tried first
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
// Sort: preferred keys first (in order), then rest alphabetically
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
log("Searching for default SSH keys", { sshDir, found: sorted });
|
||||
|
||||
for (const name of sorted) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) continue;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
// Skip encrypted keys - they require a passphrase and would abort
|
||||
// authentication before password/keyboard-interactive can be tried
|
||||
if (!looksLikePrivateKey(privateKey)) {
|
||||
log("Skipping non-key file", { keyPath, keyName: name });
|
||||
continue;
|
||||
}
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
log("Key file read", { keyPath, keyName: name, encrypted, keyLength: privateKey.length });
|
||||
if (encrypted) {
|
||||
@@ -114,13 +152,28 @@ async function findDefaultPrivateKey() {
|
||||
*/
|
||||
async function findAllDefaultPrivateKeys() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
log("Searching for ALL default SSH keys", { sshDir, keyNames: DEFAULT_KEY_NAMES });
|
||||
let allNames = [];
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
allNames = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => allNames.includes(n));
|
||||
const rest = allNames.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
const sorted = [...preferred, ...rest];
|
||||
log("Searching for ALL default SSH keys", { sshDir, found: sorted });
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const promises = sorted.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const stat = await fs.promises.stat(keyPath);
|
||||
if (!stat.isFile()) return null;
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (!looksLikePrivateKey(privateKey)) {
|
||||
log("Skipping non-key file", { keyPath, keyName: name });
|
||||
return null;
|
||||
}
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (!encrypted) {
|
||||
log("Found default key for fallback", { keyPath, keyName: name });
|
||||
@@ -330,12 +383,12 @@ function init(deps) {
|
||||
*/
|
||||
async function connectThroughChain(event, options, jumpHosts, targetHost, targetPort, sessionId) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
const connections = options?._connectionsRef || [];
|
||||
let currentSocket = null;
|
||||
|
||||
const sendProgress = (hop, total, label, status) => {
|
||||
const sendProgress = (hop, total, label, status, error) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:chain:progress", { hop, total, label, status });
|
||||
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -347,11 +400,15 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
const jump = jumpHosts[i];
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === jumpHosts.length - 1;
|
||||
const hopLabel = jump.label || `${jump.hostname}:${jump.port || 22}`;
|
||||
const hopLabel = jump.label || (jump.hostname.includes(':') && !jump.hostname.startsWith('[') ? `[${jump.hostname}]:${jump.port || 22}` : `${jump.hostname}:${jump.port || 22}`);
|
||||
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connecting');
|
||||
|
||||
const conn = new SSHClient();
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = conn;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
|
||||
// Build connection options
|
||||
const connOpts = {
|
||||
@@ -387,7 +444,64 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
connOpts.agent = authAgent;
|
||||
} else if (jump.privateKey) {
|
||||
connOpts.privateKey = jump.privateKey;
|
||||
if (jump.passphrase) connOpts.passphrase = jump.passphrase;
|
||||
if (jump.passphrase) {
|
||||
connOpts.passphrase = jump.passphrase;
|
||||
} else if (isKeyEncrypted(jump.privateKey)) {
|
||||
// Key is encrypted but no passphrase provided — prompt the user
|
||||
console.log(`[Chain] Hop ${i + 1}: key is encrypted, requesting passphrase`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'passphrase required');
|
||||
const keyLabel = jump.label || hopLabel;
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
`SSH key for ${keyLabel}`,
|
||||
keyLabel,
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
// No passphrase (cancelled/skipped/timeout) — remove the encrypted
|
||||
// key so buildAuthHandler won't try it and stall auth.
|
||||
delete connOpts.privateKey;
|
||||
if (result?.cancelled) {
|
||||
throw new Error(`Passphrase entry cancelled for ${hopLabel}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
if (!connOpts.privateKey && !connOpts.agent && jump.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of jump.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connOpts.privateKey = keyContent;
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
console.log(`[Chain] Hop ${i + 1}: identity file ${resolvedPath} is encrypted, requesting passphrase`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'passphrase required');
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
hopLabel
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
// Cancelled/skipped/timeout — clear encrypted key, try next file
|
||||
delete connOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
console.log(`[Chain] Hop ${i + 1}: loaded identity file ${resolvedPath}`);
|
||||
break;
|
||||
} catch (err) {
|
||||
console.warn(`[Chain] Hop ${i + 1}: failed to read identity file ${keyPath}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (jump.password) connOpts.password = jump.password;
|
||||
@@ -406,12 +520,28 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
logPrefix: `[Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
onAuthAttempt: (method) => {
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', method);
|
||||
},
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
// If first hop and proxy is configured, connect through proxy
|
||||
if (isFirst && options.proxy) {
|
||||
currentSocket = await createProxySocket(options.proxy, jump.hostname, jump.port || 22);
|
||||
const hasUsableJumpProxy = !!(jump.proxy?.host && jump.proxy?.port);
|
||||
const effectiveHopProxy = isFirst ? ((hasUsableJumpProxy ? jump.proxy : null) || options.proxy) : null;
|
||||
if (effectiveHopProxy) {
|
||||
currentSocket = await createProxySocket(effectiveHopProxy, jump.hostname, jump.port || 22, {
|
||||
onSocket: (socket) => {
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = socket;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
},
|
||||
});
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = null;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
connOpts.sock = currentSocket;
|
||||
delete connOpts.host;
|
||||
delete connOpts.port;
|
||||
@@ -424,28 +554,48 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
|
||||
// Connect this hop
|
||||
await new Promise((resolve, reject) => {
|
||||
conn.once('handshake', () => {
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} handshake complete`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'authenticating');
|
||||
});
|
||||
conn.once('ready', () => {
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} connected`);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'connected');
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = null;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
conn.once('error', (err) => {
|
||||
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} error:`, err.message);
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'error');
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', err.message);
|
||||
reject(err);
|
||||
});
|
||||
conn.once('timeout', () => {
|
||||
console.error(`[Chain] Hop ${i + 1}/${totalHops}: ${hopLabel} timeout`);
|
||||
reject(new Error(`Connection timeout to ${hopLabel}`));
|
||||
const errMsg = `Connection timeout to ${hopLabel}`;
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'error', errMsg);
|
||||
reject(new Error(errMsg));
|
||||
});
|
||||
// Handle keyboard-interactive authentication for jump hosts (2FA/MFA)
|
||||
conn.on('keyboard-interactive', createKeyboardInteractiveHandler({
|
||||
const chainKiHandler = createKeyboardInteractiveHandler({
|
||||
sender,
|
||||
sessionId,
|
||||
hostname: hopLabel,
|
||||
password: jump.password,
|
||||
logPrefix: `[Chain] Hop ${i + 1}/${totalHops}`,
|
||||
}));
|
||||
});
|
||||
conn.on('keyboard-interactive', (name, instructions, lang, prompts, finish) => {
|
||||
if (prompts && prompts.length > 0) {
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'waiting for user input...');
|
||||
}
|
||||
const wrappedFinish = (...args) => {
|
||||
sendProgress(i + 1, totalHops + 1, hopLabel, 'auth-attempt', 'user responded');
|
||||
finish(...args);
|
||||
};
|
||||
chainKiHandler(name, instructions, lang, prompts, wrappedFinish);
|
||||
});
|
||||
console.log(`[Chain] Hop ${i + 1}/${totalHops}: Connecting to ${hopLabel}...`);
|
||||
conn.connect(connOpts);
|
||||
});
|
||||
@@ -488,6 +638,10 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
sendProgress
|
||||
};
|
||||
} catch (err) {
|
||||
if (options?._tunnelRef) {
|
||||
options._tunnelRef.pendingConn = null;
|
||||
options._tunnelRef.chainConnections = connections;
|
||||
}
|
||||
// Cleanup on error
|
||||
for (const conn of connections) {
|
||||
try { conn.end(); } catch { }
|
||||
@@ -508,9 +662,9 @@ async function startSSHSession(event, options) {
|
||||
const rows = options.rows || 24;
|
||||
const sender = event.sender;
|
||||
|
||||
const sendProgress = (hop, total, label, status) => {
|
||||
const sendProgress = (hop, total, label, status, error) => {
|
||||
if (!sender.isDestroyed()) {
|
||||
sender.send("netcatty:chain:progress", { hop, total, label, status });
|
||||
sender.send("netcatty:chain:progress", { sessionId, hop, total, label, status, error });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -581,6 +735,41 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// Read identity files from local paths (e.g. from SSH config IdentityFile)
|
||||
// Only if no explicit key was already configured
|
||||
if (!connectOpts.privateKey && !connectOpts.agent && options.identityFilePaths?.length > 0) {
|
||||
for (const keyPath of options.identityFilePaths) {
|
||||
try {
|
||||
const resolvedPath = keyPath.startsWith("~/")
|
||||
? path.join(os.homedir(), keyPath.slice(2))
|
||||
: keyPath;
|
||||
const keyContent = await fs.promises.readFile(resolvedPath, "utf8");
|
||||
connectOpts.privateKey = keyContent;
|
||||
// Check if key is encrypted — if so, prompt for passphrase
|
||||
if (isKeyEncrypted(keyContent)) {
|
||||
log("Identity file is encrypted, requesting passphrase", { keyPath: resolvedPath });
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
resolvedPath,
|
||||
path.basename(resolvedPath),
|
||||
options.hostname
|
||||
);
|
||||
if (result?.passphrase) {
|
||||
connectOpts.passphrase = result.passphrase;
|
||||
} else {
|
||||
// Cancelled/skipped/timeout — clear encrypted key, try next file
|
||||
delete connectOpts.privateKey;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log("Loaded identity file", { keyPath: resolvedPath, encrypted: isKeyEncrypted(keyContent) });
|
||||
break; // Use the first successfully loaded key
|
||||
} catch (err) {
|
||||
log("Failed to read identity file", { keyPath, error: err.message });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options.password && typeof options.password === "string" && options.password.trim().length > 0) {
|
||||
connectOpts.password = options.password;
|
||||
}
|
||||
@@ -659,7 +848,7 @@ async function startSSHSession(event, options) {
|
||||
let lastTriedMethod = null;
|
||||
|
||||
if (authAgent) {
|
||||
const order = ["agent"];
|
||||
const order = ["none", "agent"];
|
||||
if (connectOpts.password) order.push("password");
|
||||
// Add default key fallback if available and no user key configured
|
||||
// Must also set connectOpts.privateKey for ssh2 to actually try publickey auth
|
||||
@@ -737,8 +926,9 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
// Use dynamic authHandler if we have multiple auth options
|
||||
if (authMethods.length > 1) {
|
||||
// Always use dynamic authHandler to ensure consistent "none" probing
|
||||
// and auth method logging regardless of how many methods are configured
|
||||
if (authMethods.length >= 1) {
|
||||
let authIndex = 0;
|
||||
// Track methods that have been attempted (to avoid re-trying on failure)
|
||||
// This prevents reusing the same key when server requires multiple publickey auth steps
|
||||
@@ -752,6 +942,22 @@ async function startSSHSession(event, options) {
|
||||
connectOpts.authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
log("authHandler called", { methodsLeft, partialSuccess, authIndex, attemptedMethodIds: Array.from(attemptedMethodIds) });
|
||||
|
||||
// Log rejection of previous method
|
||||
if (lastTriedMethod && !partialSuccess) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', `${lastTriedMethod} rejected`);
|
||||
}
|
||||
|
||||
// On the very first call (methodsLeft === null), try "none" auth.
|
||||
// Per RFC 4252, the "none" request is how the client discovers which
|
||||
// methods the server supports. It also allows passwordless login on
|
||||
// embedded devices. This matches the behavior of OpenSSH and Tabby.
|
||||
if (methodsLeft === null && !attemptedMethodIds.has("none")) {
|
||||
attemptedMethodIds.add("none");
|
||||
lastTriedMethod = "none";
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'none (no credentials)');
|
||||
return callback("none");
|
||||
}
|
||||
|
||||
// methodsLeft can be null on first call (before server responds with available methods)
|
||||
// Include "agent" for SSH agent-based auth (used with agentForwarding)
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
@@ -850,10 +1056,19 @@ async function startSSHSession(event, options) {
|
||||
// Only log safe identifier, not the full agent object which may contain private keys
|
||||
const agentType = typeof connectOpts.agent === "string" ? "path" : "NetcattyAgent";
|
||||
log("Trying agent auth", { id: method.id, agentType });
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'SSH agent');
|
||||
// Return "agent" string to use SSH agent for authentication
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey") {
|
||||
log("Trying publickey auth", { id: method.id, isDefault: method.isDefault || false });
|
||||
const keyLabel = method.id.startsWith("publickey-default-")
|
||||
? `key ${method.id.replace("publickey-default-", "")}`
|
||||
: method.id.startsWith("publickey-encrypted-")
|
||||
? `key ${method.id.replace("publickey-encrypted-", "")} (encrypted)`
|
||||
: method.id === "publickey-user"
|
||||
? "configured key"
|
||||
: method.id;
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', keyLabel);
|
||||
return callback({
|
||||
type: "publickey",
|
||||
username: connectOpts.username,
|
||||
@@ -862,6 +1077,7 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
} else if (method.type === "password") {
|
||||
log("Trying password auth", { id: method.id });
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'password');
|
||||
return callback({
|
||||
type: "password",
|
||||
username: connectOpts.username,
|
||||
@@ -869,6 +1085,7 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive") {
|
||||
log("Trying keyboard-interactive auth", { id: method.id });
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'keyboard-interactive');
|
||||
// Return string instead of object - ssh2 requires a prompt function
|
||||
// for keyboard-interactive objects. Returning the string lets ssh2
|
||||
// use its default handling and trigger the keyboard-interactive event.
|
||||
@@ -877,6 +1094,7 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
|
||||
log("All auth methods exhausted");
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'all methods exhausted');
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
@@ -924,10 +1142,20 @@ async function startSSHSession(event, options) {
|
||||
connectOpts.sock = connectionSocket;
|
||||
delete connectOpts.host;
|
||||
delete connectOpts.port;
|
||||
} else {
|
||||
// Direct connection (no jump hosts, no proxy)
|
||||
sendProgress(1, 1, options.hostname, 'connecting');
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const logPrefix = hasJumpHosts ? '[Chain]' : '[SSH]';
|
||||
let settled = false;
|
||||
|
||||
conn.once("handshake", () => {
|
||||
console.log(`${logPrefix} ${options.hostname} handshake complete`);
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'authenticating');
|
||||
});
|
||||
|
||||
conn.once("ready", () => {
|
||||
console.log(`${logPrefix} ${options.hostname} ready`);
|
||||
|
||||
@@ -939,9 +1167,8 @@ async function startSSHSession(event, options) {
|
||||
}
|
||||
}
|
||||
|
||||
if (hasJumpHosts || hasProxy) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'connected');
|
||||
}
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'authenticated');
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'shell');
|
||||
|
||||
conn.shell(
|
||||
{
|
||||
@@ -958,14 +1185,18 @@ async function startSSHSession(event, options) {
|
||||
},
|
||||
(err, stream) => {
|
||||
if (err) {
|
||||
settled = true;
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', `Failed to open shell: ${err.message}`);
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'connected');
|
||||
|
||||
const session = {
|
||||
conn,
|
||||
stream,
|
||||
@@ -975,6 +1206,9 @@ async function startSSHSession(event, options) {
|
||||
hostname: options.host || options.hostname || '',
|
||||
username: options.username || '',
|
||||
label: options.label || '',
|
||||
lastIdlePrompt: '',
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: '',
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -1022,6 +1256,7 @@ async function startSSHSession(event, options) {
|
||||
stream.on("data", (data) => {
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
const decoded = decoder.write(data);
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
});
|
||||
@@ -1047,17 +1282,29 @@ async function startSSHSession(event, options) {
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
// Flush any remaining data before close
|
||||
// Always flush buffered data regardless of session state
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
}
|
||||
flushBuffer();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
|
||||
// Only send exit if session hasn't already been cleaned up by
|
||||
// conn.once("close") — which fires before stream.on("close")
|
||||
// in ssh2 when the transport drops.
|
||||
if (sessions.has(sessionId)) {
|
||||
const contents = event.sender;
|
||||
const session = sessions.get(sessionId);
|
||||
const transportError = session?._transportError;
|
||||
if (transportError) {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: streamExitCode, reason: streamExited ? "exited" : "closed" });
|
||||
}
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
}
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
@@ -1076,12 +1323,29 @@ async function startSSHSession(event, options) {
|
||||
}, 300);
|
||||
}
|
||||
|
||||
settled = true;
|
||||
resolve({ sessionId });
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
conn.on("error", (err) => {
|
||||
// After the promise is settled, we can't reject again. But if the
|
||||
// session was already established (resolved), we still need to notify
|
||||
// the renderer about transport errors so the session shows as failed
|
||||
// rather than silently closing.
|
||||
// Don't send netcatty:exit here — the stream close handler will flush
|
||||
// any buffered data first and then send exit with this error info.
|
||||
if (settled) {
|
||||
console.warn(`${logPrefix} ${options.hostname} post-settle error:`, err.message);
|
||||
// Store the error so the close handler can include it in the exit event
|
||||
if (sessions.has(sessionId)) {
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) session._transportError = err.message;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const contents = event.sender;
|
||||
|
||||
const isAuthError = err.message?.toLowerCase().includes('authentication') ||
|
||||
@@ -1102,6 +1366,7 @@ async function startSSHSession(event, options) {
|
||||
console.error(`${logPrefix} ${options.hostname} error:`, err.message);
|
||||
}
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "error" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
@@ -1110,6 +1375,10 @@ async function startSSHSession(event, options) {
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
// Destroy the connection to prevent further socket errors from leaking
|
||||
// as uncaught exceptions (e.g. ECONNRESET on embedded devices).
|
||||
try { conn.destroy(); } catch { }
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@@ -1117,6 +1386,7 @@ async function startSSHSession(event, options) {
|
||||
console.error(`${logPrefix} ${options.hostname} connection timeout`);
|
||||
const err = new Error(`Connection timeout to ${options.hostname}`);
|
||||
const contents = event.sender;
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', err.message);
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: err.message, reason: "timeout" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
@@ -1125,12 +1395,29 @@ async function startSSHSession(event, options) {
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
try { conn.destroy(); } catch { }
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
conn.once("close", () => {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
if (!settled) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', `Connection to ${options.hostname} closed unexpectedly`);
|
||||
}
|
||||
// Only send exit if the session hasn't already been cleaned up by the
|
||||
// error handler (avoids sending a misleading exitCode:0 "closed" after
|
||||
// a real transport error was already reported).
|
||||
if (sessions.has(sessionId)) {
|
||||
const session = sessions.get(sessionId);
|
||||
const transportError = session?._transportError;
|
||||
if (transportError) {
|
||||
// A transport error was recorded — report it as an error exit
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 1, error: transportError, reason: "error" });
|
||||
} else {
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
}
|
||||
}
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
@@ -1138,6 +1425,10 @@ async function startSSHSession(event, options) {
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(new Error(`Connection to ${options.hostname} closed unexpectedly`));
|
||||
}
|
||||
});
|
||||
|
||||
// Handle keyboard-interactive authentication (2FA/MFA)
|
||||
@@ -1156,12 +1447,15 @@ async function startSSHSession(event, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'waiting for user input...');
|
||||
|
||||
// Forward ALL prompts to user - no auto-fill to avoid semantic detection issues
|
||||
// (Prompt text is admin-customizable and may not contain expected keywords)
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'auth-attempt', 'user responded');
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
@@ -1277,10 +1571,11 @@ async function execCommand(event, payload) {
|
||||
});
|
||||
});
|
||||
})
|
||||
.once("error", (err) => {
|
||||
.on("error", (err) => {
|
||||
if (settled) return;
|
||||
clearTimeout(timer);
|
||||
settled = true;
|
||||
conn.end();
|
||||
reject(err);
|
||||
})
|
||||
.once("end", () => {
|
||||
@@ -1462,7 +1757,11 @@ async function startSSHSessionWrapper(event, options) {
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw retryErr;
|
||||
// Wrap non-auth retry errors as connection errors to prevent crash
|
||||
const connError = new Error(retryErr.message);
|
||||
connError.level = retryErr.level || 'client-socket';
|
||||
connError.code = retryErr.code;
|
||||
throw connError;
|
||||
}
|
||||
} else {
|
||||
console.log('[SSH] User did not unlock any keys, not retrying');
|
||||
@@ -1477,7 +1776,15 @@ async function startSSHSessionWrapper(event, options) {
|
||||
authError.isAuthError = true;
|
||||
throw authError;
|
||||
}
|
||||
throw err;
|
||||
|
||||
// Non-auth errors (e.g. ECONNRESET, ETIMEDOUT) — wrap in a clean Error
|
||||
// so Electron's ipcMain.handle can serialize it back to the renderer
|
||||
// instead of it becoming an uncaught exception that crashes the app.
|
||||
// See: https://github.com/nicely-gg/netcatty/issues/482
|
||||
const connError = new Error(err.message);
|
||||
connError.level = err.level || 'client-socket';
|
||||
connError.code = err.code;
|
||||
throw connError;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1547,11 +1854,41 @@ async function getServerStats(event, payload) {
|
||||
|
||||
const conn = session.conn;
|
||||
|
||||
// macOS stats command: uses sysctl, vm_stat, top, ps, df, netstat
|
||||
// CPU reported as direct percentage (top computes delta internally)
|
||||
// cpuPerCore not available on macOS without sudo
|
||||
const macosStatsCommand = [
|
||||
`cores=$(sysctl -n hw.logicalcpu 2>/dev/null || echo "1")`,
|
||||
`pagesize=$(sysctl -n hw.pagesize 2>/dev/null || echo "4096")`,
|
||||
`memsize=$(sysctl -n hw.memsize 2>/dev/null || echo "0")`,
|
||||
// CPU usage: top -l 1 gives one logging sample, parse idle%
|
||||
`cpuline=$(top -l 1 -s 0 -n 0 2>/dev/null | grep "CPU usage:" | head -1)`,
|
||||
`cpupct=$(echo "$cpuline" | awk '{for(i=1;i<=NF;i++){if($(i+1)~/^idle/){v=$i;gsub(/%/,"",v);idle=v+0;found=1}};if(found)printf "%.0f",100-idle}')`,
|
||||
// Memory: single vm_stat pipe → awk extracts all page counts (strip trailing dots with gsub)
|
||||
// Outputs: "memfree memcached" in MB
|
||||
`vmmem=$(vm_stat 2>/dev/null | awk -v ps="$pagesize" '/^Pages free:/{gsub(/[^0-9]/,"",$NF);free=$NF+0} /^Pages speculative:/{gsub(/[^0-9]/,"",$NF);spec=$NF+0} /^Pages inactive:/{gsub(/[^0-9]/,"",$NF);inact=$NF+0} /^Pages purgeable:/{gsub(/[^0-9]/,"",$NF);purg=$NF+0} END{mfree=int((free+spec)*ps/1024/1024);mcached=int((inact+purg)*ps/1024/1024);printf "%d %d",mfree,mcached}')`,
|
||||
`memtotal=$(echo "$memsize" | awk '{printf "%d",$1/1024/1024}')`,
|
||||
`memfree=$(echo "$vmmem" | awk '{print $1}')`,
|
||||
`memcached=$(echo "$vmmem" | awk '{print $2}')`,
|
||||
// Swap
|
||||
`swapraw=$(sysctl vm.swapusage 2>/dev/null)`,
|
||||
`swaptotal=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="total"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);st=v*m}};printf "%.0f",st+0}')`,
|
||||
`swapused=$(echo "$swapraw" | awk '{for(i=1;i<=NF;i++){if($i=="used"&&$(i+1)=="="){v=$(i+2);m=1;if(v~/G/)m=1024;gsub(/[MmGg]/,"",v);su=v*m}};printf "%.0f",su+0}')`,
|
||||
`swapfree=$(echo "$swaptotal $swapused" | awk '{printf "%.0f",$1-$2}')`,
|
||||
// Top processes by memory%
|
||||
`procs=$(ps -A -o pid=,%mem=,comm= 2>/dev/null | sort -k2 -rn | head -10 | awk '{gsub(/;/,"_",$3);printf "%s;%.1f;%s,",$1,$2,$3}' | sed 's/,$//')`,
|
||||
// Disk: only show root "/" and external volumes "/Volumes/*", skip system APFS snapshots
|
||||
`disks=$(df -k 2>/dev/null | awk 'NR>1&&index($1,"/dev/")==1&&NF>=9&&($NF=="/"||index($NF,"/Volumes/")==1){u=$3/1048576;t=$2/1048576;p=$5;gsub(/%/,"",p);printf "%s:%.0f:%.0f:%s,",$NF,u,t,p}' | sed 's/,$//')`,
|
||||
// Network: Link# lines only, exclude loopback, detect column shift (no MAC addr → cols shift left)
|
||||
`net=$(netstat -ib 2>/dev/null | awk '/^[a-z]/&&$3~/Link/&&$1!~/^lo/{if($4~/:/){rx=$7;tx=$10}else{rx=$6;tx=$9};if((rx+0)>0){gsub(/[*]/,"",$1);printf "%s:%s:%s,",$1,rx,tx}}' | sed 's/,$//')`,
|
||||
`echo "CPU:$cpupct|CORES:$cores|MEMINFO:$memtotal $memfree 0 $memcached $swaptotal $swapfree|PROCS:$procs|DISKS:$disks|NET:$net"`,
|
||||
].join('; ');
|
||||
|
||||
// Command to get CPU (overall + per-core), Memory, Disk, and Network stats
|
||||
// This command is designed to work across most Linux distributions
|
||||
// Note: Using semicolons and avoiding comments for single-line execution
|
||||
// CPU: Output raw values (total and idle) instead of percentage - we calculate delta on backend
|
||||
const statsCommand = [
|
||||
const linuxStatsCommand = [
|
||||
// Get number of CPU cores
|
||||
`cores=$(nproc 2>/dev/null || grep -c "^processor" /proc/cpuinfo 2>/dev/null || echo "1")`,
|
||||
// Get raw CPU values from /proc/stat: "total idle" for overall CPU
|
||||
@@ -1575,6 +1912,8 @@ async function getServerStats(event, payload) {
|
||||
`echo "CPURAW:$cpuraw|CORES:$cores|PERCORERAW:$percoreraw|MEMINFO:$meminfo|PROCS:$procs|DISKS:$disks|NET:$net"`
|
||||
].join('; ');
|
||||
|
||||
// Auto-detect OS via uname — only Linux and macOS are supported
|
||||
const statsCommand = `ostype=$(uname -s 2>/dev/null || echo "Unknown"); if [ "$ostype" = "Darwin" ]; then ${macosStatsCommand}; elif [ "$ostype" = "Linux" ]; then ${linuxStatsCommand}; else echo "UNSUPPORTED_OS:$ostype"; fi`;
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve({ success: false, error: 'Timeout getting server stats' });
|
||||
@@ -1603,8 +1942,16 @@ async function getServerStats(event, payload) {
|
||||
|
||||
// Parse the output
|
||||
const output = stdout.trim();
|
||||
|
||||
// Unsupported OS — stop polling this session
|
||||
if (output.startsWith('UNSUPPORTED_OS:')) {
|
||||
resolve({ success: false, error: `Server stats not supported on this OS (${output.substring(15)})` });
|
||||
return;
|
||||
}
|
||||
|
||||
const parts = output.split('|');
|
||||
|
||||
let cpuDirect = null; // macOS: direct CPU percentage from top
|
||||
let cpuRawTotal = null;
|
||||
let cpuRawIdle = null;
|
||||
let cpuPerCoreRaw = []; // Array of { total, idle }
|
||||
@@ -1621,7 +1968,11 @@ async function getServerStats(event, payload) {
|
||||
let networkInterfaces = []; // Array of { name, rxBytes, txBytes }
|
||||
|
||||
for (const part of parts) {
|
||||
if (part.startsWith('CPURAW:')) {
|
||||
if (part.startsWith('CPU:')) {
|
||||
// macOS: top reports CPU% directly (no delta needed)
|
||||
const val = parseFloat(part.substring(4).trim());
|
||||
if (!isNaN(val)) cpuDirect = Math.min(100, Math.max(0, Math.round(val)));
|
||||
} else if (part.startsWith('CPURAW:')) {
|
||||
const rawParts = part.substring(7).trim().split(/\s+/);
|
||||
if (rawParts.length >= 2) {
|
||||
cpuRawTotal = parseInt(rawParts[0], 10);
|
||||
@@ -1798,6 +2149,11 @@ async function getServerStats(event, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// macOS: use direct percentage from top (no delta needed)
|
||||
if (cpu === null && cpuDirect !== null) {
|
||||
cpu = cpuDirect;
|
||||
}
|
||||
|
||||
// Calculate per-core CPU usage from deltas
|
||||
if (cpuPerCoreRaw.length > 0 && prevCpu.perCore.length > 0) {
|
||||
cpuPerCore = cpuPerCoreRaw.map((core, index) => {
|
||||
@@ -1833,6 +2189,12 @@ async function getServerStats(event, payload) {
|
||||
const diskUsed = rootDisk ? rootDisk.used : null;
|
||||
const diskTotal = rootDisk ? rootDisk.total : null;
|
||||
|
||||
// If no meaningful data was parsed, treat as failure to stop futile polling
|
||||
if (cpu === null && memTotal === null && cpuCores === null) {
|
||||
resolve({ success: false, error: 'Unable to parse server stats (unsupported OS or shell)' });
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
stats: {
|
||||
@@ -1895,14 +2257,17 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ssh:get-default-keys", async () => {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const keys = [];
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
keys.push({ name, path: keyPath });
|
||||
} catch {
|
||||
// ignore missing keys
|
||||
try {
|
||||
const entries = await fs.promises.readdir(sshDir);
|
||||
const names = entries.filter(f => SSH_KEY_PATTERN.test(f));
|
||||
// Preferred first, then rest alphabetically
|
||||
const preferred = PREFERRED_KEY_NAMES.filter(n => names.includes(n));
|
||||
const rest = names.filter(n => !PREFERRED_KEY_NAMES.includes(n)).sort();
|
||||
for (const name of [...preferred, ...rest]) {
|
||||
keys.push({ name, path: path.join(sshDir, name) });
|
||||
}
|
||||
} catch {
|
||||
// ~/.ssh doesn't exist
|
||||
}
|
||||
return keys;
|
||||
});
|
||||
@@ -1915,6 +2280,7 @@ function registerHandlers(ipcMain) {
|
||||
module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
connectThroughChain,
|
||||
createProxySocket,
|
||||
startSSHSession,
|
||||
execCommand,
|
||||
|
||||
@@ -13,6 +13,7 @@ const { SerialPort } = require("serialport");
|
||||
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { detectShellKind } = require("./ai/ptyExec.cjs");
|
||||
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -52,6 +53,51 @@ function init(deps) {
|
||||
electronModule = deps.electronModule;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an 8ms/16KB PTY data buffer for reduced IPC overhead.
|
||||
* Mirrors the SSH stream buffering strategy in sshBridge.cjs.
|
||||
* @param {Function} sendFn - called with the accumulated string to deliver
|
||||
* @returns {{ bufferData: (data: string) => void, flush: () => void }}
|
||||
*/
|
||||
function createPtyBuffer(sendFn) {
|
||||
const FLUSH_INTERVAL = 8; // ms - flush every 8ms (~120fps equivalent)
|
||||
const MAX_BUFFER_SIZE = 16384; // 16KB - flush immediately if buffer grows too large
|
||||
|
||||
let dataBuffer = '';
|
||||
let flushTimeout = null;
|
||||
|
||||
const flushBuffer = () => {
|
||||
if (dataBuffer.length > 0) {
|
||||
sendFn(dataBuffer);
|
||||
dataBuffer = '';
|
||||
}
|
||||
flushTimeout = null;
|
||||
};
|
||||
|
||||
const flush = () => {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
};
|
||||
|
||||
const bufferData = (data) => {
|
||||
dataBuffer += data;
|
||||
if (dataBuffer.length >= MAX_BUFFER_SIZE) {
|
||||
if (flushTimeout) {
|
||||
clearTimeout(flushTimeout);
|
||||
flushTimeout = null;
|
||||
}
|
||||
flushBuffer();
|
||||
} else if (!flushTimeout) {
|
||||
flushTimeout = setTimeout(flushBuffer, FLUSH_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
return { bufferData, flush };
|
||||
}
|
||||
|
||||
/**
|
||||
* Find executable path on Windows
|
||||
*/
|
||||
@@ -245,6 +291,10 @@ function startLocalSession(event, payload) {
|
||||
label: "Local Terminal",
|
||||
shellExecutable: shell,
|
||||
shellKind,
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -259,13 +309,20 @@ function startLocalSession(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const { bufferData: bufferLocalData, flush: flushLocal } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
session.flushPendingData = flushLocal;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferLocalData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushLocal();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
@@ -434,7 +491,12 @@ async function startTelnetSession(event, options) {
|
||||
webContentsId: event.sender.id,
|
||||
cols,
|
||||
rows,
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
session.flushPendingData = flushTelnet;
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
// Start real-time session log stream if configured
|
||||
@@ -463,6 +525,12 @@ async function startTelnetSession(event, options) {
|
||||
|
||||
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
|
||||
|
||||
const telnetWebContentsId = event.sender.id;
|
||||
const { bufferData: bufferTelnetData, flush: flushTelnet } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(telnetWebContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
@@ -472,8 +540,8 @@ async function startTelnetSession(event, options) {
|
||||
if (cleanData.length > 0) {
|
||||
const decoded = telnetDecoder.write(cleanData);
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
trackSessionIdlePrompt(session, decoded);
|
||||
bufferTelnetData(decoded);
|
||||
sessionLogStreamManager.appendData(sessionId, decoded);
|
||||
}
|
||||
}
|
||||
@@ -486,6 +554,7 @@ async function startTelnetSession(event, options) {
|
||||
if (!connected) {
|
||||
reject(new Error(`Failed to connect: ${err.message}`));
|
||||
} else {
|
||||
flushTelnet();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -500,6 +569,7 @@ async function startTelnetSession(event, options) {
|
||||
console.log(`[Telnet] Connection closed${hadError ? ' with error' : ''}`);
|
||||
clearTimeout(connectTimeout);
|
||||
|
||||
flushTelnet();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
const session = sessions.get(sessionId);
|
||||
if (session) {
|
||||
@@ -584,6 +654,10 @@ async function startMoshSession(event, options) {
|
||||
label: options.label || options.hostname || 'Mosh Session',
|
||||
shellKind: 'posix',
|
||||
shellExecutable: 'remote-shell',
|
||||
flushPendingData: null,
|
||||
lastIdlePrompt: "",
|
||||
lastIdlePromptAt: 0,
|
||||
_promptTrackTail: "",
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -598,13 +672,20 @@ async function startMoshSession(event, options) {
|
||||
});
|
||||
}
|
||||
|
||||
proc.onData((data) => {
|
||||
const { bufferData: bufferMoshData, flush: flushMosh } = createPtyBuffer((data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data });
|
||||
});
|
||||
session.flushPendingData = flushMosh;
|
||||
|
||||
proc.onData((data) => {
|
||||
trackSessionIdlePrompt(session, data);
|
||||
bufferMoshData(data);
|
||||
sessionLogStreamManager.appendData(sessionId, data);
|
||||
});
|
||||
|
||||
proc.onExit((evt) => {
|
||||
flushMosh();
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
@@ -798,6 +879,7 @@ function closeSession(event, payload) {
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
session.flushPendingData?.();
|
||||
if (session.stream) {
|
||||
session.stream.close();
|
||||
session.conn?.end();
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const globalShortcutBridge = require("./globalShortcutBridge.cjs");
|
||||
|
||||
const V8_CACHE_OPTIONS = "bypassHeatCheck";
|
||||
|
||||
function getGlobalShortcutBridge() {
|
||||
return require("./globalShortcutBridge.cjs");
|
||||
}
|
||||
|
||||
// Theme colors configuration
|
||||
const THEME_COLORS = {
|
||||
@@ -443,6 +448,7 @@ function createAppWindowOpenHandler(shell, { backgroundColor, appIcon }) {
|
||||
nodeIntegration: false,
|
||||
// Sandboxed because this window renders remote content and does not need a preload bridge.
|
||||
sandbox: true,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -687,11 +693,24 @@ async function createWindow(electronModule, options) {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow = win;
|
||||
|
||||
// Log renderer crashes for diagnostics (skip normal clean exits)
|
||||
win.webContents.on("render-process-gone", (_event, details) => {
|
||||
if (details?.reason === "clean-exit") return;
|
||||
try {
|
||||
const crashLogBridge = require("./crashLogBridge.cjs");
|
||||
crashLogBridge.captureError("render-process-gone", new Error(
|
||||
`Renderer process gone: reason=${details?.reason}, exitCode=${details?.exitCode}`
|
||||
), { reason: details?.reason, exitCode: details?.exitCode });
|
||||
} catch {}
|
||||
console.error("[WindowManager] Renderer process gone:", details);
|
||||
});
|
||||
|
||||
// Prevent top-level navigation away from the app origin. If a remote origin ever
|
||||
// loads in a privileged window (with preload), it can become an RCE vector.
|
||||
const allowedOrigins = new Set(["app://netcatty"]);
|
||||
@@ -770,12 +789,12 @@ async function createWindow(electronModule, options) {
|
||||
// Save state when window is about to close
|
||||
win.on("close", (event) => {
|
||||
// Check if close-to-tray is enabled
|
||||
if (!isQuitting && globalShortcutBridge.handleWindowClose(event, win)) {
|
||||
if (!isQuitting && getGlobalShortcutBridge().handleWindowClose(event, win)) {
|
||||
// Window was hidden to tray - save state before returning
|
||||
if (saveStateTimer) clearTimeout(saveStateTimer);
|
||||
const state = getWindowBoundsState(win, lastNormalBounds);
|
||||
if (state) saveWindowStateSync(state);
|
||||
closeSettingsWindow();
|
||||
hideSettingsWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -891,12 +910,13 @@ async function createWindow(electronModule, options) {
|
||||
/**
|
||||
* Create or focus the settings window
|
||||
*/
|
||||
async function openSettingsWindow(electronModule, options) {
|
||||
async function openSettingsWindow(electronModule, options, { showOnLoad = true } = {}) {
|
||||
const { BrowserWindow, shell } = electronModule;
|
||||
const { preload, devServerUrl, isDev, appIcon, isMac, electronDir } = options;
|
||||
|
||||
// If settings window already exists, just focus it
|
||||
// If settings window already exists, show and focus it
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.show();
|
||||
settingsWindow.focus();
|
||||
return settingsWindow;
|
||||
}
|
||||
@@ -943,6 +963,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: false,
|
||||
v8CacheOptions: V8_CACHE_OPTIONS,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1027,10 +1048,20 @@ async function openSettingsWindow(electronModule, options) {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// Defer show until renderer is ready; use fallback timeout to avoid keeping window hidden forever.
|
||||
setupDeferredShow(win, { timeoutMs: isDev ? 1200 : 600, waitForRendererReady: false });
|
||||
// Hide instead of close so the window can be reused instantly.
|
||||
// When the app is quitting, allow normal close/destroy.
|
||||
win.on('close', (event) => {
|
||||
if (!isQuitting) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
win.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clean up reference when closed
|
||||
// Clean up reference when actually destroyed
|
||||
win.on('closed', () => {
|
||||
settingsWindow = null;
|
||||
});
|
||||
@@ -1042,6 +1073,7 @@ async function openSettingsWindow(electronModule, options) {
|
||||
try {
|
||||
const baseUrl = getDevRendererBaseUrl(devServerUrl);
|
||||
await win.loadURL(`${baseUrl}${settingsPath}`);
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
return win;
|
||||
} catch (e) {
|
||||
console.warn("Dev server not reachable for settings window", e);
|
||||
@@ -1050,20 +1082,51 @@ async function openSettingsWindow(electronModule, options) {
|
||||
|
||||
// Production mode - load via custom protocol.
|
||||
await win.loadURL("app://netcatty/index.html#/settings");
|
||||
if (showOnLoad) { win.show(); win.focus(); }
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the settings window
|
||||
* Destroy the settings window (used when the app is quitting).
|
||||
*/
|
||||
function closeSettingsWindow() {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.close();
|
||||
try {
|
||||
settingsWindow.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
settingsWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the settings window without destroying it (used when main window hides to tray).
|
||||
*/
|
||||
function hideSettingsWindow() {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
try {
|
||||
settingsWindow.hide();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-warm the settings window in the background so that opening it later is instant.
|
||||
* The window is created hidden and fully loaded; `openSettingsWindow` will simply show it.
|
||||
*/
|
||||
async function prewarmSettingsWindow(electronModule, options) {
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) return;
|
||||
try {
|
||||
await openSettingsWindow(electronModule, options, { showOnLoad: false });
|
||||
} catch (err) {
|
||||
debugLog("Failed to pre-warm settings window", { error: String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register window control IPC handlers (only once)
|
||||
*/
|
||||
@@ -1164,13 +1227,13 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
|
||||
// Settings window close handler
|
||||
ipcMain.handle("netcatty:settings:close", (event) => {
|
||||
// Prefer closing the tracked settings window (if any).
|
||||
// Prefer hiding the tracked settings window (reused on next open).
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
debugLog("settings:close (tracked)", {
|
||||
senderId: event?.sender?.id,
|
||||
settingsId: settingsWindow.webContents?.id,
|
||||
});
|
||||
closeSettingsWindow();
|
||||
hideSettingsWindow();
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1300,6 +1363,7 @@ module.exports = {
|
||||
createWindow,
|
||||
openSettingsWindow,
|
||||
closeSettingsWindow,
|
||||
prewarmSettingsWindow,
|
||||
buildAppMenu,
|
||||
getMainWindow,
|
||||
getSettingsWindow,
|
||||
|
||||
@@ -18,16 +18,82 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
|
||||
delete process.env.ELECTRON_RUN_AS_NODE;
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions for EPIPE errors
|
||||
// Load crash log bridge early so process-level error handlers can use it
|
||||
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
|
||||
|
||||
// SSH / network errors that must never crash the process.
|
||||
// ssh2 can emit multiple 'error' events per connection (e.g. ECONNRESET followed
|
||||
// by "Connection lost before handshake"). If a listener is consumed after the first
|
||||
// event, the second becomes an uncaught exception. These are non-fatal for the app.
|
||||
function isNonFatalNetworkError(err) {
|
||||
if (!err) return false;
|
||||
// Any error with an ssh2 `level` property is a connection/auth-level error,
|
||||
// never a reason to kill the entire multi-session app.
|
||||
if (err.level) return true;
|
||||
const code = err.code;
|
||||
// Common TCP/DNS/routing errors that can surface from Node.js sockets
|
||||
// without an ssh2 `level` (e.g. proxy sockets, raw net.connect calls).
|
||||
switch (code) {
|
||||
case 'ECONNRESET':
|
||||
case 'ECONNREFUSED':
|
||||
case 'ECONNABORTED':
|
||||
case 'ETIMEDOUT':
|
||||
case 'ENOTFOUND':
|
||||
case 'EHOSTUNREACH':
|
||||
case 'EHOSTDOWN':
|
||||
case 'ENETUNREACH':
|
||||
case 'ENETDOWN':
|
||||
case 'EADDRNOTAVAIL':
|
||||
case 'EPROTO':
|
||||
case 'EPERM':
|
||||
return true;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle uncaught exceptions — log all, only re-throw truly fatal ones
|
||||
process.on('uncaughtException', (err) => {
|
||||
// Skip benign stream teardown errors — don't pollute crash logs with false positives
|
||||
if (err.code === 'EPIPE' || err.code === 'ERR_STREAM_DESTROYED') {
|
||||
console.warn('Ignored stream error:', err.code);
|
||||
return;
|
||||
}
|
||||
// Non-fatal SSH/network errors: log but do NOT crash the process
|
||||
if (isNonFatalNetworkError(err)) {
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
}
|
||||
console.warn('Non-fatal uncaught exception (suppressed):', err.message);
|
||||
return;
|
||||
}
|
||||
// Skip logging if already captured by unhandledRejection handler
|
||||
if (!err.__fromUnhandledRejection) {
|
||||
try { crashLogBridge.captureError('uncaughtException', err); } catch {}
|
||||
}
|
||||
console.error('Uncaught exception:', err);
|
||||
throw err;
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
// Skip benign stream teardown errors
|
||||
const code = reason?.code;
|
||||
if (code === 'EPIPE' || code === 'ERR_STREAM_DESTROYED') return;
|
||||
// Non-fatal SSH/network errors: log but do NOT re-throw
|
||||
if (isNonFatalNetworkError(reason)) {
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.warn('Non-fatal unhandled rejection (suppressed):', reason?.message || reason);
|
||||
return;
|
||||
}
|
||||
try { crashLogBridge.captureError('unhandledRejection', reason); } catch {}
|
||||
console.error('Unhandled rejection:', reason);
|
||||
// Re-throw to preserve fatal semantics. Mark so uncaughtException handler
|
||||
// can skip duplicate logging.
|
||||
const err = reason instanceof Error ? reason : new Error(String(reason));
|
||||
err.__fromUnhandledRejection = true;
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Load Electron
|
||||
let electronModule;
|
||||
try {
|
||||
@@ -64,6 +130,16 @@ try {
|
||||
|
||||
// Apply ssh2 protocol patch needed for OpenSSH sk-* signature layouts.
|
||||
|
||||
function createLazyModule(modulePath) {
|
||||
let cachedModule = null;
|
||||
return () => {
|
||||
if (!cachedModule) {
|
||||
cachedModule = require(modulePath);
|
||||
}
|
||||
return cachedModule;
|
||||
};
|
||||
}
|
||||
|
||||
// Import bridge modules
|
||||
const sshBridge = require("./bridges/sshBridge.cjs");
|
||||
const sftpBridge = require("./bridges/sftpBridge.cjs");
|
||||
@@ -71,21 +147,22 @@ const localFsBridge = require("./bridges/localFsBridge.cjs");
|
||||
const transferBridge = require("./bridges/transferBridge.cjs");
|
||||
const portForwardingBridge = require("./bridges/portForwardingBridge.cjs");
|
||||
const terminalBridge = require("./bridges/terminalBridge.cjs");
|
||||
const oauthBridge = require("./bridges/oauthBridge.cjs");
|
||||
const githubAuthBridge = require("./bridges/githubAuthBridge.cjs");
|
||||
const googleAuthBridge = require("./bridges/googleAuthBridge.cjs");
|
||||
const onedriveAuthBridge = require("./bridges/onedriveAuthBridge.cjs");
|
||||
const cloudSyncBridge = require("./bridges/cloudSyncBridge.cjs");
|
||||
const fileWatcherBridge = require("./bridges/fileWatcherBridge.cjs");
|
||||
const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const sessionLogStreamManager = require("./bridges/sessionLogStreamManager.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
const autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
|
||||
const aiBridge = require("./bridges/aiBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
// crashLogBridge is required at the top of the file (before error handlers)
|
||||
const getOauthBridge = createLazyModule("./bridges/oauthBridge.cjs");
|
||||
const getGithubAuthBridge = createLazyModule("./bridges/githubAuthBridge.cjs");
|
||||
const getGoogleAuthBridge = createLazyModule("./bridges/googleAuthBridge.cjs");
|
||||
const getOnedriveAuthBridge = createLazyModule("./bridges/onedriveAuthBridge.cjs");
|
||||
const getCloudSyncBridge = createLazyModule("./bridges/cloudSyncBridge.cjs");
|
||||
const getFileWatcherBridge = createLazyModule("./bridges/fileWatcherBridge.cjs");
|
||||
const getTempDirBridge = createLazyModule("./bridges/tempDirBridge.cjs");
|
||||
const getSessionLogsBridge = createLazyModule("./bridges/sessionLogsBridge.cjs");
|
||||
const getCompressUploadBridge = createLazyModule("./bridges/compressUploadBridge.cjs");
|
||||
const getGlobalShortcutBridge = createLazyModule("./bridges/globalShortcutBridge.cjs");
|
||||
const getCredentialBridge = createLazyModule("./bridges/credentialBridge.cjs");
|
||||
const getAutoUpdateBridge = createLazyModule("./bridges/autoUpdateBridge.cjs");
|
||||
const getAiBridge = createLazyModule("./bridges/aiBridge.cjs");
|
||||
const getWindowManager = createLazyModule("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
// NOTE: Do not disable Chromium sandbox by default.
|
||||
@@ -317,6 +394,19 @@ const registerBridges = (win) => {
|
||||
|
||||
const { ipcMain } = electronModule;
|
||||
const { safeStorage } = electronModule;
|
||||
const oauthBridge = getOauthBridge();
|
||||
const githubAuthBridge = getGithubAuthBridge();
|
||||
const googleAuthBridge = getGoogleAuthBridge();
|
||||
const onedriveAuthBridge = getOnedriveAuthBridge();
|
||||
const cloudSyncBridge = getCloudSyncBridge();
|
||||
const fileWatcherBridge = getFileWatcherBridge();
|
||||
const tempDirBridge = getTempDirBridge();
|
||||
const sessionLogsBridge = getSessionLogsBridge();
|
||||
const compressUploadBridge = getCompressUploadBridge();
|
||||
const globalShortcutBridge = getGlobalShortcutBridge();
|
||||
const credentialBridge = getCredentialBridge();
|
||||
const autoUpdateBridge = getAutoUpdateBridge();
|
||||
const aiBridge = getAiBridge();
|
||||
|
||||
const getCloudSyncPasswordPath = () => {
|
||||
try {
|
||||
@@ -381,6 +471,7 @@ const registerBridges = (win) => {
|
||||
fileWatcherBridge.init(deps);
|
||||
globalShortcutBridge.init(deps);
|
||||
aiBridge.init(deps);
|
||||
crashLogBridge.init(deps);
|
||||
|
||||
// Initialize compress upload bridge with transferBridge dependency
|
||||
compressUploadBridge.init({
|
||||
@@ -412,11 +503,12 @@ const registerBridges = (win) => {
|
||||
autoUpdateBridge.init(deps);
|
||||
autoUpdateBridge.registerHandlers(ipcMain);
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
crashLogBridge.registerHandlers(ipcMain);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
try {
|
||||
await windowManager.openSettingsWindow(electronModule, {
|
||||
await getWindowManager().openSettingsWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
@@ -606,6 +698,24 @@ const registerBridges = (win) => {
|
||||
return result.filePath;
|
||||
});
|
||||
|
||||
// Select a file and return the selected path
|
||||
ipcMain.handle("netcatty:selectFile", async (_event, { title, defaultPath, filters }) => {
|
||||
const { dialog } = electronModule;
|
||||
|
||||
const result = await dialog.showOpenDialog({
|
||||
title: title || "Select File",
|
||||
defaultPath: defaultPath || os.homedir(),
|
||||
filters: filters || [{ name: "All Files", extensions: ["*"] }],
|
||||
properties: ["openFile", "showHiddenFiles"],
|
||||
});
|
||||
|
||||
if (result.canceled || !result.filePaths.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return result.filePaths[0];
|
||||
});
|
||||
|
||||
// Select a directory and return the selected path
|
||||
ipcMain.handle("netcatty:selectDirectory", async (_event, { title, defaultPath }) => {
|
||||
const { dialog } = electronModule;
|
||||
@@ -632,7 +742,7 @@ const registerBridges = (win) => {
|
||||
|
||||
const client = require("./bridges/sftpBridge.cjs");
|
||||
// Use tempDirBridge for dedicated Netcatty temp directory
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
const localPath = await getTempDirBridge().getTempFilePath(fileName);
|
||||
|
||||
console.log(`[Main] Local temp path: ${localPath}`);
|
||||
|
||||
@@ -671,7 +781,7 @@ const registerBridges = (win) => {
|
||||
// only carries the resolved temp path. Cancellation is NOT an error here —
|
||||
// the UI already transitions the task to "cancelled" via the dedicated event.
|
||||
ipcMain.handle("netcatty:sftp:downloadToTempWithProgress", async (event, { sftpId, remotePath, fileName, encoding, transferId }) => {
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
const localPath = await getTempDirBridge().getTempFilePath(fileName);
|
||||
const cleanupPartialDownload = async () => {
|
||||
try {
|
||||
await fs.promises.rm(localPath, { force: true });
|
||||
@@ -712,7 +822,7 @@ const registerBridges = (win) => {
|
||||
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
|
||||
try {
|
||||
// Only allow deleting files in Netcatty temp directory for security
|
||||
const netcattyTempDir = path.resolve(tempDirBridge.getTempDir());
|
||||
const netcattyTempDir = path.resolve(getTempDirBridge().getTempDir());
|
||||
const resolvedPath = path.resolve(String(filePath || ""));
|
||||
if (!isPathInside(netcattyTempDir, resolvedPath)) {
|
||||
console.warn(`[Main] Refused to delete file outside Netcatty temp dir: ${filePath}`);
|
||||
@@ -736,7 +846,7 @@ const registerBridges = (win) => {
|
||||
* Create the main application window
|
||||
*/
|
||||
async function createWindow() {
|
||||
const win = await windowManager.createWindow(electronModule, {
|
||||
const win = await getWindowManager().createWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
@@ -795,11 +905,12 @@ if (!gotLock) {
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = windowManager.buildAppMenu(Menu, app, isMac);
|
||||
const menu = getWindowManager().buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
const windowManager = getWindowManager();
|
||||
const mainWin = windowManager.getMainWindow();
|
||||
const settingsWin = windowManager.getSettingsWindow();
|
||||
const isPrimary = win === mainWin || win === settingsWin;
|
||||
@@ -818,7 +929,20 @@ if (!gotLock) {
|
||||
void createWindow().then(() => {
|
||||
// Trigger auto-update check 5 s after window creation.
|
||||
// startAutoCheck() is a no-op on unsupported platforms (Linux deb/rpm/snap).
|
||||
autoUpdateBridge.startAutoCheck(5000);
|
||||
getAutoUpdateBridge().startAutoCheck(5000);
|
||||
|
||||
// Pre-warm the settings window in the background so it opens instantly.
|
||||
// Delay slightly to avoid competing with main window first-paint resources.
|
||||
setTimeout(() => {
|
||||
getWindowManager().prewarmSettingsWindow(electronModule, {
|
||||
preload,
|
||||
devServerUrl: effectiveDevServerUrl,
|
||||
isDev,
|
||||
appIcon,
|
||||
isMac,
|
||||
electronDir,
|
||||
});
|
||||
}, 3000);
|
||||
}).catch((err) => {
|
||||
console.error("[Main] Failed to create main window:", err);
|
||||
showStartupError(err);
|
||||
@@ -832,7 +956,7 @@ if (!gotLock) {
|
||||
// If the main window was hidden (e.g. "close to tray"), clicking the Dock icon
|
||||
// should bring it back. Fallback to creating a new window if none exists.
|
||||
try {
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
const mainWin = getWindowManager().getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
if (mainWin.isMinimized?.()) mainWin.restore();
|
||||
mainWin.show?.();
|
||||
@@ -862,7 +986,7 @@ if (!gotLock) {
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
windowManager.setIsQuitting(true);
|
||||
getWindowManager().setIsQuitting(true);
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
@@ -883,12 +1007,12 @@ if (!gotLock) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
try {
|
||||
globalShortcutBridge.cleanup();
|
||||
getGlobalShortcutBridge().cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
try {
|
||||
aiBridge.cleanup();
|
||||
getAiBridge().cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during AI bridge cleanup:", err);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ const transferCompleteListeners = new Map();
|
||||
const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const sftpConnectionProgressListeners = new Set();
|
||||
const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
const fullscreenChangeListeners = new Set();
|
||||
@@ -34,6 +35,7 @@ function cleanupTransferListeners(transferId) {
|
||||
// chunk, then filter complete lines that contain the marker.
|
||||
|
||||
const _mcpLineBufs = new Map(); // sessionId -> trailing fragment string
|
||||
const _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
|
||||
|
||||
// Returns true if `s` ends with a non-empty prefix of "__NCMCP_"
|
||||
// (i.e. the next chunk might complete it into a marker-containing line).
|
||||
@@ -46,6 +48,13 @@ function _endsWithMarkerPrefix(s) {
|
||||
}
|
||||
|
||||
function filterMcpChunk(sessionId, chunk) {
|
||||
// Cancel any pending delayed flush — new data arrived
|
||||
const pendingTimer = _mcpFlushTimers.get(sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
_mcpFlushTimers.delete(sessionId);
|
||||
}
|
||||
|
||||
// Prepend any buffered fragment from the previous chunk
|
||||
const held = _mcpLineBufs.get(sessionId) || "";
|
||||
const data = held + chunk;
|
||||
@@ -58,14 +67,18 @@ function filterMcpChunk(sessionId, chunk) {
|
||||
|
||||
// Slow path: scan line by line
|
||||
let result = "";
|
||||
let droppedAny = false;
|
||||
let pos = 0;
|
||||
while (pos < data.length) {
|
||||
const nlIdx = data.indexOf("\n", pos);
|
||||
if (nlIdx === -1) {
|
||||
// Incomplete trailing line — no newline yet
|
||||
// Incomplete trailing line — no newline yet.
|
||||
// If we dropped any marker line in this chunk, or the tail itself
|
||||
// looks like it could contain a marker, buffer it. Long command
|
||||
// echoes can wrap across PTY lines; wrapped fragments that don't
|
||||
// contain __NCMCP_ would otherwise leak through as garbage.
|
||||
const tail = data.slice(pos);
|
||||
if (tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
// Hold it; next chunk might complete or confirm the marker
|
||||
if (droppedAny || tail.includes("__NCMCP_") || _endsWithMarkerPrefix(tail)) {
|
||||
_mcpLineBufs.set(sessionId, tail);
|
||||
} else {
|
||||
result += tail; // safe to display immediately
|
||||
@@ -75,34 +88,52 @@ function filterMcpChunk(sessionId, chunk) {
|
||||
const line = data.slice(pos, nlIdx + 1); // includes the \n
|
||||
if (!line.includes("__NCMCP_")) {
|
||||
result += line;
|
||||
} else {
|
||||
droppedAny = true;
|
||||
}
|
||||
// else: drop it — it's a wrapper marker line (or echo of one)
|
||||
pos = nlIdx + 1;
|
||||
}
|
||||
|
||||
// Also strip Posix pager prefix and Fish env lines that have no __NCMCP_
|
||||
if (result) {
|
||||
result = result
|
||||
.replace(/PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= /g, "")
|
||||
.replace(/^set -gx (?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS) [^\r\n]*[\r\n]*/gm, "")
|
||||
.replace(/^set "(?:PAGER|SYSTEMD_PAGER|GIT_PAGER|LESS)=[^"]*"[\r\n]*/gm, "");
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deliver data to session listeners. Used both by the normal data path
|
||||
* and by the delayed-flush timer.
|
||||
*/
|
||||
function _deliverToListeners(sessionId, data) {
|
||||
const set = dataListeners.get(sessionId);
|
||||
if (!set || !data) return;
|
||||
set.forEach((cb) => {
|
||||
try { cb(data); } catch (err) { console.error("Data callback failed", err); }
|
||||
});
|
||||
}
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
if (!set) return;
|
||||
const data = filterMcpChunk(payload.sessionId, payload.data);
|
||||
if (!data) return;
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error("Data callback failed", err);
|
||||
}
|
||||
});
|
||||
if (data) {
|
||||
set.forEach((cb) => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error("Data callback failed", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
// If there is buffered content waiting for more data (e.g. a prompt
|
||||
// right after a dropped marker line), schedule a delayed flush so it
|
||||
// appears after a short pause instead of staying hidden forever.
|
||||
if (_mcpLineBufs.has(payload.sessionId)) {
|
||||
const sid = payload.sessionId;
|
||||
_mcpFlushTimers.set(sid, setTimeout(() => {
|
||||
const held = _mcpLineBufs.get(sid);
|
||||
_mcpLineBufs.delete(sid);
|
||||
_mcpFlushTimers.delete(sid);
|
||||
if (held) _deliverToListeners(sid, held);
|
||||
}, 80));
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
@@ -118,22 +149,38 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
}
|
||||
dataListeners.delete(payload.sessionId);
|
||||
exitListeners.delete(payload.sessionId);
|
||||
const pendingTimer = _mcpFlushTimers.get(payload.sessionId);
|
||||
if (pendingTimer) {
|
||||
clearTimeout(pendingTimer);
|
||||
_mcpFlushTimers.delete(payload.sessionId);
|
||||
}
|
||||
_mcpLineBufs.delete(payload.sessionId); // clean up any held fragment
|
||||
});
|
||||
|
||||
// Chain progress events (for jump host connections)
|
||||
ipcRenderer.on("netcatty:chain:progress", (_event, payload) => {
|
||||
const { hop, total, label, status } = payload;
|
||||
const { sessionId, hop, total, label, status, error } = payload;
|
||||
// Notify all registered chain progress listeners
|
||||
chainProgressListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(hop, total, label, status);
|
||||
cb(sessionId, hop, total, label, status, error);
|
||||
} catch (err) {
|
||||
console.error("Chain progress callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// SFTP connection progress events (auth method logs)
|
||||
ipcRenderer.on("netcatty:sftp:connection-progress", (_event, payload) => {
|
||||
sftpConnectionProgressListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload.sessionId, payload.label, payload.status, payload.detail);
|
||||
} catch (err) {
|
||||
console.error("SFTP connection progress callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:languageChanged", (_event, language) => {
|
||||
languageChangeListeners.forEach((cb) => {
|
||||
try {
|
||||
@@ -605,6 +652,9 @@ const api = {
|
||||
chmodSftp: async (sftpId, path, mode, encoding) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:chmod", { sftpId, path, mode, encoding });
|
||||
},
|
||||
getSftpHomeDir: async (sftpId) => {
|
||||
return ipcRenderer.invoke("netcatty:sftp:homeDir", { sftpId });
|
||||
},
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress: async (sftpId, path, content, transferId, encoding, onProgress, onComplete, onError) => {
|
||||
// Register callbacks
|
||||
@@ -805,6 +855,13 @@ const api = {
|
||||
chainProgressListeners.delete(id);
|
||||
};
|
||||
},
|
||||
// SFTP connection progress listener (auth method logs)
|
||||
onSftpConnectionProgress: (cb) => {
|
||||
sftpConnectionProgressListeners.add(cb);
|
||||
return () => {
|
||||
sftpConnectionProgressListeners.delete(cb);
|
||||
};
|
||||
},
|
||||
|
||||
// OAuth callback server
|
||||
startOAuthCallback: (expectedState) => ipcRenderer.invoke("oauth:startCallback", expectedState),
|
||||
@@ -875,6 +932,8 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:showSaveDialog", { defaultPath, filters }),
|
||||
selectDirectory: (title, defaultPath) =>
|
||||
ipcRenderer.invoke("netcatty:selectDirectory", { title, defaultPath }),
|
||||
selectFile: (title, defaultPath, filters) =>
|
||||
ipcRenderer.invoke("netcatty:selectFile", { title, defaultPath, filters }),
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch: (localPath, remotePath, sftpId, encoding) =>
|
||||
@@ -918,6 +977,16 @@ const api = {
|
||||
openSessionLogsDir: (directory) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
|
||||
|
||||
// Crash Logs
|
||||
getCrashLogs: () =>
|
||||
ipcRenderer.invoke("netcatty:crashLogs:list"),
|
||||
readCrashLog: (fileName) =>
|
||||
ipcRenderer.invoke("netcatty:crashLogs:read", { fileName }),
|
||||
clearCrashLogs: () =>
|
||||
ipcRenderer.invoke("netcatty:crashLogs:clear"),
|
||||
openCrashLogsDir: () =>
|
||||
ipcRenderer.invoke("netcatty:crashLogs:openDir"),
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey: (hotkey) =>
|
||||
ipcRenderer.invoke("netcatty:globalHotkey:register", { hotkey }),
|
||||
@@ -1057,8 +1126,11 @@ const api = {
|
||||
aiAllowlistAddHost: async (baseURL) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
|
||||
},
|
||||
aiExec: async (sessionId, command) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command });
|
||||
aiExec: async (sessionId, command, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command, chatSessionId });
|
||||
},
|
||||
aiCattyCancelExec: async (chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:catty:cancel", { chatSessionId });
|
||||
},
|
||||
aiTerminalWrite: async (sessionId, data) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:terminal:write", { sessionId, data });
|
||||
|
||||
46
global.d.ts
vendored
46
global.d.ts
vendored
@@ -38,6 +38,8 @@ declare global {
|
||||
keyId?: string;
|
||||
keySource?: 'generated' | 'imported';
|
||||
label?: string; // Display label for UI
|
||||
proxy?: NetcattyProxyConfig;
|
||||
identityFilePaths?: string[];
|
||||
}
|
||||
|
||||
// Host key information for verification
|
||||
@@ -84,6 +86,8 @@ declare global {
|
||||
sudo?: boolean;
|
||||
// Session log configuration for real-time streaming
|
||||
sessionLog?: { enabled: boolean; directory: string; format: string };
|
||||
// Local SSH key file paths (from SSH config IdentityFile)
|
||||
identityFilePaths?: string[];
|
||||
}
|
||||
|
||||
interface SftpStatResult {
|
||||
@@ -117,12 +121,18 @@ declare global {
|
||||
username: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
keyId?: string;
|
||||
passphrase?: string;
|
||||
proxy?: NetcattyProxyConfig;
|
||||
jumpHosts?: NetcattyJumpHost[];
|
||||
identityFilePaths?: string[];
|
||||
}
|
||||
|
||||
interface PortForwardResult {
|
||||
tunnelId: string;
|
||||
success: boolean;
|
||||
cancelled?: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@@ -208,7 +218,7 @@ declare global {
|
||||
}): Promise<{ stdout: string; stderr: string; code: number | null }>;
|
||||
/** Get current working directory from an active SSH session */
|
||||
getSessionPwd?(sessionId: string): Promise<{ success: boolean; cwd?: string; error?: string }>;
|
||||
/** Get server stats (CPU, Memory, Disk, Network) from an active SSH session - Linux only */
|
||||
/** Get server stats (CPU, Memory, Disk, Network) from an active SSH session */
|
||||
getServerStats?(sessionId: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
@@ -314,6 +324,7 @@ declare global {
|
||||
renameSftp?(sftpId: string, oldPath: string, newPath: string, encoding?: SftpFilenameEncoding): Promise<void>;
|
||||
statSftp?(sftpId: string, path: string, encoding?: SftpFilenameEncoding): Promise<SftpStatResult>;
|
||||
chmodSftp?(sftpId: string, path: string, mode: string, encoding?: SftpFilenameEncoding): Promise<void>;
|
||||
getSftpHomeDir?(sftpId: string): Promise<{ success: boolean; homeDir?: string; error?: string }>;
|
||||
|
||||
// Write binary with real-time progress callback
|
||||
writeSftpBinaryWithProgress?(
|
||||
@@ -461,8 +472,11 @@ declare global {
|
||||
onLanguageChanged?(cb: (language: string) => void): () => void;
|
||||
|
||||
// Chain progress listener for jump host connections
|
||||
// Callback receives: (currentHop: number, totalHops: number, hostLabel: string, status: string)
|
||||
onChainProgress?(cb: (hop: number, total: number, label: string, status: string) => void): () => void;
|
||||
// Callback receives: (sessionId: string, currentHop: number, totalHops: number, hostLabel: string, status: string, error?: string)
|
||||
onChainProgress?(cb: (sessionId: string, hop: number, total: number, label: string, status: string, error?: string) => void): () => void;
|
||||
|
||||
// SFTP connection progress listener (auth method logs)
|
||||
onSftpConnectionProgress?(cb: (sessionId: string, label: string, status: string, detail?: string) => void): () => void;
|
||||
|
||||
// OAuth callback server for cloud sync
|
||||
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
@@ -578,6 +592,7 @@ declare global {
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
|
||||
selectDirectory?(title?: string, defaultPath?: string): Promise<string | null>;
|
||||
selectFile?(title?: string, defaultPath?: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
|
||||
|
||||
// File watcher for auto-sync feature
|
||||
startFileWatch?(localPath: string, remotePath: string, sftpId: string, encoding?: SftpFilenameEncoding): Promise<{ watchId: string }>;
|
||||
@@ -590,6 +605,28 @@ declare global {
|
||||
// Temp file cleanup
|
||||
deleteTempFile?(filePath: string): Promise<{ success: boolean }>;
|
||||
|
||||
// Crash Logs
|
||||
getCrashLogs?(): Promise<Array<{ fileName: string; date: string; size: number; entryCount: number }>>;
|
||||
readCrashLog?(fileName: string): Promise<Array<{
|
||||
timestamp: string;
|
||||
source: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
errorMeta?: Record<string, unknown>;
|
||||
extra?: Record<string, unknown>;
|
||||
pid?: number;
|
||||
platform?: string;
|
||||
arch?: string;
|
||||
version?: string;
|
||||
electronVersion?: string;
|
||||
osVersion?: string;
|
||||
memoryMB?: { rss: number; heapUsed: number; heapTotal: number };
|
||||
activeSessionCount?: number;
|
||||
uptimeSeconds?: number;
|
||||
}>>;
|
||||
clearCrashLogs?(): Promise<{ deletedCount: number }>;
|
||||
openCrashLogsDir?(): Promise<{ success: boolean }>;
|
||||
|
||||
// Temp directory management
|
||||
getTempDirInfo?(): Promise<{ path: string; fileCount: number; totalSize: number }>;
|
||||
clearTempDir?(): Promise<{ deletedCount: number; failedCount: number; error?: string }>;
|
||||
@@ -631,7 +668,8 @@ declare global {
|
||||
aiChatCancel?(requestId: string): Promise<boolean>;
|
||||
aiFetch?(url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; status: number; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?(baseURL: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiExec?(sessionId: string, command: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
|
||||
aiExec?(sessionId: string, command: string, chatSessionId?: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
|
||||
aiCattyCancelExec?(chatSessionId: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiTerminalWrite?(sessionId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiDiscoverAgents?(): Promise<Array<{
|
||||
command: string;
|
||||
|
||||
68
index.tsx
68
index.tsx
@@ -13,6 +13,72 @@ import { ToastProvider } from './components/ui/toast';
|
||||
const LazySettingsPage = lazy(() => import('./components/SettingsPage'));
|
||||
const LazyTrayPanel = lazy(() => import('./components/TrayPanel'));
|
||||
|
||||
function SettingsWindowFallback() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: '100vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: 'hsl(var(--background))',
|
||||
color: 'hsl(var(--foreground))',
|
||||
fontFamily: 'Space Grotesk, system-ui, sans-serif',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
borderBottom: '1px solid hsl(var(--border))',
|
||||
padding: '20px 16px 12px',
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 18, fontWeight: 600 }}>Settings</div>
|
||||
<div style={{ marginTop: 6, fontSize: 13, color: 'hsl(var(--muted-foreground))' }}>
|
||||
Loading preferences...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', flex: 1, minHeight: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
width: 224,
|
||||
flexShrink: 0,
|
||||
borderRight: '1px solid hsl(var(--border))',
|
||||
padding: 12,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{Array.from({ length: 7 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
height: 36,
|
||||
borderRadius: 8,
|
||||
background: index === 0 ? 'hsl(var(--card))' : 'hsl(var(--muted) / 0.45)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, padding: 20, display: 'flex', flexDirection: 'column', gap: 14 }}>
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
style={{
|
||||
height: index === 0 ? 54 : 76,
|
||||
borderRadius: 12,
|
||||
background: 'hsl(var(--muted) / 0.38)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
@@ -37,7 +103,7 @@ const renderApp = () => {
|
||||
if (route === 'settings') {
|
||||
root.render(
|
||||
<ToastProvider>
|
||||
<Suspense fallback={null}>
|
||||
<Suspense fallback={<SettingsWindowFallback />}>
|
||||
<LazySettingsPage />
|
||||
</Suspense>
|
||||
</ToastProvider>
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface NetcattyBridge {
|
||||
aiExec(
|
||||
sessionId: string,
|
||||
command: string,
|
||||
chatSessionId?: string,
|
||||
): Promise<{
|
||||
ok: boolean;
|
||||
stdout?: string;
|
||||
@@ -82,6 +83,7 @@ export function createToolExecutor(
|
||||
commandBlocklist?: string[],
|
||||
permissionMode: AIPermissionMode = 'confirm',
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
chatSessionId?: string,
|
||||
): (toolCall: ToolCall) => Promise<ToolResult> {
|
||||
return async (toolCall: ToolCall): Promise<ToolResult> => {
|
||||
if (!bridge) {
|
||||
@@ -92,7 +94,7 @@ export function createToolExecutor(
|
||||
};
|
||||
}
|
||||
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig, chatSessionId };
|
||||
const args = toolCall.arguments;
|
||||
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,7 @@ export function createCattyTools(
|
||||
webSearchConfig?: WebSearchConfig,
|
||||
chatSessionId?: string,
|
||||
) {
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig };
|
||||
const deps: ToolDeps = { bridge, context, commandBlocklist, permissionMode, webSearchConfig, chatSessionId };
|
||||
|
||||
return {
|
||||
terminal_execute: tool({
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface ToolDeps {
|
||||
commandBlocklist?: string[];
|
||||
permissionMode: AIPermissionMode;
|
||||
webSearchConfig?: WebSearchConfig;
|
||||
chatSessionId?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -82,7 +83,7 @@ export async function executeTerminalExecute(
|
||||
return { ok: false, error: `Command blocked by safety policy. Matched pattern: ${safety.matchedPattern}` };
|
||||
}
|
||||
|
||||
const result = await bridge.aiExec(sessionId, command);
|
||||
const result = await bridge.aiExec(sessionId, command, deps.chatSessionId);
|
||||
// Real execution failures (timeout, disconnect, no stream) have an `error` field
|
||||
if (!result.ok && result.error) {
|
||||
const parts = [result.error];
|
||||
|
||||
@@ -87,5 +87,6 @@ export const STORAGE_KEY_AI_COMMAND_BLOCKLIST = 'netcatty_ai_command_blocklist_v
|
||||
export const STORAGE_KEY_AI_COMMAND_TIMEOUT = 'netcatty_ai_command_timeout_v1';
|
||||
export const STORAGE_KEY_AI_MAX_ITERATIONS = 'netcatty_ai_max_iterations_v1';
|
||||
export const STORAGE_KEY_AI_SESSIONS = 'netcatty_ai_sessions_v1';
|
||||
export const STORAGE_KEY_AI_ACTIVE_SESSION_MAP = 'netcatty_ai_active_session_map_v1';
|
||||
export const STORAGE_KEY_AI_AGENT_MODEL_MAP = 'netcatty_ai_agent_model_map_v1';
|
||||
export const STORAGE_KEY_AI_WEB_SEARCH = 'netcatty_ai_web_search_v1';
|
||||
|
||||
@@ -36,6 +36,9 @@ export const XTERM_PERFORMANCE_CONFIG = {
|
||||
// Font rendering settings
|
||||
letterSpacing: 0,
|
||||
lineHeight: 1,
|
||||
|
||||
// Keep viewport movement smooth without feeling sluggish.
|
||||
smoothScrollDuration: 120,
|
||||
},
|
||||
|
||||
// WebGL-specific optimizations
|
||||
@@ -94,6 +97,11 @@ export const XTERM_PERFORMANCE_CONFIG = {
|
||||
// Debounce time for viewport scanning (ms)
|
||||
// Higher values = better scrolling performance, but slower highlight "catch up"
|
||||
debounceMs: 200,
|
||||
// Minimum interval between immediate (rAF) refreshes in ms.
|
||||
// Prevents heavy output (e.g. tail -f) from refreshing every frame.
|
||||
immediateMinIntervalMs: 50,
|
||||
// Number of unique line scan results to keep cached.
|
||||
cacheEntries: 1200,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -110,6 +118,7 @@ export type ResolvedXTermPerformance = {
|
||||
customGlyphs: boolean;
|
||||
letterSpacing: number;
|
||||
lineHeight: number;
|
||||
smoothScrollDuration: number;
|
||||
documentOverride: boolean;
|
||||
tabStopWidth: number;
|
||||
convertEol: boolean;
|
||||
@@ -177,6 +186,7 @@ export function resolveXTermPerformanceConfig({
|
||||
customGlyphs: baseConfig.rendering.customGlyphs,
|
||||
letterSpacing: baseConfig.rendering.letterSpacing,
|
||||
lineHeight: baseConfig.rendering.lineHeight,
|
||||
smoothScrollDuration: baseConfig.rendering.smoothScrollDuration,
|
||||
documentOverride: baseConfig.events.documentOverride,
|
||||
tabStopWidth: baseConfig.events.tabStopWidth,
|
||||
convertEol: baseConfig.events.convertEol,
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* for establishing and managing SSH port forwarding tunnels.
|
||||
*/
|
||||
|
||||
import { Host,PortForwardingRule } from '../../domain/models';
|
||||
import { Host, Identity, PortForwardingRule, SSHKey } from '../../domain/models';
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { logger } from '../../lib/logger';
|
||||
import { netcattyBridge } from './netcattyBridge';
|
||||
|
||||
@@ -357,7 +359,9 @@ export const reconcileWithBackend = async (): Promise<{
|
||||
export const startPortForward = async (
|
||||
rule: PortForwardingRule,
|
||||
host: Host,
|
||||
keys: { id: string; privateKey: string; passphrase: string }[],
|
||||
hosts: Host[],
|
||||
keys: SSHKey[],
|
||||
identities: Identity[],
|
||||
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
|
||||
enableReconnect = false
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
@@ -375,16 +379,71 @@ export const startPortForward = async (
|
||||
try {
|
||||
// Generate a unique tunnel ID
|
||||
const tunnelId = `pf-${rule.id}-${Date.now()}`;
|
||||
|
||||
// Get the private key and passphrase if using key auth
|
||||
let privateKey: string | undefined;
|
||||
let passphrase: string | undefined;
|
||||
if (host.identityFileId) {
|
||||
const key = keys.find(k => k.id === host.identityFileId);
|
||||
if (key) {
|
||||
privateKey = key.privateKey;
|
||||
passphrase = key.passphrase;
|
||||
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key;
|
||||
const proxy = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds?.length) {
|
||||
const resolvedJumpHosts = host.hostChain.hostIds.map((hostId) =>
|
||||
hosts.find((candidate) => candidate.id === hostId),
|
||||
);
|
||||
const missingJumpHostIds = host.hostChain.hostIds.filter((_, index) => !resolvedJumpHosts[index]);
|
||||
if (missingJumpHostIds.length > 0) {
|
||||
throw new Error(`Missing jump host configuration for host chain: ${missingJumpHostIds.join(", ")}`);
|
||||
}
|
||||
jumpHosts = resolvedJumpHosts
|
||||
.filter((jumpHost): jumpHost is Host => Boolean(jumpHost))
|
||||
.map((jumpHost, index) => {
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
const jumpResolved = resolveHostAuth({ host: jumpHost, keys, identities });
|
||||
const jumpKey = jumpResolved.key;
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpResolved.username || 'root',
|
||||
password: jumpResolved.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpResolved.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpResolved.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxy && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxy?.password) {
|
||||
throw new Error('Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.');
|
||||
}
|
||||
|
||||
// Subscribe to status updates first
|
||||
@@ -428,10 +487,15 @@ export const startPortForward = async (
|
||||
remotePort: rule.remotePort,
|
||||
hostname: host.hostname,
|
||||
port: host.port,
|
||||
username: host.username,
|
||||
password: host.password,
|
||||
privateKey,
|
||||
passphrase,
|
||||
username: resolved.username,
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
keyId: resolved.keyId,
|
||||
passphrase: resolved.passphrase || key?.passphrase,
|
||||
proxy,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
|
||||
@@ -54,6 +54,7 @@ const KNOWN_MONOSPACE_FONTS = new Set([
|
||||
'noto sans mono',
|
||||
'sarasa mono',
|
||||
'maple mono',
|
||||
'meslolgs nf',
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -124,4 +125,4 @@ export async function getMonospaceFonts(): Promise<TerminalFont[]> {
|
||||
console.warn('Failed to query local fonts:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user