Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cfaee48553 | ||
|
|
1f05fe3efa | ||
|
|
e9c3b82c16 | ||
|
|
83fce70b20 | ||
|
|
d36c8bcbea | ||
|
|
5346752994 | ||
|
|
d267c4b6fc | ||
|
|
1a1da02e92 | ||
|
|
1adcffa7a8 | ||
|
|
7a2bedc4f4 | ||
|
|
5e753334ed | ||
|
|
a488bc466b | ||
|
|
2748cd5363 | ||
|
|
033165561d | ||
|
|
8e514f1008 | ||
|
|
0acd39603f | ||
|
|
4bdb0bbbf7 | ||
|
|
6b2c58f8f0 | ||
|
|
c0199c43cf | ||
|
|
7940b9a0a7 | ||
|
|
920914e3ee | ||
|
|
b5feb888d2 | ||
|
|
62d19974c9 | ||
|
|
932bb5032d | ||
|
|
3020d422fe | ||
|
|
bb526601bb | ||
|
|
d349c31cd6 | ||
|
|
8313cf780d | ||
|
|
29c0cc30a4 | ||
|
|
ee80048ece |
12
.github/scripts/generate-release-note.js
vendored
12
.github/scripts/generate-release-note.js
vendored
@@ -46,6 +46,10 @@ const tag = (process.env.GITHUB_REF_NAME && /^v\d+\.\d+\.\d+/.test(process.env.G
|
||||
const baseUrl = `https://github.com/${repo}/releases/download/${tag}`;
|
||||
|
||||
// Filename patterns based on electron-builder.config.cjs artifactName: '${productName}-${version}-${os}-${arch}.${ext}'
|
||||
// Note: electron-builder uses different arch names for Linux packages:
|
||||
// - AppImage: x64 -> x86_64, arm64 -> arm64
|
||||
// - deb: x64 -> amd64, arm64 -> arm64
|
||||
// - rpm: x64 -> x86_64, arm64 -> aarch64
|
||||
const files = {
|
||||
mac: {
|
||||
arm64: `Netcatty-${version}-mac-arm64.dmg`,
|
||||
@@ -57,16 +61,16 @@ const files = {
|
||||
},
|
||||
linux: {
|
||||
appimage: {
|
||||
x64: `Netcatty-${version}-linux-x64.AppImage`,
|
||||
x64: `Netcatty-${version}-linux-x86_64.AppImage`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.AppImage`
|
||||
},
|
||||
deb: {
|
||||
x64: `Netcatty-${version}-linux-x64.deb`,
|
||||
x64: `Netcatty-${version}-linux-amd64.deb`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.deb`
|
||||
},
|
||||
rpm: {
|
||||
x64: `Netcatty-${version}-linux-x64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-arm64.rpm`
|
||||
x64: `Netcatty-${version}-linux-x86_64.rpm`,
|
||||
arm64: `Netcatty-${version}-linux-aarch64.rpm`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
42
.github/workflows/sync.yml
vendored
42
.github/workflows/sync.yml
vendored
@@ -1,42 +0,0 @@
|
||||
name: Sync Upstream
|
||||
|
||||
env:
|
||||
UPSTREAM_URL: "https://github.com/binaricat/Netcatty.git"
|
||||
UPSTREAM_BRANCH: "main"
|
||||
TARGET_BRANCH: "main"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *' # Run daily at midnight
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Configure Git
|
||||
run: |
|
||||
git config --global user.name "github-actions[bot]"
|
||||
git config --global user.email "github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: Merge Upstream
|
||||
run: |
|
||||
echo "Adding upstream remote..."
|
||||
git remote add upstream ${{ env.UPSTREAM_URL }}
|
||||
git fetch upstream ${{ env.UPSTREAM_BRANCH }}
|
||||
|
||||
echo "Merging upstream/${{ env.UPSTREAM_BRANCH }} into ${{ env.TARGET_BRANCH }}..."
|
||||
# This will fail if there are conflicts, which is the desired behavior (notify user via failure)
|
||||
git merge upstream/${{ env.UPSTREAM_BRANCH }} --no-edit
|
||||
|
||||
echo "Pushing changes..."
|
||||
git push origin ${{ env.TARGET_BRANCH }}
|
||||
40
App.tsx
40
App.tsx
@@ -769,11 +769,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Note: xterm terminal handles its own key interception via attachCustomKeyEventHandler
|
||||
const target = e.target as HTMLElement;
|
||||
const isFormElement = target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable;
|
||||
const isMonacoElement =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.('.monaco-editor, .monaco-diff-editor, .monaco-inputbox');
|
||||
const isXtermInput =
|
||||
target instanceof HTMLElement &&
|
||||
!!target.closest?.(".xterm, .xterm-helper-textarea, .xterm-screen, .xterm-viewport");
|
||||
|
||||
if (isFormElement && !isXtermInput && e.key !== 'Escape') {
|
||||
// Monaco is not always contentEditable/input, so treat it as an editor surface.
|
||||
if ((isFormElement || isMonacoElement) && !isXtermInput && e.key !== 'Escape') {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -797,6 +801,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const keyStr = isMac ? binding.mac : binding.pc;
|
||||
if (matchesKeyBinding(e, keyStr, isMac)) {
|
||||
if (HOTKEY_DEBUG) console.log('[Hotkeys] Matched binding:', binding.action, keyStr);
|
||||
// SFTP shortcuts are handled by SFTP-specific hooks.
|
||||
if (binding.category === 'sftp') {
|
||||
continue;
|
||||
}
|
||||
// Terminal-specific actions should be handled by the terminal
|
||||
// Don't handle them at app level
|
||||
const terminalActions = ['copy', 'paste', 'selectAll', 'clearBuffer', 'searchTerminal'];
|
||||
@@ -1078,8 +1086,36 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
setDraggingSessionId(null);
|
||||
}, [setDraggingSessionId]);
|
||||
|
||||
const handleRootContextMenu = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
||||
const editableSelector =
|
||||
"input, textarea, [contenteditable], .monaco-editor, .monaco-diff-editor, .monaco-inputbox, .monaco-menu-container";
|
||||
|
||||
const nativeEvent = e.nativeEvent;
|
||||
const path = typeof nativeEvent.composedPath === "function" ? nativeEvent.composedPath() : [];
|
||||
const allowFromPath = path.some(
|
||||
(node) => node instanceof Element && !!node.closest(editableSelector),
|
||||
);
|
||||
|
||||
const target = e.target;
|
||||
const targetElement =
|
||||
target instanceof Element
|
||||
? target
|
||||
: target instanceof Node
|
||||
? target.parentElement
|
||||
: null;
|
||||
const allowFromTarget = !!targetElement?.closest(editableSelector);
|
||||
|
||||
const allowNativeContextMenu = allowFromPath || allowFromTarget;
|
||||
|
||||
if (allowNativeContextMenu) {
|
||||
return;
|
||||
}
|
||||
|
||||
e.preventDefault();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={(e) => e.preventDefault()}>
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={theme}
|
||||
sessions={sessions}
|
||||
|
||||
@@ -122,6 +122,7 @@ const en: Messages = {
|
||||
'tray.recentHosts': 'Recent Hosts',
|
||||
'tray.empty.title': 'Nothing here yet',
|
||||
'tray.empty.subtitle': 'Go connect to a server, they miss you 🚀',
|
||||
'tray.quit': 'Quit Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': 'Collapse sidebar',
|
||||
@@ -389,6 +390,8 @@ const en: Messages = {
|
||||
'vault.hosts.deselectAll': 'Deselect All',
|
||||
'vault.hosts.deleteSelected': 'Delete ({count})',
|
||||
'vault.hosts.deleteMultiple.success': 'Deleted {count} hosts',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
'vault.hosts.empty.desc': 'Save hosts to quickly connect to your servers, VMs, and containers.',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': 'Add data to your vault',
|
||||
@@ -1355,6 +1358,9 @@ const en: Messages = {
|
||||
'passphrase.unlock': 'Unlock',
|
||||
'passphrase.unlocking': 'Unlocking...',
|
||||
'passphrase.skip': 'Skip',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': 'Word Wrap',
|
||||
};
|
||||
|
||||
export default en;
|
||||
|
||||
@@ -107,6 +107,7 @@ const zhCN: Messages = {
|
||||
'tray.recentHosts': '最近连接的主机',
|
||||
'tray.empty.title': '一切都很安静',
|
||||
'tray.empty.subtitle': '去连接个服务器吧,它们想念你了 🚀',
|
||||
'tray.quit': '退出 Netcatty',
|
||||
|
||||
// Vault Sidebar
|
||||
'vault.sidebar.collapse': '收起侧边栏',
|
||||
@@ -256,6 +257,8 @@ const zhCN: Messages = {
|
||||
'vault.hosts.deselectAll': '取消全选',
|
||||
'vault.hosts.deleteSelected': '删除 ({count})',
|
||||
'vault.hosts.deleteMultiple.success': '已删除 {count} 个主机',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
'vault.hosts.empty.desc': '保存主机以快速连接到你的服务器、虚拟机和容器。',
|
||||
|
||||
// Vault import
|
||||
'vault.import.title': '添加数据到你的 Vault',
|
||||
@@ -1341,6 +1344,9 @@ const zhCN: Messages = {
|
||||
'passphrase.unlock': '解锁',
|
||||
'passphrase.unlocking': '解锁中...',
|
||||
'passphrase.skip': '跳过',
|
||||
|
||||
// Text Editor
|
||||
'sftp.editor.wordWrap': '自动换行',
|
||||
};
|
||||
|
||||
export default zhCN;
|
||||
|
||||
14
application/state/useClipboardBackend.ts
Normal file
14
application/state/useClipboardBackend.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useCallback } from "react";
|
||||
import { netcattyBridge } from "../../infrastructure/services/netcattyBridge";
|
||||
|
||||
export const useClipboardBackend = () => {
|
||||
const readClipboardText = useCallback(async (): Promise<string> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.readClipboardText) throw new Error("clipboard bridge unavailable");
|
||||
|
||||
const text = await bridge.readClipboardText();
|
||||
return typeof text === "string" ? text : "";
|
||||
}, []);
|
||||
|
||||
return { readClipboardText };
|
||||
};
|
||||
@@ -1,31 +1,32 @@
|
||||
import { useCallback,useEffect,useLayoutEffect,useMemo,useState } from 'react';
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type SetStateAction } from 'react';
|
||||
import { SyncConfig, TerminalSettings, DEFAULT_TERMINAL_SETTINGS, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_HOTKEY_SCHEME,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_HOTKEY_RECORDING,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SESSION_LOGS_ENABLED,
|
||||
STORAGE_KEY_SESSION_LOGS_DIR,
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
@@ -54,6 +55,9 @@ const DEFAULT_SFTP_AUTO_SYNC = false;
|
||||
const DEFAULT_SFTP_SHOW_HIDDEN_FILES = false;
|
||||
const DEFAULT_SFTP_USE_COMPRESSED_UPLOAD = true;
|
||||
|
||||
// Editor defaults
|
||||
const DEFAULT_EDITOR_WORD_WRAP = false;
|
||||
|
||||
// Session Logs defaults
|
||||
const DEFAULT_SESSION_LOGS_ENABLED = false;
|
||||
const DEFAULT_SESSION_LOGS_FORMAT: SessionLogFormat = 'txt';
|
||||
@@ -89,9 +93,15 @@ const isValidUiFontId = (value: string): boolean => {
|
||||
if (value.startsWith('local-')) return true;
|
||||
// Check bundled fonts first, then check dynamically loaded fonts
|
||||
return UI_FONTS.some((font) => font.id === value) ||
|
||||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
|
||||
uiFontStore.getAvailableFonts().some((font) => font.id === value);
|
||||
};
|
||||
|
||||
const serializeTerminalSettings = (settings: TerminalSettings): string =>
|
||||
JSON.stringify(settings);
|
||||
|
||||
const areTerminalSettingsEqual = (a: TerminalSettings, b: TerminalSettings): boolean =>
|
||||
serializeTerminalSettings(a) === serializeTerminalSettings(b);
|
||||
|
||||
const applyThemeTokens = (
|
||||
theme: 'light' | 'dark',
|
||||
tokens: UiThemeTokens,
|
||||
@@ -169,7 +179,7 @@ export const useSettingsState = () => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
return resolveSupportedLocale(stored || DEFAULT_UI_LOCALE);
|
||||
});
|
||||
const [terminalSettings, setTerminalSettings] = useState<TerminalSettings>(() => {
|
||||
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
|
||||
});
|
||||
@@ -208,6 +218,12 @@ export const useSettingsState = () => {
|
||||
return DEFAULT_SFTP_USE_COMPRESSED_UPLOAD;
|
||||
});
|
||||
|
||||
// Editor Settings
|
||||
const [editorWordWrap, setEditorWordWrapState] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
return stored === 'true' ? true : DEFAULT_EDITOR_WORD_WRAP;
|
||||
});
|
||||
|
||||
// Session Logs Settings
|
||||
const [sessionLogsEnabled, setSessionLogsEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_SESSION_LOGS_ENABLED);
|
||||
@@ -237,6 +253,34 @@ export const useSettingsState = () => {
|
||||
return stored === 'true';
|
||||
});
|
||||
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
|
||||
: nextValue;
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
localTerminalSettingsVersionRef.current += 1;
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = { ...prev, ...incoming };
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
// Mark the exact incoming snapshot so only this state is skipped for IPC rebroadcast.
|
||||
incomingTerminalSettingsSignatureRef.current = serializeTerminalSettings(next);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Helper to notify other windows about settings changes via IPC
|
||||
const notifySettingsChanged = useCallback((key: string, value: unknown) => {
|
||||
@@ -307,11 +351,11 @@ export const useSettingsState = () => {
|
||||
}, [uiFontFamilyId, uiFontsLoaded, notifySettingsChanged]);
|
||||
|
||||
// Listen for settings changes from other windows via IPC
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSettingsChanged) return;
|
||||
const unsubscribe = bridge.onSettingsChanged((payload) => {
|
||||
const { key, value } = payload;
|
||||
if (
|
||||
key === STORAGE_KEY_THEME ||
|
||||
key === STORAGE_KEY_UI_THEME_LIGHT ||
|
||||
@@ -325,8 +369,8 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_UI_LANGUAGE && typeof value === 'string') {
|
||||
const next = resolveSupportedLocale(value);
|
||||
setUiLanguage((prev) => (prev === next ? prev : next));
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
document.documentElement.lang = next;
|
||||
}
|
||||
if (key === STORAGE_KEY_CUSTOM_CSS && typeof value === 'string') {
|
||||
syncCustomCssFromStorage();
|
||||
}
|
||||
@@ -344,6 +388,21 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_TERM_FONT_SIZE && typeof value === 'number') {
|
||||
setTerminalFontSize(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_TERM_SETTINGS) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<TerminalSettings>;
|
||||
mergeIncomingTerminalSettings(parsed);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
} else if (value && typeof value === 'object') {
|
||||
mergeIncomingTerminalSettings(value as Partial<TerminalSettings>);
|
||||
}
|
||||
}
|
||||
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
|
||||
setEditorWordWrapState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -369,7 +428,7 @@ export const useSettingsState = () => {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
}, [mergeIncomingTerminalSettings, syncAppearanceFromStorage, syncCustomCssFromStorage]);
|
||||
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -447,14 +506,14 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}
|
||||
// Sync terminal settings from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
setTerminalSettings(_prev => ({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings }));
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
// Sync terminal theme from other windows
|
||||
if (e.key === STORAGE_KEY_TERM_THEME && e.newValue) {
|
||||
if (e.newValue !== terminalThemeId) {
|
||||
@@ -494,6 +553,12 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_EDITOR_WORD_WRAP && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== editorWordWrap) {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
// Sync SFTP compressed upload setting from other windows
|
||||
if (e.key === STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true' || e.newValue === 'enabled';
|
||||
@@ -505,7 +570,7 @@ export const useSettingsState = () => {
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -524,7 +589,17 @@ export const useSettingsState = () => {
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
}, [terminalSettings]);
|
||||
const currentSignature = serializeTerminalSettings(terminalSettings);
|
||||
const hasPendingUnbroadcastLocalChanges =
|
||||
localTerminalSettingsVersionRef.current !== broadcastedLocalTerminalSettingsVersionRef.current;
|
||||
if (incomingTerminalSettingsSignatureRef.current === currentSignature && !hasPendingUnbroadcastLocalChanges) {
|
||||
incomingTerminalSettingsSignatureRef.current = null;
|
||||
return;
|
||||
}
|
||||
incomingTerminalSettingsSignatureRef.current = null;
|
||||
notifySettingsChanged(STORAGE_KEY_TERM_SETTINGS, terminalSettings);
|
||||
broadcastedLocalTerminalSettingsVersionRef.current = localTerminalSettingsVersionRef.current;
|
||||
}, [terminalSettings, notifySettingsChanged]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_HOTKEY_SCHEME, hotkeyScheme);
|
||||
@@ -706,7 +781,7 @@ export const useSettingsState = () => {
|
||||
value: TerminalSettings[K]
|
||||
) => {
|
||||
setTerminalSettings(prev => ({ ...prev, [key]: value }));
|
||||
}, []);
|
||||
}, [setTerminalSettings]);
|
||||
|
||||
return {
|
||||
theme,
|
||||
@@ -755,6 +830,13 @@ export const useSettingsState = () => {
|
||||
setSftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
setSftpUseCompressedUpload,
|
||||
// Editor Settings
|
||||
editorWordWrap,
|
||||
setEditorWordWrap: useCallback((enabled: boolean) => {
|
||||
setEditorWordWrapState(enabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(enabled));
|
||||
notifySettingsChanged(STORAGE_KEY_EDITOR_WORD_WRAP, enabled);
|
||||
}, [notifySettingsChanged]),
|
||||
availableFonts,
|
||||
// Session Logs
|
||||
sessionLogsEnabled,
|
||||
|
||||
@@ -12,6 +12,11 @@ export const useTrayPanelBackend = () => {
|
||||
await bridge?.openMainWindow?.();
|
||||
}, []);
|
||||
|
||||
const quitApp = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.quitApp?.();
|
||||
}, []);
|
||||
|
||||
const jumpToSession = useCallback(async (sessionId: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
await bridge?.jumpToSessionFromTrayPanel?.(sessionId);
|
||||
@@ -57,6 +62,7 @@ export const useTrayPanelBackend = () => {
|
||||
return {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
connectToHostFromTrayPanel,
|
||||
onTrayPanelCloseRequest,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { normalizeDistroId, sanitizeHost } from "../../domain/host";
|
||||
import {
|
||||
ConnectionLog,
|
||||
@@ -29,6 +29,14 @@ import {
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
} from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
} from "../../infrastructure/persistence/secureFieldAdapter";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
@@ -99,20 +107,47 @@ export const useVaultState = () => {
|
||||
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
|
||||
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
|
||||
|
||||
// Write-version counters prevent out-of-order async writes from overwriting
|
||||
// newer data. Each update bumps the counter; the .then() callback only
|
||||
// persists if its version still matches the latest.
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
// event bumps the counter; the async decrypt callback only applies state if
|
||||
// its sequence still matches, preventing stale decrypts from overwriting
|
||||
// newer data when multiple events arrive in quick succession.
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
setHosts(cleaned);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(cleaned).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, data);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
encryptKeys(data).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, data);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
encryptIdentities(data).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
@@ -271,7 +306,11 @@ export const useVaultState = () => {
|
||||
// Add to hosts using functional update
|
||||
setHosts((prevHosts) => {
|
||||
const updated = [...prevHosts, sanitizeHost(newHost)];
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, updated);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(updated).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
|
||||
@@ -279,82 +318,120 @@ export const useVaultState = () => {
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
const init = async () => {
|
||||
const savedHosts = localStorageAdapter.read<Host[]>(STORAGE_KEY_HOSTS);
|
||||
|
||||
if (savedHosts) {
|
||||
const sanitized = savedHosts.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, sanitized);
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
if (savedHosts) {
|
||||
// Capture version before the async gap so that any write occurring
|
||||
// during decryption (storage event, user edit) advances the counter
|
||||
// and causes this stale result to be discarded.
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
const decrypted = await decryptHosts(savedHosts);
|
||||
if (ver === hostsWriteVersion.current) {
|
||||
const sanitized = decrypted.map(sanitizeHost);
|
||||
setHosts(sanitized);
|
||||
encryptHosts(sanitized).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
updateHosts(INITIAL_HOSTS);
|
||||
}
|
||||
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
// Read keys fresh here (not before the hosts await) so we don't apply
|
||||
// a stale snapshot if keys were updated during host decryption.
|
||||
const savedKeysRaw = localStorageAdapter.read<unknown[]>(STORAGE_KEY_KEYS);
|
||||
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
// Migrate old keys to new format with source/category fields
|
||||
if (savedKeysRaw?.length) {
|
||||
const migratedKeys: SSHKey[] = [];
|
||||
const legacyKeys: LegacyKeyRecord[] = [];
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
for (const entry of savedKeysRaw) {
|
||||
const record =
|
||||
entry && typeof entry === "object" ? (entry as LegacyKeyRecord) : null;
|
||||
if (!record) continue;
|
||||
|
||||
if (isLegacyUnsupportedKey(record)) {
|
||||
legacyKeys.push(record);
|
||||
continue;
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
// Decrypt sensitive fields (passphrase, privateKey)
|
||||
const keyVer = ++keysWriteVersion.current;
|
||||
const decryptedKeys = await decryptKeys(migratedKeys);
|
||||
if (keyVer === keysWriteVersion.current) {
|
||||
setKeys(decryptedKeys);
|
||||
encryptKeys(decryptedKeys).then((enc) => {
|
||||
if (keyVer === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
}
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
}
|
||||
}
|
||||
|
||||
setKeys(migratedKeys);
|
||||
// Persist migrated keys
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, migratedKeys);
|
||||
if (legacyKeys.length) {
|
||||
localStorageAdapter.write(STORAGE_KEY_LEGACY_KEYS, legacyKeys);
|
||||
// Read identities fresh here (not before the hosts/keys awaits) so we
|
||||
// don't apply a stale snapshot if identities were updated during prior decryption.
|
||||
const savedIdentities =
|
||||
localStorageAdapter.read<Identity[]>(STORAGE_KEY_IDENTITIES);
|
||||
if (savedIdentities) {
|
||||
const idVer = ++identitiesWriteVersion.current;
|
||||
const decryptedIds = await decryptIdentities(savedIdentities);
|
||||
if (idVer === identitiesWriteVersion.current) {
|
||||
setIdentities(decryptedIds);
|
||||
encryptIdentities(decryptedIds).then((enc) => {
|
||||
if (idVer === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (savedIdentities) setIdentities(savedIdentities);
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
localStorageAdapter.read<Snippet[]>(STORAGE_KEY_SNIPPETS);
|
||||
const savedSnippetPackages = localStorageAdapter.read<string[]>(
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
);
|
||||
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
if (savedSnippets) setSnippets(savedSnippets);
|
||||
else updateSnippets(INITIAL_SNIPPETS);
|
||||
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
if (savedGroups) setCustomGroups(savedGroups);
|
||||
if (savedSnippetPackages) setSnippetPackages(savedSnippetPackages);
|
||||
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
// Load known hosts
|
||||
const savedKnownHosts = localStorageAdapter.read<KnownHost[]>(
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
);
|
||||
if (savedKnownHosts) setKnownHosts(savedKnownHosts);
|
||||
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
// Load shell history
|
||||
const savedShellHistory = localStorageAdapter.read<ShellHistoryEntry[]>(
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
);
|
||||
if (savedShellHistory) setShellHistory(savedShellHistory);
|
||||
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
// Load connection logs
|
||||
const savedConnectionLogs = localStorageAdapter.read<ConnectionLog[]>(
|
||||
STORAGE_KEY_CONNECTION_LOGS,
|
||||
);
|
||||
if (savedConnectionLogs) setConnectionLogs(savedConnectionLogs);
|
||||
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
// Load managed sources
|
||||
const savedManagedSources = localStorageAdapter.read<ManagedSource[]>(
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
);
|
||||
if (savedManagedSources) setManagedSources(savedManagedSources);
|
||||
};
|
||||
|
||||
init();
|
||||
}, [updateHosts, updateSnippets]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -367,7 +444,17 @@ export const useVaultState = () => {
|
||||
|
||||
if (key === STORAGE_KEY_HOSTS) {
|
||||
const next = safeParse<Host[]>(event.newValue) ?? [];
|
||||
setHosts(next.map(sanitizeHost));
|
||||
// Bump write version to invalidate any in-flight encrypt from this
|
||||
// window — the cross-window data is newer and must not be overwritten.
|
||||
++hostsWriteVersion.current;
|
||||
const seq = ++hostsReadSeq.current;
|
||||
const writeAtStart = hostsWriteVersion.current;
|
||||
decryptHosts(next).then((dec) => {
|
||||
// Discard if a newer storage event arrived OR a local write occurred
|
||||
// during the decrypt (writeVersion would have advanced).
|
||||
if (seq === hostsReadSeq.current && writeAtStart === hostsWriteVersion.current)
|
||||
setHosts(dec.map(sanitizeHost));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -380,13 +467,25 @@ export const useVaultState = () => {
|
||||
if (!record || isLegacyUnsupportedKey(record)) continue;
|
||||
migratedKeys.push(migrateKey(record as Partial<SSHKey>));
|
||||
}
|
||||
setKeys(migratedKeys);
|
||||
++keysWriteVersion.current;
|
||||
const seq = ++keysReadSeq.current;
|
||||
const writeAtStart = keysWriteVersion.current;
|
||||
decryptKeys(migratedKeys).then((dec) => {
|
||||
if (seq === keysReadSeq.current && writeAtStart === keysWriteVersion.current)
|
||||
setKeys(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_IDENTITIES) {
|
||||
const next = safeParse<Identity[]>(event.newValue) ?? [];
|
||||
setIdentities(next);
|
||||
++identitiesWriteVersion.current;
|
||||
const seq = ++identitiesReadSeq.current;
|
||||
const writeAtStart = identitiesWriteVersion.current;
|
||||
decryptIdentities(next).then((dec) => {
|
||||
if (seq === identitiesReadSeq.current && writeAtStart === identitiesWriteVersion.current)
|
||||
setIdentities(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -442,7 +541,11 @@ export const useVaultState = () => {
|
||||
const next = prev.map((h) =>
|
||||
h.id === hostId ? { ...h, distro: normalized } : h,
|
||||
);
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, next);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(next).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Expand, Minimize2 } from 'lucide-react';
|
||||
import { CheckSquare, ChevronRight, FileSymlink, Folder, FolderOpen, Monitor, Server, Square, Expand, Minimize2 } from 'lucide-react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useTreeExpandedState } from '../application/state/useTreeExpandedState';
|
||||
@@ -32,6 +32,9 @@ interface HostTreeViewProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
interface TreeNodeProps {
|
||||
@@ -53,6 +56,9 @@ interface TreeNodeProps {
|
||||
moveGroup: (sourcePath: string, targetPath: string) => void;
|
||||
managedGroupPaths?: Set<string>;
|
||||
onUnmanageGroup?: (groupPath: string) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
@@ -74,6 +80,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
@@ -215,9 +224,12 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
{/* Hosts in this group */}
|
||||
{sortedHosts.map((host) => (
|
||||
<HostTreeItem
|
||||
@@ -230,6 +242,9 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
@@ -247,6 +262,9 @@ interface HostTreeItemProps {
|
||||
onDeleteHost: (host: Host) => void;
|
||||
onCopyCredentials: (host: Host) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
isMultiSelectMode?: boolean;
|
||||
selectedHostIds?: Set<string>;
|
||||
toggleHostSelection?: (hostId: string) => void;
|
||||
}
|
||||
|
||||
const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
@@ -258,6 +276,9 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
onDeleteHost,
|
||||
onCopyCredentials,
|
||||
moveHostToGroup: _moveHostToGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const paddingLeft = `${depth * 20 + 12}px`;
|
||||
@@ -270,18 +291,40 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
const displayPort = isTelnet
|
||||
? (host.telnetPort ?? host.port ?? 23)
|
||||
: (host.port ?? 22);
|
||||
const isSelected = isMultiSelectMode && selectedHostIds?.has(host.id);
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
className="flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg"
|
||||
className={cn(
|
||||
"flex items-center py-2 pr-3 text-sm cursor-pointer transition-colors select-none group hover:bg-secondary/40 rounded-lg",
|
||||
isSelected ? "bg-primary/10" : "",
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
draggable
|
||||
draggable={!isMultiSelectMode}
|
||||
onDragStart={(e) => e.dataTransfer.setData("host-id", host.id)}
|
||||
onClick={() => onConnect(safeHost)}
|
||||
onClick={() => {
|
||||
if (isMultiSelectMode && toggleHostSelection) {
|
||||
toggleHostSelection(host.id);
|
||||
} else {
|
||||
onConnect(safeHost);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="mr-2 flex-shrink-0 w-4 h-4" />
|
||||
{isMultiSelectMode && (
|
||||
<div className="mr-2 flex-shrink-0" onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
toggleHostSelection?.(host.id);
|
||||
}}>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={18} className="text-primary" />
|
||||
) : (
|
||||
<Square size={18} className="text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isMultiSelectMode && <div className="mr-2 flex-shrink-0 w-4 h-4" />}
|
||||
<div className="mr-3 flex-shrink-0">
|
||||
<DistroAvatar host={host} fallback={(host.os || "L")[0].toUpperCase()} size="sm" />
|
||||
</div>
|
||||
@@ -351,6 +394,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
isMultiSelectMode,
|
||||
selectedHostIds,
|
||||
toggleHostSelection,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
|
||||
@@ -471,6 +517,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
@@ -486,6 +535,9 @@ export const HostTreeView: React.FC<HostTreeViewProps> = ({
|
||||
onDeleteHost={onDeleteHost}
|
||||
onCopyCredentials={onCopyCredentials}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -86,7 +86,15 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
showSaveDialog,
|
||||
} = useSftpBackend();
|
||||
const { t } = useI18n();
|
||||
const { sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, hotkeyScheme, keyBindings } = useSettingsState();
|
||||
const {
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
const isLocalSession = host.protocol === "local";
|
||||
const [filenameEncoding, setFilenameEncoding] = useState<SftpFilenameEncoding>(
|
||||
host.sftpEncoding ?? "auto"
|
||||
@@ -735,6 +743,8 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
fileName={textEditorTarget?.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -53,7 +53,15 @@ interface SftpViewProps {
|
||||
const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) => {
|
||||
const { t } = useI18n();
|
||||
const isActive = useIsSftpActive();
|
||||
const { sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, hotkeyScheme, keyBindings } = useSettingsState();
|
||||
const {
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
} = useSettingsState();
|
||||
|
||||
// File watch event handlers (stable refs to avoid re-creating the useSftpState options)
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
@@ -356,6 +364,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities }) =>
|
||||
textEditorContent={textEditorContent}
|
||||
setTextEditorContent={setTextEditorContent}
|
||||
handleSaveTextFile={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
setEditorWordWrap={setEditorWordWrap}
|
||||
showFileOpenerDialog={showFileOpenerDialog}
|
||||
setShowFileOpenerDialog={setShowFileOpenerDialog}
|
||||
fileOpenerTarget={fileOpenerTarget}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
CloudUpload,
|
||||
Loader2,
|
||||
Search,
|
||||
WrapText,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import Editor, { type OnMount, loader, useMonaco } from '@monaco-editor/react';
|
||||
@@ -18,6 +19,7 @@ const monacoBasePath = import.meta.env.DEV
|
||||
loader.config({ paths: { vs: monacoBasePath } });
|
||||
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useClipboardBackend } from '../application/state/useClipboardBackend';
|
||||
import { getLanguageId, getLanguageName, getSupportedLanguages } from '../lib/sftpFileUtils';
|
||||
import { Button } from './ui/button';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -30,6 +32,8 @@ interface TextEditorModalProps {
|
||||
fileName: string;
|
||||
initialContent: string;
|
||||
onSave: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
onToggleWordWrap: () => void;
|
||||
}
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
@@ -132,8 +136,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
fileName,
|
||||
initialContent,
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
onToggleWordWrap,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { readClipboardText: readClipboardTextFromBridge } = useClipboardBackend();
|
||||
const monaco = useMonaco();
|
||||
const [content, setContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -143,6 +150,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
|
||||
// Ref to store the latest save function to avoid stale closure in keyboard shortcut
|
||||
const handleSaveRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
const handlePasteRef = useRef<() => Promise<void>>(() => Promise.resolve());
|
||||
|
||||
// Track theme from document.documentElement class (syncs with app theme)
|
||||
const [isDarkTheme, setIsDarkTheme] = useState(() =>
|
||||
@@ -229,6 +237,58 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
handleSaveRef.current = handleSave;
|
||||
}, [handleSave]);
|
||||
|
||||
const readClipboardText = useCallback(async (): Promise<string | null> => {
|
||||
try {
|
||||
if (navigator.clipboard?.readText) {
|
||||
return await navigator.clipboard.readText();
|
||||
}
|
||||
} catch {
|
||||
// Fall through to Electron bridge
|
||||
}
|
||||
|
||||
try {
|
||||
return await readClipboardTextFromBridge();
|
||||
} catch {
|
||||
// Both clipboard APIs unavailable; signal failure so caller can fall back.
|
||||
return null;
|
||||
}
|
||||
}, [readClipboardTextFromBridge]);
|
||||
|
||||
const handlePaste = useCallback(async () => {
|
||||
const editor = editorRef.current;
|
||||
if (!editor) return;
|
||||
|
||||
const text = await readClipboardText();
|
||||
if (text === null) {
|
||||
// Clipboard read unavailable; fall back to Monaco's native paste.
|
||||
editor.trigger('keyboard', 'editor.action.clipboardPasteAction', null);
|
||||
return;
|
||||
}
|
||||
if (!text) return;
|
||||
|
||||
const selections = editor.getSelections();
|
||||
if (!selections || selections.length === 0) return;
|
||||
|
||||
// Match Monaco's default multicursorPaste:'spread' behavior:
|
||||
// distribute one line per cursor when line count equals cursor count.
|
||||
const lines = text.split(/\r\n|\n/);
|
||||
const distribute = selections.length > 1 && lines.length === selections.length;
|
||||
|
||||
editor.executeEdits(
|
||||
'netcatty-paste',
|
||||
selections.map((selection, i) => ({
|
||||
range: selection,
|
||||
text: distribute ? lines[i] : text,
|
||||
forceMoveMarkers: true,
|
||||
})),
|
||||
);
|
||||
editor.focus();
|
||||
}, [readClipboardText]);
|
||||
|
||||
useEffect(() => {
|
||||
handlePasteRef.current = handlePaste;
|
||||
}, [handlePaste]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasChanges) {
|
||||
const confirmed = confirm(t('sftp.editor.unsavedChanges'));
|
||||
@@ -254,6 +314,11 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
// Trigger Monaco's built-in find widget
|
||||
editor.trigger('keyboard', 'actions.find', null);
|
||||
});
|
||||
|
||||
// Fallback paste path for Electron environments where Monaco paste can fail.
|
||||
editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyV, () => {
|
||||
void handlePasteRef.current();
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Trigger search dialog
|
||||
@@ -299,6 +364,17 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
<Search size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Word wrap toggle */}
|
||||
<Button
|
||||
variant={editorWordWrap ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={onToggleWordWrap}
|
||||
title={t('sftp.editor.wordWrap')}
|
||||
>
|
||||
<WrapText size={14} />
|
||||
</Button>
|
||||
|
||||
{/* Language selector */}
|
||||
<Combobox
|
||||
options={languageOptions}
|
||||
@@ -352,6 +428,8 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
</div>
|
||||
}
|
||||
options={{
|
||||
// Prefer native context menu in Electron so right-click Paste uses OS clipboard path.
|
||||
contextmenu: false,
|
||||
minimap: { enabled: true },
|
||||
fontSize: 14,
|
||||
lineNumbers: 'on',
|
||||
@@ -360,7 +438,7 @@ export const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
automaticLayout: true,
|
||||
tabSize: 2,
|
||||
insertSpaces: true,
|
||||
wordWrap: 'off',
|
||||
wordWrap: editorWordWrap ? 'on' : 'off',
|
||||
folding: true,
|
||||
renderWhitespace: 'selection',
|
||||
bracketPairColorization: { enabled: true },
|
||||
|
||||
@@ -543,8 +543,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
{/* Always-on drag stripe so the window can be moved even when tabs fill the bar */}
|
||||
<div className="absolute inset-x-0 top-0 h-1 app-drag pointer-events-auto z-10" style={dragRegionStyle} aria-hidden />
|
||||
<div
|
||||
className="h-8 px-3 flex items-center gap-2 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12 }}
|
||||
className="h-8 flex items-center gap-2 app-drag"
|
||||
style={{ ...dragRegionStyle, paddingLeft: isMacClient && !isWindowFullscreen ? 76 : 12, paddingRight: isMacClient ? 12 : 0 }}
|
||||
>
|
||||
{/* Fixed left tabs: Vaults and SFTP */}
|
||||
<div className="flex items-center gap-2 flex-shrink-0 app-drag">
|
||||
@@ -654,8 +654,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</div>
|
||||
{/* Custom window controls for Windows/Linux */}
|
||||
{!isMacClient && <WindowControls />}
|
||||
{/* Small drag shim to the right edge */}
|
||||
<div className="w-2 h-8 app-drag flex-shrink-0" />
|
||||
{/* Small drag shim to the right edge (macOS only – on Windows the close button should touch the edge) */}
|
||||
{isMacClient && <div className="w-2 h-8 app-drag flex-shrink-0" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown } from "lucide-react";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
const StatusDot: React.FC<{ status: "success" | "warning" | "error" | "neutral"; spinning?: boolean }> = ({
|
||||
@@ -109,6 +109,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
const {
|
||||
hideTrayPanel,
|
||||
openMainWindow,
|
||||
quitApp,
|
||||
jumpToSession,
|
||||
onTrayPanelCloseRequest,
|
||||
onTrayPanelRefresh,
|
||||
@@ -200,8 +201,12 @@ const TrayPanelContent: React.FC = () => {
|
||||
void openMainWindow();
|
||||
}, [openMainWindow]);
|
||||
|
||||
const handleQuit = useCallback(() => {
|
||||
void quitApp();
|
||||
}, [quitApp]);
|
||||
|
||||
return (
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden">
|
||||
<div id="tray-panel-root" className="w-full h-full bg-background/95 backdrop-blur border border-border/60 rounded-lg shadow-lg overflow-hidden flex flex-col">
|
||||
<div className="px-3 py-2 border-b border-border/60 flex items-center justify-between app-no-drag">
|
||||
<div className="flex items-center gap-2">
|
||||
<AppLogo className="w-5 h-5" />
|
||||
@@ -225,7 +230,7 @@ const TrayPanelContent: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-2 space-y-3 text-sm">
|
||||
<div className="p-2 space-y-3 text-sm flex-1 overflow-y-auto min-h-0">
|
||||
|
||||
{jumpableSessions.length > 0 && (() => {
|
||||
// Group sessions by workspace
|
||||
@@ -378,6 +383,17 @@ const TrayPanelContent: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Quit button at the bottom */}
|
||||
<div className="px-3 py-2 border-t border-border/60">
|
||||
<button
|
||||
className="w-full text-left px-2 py-1.5 rounded hover:bg-destructive/10 flex items-center gap-2 text-sm text-muted-foreground hover:text-destructive transition-colors"
|
||||
onClick={handleQuit}
|
||||
>
|
||||
<Power size={14} />
|
||||
<span>{t("tray.quit")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1857,6 +1857,9 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={handleUnmanageGroup}
|
||||
isMultiSelectMode={isMultiSelectMode}
|
||||
selectedHostIds={selectedHostIds}
|
||||
toggleHostSelection={toggleHostSelection}
|
||||
/>
|
||||
) : sortMode === "group" && groupedDisplayHosts ? (
|
||||
<div className="space-y-6">
|
||||
@@ -2000,11 +2003,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -2136,11 +2138,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
<LayoutGrid size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your hosts
|
||||
{t('vault.hosts.empty.title')}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Save hosts to quickly connect to your servers, VMs,
|
||||
and containers.
|
||||
{t('vault.hosts.empty.desc')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,11 +71,12 @@ export const useSftpModalKeyboardShortcuts = ({
|
||||
|
||||
// Skip if focus is on an input element
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
const isEditableTarget =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
target.isContentEditable ||
|
||||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
|
||||
if (isEditableTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ interface SftpOverlaysProps {
|
||||
textEditorContent: string;
|
||||
setTextEditorContent: (content: string) => void;
|
||||
handleSaveTextFile: (content: string) => Promise<void>;
|
||||
editorWordWrap: boolean;
|
||||
setEditorWordWrap: (enabled: boolean) => void;
|
||||
showFileOpenerDialog: boolean;
|
||||
setShowFileOpenerDialog: (open: boolean) => void;
|
||||
fileOpenerTarget: { file: SftpFileEntry; side: "left" | "right"; fullPath: string } | null;
|
||||
@@ -63,6 +65,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
textEditorContent,
|
||||
setTextEditorContent,
|
||||
handleSaveTextFile,
|
||||
editorWordWrap,
|
||||
setEditorWordWrap,
|
||||
showFileOpenerDialog,
|
||||
setShowFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
@@ -178,6 +182,8 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = ({
|
||||
fileName={textEditorTarget?.file.name || ""}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
onToggleWordWrap={() => setEditorWordWrap(!editorWordWrap)}
|
||||
/>
|
||||
|
||||
{/* File Opener Dialog */}
|
||||
|
||||
@@ -67,11 +67,12 @@ export const useSftpKeyboardShortcuts = ({
|
||||
|
||||
// Skip if focus is on an input element
|
||||
const target = e.target as HTMLElement;
|
||||
if (
|
||||
const isEditableTarget =
|
||||
target.tagName === "INPUT" ||
|
||||
target.tagName === "TEXTAREA" ||
|
||||
target.isContentEditable
|
||||
) {
|
||||
target.isContentEditable ||
|
||||
!!target.closest?.(".monaco-editor, .monaco-diff-editor, .monaco-inputbox");
|
||||
if (isEditableTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Terminal as XTerm } from "@xterm/xterm";
|
||||
import { useCallback } from "react";
|
||||
import type { RefObject } from "react";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { normalizeLineEndings } from "../../../lib/utils";
|
||||
import { normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
|
||||
type TerminalBackendWriteApi = {
|
||||
writeToSession: (sessionId: string, data: string) => void;
|
||||
@@ -33,7 +33,11 @@ export const useTerminalContextActions = ({
|
||||
if (!term) return;
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && sessionRef.current) terminalBackend.writeToSession(sessionRef.current, normalizeLineEndings(text));
|
||||
if (text && sessionRef.current) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
resolveXTermPerformanceConfig,
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform, normalizeLineEndings } from "../../../lib/utils";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -407,7 +407,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
case "paste": {
|
||||
navigator.clipboard.readText().then((text) => {
|
||||
const id = ctx.sessionRef.current;
|
||||
if (id) ctx.terminalBackend.writeToSession(id, normalizeLineEndings(text));
|
||||
if (id) {
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(id, data);
|
||||
}
|
||||
});
|
||||
break;
|
||||
}
|
||||
@@ -439,7 +443,9 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
try {
|
||||
const text = await navigator.clipboard.readText();
|
||||
if (text && ctx.sessionRef.current) {
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, normalizeLineEndings(text));
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode) data = wrapBracketedPaste(data);
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, data);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("[Terminal] Failed to paste from clipboard:", err);
|
||||
|
||||
85
electron/bridges/credentialBridge.cjs
Normal file
85
electron/bridges/credentialBridge.cjs
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Credential Bridge - Field-level encryption for sensitive data at rest
|
||||
*
|
||||
* Uses Electron's safeStorage API to encrypt individual sensitive fields
|
||||
* (passwords, tokens, private keys) before they are persisted to localStorage.
|
||||
*
|
||||
* Sentinel prefix "enc:v1:" on encrypted values enables:
|
||||
* - Detection of already-encrypted vs plaintext (migration)
|
||||
* - No double-encryption
|
||||
* - Future re-keying with enc:v2: etc.
|
||||
*
|
||||
* When safeStorage is unavailable (e.g. Linux without libsecret), all values
|
||||
* pass through unmodified so the app still works.
|
||||
*/
|
||||
|
||||
const ENC_PREFIX = "enc:v1:";
|
||||
|
||||
let safeStorage = null;
|
||||
|
||||
/**
|
||||
* Register IPC handlers for credential encryption/decryption
|
||||
* @param {Electron.IpcMain} ipcMain
|
||||
* @param {typeof Electron} electronModule
|
||||
*/
|
||||
function registerHandlers(ipcMain, electronModule) {
|
||||
safeStorage = electronModule?.safeStorage ?? null;
|
||||
|
||||
ipcMain.handle("netcatty:credentials:available", () => {
|
||||
return Boolean(safeStorage?.isEncryptionAvailable?.());
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:credentials:encrypt", (_event, plaintext) => {
|
||||
if (typeof plaintext !== "string" || plaintext.length === 0) {
|
||||
return plaintext ?? "";
|
||||
}
|
||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||
return plaintext;
|
||||
}
|
||||
// If value looks like it might already be encrypted, verify by attempting
|
||||
// to decode and decrypt. If it succeeds the value is genuinely encrypted
|
||||
// and we return it as-is; if it fails, the prefix was a coincidence and
|
||||
// we proceed to encrypt the raw plaintext.
|
||||
if (plaintext.startsWith(ENC_PREFIX)) {
|
||||
try {
|
||||
const base64 = plaintext.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
safeStorage.decryptString(buf); // throws on invalid ciphertext
|
||||
return plaintext; // verified — already encrypted
|
||||
} catch {
|
||||
// Not valid ciphertext — fall through to encrypt
|
||||
}
|
||||
}
|
||||
try {
|
||||
const encrypted = safeStorage.encryptString(plaintext);
|
||||
return ENC_PREFIX + encrypted.toString("base64");
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] encrypt failed, returning plaintext:", err?.message || err);
|
||||
return plaintext;
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:credentials:decrypt", (_event, value) => {
|
||||
if (typeof value !== "string" || value.length === 0) {
|
||||
return value ?? "";
|
||||
}
|
||||
// Not encrypted — pass through (supports migration from plaintext)
|
||||
if (!value.startsWith(ENC_PREFIX)) {
|
||||
return value;
|
||||
}
|
||||
if (!safeStorage?.isEncryptionAvailable?.()) {
|
||||
// Cannot decrypt without safeStorage; return raw value
|
||||
return value;
|
||||
}
|
||||
try {
|
||||
const base64 = value.slice(ENC_PREFIX.length);
|
||||
const buf = Buffer.from(base64, "base64");
|
||||
return safeStorage.decryptString(buf);
|
||||
} catch (err) {
|
||||
console.warn("[Credentials] decrypt failed:", err?.message || err);
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerHandlers };
|
||||
@@ -691,6 +691,13 @@ function registerHandlers(ipcMain) {
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:trayPanel:quitApp", async () => {
|
||||
const { app } = electronModule;
|
||||
closeToTray = false;
|
||||
app.quit();
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
console.log("[GlobalShortcut] IPC handlers registered");
|
||||
}
|
||||
|
||||
@@ -700,6 +707,20 @@ function registerHandlers(ipcMain) {
|
||||
function cleanup() {
|
||||
unregisterGlobalHotkey();
|
||||
destroyTray();
|
||||
|
||||
if (trayPanelRefreshTimer) {
|
||||
clearInterval(trayPanelRefreshTimer);
|
||||
trayPanelRefreshTimer = null;
|
||||
}
|
||||
|
||||
if (trayPanelWindow && !trayPanelWindow.isDestroyed()) {
|
||||
try {
|
||||
trayPanelWindow.destroy();
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
trayPanelWindow = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -7,6 +7,7 @@ const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const path = require("node:path");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
|
||||
@@ -315,15 +316,30 @@ async function startTelnetSession(event, options) {
|
||||
resolve({ sessionId });
|
||||
});
|
||||
|
||||
const charsetToNodeEncoding = (charset) => {
|
||||
if (!charset) return 'utf8';
|
||||
const normalized = String(charset).trim().toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||
if (['utf8', 'utf-8'].includes(normalized)) return 'utf8';
|
||||
if (['latin1', 'iso88591', 'iso-8859-1', 'binary'].includes(normalized)) return 'latin1';
|
||||
if (normalized === 'ascii') return 'ascii';
|
||||
if (['utf16le', 'ucs2'].includes(normalized)) return 'utf16le';
|
||||
return 'utf8';
|
||||
};
|
||||
|
||||
const telnetDecoder = new StringDecoder(charsetToNodeEncoding(options.charset));
|
||||
|
||||
socket.on('data', (data) => {
|
||||
const session = sessions.get(sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const cleanData = handleTelnetNegotiation(data);
|
||||
|
||||
|
||||
if (cleanData.length > 0) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: cleanData.toString('binary') });
|
||||
const decoded = telnetDecoder.write(cleanData);
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -513,9 +529,14 @@ async function startSerialSession(event, options) {
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
const serialDecoder = new StringDecoder('latin1');
|
||||
|
||||
serialPort.on('data', (data) => {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: data.toString('binary') });
|
||||
const decoded = serialDecoder.write(data);
|
||||
if (decoded) {
|
||||
const contents = electronModule.webContents.fromId(session.webContentsId);
|
||||
contents?.send("netcatty:data", { sessionId, data: decoded });
|
||||
}
|
||||
});
|
||||
|
||||
serialPort.on('error', (err) => {
|
||||
|
||||
@@ -167,8 +167,8 @@ function getWindowBoundsState(win, overrideBounds) {
|
||||
}
|
||||
|
||||
const MENU_LABELS = {
|
||||
en: { edit: "Edit", view: "View", window: "Window" },
|
||||
"zh-CN": { edit: "编辑", view: "视图", window: "窗口" },
|
||||
en: { edit: "Edit", view: "View", window: "Window", reload: "Reload" },
|
||||
"zh-CN": { edit: "编辑", view: "视图", window: "窗口", reload: "重新加载" },
|
||||
};
|
||||
|
||||
function tMenu(language, key) {
|
||||
@@ -1079,7 +1079,7 @@ function buildAppMenu(Menu, app, isMac, language = currentLanguage) {
|
||||
{
|
||||
label: tMenu(language, "view"),
|
||||
submenu: [
|
||||
{ role: "reload" },
|
||||
{ label: tMenu(language, "reload"), click: (_, win) => { if (win) win.reload(); } },
|
||||
{ role: "forceReload" },
|
||||
{ role: "toggleDevTools" },
|
||||
{ type: "separator" },
|
||||
|
||||
@@ -36,7 +36,7 @@ try {
|
||||
electronModule = require("electron");
|
||||
}
|
||||
|
||||
const { app, BrowserWindow, Menu, protocol, shell } = electronModule || {};
|
||||
const { app, BrowserWindow, Menu, protocol, shell, clipboard } = electronModule || {};
|
||||
if (!app || !BrowserWindow) {
|
||||
throw new Error("Failed to load Electron runtime. Ensure the app is launched with the Electron binary.");
|
||||
}
|
||||
@@ -81,6 +81,7 @@ const tempDirBridge = require("./bridges/tempDirBridge.cjs");
|
||||
const sessionLogsBridge = require("./bridges/sessionLogsBridge.cjs");
|
||||
const compressUploadBridge = require("./bridges/compressUploadBridge.cjs");
|
||||
const globalShortcutBridge = require("./bridges/globalShortcutBridge.cjs");
|
||||
const credentialBridge = require("./bridges/credentialBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -241,6 +242,15 @@ function focusMainWindow() {
|
||||
const win = wins && wins.length ? wins[0] : null;
|
||||
if (!win) return false;
|
||||
|
||||
// Check if the webContents has crashed or been destroyed
|
||||
try {
|
||||
if (win.webContents?.isCrashed?.()) {
|
||||
console.warn('[Main] Main window webContents has crashed, destroying window');
|
||||
win.destroy();
|
||||
return false;
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
if (win.isMinimized && win.isMinimized()) win.restore();
|
||||
} catch {}
|
||||
@@ -393,6 +403,7 @@ const registerBridges = (win) => {
|
||||
sessionLogsBridge.registerHandlers(ipcMain);
|
||||
compressUploadBridge.registerHandlers(ipcMain);
|
||||
globalShortcutBridge.registerHandlers(ipcMain);
|
||||
credentialBridge.registerHandlers(ipcMain, electronModule);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
@@ -453,6 +464,15 @@ const registerBridges = (win) => {
|
||||
};
|
||||
});
|
||||
|
||||
// Clipboard helpers for renderer fallback paths (e.g. Monaco paste in Electron)
|
||||
ipcMain.handle("netcatty:clipboard:readText", async () => {
|
||||
try {
|
||||
return clipboard?.readText?.() || "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
// Select an application from system file picker
|
||||
ipcMain.handle("netcatty:selectApplication", async () => {
|
||||
const { dialog } = electronModule;
|
||||
@@ -676,100 +696,114 @@ function showStartupError(err) {
|
||||
}
|
||||
}
|
||||
|
||||
// Application lifecycle
|
||||
app.whenReady().then(() => {
|
||||
registerAppProtocol();
|
||||
|
||||
// Set dock icon on macOS
|
||||
if (isMac && appIcon && app.dock?.setIcon) {
|
||||
try {
|
||||
app.dock.setIcon(appIcon);
|
||||
} catch (err) {
|
||||
console.warn("Failed to set dock icon", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = windowManager.buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
const mainWin = windowManager.getMainWindow();
|
||||
const settingsWin = windowManager.getSettingsWindow();
|
||||
const isPrimary = win === mainWin || win === settingsWin;
|
||||
if (!isPrimary) {
|
||||
win.setMenuBarVisibility(false);
|
||||
win.autoHideMenuBar = true;
|
||||
win.setMenu(null);
|
||||
if (appIcon && win.setIcon) win.setIcon(appIcon);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
|
||||
// Create the main window
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create main window:", err);
|
||||
showStartupError(err);
|
||||
try {
|
||||
app.quit();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Re-create window on macOS dock click
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
// Ensure single-instance behavior — must run before app.whenReady() so
|
||||
// the second instance never attempts to register the app:// protocol or
|
||||
// create a BrowserWindow (which would fail with ERR_FAILED).
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
if (!focusMainWindow()) {
|
||||
// Window is missing or crashed — try to recreate it
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
console.error("[Main] Failed to recreate window on second-instance:", err);
|
||||
showStartupError(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Ensure single-instance behavior focuses existing window
|
||||
try {
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on("second-instance", () => {
|
||||
focusMainWindow();
|
||||
// Application lifecycle
|
||||
app.whenReady().then(() => {
|
||||
registerAppProtocol();
|
||||
|
||||
// Set dock icon on macOS
|
||||
if (isMac && appIcon && app.dock?.setIcon) {
|
||||
try {
|
||||
app.dock.setIcon(appIcon);
|
||||
} catch (err) {
|
||||
console.warn("Failed to set dock icon", err);
|
||||
}
|
||||
}
|
||||
|
||||
// Build and set application menu
|
||||
const menu = windowManager.buildAppMenu(Menu, app, isMac);
|
||||
Menu.setApplicationMenu(menu);
|
||||
|
||||
app.on("browser-window-created", (_event, win) => {
|
||||
try {
|
||||
const mainWin = windowManager.getMainWindow();
|
||||
const settingsWin = windowManager.getSettingsWindow();
|
||||
const isPrimary = win === mainWin || win === settingsWin;
|
||||
if (!isPrimary) {
|
||||
win.setMenuBarVisibility(false);
|
||||
win.autoHideMenuBar = true;
|
||||
win.setMenu(null);
|
||||
if (appIcon && win.setIcon) win.setIcon(appIcon);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Cleanup on all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
// Create the main window
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create main window:", err);
|
||||
showStartupError(err);
|
||||
try {
|
||||
app.quit();
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Re-create window on macOS dock click
|
||||
app.on("activate", () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
void createWindow().catch((err) => {
|
||||
console.error("[Main] Failed to create window on activate:", err);
|
||||
showStartupError(err);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Cleanup on all windows closed
|
||||
app.on("window-all-closed", () => {
|
||||
if (process.platform !== "darwin") {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
windowManager.setIsQuitting(true);
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
console.warn("Error during terminal cleanup:", err);
|
||||
}
|
||||
try {
|
||||
portForwardingBridge.stopAllPortForwards();
|
||||
} catch (err) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
try {
|
||||
globalShortcutBridge.cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Graceful shutdown on SIGTERM/SIGINT to prevent zombie processes
|
||||
for (const sig of ['SIGTERM', 'SIGINT']) {
|
||||
process.on(sig, () => {
|
||||
console.log(`[Main] Received ${sig}, quitting…`);
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on("before-quit", () => {
|
||||
windowManager.setIsQuitting(true);
|
||||
});
|
||||
|
||||
// Cleanup all PTY sessions and port forwarding tunnels before quitting
|
||||
app.on("will-quit", () => {
|
||||
try {
|
||||
terminalBridge.cleanupAllSessions();
|
||||
} catch (err) {
|
||||
console.warn("Error during terminal cleanup:", err);
|
||||
}
|
||||
try {
|
||||
portForwardingBridge.stopAllPortForwards();
|
||||
} catch (err) {
|
||||
console.warn("Error during port forwarding cleanup:", err);
|
||||
}
|
||||
try {
|
||||
globalShortcutBridge.cleanup();
|
||||
} catch (err) {
|
||||
console.warn("Error during global shortcut cleanup:", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Export for testing
|
||||
module.exports = {
|
||||
|
||||
@@ -312,6 +312,13 @@ ipcRenderer.on("netcatty:filewatch:error", (_event, payload) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Buffer the latest tray menu data so it can be replayed when the React
|
||||
// component subscribes after lazy-mount (avoiding the first-open race).
|
||||
let _lastTrayMenuData = null;
|
||||
ipcRenderer.on("netcatty:trayPanel:setMenuData", (_event, data) => {
|
||||
_lastTrayMenuData = data;
|
||||
});
|
||||
|
||||
const api = {
|
||||
startSSHSession: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:start", options);
|
||||
@@ -794,6 +801,7 @@ const api = {
|
||||
// Tray panel window
|
||||
hideTrayPanel: () => ipcRenderer.invoke("netcatty:trayPanel:hide"),
|
||||
openMainWindow: () => ipcRenderer.invoke("netcatty:trayPanel:openMainWindow"),
|
||||
quitApp: () => ipcRenderer.invoke("netcatty:trayPanel:quitApp"),
|
||||
jumpToSessionFromTrayPanel: (sessionId) =>
|
||||
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
|
||||
connectToHostFromTrayPanel: (hostId) =>
|
||||
@@ -811,7 +819,15 @@ const api = {
|
||||
},
|
||||
|
||||
onTrayPanelMenuData: (callback) => {
|
||||
const handler = (_event, data) => callback(data);
|
||||
// Replay buffered data so late subscribers (e.g. after React lazy-mount) don't miss
|
||||
// the initial payload that was sent before the useEffect listener was registered.
|
||||
if (_lastTrayMenuData) {
|
||||
queueMicrotask(() => callback(_lastTrayMenuData));
|
||||
}
|
||||
const handler = (_event, data) => {
|
||||
_lastTrayMenuData = data;
|
||||
callback(data);
|
||||
};
|
||||
ipcRenderer.on("netcatty:trayPanel:setMenuData", handler);
|
||||
return () => ipcRenderer.removeListener("netcatty:trayPanel:setMenuData", handler);
|
||||
},
|
||||
@@ -824,6 +840,16 @@ const api = {
|
||||
return undefined;
|
||||
}
|
||||
},
|
||||
|
||||
// Clipboard fallback helpers
|
||||
readClipboardText: async () => {
|
||||
return ipcRenderer.invoke("netcatty:clipboard:readText");
|
||||
},
|
||||
|
||||
// Credential encryption (field-level safeStorage)
|
||||
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
|
||||
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
|
||||
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
|
||||
};
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
|
||||
7
global.d.ts
vendored
7
global.d.ts
vendored
@@ -580,6 +580,12 @@ declare global {
|
||||
|
||||
// Get file path from File object (for drag-and-drop, uses Electron's webUtils)
|
||||
getPathForFile?(file: File): string | undefined;
|
||||
readClipboardText?(): Promise<string>;
|
||||
|
||||
// Credential encryption (field-level safeStorage for sensitive data at rest)
|
||||
credentialsAvailable?(): Promise<boolean>;
|
||||
credentialsEncrypt?(plaintext: string): Promise<string>;
|
||||
credentialsDecrypt?(value: string): Promise<string>;
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey?(hotkey: string): Promise<{ success: boolean; enabled?: boolean; error?: string; accelerator?: string }>;
|
||||
@@ -609,6 +615,7 @@ declare global {
|
||||
|
||||
hideTrayPanel?(): Promise<{ success: boolean }>;
|
||||
openMainWindow?(): Promise<{ success: boolean }>;
|
||||
quitApp?(): Promise<{ success: boolean }>;
|
||||
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
|
||||
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
|
||||
onTrayPanelCloseRequest?(callback: () => void): () => void;
|
||||
|
||||
@@ -47,6 +47,9 @@ export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
|
||||
export const STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES = 'netcatty_sftp_show_hidden_files_v1';
|
||||
export const STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD = 'netcatty_sftp_use_compressed_upload_v1';
|
||||
|
||||
// Editor Settings
|
||||
export const STORAGE_KEY_EDITOR_WORD_WRAP = 'netcatty_editor_word_wrap_v1';
|
||||
|
||||
// Session Logs Settings
|
||||
export const STORAGE_KEY_SESSION_LOGS_ENABLED = 'netcatty_session_logs_enabled_v1';
|
||||
export const STORAGE_KEY_SESSION_LOGS_DIR = 'netcatty_session_logs_dir_v1';
|
||||
|
||||
184
infrastructure/persistence/secureFieldAdapter.ts
Normal file
184
infrastructure/persistence/secureFieldAdapter.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* Secure Field Adapter — Renderer-side helpers for field-level encryption
|
||||
*
|
||||
* Encrypts / decrypts individual sensitive fields within domain models before
|
||||
* they are written to (or after they are read from) localStorage.
|
||||
*
|
||||
* The heavy lifting is done by Electron's safeStorage via the credential
|
||||
* bridge IPC. When the bridge is unavailable (web fallback, tests) every
|
||||
* function degrades to a no-op — values pass through unmodified.
|
||||
*/
|
||||
|
||||
import type { Host, Identity, SSHKey } from "../../domain/models";
|
||||
import type { ProviderConnection, S3Config, WebDAVConfig } from "../../domain/sync";
|
||||
import { netcattyBridge } from "../services/netcattyBridge";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Primitive helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const bridge = () => netcattyBridge.get();
|
||||
|
||||
export async function encryptField(value: string | undefined): Promise<string | undefined> {
|
||||
if (!value) return value;
|
||||
const b = bridge();
|
||||
if (!b?.credentialsEncrypt) return value;
|
||||
return b.credentialsEncrypt(value);
|
||||
}
|
||||
|
||||
export async function decryptField(value: string | undefined): Promise<string | undefined> {
|
||||
if (!value) return value;
|
||||
const b = bridge();
|
||||
if (!b?.credentialsDecrypt) return value;
|
||||
return b.credentialsDecrypt(value);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Host
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptHostSecrets(host: Host): Promise<Host> {
|
||||
const out = { ...host };
|
||||
out.password = await encryptField(out.password);
|
||||
out.telnetPassword = await encryptField(out.telnetPassword);
|
||||
if (out.proxyConfig?.password) {
|
||||
out.proxyConfig = { ...out.proxyConfig, password: await encryptField(out.proxyConfig.password) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptHostSecrets(host: Host): Promise<Host> {
|
||||
const out = { ...host };
|
||||
out.password = await decryptField(out.password);
|
||||
out.telnetPassword = await decryptField(out.telnetPassword);
|
||||
if (out.proxyConfig?.password) {
|
||||
out.proxyConfig = { ...out.proxyConfig, password: await decryptField(out.proxyConfig.password) };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSHKey
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptKeySecrets(key: SSHKey): Promise<SSHKey> {
|
||||
const out = { ...key };
|
||||
out.passphrase = await encryptField(out.passphrase);
|
||||
out.privateKey = (await encryptField(out.privateKey)) ?? "";
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptKeySecrets(key: SSHKey): Promise<SSHKey> {
|
||||
const out = { ...key };
|
||||
out.passphrase = await decryptField(out.passphrase);
|
||||
out.privateKey = (await decryptField(out.privateKey)) ?? "";
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Identity
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptIdentitySecrets(identity: Identity): Promise<Identity> {
|
||||
const out = { ...identity };
|
||||
out.password = await encryptField(out.password);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptIdentitySecrets(identity: Identity): Promise<Identity> {
|
||||
const out = { ...identity };
|
||||
out.password = await decryptField(out.password);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Connection (Cloud Sync)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
|
||||
const out = { ...conn };
|
||||
|
||||
if (out.tokens) {
|
||||
const t = { ...out.tokens };
|
||||
t.accessToken = (await encryptField(t.accessToken)) ?? "";
|
||||
t.refreshToken = await encryptField(t.refreshToken);
|
||||
out.tokens = t;
|
||||
}
|
||||
|
||||
if (out.config) {
|
||||
// WebDAV — use authType (required field unique to WebDAVConfig) as discriminator
|
||||
// so that token-auth configs (which may lack a password key after JSON round-trip)
|
||||
// still get their token field encrypted.
|
||||
if ("authType" in out.config) {
|
||||
const c = { ...out.config } as WebDAVConfig;
|
||||
c.password = await encryptField(c.password);
|
||||
c.token = await encryptField(c.token);
|
||||
out.config = c;
|
||||
}
|
||||
// S3
|
||||
if ("secretAccessKey" in out.config) {
|
||||
const c = { ...out.config } as S3Config;
|
||||
c.secretAccessKey = (await encryptField(c.secretAccessKey)) ?? "";
|
||||
c.sessionToken = await encryptField(c.sessionToken);
|
||||
out.config = c;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptProviderSecrets(conn: ProviderConnection): Promise<ProviderConnection> {
|
||||
const out = { ...conn };
|
||||
|
||||
if (out.tokens) {
|
||||
const t = { ...out.tokens };
|
||||
t.accessToken = (await decryptField(t.accessToken)) ?? "";
|
||||
t.refreshToken = await decryptField(t.refreshToken);
|
||||
out.tokens = t;
|
||||
}
|
||||
|
||||
if (out.config) {
|
||||
if ("authType" in out.config) {
|
||||
const c = { ...out.config } as WebDAVConfig;
|
||||
c.password = await decryptField(c.password);
|
||||
c.token = await decryptField(c.token);
|
||||
out.config = c;
|
||||
}
|
||||
if ("secretAccessKey" in out.config) {
|
||||
const c = { ...out.config } as S3Config;
|
||||
c.secretAccessKey = (await decryptField(c.secretAccessKey)) ?? "";
|
||||
c.sessionToken = await decryptField(c.sessionToken);
|
||||
out.config = c;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Batch helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function encryptHosts(hosts: Host[]): Promise<Host[]> {
|
||||
return Promise.all(hosts.map(encryptHostSecrets));
|
||||
}
|
||||
|
||||
export function decryptHosts(hosts: Host[]): Promise<Host[]> {
|
||||
return Promise.all(hosts.map(decryptHostSecrets));
|
||||
}
|
||||
|
||||
export function encryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
|
||||
return Promise.all(keys.map(encryptKeySecrets));
|
||||
}
|
||||
|
||||
export function decryptKeys(keys: SSHKey[]): Promise<SSHKey[]> {
|
||||
return Promise.all(keys.map(decryptKeySecrets));
|
||||
}
|
||||
|
||||
export function encryptIdentities(identities: Identity[]): Promise<Identity[]> {
|
||||
return Promise.all(identities.map(encryptIdentitySecrets));
|
||||
}
|
||||
|
||||
export function decryptIdentities(identities: Identity[]): Promise<Identity[]> {
|
||||
return Promise.all(identities.map(decryptIdentitySecrets));
|
||||
}
|
||||
@@ -38,6 +38,10 @@ import { createAdapter, type CloudAdapter } from './adapters';
|
||||
import type { GitHubAdapter } from './adapters/GitHubAdapter';
|
||||
import type { GoogleDriveAdapter } from './adapters/GoogleDriveAdapter';
|
||||
import type { OneDriveAdapter } from './adapters/OneDriveAdapter';
|
||||
import {
|
||||
decryptProviderSecrets,
|
||||
encryptProviderSecrets,
|
||||
} from '../persistence/secureFieldAdapter';
|
||||
|
||||
const SYNC_HISTORY_STORAGE_KEY = 'netcatty_sync_history_v1';
|
||||
|
||||
@@ -79,11 +83,25 @@ export class CloudSyncManager {
|
||||
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private masterPassword: string | null = null; // In memory only!
|
||||
private hasStorageListener = false;
|
||||
// Per-provider sequence counters for async decrypt callbacks (startup,
|
||||
// cross-window storage events). Bumped by any state mutation so stale
|
||||
// decrypt results are discarded.
|
||||
private providerDecryptSeq: Record<CloudProvider, number> = {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
};
|
||||
// Per-provider write sequence counters for saveProviderConnection.
|
||||
// Only bumped when a new save is initiated, so status-only updates
|
||||
// (which don't persist) cannot discard an in-flight encrypted write.
|
||||
private providerWriteSeq: Record<CloudProvider, number> = {
|
||||
github: 0, google: 0, onedrive: 0, webdav: 0, s3: 0,
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.state = this.loadInitialState();
|
||||
this.stateSnapshot = { ...this.state };
|
||||
this.setupCrossWindowSync();
|
||||
// Decrypt provider secrets asynchronously after initial load
|
||||
this.initProviderDecryption();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -167,11 +185,41 @@ export class CloudSyncManager {
|
||||
} as ProviderConnection;
|
||||
}
|
||||
|
||||
private saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): void {
|
||||
/**
|
||||
* Asynchronously decrypt provider connection secrets after initial load.
|
||||
* Runs once at construction; decrypted tokens replace the encrypted ones
|
||||
* in-memory so adapters can use them.
|
||||
*/
|
||||
private async initProviderDecryption(): Promise<void> {
|
||||
const providers: CloudProvider[] = ['github', 'google', 'onedrive', 'webdav', 's3'];
|
||||
for (const p of providers) {
|
||||
try {
|
||||
const conn = this.state.providers[p];
|
||||
if (conn.tokens || conn.config) {
|
||||
const seq = ++this.providerDecryptSeq[p];
|
||||
const decrypted = await decryptProviderSecrets(conn);
|
||||
// Only apply if no newer update has occurred during the async gap
|
||||
if (seq === this.providerDecryptSeq[p]) {
|
||||
this.state.providers[p] = decrypted;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Decryption failure is non-fatal; the adapter will fail on use
|
||||
}
|
||||
}
|
||||
this.notifyStateChange();
|
||||
}
|
||||
|
||||
private async saveProviderConnection(provider: CloudProvider, connection: ProviderConnection): Promise<void> {
|
||||
const key = SYNC_STORAGE_KEYS[`PROVIDER_${provider.toUpperCase()}` as keyof typeof SYNC_STORAGE_KEYS];
|
||||
// Don't persist sensitive tokens directly - use safeStorage in production
|
||||
const { tokens, ...safeData } = connection;
|
||||
this.saveToStorage(key, { ...safeData, tokens }); // In production, encrypt tokens/config
|
||||
// Use write-specific counter so status-only updates cannot discard
|
||||
// an in-flight encrypted write that must be persisted.
|
||||
const seq = ++this.providerWriteSeq[provider];
|
||||
const encrypted = await encryptProviderSecrets(connection);
|
||||
// Only persist if no newer save has started during the async gap
|
||||
if (seq === this.providerWriteSeq[provider]) {
|
||||
this.saveToStorage(key, encrypted);
|
||||
}
|
||||
}
|
||||
|
||||
private loadFromStorage<T>(key: string): T | null {
|
||||
@@ -292,48 +340,61 @@ export class CloudSyncManager {
|
||||
};
|
||||
const provider = providerByKey[key];
|
||||
if (provider) {
|
||||
const prev = this.state.providers[provider];
|
||||
const next = this.loadProviderConnection(provider);
|
||||
const rawNext = this.loadProviderConnection(provider);
|
||||
const seq = ++this.providerDecryptSeq[provider];
|
||||
// Also bump write seq so any in-flight save from this window for the
|
||||
// same provider is discarded — the cross-window data is newer.
|
||||
++this.providerWriteSeq[provider];
|
||||
|
||||
const preserveTransientStatus =
|
||||
prev.status === 'connecting' || prev.status === 'syncing';
|
||||
// Decrypt secrets asynchronously, then update state.
|
||||
// Use sequence counter to discard stale results when multiple events
|
||||
// for the same provider arrive in quick succession.
|
||||
decryptProviderSecrets(rawNext).then((next) => {
|
||||
if (seq !== this.providerDecryptSeq[provider]) return; // stale — discard
|
||||
|
||||
this.state.providers[provider] = {
|
||||
...next,
|
||||
status: preserveTransientStatus ? prev.status : next.status,
|
||||
error: preserveTransientStatus ? prev.error : next.error,
|
||||
};
|
||||
const prev = this.state.providers[provider];
|
||||
const preserveTransientStatus =
|
||||
prev.status === 'connecting' || prev.status === 'syncing';
|
||||
|
||||
const nextTokens = next.tokens;
|
||||
const nextConfig = next.config;
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!nextTokens && !nextConfig) {
|
||||
if (adapter) {
|
||||
this.state.providers[provider] = {
|
||||
...next,
|
||||
status: preserveTransientStatus ? prev.status : next.status,
|
||||
error: preserveTransientStatus ? prev.error : next.error,
|
||||
};
|
||||
|
||||
const nextTokens = next.tokens;
|
||||
const nextConfig = next.config;
|
||||
const adapter = this.adapters.get(provider);
|
||||
if (!nextTokens && !nextConfig) {
|
||||
if (adapter) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
this.notifyStateChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenChanged =
|
||||
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
|
||||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
|
||||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
|
||||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
|
||||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
|
||||
|
||||
const configChanged =
|
||||
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
|
||||
|
||||
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
|
||||
|
||||
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
this.notifyStateChange();
|
||||
return;
|
||||
}
|
||||
|
||||
const tokenChanged =
|
||||
(prev.tokens?.accessToken || null) !== (nextTokens?.accessToken || null) ||
|
||||
(prev.tokens?.refreshToken || null) !== (nextTokens?.refreshToken || null) ||
|
||||
(prev.tokens?.expiresAt || null) !== (nextTokens?.expiresAt || null) ||
|
||||
(prev.tokens?.tokenType || null) !== (nextTokens?.tokenType || null) ||
|
||||
(prev.tokens?.scope || null) !== (nextTokens?.scope || null);
|
||||
|
||||
const configChanged =
|
||||
JSON.stringify(prev.config || null) !== JSON.stringify(nextConfig || null);
|
||||
|
||||
const resourceChanged = (adapter?.resourceId || null) !== (next.resourceId || null);
|
||||
|
||||
if (adapter && (tokenChanged || configChanged || resourceChanged)) {
|
||||
adapter.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
this.notifyStateChange();
|
||||
}).catch(() => {
|
||||
// Decryption failure in cross-window handler is non-fatal
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -637,6 +698,7 @@ export class CloudSyncManager {
|
||||
try {
|
||||
const tokens = await ghAdapter.completeAuth(deviceCode, interval, expiresAt, onPending);
|
||||
|
||||
++this.providerDecryptSeq.github;
|
||||
this.state.providers.github = {
|
||||
...this.state.providers.github,
|
||||
status: 'connected',
|
||||
@@ -650,7 +712,7 @@ export class CloudSyncManager {
|
||||
this.state.providers.github.resourceId = resourceId;
|
||||
}
|
||||
|
||||
this.saveProviderConnection('github', this.state.providers.github);
|
||||
await this.saveProviderConnection('github', this.state.providers.github);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider: 'github',
|
||||
@@ -689,6 +751,7 @@ export class CloudSyncManager {
|
||||
account = odAdapter.accountInfo;
|
||||
}
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
status: 'connected',
|
||||
@@ -702,7 +765,7 @@ export class CloudSyncManager {
|
||||
this.state.providers[provider].resourceId = resourceId;
|
||||
}
|
||||
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -729,6 +792,7 @@ export class CloudSyncManager {
|
||||
const resourceId = await adapter.initializeSync();
|
||||
const account = adapter.accountInfo || this.buildAccountFromConfig(provider, config);
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
provider,
|
||||
status: 'connected',
|
||||
@@ -737,7 +801,7 @@ export class CloudSyncManager {
|
||||
resourceId: resourceId || undefined,
|
||||
};
|
||||
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.emit({
|
||||
type: 'AUTH_COMPLETED',
|
||||
provider,
|
||||
@@ -759,12 +823,13 @@ export class CloudSyncManager {
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
provider,
|
||||
status: 'disconnected',
|
||||
};
|
||||
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange(); // Ensure UI updates immediately after disconnect
|
||||
}
|
||||
|
||||
@@ -773,6 +838,8 @@ export class CloudSyncManager {
|
||||
status: ProviderConnection['status'],
|
||||
error?: string
|
||||
): void {
|
||||
// Bump sequence to invalidate any in-flight async decrypt for this provider
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider] = {
|
||||
...this.state.providers[provider],
|
||||
status,
|
||||
@@ -842,11 +909,14 @@ export class CloudSyncManager {
|
||||
this.state.localUpdatedAt = syncedFile.meta.updatedAt;
|
||||
this.state.remoteVersion = syncedFile.meta.version;
|
||||
this.state.remoteUpdatedAt = syncedFile.meta.updatedAt;
|
||||
// Invalidate any pending provider decrypt so it cannot overwrite
|
||||
// the lastSync/lastSyncVersion we are about to set.
|
||||
++this.providerDecryptSeq[provider];
|
||||
this.state.providers[provider].lastSync = Date.now();
|
||||
this.state.providers[provider].lastSyncVersion = syncedFile.meta.version;
|
||||
|
||||
this.saveSyncConfig();
|
||||
this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
await this.saveProviderConnection(provider, this.state.providers[provider]);
|
||||
this.notifyStateChange();
|
||||
|
||||
// Add to sync history
|
||||
|
||||
10
lib/utils.ts
10
lib/utils.ts
@@ -14,6 +14,16 @@ export function normalizeLineEndings(text: string): string {
|
||||
return text.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text in bracketed paste escape sequences.
|
||||
* When a terminal application enables bracketed paste mode (CSI ?2004h),
|
||||
* pasted text should be wrapped so the application can distinguish paste
|
||||
* from typed input (e.g. vim disables autoindent during paste).
|
||||
*/
|
||||
export function wrapBracketedPaste(text: string): string {
|
||||
return `\x1b[200~${text}\x1b[201~`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect if the current platform is macOS.
|
||||
* Used for keyboard shortcut handling to differentiate between Mac and PC shortcuts.
|
||||
|
||||
93
package-lock.json
generated
93
package-lock.json
generated
@@ -955,13 +955,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@aws-sdk/xml-builder": {
|
||||
"version": "3.972.2",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.2.tgz",
|
||||
"integrity": "sha512-jGOOV/bV1DhkkUhHiZ3/1GZ67cZyOXaDb7d1rYD6ZiXf5V9tBNOcgqXwRRPvrCbYaFRa1pPMFb3ZjqjWpR3YfA==",
|
||||
"version": "3.972.4",
|
||||
"resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.4.tgz",
|
||||
"integrity": "sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@smithy/types": "^4.12.0",
|
||||
"fast-xml-parser": "5.2.5",
|
||||
"fast-xml-parser": "5.3.4",
|
||||
"tslib": "^2.6.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1008,6 +1008,7 @@
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
@@ -1653,7 +1654,6 @@
|
||||
"dev": true,
|
||||
"license": "BSD-2-Clause",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"cross-dirname": "^0.1.0",
|
||||
"debug": "^4.3.4",
|
||||
@@ -1675,7 +1675,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
@@ -1692,7 +1691,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
@@ -1707,7 +1705,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
@@ -2544,9 +2541,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@isaacs/brace-expansion": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
|
||||
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz",
|
||||
"integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -5673,6 +5670,7 @@
|
||||
"integrity": "sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
@@ -5702,6 +5700,7 @@
|
||||
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.54.0",
|
||||
"@typescript-eslint/types": "8.54.0",
|
||||
@@ -5980,7 +5979,8 @@
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz",
|
||||
"integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==",
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@yarnpkg/lockfile": {
|
||||
"version": "1.1.0",
|
||||
@@ -6012,6 +6012,7 @@
|
||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -6044,6 +6045,7 @@
|
||||
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"fast-deep-equal": "^3.1.1",
|
||||
"fast-json-stable-stringify": "^2.0.0",
|
||||
@@ -6516,6 +6518,7 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
@@ -7123,8 +7126,7 @@
|
||||
"integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/cross-env": {
|
||||
"version": "10.1.0",
|
||||
@@ -7364,6 +7366,7 @@
|
||||
"integrity": "sha512-uOOBA3f+kW3o4KpSoMQ6SNpdXU7WtxlJRb9vCZgOvqhTz4b3GjcoWKstdisizNZLsylhTMv8TLHFPFW0Uxsj/g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"app-builder-lib": "26.7.0",
|
||||
"builder-util": "26.4.1",
|
||||
@@ -7689,7 +7692,6 @@
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@electron/asar": "^3.2.1",
|
||||
"debug": "^4.1.1",
|
||||
@@ -7710,7 +7712,6 @@
|
||||
"integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.1.2",
|
||||
"jsonfile": "^4.0.0",
|
||||
@@ -7928,6 +7929,7 @@
|
||||
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.8.0",
|
||||
"@eslint-community/regexpp": "^4.12.1",
|
||||
@@ -8239,9 +8241,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fast-xml-parser": {
|
||||
"version": "5.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.2.5.tgz",
|
||||
"integrity": "sha512-pfX9uG9Ki0yekDHx2SiuRIyFdyAr1kMIMitPvb0YBo8SUfKvia7w7FIyd/l6av85pFYRhZscS75MwMnbvY+hcQ==",
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.4.tgz",
|
||||
"integrity": "sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -10167,7 +10169,6 @@
|
||||
"integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"minimist": "^1.2.6"
|
||||
},
|
||||
@@ -10180,6 +10181,7 @@
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
|
||||
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"dompurify": "3.2.7",
|
||||
"marked": "14.0.0"
|
||||
@@ -10826,7 +10828,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"commander": "^9.4.0"
|
||||
},
|
||||
@@ -10844,7 +10845,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^12.20.0 || >=14"
|
||||
}
|
||||
@@ -10957,6 +10957,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz",
|
||||
"integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
@@ -10966,6 +10967,7 @@
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||
"integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
@@ -11833,7 +11835,6 @@
|
||||
"integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"mkdirp": "^0.5.1",
|
||||
"rimraf": "~2.6.2"
|
||||
@@ -11898,7 +11899,6 @@
|
||||
"deprecated": "Rimraf versions prior to v4 are no longer supported",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"glob": "^7.1.3"
|
||||
},
|
||||
@@ -11967,6 +11967,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12091,6 +12092,7 @@
|
||||
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -12293,6 +12295,7 @@
|
||||
"integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.27.0",
|
||||
"fdir": "^6.5.0",
|
||||
@@ -12386,6 +12389,7 @@
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
@@ -12433,16 +12437,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/webdav": {
|
||||
"version": "5.8.0",
|
||||
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.8.0.tgz",
|
||||
"integrity": "sha512-iuFG7NamJ41Oshg4930iQgfIpRrUiatPWIekeznYgEf2EOraTRcDPTjy7gIOMtkdpKTaqPk1E68NO5PAGtJahA==",
|
||||
"version": "5.9.0",
|
||||
"resolved": "https://registry.npmjs.org/webdav/-/webdav-5.9.0.tgz",
|
||||
"integrity": "sha512-OMJ6wtK1WvCO++aOLoQgE96S8KT4e5aaClWHmHXfFU369r4eyELN569B7EqT4OOUb99mmO58GkyuiCv/Ag6J0Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@buttercup/fetch": "^0.2.1",
|
||||
"base-64": "^1.0.0",
|
||||
"byte-length": "^1.0.2",
|
||||
"entities": "^6.0.0",
|
||||
"fast-xml-parser": "^4.5.1",
|
||||
"entities": "^6.0.1",
|
||||
"fast-xml-parser": "^5.3.4",
|
||||
"hot-patcher": "^2.0.1",
|
||||
"layerr": "^3.0.0",
|
||||
"md5": "^2.3.0",
|
||||
@@ -12457,36 +12461,6 @@
|
||||
"node": ">=14"
|
||||
}
|
||||
},
|
||||
"node_modules/webdav/node_modules/fast-xml-parser": {
|
||||
"version": "4.5.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-4.5.3.tgz",
|
||||
"integrity": "sha512-RKihhV+SHsIUGXObeVy9AXiBbFwkVk7Syp8XgwN5U3JV416+Gwp/GO9i0JYKmikykgz/UHRrrV4ROuZEo/T0ig==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"strnum": "^1.1.1"
|
||||
},
|
||||
"bin": {
|
||||
"fxparser": "src/cli/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/webdav/node_modules/strnum": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-1.1.2.tgz",
|
||||
"integrity": "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@@ -12678,6 +12652,7 @@
|
||||
"integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/node_modules/ssh2/lib/protocol/SFTP.js b/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
index 9f33c02..c311d3a 100644
|
||||
index 9f33c02..9751164 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/SFTP.js
|
||||
@@ -117,6 +117,20 @@ const OPENSSH_MAX_PKT_LEN = 256 * 1024;
|
||||
@@ -23,7 +23,70 @@ index 9f33c02..c311d3a 100644
|
||||
const fakeStderr = {
|
||||
readable: false,
|
||||
writable: false,
|
||||
@@ -339,7 +351,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -155,6 +169,8 @@ class SFTP extends EventEmitter {
|
||||
this._writeReqid = -1;
|
||||
this._requests = {};
|
||||
this._maxInPktLen = OPENSSH_MAX_PKT_LEN;
|
||||
+ this._preambleSkipped = false; // Track if we've found the start of SFTP binary data
|
||||
+ this._preambleBuf = null; // Buffer for partial preamble data across frames
|
||||
this._maxOutPktLen = 34000;
|
||||
this._maxReadLen =
|
||||
(this._isOpenSSH ? OPENSSH_MAX_PKT_LEN : 34000) - PKT_RW_OVERHEAD;
|
||||
@@ -196,6 +212,53 @@ class SFTP extends EventEmitter {
|
||||
this.emit('end');
|
||||
return;
|
||||
}
|
||||
+
|
||||
+ // Skip non-SFTP preamble data (e.g. MOTD/banner text from misconfigured servers)
|
||||
+ // Only applies to client mode; server mode expects SSH_FXP_INIT directly.
|
||||
+ if (!this._preambleSkipped) {
|
||||
+ if (this.server) {
|
||||
+ // Server mode: no preamble skipping, proceed to normal parsing
|
||||
+ this._preambleSkipped = true;
|
||||
+ } else {
|
||||
+ // Concatenate with any previously buffered partial data
|
||||
+ if (this._preambleBuf) {
|
||||
+ data = Buffer.concat([this._preambleBuf, data]);
|
||||
+ this._preambleBuf = null;
|
||||
+ }
|
||||
+
|
||||
+ // Look for the start of a valid SFTP packet in the data.
|
||||
+ // The first SFTP packet from the server is SSH_FXP_VERSION (type=2).
|
||||
+ // Format: uint32 length, byte type=0x02, uint32 version, ...
|
||||
+ // The length should be >= 5 (1 byte type + 4 bytes version).
|
||||
+ let found = -1;
|
||||
+ for (let i = 0; i <= data.length - 5; i++) {
|
||||
+ const len = (data[i] << 24) | (data[i+1] << 16) | (data[i+2] << 8) | data[i+3];
|
||||
+ if (len >= 5 && len <= this._maxInPktLen && data[i+4] === 0x02) {
|
||||
+ found = i;
|
||||
+ break;
|
||||
+ }
|
||||
+ }
|
||||
+ if (found === -1) {
|
||||
+ // No valid SFTP packet header found yet.
|
||||
+ // Keep up to the last 4 bytes in case a valid header spans this and the
|
||||
+ // next chunk (the uint32 length could be split across frames).
|
||||
+ const keep = Math.min(data.length, 4);
|
||||
+ this._preambleBuf = Buffer.from(data.slice(data.length - keep));
|
||||
+ this._debug && this._debug(
|
||||
+ 'SFTP: Skipping non-SFTP preamble data (' + data.length + ' bytes, buffered last ' + keep + ')'
|
||||
+ );
|
||||
+ return;
|
||||
+ }
|
||||
+ if (found > 0) {
|
||||
+ this._debug && this._debug(
|
||||
+ 'SFTP: Skipped ' + found + ' bytes of non-SFTP preamble data'
|
||||
+ );
|
||||
+ data = data.slice(found);
|
||||
+ }
|
||||
+ this._preambleSkipped = true;
|
||||
+ }
|
||||
+ }
|
||||
+
|
||||
/*
|
||||
uint32 length
|
||||
byte type
|
||||
@@ -339,7 +402,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 pflags
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -32,7 +95,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + 4 + attrsLen);
|
||||
|
||||
@@ -349,7 +361,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -349,7 +412,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -41,7 +104,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
writeUInt32BE(buf, attrsFlags, p += 4);
|
||||
if (attrsLen) {
|
||||
@@ -734,7 +746,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -734,7 +797,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string filename
|
||||
*/
|
||||
@@ -50,7 +113,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + fnameLen);
|
||||
|
||||
@@ -744,7 +756,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -744,7 +807,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, fnameLen, p);
|
||||
@@ -59,7 +122,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -762,8 +774,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -762,8 +825,8 @@ class SFTP extends EventEmitter {
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -70,7 +133,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + oldLen + 4 + newLen);
|
||||
|
||||
@@ -773,9 +785,9 @@ class SFTP extends EventEmitter {
|
||||
@@ -773,9 +836,9 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, oldLen, p);
|
||||
@@ -82,7 +145,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -806,7 +818,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -806,7 +869,7 @@ class SFTP extends EventEmitter {
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -91,7 +154,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
|
||||
|
||||
@@ -816,7 +828,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -816,7 +879,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -100,7 +163,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
p += 4;
|
||||
@@ -844,7 +856,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -844,7 +907,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -109,7 +172,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -854,7 +866,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -854,7 +917,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -118,7 +181,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -987,7 +999,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -987,7 +1050,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -127,7 +190,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -997,7 +1009,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -997,7 +1060,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -136,7 +199,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1014,7 +1026,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1014,7 +1077,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -145,7 +208,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1024,7 +1036,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1024,7 +1087,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -154,7 +217,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1041,7 +1053,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1041,7 +1104,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -163,7 +226,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1051,7 +1063,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1051,7 +1114,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -172,7 +235,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1080,7 +1092,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1080,7 +1143,7 @@ class SFTP extends EventEmitter {
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -181,7 +244,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen + 4 + attrsLen);
|
||||
|
||||
@@ -1090,7 +1102,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1090,7 +1153,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -190,7 +253,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
p += 4;
|
||||
@@ -1205,7 +1217,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1205,7 +1268,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -199,7 +262,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1215,7 +1227,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1215,7 +1278,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -208,7 +271,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1243,8 +1255,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -1243,8 +1306,8 @@ class SFTP extends EventEmitter {
|
||||
string linkpath
|
||||
string targetpath
|
||||
*/
|
||||
@@ -219,7 +282,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + linkLen + 4 + targetLen);
|
||||
|
||||
@@ -1256,14 +1268,14 @@ class SFTP extends EventEmitter {
|
||||
@@ -1256,14 +1319,14 @@ class SFTP extends EventEmitter {
|
||||
if (this._isOpenSSH) {
|
||||
// OpenSSH has linkpath and targetpath positions switched
|
||||
writeUInt32BE(buf, targetLen, p);
|
||||
@@ -238,7 +301,7 @@ index 9f33c02..c311d3a 100644
|
||||
}
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
@@ -1281,7 +1293,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1281,7 +1344,7 @@ class SFTP extends EventEmitter {
|
||||
uint32 id
|
||||
string path
|
||||
*/
|
||||
@@ -247,7 +310,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + pathLen);
|
||||
|
||||
@@ -1291,7 +1303,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1291,7 +1354,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, pathLen, p);
|
||||
@@ -256,7 +319,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1325,8 +1337,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -1325,8 +1388,8 @@ class SFTP extends EventEmitter {
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -267,7 +330,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 24 + 4 + oldLen + 4 + newLen);
|
||||
@@ -1337,11 +1349,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -1337,11 +1400,11 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 24, p);
|
||||
@@ -282,7 +345,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1364,7 +1376,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1364,7 +1427,7 @@ class SFTP extends EventEmitter {
|
||||
string "statvfs@openssh.com"
|
||||
string path
|
||||
*/
|
||||
@@ -291,7 +354,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 19 + 4 + pathLen);
|
||||
|
||||
@@ -1374,9 +1386,9 @@ class SFTP extends EventEmitter {
|
||||
@@ -1374,9 +1437,9 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 19, p);
|
||||
@@ -303,7 +366,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { extended: 'statvfs@openssh.com', cb };
|
||||
|
||||
@@ -1411,7 +1423,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1411,7 +1474,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -312,7 +375,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, handleLen, p += 20);
|
||||
buf.set(handle, p += 4);
|
||||
|
||||
@@ -1437,8 +1449,8 @@ class SFTP extends EventEmitter {
|
||||
@@ -1437,8 +1500,8 @@ class SFTP extends EventEmitter {
|
||||
string oldpath
|
||||
string newpath
|
||||
*/
|
||||
@@ -323,7 +386,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + oldLen + 4 + newLen);
|
||||
@@ -1449,11 +1461,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -1449,11 +1512,11 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -338,7 +401,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = { cb };
|
||||
|
||||
@@ -1488,7 +1500,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1488,7 +1551,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 17, p);
|
||||
@@ -347,7 +410,7 @@ index 9f33c02..c311d3a 100644
|
||||
writeUInt32BE(buf, handleLen, p += 17);
|
||||
buf.set(handle, p += 4);
|
||||
|
||||
@@ -1524,7 +1536,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1524,7 +1587,7 @@ class SFTP extends EventEmitter {
|
||||
string path
|
||||
ATTRS attrs
|
||||
*/
|
||||
@@ -356,7 +419,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf =
|
||||
Buffer.allocUnsafe(4 + 1 + 4 + 4 + 20 + 4 + pathLen + 4 + attrsLen);
|
||||
@@ -1535,10 +1547,10 @@ class SFTP extends EventEmitter {
|
||||
@@ -1535,10 +1598,10 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 20, p);
|
||||
@@ -369,7 +432,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
writeUInt32BE(buf, flags, p += pathLen);
|
||||
if (attrsLen) {
|
||||
@@ -1573,7 +1585,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1573,7 +1636,7 @@ class SFTP extends EventEmitter {
|
||||
string "expand-path@openssh.com"
|
||||
string path
|
||||
*/
|
||||
@@ -378,7 +441,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 23 + 4 + pathLen);
|
||||
|
||||
@@ -1583,10 +1595,10 @@ class SFTP extends EventEmitter {
|
||||
@@ -1583,10 +1646,10 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 23, p);
|
||||
@@ -391,7 +454,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
this._requests[reqid] = {
|
||||
cb: (err, names) => {
|
||||
@@ -1653,7 +1665,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1653,7 +1716,7 @@ class SFTP extends EventEmitter {
|
||||
|
||||
writeUInt32BE(buf, 9, p);
|
||||
p += 4;
|
||||
@@ -400,7 +463,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += 9;
|
||||
|
||||
writeUInt32BE(buf, srcHandle.length, p);
|
||||
@@ -1708,7 +1720,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1708,7 +1771,7 @@ class SFTP extends EventEmitter {
|
||||
string username
|
||||
*/
|
||||
let p = 0;
|
||||
@@ -409,7 +472,7 @@ index 9f33c02..c311d3a 100644
|
||||
const buf = Buffer.allocUnsafe(
|
||||
4 + 1
|
||||
+ 4
|
||||
@@ -1728,12 +1740,12 @@ class SFTP extends EventEmitter {
|
||||
@@ -1728,12 +1791,12 @@ class SFTP extends EventEmitter {
|
||||
|
||||
writeUInt32BE(buf, 14, p);
|
||||
p += 4;
|
||||
@@ -424,7 +487,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += usernameLen;
|
||||
|
||||
this._requests[reqid] = {
|
||||
@@ -1806,7 +1818,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1806,7 +1869,7 @@ class SFTP extends EventEmitter {
|
||||
|
||||
writeUInt32BE(buf, 30, p);
|
||||
p += 4;
|
||||
@@ -433,7 +496,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += 30;
|
||||
|
||||
writeUInt32BE(buf, 4 * uids.length, p);
|
||||
@@ -1871,7 +1883,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1871,7 +1934,7 @@ class SFTP extends EventEmitter {
|
||||
|
||||
message || (message = '');
|
||||
|
||||
@@ -442,7 +505,7 @@ index 9f33c02..c311d3a 100644
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + 4 + msgLen + 4);
|
||||
|
||||
@@ -1884,7 +1896,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1884,7 +1947,7 @@ class SFTP extends EventEmitter {
|
||||
writeUInt32BE(buf, msgLen, p += 4);
|
||||
p += 4;
|
||||
if (msgLen) {
|
||||
@@ -451,7 +514,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += msgLen;
|
||||
}
|
||||
|
||||
@@ -1913,7 +1925,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1913,7 +1976,7 @@ class SFTP extends EventEmitter {
|
||||
const dataLen = (
|
||||
isBuffer
|
||||
? data.length
|
||||
@@ -460,7 +523,7 @@ index 9f33c02..c311d3a 100644
|
||||
);
|
||||
let p = 9;
|
||||
const buf = Buffer.allocUnsafe(4 + 1 + 4 + 4 + dataLen);
|
||||
@@ -1927,7 +1939,7 @@ class SFTP extends EventEmitter {
|
||||
@@ -1927,7 +1990,7 @@ class SFTP extends EventEmitter {
|
||||
if (isBuffer)
|
||||
buf.set(data, p += 4);
|
||||
else if (isUTF8)
|
||||
@@ -469,7 +532,7 @@ index 9f33c02..c311d3a 100644
|
||||
else
|
||||
buf.write(data, p += 4, dataLen, encoding);
|
||||
}
|
||||
@@ -1959,13 +1971,13 @@ class SFTP extends EventEmitter {
|
||||
@@ -1959,13 +2022,13 @@ class SFTP extends EventEmitter {
|
||||
? ''
|
||||
: name.filename
|
||||
);
|
||||
@@ -485,7 +548,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
if (typeof name.attrs === 'object' && name.attrs !== null) {
|
||||
nameAttrs = attrsToBytes(name.attrs);
|
||||
@@ -2011,11 +2023,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -2011,11 +2074,11 @@ class SFTP extends EventEmitter {
|
||||
? ''
|
||||
: name.filename
|
||||
);
|
||||
@@ -499,7 +562,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += len;
|
||||
}
|
||||
}
|
||||
@@ -2026,11 +2038,11 @@ class SFTP extends EventEmitter {
|
||||
@@ -2026,11 +2089,11 @@ class SFTP extends EventEmitter {
|
||||
? ''
|
||||
: name.longname
|
||||
);
|
||||
@@ -513,7 +576,7 @@ index 9f33c02..c311d3a 100644
|
||||
p += len;
|
||||
}
|
||||
}
|
||||
@@ -2749,7 +2761,7 @@ function requestLimits(sftp, cb) {
|
||||
@@ -2749,7 +2812,7 @@ function requestLimits(sftp, cb) {
|
||||
writeUInt32BE(buf, reqid, 5);
|
||||
|
||||
writeUInt32BE(buf, 18, p);
|
||||
@@ -522,7 +585,7 @@ index 9f33c02..c311d3a 100644
|
||||
|
||||
sftp._requests[reqid] = { extended: 'limits@openssh.com', cb };
|
||||
|
||||
@@ -2953,18 +2965,28 @@ const CLIENT_HANDLERS = {
|
||||
@@ -2953,18 +3016,28 @@ const CLIENT_HANDLERS = {
|
||||
// spec not specifying an encoding because the specs for newer
|
||||
// versions of the protocol all explicitly specify UTF-8 for
|
||||
// filenames
|
||||
|
||||
Reference in New Issue
Block a user