Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
c136006108 | ||
|
|
ba073219e5 | ||
|
|
034e5ea3bc | ||
|
|
6b24e38326 | ||
|
|
b972866c8e | ||
|
|
8c541fb6e2 | ||
|
|
b73e60fb6d |
62
.github/workflows/build.yml
vendored
62
.github/workflows/build.yml
vendored
@@ -93,6 +93,8 @@ jobs:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-22.04
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
npm_config_target_arch: x64
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
@@ -122,16 +124,22 @@ jobs:
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Prepare node-pty Linux runtime
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
run: bash scripts/ensure-node-pty-linux.sh prepare x64
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
npm_config_arch: x64
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-x64
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh amd64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -153,6 +161,8 @@ jobs:
|
||||
container:
|
||||
image: debian:bullseye
|
||||
env:
|
||||
npm_config_arch: arm64
|
||||
npm_config_target_arch: arm64
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
@@ -198,6 +208,9 @@ jobs:
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
||||
|
||||
- name: Verify packaged deb artifact
|
||||
run: bash scripts/verify-linux-deb-artifact.sh arm64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -217,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
|
||||
@@ -230,6 +244,54 @@ 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)"
|
||||
test -n "${deb_file}"
|
||||
bash scripts/verify-linux-deb-artifact.sh amd64 "${deb_file}"
|
||||
|
||||
- name: Verify downloaded Linux arm64 deb artifact metadata
|
||||
env:
|
||||
VERIFY_LOAD: "0"
|
||||
run: |
|
||||
deb_file="$(find artifacts -maxdepth 1 -type f -name '*-linux-arm64.deb' -print | sort | head -n 1)"
|
||||
test -n "${deb_file}"
|
||||
bash scripts/verify-linux-deb-artifact.sh arm64 "${deb_file}"
|
||||
|
||||
- name: Generate Release Body
|
||||
run: node .github/scripts/generate-release-note.js
|
||||
env:
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '当前版本',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -391,13 +391,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);
|
||||
|
||||
@@ -6,6 +6,7 @@ module.exports = {
|
||||
productName: 'Netcatty',
|
||||
artifactName: '${productName}-${version}-${os}-${arch}.${ext}',
|
||||
icon: 'public/icon.png',
|
||||
npmRebuild: false,
|
||||
directories: {
|
||||
buildResources: 'build',
|
||||
output: 'release'
|
||||
@@ -90,20 +91,7 @@ module.exports = {
|
||||
shortcutName: 'Netcatty'
|
||||
},
|
||||
linux: {
|
||||
target: [
|
||||
{
|
||||
target: 'AppImage',
|
||||
arch: ['x64', 'arm64']
|
||||
},
|
||||
{
|
||||
target: 'deb',
|
||||
arch: ['x64', 'arm64']
|
||||
},
|
||||
{
|
||||
target: 'rpm',
|
||||
arch: ['x64', 'arm64']
|
||||
}
|
||||
],
|
||||
target: ['AppImage', 'deb', 'rpm'],
|
||||
category: 'Development'
|
||||
},
|
||||
deb: {
|
||||
|
||||
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)));
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
23
global.d.ts
vendored
23
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?(
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -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 [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,22 +53,43 @@ assert_loadable_native_module() {
|
||||
' "${file}"
|
||||
}
|
||||
|
||||
resolve_serialport_prebuild() {
|
||||
local root="$1"
|
||||
local arch="$2"
|
||||
local file
|
||||
|
||||
file="$(find "${root}/prebuilds/linux-${arch}" -maxdepth 1 -type f -name '@serialport+bindings-cpp*.glibc.node' -print | sort | head -n 1)"
|
||||
if [[ -z "${file}" ]]; then
|
||||
echo "[node-pty] serialport glibc prebuild not found for linux-${arch}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${file}"
|
||||
}
|
||||
|
||||
prepare() {
|
||||
local arch="$1"
|
||||
local root="node_modules/node-pty"
|
||||
local release_dir="${root}/build/Release"
|
||||
local prebuild_dir="${root}/prebuilds/linux-${arch}"
|
||||
local serialport_root="node_modules/@serialport/bindings-cpp"
|
||||
local serialport_release_dir="${serialport_root}/build/Release"
|
||||
local serialport_prebuild
|
||||
|
||||
echo "[node-pty] rebuilding native modules for Electron on linux-${arch}"
|
||||
log_electron_runtime_info
|
||||
npx electron-rebuild
|
||||
rm -rf "${release_dir}" "${prebuild_dir}" "${serialport_release_dir}"
|
||||
npx electron-rebuild --force --arch "${arch}" -w "node-pty,@serialport/bindings-cpp"
|
||||
|
||||
test -f "${release_dir}/pty.node"
|
||||
test -f "${serialport_release_dir}/bindings.node"
|
||||
|
||||
echo "[node-pty] built Linux runtime artifacts:"
|
||||
log_file_info "${release_dir}/pty.node"
|
||||
log_optional_spawn_helper "${release_dir}/spawn-helper"
|
||||
assert_loadable_native_module "${release_dir}/pty.node"
|
||||
log_file_info "${serialport_release_dir}/bindings.node"
|
||||
assert_loadable_native_module "${serialport_release_dir}/bindings.node"
|
||||
|
||||
mkdir -p "${prebuild_dir}"
|
||||
cp "${release_dir}/pty.node" "${prebuild_dir}/pty.node"
|
||||
@@ -79,17 +100,26 @@ prepare() {
|
||||
echo "[node-pty] mirrored Linux runtime artifacts into ${prebuild_dir}:"
|
||||
log_file_info "${prebuild_dir}/pty.node"
|
||||
log_optional_spawn_helper "${prebuild_dir}/spawn-helper"
|
||||
|
||||
serialport_prebuild="$(resolve_serialport_prebuild "${serialport_root}" "${arch}")"
|
||||
echo "[node-pty] serialport packaged prebuild candidate:"
|
||||
log_file_info "${serialport_prebuild}"
|
||||
assert_loadable_native_module "${serialport_prebuild}"
|
||||
}
|
||||
|
||||
verify() {
|
||||
local arch="$1"
|
||||
local release_dir
|
||||
local prebuild_dir
|
||||
local serialport_release_file
|
||||
local serialport_prebuild_file
|
||||
|
||||
log_electron_runtime_info
|
||||
|
||||
release_dir="$(find release -type d -path "*/resources/app.asar.unpacked/node_modules/node-pty/build/Release" -print -quit)"
|
||||
prebuild_dir="$(find release -type d -path "*/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-${arch}" -print -quit)"
|
||||
serialport_release_file="$(find release -type f -path "*/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp/build/Release/bindings.node" -print -quit)"
|
||||
serialport_prebuild_file="$(find release -type f -path "*/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp/prebuilds/linux-${arch}/@serialport+bindings-cpp*.glibc.node" -print | sort | head -n 1)"
|
||||
|
||||
if [[ -z "${release_dir}" ]]; then
|
||||
echo "[node-pty] packaged build/Release directory not found under release/" >&2
|
||||
@@ -101,6 +131,16 @@ verify() {
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${serialport_release_file}" ]]; then
|
||||
echo "[node-pty] packaged serialport build/Release binding not found under release/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "${serialport_prebuild_file}" ]]; then
|
||||
echo "[node-pty] packaged serialport glibc prebuild not found for linux-${arch} under release/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
test -f "${release_dir}/pty.node"
|
||||
test -f "${prebuild_dir}/pty.node"
|
||||
|
||||
@@ -114,10 +154,22 @@ verify() {
|
||||
log_optional_spawn_helper "${prebuild_dir}/spawn-helper"
|
||||
assert_loadable_native_module "${prebuild_dir}/pty.node"
|
||||
|
||||
echo "[node-pty] packaged serialport build/Release artifact:"
|
||||
log_file_info "${serialport_release_file}"
|
||||
assert_loadable_native_module "${serialport_release_file}"
|
||||
|
||||
echo "[node-pty] packaged serialport prebuild artifact:"
|
||||
log_file_info "${serialport_prebuild_file}"
|
||||
assert_loadable_native_module "${serialport_prebuild_file}"
|
||||
|
||||
echo "[node-pty] packaged artifact locations:"
|
||||
find release -path "*/resources/app.asar.unpacked/node_modules/node-pty/*" \
|
||||
\( -name 'pty.node' -o -name 'spawn-helper' \) \
|
||||
-print | sort
|
||||
|
||||
find release -path "*/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp/*" \
|
||||
\( -name 'bindings.node' -o -name '@serialport+bindings-cpp*.node' \) \
|
||||
-print | sort
|
||||
}
|
||||
|
||||
main() {
|
||||
|
||||
208
scripts/verify-linux-deb-artifact.sh
Executable file
208
scripts/verify-linux-deb-artifact.sh
Executable file
@@ -0,0 +1,208 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
TEMP_DIR=""
|
||||
|
||||
usage() {
|
||||
echo "Usage: $0 <amd64|arm64> [deb-file]" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
checksum() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$@"
|
||||
else
|
||||
shasum -a 256 "$@"
|
||||
fi
|
||||
}
|
||||
|
||||
require_cmd() {
|
||||
local cmd="$1"
|
||||
command -v "${cmd}" >/dev/null 2>&1 || {
|
||||
echo "[deb-verify] missing required command: ${cmd}" >&2
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
assert_exists() {
|
||||
local file="$1"
|
||||
if [[ ! -e "${file}" ]]; then
|
||||
echo "[deb-verify] expected file does not exist: ${file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_executable() {
|
||||
local file="$1"
|
||||
if [[ ! -x "${file}" ]]; then
|
||||
echo "[deb-verify] expected executable file is missing or not executable: ${file}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
log_file_info() {
|
||||
local file="$1"
|
||||
echo "[deb-verify] file: ${file}"
|
||||
ls -lh "${file}"
|
||||
file "${file}"
|
||||
checksum "${file}"
|
||||
}
|
||||
|
||||
assert_file_arch() {
|
||||
local file="$1"
|
||||
local expected="$2"
|
||||
local info
|
||||
|
||||
info="$(file "${file}")"
|
||||
echo "[deb-verify] arch-check: ${info}"
|
||||
if [[ "${info}" != *"${expected}"* ]]; then
|
||||
echo "[deb-verify] unexpected architecture for ${file}" >&2
|
||||
echo "[deb-verify] expected substring: ${expected}" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
assert_loadable_native_module() {
|
||||
local electron_bin="$1"
|
||||
local native_module="$2"
|
||||
|
||||
if [[ "${VERIFY_LOAD:-1}" != "1" ]]; then
|
||||
echo "[deb-verify] skipping native module load check for ${native_module} (VERIFY_LOAD=${VERIFY_LOAD:-1})"
|
||||
return
|
||||
fi
|
||||
|
||||
echo "[deb-verify] loading native module with packaged Electron runtime: ${native_module}"
|
||||
ELECTRON_RUN_AS_NODE=1 "${electron_bin}" -e '
|
||||
const path = require("node:path");
|
||||
require(path.resolve(process.argv[1]));
|
||||
console.log("[deb-verify] native module loaded successfully");
|
||||
' "${native_module}"
|
||||
}
|
||||
|
||||
resolve_file_from_glob() {
|
||||
local search_dir="$1"
|
||||
local pattern="$2"
|
||||
find "${search_dir}" -maxdepth 1 -type f -name "${pattern}" -print | sort | head -n 1
|
||||
}
|
||||
|
||||
resolve_single_file() {
|
||||
local search_dir="$1"
|
||||
local pattern="$2"
|
||||
local file
|
||||
|
||||
file="$(resolve_file_from_glob "${search_dir}" "${pattern}")"
|
||||
if [[ -z "${file}" ]]; then
|
||||
echo "[deb-verify] no file matched ${pattern} under ${search_dir}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${file}"
|
||||
}
|
||||
|
||||
resolve_serialport_prebuild() {
|
||||
local root="$1"
|
||||
local arch="$2"
|
||||
local prebuild_dir="${root}/prebuilds/linux-${arch}"
|
||||
local file
|
||||
|
||||
file="$(find "${prebuild_dir}" -maxdepth 1 -type f -name '@serialport+bindings-cpp*.glibc.node' -print | sort | head -n 1)"
|
||||
if [[ -z "${file}" ]]; then
|
||||
echo "[deb-verify] serialport glibc prebuild not found under ${prebuild_dir}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "${file}"
|
||||
}
|
||||
|
||||
verify_native_module() {
|
||||
local label="$1"
|
||||
local electron_bin="$2"
|
||||
local file="$3"
|
||||
local expected_machine="$4"
|
||||
|
||||
assert_exists "${file}"
|
||||
echo "[deb-verify] verifying ${label}"
|
||||
log_file_info "${file}"
|
||||
assert_file_arch "${file}" "${expected_machine}"
|
||||
assert_loadable_native_module "${electron_bin}" "${file}"
|
||||
}
|
||||
|
||||
main() {
|
||||
if [[ $# -lt 1 || $# -gt 2 ]]; then
|
||||
usage
|
||||
fi
|
||||
|
||||
local deb_arch="$1"
|
||||
local prebuild_arch
|
||||
local expected_machine
|
||||
local deb_file
|
||||
local control_arch
|
||||
local electron_bin
|
||||
local main_binary
|
||||
local build_release_pty
|
||||
local prebuild_pty
|
||||
local serialport_root
|
||||
local build_release_serialport
|
||||
local prebuild_serialport
|
||||
|
||||
require_cmd dpkg-deb
|
||||
require_cmd file
|
||||
|
||||
case "${deb_arch}" in
|
||||
amd64)
|
||||
prebuild_arch="x64"
|
||||
expected_machine="x86-64"
|
||||
;;
|
||||
arm64)
|
||||
prebuild_arch="arm64"
|
||||
expected_machine="ARM aarch64"
|
||||
;;
|
||||
*)
|
||||
usage
|
||||
;;
|
||||
esac
|
||||
|
||||
if [[ $# -eq 2 ]]; then
|
||||
deb_file="$2"
|
||||
assert_exists "${deb_file}"
|
||||
else
|
||||
deb_file="$(resolve_single_file "release" "*-linux-${deb_arch}.deb")"
|
||||
fi
|
||||
|
||||
echo "[deb-verify] verifying deb artifact: ${deb_file}"
|
||||
log_file_info "${deb_file}"
|
||||
|
||||
control_arch="$(dpkg-deb -f "${deb_file}" Architecture)"
|
||||
echo "[deb-verify] control architecture: ${control_arch}"
|
||||
if [[ "${control_arch}" != "${deb_arch}" ]]; then
|
||||
echo "[deb-verify] deb control architecture mismatch: expected ${deb_arch}, got ${control_arch}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TEMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "${TEMP_DIR:-}"' EXIT
|
||||
dpkg-deb -x "${deb_file}" "${TEMP_DIR}"
|
||||
|
||||
electron_bin="${TEMP_DIR}/opt/Netcatty/netcatty"
|
||||
main_binary="${TEMP_DIR}/opt/Netcatty/netcatty"
|
||||
build_release_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/build/Release/pty.node"
|
||||
prebuild_pty="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/node-pty/prebuilds/linux-${prebuild_arch}/pty.node"
|
||||
serialport_root="${TEMP_DIR}/opt/Netcatty/resources/app.asar.unpacked/node_modules/@serialport/bindings-cpp"
|
||||
build_release_serialport="${serialport_root}/build/Release/bindings.node"
|
||||
prebuild_serialport="$(resolve_serialport_prebuild "${serialport_root}" "${prebuild_arch}")"
|
||||
|
||||
assert_executable "${electron_bin}"
|
||||
|
||||
echo "[deb-verify] verifying packaged binary architectures"
|
||||
log_file_info "${main_binary}"
|
||||
assert_file_arch "${main_binary}" "${expected_machine}"
|
||||
verify_native_module "node-pty build/Release" "${electron_bin}" "${build_release_pty}" "${expected_machine}"
|
||||
verify_native_module "node-pty prebuild" "${electron_bin}" "${prebuild_pty}" "${expected_machine}"
|
||||
verify_native_module "serialport build/Release" "${electron_bin}" "${build_release_serialport}" "${expected_machine}"
|
||||
verify_native_module "serialport glibc prebuild" "${electron_bin}" "${prebuild_serialport}" "${expected_machine}"
|
||||
|
||||
echo "[deb-verify] deb artifact verification passed for ${deb_file}"
|
||||
}
|
||||
|
||||
main "$@"
|
||||
Reference in New Issue
Block a user