Compare commits

...

32 Commits

Author SHA1 Message Date
陈大猫
0a3e61af4b Merge pull request #462 from binaricat/fix/snippet-execution-order
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
fix: normalize line endings and bracket-paste multi-line snippets
2026-03-23 17:51:06 +08:00
bincxz
9e4a79acd7 fix: remove unconditional bracket paste from sidebar, fix broadcast
- TerminalLayer: remove bracket paste wrapping since we can't check
  term.modes.bracketedPasteMode here — keep only normalizeLineEndings
- createXTermRuntime: broadcast un-wrapped data before applying
  bracket paste, so target sessions don't receive literal escape
  sequences meant for the source terminal's paste mode state

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:44:49 +08:00
bincxz
a62353bb41 fix: respect bracketedPasteMode and disableBracketedPaste for snippets
Only wrap multi-line snippets in bracket paste sequences when:
- createXTermRuntime: term.modes.bracketedPasteMode is active AND
  disableBracketedPaste setting is false (matches paste handler)
- TerminalLayer: disableBracketedPaste setting is false (no access
  to term.modes, but respects user opt-out)

Prevents sending literal ^[[200~ escape sequences to shells that
don't support or have disabled bracketed paste mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:39:48 +08:00
bincxz
d2ab27ab92 fix: normalize line endings and bracket-paste multi-line snippets
Snippet execution via sidebar click was missing normalizeLineEndings()
and bracket paste wrapping that the paste handler and shortkey handler
already apply. On Windows ConPTY/PowerShell, sending raw multi-line
input without bracket paste can cause out-of-order line execution
because the shell processes lines individually and asynchronously.

- Add normalizeLineEndings() to sidebar snippet click handler
- Wrap multi-line snippets in bracketed paste sequences (\e[200~...\e[201~)
  so the shell treats them as a single atomic paste
- Apply same fix to shortkey snippet handler for consistency
- Fix broadcast payload to use the processed data

Fixes #455

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:33:36 +08:00
陈大猫
65f62983b6 Merge pull request #461 from binaricat/fix/sftp-home-dir
fix: detect actual home directory for SFTP auto-open
2026-03-23 17:21:16 +08:00
bincxz
56d3109d23 fix: abort timed-out exec channel, treat realpath '/' as ambiguous
- Close/destroy the SSH exec stream when the 5s timeout fires to
  avoid leaking session slots (MaxSessions).
- Treat SFTP realpath('.') returning '/' as non-authoritative so
  non-root users fall through to the candidate probe chain instead
  of incorrectly opening at root.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:15:13 +08:00
bincxz
34ab6c0e98 fix: add 5s timeout to SSH echo ~ home dir probe
Prevent indefinite blocking when the remote shell init hangs or a
forced command never exits. Falls through to SFTP realpath after
timeout.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:07:32 +08:00
bincxz
3db9b0aa26 fix: restore listSftp fallback when statSftp is unavailable
Preserve the original fallback behavior for bridges that don't expose
statSftp — probe candidate directories via listSftp instead.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 17:03:06 +08:00
陈大猫
fe49ea74e2 Merge pull request #460 from binaricat/fix/update-metadata-verify
ci: verify and recover update metadata after artifact merge
2026-03-23 16:59:38 +08:00
bincxz
be91740582 fix: add actions:read permission for artifact recovery in release job
gh run download requires actions:read scope. Without it, the recovery
step would fail silently when trying to re-download individual artifacts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:56:27 +08:00
bincxz
ad15d8ceb5 fix: detect actual home directory for SFTP instead of hardcoding /home
Query the remote server for the real home directory using two methods:
1. SSH exec `echo ~` — works for any user regardless of home path
2. SFTP realpath('.') — fallback, SFTP cwd is typically home dir

Falls back to the previous hardcoded /home/{username} candidates if
both methods fail. This fixes SFTP auto-open sidebar not navigating
to the correct directory for users with non-standard home paths
(e.g. /usr/home, /export/home, custom paths).

Fixes #458

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:54:36 +08:00
bincxz
c37fe8f9e0 ci: verify and recover update metadata after artifact merge
download-artifact@v4 merge-multiple can silently drop files when
multiple artifacts contain same-named files (builder-debug.yml).
This caused latest-mac.yml to be missing from v1.0.64 release.

Add a verification step that checks all platform update yml files
exist after merge. If any are missing, re-downloads individual
artifacts to recover them. Fails the release if recovery fails.

Fixes #456

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 16:44:52 +08:00
陈大猫
b0924c14b1 Merge pull request #454 from binaricat/feat/crash-logs
feat: crash log capture and viewer in Settings
2026-03-23 15:56:12 +08:00
bincxz
774c25086e fix: truncate crash log env info with tooltip on overflow
Replace flex-wrap layout with single-line truncate + title tooltip
for the environment metadata row, preventing awkward wrapping when
the settings window is narrow.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:45:45 +08:00
bincxz
05c0d43bc4 feat: enrich crash logs with error metadata and process details
- Extract error properties (code, errno, syscall, hostname, port,
  signal, level) into errorMeta field for system-level diagnostics.
- Add extra field for structured context (e.g. render-process-gone
  reason and exitCode as separate fields, not just a string).
- Add process PID for correlating with OS-level logs.
- Accept optional extra parameter in captureError() for callers to
  attach structured context data.
- Display errorMeta and extra as tagged badges in the crash log viewer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:41:45 +08:00
bincxz
baac8670d3 feat: enrich crash log entries with environment diagnostics
Add electronVersion, osVersion, memoryUsage (RSS/heap in MB),
activeSessionCount, and process uptime to each crash log entry.
Display these fields inline in the Settings crash log viewer.

These extra fields help diagnose issues like #452 where knowing
the session count and memory state at crash time is critical.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:34:02 +08:00
bincxz
c84bf497f2 fix: address codex review round 6 — stream line counting, tail-read logs
- listLogs: stream-count newlines instead of reading entire file content
  just to compute entryCount.
- readLog: read only the last 256KB of large files and parse the tail,
  avoiding O(file_size) memory/CPU for crash-loop scenarios.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:23:14 +08:00
bincxz
ac5f708eba fix: address codex review round 5 — filter benign rejections and clean exits
- Skip EPIPE/ERR_STREAM_DESTROYED in unhandledRejection handler to
  avoid false positives in crash logs.
- Skip render-process-gone events with reason 'clean-exit' since
  those are normal shutdowns, not crashes.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:12:46 +08:00
bincxz
ecba2560c9 fix: address codex review round 4 — skip benign errors, check openPath result
- Move EPIPE/ERR_STREAM_DESTROYED check before captureError so benign
  stream teardown errors don't pollute crash logs.
- Check shell.openPath return value (error string) instead of always
  returning success: true.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 15:03:27 +08:00
bincxz
ff638c64cd fix: address codex review round 3 — dedupe logs, reload after clear
- Mark re-thrown unhandledRejection errors so uncaughtException handler
  skips duplicate logging.
- Reload crash log list after clearing instead of blindly emptying,
  so partial delete failures still show remaining files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:54:23 +08:00
bincxz
3db6465340 fix: address codex review round 2 — early require, stale request guard
- Move crashLogBridge require before process error handlers so it is
  available if a bridge import throws during startup.
- Add request ID ref to handleExpandCrashLog to discard out-of-order
  results when the user clicks different log files in quick succession.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:21:50 +08:00
bincxz
2b4f8d33c9 fix: address codex review — re-throw unhandled rejections, early crash capture
- P1: Re-throw in unhandledRejection handler to preserve default fatal
  semantics instead of silently swallowing rejections.
- P2: Fall back to require('electron').app.getPath('userData') in
  ensureLogDir() so crash logs work even before init() is called,
  catching early startup failures.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:14:04 +08:00
bincxz
bc6c0a2ef6 feat: add crash log capture and viewer in Settings > System
Capture main-process errors (uncaughtException, unhandledRejection,
render-process-gone) to JSONL log files in userData/crash-logs/ with
30-day auto-rotation. Users can view, expand, and clear crash logs
from Settings > System to help diagnose issues like #452.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 14:05:56 +08:00
陈大猫
9cccc943ff Merge pull request #451 from tces1/patch-1 2026-03-23 12:31:30 +08:00
Eric Chan
cecda50ce2 Add 'meslolgs nf' to local fonts list
Fixes an issue on macOS where MesloLGS NF was incorrectly filtered out of the terminal font list
2026-03-23 12:28:30 +08:00
bincxz
c136006108 fix: prevent x64 build from producing arm64 packages with wrong native modules
Some checks failed
build-packages / release (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
The linux target config specified arch: ['x64', 'arm64'] for each format,
causing the x64 build job to also produce arm64 packages. These packages
contained x86-64 native modules (node-pty, serialport) since the x64 job
only rebuilds for x64. When artifacts were merged in the release job,
the incorrect arm64 deb from the x64 build could overwrite the correct
one from the arm64 build.

Remove arch from linux target config so the CLI flags (--x64/--arm64)
control which architecture is built per job.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 10:25:12 +08:00
陈大猫
ba073219e5 Merge pull request #450 from binaricat/fix/linux-native-module-arch-verification
ci(linux): enhance native module arch verification
2026-03-23 09:43:41 +08:00
li88iioo
034e5ea3bc ci(linux): enhance artifact verification and architecture handling
- Added environment variables for npm configuration to specify architecture in CI jobs for both x64 and arm64 builds.
- Implemented verification steps for downloaded Linux deb artifacts, ensuring both amd64 and arm64 versions are checked for integrity.
- Updated the `ensure-node-pty-linux.sh` script to resolve and verify serialport prebuilds, ensuring compatibility with the specified architecture.
- Enhanced the `verify-linux-deb-artifact.sh` script to allow optional deb file input and improved error handling for missing artifacts.

These changes improve the reliability of the build process and ensure that the correct native modules are used for each architecture.
2026-03-23 09:40:56 +08:00
陈大猫
6b24e38326 Merge pull request #447 from li88iioo/fix/linux-deb-final-verification
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
ci(linux): verify final deb artifact before publish
2026-03-22 22:33:25 +08:00
陈大猫
b972866c8e Merge pull request #449 from binaricat/fix/linux-node-pty-arch-mismatch
fix: pin native module architecture in Linux builds
2026-03-22 22:33:19 +08:00
bincxz
8c541fb6e2 fix: pin native module architecture in Linux builds
The v1.0.62 amd64 deb/AppImage shipped with an aarch64 node-pty binary
because the build pipeline never explicitly locked the target architecture:

1. `electron-rebuild` was called without `--arch`, relying on auto-detection
2. electron-builder's default `npmRebuild` re-compiled native modules during
   packaging, adding a second uncontrolled rebuild that could override the
   prepare script's output
3. The x64 job did not set `npm_config_arch`, unlike the arm64 job

Changes:
- Pass `--arch` explicitly to `electron-rebuild` in ensure-node-pty-linux.sh
- Set `npm_config_arch: x64` in the x64 CI job (prepare + build steps)
- Disable `npmRebuild` in electron-builder config so only the prepare script
  controls native module compilation

Closes #446, closes #448

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 22:30:59 +08:00
li88iioo
b73e60fb6d ci(linux): verify final deb artifact before publish 2026-03-22 19:42:32 +08:00
17 changed files with 1109 additions and 77 deletions

View File

@@ -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:

View File

@@ -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',

View File

@@ -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': '当前版本',

View File

@@ -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
}
}
}

View File

@@ -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;

View File

@@ -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">

View File

@@ -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);

View File

@@ -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: {

View 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,
};

View File

@@ -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);
}
/**

View File

@@ -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"]);

View File

@@ -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 () => {

View File

@@ -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
View File

@@ -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 }>;

View File

@@ -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 [];
}
}
}

View File

@@ -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() {

View 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 "$@"