Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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)"
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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': '当前版本',
|
||||
@@ -1204,6 +1219,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': '无(直接点击)',
|
||||
|
||||
@@ -278,8 +278,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 +305,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}, []);
|
||||
|
||||
@@ -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)}`;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -635,28 +635,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 +657,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}, 200);
|
||||
|
||||
return () => {
|
||||
if (stepTimer) clearInterval(stepTimer);
|
||||
clearInterval(countdown);
|
||||
clearTimeout(timeout);
|
||||
clearInterval(prog);
|
||||
@@ -787,6 +764,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;
|
||||
|
||||
@@ -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';
|
||||
@@ -982,8 +982,9 @@ 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);
|
||||
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;
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -403,21 +403,56 @@ 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':
|
||||
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));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -410,6 +410,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 +534,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollOnOutput: false,
|
||||
scrollOnKeyPress: false,
|
||||
scrollOnPaste: true,
|
||||
smoothScrolling: true,
|
||||
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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
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,
|
||||
};
|
||||
@@ -225,6 +225,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)));
|
||||
@@ -439,7 +444,7 @@ 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}...`);
|
||||
|
||||
@@ -1586,6 +1591,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 +1665,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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -213,7 +213,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;
|
||||
@@ -394,9 +394,19 @@ function buildAuthHandler(options) {
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
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;
|
||||
onAuthAttempt?.(keyLabel);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
@@ -408,12 +418,14 @@ function buildAuthHandler(options) {
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
onAuthAttempt?.("password");
|
||||
return callback({
|
||||
type: "password",
|
||||
username,
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
onAuthAttempt?.("keyboard-interactive");
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -333,9 +333,9 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
const connections = [];
|
||||
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,7 +347,7 @@ 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');
|
||||
|
||||
@@ -406,6 +406,9 @@ 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);
|
||||
|
||||
@@ -424,6 +427,10 @@ 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');
|
||||
@@ -431,12 +438,14 @@ async function connectThroughChain(event, options, jumpHosts, targetHost, target
|
||||
});
|
||||
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({
|
||||
@@ -508,9 +517,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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -850,10 +859,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 +880,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 +888,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.
|
||||
@@ -924,10 +944,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 +969,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 +987,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,
|
||||
@@ -1076,6 +1109,7 @@ async function startSSHSession(event, options) {
|
||||
}, 300);
|
||||
}
|
||||
|
||||
settled = true;
|
||||
resolve({ sessionId });
|
||||
}
|
||||
);
|
||||
@@ -1102,6 +1136,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 +1145,7 @@ async function startSSHSession(event, options) {
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@@ -1117,6 +1153,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,11 +1162,15 @@ async function startSSHSession(event, options) {
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
conn.once("close", () => {
|
||||
const contents = event.sender;
|
||||
if (!settled) {
|
||||
sendProgress(totalHops, totalHops, options.hostname, 'error', `Connection to ${options.hostname} closed unexpectedly`);
|
||||
}
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0, reason: "closed" });
|
||||
sessionLogStreamManager.stopStream(sessionId);
|
||||
sessions.delete(sessionId);
|
||||
@@ -1138,6 +1179,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)
|
||||
|
||||
@@ -692,6 +692,18 @@ async function createWindow(electronModule, 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"]);
|
||||
|
||||
@@ -18,16 +18,37 @@ if (process.env.ELECTRON_RUN_AS_NODE) {
|
||||
delete process.env.ELECTRON_RUN_AS_NODE;
|
||||
}
|
||||
|
||||
// Load crash log bridge early so process-level error handlers can use it
|
||||
const crashLogBridge = require("./bridges/crashLogBridge.cjs");
|
||||
|
||||
// Handle uncaught exceptions for EPIPE errors
|
||||
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;
|
||||
}
|
||||
// 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;
|
||||
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 {
|
||||
@@ -85,6 +106,7 @@ const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
const autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
|
||||
const aiBridge = require("./bridges/aiBridge.cjs");
|
||||
// crashLogBridge is required at the top of the file (before error handlers)
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -381,6 +403,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,6 +435,7 @@ const registerBridges = (win) => {
|
||||
autoUpdateBridge.init(deps);
|
||||
autoUpdateBridge.registerHandlers(ipcMain);
|
||||
aiBridge.registerHandlers(ipcMain);
|
||||
crashLogBridge.registerHandlers(ipcMain);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
|
||||
@@ -123,11 +123,11 @@ ipcRenderer.on("netcatty:exit", (_event, payload) => {
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -605,6 +605,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
|
||||
@@ -918,6 +921,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 }),
|
||||
|
||||
27
global.d.ts
vendored
27
global.d.ts
vendored
@@ -314,6 +314,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 +462,8 @@ 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;
|
||||
|
||||
// OAuth callback server for cloud sync
|
||||
startOAuthCallback?(expectedState?: string): Promise<{ code: string; state?: string }>;
|
||||
@@ -590,6 +591,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 }>;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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