Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a451fd8811 | ||
|
|
49cef792a8 | ||
|
|
62511ceb21 | ||
|
|
00cbb05d71 | ||
|
|
3497614165 | ||
|
|
b652b836a7 | ||
|
|
cd604107ee | ||
|
|
adc4c25dc9 | ||
|
|
eaaf0265f8 | ||
|
|
f4d833497d | ||
|
|
75871717a9 | ||
|
|
f6619c28ed | ||
|
|
ca77315257 | ||
|
|
3ab681e63b | ||
|
|
2ee7781b82 | ||
|
|
95780a29dc | ||
|
|
060c35f66a | ||
|
|
ee5d3827d5 | ||
|
|
f06333b95e | ||
|
|
a07c644ec8 | ||
|
|
1d5c40c665 | ||
|
|
ab0c4ede7e | ||
|
|
cf86c166cf | ||
|
|
686a707fef | ||
|
|
159a5eccd2 | ||
|
|
8a6e915dd7 | ||
|
|
474a8bae87 | ||
|
|
6c2e902007 | ||
|
|
0e61262bc0 | ||
|
|
200d710cc9 | ||
|
|
a7873fc457 | ||
|
|
1286975a4b | ||
|
|
2933e108bc | ||
|
|
8278bfde0f | ||
|
|
d0b941eabf | ||
|
|
a98821acb7 | ||
|
|
4edc28113e | ||
|
|
adc712e121 | ||
|
|
81d1b4602d | ||
|
|
540aabb676 | ||
|
|
8d014193ca | ||
|
|
892c6da44d | ||
|
|
0ff6273882 | ||
|
|
92556d824e | ||
|
|
f3676734a7 | ||
|
|
3d1db751ca | ||
|
|
35f531bb55 | ||
|
|
71ff9953bd | ||
|
|
72635eeaeb | ||
|
|
ec17abb507 | ||
|
|
fe7f760a47 | ||
|
|
ab70a406c9 | ||
|
|
7e73da5557 | ||
|
|
97474acb89 | ||
|
|
f59c83be2a | ||
|
|
cba1803230 | ||
|
|
e50a087a07 | ||
|
|
5839c00b67 | ||
|
|
f5cb590e0c | ||
|
|
237b4404dc | ||
|
|
1c10076866 | ||
|
|
eb80b8f60c | ||
|
|
f38515d383 | ||
|
|
64a1b8de3e | ||
|
|
c1eb19a739 | ||
|
|
7342b4a872 | ||
|
|
db682d7857 | ||
|
|
c6491b71c9 | ||
|
|
8667d0d535 | ||
|
|
2bcb081486 | ||
|
|
fefda0015e | ||
|
|
5fc5471685 | ||
|
|
4601372ce6 | ||
|
|
6491ab38bc | ||
|
|
6476bc95df | ||
|
|
7ef1059f7b | ||
|
|
fd78fc7baa | ||
|
|
5787a6ac6a | ||
|
|
787760d02c | ||
|
|
1b2c3e30a2 | ||
|
|
ae7495baf9 | ||
|
|
2bcea8386f | ||
|
|
be7d29f45e | ||
|
|
4a762097ee | ||
|
|
c91cf1d2f8 | ||
|
|
0a43220057 | ||
|
|
288ea06c04 | ||
|
|
9ca7e39748 | ||
|
|
1cbbb61afa | ||
|
|
cf352502f8 | ||
|
|
72d270580f | ||
|
|
f0cfcbc560 | ||
|
|
f8262a64ab |
77
.github/workflows/build.yml
vendored
@@ -25,9 +25,6 @@ jobs:
|
||||
- name: windows
|
||||
os: windows-latest
|
||||
pack_script: pack:win
|
||||
- name: linux-x64
|
||||
os: ubuntu-latest
|
||||
pack_script: pack:linux-x64
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
@@ -40,7 +37,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
@@ -61,8 +58,13 @@ jobs:
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: ${{ matrix.name == 'macos' && 'false' || '' }}
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
# macOS code signing & notarization (ignored on other platforms)
|
||||
CSC_LINK: ${{ secrets.MAC_CSC_LINK }}
|
||||
CSC_KEY_PASSWORD: ${{ secrets.MAC_CSC_KEY_PASSWORD }}
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Upload artifacts
|
||||
@@ -71,12 +73,68 @@ jobs:
|
||||
name: netcatty-${{ matrix.name }}
|
||||
path: |
|
||||
release/*.dmg
|
||||
release/*.zip
|
||||
release/*.exe
|
||||
release/*.msi
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.tar.gz
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Linux x64 — builds directly on ubuntu-latest (no container).
|
||||
# v1.0.39 used a debian:bullseye container which broke native module
|
||||
# packaging (node-pty .node file missing from asar.unpacked). Reverted
|
||||
# to the v1.0.38 approach. See #264.
|
||||
build-linux-x64:
|
||||
name: build-linux-x64
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VITE_SYNC_GITHUB_CLIENT_ID: ${{ secrets.VITE_SYNC_GITHUB_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_ID: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_ID }}
|
||||
VITE_SYNC_GOOGLE_CLIENT_SECRET: ${{ secrets.VITE_SYNC_GOOGLE_CLIENT_SECRET }}
|
||||
VITE_SYNC_ONEDRIVE_CLIENT_ID: ${{ secrets.VITE_SYNC_ONEDRIVE_CLIENT_ID }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install deps
|
||||
run: npm ci
|
||||
|
||||
- name: Set version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "$GITHUB_REF" == refs/tags/v* ]]; then
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
else
|
||||
VERSION="${GITHUB_SHA:0:7}"
|
||||
fi
|
||||
echo "Setting version to ${VERSION}"
|
||||
npm pkg set version="${VERSION}"
|
||||
|
||||
- name: Build package
|
||||
env:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-x64
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: netcatty-linux-x64
|
||||
path: |
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
# Dedicated job for Linux ARM64 — builds inside Debian Bullseye (GLIBC 2.31)
|
||||
@@ -97,7 +155,7 @@ jobs:
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y curl build-essential python3 git libfuse2 file rpm
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | bash -
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | bash -
|
||||
apt-get install -y nodejs
|
||||
|
||||
- name: Checkout
|
||||
@@ -131,12 +189,14 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
release:
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build, build-linux-arm64]
|
||||
needs: [build, build-linux-x64, build-linux-arm64]
|
||||
if: startsWith(github.ref, 'refs/tags/') || (github.event_name == 'workflow_dispatch' && inputs.publish_release)
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -166,10 +226,13 @@ jobs:
|
||||
body_path: release_notes.md
|
||||
files: |
|
||||
artifacts/*.dmg
|
||||
artifacts/*.zip
|
||||
artifacts/*.exe
|
||||
artifacts/*.AppImage
|
||||
artifacts/*.deb
|
||||
artifacts/*.rpm
|
||||
artifacts/*.yml
|
||||
artifacts/*.blockmap
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: false
|
||||
token: ${{ secrets.RELEASE_TOKEN }}
|
||||
|
||||
3
.gitignore
vendored
@@ -17,7 +17,8 @@ dist-ssr
|
||||
*.tsbuildinfo
|
||||
coverage
|
||||
/.vite
|
||||
/build
|
||||
/build/*
|
||||
!/build/icons
|
||||
/electron/native/**/build
|
||||
/release
|
||||
/out
|
||||
|
||||
108
App.tsx
@@ -14,6 +14,7 @@ import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { applySyncPayload } from './domain/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
@@ -167,8 +168,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
const [passphraseQueue, setPassphraseQueue] = useState<PassphraseRequest[]>([]);
|
||||
|
||||
const {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
setTerminalThemeId,
|
||||
currentTerminalTheme,
|
||||
terminalFontFamilyId,
|
||||
@@ -285,17 +286,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
onApplyPayload: (payload) => {
|
||||
importDataFromString(JSON.stringify({
|
||||
hosts: payload.hosts,
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
}));
|
||||
|
||||
if (payload.portForwardingRules) {
|
||||
importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -310,7 +304,10 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [handleSyncNow]);
|
||||
|
||||
// Update check hook - checks for new versions on startup
|
||||
const { updateState, openReleasePage, dismissUpdate } = useUpdateCheck();
|
||||
const { updateState, dismissUpdate } = useUpdateCheck();
|
||||
|
||||
// Window controls - must be before update toast effect which uses openSettingsWindow
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
|
||||
// Show toast notification when update is available
|
||||
useEffect(() => {
|
||||
@@ -322,14 +319,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
title: t('update.available.title'),
|
||||
duration: 8000, // Show longer for update notifications
|
||||
onClick: () => {
|
||||
openReleasePage();
|
||||
void openSettingsWindow();
|
||||
dismissUpdate();
|
||||
},
|
||||
actionLabel: t('update.downloadNow'),
|
||||
actionLabel: t('update.viewInSettings'),
|
||||
}
|
||||
);
|
||||
}
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
|
||||
}, [updateState.hasUpdate, updateState.latestRelease, t, openSettingsWindow, dismissUpdate]);
|
||||
|
||||
// Memoize keys for port forwarding to prevent unnecessary re-renders
|
||||
const portForwardingKeys = useMemo(
|
||||
@@ -440,16 +437,40 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { username, hostname: localHost } = systemInfoRef.current;
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
username,
|
||||
protocol: 'serial',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
username: host.username,
|
||||
localHostname: "",
|
||||
username: resolvedAuth.username || 'root',
|
||||
protocol: protocol as 'ssh' | 'telnet' | 'local' | 'mosh',
|
||||
startTime: Date.now(),
|
||||
localUsername: username,
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
|
||||
connectToHost(host);
|
||||
};
|
||||
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -461,7 +482,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
unsubscribeJump?.();
|
||||
unsubscribeConnect?.();
|
||||
};
|
||||
}, [addConnectionLog, connectToHost, hosts, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
}, [addConnectionLog, connectToHost, hosts, identities, keys, sessions, setActiveTabId, setWorkspaceFocusedSession, t]);
|
||||
|
||||
// Keyboard-interactive authentication (2FA/MFA) event listener
|
||||
useEffect(() => {
|
||||
@@ -895,7 +916,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Wrapper to create local terminal with logging
|
||||
const handleCreateLocalTerminal = useCallback(() => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const sessionId = createLocalTerminal();
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: 'Local Terminal',
|
||||
hostname: 'localhost',
|
||||
@@ -906,7 +929,6 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
createLocalTerminal();
|
||||
}, [addConnectionLog, createLocalTerminal]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
@@ -916,7 +938,9 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
// Handle serial hosts separately
|
||||
if (host.protocol === 'serial') {
|
||||
const portName = host.hostname.split('/').pop() || host.hostname;
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label || `Serial: ${portName}`,
|
||||
hostname: host.hostname,
|
||||
@@ -927,13 +951,14 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
connectToHost(host);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocol = host.moshEnabled ? 'mosh' : (host.protocol || 'ssh');
|
||||
const resolvedAuth = resolveHostAuth({ host, keys, identities });
|
||||
const sessionId = connectToHost(host);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: host.id,
|
||||
hostLabel: host.label,
|
||||
hostname: host.hostname,
|
||||
@@ -944,14 +969,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: localHost,
|
||||
saved: false,
|
||||
});
|
||||
connectToHost(host);
|
||||
}, [addConnectionLog, connectToHost, identities, keys]);
|
||||
|
||||
// Wrapper to create serial session with logging
|
||||
const handleConnectSerial = useCallback((config: SerialConfig) => {
|
||||
const { username, hostname } = systemInfoRef.current;
|
||||
const portName = config.path.split('/').pop() || config.path;
|
||||
const sessionId = createSerialSession(config);
|
||||
addConnectionLog({
|
||||
sessionId,
|
||||
hostId: '',
|
||||
hostLabel: `Serial: ${portName}`,
|
||||
hostname: config.path,
|
||||
@@ -962,32 +988,23 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
localHostname: hostname,
|
||||
saved: false,
|
||||
});
|
||||
createSerialSession(config);
|
||||
}, [addConnectionLog, createSerialSession]);
|
||||
|
||||
// Handle terminal data capture when session exits
|
||||
const handleTerminalDataCapture = useCallback((sessionId: string, data: string) => {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Called', { sessionId, dataLength: data.length });
|
||||
// Find the connection log for this session
|
||||
const session = sessions.find(s => s.id === sessionId);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Session', session);
|
||||
if (!session) {
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] No session found');
|
||||
return;
|
||||
}
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, sessionId: l.sessionId, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Looking for logs with hostname:', session.hostname);
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] All logs:', connectionLogs.map(l => ({ id: l.id, hostname: l.hostname, endTime: l.endTime, hasTerminalData: !!l.terminalData })));
|
||||
|
||||
// Find the most recent log matching this session's hostname and doesn't have terminalData yet
|
||||
// For local terminal, hostname is 'localhost'
|
||||
// Sort by startTime descending to find the most recent matching log
|
||||
// Prefer the persisted sessionId because the session may already have been
|
||||
// removed from state by the time the terminal unmount cleanup runs.
|
||||
const matchingLog = connectionLogs
|
||||
.filter(log =>
|
||||
log.hostname === session.hostname &&
|
||||
!log.endTime &&
|
||||
!log.terminalData
|
||||
)
|
||||
.filter((log) => {
|
||||
if (log.endTime || log.terminalData) return false;
|
||||
if (log.sessionId) return log.sessionId === sessionId;
|
||||
return !!session && log.hostname === session.hostname;
|
||||
})
|
||||
.sort((a, b) => b.startTime - a.startTime)[0];
|
||||
|
||||
if (IS_DEV) console.log('[handleTerminalDataCapture] Matching log', matchingLog);
|
||||
@@ -1067,14 +1084,15 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
}, [protocolSelectHost, handleConnectToHost]);
|
||||
|
||||
const handleToggleTheme = useCallback(() => {
|
||||
setTheme(prev => prev === 'dark' ? 'light' : 'dark');
|
||||
}, [setTheme]);
|
||||
// Toggle based on the actual rendered theme so clicking always produces a visible change,
|
||||
// even when the stored preference is 'system'.
|
||||
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
|
||||
}, [resolvedTheme, setTheme]);
|
||||
|
||||
const handleOpenQuickSwitcher = useCallback(() => {
|
||||
setIsQuickSwitcherOpen(true);
|
||||
}, []);
|
||||
|
||||
const { openSettingsWindow } = useWindowControls();
|
||||
|
||||
const handleOpenSettings = useCallback(() => {
|
||||
void (async () => {
|
||||
@@ -1142,7 +1160,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return (
|
||||
<div className="flex flex-col h-screen text-foreground font-sans netcatty-shell" onContextMenu={handleRootContextMenu}>
|
||||
<TopTabs
|
||||
theme={theme}
|
||||
theme={resolvedTheme}
|
||||
sessions={sessions}
|
||||
orphanSessions={orphanSessions}
|
||||
workspaces={workspaces}
|
||||
|
||||
@@ -215,11 +215,7 @@ Netcatty は接続したホストの OS を検出し、ホスト一覧でアイ
|
||||
|
||||
または [GitHub Releases](https://github.com/binaricat/Netcatty/releases) ですべてのリリースを参照してください。
|
||||
|
||||
> **⚠️ macOS ユーザーへ:** アプリはコード署名されていないため、macOS Gatekeeper によってブロックされます。ダウンロード後、以下のコマンドを実行して隔離属性を削除してください:
|
||||
> ```bash
|
||||
> xattr -cr /Applications/Netcatty.app
|
||||
> ```
|
||||
> または、アプリを右クリック → 開く → ダイアログで「開く」をクリックしてください。
|
||||
> **macOS ユーザーへ:** 現在のリリースはコード署名と notarization が行われている想定です。Gatekeeper の警告が出る場合は、GitHub Releases から最新版の公式ビルドを取得しているか確認してください。
|
||||
|
||||
### 前提条件
|
||||
- Node.js 18+ と npm
|
||||
|
||||
@@ -214,11 +214,7 @@ Download the latest release for your platform from [GitHub Releases](https://git
|
||||
|
||||
Or browse all releases at [GitHub Releases](https://github.com/binaricat/Netcatty/releases).
|
||||
|
||||
> **⚠️ macOS Users:** Since the app is not code-signed, macOS Gatekeeper will block it. After downloading, run this command to remove the quarantine attribute:
|
||||
> ```bash
|
||||
> xattr -cr /Applications/Netcatty.app
|
||||
> ```
|
||||
> Or right-click the app → Open → Click "Open" in the dialog.
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
### Prerequisites
|
||||
- Node.js 18+ and npm
|
||||
|
||||
@@ -214,11 +214,7 @@ Netcatty 会自动识别并在主机列表中展示对应的系统图标:
|
||||
|
||||
或在 [GitHub Releases](https://github.com/binaricat/Netcatty/releases) 浏览所有版本。
|
||||
|
||||
> **⚠️ macOS 用户注意:** 由于应用未经代码签名,macOS Gatekeeper 会阻止运行。下载后,请在终端运行以下命令移除隔离属性:
|
||||
> ```bash
|
||||
> xattr -cr /Applications/Netcatty.app
|
||||
> ```
|
||||
> 或者右键点击应用 → 打开 → 在弹出的对话框中点击"打开"。
|
||||
> **macOS 用户注意:** 当前发布版本应已完成代码签名和公证。如果 Gatekeeper 仍然提示风险,请确认您下载的是 GitHub Releases 中的最新官方构建。
|
||||
|
||||
### 前置条件
|
||||
- Node.js 18+ 和 npm
|
||||
|
||||
@@ -93,6 +93,23 @@ const en: Messages = {
|
||||
'settings.system.credentials.unavailableHint': 'Credentials encrypted on another user profile or machine cannot be decrypted here. Re-enter and save credentials on this device.',
|
||||
'settings.system.credentials.portabilityHint': 'Cloud Sync is portable because it uses your master key encryption. Local safeStorage encryption is device/user scoped.',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': 'Software Update',
|
||||
'settings.update.currentVersion': 'Current version',
|
||||
'settings.update.checkForUpdates': 'Check for Updates',
|
||||
'settings.update.checking': 'Checking...',
|
||||
'settings.update.upToDate': 'You are using the latest version.',
|
||||
'settings.update.available': 'New version {version} is available.',
|
||||
'settings.update.download': 'Download Update',
|
||||
'settings.update.downloading': 'Downloading... {percent}%',
|
||||
'settings.update.readyToInstall': 'Update downloaded and ready to install.',
|
||||
'settings.update.restartNow': 'Restart to Update',
|
||||
'settings.update.error': 'Failed to check for updates.',
|
||||
'settings.update.downloadError': 'Download failed.',
|
||||
'settings.update.manualDownload': 'Download from GitHub',
|
||||
'settings.update.manualDownloadHint': 'Auto-update is not available on this platform. Download the latest version from GitHub.',
|
||||
'settings.update.hint': 'Netcatty checks for updates from GitHub Releases.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
'settings.sessionLogs.description': 'Configure session log export and auto-save settings.',
|
||||
@@ -159,13 +176,17 @@ const en: Messages = {
|
||||
'update.upToDate.message': 'You are running the latest version ({version}).',
|
||||
'update.error': 'Failed to check for updates',
|
||||
'update.downloadNow': 'Download Now',
|
||||
'update.viewInSettings': 'View in Settings',
|
||||
'update.remindLater': 'Remind Later',
|
||||
'update.skipVersion': 'Skip This Version',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': 'UI Theme',
|
||||
'settings.appearance.darkMode': 'Dark Mode',
|
||||
'settings.appearance.darkMode.desc': 'Toggle between light and dark theme',
|
||||
'settings.appearance.theme': 'Theme',
|
||||
'settings.appearance.theme.desc': 'Choose light, dark, or follow system preference',
|
||||
'settings.appearance.theme.light': 'Light',
|
||||
'settings.appearance.theme.dark': 'Dark',
|
||||
'settings.appearance.theme.system': 'System',
|
||||
'settings.appearance.accentColor': 'Accent Color',
|
||||
'settings.appearance.customColor': 'Custom color',
|
||||
'settings.appearance.accentColor.mode': 'Use custom accent',
|
||||
@@ -226,7 +247,7 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.rightClick.paste': 'Paste',
|
||||
'settings.terminal.behavior.rightClick.selectWord': 'Select word',
|
||||
'settings.terminal.behavior.copyOnSelect': 'Copy on select',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': 'Automatically copy selected text. In tmux/vim with mouse mode, hold Shift to select',
|
||||
'settings.terminal.behavior.middleClickPaste': 'Middle-click paste',
|
||||
'settings.terminal.behavior.middleClickPaste.desc':
|
||||
'Paste clipboard content on middle-click',
|
||||
@@ -707,6 +728,7 @@ const en: Messages = {
|
||||
'sftp.upload.currentFile': 'Current: {fileName}',
|
||||
'sftp.upload.cancelled': 'Upload cancelled',
|
||||
'sftp.upload.cancel': 'Cancel',
|
||||
'sftp.upload.completedToPath': 'Uploaded to {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': 'Downloaded',
|
||||
@@ -723,9 +745,9 @@ const en: Messages = {
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.',
|
||||
'settings.sftp.showHiddenFiles.desc': 'Display hidden files (dotfiles on Unix/macOS and files with the hidden attribute on Windows) in the SFTP file browser.',
|
||||
'settings.sftp.showHiddenFiles.enable': 'Show hidden files',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display Windows hidden files when browsing local filesystem',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': 'Display hidden files when browsing both local and remote filesystems',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': 'Folder Compression Transfer',
|
||||
@@ -926,6 +948,9 @@ const en: Messages = {
|
||||
'terminal.composeBar.broadcasting': 'Broadcasting to all sessions',
|
||||
'terminal.toolbar.focus': 'Focus',
|
||||
'terminal.toolbar.focusMode': 'Focus Mode',
|
||||
'terminal.toolbar.encoding': 'Terminal Encoding',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': 'Close session',
|
||||
'terminal.toolbar.hostHighlight.title': 'Host Keyword Highlighting',
|
||||
'terminal.toolbar.hostHighlight.noRules': 'No custom highlight rules defined for this host',
|
||||
|
||||
@@ -77,6 +77,23 @@ const zhCN: Messages = {
|
||||
'settings.system.credentials.unavailableHint': '在其他用户或机器上加密的凭据无法在此处解密。请在当前设备重新输入并保存凭据。',
|
||||
'settings.system.credentials.portabilityHint': '云同步可跨设备,因为使用主密钥加密;本地 safeStorage 加密仅绑定当前系统用户/设备。',
|
||||
|
||||
// Settings > System > Software Update
|
||||
'settings.update.title': '软件更新',
|
||||
'settings.update.currentVersion': '当前版本',
|
||||
'settings.update.checkForUpdates': '检查更新',
|
||||
'settings.update.checking': '检查中...',
|
||||
'settings.update.upToDate': '当前已是最新版本。',
|
||||
'settings.update.available': '新版本 {version} 已发布。',
|
||||
'settings.update.download': '下载更新',
|
||||
'settings.update.downloading': '正在下载... {percent}%',
|
||||
'settings.update.readyToInstall': '更新已下载,准备安装。',
|
||||
'settings.update.restartNow': '重启并更新',
|
||||
'settings.update.error': '检查更新失败。',
|
||||
'settings.update.downloadError': '下载失败。',
|
||||
'settings.update.manualDownload': '前往 GitHub 下载',
|
||||
'settings.update.manualDownloadHint': '当前平台不支持自动更新,请前往 GitHub 下载最新版本。',
|
||||
'settings.update.hint': 'Netcatty 从 GitHub Releases 检查更新。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
'settings.sessionLogs.description': '配置会话日志导出和自动保存设置。',
|
||||
@@ -143,13 +160,17 @@ const zhCN: Messages = {
|
||||
'update.upToDate.message': '当前版本 ({version}) 已是最新。',
|
||||
'update.error': '检查更新失败',
|
||||
'update.downloadNow': '立即下载',
|
||||
'update.viewInSettings': '在设置中查看',
|
||||
'update.remindLater': '稍后提醒',
|
||||
'update.skipVersion': '跳过此版本',
|
||||
|
||||
// Settings > Appearance
|
||||
'settings.appearance.uiTheme': '界面主题',
|
||||
'settings.appearance.darkMode': '深色模式',
|
||||
'settings.appearance.darkMode.desc': '在浅色与深色主题之间切换',
|
||||
'settings.appearance.theme': '主题',
|
||||
'settings.appearance.theme.desc': '选择浅色、深色或跟随系统设置',
|
||||
'settings.appearance.theme.light': '浅色',
|
||||
'settings.appearance.theme.dark': '深色',
|
||||
'settings.appearance.theme.system': '系统',
|
||||
'settings.appearance.accentColor': '强调色',
|
||||
'settings.appearance.customColor': '自定义颜色',
|
||||
'settings.appearance.accentColor.mode': '使用自定义强调色',
|
||||
@@ -608,6 +629,9 @@ const zhCN: Messages = {
|
||||
'terminal.composeBar.broadcasting': '正在广播到所有会话',
|
||||
'terminal.toolbar.focus': '聚焦',
|
||||
'terminal.toolbar.focusMode': '聚焦模式',
|
||||
'terminal.toolbar.encoding': '终端编码',
|
||||
'terminal.toolbar.encoding.utf8': 'UTF-8',
|
||||
'terminal.toolbar.encoding.gb18030': 'GB18030',
|
||||
'terminal.toolbar.closeSession': '关闭会话',
|
||||
'terminal.toolbar.hostHighlight.title': '主机关键字高亮',
|
||||
'terminal.toolbar.hostHighlight.noRules': '此主机未定义自定义高亮规则',
|
||||
@@ -1029,6 +1053,7 @@ const zhCN: Messages = {
|
||||
'sftp.upload.currentFile': '当前: {fileName}',
|
||||
'sftp.upload.cancelled': '上传已取消',
|
||||
'sftp.upload.cancel': '取消',
|
||||
'sftp.upload.completedToPath': '已上传至 {path}',
|
||||
|
||||
// SFTP Download
|
||||
'sftp.download.completed': '已下载',
|
||||
@@ -1045,9 +1070,9 @@ const zhCN: Messages = {
|
||||
|
||||
// Settings > SFTP Show Hidden Files
|
||||
'settings.sftp.showHiddenFiles': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。',
|
||||
'settings.sftp.showHiddenFiles.desc': '在 SFTP 文件浏览器中显示隐藏文件(Unix/macOS 点文件和 Windows 隐藏属性文件)。',
|
||||
'settings.sftp.showHiddenFiles.enable': '显示隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地文件系统时显示 Windows 隐藏文件',
|
||||
'settings.sftp.showHiddenFiles.enableDesc': '浏览本地和远程文件系统时显示隐藏文件',
|
||||
|
||||
// Settings > SFTP Compressed Upload
|
||||
'settings.sftp.compressedUpload': '文件夹压缩传输',
|
||||
@@ -1094,7 +1119,7 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.rightClick.paste': '粘贴',
|
||||
'settings.terminal.behavior.rightClick.selectWord': '选择单词',
|
||||
'settings.terminal.behavior.copyOnSelect': '选择即复制',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本',
|
||||
'settings.terminal.behavior.copyOnSelect.desc': '自动复制选中的文本。在 tmux/vim 鼠标模式下,按住 Shift 拖选即可选中文本',
|
||||
'settings.terminal.behavior.middleClickPaste': '中键粘贴',
|
||||
'settings.terminal.behavior.middleClickPaste.desc': '中键点击时粘贴剪贴板内容',
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
|
||||
@@ -22,7 +22,6 @@ type Listener = () => void;
|
||||
class CustomThemeStore {
|
||||
private themes: TerminalTheme[] = [];
|
||||
private listeners = new Set<Listener>();
|
||||
private loaded = false;
|
||||
/** Cached merged array for stable useSyncExternalStore snapshots */
|
||||
private cachedAllThemes: TerminalTheme[] | null = null;
|
||||
|
||||
@@ -40,7 +39,6 @@ class CustomThemeStore {
|
||||
} catch {
|
||||
// ignore corrupt data
|
||||
}
|
||||
this.loaded = true;
|
||||
this.cachedAllThemes = null; // invalidate cache
|
||||
};
|
||||
|
||||
|
||||
@@ -4,31 +4,16 @@ export const isSessionError = (err: unknown): boolean => {
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset")
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if an error message indicates a fatal error that should stop the entire upload.
|
||||
* This includes session errors AND target directory deletion errors.
|
||||
*/
|
||||
export const isFatalUploadError = (errorMessage: string): boolean => {
|
||||
const msg = errorMessage.toLowerCase();
|
||||
return (
|
||||
// Session-related errors
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("connection") ||
|
||||
msg.includes("disconnected") ||
|
||||
// Target directory was deleted during upload
|
||||
msg.includes("no such file") ||
|
||||
msg.includes("enoent") ||
|
||||
msg.includes("does not exist") ||
|
||||
msg.includes("write stream error") ||
|
||||
// Directory was removed
|
||||
msg.includes("directory not found") ||
|
||||
msg.includes("not a directory")
|
||||
msg.includes("session lost") ||
|
||||
msg.includes("channel not ready") ||
|
||||
msg.includes("readdir is not a function") ||
|
||||
msg.includes("channel closed") ||
|
||||
msg.includes("connection closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("write after end") ||
|
||||
msg.includes("no response") ||
|
||||
msg.includes("not connected") ||
|
||||
msg.includes("client disconnected") ||
|
||||
msg.includes("timed out")
|
||||
);
|
||||
};
|
||||
|
||||
@@ -52,4 +52,5 @@ export interface FileWatchErrorEvent {
|
||||
export interface SftpStateOptions {
|
||||
onFileWatchSynced?: (event: FileWatchSyncedEvent) => void;
|
||||
onFileWatchError?: (event: FileWatchErrorEvent) => void;
|
||||
useCompressedUpload?: boolean;
|
||||
}
|
||||
|
||||
@@ -20,8 +20,10 @@ interface UseSftpExternalOperationsParams {
|
||||
getActivePane: (side: "left" | "right") => SftpPane | null;
|
||||
refresh: (side: "left" | "right") => Promise<void>;
|
||||
sftpSessionsRef: React.MutableRefObject<Map<string, string>>;
|
||||
useCompressedUpload?: boolean;
|
||||
addExternalUpload?: (task: TransferTask) => void;
|
||||
updateExternalUpload?: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
isTransferCancelled?: (taskId: string) => boolean;
|
||||
dismissExternalUpload?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
@@ -47,7 +49,16 @@ interface SftpExternalOperationsResult {
|
||||
export const useSftpExternalOperations = (
|
||||
params: UseSftpExternalOperationsParams
|
||||
): SftpExternalOperationsResult => {
|
||||
const { getActivePane, refresh, sftpSessionsRef, addExternalUpload, updateExternalUpload, dismissExternalUpload } = params;
|
||||
const {
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
useCompressedUpload = false,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
isTransferCancelled,
|
||||
dismissExternalUpload,
|
||||
} = params;
|
||||
|
||||
// Upload controller for cancellation support
|
||||
const uploadControllerRef = useRef<UploadController | null>(null);
|
||||
@@ -173,14 +184,113 @@ export const useSftpExternalOperations = (
|
||||
throw new Error("SFTP session not found");
|
||||
}
|
||||
|
||||
console.log("[SFTP] Downloading file to temp", { sftpId, remotePath, fileName });
|
||||
const localTempPath = await bridge.downloadSftpToTemp(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
console.log("[SFTP] File downloaded to temp", { localTempPath });
|
||||
let localTempPath: string;
|
||||
let wasCancelled = false;
|
||||
let externalTransferId: string | undefined;
|
||||
const isLocalTempDownloadCancelled = () =>
|
||||
!!externalTransferId && !!isTransferCancelled?.(externalTransferId);
|
||||
const cleanupTempDownload = async (filePath: string) => {
|
||||
if (!bridge.deleteTempFile) return;
|
||||
try {
|
||||
await bridge.deleteTempFile(filePath);
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to delete cancelled temp download:", err);
|
||||
}
|
||||
};
|
||||
|
||||
if (bridge.downloadSftpToTempWithProgress && addExternalUpload && updateExternalUpload) {
|
||||
externalTransferId = `download-temp-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
addExternalUpload({
|
||||
id: externalTransferId,
|
||||
fileName,
|
||||
sourcePath: remotePath,
|
||||
targetPath: "(temp)",
|
||||
sourceConnectionId: pane.connection.id,
|
||||
targetConnectionId: "local",
|
||||
direction: "download",
|
||||
status: "transferring" as TransferStatus,
|
||||
totalBytes: 0,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
isDirectory: false,
|
||||
retryable: false,
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await bridge.downloadSftpToTempWithProgress(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
externalTransferId,
|
||||
(transferred, total, speed) => {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
transferredBytes: transferred,
|
||||
totalBytes: total,
|
||||
speed,
|
||||
});
|
||||
},
|
||||
undefined,
|
||||
(error) => {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error,
|
||||
speed: 0,
|
||||
});
|
||||
},
|
||||
() => {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "cancelled" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
},
|
||||
);
|
||||
wasCancelled = result.cancelled;
|
||||
localTempPath = result.localPath;
|
||||
} catch (err) {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
speed: 0,
|
||||
});
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (wasCancelled) {
|
||||
if (localTempPath && bridge.deleteTempFile) {
|
||||
bridge.deleteTempFile(localTempPath).catch(() => {});
|
||||
}
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "completed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
} else {
|
||||
localTempPath = await bridge.downloadSftpToTemp(
|
||||
sftpId,
|
||||
remotePath,
|
||||
fileName,
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
}
|
||||
|
||||
if (isLocalTempDownloadCancelled()) {
|
||||
await cleanupTempDownload(localTempPath);
|
||||
return { localTempPath: "" };
|
||||
}
|
||||
|
||||
if (bridge.registerTempFile) {
|
||||
try {
|
||||
@@ -190,15 +300,23 @@ export const useSftpExternalOperations = (
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[SFTP] Opening with application", { localTempPath, appPath });
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
console.log("[SFTP] Application launched");
|
||||
try {
|
||||
await bridge.openWithApplication(localTempPath, appPath);
|
||||
} catch (err) {
|
||||
if (externalTransferId) {
|
||||
updateExternalUpload(externalTransferId, {
|
||||
status: "failed" as TransferStatus,
|
||||
endTime: Date.now(),
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
speed: 0,
|
||||
});
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
let watchId: string | undefined;
|
||||
console.log("[SFTP] Auto-sync enabled check", { enableWatch: options?.enableWatch, hasStartFileWatch: !!bridge.startFileWatch });
|
||||
if (options?.enableWatch && bridge.startFileWatch) {
|
||||
try {
|
||||
console.log("[SFTP] Starting file watch", { localTempPath, remotePath, sftpId });
|
||||
const result = await bridge.startFileWatch(
|
||||
localTempPath,
|
||||
remotePath,
|
||||
@@ -206,17 +324,14 @@ export const useSftpExternalOperations = (
|
||||
pane.filenameEncoding,
|
||||
);
|
||||
watchId = result.watchId;
|
||||
console.log("[SFTP] File watch started successfully", { watchId, localTempPath, remotePath });
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to start file watch:", err);
|
||||
}
|
||||
} else {
|
||||
console.log("[SFTP] File watching not enabled or not available");
|
||||
}
|
||||
|
||||
return { localTempPath, watchId };
|
||||
},
|
||||
[getActivePane, sftpSessionsRef],
|
||||
[getActivePane, sftpSessionsRef, addExternalUpload, updateExternalUpload, isTransferCancelled],
|
||||
);
|
||||
|
||||
// Create upload callbacks that translate to TransferTask updates
|
||||
@@ -402,6 +517,7 @@ export const useSftpExternalOperations = (
|
||||
bridge: createUploadBridge,
|
||||
joinPath,
|
||||
callbacks,
|
||||
useCompressedUpload,
|
||||
},
|
||||
controller
|
||||
);
|
||||
@@ -415,7 +531,14 @@ export const useSftpExternalOperations = (
|
||||
uploadControllerRef.current = null;
|
||||
}
|
||||
},
|
||||
[getActivePane, refresh, sftpSessionsRef, createUploadCallbacks, createUploadBridge],
|
||||
[
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
createUploadCallbacks,
|
||||
createUploadBridge,
|
||||
useCompressedUpload,
|
||||
],
|
||||
);
|
||||
|
||||
const cancelExternalUpload = useCallback(async () => {
|
||||
|
||||
@@ -39,6 +39,7 @@ interface UseSftpTransfersResult {
|
||||
addExternalUpload: (task: TransferTask) => void;
|
||||
updateExternalUpload: (taskId: string, updates: Partial<TransferTask>) => void;
|
||||
cancelTransfer: (transferId: string) => Promise<void>;
|
||||
isTransferCancelled: (transferId: string) => boolean;
|
||||
retryTransfer: (transferId: string) => Promise<void>;
|
||||
clearCompletedTransfers: () => void;
|
||||
dismissTransfer: (transferId: string) => void;
|
||||
@@ -123,6 +124,73 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearCancelledTask = useCallback((taskId: string) => {
|
||||
cancelledTasksRef.current.delete(taskId);
|
||||
}, []);
|
||||
|
||||
const isTransferCancelledError = useCallback(
|
||||
(error: unknown): boolean =>
|
||||
error instanceof Error && error.message === "Transfer cancelled",
|
||||
[],
|
||||
);
|
||||
|
||||
const getEntrySize = useCallback((entry: SftpFileEntry): number => {
|
||||
if (typeof entry.size === "string") {
|
||||
const parsed = parseInt(entry.size, 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
||||
}
|
||||
return typeof entry.size === "number" && entry.size > 0 ? entry.size : 0;
|
||||
}, []);
|
||||
|
||||
const estimateDirectoryBytes = useCallback(
|
||||
async (
|
||||
sourcePath: string,
|
||||
sourceSftpId: string | null,
|
||||
sourceIsLocal: boolean,
|
||||
sourceEncoding: SftpFilenameEncoding,
|
||||
rootTaskId: string,
|
||||
): Promise<number> => {
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
const files = sourceIsLocal
|
||||
? await listLocalFiles(sourcePath)
|
||||
: sourceSftpId
|
||||
? await listRemoteFiles(sourceSftpId, sourcePath, sourceEncoding)
|
||||
: null;
|
||||
|
||||
if (!files) {
|
||||
throw new Error("No source connection");
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
|
||||
for (const file of files) {
|
||||
if (file.name === "..") continue;
|
||||
|
||||
if (cancelledTasksRef.current.has(rootTaskId)) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
if (file.type === "directory") {
|
||||
totalBytes += await estimateDirectoryBytes(
|
||||
joinPath(sourcePath, file.name),
|
||||
sourceSftpId,
|
||||
sourceIsLocal,
|
||||
sourceEncoding,
|
||||
rootTaskId,
|
||||
);
|
||||
} else {
|
||||
totalBytes += getEntrySize(file);
|
||||
}
|
||||
}
|
||||
|
||||
return totalBytes;
|
||||
},
|
||||
[getEntrySize, listLocalFiles, listRemoteFiles],
|
||||
);
|
||||
|
||||
const transferFile = async (
|
||||
task: TransferTask,
|
||||
sourceSftpId: string | null,
|
||||
@@ -367,7 +435,27 @@ export const useSftpTransfers = ({
|
||||
: targetPane.filenameEncoding || "auto";
|
||||
|
||||
let actualFileSize = task.totalBytes;
|
||||
if (!task.isDirectory && actualFileSize === 0) {
|
||||
let prescanCancelled = false;
|
||||
if (task.isDirectory) {
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection!.id);
|
||||
|
||||
actualFileSize = await estimateDirectoryBytes(
|
||||
task.sourcePath,
|
||||
sourceSftpId,
|
||||
sourcePane.connection!.isLocal,
|
||||
sourceEncoding,
|
||||
task.id,
|
||||
);
|
||||
} catch (err) {
|
||||
if (isTransferCancelledError(err)) {
|
||||
prescanCancelled = true;
|
||||
}
|
||||
// Fall back to the existing estimate below if size discovery fails.
|
||||
}
|
||||
} else if (actualFileSize === 0) {
|
||||
try {
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
@@ -398,13 +486,6 @@ export const useSftpTransfers = ({
|
||||
|
||||
const hasStreamingTransfer = !!netcattyBridge.get()?.startStreamTransfer;
|
||||
|
||||
updateTask({
|
||||
status: "transferring",
|
||||
totalBytes: estimatedSize,
|
||||
transferredBytes: 0,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
const sourceSftpId = sourcePane.connection?.isLocal
|
||||
? null
|
||||
: sftpSessionsRef.current.get(sourcePane.connection!.id);
|
||||
@@ -424,12 +505,24 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
|
||||
let useSimulatedProgress = false;
|
||||
if (!hasStreamingTransfer && !task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
|
||||
try {
|
||||
if (prescanCancelled) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
|
||||
updateTask({
|
||||
status: "transferring",
|
||||
totalBytes: estimatedSize,
|
||||
transferredBytes: 0,
|
||||
startTime: Date.now(),
|
||||
});
|
||||
|
||||
if (!hasStreamingTransfer && !task.isDirectory) {
|
||||
useSimulatedProgress = true;
|
||||
startProgressSimulation(task.id, estimatedSize);
|
||||
}
|
||||
|
||||
if (!task.skipConflictCheck && !task.isDirectory && targetPane.connection) {
|
||||
let targetExists = false;
|
||||
let existingStat: { size: number; mtime: number } | null = null;
|
||||
@@ -520,10 +613,17 @@ export const useSftpTransfers = ({
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) => {
|
||||
if (t.id !== task.id || t.status === "cancelled") return t;
|
||||
const newTotal = Math.max(t.totalBytes, totalProgress, completedBytes + currentFileTotal);
|
||||
const newTotal = Math.max(
|
||||
t.totalBytes,
|
||||
totalProgress,
|
||||
completedBytes + currentFileTotal,
|
||||
);
|
||||
return {
|
||||
...t,
|
||||
transferredBytes: Math.max(t.transferredBytes, totalProgress),
|
||||
transferredBytes: Math.max(
|
||||
t.transferredBytes,
|
||||
Math.min(totalProgress, newTotal),
|
||||
),
|
||||
totalBytes: newTotal,
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : t.speed,
|
||||
};
|
||||
@@ -610,6 +710,7 @@ export const useSftpTransfers = ({
|
||||
completionHandlersRef.current.delete(task.id);
|
||||
}
|
||||
}
|
||||
clearCancelledTask(task.id);
|
||||
return "cancelled";
|
||||
}
|
||||
|
||||
@@ -768,10 +869,6 @@ export const useSftpTransfers = ({
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up cancelled task ID after a delay to ensure all async ops see it
|
||||
setTimeout(() => {
|
||||
cancelledTasksRef.current.delete(transferId);
|
||||
}, 5000);
|
||||
},
|
||||
[stopProgressSimulation],
|
||||
);
|
||||
@@ -779,7 +876,18 @@ export const useSftpTransfers = ({
|
||||
const retryTransfer = useCallback(
|
||||
async (transferId: string) => {
|
||||
const task = transfers.find((t) => t.id === transferId);
|
||||
if (!task) return;
|
||||
if (!task || task.retryable === false) return;
|
||||
|
||||
const retriedTask: TransferTask = {
|
||||
...task,
|
||||
id: crypto.randomUUID(),
|
||||
status: "pending" as TransferStatus,
|
||||
error: undefined,
|
||||
transferredBytes: 0,
|
||||
speed: 0,
|
||||
startTime: Date.now(),
|
||||
endTime: undefined,
|
||||
};
|
||||
|
||||
const sourceSide = task.sourceConnectionId.startsWith("left") ? "left" : "right";
|
||||
const targetSide = task.targetConnectionId.startsWith("left") ? "left" : "right";
|
||||
@@ -787,14 +895,20 @@ export const useSftpTransfers = ({
|
||||
const targetPane = getActivePane(targetSide as "left" | "right");
|
||||
|
||||
if (sourcePane?.connection && targetPane?.connection) {
|
||||
const completionHandler = completionHandlersRef.current.get(transferId);
|
||||
if (completionHandler) {
|
||||
completionHandlersRef.current.set(retriedTask.id, completionHandler);
|
||||
completionHandlersRef.current.delete(transferId);
|
||||
}
|
||||
|
||||
setTransfers((prev) =>
|
||||
prev.map((t) =>
|
||||
t.id === transferId
|
||||
? { ...t, status: "pending" as TransferStatus, error: undefined }
|
||||
? retriedTask
|
||||
: t,
|
||||
),
|
||||
);
|
||||
await processTransfer(task, sourcePane, targetPane, targetSide);
|
||||
await processTransfer(retriedTask, sourcePane, targetPane, targetSide);
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- processTransfer is defined inline
|
||||
@@ -811,6 +925,10 @@ export const useSftpTransfers = ({
|
||||
setTransfers((prev) => prev.filter((t) => t.id !== transferId));
|
||||
}, []);
|
||||
|
||||
const isTransferCancelled = useCallback((transferId: string) => {
|
||||
return cancelledTasksRef.current.has(transferId);
|
||||
}, []);
|
||||
|
||||
const addExternalUpload = useCallback((task: TransferTask) => {
|
||||
// Filter out any pending scanning tasks before adding the new task.
|
||||
// This ensures that even if dismissExternalUpload's state update hasn't been applied yet
|
||||
@@ -940,6 +1058,7 @@ export const useSftpTransfers = ({
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
isTransferCancelled,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
|
||||
@@ -16,6 +16,8 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import type { SyncPayload } from '../../domain/sync';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
|
||||
interface AutoSyncConfig {
|
||||
@@ -51,13 +53,30 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Build sync payload
|
||||
const buildPayload = useCallback((): SyncPayload => {
|
||||
// If port-forwarding hook state is still [] (async init in progress),
|
||||
// fall back to localStorage to avoid uploading an empty array that
|
||||
// overwrites the cloud snapshot.
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
effectivePFRules = stored.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
portForwardingRules: config.portForwardingRules,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: config.knownHosts,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
@@ -65,15 +84,32 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
|
||||
// Create a hash of current data for comparison
|
||||
const getDataHash = useCallback(() => {
|
||||
// Same fallback as buildPayload
|
||||
let effectivePFRules = config.portForwardingRules;
|
||||
if (!effectivePFRules || effectivePFRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<SyncPayload['portForwardingRules']>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
effectivePFRules = stored.map((rule) => ({
|
||||
...rule,
|
||||
status: 'inactive' as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
const data = {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
snippets: config.snippets,
|
||||
portForwardingRules: config.portForwardingRules,
|
||||
customGroups: config.customGroups,
|
||||
portForwardingRules: effectivePFRules,
|
||||
knownHosts: config.knownHosts,
|
||||
};
|
||||
return JSON.stringify(data);
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.portForwardingRules]);
|
||||
}, [config.hosts, config.keys, config.identities, config.snippets, config.customGroups, config.portForwardingRules, config.knownHosts]);
|
||||
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
const syncNow = useCallback(async (options?: SyncNowOptions) => {
|
||||
|
||||
@@ -135,8 +135,6 @@ export const useGlobalHotkeys = ({
|
||||
e.stopPropagation();
|
||||
|
||||
const currentActions = actionsRef.current;
|
||||
const _tabs = orderedTabsRef.current;
|
||||
|
||||
switch (action) {
|
||||
case 'switchToTab': {
|
||||
const num = parseInt(e.key, 10);
|
||||
|
||||
@@ -9,12 +9,25 @@ import { localStorageAdapter } from "../../infrastructure/persistence/localStora
|
||||
import {
|
||||
clearReconnectTimer,
|
||||
getActiveConnection,
|
||||
initReconnectCancelListener,
|
||||
reconcileWithBackend,
|
||||
startPortForward,
|
||||
stopAllPortForwards,
|
||||
stopAndCleanupRule,
|
||||
stopPortForward,
|
||||
syncWithBackend,
|
||||
} from "../../infrastructure/services/portForwardingService";
|
||||
import { useStoredViewMode, ViewMode } from "./useStoredViewMode";
|
||||
|
||||
// Module-level ref-counts: these side effects must run at most once per
|
||||
// window, not per hook instance (the hook mounts from both App.tsx
|
||||
// and PortForwardingNew.tsx). Ref-counting ensures the resources
|
||||
// stay alive as long as ANY instance is mounted.
|
||||
let reconnectCancelListenerRefs = 0;
|
||||
let reconnectCancelCleanup: (() => void) | undefined;
|
||||
let heartbeatRefs = 0;
|
||||
let heartbeatIntervalId: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
export type { ViewMode };
|
||||
|
||||
export type SortMode = "az" | "za" | "newest" | "oldest";
|
||||
@@ -177,6 +190,53 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
return () => window.removeEventListener("storage", handleStorageChange);
|
||||
}, []);
|
||||
|
||||
// Listen for cross-window reconnect cancellation events.
|
||||
// Ref-counted so the listener stays alive as long as ANY hook
|
||||
// instance is mounted (App.tsx outlives PortForwardingNew.tsx).
|
||||
useEffect(() => {
|
||||
reconnectCancelListenerRefs++;
|
||||
let cleanup: (() => void) | undefined;
|
||||
if (reconnectCancelListenerRefs === 1) {
|
||||
cleanup = initReconnectCancelListener();
|
||||
reconnectCancelCleanup = cleanup;
|
||||
}
|
||||
return () => {
|
||||
reconnectCancelListenerRefs--;
|
||||
if (reconnectCancelListenerRefs === 0 && reconnectCancelCleanup) {
|
||||
reconnectCancelCleanup();
|
||||
reconnectCancelCleanup = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Periodic heartbeat: reconcile renderer state with the backend every 4s.
|
||||
// Ref-counted — same pattern as the reconnect cancel listener.
|
||||
useEffect(() => {
|
||||
heartbeatRefs++;
|
||||
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||
if (heartbeatRefs === 1) {
|
||||
const HEARTBEAT_INTERVAL_MS = 4_000;
|
||||
|
||||
const tick = async () => {
|
||||
const { gone, appeared } = await reconcileWithBackend();
|
||||
if (gone.length === 0 && appeared.length === 0) return;
|
||||
|
||||
// Re-derive statuses from the now-updated activeConnections map
|
||||
setGlobalRules(normalizeRulesWithConnections(globalRules));
|
||||
};
|
||||
|
||||
intervalId = setInterval(tick, HEARTBEAT_INTERVAL_MS);
|
||||
heartbeatIntervalId = intervalId;
|
||||
}
|
||||
return () => {
|
||||
heartbeatRefs--;
|
||||
if (heartbeatRefs === 0 && heartbeatIntervalId !== undefined) {
|
||||
clearInterval(heartbeatIntervalId);
|
||||
heartbeatIntervalId = undefined;
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const addRule = useCallback(
|
||||
(
|
||||
rule: Omit<PortForwardingRule, "id" | "createdAt" | "status">,
|
||||
@@ -207,6 +267,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
|
||||
const deleteRule = useCallback(
|
||||
(id: string) => {
|
||||
// Stop any active tunnel before removing the rule
|
||||
stopAndCleanupRule(id);
|
||||
const updated = globalRules.filter((r) => r.id !== id);
|
||||
setGlobalRules(updated);
|
||||
if (selectedRuleId === id) {
|
||||
@@ -238,6 +300,60 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
|
||||
);
|
||||
|
||||
const importRules = useCallback((newRules: PortForwardingRule[]) => {
|
||||
// When clearing all rules (e.g. "Clear local data"), stop ALL tunnels
|
||||
// and broadcast per-rule reconnect cancellation. stopAllPortForwards
|
||||
// handles the backend, but we also need per-rule broadcasts so other
|
||||
// windows cancel their pending reconnect timers.
|
||||
if (newRules.length === 0) {
|
||||
// Read from localStorage since globalRules may be empty (uninitialized)
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
const rulesToCancel = globalRules.length > 0
|
||||
? globalRules
|
||||
: (storedRules && Array.isArray(storedRules) ? storedRules : []);
|
||||
for (const rule of rulesToCancel) {
|
||||
stopAndCleanupRule(rule.id);
|
||||
}
|
||||
// Safety net: also stop anything the renderer doesn't know about
|
||||
void stopAllPortForwards();
|
||||
}
|
||||
|
||||
// Stop tunnels for rules that are being removed or whose connection
|
||||
// config has changed (same ID but different host/port/type means the
|
||||
// old tunnel is pointing at stale parameters and must be torn down).
|
||||
//
|
||||
// Use globalRules as the diff baseline. In a freshly opened settings
|
||||
// window, globalRules may still be empty because initializeStore is
|
||||
// async. Fall back to reading directly from localStorage to avoid
|
||||
// missing tunnels that need to be stopped.
|
||||
let diffBaseline = globalRules;
|
||||
if (diffBaseline.length === 0 && newRules.length > 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
diffBaseline = stored;
|
||||
}
|
||||
}
|
||||
const newRulesById = new Map(newRules.map((r) => [r.id, r]));
|
||||
for (const existing of diffBaseline) {
|
||||
const incoming = newRulesById.get(existing.id);
|
||||
if (!incoming) {
|
||||
// Rule removed entirely
|
||||
stopAndCleanupRule(existing.id);
|
||||
} else if (
|
||||
existing.type !== incoming.type ||
|
||||
existing.localPort !== incoming.localPort ||
|
||||
existing.remoteHost !== incoming.remoteHost ||
|
||||
existing.remotePort !== incoming.remotePort ||
|
||||
existing.bindAddress !== incoming.bindAddress ||
|
||||
existing.hostId !== incoming.hostId
|
||||
) {
|
||||
// Connection-relevant config changed — tear down the old tunnel
|
||||
stopAndCleanupRule(existing.id);
|
||||
}
|
||||
}
|
||||
setGlobalRules(normalizeRulesWithConnections(newRules));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -49,9 +49,10 @@ export const useSessionState = () => {
|
||||
username: 'local',
|
||||
status: 'connecting',
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
}, [setActiveTabId]);
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const createSerialSession = useCallback((config: SerialConfig) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
@@ -69,6 +70,7 @@ export const useSessionState = () => {
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return sessionId;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const connectToHost = useCallback((host: Host) => {
|
||||
@@ -100,7 +102,7 @@ export const useSessionState = () => {
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(sessionId);
|
||||
return;
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
const newSession: TerminalSession = {
|
||||
@@ -115,9 +117,10 @@ export const useSessionState = () => {
|
||||
port: host.port,
|
||||
moshEnabled: host.moshEnabled,
|
||||
};
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
}, [setActiveTabId]);
|
||||
setSessions(prev => [...prev, newSession]);
|
||||
setActiveTabId(newSession.id);
|
||||
return newSession.id;
|
||||
}, [setActiveTabId]);
|
||||
|
||||
const updateSessionStatus = useCallback((sessionId: string, status: TerminalSession['status']) => {
|
||||
setSessions(prev => prev.map(s => s.id === sessionId ? { ...s, status } : s));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 { SyncConfig, TerminalSettings, HotkeyScheme, CustomKeyBindings, DEFAULT_KEY_BINDINGS, KeyBinding, UILanguage, SessionLogFormat, normalizeTerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_SYNC,
|
||||
@@ -39,7 +39,13 @@ import { useAvailableFonts } from './fontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
const DEFAULT_THEME: 'light' | 'dark' = 'light';
|
||||
const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'system';
|
||||
|
||||
/** Resolve the current OS color scheme preference. */
|
||||
const getSystemPreference = (): 'light' | 'dark' =>
|
||||
typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
const DEFAULT_LIGHT_UI_THEME = 'snow';
|
||||
const DEFAULT_DARK_UI_THEME = 'midnight';
|
||||
const DEFAULT_ACCENT_MODE: 'theme' | 'custom' = 'theme';
|
||||
@@ -77,7 +83,7 @@ const readStoredString = (key: string): string | null => {
|
||||
}
|
||||
};
|
||||
|
||||
const isValidTheme = (value: unknown): value is 'light' | 'dark' => value === 'light' || value === 'dark';
|
||||
const isValidTheme = (value: unknown): value is 'light' | 'dark' | 'system' => value === 'light' || value === 'dark' || value === 'system';
|
||||
|
||||
const isValidHslToken = (value: string): boolean => {
|
||||
// Expect: "<h> <s>% <l>%", e.g. "221.2 83.2% 53.3%"
|
||||
@@ -146,10 +152,14 @@ const applyThemeTokens = (
|
||||
export const useSettingsState = () => {
|
||||
const availableFonts = useAvailableFonts();
|
||||
const uiFontsLoaded = useUIFontsLoaded();
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||
const [theme, setTheme] = useState<'dark' | 'light' | 'system'>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_THEME);
|
||||
return stored && isValidTheme(stored) ? stored : DEFAULT_THEME;
|
||||
});
|
||||
// Track the OS color scheme preference (updated by matchMedia listener)
|
||||
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
|
||||
// resolvedTheme is always 'light' or 'dark' — derived synchronously from theme + OS preference
|
||||
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
|
||||
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
|
||||
return stored && isValidUiThemeId('light', stored) ? stored : DEFAULT_LIGHT_UI_THEME;
|
||||
@@ -182,7 +192,7 @@ export const useSettingsState = () => {
|
||||
});
|
||||
const [terminalSettings, setTerminalSettingsState] = useState<TerminalSettings>(() => {
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return stored ? { ...DEFAULT_TERMINAL_SETTINGS, ...stored } : DEFAULT_TERMINAL_SETTINGS;
|
||||
return normalizeTerminalSettings(stored);
|
||||
});
|
||||
const [hotkeyScheme, setHotkeyScheme] = useState<HotkeyScheme>(() => {
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_HOTKEY_SCHEME);
|
||||
@@ -260,9 +270,10 @@ export const useSettingsState = () => {
|
||||
|
||||
const setTerminalSettings = useCallback((nextValue: SetStateAction<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = typeof nextValue === 'function'
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: TerminalSettings) => TerminalSettings)(prev)
|
||||
: nextValue;
|
||||
const next = normalizeTerminalSettings(candidate);
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
@@ -273,7 +284,7 @@ export const useSettingsState = () => {
|
||||
|
||||
const mergeIncomingTerminalSettings = useCallback((incoming: Partial<TerminalSettings>) => {
|
||||
setTerminalSettingsState((prev) => {
|
||||
const next = { ...prev, ...incoming };
|
||||
const next = normalizeTerminalSettings({ ...prev, ...incoming });
|
||||
if (areTerminalSettingsEqual(prev, next)) {
|
||||
return prev;
|
||||
}
|
||||
@@ -310,8 +321,9 @@ export const useSettingsState = () => {
|
||||
setAccentMode(nextAccentMode);
|
||||
setCustomAccent(nextAccent);
|
||||
|
||||
const tokens = getUiThemeById(nextTheme, nextTheme === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
applyThemeTokens(nextTheme, tokens, nextAccentMode, nextAccent);
|
||||
const effective = nextTheme === 'system' ? getSystemPreference() : nextTheme;
|
||||
const tokens = getUiThemeById(effective, effective === 'dark' ? nextDarkId : nextLightId).tokens;
|
||||
applyThemeTokens(effective, tokens, nextAccentMode, nextAccent);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent]);
|
||||
|
||||
const syncCustomCssFromStorage = useCallback(() => {
|
||||
@@ -320,8 +332,8 @@ export const useSettingsState = () => {
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(theme, theme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, tokens, accentMode, customAccent);
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(resolvedTheme, tokens, accentMode, customAccent);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_THEME, theme);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, lightUiThemeId);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
@@ -333,7 +345,18 @@ export const useSettingsState = () => {
|
||||
notifySettingsChanged(STORAGE_KEY_UI_THEME_DARK, darkUiThemeId);
|
||||
notifySettingsChanged(STORAGE_KEY_ACCENT_MODE, accentMode);
|
||||
notifySettingsChanged(STORAGE_KEY_COLOR, customAccent);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
}, [theme, resolvedTheme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, notifySettingsChanged]);
|
||||
|
||||
// Listen for OS color scheme changes to keep systemPreference in sync
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return;
|
||||
const mql = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const handler = (e: MediaQueryListEvent) => {
|
||||
setSystemPreference(e.matches ? 'dark' : 'light');
|
||||
};
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, uiLanguage);
|
||||
@@ -404,6 +427,18 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_EDITOR_WORD_WRAP && typeof value === 'boolean') {
|
||||
setEditorWordWrapState((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_ENABLED && typeof value === 'boolean') {
|
||||
setSessionLogsEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_SESSION_LOGS_DIR && typeof value === 'string') {
|
||||
setSessionLogsDir((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (
|
||||
key === STORAGE_KEY_SESSION_LOGS_FORMAT &&
|
||||
(value === 'txt' || value === 'raw' || value === 'html')
|
||||
) {
|
||||
setSessionLogsFormat((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) {
|
||||
setHotkeyScheme(value);
|
||||
}
|
||||
@@ -510,7 +545,7 @@ export const useSettingsState = () => {
|
||||
if (e.key === STORAGE_KEY_TERM_SETTINGS && e.newValue) {
|
||||
try {
|
||||
const newSettings = JSON.parse(e.newValue) as TerminalSettings;
|
||||
mergeIncomingTerminalSettings({ ...DEFAULT_TERMINAL_SETTINGS, ...newSettings });
|
||||
mergeIncomingTerminalSettings(newSettings);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
@@ -560,6 +595,25 @@ export const useSettingsState = () => {
|
||||
setEditorWordWrapState(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== sessionLogsEnabled) {
|
||||
setSessionLogsEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_DIR && e.newValue !== null) {
|
||||
if (e.newValue !== sessionLogsDir) {
|
||||
setSessionLogsDir(e.newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_SESSION_LOGS_FORMAT && e.newValue) {
|
||||
if (
|
||||
(e.newValue === 'txt' || e.newValue === 'raw' || e.newValue === 'html') &&
|
||||
e.newValue !== sessionLogsFormat
|
||||
) {
|
||||
setSessionLogsFormat(e.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';
|
||||
@@ -571,7 +625,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, editorWordWrap, mergeIncomingTerminalSettings]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -792,6 +846,7 @@ export const useSettingsState = () => {
|
||||
return {
|
||||
theme,
|
||||
setTheme,
|
||||
resolvedTheme,
|
||||
lightUiThemeId,
|
||||
setLightUiThemeId,
|
||||
darkUiThemeId,
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
/**
|
||||
* useSftpFileOperations - Shared file operations for SFTP components
|
||||
*
|
||||
* This hook provides common file operations like open, edit, preview
|
||||
* that can be shared between SFTPModal and SftpView components.
|
||||
*/
|
||||
|
||||
import { useCallback, useState } from "react";
|
||||
import { getFileExtension, isTextFile, FileOpenerType } from "../../lib/sftpFileUtils";
|
||||
import { toast } from "../../components/ui/toast";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
import { useSftpFileAssociations } from "./useSftpFileAssociations";
|
||||
|
||||
export interface FileOperationsState {
|
||||
// Text editor state
|
||||
showTextEditor: boolean;
|
||||
textEditorTarget: { name: string; fullPath: string } | null;
|
||||
textEditorContent: string;
|
||||
loadingTextContent: boolean;
|
||||
|
||||
// File opener dialog state
|
||||
showFileOpenerDialog: boolean;
|
||||
fileOpenerTarget: { name: string; fullPath: string } | null;
|
||||
}
|
||||
|
||||
export interface FileOperationsActions {
|
||||
// Open file based on type/association
|
||||
openFile: (fileName: string, fullPath: string) => void;
|
||||
|
||||
// Edit text file
|
||||
editFile: (
|
||||
fileName: string,
|
||||
fullPath: string,
|
||||
readContent: () => Promise<string>
|
||||
) => Promise<void>;
|
||||
|
||||
// Save text file
|
||||
saveTextFile: (
|
||||
content: string,
|
||||
writeContent: (path: string, content: string) => Promise<void>
|
||||
) => Promise<void>;
|
||||
|
||||
// Handle file opener selection
|
||||
handleFileOpenerSelect: (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
readTextContent: () => Promise<string>,
|
||||
readImageData: () => Promise<ArrayBuffer>
|
||||
) => Promise<void>;
|
||||
|
||||
// Close modals
|
||||
closeTextEditor: () => void;
|
||||
closeFileOpenerDialog: () => void;
|
||||
|
||||
// Check if file can be edited
|
||||
canEditFile: (fileName: string) => boolean;
|
||||
}
|
||||
|
||||
export interface UseSftpFileOperationsResult {
|
||||
state: FileOperationsState;
|
||||
actions: FileOperationsActions;
|
||||
}
|
||||
|
||||
export function useSftpFileOperations(): UseSftpFileOperationsResult {
|
||||
const { t } = useI18n();
|
||||
const { getOpenerForFile, setOpenerForExtension } = useSftpFileAssociations();
|
||||
|
||||
// Text editor state
|
||||
const [showTextEditor, setShowTextEditor] = useState(false);
|
||||
const [textEditorTarget, setTextEditorTarget] = useState<{ name: string; fullPath: string } | null>(null);
|
||||
const [textEditorContent, setTextEditorContent] = useState("");
|
||||
const [loadingTextContent, setLoadingTextContent] = useState(false);
|
||||
|
||||
// File opener dialog state
|
||||
const [showFileOpenerDialog, setShowFileOpenerDialog] = useState(false);
|
||||
const [fileOpenerTarget, setFileOpenerTarget] = useState<{ name: string; fullPath: string } | null>(null);
|
||||
|
||||
const canEditFile = useCallback((fileName: string) => {
|
||||
return isTextFile(fileName);
|
||||
}, []);
|
||||
|
||||
const closeTextEditor = useCallback(() => {
|
||||
setShowTextEditor(false);
|
||||
setTextEditorTarget(null);
|
||||
setTextEditorContent("");
|
||||
}, []);
|
||||
|
||||
const closeFileOpenerDialog = useCallback(() => {
|
||||
setShowFileOpenerDialog(false);
|
||||
setFileOpenerTarget(null);
|
||||
}, []);
|
||||
|
||||
const editFile = useCallback(async (
|
||||
fileName: string,
|
||||
fullPath: string,
|
||||
readContent: () => Promise<string>
|
||||
) => {
|
||||
try {
|
||||
setLoadingTextContent(true);
|
||||
setTextEditorTarget({ name: fileName, fullPath });
|
||||
const content = await readContent();
|
||||
setTextEditorContent(content);
|
||||
setShowTextEditor(true);
|
||||
} catch (e) {
|
||||
toast.error(
|
||||
e instanceof Error ? e.message : t("sftp.error.loadFailed"),
|
||||
"SFTP",
|
||||
);
|
||||
} finally {
|
||||
setLoadingTextContent(false);
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
const saveTextFile = useCallback(async (
|
||||
content: string,
|
||||
writeContent: (path: string, content: string) => Promise<void>
|
||||
) => {
|
||||
if (!textEditorTarget) return;
|
||||
await writeContent(textEditorTarget.fullPath, content);
|
||||
}, [textEditorTarget]);
|
||||
|
||||
const openFile = useCallback((fileName: string, fullPath: string) => {
|
||||
const savedOpener = getOpenerForFile(fileName);
|
||||
|
||||
if (savedOpener) {
|
||||
// User has saved an opener for this file type
|
||||
// We'll just set the target and let the caller handle it
|
||||
setFileOpenerTarget({ name: fileName, fullPath });
|
||||
|
||||
// Return the opener type so caller knows which operation to perform
|
||||
if (savedOpener === 'builtin-editor' && canEditFile(fileName)) {
|
||||
// Don't show dialog, caller should call editFile
|
||||
return 'edit' as const;
|
||||
}
|
||||
}
|
||||
|
||||
// No saved opener, show the dialog
|
||||
setFileOpenerTarget({ name: fileName, fullPath });
|
||||
setShowFileOpenerDialog(true);
|
||||
return 'dialog' as const;
|
||||
}, [getOpenerForFile, canEditFile]);
|
||||
|
||||
const handleFileOpenerSelect = useCallback(async (
|
||||
openerType: FileOpenerType,
|
||||
setAsDefault: boolean,
|
||||
readTextContent: () => Promise<string>,
|
||||
_readImageData: () => Promise<ArrayBuffer>
|
||||
) => {
|
||||
if (!fileOpenerTarget) return;
|
||||
|
||||
if (setAsDefault) {
|
||||
const ext = getFileExtension(fileOpenerTarget.name);
|
||||
if (ext !== 'file') {
|
||||
setOpenerForExtension(ext, openerType);
|
||||
}
|
||||
}
|
||||
|
||||
setShowFileOpenerDialog(false);
|
||||
|
||||
if (openerType === 'builtin-editor') {
|
||||
await editFile(fileOpenerTarget.name, fileOpenerTarget.fullPath, readTextContent);
|
||||
}
|
||||
}, [fileOpenerTarget, setOpenerForExtension, editFile]);
|
||||
|
||||
return {
|
||||
state: {
|
||||
showTextEditor,
|
||||
textEditorTarget,
|
||||
textEditorContent,
|
||||
loadingTextContent,
|
||||
showFileOpenerDialog,
|
||||
fileOpenerTarget,
|
||||
},
|
||||
actions: {
|
||||
openFile,
|
||||
editFile,
|
||||
saveTextFile,
|
||||
handleFileOpenerSelect,
|
||||
closeTextEditor,
|
||||
closeFileOpenerDialog,
|
||||
canEditFile,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -213,6 +213,7 @@ export const useSftpState = (
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
cancelTransfer,
|
||||
isTransferCancelled,
|
||||
retryTransfer,
|
||||
clearCompletedTransfers,
|
||||
dismissTransfer,
|
||||
@@ -238,8 +239,10 @@ export const useSftpState = (
|
||||
getActivePane,
|
||||
refresh,
|
||||
sftpSessionsRef,
|
||||
useCompressedUpload: options?.useCompressedUpload,
|
||||
addExternalUpload,
|
||||
updateExternalUpload,
|
||||
isTransferCancelled,
|
||||
dismissExternalUpload: dismissTransfer,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { loadFromGist, syncToGist } from "../../infrastructure/services/syncService";
|
||||
|
||||
export type SyncStatus = "idle" | "success" | "error";
|
||||
|
||||
export const useSyncState = () => {
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<SyncStatus>("idle");
|
||||
|
||||
const resetSyncStatus = useCallback(() => {
|
||||
setSyncStatus("idle");
|
||||
}, []);
|
||||
|
||||
const verify = useCallback(async (token: string, gistId?: string) => {
|
||||
setIsSyncing(true);
|
||||
setSyncStatus("idle");
|
||||
try {
|
||||
if (gistId) {
|
||||
await loadFromGist(token, gistId);
|
||||
}
|
||||
setSyncStatus("success");
|
||||
} catch (err) {
|
||||
setSyncStatus("error");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const upload = useCallback(
|
||||
async (
|
||||
token: string,
|
||||
gistId: string | undefined,
|
||||
data: Parameters<typeof syncToGist>[2],
|
||||
) => {
|
||||
setIsSyncing(true);
|
||||
setSyncStatus("idle");
|
||||
try {
|
||||
const newGistId = await syncToGist(token, gistId, data);
|
||||
setSyncStatus("success");
|
||||
return newGistId;
|
||||
} catch (err) {
|
||||
setSyncStatus("error");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const download = useCallback(async (token: string, gistId: string) => {
|
||||
setIsSyncing(true);
|
||||
setSyncStatus("idle");
|
||||
try {
|
||||
const data = await loadFromGist(token, gistId);
|
||||
setSyncStatus("success");
|
||||
return data;
|
||||
} catch (err) {
|
||||
setSyncStatus("error");
|
||||
throw err;
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { isSyncing, syncStatus, resetSyncStatus, verify, upload, download };
|
||||
};
|
||||
@@ -78,6 +78,12 @@ export const useTerminalBackend = () => {
|
||||
bridge?.closeSession?.(sessionId);
|
||||
}, []);
|
||||
|
||||
const setSessionEncoding = useCallback(async (sessionId: string, encoding: string) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.setSessionEncoding) return { ok: false, encoding };
|
||||
return bridge.setSessionEncoding(sessionId, encoding);
|
||||
}, []);
|
||||
|
||||
const onSessionData = useCallback((sessionId: string, cb: (data: string) => void) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.onSessionData) throw new Error("onSessionData unavailable");
|
||||
@@ -148,6 +154,7 @@ export const useTerminalBackend = () => {
|
||||
writeToSession,
|
||||
resizeSession,
|
||||
closeSession,
|
||||
setSessionEncoding,
|
||||
onSessionData,
|
||||
onSessionExit,
|
||||
onChainProgress,
|
||||
|
||||
BIN
build/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 4.5 KiB |
BIN
build/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 645 B |
BIN
build/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
build/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
build/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 1.7 KiB |
BIN
build/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
BIN
build/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,309 +0,0 @@
|
||||
import { ChevronDown, Eye, EyeOff, Key, Lock, User } from "lucide-react";
|
||||
import React, { useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
interface AuthDialogProps {
|
||||
host: Host;
|
||||
keys: SSHKey[];
|
||||
onSubmit: (auth: {
|
||||
username: string;
|
||||
authMethod: "password" | "key";
|
||||
password?: string;
|
||||
keyId?: string;
|
||||
saveCredentials: boolean;
|
||||
}) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const AuthDialog: React.FC<AuthDialogProps> = ({
|
||||
host,
|
||||
keys,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [username, setUsername] = useState(host.username || "root");
|
||||
const [authMethod, setAuthMethod] = useState<"password" | "key">("password");
|
||||
const [password, setPassword] = useState("");
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [saveCredentials, setSaveCredentials] = useState(true);
|
||||
const [isKeySelectOpen, setIsKeySelectOpen] = useState(false);
|
||||
|
||||
const _selectedKey = keys.find((k) => k.id === selectedKeyId);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
username,
|
||||
authMethod,
|
||||
password: authMethod === "password" ? password : undefined,
|
||||
keyId: authMethod === "key" ? (selectedKeyId ?? undefined) : undefined,
|
||||
saveCredentials,
|
||||
});
|
||||
};
|
||||
|
||||
const isValid =
|
||||
username.trim() &&
|
||||
((authMethod === "password" && password.trim()) ||
|
||||
(authMethod === "key" && selectedKeyId));
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="w-[420px] max-w-[90vw] bg-background border border-border/60 rounded-2xl shadow-2xl animate-in fade-in-0 zoom-in-95 duration-200">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-border/50">
|
||||
<div className="flex items-center gap-3">
|
||||
<DistroAvatar
|
||||
host={host}
|
||||
fallback={host.label.slice(0, 2).toUpperCase()}
|
||||
className="h-12 w-12"
|
||||
/>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">{host.label}</h2>
|
||||
<p className="text-xs text-muted-foreground font-mono">
|
||||
SSH {host.hostname}:{host.port || 22}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress indicator */}
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-8 w-8 rounded-full bg-primary text-primary-foreground flex items-center justify-center">
|
||||
<User size={14} />
|
||||
</div>
|
||||
<div className="flex-1 h-0.5 bg-muted" />
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full flex items-center justify-center transition-colors",
|
||||
username.trim()
|
||||
? "bg-primary/20 text-primary"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{authMethod === "password" ? (
|
||||
<Lock size={14} />
|
||||
) : (
|
||||
<Key size={14} />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 h-0.5 bg-muted" />
|
||||
<div className="h-8 w-8 rounded-full bg-muted text-muted-foreground flex items-center justify-center text-xs font-mono">
|
||||
{">_"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auth method tabs */}
|
||||
<div className="px-6">
|
||||
<div className="flex gap-1 p-1 bg-secondary/80 rounded-lg border border-border/60">
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
|
||||
authMethod === "password"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => setAuthMethod("password")}
|
||||
>
|
||||
<Lock size={14} />
|
||||
{t("terminal.auth.password")}
|
||||
</button>
|
||||
<button
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center gap-2 py-2 text-sm font-medium rounded-md transition-all",
|
||||
authMethod === "key"
|
||||
? "bg-primary text-primary-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => setAuthMethod("key")}
|
||||
>
|
||||
<Key size={14} />
|
||||
{t("terminal.auth.sshKey")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<div className="px-6 py-4 space-y-4">
|
||||
{/* Username field (shown when no username on host) */}
|
||||
{!host.username && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth-username">{t("terminal.auth.username")}</Label>
|
||||
<Input
|
||||
id="auth-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("terminal.auth.username.placeholder")}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Password field */}
|
||||
{authMethod === "password" && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="auth-password">
|
||||
{t("terminal.auth.passwordLabel")}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="auth-password"
|
||||
type={showPassword ? "text" : "password"}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("terminal.auth.password.placeholder")}
|
||||
className="pr-10"
|
||||
autoFocus={!!host.username}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && isValid) {
|
||||
handleSubmit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Key selection */}
|
||||
{authMethod === "key" && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("terminal.auth.selectKey")}</Label>
|
||||
{keys.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground p-3 border border-dashed border-border/60 rounded-lg text-center">
|
||||
{t("terminal.auth.noKeysHint")}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{keys
|
||||
.filter((k) => k.category === "key")
|
||||
.slice(0, 5)
|
||||
.map((key) => (
|
||||
<button
|
||||
key={key.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-3 px-3 py-2.5 rounded-lg border transition-colors text-left",
|
||||
selectedKeyId === key.id
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border/50 hover:bg-secondary/50",
|
||||
)}
|
||||
onClick={() => setSelectedKeyId(key.id)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-lg flex items-center justify-center",
|
||||
"bg-primary/20 text-primary",
|
||||
)}
|
||||
>
|
||||
<Key size={14} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{key.label}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("auth.keyType", { type: key.type })}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{keys.filter((k) => k.category === "key").length > 5 && (
|
||||
<Popover
|
||||
open={isKeySelectOpen}
|
||||
onOpenChange={setIsKeySelectOpen}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="outline" className="w-full">
|
||||
{t("auth.showAllKeys")}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80 p-0">
|
||||
<ScrollArea className="h-64">
|
||||
<div className="p-2 space-y-1">
|
||||
{keys
|
||||
.filter((k) => k.category === "key")
|
||||
.map((key) => (
|
||||
<button
|
||||
key={key.id}
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-2 rounded-md text-left transition-colors",
|
||||
selectedKeyId === key.id
|
||||
? "bg-primary/10"
|
||||
: "hover:bg-secondary",
|
||||
)}
|
||||
onClick={() => {
|
||||
setSelectedKeyId(key.id);
|
||||
setIsKeySelectOpen(false);
|
||||
}}
|
||||
>
|
||||
<Key size={14} className="text-primary" />
|
||||
<span className="text-sm truncate">
|
||||
{key.label}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto">
|
||||
{key.type}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-border/50 flex items-center justify-between">
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button disabled={!isValid} onClick={handleSubmit}>
|
||||
{t("terminal.auth.continueSave")}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={() => {
|
||||
setSaveCredentials(false);
|
||||
handleSubmit();
|
||||
}}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{t("common.continue")}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthDialog;
|
||||
@@ -1,107 +0,0 @@
|
||||
import { ChevronRight,Folder,FolderOpen,FolderPlus,Plus } from 'lucide-react';
|
||||
import React,{ useMemo } from 'react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { GroupNode } from '../types';
|
||||
import { Collapsible,CollapsibleContent,CollapsibleTrigger } from './ui/collapsible';
|
||||
import { ContextMenu,ContextMenuContent,ContextMenuItem,ContextMenuTrigger } from './ui/context-menu';
|
||||
|
||||
interface GroupTreeItemProps {
|
||||
node: GroupNode;
|
||||
depth: number;
|
||||
expandedPaths: Set<string>;
|
||||
onToggle: (path: string) => void;
|
||||
onSelectGroup: (path: string) => void;
|
||||
selectedGroup: string | null;
|
||||
onEditGroup: (path: string) => void;
|
||||
onNewHost: (path: string) => void;
|
||||
onNewSubfolder: (path: string) => void;
|
||||
isManagedGroup?: (path: string) => boolean;
|
||||
}
|
||||
|
||||
export const GroupTreeItem: React.FC<GroupTreeItemProps> = ({
|
||||
node,
|
||||
depth,
|
||||
expandedPaths,
|
||||
onToggle,
|
||||
onSelectGroup,
|
||||
selectedGroup,
|
||||
onEditGroup,
|
||||
onNewHost,
|
||||
onNewSubfolder,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const isExpanded = expandedPaths.has(node.path);
|
||||
const hasChildren = node.children && Object.keys(node.children).length > 0;
|
||||
const paddingLeft = `${depth * 12 + 12}px`;
|
||||
const isSelected = selectedGroup === node.path;
|
||||
|
||||
const childNodes = useMemo(() => {
|
||||
return node.children
|
||||
? (Object.values(node.children) as unknown as GroupNode[]).sort((a, b) => a.name.localeCompare(b.name))
|
||||
: [];
|
||||
}, [node.children]);
|
||||
|
||||
return (
|
||||
<Collapsible open={isExpanded} onOpenChange={() => onToggle(node.path)}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<CollapsibleTrigger asChild>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center py-1.5 pr-2 text-sm font-medium cursor-pointer transition-colors select-none group relative rounded-r-md",
|
||||
isSelected ? "bg-primary/10 text-primary border-l-2 border-primary" : "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
|
||||
)}
|
||||
style={{ paddingLeft }}
|
||||
onClick={() => onSelectGroup(node.path)}
|
||||
>
|
||||
<div className="mr-1.5 flex-shrink-0 w-4 h-4 flex items-center justify-center">
|
||||
{hasChildren && (
|
||||
<div className={cn("transition-transform duration-200", isExpanded ? "rotate-90" : "")}>
|
||||
<ChevronRight size={12} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-2 text-primary/80 group-hover:text-primary transition-colors">
|
||||
{isExpanded ? <FolderOpen size={16} /> : <Folder size={16} />}
|
||||
</div>
|
||||
<span className="truncate flex-1">{node.name}</span>
|
||||
{node.hosts.length > 0 && (
|
||||
<span className="text-[10px] opacity-70 bg-background/50 px-1.5 rounded-full border border-border">
|
||||
{node.hosts.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CollapsibleTrigger>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={() => onNewHost(node.path)}>
|
||||
<Plus className="mr-2 h-4 w-4" /> {t("action.newHost")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => onNewSubfolder(node.path)}>
|
||||
<FolderPlus className="mr-2 h-4 w-4" /> {t("action.newSubfolder")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
|
||||
{hasChildren && (
|
||||
<CollapsibleContent>
|
||||
{childNodes.map((child) => (
|
||||
<GroupTreeItem
|
||||
key={child.path}
|
||||
node={child}
|
||||
depth={depth + 1}
|
||||
expandedPaths={expandedPaths}
|
||||
onToggle={onToggle}
|
||||
onSelectGroup={onSelectGroup}
|
||||
selectedGroup={selectedGroup}
|
||||
onEditGroup={onEditGroup}
|
||||
onNewHost={onNewHost}
|
||||
onNewSubfolder={onNewSubfolder}
|
||||
/>
|
||||
))}
|
||||
</CollapsibleContent>
|
||||
)}
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -156,13 +156,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
// Group input state for inline creation suggestion
|
||||
const [groupInputValue, setGroupInputValue] = useState(form.group || "");
|
||||
|
||||
// Check if the entered group is new (doesn't exist)
|
||||
// Reserved for future use: showing inline "create new group" suggestion
|
||||
const _isNewGroup = useMemo(() => {
|
||||
const trimmed = groupInputValue.trim();
|
||||
return trimmed.length > 0 && !groups.includes(trimmed);
|
||||
}, [groupInputValue, groups]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
// Ensure telnetEnabled is set when protocol is telnet
|
||||
|
||||
@@ -1,382 +0,0 @@
|
||||
import { Key, Lock, Plus, Save, Server, X } from "lucide-react";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "./ui/select";
|
||||
|
||||
interface HostFormProps {
|
||||
initialData?: Host | null;
|
||||
availableKeys: SSHKey[];
|
||||
groups: string[];
|
||||
onSave: (host: Host) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const HostForm: React.FC<HostFormProps> = ({
|
||||
initialData,
|
||||
availableKeys,
|
||||
groups,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [formData, setFormData] = useState<Partial<Host>>(
|
||||
initialData || {
|
||||
label: "",
|
||||
hostname: "",
|
||||
port: 22,
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
group: "General",
|
||||
identityFileId: "",
|
||||
},
|
||||
);
|
||||
|
||||
const [authType, setAuthType] = useState<"password" | "key">(
|
||||
initialData?.identityFileId ? "key" : "password",
|
||||
);
|
||||
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
|
||||
const handleAddTag = () => {
|
||||
const tag = tagInput.trim();
|
||||
if (tag && !formData.tags?.includes(tag)) {
|
||||
setFormData((prev) => ({ ...prev, tags: [...(prev.tags || []), tag] }));
|
||||
setTagInput("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
tags: (prev.tags || []).filter((t) => t !== tagToRemove),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleTagKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAddTag();
|
||||
}
|
||||
};
|
||||
|
||||
// Effect to ensure we have a valid auth state if switching back and forth
|
||||
useEffect(() => {
|
||||
if (authType === "password") {
|
||||
setFormData((prev) => ({ ...prev, identityFileId: "" }));
|
||||
} else if (
|
||||
authType === "key" &&
|
||||
!formData.identityFileId &&
|
||||
availableKeys.length > 0
|
||||
) {
|
||||
// Default to first key if none selected
|
||||
setFormData((prev) => ({ ...prev, identityFileId: availableKeys[0].id }));
|
||||
}
|
||||
}, [authType, availableKeys, formData.identityFileId]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (formData.label && formData.hostname && formData.username) {
|
||||
onSave({
|
||||
...formData,
|
||||
id: initialData?.id || crypto.randomUUID(),
|
||||
tags: formData.tags || [],
|
||||
port: formData.port || 22,
|
||||
group: formData.group || "General",
|
||||
identityFileId:
|
||||
authType === "key" ? formData.identityFileId : undefined,
|
||||
createdAt: initialData?.createdAt || Date.now(),
|
||||
} as Host);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={true} onOpenChange={() => onCancel()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Server className="h-5 w-5 text-primary" />
|
||||
{initialData ? t("hostForm.title.edit") : t("hostForm.title.new")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{initialData ? t("hostForm.desc.edit") : t("hostForm.desc.new")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="label">{t("hostForm.field.label")}</Label>
|
||||
<Input
|
||||
id="label"
|
||||
placeholder={t("hostForm.placeholder.label")}
|
||||
value={formData.label}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, label: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="col-span-2 grid gap-2">
|
||||
<Label htmlFor="hostname">{t("hostForm.field.hostname")}</Label>
|
||||
<Input
|
||||
id="hostname"
|
||||
placeholder={t("hostForm.placeholder.hostname")}
|
||||
value={formData.hostname}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, hostname: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="port">{t("hostForm.field.port")}</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={formData.port}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, port: parseInt(e.target.value) })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="username">{t("hostForm.field.username")}</Label>
|
||||
<Input
|
||||
id="username"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, username: e.target.value })
|
||||
}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="os">{t("hostForm.field.osType")}</Label>
|
||||
<Select
|
||||
value={formData.os}
|
||||
onValueChange={(val: "linux" | "windows" | "macos") =>
|
||||
setFormData({ ...formData, os: val })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("hostForm.placeholder.selectOs")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="linux">Linux</SelectItem>
|
||||
<SelectItem value="windows">Windows</SelectItem>
|
||||
<SelectItem value="macos">macOS</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="group">{t("hostForm.field.group")}</Label>
|
||||
<Input
|
||||
id="group"
|
||||
placeholder={t("hostForm.placeholder.group")}
|
||||
value={formData.group}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, group: e.target.value })
|
||||
}
|
||||
list="group-suggestions"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<datalist id="group-suggestions">
|
||||
{groups.map((g) => (
|
||||
<option key={g} value={g} />
|
||||
))}
|
||||
</datalist>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="tags">{t("hostForm.field.tags")}</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
id="tags"
|
||||
placeholder={t("hostForm.placeholder.addTag")}
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyDown={handleTagKeyDown}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={handleAddTag}
|
||||
disabled={!tagInput.trim()}
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 mt-1">
|
||||
{formData.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full bg-primary/10 text-primary text-xs"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="hover:bg-primary/20 rounded-full p-0.5"
|
||||
>
|
||||
<X size={12} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 pt-2">
|
||||
<div className="flex items-center justify-between space-x-2 border rounded-md p-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sftp-sudo" className="text-base">
|
||||
{t("hostDetails.sftp.sudo")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.sudo.desc")}
|
||||
</p>
|
||||
{formData.sftpSudo && authType === "key" && (
|
||||
<p className="text-xs text-amber-500 mt-1">
|
||||
{t("hostDetails.sftp.sudo.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
id="sftp-sudo"
|
||||
checked={formData.sftpSudo || false}
|
||||
onCheckedChange={(checked) =>
|
||||
setFormData({ ...formData, sftpSudo: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="sftp-encoding">
|
||||
{t("hostDetails.sftp.encoding")}
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.sftpEncoding || "auto"}
|
||||
onValueChange={(val) =>
|
||||
setFormData({ ...formData, sftpEncoding: val as Host["sftpEncoding"] })
|
||||
}
|
||||
>
|
||||
<SelectTrigger id="sftp-encoding">
|
||||
<SelectValue placeholder={t("sftp.encoding.label")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">{t("sftp.encoding.auto")}</SelectItem>
|
||||
<SelectItem value="utf-8">{t("sftp.encoding.utf8")}</SelectItem>
|
||||
<SelectItem value="gb18030">{t("sftp.encoding.gb18030")}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sftp.encoding.desc")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Label>{t("hostForm.auth.method")}</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
|
||||
authType === "password"
|
||||
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setAuthType("password")}
|
||||
>
|
||||
<Lock size={20} />
|
||||
<span className="text-xs font-medium">{t("hostForm.auth.password")}</span>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"border rounded-md p-3 flex flex-col items-center justify-center gap-2 cursor-pointer transition-all hover:bg-accent/50",
|
||||
authType === "key"
|
||||
? "border-primary bg-primary/5 text-primary ring-1 ring-primary"
|
||||
: "text-muted-foreground",
|
||||
)}
|
||||
onClick={() => setAuthType("key")}
|
||||
>
|
||||
<Key size={20} />
|
||||
<span className="text-xs font-medium">{t("hostForm.auth.sshKey")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authType === "key" && (
|
||||
<div className="animate-in fade-in zoom-in-95 duration-200">
|
||||
<Select
|
||||
value={formData.identityFileId || ""}
|
||||
onValueChange={(val) =>
|
||||
setFormData({ ...formData, identityFileId: val })
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("hostForm.auth.selectKey")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableKeys.map((key) => (
|
||||
<SelectItem key={key.id} value={key.id}>
|
||||
{key.label} ({key.type})
|
||||
</SelectItem>
|
||||
))}
|
||||
{availableKeys.length === 0 && (
|
||||
<SelectItem value="none" disabled>
|
||||
{t("hostForm.auth.noKeys")}
|
||||
</SelectItem>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{availableKeys.length === 0 && (
|
||||
<p className="text-[10px] text-destructive mt-1">
|
||||
{t("hostForm.auth.noKeysHint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="ghost" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
<Save className="mr-2 h-4 w-4" /> {t("hostForm.saveHost")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default HostForm;
|
||||
@@ -1,411 +0,0 @@
|
||||
import {
|
||||
Key,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Shield,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { KeyType } from "../domain/models";
|
||||
import { cn } from "../lib/utils";
|
||||
import { SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card, CardDescription, CardTitle } from "./ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Textarea } from "./ui/textarea";
|
||||
|
||||
interface KeyManagerProps {
|
||||
keys: SSHKey[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
const KeyManager: React.FC<KeyManagerProps> = ({ keys, onSave, onDelete }) => {
|
||||
const { t } = useI18n();
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [panelMode, setPanelMode] = useState<"new" | "edit">("new");
|
||||
const [draftKey, setDraftKey] = useState<Partial<SSHKey>>({
|
||||
id: "",
|
||||
label: "",
|
||||
type: "RSA",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
created: Date.now(),
|
||||
});
|
||||
const [generateMode, setGenerateMode] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">("grid");
|
||||
|
||||
const handleGenerate = () => {
|
||||
// Simulate Key Generation
|
||||
const mockKey =
|
||||
`-----BEGIN ${draftKey.type} PRIVATE KEY-----\n` +
|
||||
`MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQC${Math.random().toString(36).substring(7)}\n` +
|
||||
`... (simulated generated content) ...\n` +
|
||||
`-----END ${draftKey.type} PRIVATE KEY-----`;
|
||||
|
||||
setDraftKey({ ...draftKey, privateKey: mockKey });
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!draftKey.label || !draftKey.privateKey) return;
|
||||
|
||||
const payload: SSHKey = {
|
||||
id: draftKey.id || crypto.randomUUID(),
|
||||
label: draftKey.label,
|
||||
type: (draftKey.type as KeyType) || "RSA",
|
||||
privateKey: draftKey.privateKey,
|
||||
publicKey: draftKey.publicKey?.trim() || undefined,
|
||||
created: draftKey.created || Date.now(),
|
||||
source: draftKey.source || (generateMode ? "generated" : "imported"),
|
||||
category: draftKey.category || "key",
|
||||
};
|
||||
onSave(payload);
|
||||
setIsDialogOpen(false);
|
||||
setGenerateMode(false);
|
||||
};
|
||||
|
||||
const openPanelForKey = (key: SSHKey) => {
|
||||
setPanelMode("edit");
|
||||
setDraftKey({ ...key });
|
||||
setIsDialogOpen(true);
|
||||
setGenerateMode(false);
|
||||
};
|
||||
|
||||
const openPanelNew = (isGenerate = false) => {
|
||||
setPanelMode("new");
|
||||
setGenerateMode(isGenerate);
|
||||
setDraftKey({
|
||||
id: "",
|
||||
label: "",
|
||||
type: "RSA",
|
||||
privateKey: isGenerate
|
||||
? "Click generate to create a new key pair..."
|
||||
: "",
|
||||
publicKey: "",
|
||||
created: Date.now(),
|
||||
});
|
||||
setIsDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
onDelete(id);
|
||||
if (draftKey.id === id) {
|
||||
setIsDialogOpen(false);
|
||||
setDraftKey({
|
||||
id: "",
|
||||
label: "",
|
||||
type: "RSA",
|
||||
privateKey: "",
|
||||
publicKey: "",
|
||||
created: Date.now(),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredKeys = useMemo(() => {
|
||||
const term = search.trim().toLowerCase();
|
||||
return keys.filter((k) => {
|
||||
if (!term) return true;
|
||||
return (
|
||||
k.label.toLowerCase().includes(term) ||
|
||||
(k.type || "").toString().toLowerCase().includes(term)
|
||||
);
|
||||
});
|
||||
}, [keys, search]);
|
||||
|
||||
const derivedPublicKey = useMemo(() => {
|
||||
if (draftKey.publicKey) return draftKey.publicKey;
|
||||
if (!draftKey.label) return "Generated By netcatty";
|
||||
return `ssh-${(draftKey.type || "ed25519").toLowerCase()} AAAAC3NzaC1lZDI1NTE5AAAA${(
|
||||
draftKey.label || "netcatty"
|
||||
)
|
||||
.replace(/\s+/g, "")
|
||||
.slice(0, 8)} Generated By netcatty`;
|
||||
}, [draftKey.label, draftKey.type, draftKey.publicKey]);
|
||||
|
||||
return (
|
||||
<div className="px-2.5 py-2.5 lg:px-3 lg:py-3 h-full overflow-y-auto space-y-3.5 relative">
|
||||
<div className="flex flex-wrap items-center gap-3 bg-secondary/60 border border-border/70 rounded-xl px-2 py-1.5 shadow-sm">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="h-8 px-3 gap-2"
|
||||
disabled
|
||||
>
|
||||
Key
|
||||
<span className="text-[10px] px-2 rounded-full h-5 min-w-[22px] flex items-center justify-center bg-primary/10 text-primary border border-border/70">
|
||||
{keys.length}
|
||||
</span>
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search
|
||||
size={14}
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="Search keys..."
|
||||
className="h-9 pl-8 w-44 md:w-56"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={viewMode === "grid" ? "secondary" : "ghost"}
|
||||
className="h-9 w-9"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant={viewMode === "list" ? "secondary" : "ghost"}
|
||||
className="h-9 w-9"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<ListIcon size={16} />
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => openPanelNew(false)}>
|
||||
<Plus size={14} className="mr-2" /> Import
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => openPanelNew(true)}
|
||||
>
|
||||
Generate
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between px-1">
|
||||
<h2 className="text-base font-semibold text-muted-foreground">
|
||||
Keys
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredKeys.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Shield size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
Set up your keys
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm">
|
||||
Import or generate SSH keys for secure authentication.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={
|
||||
viewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0"
|
||||
}
|
||||
>
|
||||
{filteredKeys.map((key) => (
|
||||
<Card
|
||||
key={key.id}
|
||||
className={cn(
|
||||
"group cursor-pointer soft-card elevate rounded-xl",
|
||||
viewMode === "grid"
|
||||
? "h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
)}
|
||||
onClick={() => openPanelForKey(key)}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-9 w-9 rounded-md bg-primary/15 text-primary flex items-center justify-center">
|
||||
<Key size={16} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<CardTitle className="text-sm font-semibold truncate">
|
||||
{key.label}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
Type {key.type}
|
||||
</CardDescription>
|
||||
<div className="text-[10px] text-muted-foreground/80 font-mono truncate">
|
||||
SHA256:{key.id.substring(0, 16)}...
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openPanelForKey(key);
|
||||
}}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(key.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{panelMode === "new"
|
||||
? t("keychain.keyDialog.newTitle")
|
||||
: t("keychain.keyDialog.editTitle")}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{panelMode === "new"
|
||||
? t("keychain.keyDialog.newDesc")
|
||||
: t("keychain.keyDialog.editDesc")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("keychain.field.label")}</Label>
|
||||
<Input
|
||||
value={draftKey.label}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, label: e.target.value })
|
||||
}
|
||||
placeholder={t("keychain.field.labelPlaceholder")}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("keychain.field.privateKeyRequired")}</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, privateKey: e.target.value })
|
||||
}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[160px] font-mono text-xs"
|
||||
required
|
||||
/>
|
||||
{generateMode && (
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={handleGenerate}
|
||||
>
|
||||
{t("keychain.generate.generate")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>{t("keychain.field.publicKey")}</Label>
|
||||
<Textarea
|
||||
value={derivedPublicKey}
|
||||
onChange={(e) =>
|
||||
setDraftKey({ ...draftKey, publicKey: e.target.value })
|
||||
}
|
||||
placeholder="ssh-ed25519 AAAAC3... user@host"
|
||||
className="min-h-[90px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
{t("terminal.auth.certificate")}{" "}
|
||||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
{t("common.optional")}
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder={t("keychain.field.certificatePlaceholder")}
|
||||
className="min-h-[80px] text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border border-dashed border-border/80 rounded-xl p-4 text-center space-y-2 bg-background/60">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("keychain.import.dropHint")}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
// mock file import
|
||||
setDraftKey({
|
||||
...draftKey,
|
||||
label: draftKey.label || t("keychain.import.importedKeyLabel"),
|
||||
privateKey:
|
||||
draftKey.privateKey ||
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nAAAAC3NzaC1lZDI1NTE5AAAA\n-----END OPENSSH PRIVATE KEY-----",
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("keychain.import.importFromFile")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
{panelMode === "edit" && draftKey.id && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="text-destructive mr-auto"
|
||||
onClick={() => handleDelete(draftKey.id!)}
|
||||
>
|
||||
{t("common.delete")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="submit">
|
||||
{panelMode === "new"
|
||||
? t("keychain.import.saveKey")
|
||||
: t("keychain.keyDialog.updateKey")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default KeyManager;
|
||||
@@ -450,11 +450,6 @@ echo $3 >> "$FILE"`);
|
||||
[onDeleteIdentity, panel, closePanel],
|
||||
);
|
||||
|
||||
// Copy to clipboard
|
||||
const _copyToClipboard = useCallback((_text: string) => {
|
||||
navigator.clipboard.writeText(_text);
|
||||
}, []);
|
||||
|
||||
// Get icon for key source
|
||||
const getKeyIcon = (key: SSHKey) => {
|
||||
if (key.certificate) return <BadgeCheck size={16} />;
|
||||
@@ -506,46 +501,6 @@ echo $3 >> "$FILE"`);
|
||||
[],
|
||||
);
|
||||
|
||||
// Handle drag and drop
|
||||
const _handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const file = event.dataTransfer.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
if (content) {
|
||||
let detectedType: KeyType = "ED25519";
|
||||
const lc = content.toLowerCase();
|
||||
if (lc.includes("rsa")) detectedType = "RSA";
|
||||
else if (lc.includes("ecdsa") || lc.includes("ec private"))
|
||||
detectedType = "ECDSA";
|
||||
else if (lc.includes("ed25519")) detectedType = "ED25519";
|
||||
|
||||
const label = file.name.replace(/\.(pem|key|pub|ppk)$/i, "");
|
||||
|
||||
setDraftKey((prev) => ({
|
||||
...prev,
|
||||
privateKey: content,
|
||||
label: prev.label || label,
|
||||
type: detectedType,
|
||||
}));
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, []);
|
||||
|
||||
const _handleDragOver = useCallback(
|
||||
(event: React.DragEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex relative">
|
||||
{/* Hidden file input */}
|
||||
|
||||
@@ -307,8 +307,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const label =
|
||||
newFormDraft.label?.trim() ||
|
||||
(() => {
|
||||
// Host lookup reserved for future label enhancement (e.g., "Local:8080 → api.example.com:80 via server1")
|
||||
const _host = hosts.find((h) => h.id === newFormDraft.hostId);
|
||||
switch (newFormDraft.type) {
|
||||
case "local":
|
||||
return `Local:${newFormDraft.localPort} → ${newFormDraft.remoteHost}:${newFormDraft.remotePort}`;
|
||||
@@ -546,12 +544,6 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Handle skip wizard (just save with defaults)
|
||||
const _skipWizard = () => {
|
||||
setShowWizard(false);
|
||||
resetWizard();
|
||||
};
|
||||
|
||||
// Render wizard panel content
|
||||
const hasRules = filteredRules.length > 0;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, KnownHost, SSHKey } from "../types";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
@@ -30,7 +30,6 @@ interface QuickConnectWizardProps {
|
||||
open: boolean;
|
||||
target: QuickConnectTarget;
|
||||
keys: SSHKey[];
|
||||
knownHosts: KnownHost[];
|
||||
warnings?: string[];
|
||||
onConnect: (host: Host) => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
@@ -42,7 +41,6 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
open,
|
||||
target,
|
||||
keys,
|
||||
knownHosts,
|
||||
warnings,
|
||||
onConnect,
|
||||
onSaveHost,
|
||||
@@ -69,16 +67,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
const [password, setPassword] = useState("");
|
||||
const [selectedKeyId, setSelectedKeyId] = useState<string | null>(null);
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [saveCredentials, _setSaveCredentials] = useState(true);
|
||||
|
||||
// Check if host is in known hosts
|
||||
const _existingKnownHost = useMemo(() => {
|
||||
return knownHosts.find(
|
||||
(kh) =>
|
||||
kh.hostname === target.hostname &&
|
||||
(kh.port === port || (!kh.port && port === 22)),
|
||||
);
|
||||
}, [knownHosts, target.hostname, port]);
|
||||
const [saveCredentials] = useState(true);
|
||||
|
||||
// Reset state when target changes
|
||||
React.useEffect(() => {
|
||||
|
||||
@@ -481,7 +481,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
// Display files with parent entry (like SftpView)
|
||||
const displayFiles = useMemo(() => {
|
||||
// Filter hidden files using utility function
|
||||
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles, isLocalSession);
|
||||
const visibleFiles = filterHiddenFiles(files, sftpShowHiddenFiles);
|
||||
|
||||
// Check if we're at root
|
||||
const atRoot = isRootPathForSession(currentPath);
|
||||
@@ -495,7 +495,7 @@ const SFTPModal: React.FC<SFTPModalProps> = ({
|
||||
lastModified: undefined,
|
||||
};
|
||||
return [parentEntry, ...visibleFiles.filter((f) => f.name !== "..")];
|
||||
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles, isLocalSession]);
|
||||
}, [files, currentPath, isRootPathForSession, sftpShowHiddenFiles]);
|
||||
|
||||
// Sorted files
|
||||
const sortedFiles = useMemo(() => {
|
||||
|
||||
@@ -197,19 +197,6 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
}));
|
||||
}, [currentPath]);
|
||||
|
||||
const _handleBack = () => {
|
||||
if (currentPath) {
|
||||
const parts = currentPath.split("/");
|
||||
if (parts.length > 1) {
|
||||
setCurrentPath(parts.slice(0, -1).join("/"));
|
||||
} else {
|
||||
setCurrentPath(null);
|
||||
}
|
||||
} else {
|
||||
onBack();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
* This component is rendered in a separate Electron window
|
||||
*/
|
||||
import { AppWindow, Cloud, FileType, HardDrive, Keyboard, Palette, TerminalSquare, X } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import { useVaultState } from "../application/state/useVaultState";
|
||||
import { useWindowControls } from "../application/state/useWindowControls";
|
||||
import { I18nProvider, useI18n } from "../application/i18n/I18nProvider";
|
||||
@@ -31,17 +32,37 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
customGroups,
|
||||
knownHosts,
|
||||
importDataFromString,
|
||||
clearVaultData,
|
||||
} = useVaultState();
|
||||
|
||||
const { rules: portForwardingRules, importRules: importPortForwardingRules } = usePortForwardingState();
|
||||
|
||||
// Strip transient runtime fields before passing to sync
|
||||
const portForwardingRulesForSync = useMemo(
|
||||
() =>
|
||||
portForwardingRules.map((rule) => ({
|
||||
...rule,
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
})),
|
||||
[portForwardingRules],
|
||||
);
|
||||
|
||||
const vault = useMemo(
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, knownHosts }),
|
||||
[hosts, keys, identities, snippets, customGroups, knownHosts],
|
||||
);
|
||||
|
||||
return (
|
||||
<SettingsSyncTab
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
vault={vault}
|
||||
portForwardingRules={portForwardingRulesForSync}
|
||||
importDataFromString={importDataFromString}
|
||||
importPortForwardingRules={importPortForwardingRules}
|
||||
clearVaultData={clearVaultData}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -58,6 +58,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
sftpDoubleClickBehavior,
|
||||
sftpAutoSync,
|
||||
sftpShowHiddenFiles,
|
||||
sftpUseCompressedUpload,
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
editorWordWrap,
|
||||
@@ -77,7 +78,12 @@ const SftpViewInner: React.FC<SftpViewProps> = ({ hosts, keys, identities, updat
|
||||
},
|
||||
}), [t]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, fileWatchHandlers);
|
||||
const sftpOptions = useMemo(() => ({
|
||||
...fileWatchHandlers,
|
||||
useCompressedUpload: sftpUseCompressedUpload,
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload]);
|
||||
|
||||
const sftp = useSftpState(hosts, keys, identities, sftpOptions);
|
||||
|
||||
// Get stream transfer functions for optimized downloads
|
||||
const { showSaveDialog, startStreamTransfer } = useSftpBackend();
|
||||
|
||||
@@ -215,6 +215,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const hasConnectedRef = useRef(false);
|
||||
const hasRunStartupCommandRef = useRef(false);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
const mouseTrackingRef = useRef(false);
|
||||
const serialLineBufferRef = useRef<string>("");
|
||||
|
||||
const terminalSettingsRef = useRef(terminalSettings);
|
||||
@@ -266,7 +268,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snippetsRef.current = snippets;
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession } = terminalBackend;
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
|
||||
|
||||
|
||||
@@ -297,6 +299,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const dragCounterRef = useRef(0);
|
||||
const [pendingUploadEntries, setPendingUploadEntries] = useState<DropEntry[]>([]);
|
||||
const [isComposeBarOpen, setIsComposeBarOpen] = useState(false);
|
||||
const [terminalEncoding, setTerminalEncoding] = useState<'utf-8' | 'gb18030'>(() => {
|
||||
if (host?.charset && /^gb/i.test(String(host.charset).trim())) return 'gb18030';
|
||||
return 'utf-8';
|
||||
});
|
||||
const terminalEncodingRef = useRef(terminalEncoding);
|
||||
terminalEncodingRef.current = terminalEncoding;
|
||||
|
||||
const terminalSearch = useTerminalSearch({ searchAddonRef, termRef });
|
||||
const {
|
||||
@@ -428,6 +436,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
setProgressValue,
|
||||
setChainProgress,
|
||||
t,
|
||||
onSessionAttached: (id: string) => {
|
||||
// Sync terminal encoding to SSH backend before first data arrives
|
||||
const isSSH = host.protocol !== 'local' && host.protocol !== 'serial' && host.protocol !== 'telnet' && host.protocol !== 'mosh' && !host.moshEnabled && !host.id?.startsWith('local-') && !host.id?.startsWith('serial-') && host.hostname !== 'localhost';
|
||||
if (isSSH) {
|
||||
setSessionEncoding(id, terminalEncodingRef.current);
|
||||
}
|
||||
},
|
||||
onSessionExit,
|
||||
onTerminalDataCapture,
|
||||
onOsDetected,
|
||||
@@ -869,6 +884,61 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
term.onSelectionChange(onSelectionChange);
|
||||
}, [terminalSettings?.copyOnSelect]);
|
||||
|
||||
// Track whether the terminal application has enabled mouse tracking
|
||||
// (e.g. tmux with `set -g mouse on`, vim with `set mouse=a`).
|
||||
// When mouse tracking is active, disable Netcatty's context menu to avoid
|
||||
// conflicting with the application's own mouse handling.
|
||||
useEffect(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
|
||||
const disposable = term.onWriteParsed(() => {
|
||||
const tracking = term.modes.mouseTrackingMode !== 'none';
|
||||
if (tracking !== mouseTrackingRef.current) {
|
||||
mouseTrackingRef.current = tracking;
|
||||
setHasMouseTracking(tracking);
|
||||
}
|
||||
});
|
||||
|
||||
// Set initial state
|
||||
const initial = term.modes.mouseTrackingMode !== 'none';
|
||||
mouseTrackingRef.current = initial;
|
||||
setHasMouseTracking(initial);
|
||||
|
||||
return () => disposable.dispose();
|
||||
}, [sessionId]);
|
||||
|
||||
// Prevent xterm.js's built-in rightClickHandler and right-button mouseup
|
||||
// from interfering with tmux/vim popup menus when mouse tracking is active.
|
||||
// - contextmenu: xterm.js calls textarea.select() which steals focus
|
||||
// - mouseup (button 2): tmux interprets the right-button release as a
|
||||
// dismiss action, closing the popup menu immediately after it appears
|
||||
// Both are intercepted at the capture phase before xterm.js's own listeners.
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const handleContextMenuCapture = (e: MouseEvent) => {
|
||||
if (mouseTrackingRef.current) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUpCapture = (e: MouseEvent) => {
|
||||
if (e.button === 2 && mouseTrackingRef.current) {
|
||||
e.stopImmediatePropagation();
|
||||
}
|
||||
};
|
||||
|
||||
el.addEventListener('contextmenu', handleContextMenuCapture, true);
|
||||
el.addEventListener('mouseup', handleMouseUpCapture, true);
|
||||
return () => {
|
||||
el.removeEventListener('contextmenu', handleContextMenuCapture, true);
|
||||
el.removeEventListener('mouseup', handleMouseUpCapture, true);
|
||||
};
|
||||
}, [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let resizeTimeout: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@@ -891,12 +961,16 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const disableBracketedPasteRef = useRef(terminalSettings?.disableBracketedPaste ?? false);
|
||||
disableBracketedPasteRef.current = terminalSettings?.disableBracketedPaste ?? false;
|
||||
|
||||
const scrollOnPasteRef = useRef(terminalSettings?.scrollOnPaste ?? true);
|
||||
scrollOnPasteRef.current = terminalSettings?.scrollOnPaste ?? true;
|
||||
|
||||
const terminalContextActions = useTerminalContextActions({
|
||||
termRef,
|
||||
sessionRef,
|
||||
terminalBackend,
|
||||
onHasSelectionChange: setHasSelection,
|
||||
disableBracketedPasteRef,
|
||||
scrollOnPasteRef,
|
||||
});
|
||||
|
||||
const handleSnippetClick = (cmd: string) => {
|
||||
@@ -909,6 +983,13 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
termRef.current?.writeln("\r\n[No active SSH session]");
|
||||
};
|
||||
|
||||
const handleSetTerminalEncoding = (encoding: 'utf-8' | 'gb18030') => {
|
||||
setTerminalEncoding(encoding);
|
||||
if (sessionRef.current) {
|
||||
setSessionEncoding(sessionRef.current, encoding);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenSFTP = async () => {
|
||||
// If SFTP is already open, toggle it off
|
||||
if (showSFTP) {
|
||||
@@ -991,6 +1072,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!termRef.current) return;
|
||||
cleanupSession();
|
||||
auth.resetForRetry();
|
||||
hasRunStartupCommandRef.current = false;
|
||||
setStatus("connecting");
|
||||
setError(null);
|
||||
setProgressLogs(["Retrying secure channel..."]);
|
||||
@@ -1113,6 +1195,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onToggleSearch={handleToggleSearch}
|
||||
isComposeBarOpen={inWorkspace ? isWorkspaceComposeBarOpen : isComposeBarOpen}
|
||||
onToggleComposeBar={inWorkspace ? onToggleComposeBar : () => setIsComposeBarOpen(prev => !prev)}
|
||||
terminalEncoding={terminalEncoding}
|
||||
onSetTerminalEncoding={handleSetTerminalEncoding}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1122,8 +1206,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
: status === "connecting"
|
||||
? "bg-amber-400"
|
||||
: "bg-rose-500";
|
||||
const _isConnecting = status === "connecting";
|
||||
const _hasError = Boolean(error);
|
||||
|
||||
return (
|
||||
<TerminalContextMenu
|
||||
@@ -1131,6 +1213,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
hotkeyScheme={hotkeyScheme}
|
||||
keyBindings={keyBindings}
|
||||
rightClickBehavior={terminalSettings?.rightClickBehavior}
|
||||
isAlternateScreen={hasMouseTracking}
|
||||
onCopy={terminalContextActions.onCopy}
|
||||
onPaste={terminalContextActions.onPaste}
|
||||
onSelectAll={terminalContextActions.onSelectAll}
|
||||
|
||||
@@ -70,18 +70,6 @@ const ThemeSelectPanel: React.FC<ThemeSelectPanelProps> = ({
|
||||
return [...TERMINAL_THEMES, ...customThemes];
|
||||
}, [customThemes]);
|
||||
|
||||
// Group themes by type - reserved for future sectioned view
|
||||
const _groupedThemes = useMemo(() => {
|
||||
const dark = allThemes.filter(t => t.type === 'dark');
|
||||
const light = allThemes.filter(t => t.type === 'light');
|
||||
return { dark, light };
|
||||
}, [allThemes]);
|
||||
|
||||
// Find selected theme info - reserved for displaying selection details
|
||||
const _selectedTheme = useMemo(() => {
|
||||
return allThemes.find(t => t.id === selectedThemeId);
|
||||
}, [selectedThemeId, allThemes]);
|
||||
|
||||
const renderThemeItem = (theme: TerminalTheme) => {
|
||||
const isSelected = theme.id === selectedThemeId;
|
||||
|
||||
|
||||
@@ -2495,7 +2495,6 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
open={isQuickConnectOpen}
|
||||
target={quickConnectTarget}
|
||||
keys={keys}
|
||||
knownHosts={knownHosts}
|
||||
onConnect={handleQuickConnect}
|
||||
onSaveHost={handleQuickConnectSaveHost}
|
||||
onClose={() => {
|
||||
@@ -2543,6 +2542,7 @@ const vaultViewAreEqual = (
|
||||
const isEqual =
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.snippets === next.snippets &&
|
||||
prev.snippetPackages === next.snippetPackages &&
|
||||
prev.customGroups === next.customGroups &&
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
/**
|
||||
* Edit Key Panel - Edit existing SSH key
|
||||
*/
|
||||
|
||||
import { Info } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
|
||||
interface EditKeyPanelProps {
|
||||
draftKey: Partial<SSHKey>;
|
||||
_originalKey: SSHKey; // Reserved for future diff/comparison feature
|
||||
setDraftKey: (key: Partial<SSHKey>) => void;
|
||||
onExport: () => void;
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export const EditKeyPanel: React.FC<EditKeyPanelProps> = ({
|
||||
draftKey,
|
||||
_originalKey, // Reserved for future diff/comparison feature
|
||||
setDraftKey,
|
||||
onExport,
|
||||
onSave,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<Label>{t('keychain.field.labelRequired')}</Label>
|
||||
<Input
|
||||
value={draftKey.label || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, label: e.target.value })}
|
||||
placeholder={t('keychain.field.labelPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-destructive">{t('keychain.field.privateKeyRequired')}</Label>
|
||||
<Textarea
|
||||
value={draftKey.privateKey || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, privateKey: e.target.value })}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.field.publicKey')}</Label>
|
||||
<Textarea
|
||||
value={draftKey.publicKey || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, publicKey: e.target.value })}
|
||||
placeholder="ssh-ed25519 AAAA..."
|
||||
className="min-h-[80px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('terminal.auth.certificate')}</Label>
|
||||
<Textarea
|
||||
value={draftKey.certificate || ''}
|
||||
onChange={e => setDraftKey({ ...draftKey, certificate: e.target.value })}
|
||||
placeholder={t('keychain.field.certificatePlaceholder')}
|
||||
className="min-h-[60px] font-mono text-xs"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Key Export section */}
|
||||
<div className="pt-4 mt-4 border-t border-border/60">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<span className="text-sm font-medium">{t('keychain.export.title')}</span>
|
||||
<div className="h-4 w-4 rounded-full bg-muted flex items-center justify-center">
|
||||
<Info size={10} className="text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full h-11" onClick={onExport}>
|
||||
{t('keychain.export.exportToHost')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Save button */}
|
||||
<Button
|
||||
className="w-full h-11 mt-4"
|
||||
disabled={!draftKey.label?.trim() || !draftKey.privateKey?.trim()}
|
||||
onClick={onSave}
|
||||
>
|
||||
{t('common.saveChanges')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,246 +0,0 @@
|
||||
/**
|
||||
* Export Key Panel - Export SSH key to remote host
|
||||
*/
|
||||
|
||||
import { ChevronRight, Info } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useKeychainBackend } from '../../application/state/useKeychainBackend';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Host, Identity, SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '../ui/collapsible';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { toast } from '../ui/toast';
|
||||
import { getKeyIcon, getKeyTypeDisplay, isMacOS } from './utils';
|
||||
|
||||
interface ExportKeyPanelProps {
|
||||
keyItem: SSHKey;
|
||||
_hosts: Host[]; // Reserved for future inline host list/validation
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
exportHost: Host | null;
|
||||
_setExportHost: (host: Host | null) => void; // Host selection handled by onShowHostSelector callback
|
||||
onShowHostSelector: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_EXPORT_SCRIPT = `DIR="$HOME/$1"
|
||||
FILE="$DIR/$2"
|
||||
if [ ! -d "$DIR" ]; then
|
||||
mkdir -p "$DIR"
|
||||
chmod 700 "$DIR"
|
||||
fi
|
||||
if [ ! -f "$FILE" ]; then
|
||||
touch "$FILE"
|
||||
chmod 600 "$FILE"
|
||||
fi
|
||||
echo $3 >> "$FILE"`;
|
||||
|
||||
export const ExportKeyPanel: React.FC<ExportKeyPanelProps> = ({
|
||||
keyItem,
|
||||
_hosts, // Reserved for future inline host list/validation
|
||||
keys,
|
||||
identities,
|
||||
exportHost,
|
||||
_setExportHost, // Host selection handled by onShowHostSelector callback
|
||||
onShowHostSelector,
|
||||
onSaveHost,
|
||||
onClose,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { execCommand } = useKeychainBackend();
|
||||
const [exportLocation, setExportLocation] = useState('.ssh');
|
||||
const [exportFilename, setExportFilename] = useState('authorized_keys');
|
||||
const [exportAdvancedOpen, setExportAdvancedOpen] = useState(false);
|
||||
const [exportScript, setExportScript] = useState(DEFAULT_EXPORT_SCRIPT);
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const isMac = isMacOS();
|
||||
|
||||
const handleExport = async () => {
|
||||
if (!exportHost || !keyItem.publicKey) return;
|
||||
|
||||
setIsExporting(true);
|
||||
|
||||
try {
|
||||
const exportAuth = resolveHostAuth({ host: exportHost, keys, identities });
|
||||
|
||||
// Check for authentication method
|
||||
if (!exportAuth.password && !exportAuth.key?.privateKey) {
|
||||
throw new Error(t('keychain.export.missingCredentials'));
|
||||
}
|
||||
|
||||
const hostPrivateKey = exportAuth.key?.privateKey;
|
||||
|
||||
// Escape the public key for shell
|
||||
const escapedPublicKey = keyItem.publicKey.replace(/'/g, "'\\''");
|
||||
|
||||
// Build the command by replacing $1, $2, $3
|
||||
const scriptWithVars = exportScript
|
||||
.replace(/\$1/g, exportLocation)
|
||||
.replace(/\$2/g, exportFilename)
|
||||
.replace(/\$3/g, `'${escapedPublicKey}'`);
|
||||
|
||||
const command = scriptWithVars;
|
||||
|
||||
// Execute via SSH
|
||||
const result = await execCommand({
|
||||
hostname: exportHost.hostname,
|
||||
username: exportAuth.username,
|
||||
port: exportHost.port || 22,
|
||||
password: exportAuth.password,
|
||||
privateKey: hostPrivateKey,
|
||||
command,
|
||||
timeout: 30000,
|
||||
enableKeyboardInteractive: true,
|
||||
sessionId: `export-key:${exportHost.id}:${keyItem.id}`,
|
||||
});
|
||||
|
||||
// Check result
|
||||
const exitCode = result?.code;
|
||||
const hasError = result?.stderr?.trim();
|
||||
if (exitCode === 0 || (exitCode == null && !hasError)) {
|
||||
// Update host to use this key for authentication
|
||||
if (onSaveHost) {
|
||||
const updatedHost: Host = {
|
||||
...exportHost,
|
||||
identityFileId: keyItem.id,
|
||||
authMethod: 'key',
|
||||
};
|
||||
onSaveHost(updatedHost);
|
||||
}
|
||||
toast.success(
|
||||
t('keychain.export.successMessage', { host: exportHost.label }),
|
||||
t('keychain.export.successTitle'),
|
||||
);
|
||||
onClose();
|
||||
} else {
|
||||
const errorMsg = hasError || result?.stdout?.trim() || `Command exited with code ${exitCode}`;
|
||||
toast.error(
|
||||
t('keychain.export.failedMessage', { error: errorMsg }),
|
||||
t('keychain.export.failedTitle'),
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
toast.error(
|
||||
t('keychain.export.failedGeneric', { message }),
|
||||
t('keychain.export.failedTitle'),
|
||||
);
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Key info card */}
|
||||
<div className="flex items-center gap-3 p-3 bg-card border border-border/80 rounded-lg">
|
||||
<div className={cn(
|
||||
"h-10 w-10 rounded-md flex items-center justify-center",
|
||||
keyItem.certificate
|
||||
? "bg-emerald-500/15 text-emerald-500"
|
||||
: "bg-primary/15 text-primary"
|
||||
)}>
|
||||
{getKeyIcon(keyItem)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold truncate">{keyItem.label}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('auth.keyType', { type: getKeyTypeDisplay(keyItem, isMac) })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Export to field */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-muted-foreground">{t('keychain.export.exportToRequired')}</Label>
|
||||
<Button
|
||||
variant="link"
|
||||
className="h-auto p-0 text-primary text-sm"
|
||||
onClick={onShowHostSelector}
|
||||
>
|
||||
{t('keychain.export.selectHost')}
|
||||
</Button>
|
||||
</div>
|
||||
<Input
|
||||
value={exportHost?.label || ''}
|
||||
readOnly
|
||||
placeholder={t('keychain.export.selectHostPlaceholder')}
|
||||
className="bg-muted/50 cursor-pointer"
|
||||
onClick={onShowHostSelector}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Location field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.export.locationLabel')}</Label>
|
||||
<Input
|
||||
value={exportLocation}
|
||||
onChange={e => setExportLocation(e.target.value)}
|
||||
placeholder=".ssh"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filename field */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.export.filenameLabel')}</Label>
|
||||
<Input
|
||||
value={exportFilename}
|
||||
onChange={e => setExportFilename(e.target.value)}
|
||||
placeholder="authorized_keys"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Info note */}
|
||||
<div className="flex items-start gap-2 p-3 bg-muted/50 border border-border/60 rounded-lg">
|
||||
<Info size={14} className="mt-0.5 text-muted-foreground shrink-0" />
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('keychain.export.note.supportsOnly')}{' '}
|
||||
<span className="font-semibold text-foreground">UNIX</span>{' '}
|
||||
{t('keychain.export.note.systems')}{' '}
|
||||
{t('keychain.export.note.use')}{' '}
|
||||
<span className="font-semibold text-foreground">{t('keychain.export.advanced')}</span>{' '}
|
||||
{t('keychain.export.note.customize')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Advanced collapsible */}
|
||||
<Collapsible open={exportAdvancedOpen} onOpenChange={setExportAdvancedOpen}>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button variant="ghost" className="w-full justify-between px-0 h-10 hover:bg-transparent hover:text-current">
|
||||
<span className="font-medium">{t('keychain.export.advanced')}</span>
|
||||
<ChevronRight size={16} className={cn(
|
||||
"transition-transform",
|
||||
exportAdvancedOpen && "rotate-90"
|
||||
)} />
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="space-y-2 pt-2">
|
||||
<Label className="text-muted-foreground">{t('keychain.export.scriptRequired')}</Label>
|
||||
<Textarea
|
||||
value={exportScript}
|
||||
onChange={e => setExportScript(e.target.value)}
|
||||
className="min-h-[180px] font-mono text-xs"
|
||||
placeholder={t('keychain.export.scriptPlaceholder')}
|
||||
/>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
|
||||
{/* Export button */}
|
||||
<Button
|
||||
className="w-full h-11"
|
||||
disabled={!exportHost || !exportLocation || !exportFilename || isExporting}
|
||||
onClick={handleExport}
|
||||
>
|
||||
{isExporting ? t('keychain.export.exporting') : t('keychain.export.exportAndAttach')}
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -15,8 +15,6 @@ export { IdentityCard } from './IdentityCard';
|
||||
export { KeyCard } from './KeyCard';
|
||||
|
||||
// Panel components
|
||||
export { EditKeyPanel } from './EditKeyPanel';
|
||||
export { ExportKeyPanel } from './ExportKeyPanel';
|
||||
export { GenerateStandardPanel } from './GenerateStandardPanel';
|
||||
export { IdentityPanel } from './IdentityPanel';
|
||||
export { ImportKeyPanel } from './ImportKeyPanel';
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useCallback } from "react";
|
||||
import { Check, Moon, Palette, Sun } from "lucide-react";
|
||||
import { Check, Monitor, Moon, Palette, Sun } from "lucide-react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES } from "../../../infrastructure/config/uiThemes";
|
||||
import { useAvailableUIFonts } from "../../../application/state/uiFontStore";
|
||||
@@ -9,8 +9,8 @@ import { SectionHeader, SettingsTabContent, SettingRow, Toggle, Select } from ".
|
||||
import { FontSelect } from "../FontSelect";
|
||||
|
||||
export default function SettingsAppearanceTab(props: {
|
||||
theme: "dark" | "light";
|
||||
setTheme: (theme: "dark" | "light") => void;
|
||||
theme: "dark" | "light" | "system";
|
||||
setTheme: (theme: "dark" | "light" | "system") => void;
|
||||
lightUiThemeId: string;
|
||||
setLightUiThemeId: (themeId: string) => void;
|
||||
darkUiThemeId: string;
|
||||
@@ -97,6 +97,12 @@ export default function SettingsAppearanceTab(props: {
|
||||
{ name: "Slate", value: "215 16% 47%" },
|
||||
];
|
||||
|
||||
const THEME_OPTIONS: { value: "light" | "system" | "dark"; icon: React.ReactNode; label: string }[] = [
|
||||
{ value: "light", icon: <Sun size={14} />, label: t("settings.appearance.theme.light") },
|
||||
{ value: "system", icon: <Monitor size={14} />, label: t("settings.appearance.theme.system") },
|
||||
{ value: "dark", icon: <Moon size={14} />, label: t("settings.appearance.theme.dark") },
|
||||
];
|
||||
|
||||
const renderThemeSwatches = (
|
||||
options: { id: string; name: string; tokens: { background: string } }[],
|
||||
value: string,
|
||||
@@ -153,13 +159,25 @@ export default function SettingsAppearanceTab(props: {
|
||||
<SectionHeader title={t("settings.appearance.uiTheme")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.appearance.darkMode")}
|
||||
description={t("settings.appearance.darkMode.desc")}
|
||||
label={t("settings.appearance.theme")}
|
||||
description={t("settings.appearance.theme.desc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sun size={14} className="text-muted-foreground" />
|
||||
<Toggle checked={theme === "dark"} onChange={(v) => setTheme(v ? "dark" : "light")} />
|
||||
<Moon size={14} className="text-muted-foreground" />
|
||||
<div className="flex items-center rounded-lg border border-border bg-muted/50 p-0.5">
|
||||
{THEME_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => setTheme(opt.value)}
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2.5 py-1 rounded-md text-xs font-medium transition-colors",
|
||||
theme === opt.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
{opt.icon}
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
@@ -1,51 +1,73 @@
|
||||
import React, { useCallback } from "react";
|
||||
import type { Host, Identity, Snippet, SSHKey } from "../../../domain/models";
|
||||
import type { PortForwardingRule } from "../../../domain/models";
|
||||
import type { SyncPayload } from "../../../domain/sync";
|
||||
import { buildSyncPayload, applySyncPayload } from "../../../domain/syncPayload";
|
||||
import type { SyncableVaultData } from "../../../domain/syncPayload";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { CloudSyncSettings } from "../../CloudSyncSettings";
|
||||
import { SettingsTabContent } from "../settings-ui";
|
||||
|
||||
export default function SettingsSyncTab(props: {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
vault: SyncableVaultData;
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
importDataFromString: (data: string) => void;
|
||||
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
clearVaultData: () => void;
|
||||
}) {
|
||||
const { hosts, keys, identities, snippets, importDataFromString, clearVaultData } = props;
|
||||
const {
|
||||
vault,
|
||||
portForwardingRules,
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
clearVaultData,
|
||||
} = props;
|
||||
|
||||
const buildSyncPayload = useCallback((): SyncPayload => {
|
||||
return {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
customGroups: [],
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}, [hosts, keys, identities, snippets]);
|
||||
|
||||
const applySyncPayload = useCallback(
|
||||
(payload: SyncPayload) => {
|
||||
importDataFromString(
|
||||
JSON.stringify({
|
||||
hosts: payload.hosts,
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
}),
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
// If hook state is empty but localStorage has data, the async store
|
||||
// initialization hasn't finished yet. Read from localStorage directly
|
||||
// to avoid uploading empty arrays and overwriting the remote snapshot.
|
||||
let effectiveRules = portForwardingRules;
|
||||
if (effectiveRules.length === 0) {
|
||||
const stored = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
);
|
||||
if (stored && Array.isArray(stored) && stored.length > 0) {
|
||||
// Strip transient per-device fields (status, error, lastUsedAt)
|
||||
// that setGlobalRules persists to localStorage but shouldn't be
|
||||
// included in the cloud sync snapshot.
|
||||
effectiveRules = stored.map(({ status: _status, error: _error, ...rest }) => ({
|
||||
...rest,
|
||||
status: "inactive" as const,
|
||||
error: undefined,
|
||||
lastUsedAt: undefined,
|
||||
}));
|
||||
}
|
||||
}
|
||||
return buildSyncPayload(vault, effectiveRules);
|
||||
}, [vault, portForwardingRules]);
|
||||
|
||||
const onApplyPayload = useCallback(
|
||||
(payload: SyncPayload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
});
|
||||
},
|
||||
[importDataFromString],
|
||||
[importDataFromString, importPortForwardingRules],
|
||||
);
|
||||
|
||||
const clearAllLocalData = useCallback(() => {
|
||||
clearVaultData();
|
||||
importPortForwardingRules([]);
|
||||
}, [clearVaultData, importPortForwardingRules]);
|
||||
|
||||
return (
|
||||
<SettingsTabContent value="sync">
|
||||
<CloudSyncSettings
|
||||
onBuildPayload={buildSyncPayload}
|
||||
onApplyPayload={applySyncPayload}
|
||||
onClearLocalData={clearVaultData}
|
||||
onBuildPayload={onBuildPayload}
|
||||
onApplyPayload={onApplyPayload}
|
||||
onClearLocalData={clearAllLocalData}
|
||||
/>
|
||||
</SettingsTabContent>
|
||||
);
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
/**
|
||||
* Settings System Tab - System information, temp file management, session logs, and global hotkey
|
||||
*/
|
||||
import { FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import { Download, ExternalLink, FileText, FolderOpen, HardDrive, Keyboard, RefreshCw, RotateCcw, Trash2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { getCredentialProtectionAvailability } from "../../../infrastructure/services/credentialProtection";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import {
|
||||
checkForUpdate,
|
||||
downloadUpdate,
|
||||
installUpdate,
|
||||
onDownloadProgress,
|
||||
onDownloaded,
|
||||
onError as onUpdateError,
|
||||
getReleasesUrl,
|
||||
} from "../../../infrastructure/services/updateService";
|
||||
import { SessionLogFormat, keyEventToString } from "../../../domain/models";
|
||||
import { TabsContent } from "../../ui/tabs";
|
||||
import { Button } from "../../ui/button";
|
||||
@@ -65,6 +74,82 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [credentialsAvailable, setCredentialsAvailable] = useState<boolean | null>(null);
|
||||
const [isCheckingCredentials, setIsCheckingCredentials] = useState(false);
|
||||
|
||||
// Software Update state
|
||||
type UpdateStatus = 'idle' | 'checking' | 'available' | 'up-to-date' | 'downloading' | 'ready' | 'error';
|
||||
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
|
||||
const [updateVersion, setUpdateVersion] = useState('');
|
||||
const [updatePercent, setUpdatePercent] = useState(0);
|
||||
const [updateError, setUpdateError] = useState('');
|
||||
const [updateSupported, setUpdateSupported] = useState(true);
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
|
||||
// Load app version on mount
|
||||
useEffect(() => {
|
||||
const promise = netcattyBridge.get()?.getAppInfo?.();
|
||||
if (promise) {
|
||||
promise.then((info) => {
|
||||
setAppVersion(info?.version ?? '');
|
||||
}).catch(() => {});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Subscribe to auto-update events
|
||||
useEffect(() => {
|
||||
const cleanupProgress = onDownloadProgress((p) => {
|
||||
setUpdatePercent(Math.round(p.percent));
|
||||
});
|
||||
const cleanupDownloaded = onDownloaded(() => {
|
||||
setUpdateStatus('ready');
|
||||
});
|
||||
const cleanupError = onUpdateError((payload) => {
|
||||
setUpdateError(payload.error);
|
||||
setUpdateStatus('error');
|
||||
});
|
||||
return () => {
|
||||
cleanupProgress?.();
|
||||
cleanupDownloaded?.();
|
||||
cleanupError?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleCheckForUpdate = useCallback(async () => {
|
||||
setUpdateStatus('checking');
|
||||
setUpdateError('');
|
||||
const result = await checkForUpdate();
|
||||
if (result.error) {
|
||||
setUpdateError(result.error);
|
||||
setUpdateSupported(result.supported !== false);
|
||||
setUpdateStatus('error');
|
||||
} else if (result.available && result.version) {
|
||||
setUpdateVersion(result.version);
|
||||
setUpdateSupported(result.supported !== false);
|
||||
setUpdateStatus('available');
|
||||
} else {
|
||||
setUpdateSupported(result.supported !== false);
|
||||
setUpdateStatus('up-to-date');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDownloadUpdate = useCallback(async () => {
|
||||
setUpdateStatus('downloading');
|
||||
setUpdatePercent(0);
|
||||
const result = await downloadUpdate();
|
||||
if (!result.success) {
|
||||
setUpdateError(result.error ?? t('settings.update.downloadError'));
|
||||
setUpdateStatus('error');
|
||||
}
|
||||
// Success is handled by onDownloaded event
|
||||
}, [t]);
|
||||
|
||||
const handleInstallUpdate = useCallback(() => {
|
||||
installUpdate();
|
||||
}, []);
|
||||
|
||||
const handleOpenReleases = useCallback(() => {
|
||||
const url = updateVersion ? getReleasesUrl(updateVersion) : getReleasesUrl();
|
||||
netcattyBridge.get()?.openExternal?.(url);
|
||||
}, [updateVersion]);
|
||||
|
||||
const loadTempDirInfo = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getTempDirInfo) return;
|
||||
@@ -218,6 +303,108 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Software Update Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download size={18} className="text-muted-foreground" />
|
||||
<h3 className="text-base font-medium">{t('settings.update.title')}</h3>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border/60 p-4 space-y-3">
|
||||
{/* Current version */}
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t('settings.update.currentVersion')}
|
||||
</span>
|
||||
<span className="text-sm font-mono">{appVersion || '...'}</span>
|
||||
</div>
|
||||
|
||||
{/* Status message */}
|
||||
{updateStatus === 'up-to-date' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('settings.update.upToDate')}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'available' && (
|
||||
<p className="text-sm text-blue-600 dark:text-blue-400">
|
||||
{t('settings.update.available').replace('{version}', updateVersion)}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'downloading' && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.update.downloading').replace('{percent}', String(updatePercent))}
|
||||
</p>
|
||||
<div className="h-2 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${updatePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{updateStatus === 'ready' && (
|
||||
<p className="text-sm text-green-600 dark:text-green-400">
|
||||
{t('settings.update.readyToInstall')}
|
||||
</p>
|
||||
)}
|
||||
{updateStatus === 'error' && (
|
||||
<p className="text-sm text-destructive">
|
||||
{updateError || t('settings.update.error')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Manual fallback hint when auto-update not supported */}
|
||||
{!updateSupported && updateStatus !== 'idle' && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('settings.update.manualDownloadHint')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center gap-2 pt-1">
|
||||
{(updateStatus === 'idle' || updateStatus === 'up-to-date' || updateStatus === 'error') && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheckForUpdate}
|
||||
disabled={updateStatus === 'checking'}
|
||||
>
|
||||
<RefreshCw size={14} className={cn('mr-1.5', updateStatus === 'checking' && 'animate-spin')} />
|
||||
{updateStatus === 'checking' ? t('settings.update.checking') : t('settings.update.checkForUpdates')}
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus === 'checking' && (
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
<RefreshCw size={14} className="mr-1.5 animate-spin" />
|
||||
{t('settings.update.checking')}
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus === 'available' && updateSupported && (
|
||||
<Button variant="default" size="sm" onClick={handleDownloadUpdate}>
|
||||
<Download size={14} className="mr-1.5" />
|
||||
{t('settings.update.download')}
|
||||
</Button>
|
||||
)}
|
||||
{updateStatus === 'ready' && (
|
||||
<Button variant="default" size="sm" onClick={handleInstallUpdate}>
|
||||
<RotateCcw size={14} className="mr-1.5" />
|
||||
{t('settings.update.restartNow')}
|
||||
</Button>
|
||||
)}
|
||||
{/* Manual fallback link — shown when unsupported, on error, or when update is available but unsupported */}
|
||||
{((updateStatus === 'error') || (updateStatus === 'available' && !updateSupported)) && (
|
||||
<Button variant="ghost" size="sm" onClick={handleOpenReleases}>
|
||||
<ExternalLink size={14} className="mr-1.5" />
|
||||
{t('settings.update.manualDownload')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('settings.update.hint')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Credential Protection Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -13,6 +13,7 @@ interface TransferTask {
|
||||
status: "pending" | "uploading" | "downloading" | "completed" | "failed" | "cancelled";
|
||||
error?: string;
|
||||
direction: "upload" | "download";
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
interface SftpModalUploadTasksProps {
|
||||
@@ -166,6 +167,9 @@ export const SftpModalUploadTasks: React.FC<SftpModalUploadTasksProps> = ({ task
|
||||
{task.status === "completed" && (
|
||||
<div className="text-[10px] text-green-600 mt-0.5">
|
||||
{t(task.direction === "download" ? "sftp.download.completed" : "sftp.upload.completed")} - {formatBytes(task.totalBytes)}
|
||||
{task.targetPath && (
|
||||
<span className="text-muted-foreground ml-1">→ {task.targetPath}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{task.status === "cancelled" && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { Host, RemoteFile } from "../../../types";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isSessionError } from "../../../application/state/sftp/errors";
|
||||
import { toast } from "../../ui/toast";
|
||||
|
||||
interface UseSftpModalSessionParams {
|
||||
@@ -78,11 +79,12 @@ export const useSftpModalSession = ({
|
||||
getHomeDir,
|
||||
onClearSelection,
|
||||
}: UseSftpModalSessionParams): UseSftpModalSessionResult => {
|
||||
const [currentPath, setCurrentPath] = useState("/");
|
||||
const [currentPath, setCurrentPathState] = useState("/");
|
||||
const [files, setFiles] = useState<RemoteFile[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [reconnecting, setReconnecting] = useState(false);
|
||||
const [sessionVersion, setSessionVersion] = useState(0);
|
||||
const currentPathRef = useRef(currentPath);
|
||||
const sftpIdRef = useRef<string | null>(null);
|
||||
const closingPromiseRef = useRef<Promise<void> | null>(null);
|
||||
const initializedRef = useRef(false);
|
||||
@@ -98,6 +100,10 @@ export const useSftpModalSession = ({
|
||||
Map<string, { files: RemoteFile[]; timestamp: number }>
|
||||
>(new Map());
|
||||
const loadSeqRef = useRef(0);
|
||||
const setCurrentPath = useCallback((path: string) => {
|
||||
currentPathRef.current = path;
|
||||
setCurrentPathState(path);
|
||||
}, []);
|
||||
const bumpSessionVersion = useCallback(() => {
|
||||
setSessionVersion((prev) => prev + 1);
|
||||
}, []);
|
||||
@@ -187,20 +193,7 @@ export const useSftpModalSession = ({
|
||||
await currentClosePromise;
|
||||
}, [bumpSessionVersion, closeSftp, isLocalSession]);
|
||||
|
||||
const isSessionError = useCallback((err: unknown): boolean => {
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
return (
|
||||
msg.includes("session not found") ||
|
||||
msg.includes("sftp session") ||
|
||||
msg.includes("not found") ||
|
||||
msg.includes("closed") ||
|
||||
msg.includes("connection reset") ||
|
||||
msg.includes("write after end") ||
|
||||
msg.includes("no response") ||
|
||||
msg.includes("client disconnected")
|
||||
);
|
||||
}, []);
|
||||
// Use shared session-error classifier from errors.ts
|
||||
|
||||
const handleSessionError = useCallback(async () => {
|
||||
if (reconnectingRef.current) return;
|
||||
@@ -212,9 +205,30 @@ export const useSftpModalSession = ({
|
||||
try {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
await closeSftpSession();
|
||||
await ensureSftp();
|
||||
const newSftpId = await ensureSftp();
|
||||
reconnectingRef.current = false;
|
||||
setReconnecting(false);
|
||||
|
||||
// Auto-reload current directory after successful reconnect
|
||||
try {
|
||||
const reloadPath = currentPathRef.current;
|
||||
const reloadRequestId = loadSeqRef.current;
|
||||
const list = await listSftp(newSftpId, reloadPath);
|
||||
if (
|
||||
reloadRequestId !== loadSeqRef.current ||
|
||||
currentPathRef.current !== reloadPath
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClearSelection();
|
||||
setFiles(list);
|
||||
dirCacheRef.current.set(`${host.id}::${reloadPath}`, {
|
||||
files: list,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
} catch {
|
||||
// Reload failed — UI still shows old data, user can manually refresh
|
||||
}
|
||||
return;
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
@@ -230,7 +244,7 @@ export const useSftpModalSession = ({
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
}, [closeSftpSession, ensureSftp, t]);
|
||||
}, [closeSftpSession, ensureSftp, listSftp, host.id, onClearSelection, t]);
|
||||
|
||||
const loadFiles = useCallback(
|
||||
async (path: string, options?: { force?: boolean }) => {
|
||||
@@ -283,7 +297,7 @@ export const useSftpModalSession = ({
|
||||
}
|
||||
}
|
||||
},
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length, onClearSelection],
|
||||
[ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, handleSessionError, files.length, onClearSelection],
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
@@ -401,6 +415,7 @@ export const useSftpModalSession = ({
|
||||
loadFiles,
|
||||
onClearSelection,
|
||||
open,
|
||||
setCurrentPath,
|
||||
t,
|
||||
]);
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ interface TransferTask {
|
||||
fileCount?: number;
|
||||
completedCount?: number;
|
||||
direction: "upload" | "download";
|
||||
targetPath?: string;
|
||||
}
|
||||
|
||||
// Keep UploadTask as alias for backwards compatibility
|
||||
@@ -246,6 +247,7 @@ export const useSftpModalTransfers = ({
|
||||
startTime: Date.now(),
|
||||
isDirectory: task.isDirectory,
|
||||
direction: "upload",
|
||||
targetPath: currentPath,
|
||||
};
|
||||
setUploadTasks(prev => [...prev, uploadTask]);
|
||||
},
|
||||
@@ -343,7 +345,7 @@ export const useSftpModalTransfers = ({
|
||||
);
|
||||
},
|
||||
};
|
||||
}, [t]);
|
||||
}, [t, currentPath]);
|
||||
|
||||
// Helper function to perform upload with compression setting from user preference
|
||||
const performUpload = useCallback(async (
|
||||
|
||||
@@ -169,8 +169,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
)}
|
||||
|
||||
{/* Bookmark button with dropdown */}
|
||||
{!pane.connection?.isLocal && (
|
||||
<Popover>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -237,7 +236,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = ({
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<div className="ml-auto flex items-center gap-0.5">
|
||||
{!pane.connection?.isLocal && (
|
||||
|
||||
@@ -25,6 +25,7 @@ import { useSftpPaneSorting } from "./hooks/useSftpPaneSorting";
|
||||
import { useSftpPaneVirtualList } from "./hooks/useSftpPaneVirtualList";
|
||||
import { useSftpDialogActionHandler } from "./hooks/useSftpDialogAction";
|
||||
import { useSftpBookmarks } from "./hooks/useSftpBookmarks";
|
||||
import { useLocalSftpBookmarks } from "./hooks/useLocalSftpBookmarks";
|
||||
|
||||
interface SftpPaneWrapperProps {
|
||||
side: "left" | "right";
|
||||
@@ -98,16 +99,20 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
(updated: Host) => updateHosts(hosts.map((h) => (h.id === updated.id ? updated : h))),
|
||||
[hosts, updateHosts],
|
||||
);
|
||||
const remoteBookmarks = useSftpBookmarks({
|
||||
host: currentHost,
|
||||
currentPath: pane.connection?.currentPath,
|
||||
onUpdateHost,
|
||||
});
|
||||
const localBookmarks = useLocalSftpBookmarks({
|
||||
currentPath: pane.connection?.currentPath,
|
||||
});
|
||||
const {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
} = useSftpBookmarks({
|
||||
host: currentHost,
|
||||
currentPath: pane.connection?.currentPath,
|
||||
onUpdateHost,
|
||||
});
|
||||
} = pane.connection?.isLocal ? localBookmarks : remoteBookmarks;
|
||||
|
||||
const { filteredFiles, sortedDisplayFiles } = useSftpPaneFiles({
|
||||
files: pane.files,
|
||||
|
||||
@@ -121,7 +121,7 @@ const SftpTransferItemInner: React.FC<SftpTransferItemProps> = ({ task, onCancel
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{task.status === 'failed' && (
|
||||
{task.status === 'failed' && task.retryable !== false && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={onRetry} title="Retry">
|
||||
<RefreshCw size={12} />
|
||||
</Button>
|
||||
|
||||
73
components/sftp/hooks/useLocalSftpBookmarks.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { useCallback, useMemo, useSyncExternalStore } from "react";
|
||||
import type { SftpBookmark } from "../../../domain/models";
|
||||
import { localStorageAdapter } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_SFTP_LOCAL_BOOKMARKS } from "../../../infrastructure/config/storageKeys";
|
||||
|
||||
// ── Shared external store so every hook instance sees the same bookmarks ──
|
||||
|
||||
type Listener = () => void;
|
||||
const listeners = new Set<Listener>();
|
||||
|
||||
let snapshot: SftpBookmark[] =
|
||||
localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS) ?? [];
|
||||
|
||||
function subscribe(listener: Listener) {
|
||||
listeners.add(listener);
|
||||
return () => { listeners.delete(listener); };
|
||||
}
|
||||
|
||||
function getSnapshot() {
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function setBookmarks(next: SftpBookmark[] | ((prev: SftpBookmark[]) => SftpBookmark[])) {
|
||||
snapshot = typeof next === "function" ? next(snapshot) : next;
|
||||
localStorageAdapter.write(STORAGE_KEY_SFTP_LOCAL_BOOKMARKS, snapshot);
|
||||
for (const l of listeners) l();
|
||||
}
|
||||
|
||||
// ── Hook ──
|
||||
|
||||
interface UseLocalSftpBookmarksParams {
|
||||
currentPath: string | undefined;
|
||||
}
|
||||
|
||||
export const useLocalSftpBookmarks = ({
|
||||
currentPath,
|
||||
}: UseLocalSftpBookmarksParams) => {
|
||||
const bookmarks = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
||||
|
||||
const isCurrentPathBookmarked = useMemo(
|
||||
() => !!currentPath && bookmarks.some((b) => b.path === currentPath),
|
||||
[currentPath, bookmarks],
|
||||
);
|
||||
|
||||
const toggleBookmark = useCallback(() => {
|
||||
if (!currentPath) return;
|
||||
if (isCurrentPathBookmarked) {
|
||||
setBookmarks((prev) => prev.filter((b) => b.path !== currentPath));
|
||||
} else {
|
||||
const isRoot = currentPath === "/" || /^[A-Za-z]:\\?$/.test(currentPath);
|
||||
const label = isRoot
|
||||
? currentPath
|
||||
: currentPath.split(/[\\/]/).filter(Boolean).pop() || currentPath;
|
||||
const newBookmark: SftpBookmark = {
|
||||
id: `bm-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
path: currentPath,
|
||||
label,
|
||||
};
|
||||
setBookmarks((prev) => [...prev, newBookmark]);
|
||||
}
|
||||
}, [currentPath, isCurrentPathBookmarked]);
|
||||
|
||||
const deleteBookmark = useCallback((id: string) => {
|
||||
setBookmarks((prev) => prev.filter((b) => b.id !== id));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
bookmarks,
|
||||
isCurrentPathBookmarked,
|
||||
toggleBookmark,
|
||||
deleteBookmark,
|
||||
};
|
||||
};
|
||||
@@ -238,7 +238,7 @@ export const useSftpKeyboardShortcuts = ({
|
||||
case "sftpSelectAll": {
|
||||
// Select all files in the current pane
|
||||
const term = pane.filter.trim().toLowerCase();
|
||||
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles, pane.connection.isLocal);
|
||||
let visibleFiles = filterHiddenFiles(pane.files, showHiddenFiles);
|
||||
if (term) {
|
||||
visibleFiles = visibleFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
|
||||
@@ -29,12 +29,12 @@ export const useSftpPaneFiles = ({
|
||||
}: UseSftpPaneFilesParams): UseSftpPaneFilesResult => {
|
||||
const filteredFiles = useMemo(() => {
|
||||
const term = filter.trim().toLowerCase();
|
||||
let nextFiles = filterHiddenFiles(files, showHiddenFiles, connection?.isLocal);
|
||||
let nextFiles = filterHiddenFiles(files, showHiddenFiles);
|
||||
if (!term) return nextFiles;
|
||||
return nextFiles.filter(
|
||||
(f) => f.name === ".." || f.name.toLowerCase().includes(term),
|
||||
);
|
||||
}, [files, filter, showHiddenFiles, connection?.isLocal]);
|
||||
}, [files, filter, showHiddenFiles]);
|
||||
|
||||
const displayFiles = useMemo(() => {
|
||||
if (!connection) return [];
|
||||
|
||||
@@ -192,39 +192,37 @@ export const isNavigableDirectory = (entry: SftpFileEntry): boolean => {
|
||||
* Check if a file is hidden
|
||||
* - Windows: checks the `hidden` attribute (set by localFsBridge)
|
||||
* - Unix/Linux (remote): also treats dotfiles (names starting with '.') as hidden
|
||||
* The ".." parent directory entry is never considered hidden.
|
||||
/**
|
||||
* A file is considered hidden if:
|
||||
* - It has the Windows hidden attribute (`hidden === true`), OR
|
||||
* - Its name starts with a dot (Unix/Linux dotfile convention)
|
||||
*
|
||||
* @param isLocal When true, only the Windows hidden attribute is checked.
|
||||
* This prevents `.gitignore` etc. from disappearing on local Windows panes.
|
||||
* The ".." parent directory entry is never considered hidden.
|
||||
*/
|
||||
export const isHiddenFile = <T extends { name: string; hidden?: boolean }>(
|
||||
file: T,
|
||||
isLocal?: boolean
|
||||
): boolean => {
|
||||
if (file.name === "..") return false;
|
||||
// Windows hidden attribute — always checked
|
||||
// Windows hidden attribute
|
||||
if (file.hidden === true) return true;
|
||||
// Unix/Linux dotfile convention — only on remote/non-local connections
|
||||
if (!isLocal && file.name.startsWith(".")) return true;
|
||||
// Unix/Linux dotfile convention
|
||||
if (file.name.startsWith(".")) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
/** @deprecated Use isHiddenFile instead */
|
||||
export const isWindowsHiddenFile = <T extends { name: string; hidden?: boolean }>(file: T): boolean =>
|
||||
isHiddenFile(file, true);
|
||||
isHiddenFile(file);
|
||||
|
||||
/**
|
||||
* Filter files based on hidden file visibility setting.
|
||||
* Filters Windows hidden files and, on remote connections, Unix/Linux dotfiles.
|
||||
* Filters Windows hidden files and Unix/Linux dotfiles on all connections.
|
||||
* Always preserves ".." parent directory entry.
|
||||
*
|
||||
* @param isLocal Pass true for local filesystem panes to skip dotfile filtering.
|
||||
*/
|
||||
export const filterHiddenFiles = <T extends { name: string; hidden?: boolean }>(
|
||||
files: T[],
|
||||
showHiddenFiles: boolean,
|
||||
isLocal?: boolean
|
||||
): T[] => {
|
||||
if (showHiddenFiles) return files;
|
||||
return files.filter((f) => !isHiddenFile(f, isLocal));
|
||||
return files.filter((f) => !isHiddenFile(f));
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ export interface TerminalConnectionDialogProps {
|
||||
authProps: Omit<TerminalAuthDialogProps, 'keys'>;
|
||||
keys: SSHKey[];
|
||||
// Progress props
|
||||
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs' | '_setShowLogs'>;
|
||||
progressProps: Omit<TerminalConnectionProgressProps, 'status' | 'error' | 'showLogs'>;
|
||||
}
|
||||
|
||||
// Helper to get protocol display info
|
||||
@@ -166,7 +166,6 @@ export const TerminalConnectionDialog: React.FC<TerminalConnectionDialogProps> =
|
||||
status={status}
|
||||
error={error}
|
||||
showLogs={showLogs}
|
||||
_setShowLogs={setShowLogs}
|
||||
{...progressProps}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -14,7 +14,6 @@ export interface TerminalConnectionProgressProps {
|
||||
timeLeft: number;
|
||||
isCancelling: boolean;
|
||||
showLogs: boolean;
|
||||
_setShowLogs: (show: boolean) => void; // Reserved for future log toggle UI within this component
|
||||
progressLogs: string[];
|
||||
onCancel: () => void;
|
||||
onRetry: () => void;
|
||||
@@ -26,7 +25,6 @@ export const TerminalConnectionProgress: React.FC<TerminalConnectionProgressProp
|
||||
timeLeft,
|
||||
isCancelling,
|
||||
showLogs,
|
||||
_setShowLogs, // Reserved for future log toggle UI within this component
|
||||
progressLogs,
|
||||
onCancel,
|
||||
onRetry,
|
||||
|
||||
@@ -28,6 +28,7 @@ export interface TerminalContextMenuProps {
|
||||
hotkeyScheme?: 'disabled' | 'mac' | 'pc';
|
||||
keyBindings?: KeyBinding[];
|
||||
rightClickBehavior?: RightClickBehavior;
|
||||
isAlternateScreen?: boolean;
|
||||
onCopy?: () => void;
|
||||
onPaste?: () => void;
|
||||
onSelectAll?: () => void;
|
||||
@@ -44,6 +45,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
hotkeyScheme = 'mac',
|
||||
keyBindings,
|
||||
rightClickBehavior = 'context-menu',
|
||||
isAlternateScreen = false,
|
||||
onCopy,
|
||||
onPaste,
|
||||
onSelectAll,
|
||||
@@ -73,10 +75,14 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
const splitVShortcut = getShortcut('split-vertical');
|
||||
const clearShortcut = getShortcut('clear-buffer');
|
||||
|
||||
const showContextMenu = rightClickBehavior === 'context-menu';
|
||||
const showContextMenu = rightClickBehavior === 'context-menu' && !isAlternateScreen;
|
||||
|
||||
const handleRightClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// In alternate screen (tmux, vim, etc.), let the terminal application
|
||||
// handle right-click natively to avoid conflicting menus
|
||||
if (isAlternateScreen) return;
|
||||
|
||||
if (rightClickBehavior === 'paste') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -87,7 +93,7 @@ export const TerminalContextMenu: React.FC<TerminalContextMenuProps> = ({
|
||||
onSelectWord?.();
|
||||
}
|
||||
},
|
||||
[rightClickBehavior, onPaste, onSelectWord],
|
||||
[rightClickBehavior, onPaste, onSelectWord, isAlternateScreen],
|
||||
);
|
||||
|
||||
// Always use ContextMenu wrapper to maintain consistent React tree structure
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
* Terminal Toolbar
|
||||
* Displays SFTP, Scripts, Theme, Highlight, Search buttons and close button in terminal status bar
|
||||
*/
|
||||
import { FolderInput, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import { Check, FolderInput, Languages, X, Zap, Palette, Search, TextCursorInput } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { Snippet, Host } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { Popover, PopoverClose, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ScrollArea } from '../ui/scroll-area';
|
||||
import ThemeCustomizeModal from './ThemeCustomizeModal';
|
||||
import HostKeywordHighlightPopover from './HostKeywordHighlightPopover';
|
||||
@@ -35,6 +36,9 @@ export interface TerminalToolbarProps {
|
||||
// Compose bar
|
||||
isComposeBarOpen?: boolean;
|
||||
onToggleComposeBar?: () => void;
|
||||
// Terminal encoding
|
||||
terminalEncoding?: 'utf-8' | 'gb18030';
|
||||
onSetTerminalEncoding?: (encoding: 'utf-8' | 'gb18030') => void;
|
||||
}
|
||||
|
||||
export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
@@ -58,6 +62,8 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
onToggleSearch,
|
||||
isComposeBarOpen,
|
||||
onToggleComposeBar,
|
||||
terminalEncoding,
|
||||
onSetTerminalEncoding,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [themeModalOpen, setThemeModalOpen] = useState(false);
|
||||
@@ -66,6 +72,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
|
||||
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
|
||||
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
|
||||
const isSSHSession = !isLocalTerminal && !isSerialTerminal && host?.protocol !== 'telnet' && host?.protocol !== 'mosh' && !host?.moshEnabled && host?.hostname !== 'localhost';
|
||||
const hidesSftp = isLocalTerminal || isSerialTerminal;
|
||||
|
||||
const currentThemeId = host?.theme || defaultThemeId;
|
||||
@@ -118,6 +125,44 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isSSHSession && onSetTerminalEncoding && (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
className={buttonBase}
|
||||
title={t("terminal.toolbar.encoding")}
|
||||
aria-label={t("terminal.toolbar.encoding")}
|
||||
>
|
||||
<Languages size={12} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-36 p-1" align="start">
|
||||
{(["utf-8", "gb18030"] as const).map((enc) => (
|
||||
<PopoverClose asChild key={enc}>
|
||||
<button
|
||||
className={cn(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors",
|
||||
terminalEncoding === enc && "font-medium"
|
||||
)}
|
||||
onClick={() => onSetTerminalEncoding(enc)}
|
||||
>
|
||||
<Check
|
||||
size={12}
|
||||
className={cn(
|
||||
"shrink-0",
|
||||
terminalEncoding === enc ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
{t(`terminal.toolbar.encoding.${enc === "utf-8" ? "utf8" : enc}`)}
|
||||
</button>
|
||||
</PopoverClose>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
|
||||
<Popover open={isScriptsOpen} onOpenChange={setIsScriptsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
|
||||
@@ -14,12 +14,14 @@ export const useTerminalContextActions = ({
|
||||
terminalBackend,
|
||||
onHasSelectionChange,
|
||||
disableBracketedPasteRef,
|
||||
scrollOnPasteRef,
|
||||
}: {
|
||||
termRef: RefObject<XTerm | null>;
|
||||
sessionRef: RefObject<string | null>;
|
||||
terminalBackend: TerminalBackendWriteApi;
|
||||
onHasSelectionChange?: (hasSelection: boolean) => void;
|
||||
disableBracketedPasteRef?: RefObject<boolean>;
|
||||
scrollOnPasteRef?: RefObject<boolean>;
|
||||
}) => {
|
||||
const onCopy = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
@@ -39,11 +41,14 @@ export const useTerminalContextActions = ({
|
||||
let data = normalizeLineEndings(text);
|
||||
if (term.modes.bracketedPasteMode && !disableBracketedPasteRef?.current) data = wrapBracketedPaste(data);
|
||||
terminalBackend.writeToSession(sessionRef.current, data);
|
||||
if (scrollOnPasteRef?.current) {
|
||||
term.scrollToBottom();
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn("Failed to paste from clipboard", err);
|
||||
}
|
||||
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef]);
|
||||
}, [sessionRef, termRef, terminalBackend, disableBracketedPasteRef, scrollOnPasteRef]);
|
||||
|
||||
const onSelectAll = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* Terminal components module
|
||||
* Re-exports all terminal sub-components
|
||||
*/
|
||||
|
||||
export { TerminalAuthDialog } from './TerminalAuthDialog';
|
||||
export type { TerminalAuthDialogProps } from './TerminalAuthDialog';
|
||||
|
||||
export { TerminalConnectionProgress } from './TerminalConnectionProgress';
|
||||
export type { TerminalConnectionProgressProps } from './TerminalConnectionProgress';
|
||||
|
||||
export { TerminalToolbar } from './TerminalToolbar';
|
||||
export type { TerminalToolbarProps } from './TerminalToolbar';
|
||||
|
||||
export { HostKeywordHighlightPopover } from './HostKeywordHighlightPopover';
|
||||
export type { HostKeywordHighlightPopoverProps } from './HostKeywordHighlightPopover';
|
||||
|
||||
export { TerminalConnectionDialog } from './TerminalConnectionDialog';
|
||||
export type { ChainProgress,TerminalConnectionDialogProps } from './TerminalConnectionDialog';
|
||||
|
||||
export { TerminalContextMenu } from './TerminalContextMenu';
|
||||
export type { TerminalContextMenuProps } from './TerminalContextMenu';
|
||||
|
||||
export { TerminalSearchBar } from './TerminalSearchBar';
|
||||
export type { TerminalSearchBarProps } from './TerminalSearchBar';
|
||||
|
||||
export { KeywordHighlighter } from './keywordHighlight';
|
||||
|
||||
export { useTerminalSearch } from './hooks/useTerminalSearch';
|
||||
export { useTerminalContextActions } from './hooks/useTerminalContextActions';
|
||||
export { useTerminalAuthState } from './hooks/useTerminalAuthState';
|
||||
@@ -91,6 +91,7 @@ export type TerminalSessionStartersContext = {
|
||||
setChainProgress: Dispatch<SetStateAction<ChainProgressState>>;
|
||||
t?: (key: string) => string;
|
||||
|
||||
onSessionAttached?: (sessionId: string) => void;
|
||||
onSessionExit?: (sessionId: string) => void;
|
||||
onTerminalDataCapture?: (sessionId: string, data: string) => void;
|
||||
onOsDetected?: (hostId: string, distro: string) => void;
|
||||
@@ -128,6 +129,7 @@ const attachSessionToTerminal = (
|
||||
},
|
||||
) => {
|
||||
ctx.sessionRef.current = id;
|
||||
ctx.onSessionAttached?.(id);
|
||||
|
||||
ctx.disposeDataRef.current = ctx.terminalBackend.onSessionData(id, (chunk) => {
|
||||
let data = chunk;
|
||||
@@ -188,9 +190,9 @@ const runDistroDetection = async (
|
||||
timeout: 8000,
|
||||
});
|
||||
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
||||
const idMatch = data.match(/ID=([\\w\\-]+)/i);
|
||||
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||||
const distro = idMatch
|
||||
? idMatch[1].replace(/"/g, "")
|
||||
? idMatch[1]
|
||||
: (data.split(/\s+/)[0] || "").toLowerCase();
|
||||
if (distro) ctx.onOsDetected?.(ctx.host.id, distro);
|
||||
} catch (err) {
|
||||
@@ -489,8 +491,11 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
|
||||
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
|
||||
ctx.hasRunStartupCommandRef.current = true;
|
||||
const scheduledSessionId = id;
|
||||
setTimeout(() => {
|
||||
if (!ctx.sessionRef.current) return;
|
||||
// Guard against stale timers: if the session changed (e.g. user
|
||||
// clicked Start Over quickly), skip to avoid double execution
|
||||
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
|
||||
if (ctx.onCommandExecuted) {
|
||||
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
@@ -609,8 +614,9 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
const commandToRun = ctx.startupCommand || ctx.host.startupCommand;
|
||||
if (commandToRun && !ctx.hasRunStartupCommandRef.current) {
|
||||
ctx.hasRunStartupCommandRef.current = true;
|
||||
const scheduledSessionId = id;
|
||||
setTimeout(() => {
|
||||
if (!ctx.sessionRef.current) return;
|
||||
if (!ctx.sessionRef.current || ctx.sessionRef.current !== scheduledSessionId) return;
|
||||
ctx.terminalBackend.writeToSession(ctx.sessionRef.current, `${commandToRun}\r`);
|
||||
if (ctx.onCommandExecuted) {
|
||||
ctx.onCommandExecuted(commandToRun, ctx.host.id, ctx.host.label, ctx.sessionId);
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "../../../infrastructure/config/xtermPerformance";
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { isMacPlatform, normalizeLineEndings, wrapBracketedPaste } from "../../../lib/utils";
|
||||
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
|
||||
import type {
|
||||
Host,
|
||||
KeyBinding,
|
||||
@@ -119,6 +120,12 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const settings = ctx.terminalSettingsRef.current;
|
||||
const rendererType = settings?.rendererType ?? "auto";
|
||||
const bridge = netcattyBridge.get();
|
||||
const isLocalTerminalHost = ctx.host.protocol === "local";
|
||||
const windowsPty =
|
||||
platform === "win32" && isLocalTerminalHost
|
||||
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
|
||||
: undefined;
|
||||
|
||||
const performanceConfig = resolveXTermPerformanceConfig({
|
||||
platform,
|
||||
@@ -157,6 +164,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
|
||||
const term = new XTerm({
|
||||
...performanceConfig.options,
|
||||
...(windowsPty ? { windowsPty } : {}),
|
||||
// Override ignoreBracketedPasteMode if user explicitly disables bracketed paste
|
||||
ignoreBracketedPasteMode: settings?.disableBracketedPaste ?? performanceConfig.options.ignoreBracketedPasteMode,
|
||||
fontSize: effectiveFontSize,
|
||||
|
||||
@@ -12,7 +12,7 @@ export const normalizeDistroId = (value?: string) => {
|
||||
if (v.includes('alpine')) return 'alpine';
|
||||
if (v.includes('amzn') || v.includes('amazon') || v.includes('aws')) return 'amazon';
|
||||
if (v.includes('opensuse') || v.includes('suse') || v.includes('sles')) return 'opensuse';
|
||||
if (v.includes('red hat') || v.includes('rhel')) return 'redhat';
|
||||
if (v.includes('red hat') || v.includes('redhat') || v.includes('rhel')) return 'redhat';
|
||||
if (v.includes('oracle')) return 'oracle';
|
||||
if (v.includes('kali')) return 'kali';
|
||||
return '';
|
||||
|
||||
@@ -438,15 +438,79 @@ export interface TerminalSettings {
|
||||
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
|
||||
}
|
||||
|
||||
const STRICT_IPV4_OCTET_PATTERN = '(?:25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)';
|
||||
|
||||
export const URL_HIGHLIGHT_PATTERN =
|
||||
"(?:\\bhttps?:\\/\\/\\[[0-9A-Fa-f:.]+\\](?::\\d+)?(?:[/?#][^\\s<>\"'`]*)?(?<![.,;:!?\\)}])|\\b(?:https?:\\/\\/|www\\.)[^\\s<>\"'`]+(?<![.,;:!?\\])}]))";
|
||||
export const IPV4_HIGHLIGHT_PATTERN =
|
||||
`(?<![\\w.])(?<!\\bver\\s)(?<!\\bversion\\s)(?:${STRICT_IPV4_OCTET_PATTERN}\\.){3}${STRICT_IPV4_OCTET_PATTERN}(?![\\w.])`;
|
||||
export const MAC_ADDRESS_HIGHLIGHT_PATTERN =
|
||||
'\\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b';
|
||||
|
||||
export const DEFAULT_KEYWORD_HIGHLIGHT_RULES: KeywordHighlightRule[] = [
|
||||
{ id: 'error', label: 'Error', patterns: ['\\[error\\]', '\\[err\\]', '\\berror\\b', '\\bfail(ed)?\\b', '\\bfatal\\b', '\\bcritical\\b', '\\bexception\\b'], color: '#F87171', enabled: true },
|
||||
{ id: 'warning', label: 'Warning', patterns: ['\\[warn(ing)?\\]', '\\bwarn(ing)?\\b', '\\bcaution\\b', '\\bdeprecated\\b'], color: '#FBBF24', enabled: true },
|
||||
{ id: 'ok', label: 'OK', patterns: ['\\[ok\\]', '\\bok\\b', '\\bsuccess(ful)?\\b', '\\bpassed\\b', '\\bcompleted\\b', '\\bdone\\b'], color: '#34D399', enabled: true },
|
||||
{ id: 'info', label: 'Info', patterns: ['\\[info\\]', '\\[notice\\]', '\\[note\\]', '\\bnotice\\b', '\\bnote\\b'], color: '#3B82F6', enabled: true },
|
||||
{ id: 'debug', label: 'Debug', patterns: ['\\[debug\\]', '\\[trace\\]', '\\[verbose\\]', '\\bdebug\\b', '\\btrace\\b', '\\bverbose\\b'], color: '#A78BFA', enabled: true },
|
||||
{ id: 'ip-mac', label: 'IP address & MAC', patterns: ['\\b(?:\\d{1,3}\\.){3}\\d{1,3}\\b', '\\b([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}\\b'], color: '#EC4899', enabled: true },
|
||||
{ id: 'ip-mac', label: 'URL, IP & MAC', patterns: [URL_HIGHLIGHT_PATTERN, IPV4_HIGHLIGHT_PATTERN, MAC_ADDRESS_HIGHLIGHT_PATTERN], color: '#EC4899', enabled: true },
|
||||
];
|
||||
|
||||
const cloneKeywordHighlightRule = (rule: KeywordHighlightRule): KeywordHighlightRule => ({
|
||||
...rule,
|
||||
patterns: [...rule.patterns],
|
||||
});
|
||||
|
||||
export const normalizeKeywordHighlightRules = (
|
||||
rules?: KeywordHighlightRule[],
|
||||
): KeywordHighlightRule[] => {
|
||||
if (!rules || rules.length === 0) {
|
||||
return DEFAULT_KEYWORD_HIGHLIGHT_RULES.map(cloneKeywordHighlightRule);
|
||||
}
|
||||
|
||||
const defaultRulesById = new Map(
|
||||
DEFAULT_KEYWORD_HIGHLIGHT_RULES.map((rule) => [rule.id, rule] as const),
|
||||
);
|
||||
|
||||
const normalizedRules = rules.map((rule) => {
|
||||
const defaultRule = defaultRulesById.get(rule.id);
|
||||
if (!defaultRule) {
|
||||
return cloneKeywordHighlightRule(rule);
|
||||
}
|
||||
|
||||
return {
|
||||
...defaultRule,
|
||||
color: rule.color,
|
||||
enabled: rule.enabled,
|
||||
};
|
||||
});
|
||||
|
||||
const existingRuleIds = new Set(normalizedRules.map((rule) => rule.id));
|
||||
for (const defaultRule of DEFAULT_KEYWORD_HIGHLIGHT_RULES) {
|
||||
if (!existingRuleIds.has(defaultRule.id)) {
|
||||
normalizedRules.push(cloneKeywordHighlightRule(defaultRule));
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedRules;
|
||||
};
|
||||
|
||||
export const normalizeTerminalSettings = (
|
||||
settings?: Partial<TerminalSettings> | null,
|
||||
): TerminalSettings => {
|
||||
const mergedSettings = {
|
||||
...DEFAULT_TERMINAL_SETTINGS,
|
||||
...(settings ?? {}),
|
||||
};
|
||||
|
||||
return {
|
||||
...mergedSettings,
|
||||
keywordHighlightRules: normalizeKeywordHighlightRules(
|
||||
mergedSettings.keywordHighlightRules,
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
scrollback: 10000,
|
||||
drawBoldInBrightColors: true,
|
||||
@@ -612,6 +676,7 @@ export interface TransferTask {
|
||||
childTasks?: string[]; // For directory transfers
|
||||
parentTaskId?: string;
|
||||
skipConflictCheck?: boolean; // Skip conflict check for replace operations
|
||||
retryable?: boolean; // False for task types that cannot be safely replayed through generic retry
|
||||
}
|
||||
|
||||
export interface FileConflict {
|
||||
|
||||
103
domain/syncPayload.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Sync Payload Builders — Single source of truth for constructing and applying
|
||||
* the encrypted cloud-sync payload.
|
||||
*
|
||||
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
|
||||
* must use these helpers to guarantee every field is included and no data is
|
||||
* silently dropped.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Host,
|
||||
Identity,
|
||||
KnownHost,
|
||||
PortForwardingRule,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
} from './models';
|
||||
import type { SyncPayload } from './sync';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** All vault-owned data that participates in cloud sync. */
|
||||
export interface SyncableVaultData {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
knownHosts: KnownHost[];
|
||||
}
|
||||
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
export interface SyncPayloadImporters {
|
||||
/** Import vault data (hosts, keys, identities, snippets, customGroups, knownHosts). */
|
||||
importVaultData: (jsonString: string) => void;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Builders
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Build a complete `SyncPayload` from local data.
|
||||
*
|
||||
* Port-forwarding rules are optional because they are managed by a separate
|
||||
* state hook (`usePortForwardingState`). Callers should strip transient
|
||||
* runtime fields (status, error, lastUsedAt) before passing them in.
|
||||
*/
|
||||
export function buildSyncPayload(
|
||||
vault: SyncableVaultData,
|
||||
portForwardingRules?: PortForwardingRule[],
|
||||
): SyncPayload {
|
||||
return {
|
||||
hosts: vault.hosts,
|
||||
keys: vault.keys,
|
||||
identities: vault.identities,
|
||||
snippets: vault.snippets,
|
||||
customGroups: vault.customGroups,
|
||||
knownHosts: vault.knownHosts,
|
||||
portForwardingRules,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a downloaded `SyncPayload` to local state via the provided importers.
|
||||
*
|
||||
* This ensures both vault data and port-forwarding rules are imported
|
||||
* consistently across windows.
|
||||
*/
|
||||
export function applySyncPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): void {
|
||||
// Build the vault import object. knownHosts is only included when the
|
||||
// payload explicitly carries the field (even if it's []). Legacy cloud
|
||||
// snapshots may omit it entirely — in that case we leave the local
|
||||
// known-hosts list untouched rather than destructively wiping it.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: payload.hosts,
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
};
|
||||
if (payload.knownHosts !== undefined) {
|
||||
vaultImport.knownHosts = payload.knownHosts;
|
||||
}
|
||||
|
||||
importers.importVaultData(JSON.stringify(vaultImport));
|
||||
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,3 @@
|
||||
/* global __dirname */
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* @type {import('electron-builder').Configuration}
|
||||
*/
|
||||
@@ -37,8 +34,8 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
category: 'public.app-category.developer-tools',
|
||||
hardenedRuntime: false,
|
||||
gatekeeperAssess: false,
|
||||
hardenedRuntime: true,
|
||||
notarize: true,
|
||||
entitlements: 'electron/entitlements.mac.plist',
|
||||
entitlementsInherit: 'electron/entitlements.mac.plist',
|
||||
extendInfo: {
|
||||
@@ -49,24 +46,15 @@ module.exports = {
|
||||
},
|
||||
dmg: {
|
||||
title: '${productName}',
|
||||
background: 'public/dmg-background.jpg',
|
||||
iconSize: 100,
|
||||
iconTextSize: 12,
|
||||
window: {
|
||||
width: 672,
|
||||
height: 500
|
||||
width: 540,
|
||||
height: 380
|
||||
},
|
||||
contents: [
|
||||
{ x: 150, y: 158 },
|
||||
{ x: 550, y: 158, type: 'link', path: '/Applications' },
|
||||
{
|
||||
x: 350,
|
||||
y: 330,
|
||||
type: 'file',
|
||||
// Use absolute path resolved at build time
|
||||
path: path.resolve(__dirname, 'scripts/FixQuarantine.app'),
|
||||
name: '已损坏修复.app'
|
||||
}
|
||||
{ x: 140, y: 158 },
|
||||
{ x: 400, y: 158, type: 'link', path: '/Applications' }
|
||||
]
|
||||
},
|
||||
win: {
|
||||
@@ -102,5 +90,13 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
category: 'Development'
|
||||
}
|
||||
},
|
||||
publish: [
|
||||
{
|
||||
provider: 'github',
|
||||
owner: 'binaricat',
|
||||
repo: 'Netcatty',
|
||||
releaseType: 'release'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
199
electron/bridges/autoUpdateBridge.cjs
Normal file
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Auto-Update Bridge
|
||||
*
|
||||
* Wraps electron-updater to provide IPC-driven update checks, downloads, and
|
||||
* install-on-quit. Designed around a "prompt" model: the renderer asks to
|
||||
* check, then explicitly triggers download and install.
|
||||
*
|
||||
* Platforms where auto-update is NOT supported (Linux deb/rpm/snap) get a
|
||||
* graceful { available: false, error } response so the renderer can fall back
|
||||
* to a manual "open GitHub releases" link.
|
||||
*/
|
||||
|
||||
let _deps = null;
|
||||
|
||||
/**
|
||||
* Returns true when the current packaging format supports electron-updater
|
||||
* (macOS zip/dmg, Windows NSIS, Linux AppImage).
|
||||
*/
|
||||
function isAutoUpdateSupported() {
|
||||
if (process.platform === "darwin" || process.platform === "win32") {
|
||||
return true;
|
||||
}
|
||||
// Linux: only AppImage supports in-place update.
|
||||
// The APPIMAGE env variable is set by the AppImage runtime.
|
||||
if (process.platform === "linux" && process.env.APPIMAGE) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Lazily resolved autoUpdater — avoids importing electron-updater in
|
||||
* contexts where native modules might not be available. */
|
||||
let _autoUpdater = null;
|
||||
function getAutoUpdater() {
|
||||
if (_autoUpdater) return _autoUpdater;
|
||||
try {
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
autoUpdater.autoDownload = false;
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
// Silence the default electron-log transport (we log ourselves).
|
||||
autoUpdater.logger = null;
|
||||
_autoUpdater = autoUpdater;
|
||||
return autoUpdater;
|
||||
} catch (err) {
|
||||
console.error("[AutoUpdate] Failed to load electron-updater:", err?.message || err);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function init(deps) {
|
||||
_deps = deps;
|
||||
}
|
||||
|
||||
/** Get the focused or first available BrowserWindow to send events to. */
|
||||
function getSenderWindow() {
|
||||
try {
|
||||
const { BrowserWindow } = _deps?.electronModule || {};
|
||||
if (!BrowserWindow) return null;
|
||||
const focused = BrowserWindow.getFocusedWindow();
|
||||
if (focused && !focused.isDestroyed()) return focused;
|
||||
const all = BrowserWindow.getAllWindows();
|
||||
for (const win of all) {
|
||||
if (!win.isDestroyed()) return win;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function registerHandlers(ipcMain) {
|
||||
// ---- Check for updates ------------------------------------------------
|
||||
ipcMain.handle("netcatty:update:check", async () => {
|
||||
if (!isAutoUpdateSupported()) {
|
||||
return {
|
||||
available: false,
|
||||
supported: false,
|
||||
error: "Auto-update is not supported on this platform/package format.",
|
||||
};
|
||||
}
|
||||
|
||||
const updater = getAutoUpdater();
|
||||
if (!updater) {
|
||||
return {
|
||||
available: false,
|
||||
supported: false,
|
||||
error: "Update module failed to load.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await updater.checkForUpdates();
|
||||
if (!result || !result.updateInfo) {
|
||||
return { available: false, supported: true };
|
||||
}
|
||||
|
||||
const { version, releaseNotes, releaseDate } = result.updateInfo;
|
||||
|
||||
// Compare with current version using semver ordering.
|
||||
// Only report an update when the feed version is strictly newer,
|
||||
// avoiding false positives for pre-release or nightly builds.
|
||||
const { app } = _deps?.electronModule || {};
|
||||
const currentVersion = app?.getVersion?.() || "0.0.0";
|
||||
const isNewer = currentVersion.localeCompare(version, undefined, { numeric: true, sensitivity: 'base' }) < 0;
|
||||
if (!isNewer) {
|
||||
return { available: false, supported: true };
|
||||
}
|
||||
|
||||
return {
|
||||
available: true,
|
||||
supported: true,
|
||||
version,
|
||||
releaseNotes: typeof releaseNotes === "string" ? releaseNotes : "",
|
||||
releaseDate: releaseDate || null,
|
||||
};
|
||||
} catch (err) {
|
||||
console.warn("[AutoUpdate] Check failed:", err?.message || err);
|
||||
return {
|
||||
available: false,
|
||||
supported: true,
|
||||
error: err?.message || "Unknown update check error",
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Download update ---------------------------------------------------
|
||||
ipcMain.handle("netcatty:update:download", async () => {
|
||||
const updater = getAutoUpdater();
|
||||
if (!updater) {
|
||||
return { success: false, error: "Update module not available." };
|
||||
}
|
||||
|
||||
try {
|
||||
// Capture the requesting window NOW so events always go back to the
|
||||
// renderer that initiated the download, even if focus changes later.
|
||||
const senderWindow = getSenderWindow();
|
||||
|
||||
// Wire progress events before starting the download.
|
||||
const progressHandler = (info) => {
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.webContents.send("netcatty:update:download-progress", {
|
||||
percent: info.percent ?? 0,
|
||||
bytesPerSecond: info.bytesPerSecond ?? 0,
|
||||
transferred: info.transferred ?? 0,
|
||||
total: info.total ?? 0,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const downloadedHandler = () => {
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.webContents.send("netcatty:update:downloaded");
|
||||
}
|
||||
// Cleanup one-shot listeners.
|
||||
updater.removeListener("download-progress", progressHandler);
|
||||
updater.removeListener("update-downloaded", downloadedHandler);
|
||||
updater.removeListener("error", errorHandler);
|
||||
};
|
||||
|
||||
const errorHandler = (err) => {
|
||||
if (senderWindow && !senderWindow.isDestroyed()) {
|
||||
senderWindow.webContents.send("netcatty:update:error", {
|
||||
error: err?.message || "Download failed",
|
||||
});
|
||||
}
|
||||
updater.removeListener("download-progress", progressHandler);
|
||||
updater.removeListener("update-downloaded", downloadedHandler);
|
||||
updater.removeListener("error", errorHandler);
|
||||
};
|
||||
|
||||
updater.on("download-progress", progressHandler);
|
||||
updater.on("update-downloaded", downloadedHandler);
|
||||
updater.on("error", errorHandler);
|
||||
|
||||
await updater.downloadUpdate();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
// Clean up listeners to prevent leaks if downloadUpdate() rejects
|
||||
// before the error event is emitted.
|
||||
const updaterForCleanup = getAutoUpdater();
|
||||
if (updaterForCleanup) {
|
||||
updaterForCleanup.removeAllListeners("download-progress");
|
||||
updaterForCleanup.removeAllListeners("update-downloaded");
|
||||
updaterForCleanup.removeAllListeners("error");
|
||||
}
|
||||
console.error("[AutoUpdate] Download failed:", err?.message || err);
|
||||
return { success: false, error: err?.message || "Download failed" };
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Install (quit & install) ------------------------------------------
|
||||
ipcMain.handle("netcatty:update:install", () => {
|
||||
const updater = getAutoUpdater();
|
||||
if (!updater) return;
|
||||
updater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
console.log("[AutoUpdate] Handlers registered");
|
||||
}
|
||||
|
||||
module.exports = { init, registerHandlers, isAutoUpdateSupported };
|
||||
@@ -100,6 +100,9 @@ async function startPortForward(event, payload) {
|
||||
}));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
// Track whether the Promise has been settled so conn.on('close')
|
||||
// can reject if the tunnel was killed during SSH handshake.
|
||||
let settled = false;
|
||||
|
||||
conn.on('ready', () => {
|
||||
console.log(`[PortForward] SSH connection ready for tunnel ${tunnelId}`);
|
||||
@@ -131,6 +134,7 @@ async function startPortForward(event, payload) {
|
||||
sendStatus('error', err.message);
|
||||
conn.end();
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@@ -140,9 +144,11 @@ async function startPortForward(event, payload) {
|
||||
type: 'local',
|
||||
conn,
|
||||
server,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
});
|
||||
|
||||
@@ -153,6 +159,7 @@ async function startPortForward(event, payload) {
|
||||
console.error(`[PortForward] Remote forward error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
conn.end();
|
||||
settled = true;
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
@@ -161,9 +168,11 @@ async function startPortForward(event, payload) {
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type: 'remote',
|
||||
conn,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
});
|
||||
|
||||
@@ -265,6 +274,7 @@ async function startPortForward(event, payload) {
|
||||
sendStatus('error', err.message);
|
||||
conn.end();
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@@ -274,12 +284,15 @@ async function startPortForward(event, payload) {
|
||||
type: 'dynamic',
|
||||
conn,
|
||||
server,
|
||||
status: 'active',
|
||||
webContentsId: sender.id
|
||||
});
|
||||
sendStatus('active');
|
||||
settled = true;
|
||||
resolve({ tunnelId, success: true });
|
||||
});
|
||||
} else {
|
||||
settled = true;
|
||||
reject(new Error(`Unknown forwarding type: ${type}`));
|
||||
}
|
||||
});
|
||||
@@ -288,12 +301,15 @@ async function startPortForward(event, payload) {
|
||||
console.error(`[PortForward] SSH error:`, err.message);
|
||||
sendStatus('error', err.message);
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
settled = true;
|
||||
reject(err);
|
||||
});
|
||||
|
||||
conn.on('close', () => {
|
||||
console.log(`[PortForward] SSH connection closed for tunnel ${tunnelId}`);
|
||||
const tunnel = portForwardingTunnels.get(tunnelId);
|
||||
// Capture the cancelled flag BEFORE cleanup deletes the entry.
|
||||
const wasCancelled = !!tunnel?.cancelled;
|
||||
if (tunnel) {
|
||||
if (tunnel.server) {
|
||||
try { tunnel.server.close(); } catch { }
|
||||
@@ -301,9 +317,30 @@ async function startPortForward(event, payload) {
|
||||
sendStatus('inactive');
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
}
|
||||
// If the Promise was never settled (tunnel killed during
|
||||
// handshake by stopPortForwardByRuleId), settle it.
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
if (wasCancelled) {
|
||||
resolve({ tunnelId, success: false, cancelled: true });
|
||||
} else {
|
||||
reject(new Error(`Tunnel ${tunnelId} closed before connection established`));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sendStatus('connecting');
|
||||
// Register the connection BEFORE the handshake starts so that
|
||||
// stopPortForwardByRuleId can find and kill it at any point,
|
||||
// including during the SSH handshake window. The conn.on('ready')
|
||||
// handler updates the entry to include the server object later.
|
||||
portForwardingTunnels.set(tunnelId, {
|
||||
type,
|
||||
conn,
|
||||
server: null,
|
||||
status: 'connecting',
|
||||
webContentsId: sender.id,
|
||||
});
|
||||
conn.connect(connectOpts);
|
||||
});
|
||||
}
|
||||
@@ -320,13 +357,17 @@ async function stopPortForward(event, payload) {
|
||||
}
|
||||
|
||||
try {
|
||||
// Mark as cancelled so conn.on('close') resolves gracefully
|
||||
// instead of rejecting for in-flight handshakes.
|
||||
tunnel.cancelled = true;
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
portForwardingTunnels.delete(tunnelId);
|
||||
// Don't delete here — let conn.on('close') handle cleanup
|
||||
// so it can read the cancelled flag.
|
||||
|
||||
return { tunnelId, success: true };
|
||||
} catch (err) {
|
||||
@@ -345,7 +386,7 @@ async function getPortForwardStatus(event, payload) {
|
||||
return { tunnelId, status: 'inactive' };
|
||||
}
|
||||
|
||||
return { tunnelId, status: 'active', type: tunnel.type };
|
||||
return { tunnelId, status: tunnel.status || 'active', type: tunnel.type };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -357,7 +398,7 @@ async function listPortForwards() {
|
||||
list.push({
|
||||
tunnelId,
|
||||
type: tunnel.type,
|
||||
status: 'active',
|
||||
status: tunnel.status || 'active',
|
||||
});
|
||||
}
|
||||
return list;
|
||||
@@ -370,21 +411,54 @@ function stopAllPortForwards() {
|
||||
console.log(`[PortForward] Stopping all ${portForwardingTunnels.size} active tunnels...`);
|
||||
for (const [tunnelId, tunnel] of portForwardingTunnels) {
|
||||
try {
|
||||
// Mark as cancelled so conn.on('close') resolves gracefully
|
||||
// instead of rejecting with an error for in-flight handshakes.
|
||||
tunnel.cancelled = true;
|
||||
if (tunnel.server) {
|
||||
tunnel.server.close();
|
||||
}
|
||||
if (tunnel.conn) {
|
||||
tunnel.conn.end();
|
||||
}
|
||||
// Don't delete here — let conn.on('close') handle cleanup
|
||||
// so it can read the cancelled flag.
|
||||
console.log(`[PortForward] Stopped tunnel ${tunnelId}`);
|
||||
} catch (err) {
|
||||
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
|
||||
}
|
||||
}
|
||||
portForwardingTunnels.clear();
|
||||
console.log('[PortForward] All tunnels stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active port forwards for a given rule ID.
|
||||
* Tunnel IDs follow the format `pf-{ruleId}-{timestamp}`, so we match
|
||||
* by checking if the tunnelId contains the ruleId.
|
||||
* This catches tunnels in ANY state (connecting, active) because it
|
||||
* operates on the main-process portForwardingTunnels map directly.
|
||||
*/
|
||||
function stopPortForwardByRuleId(_event, { ruleId }) {
|
||||
let stopped = 0;
|
||||
for (const [tunnelId, tunnel] of portForwardingTunnels) {
|
||||
if (tunnelId.includes(ruleId)) {
|
||||
try {
|
||||
// Mark as intentionally cancelled BEFORE conn.end() so the
|
||||
// close handler resolves gracefully instead of rejecting.
|
||||
tunnel.cancelled = true;
|
||||
if (tunnel.server) tunnel.server.close();
|
||||
if (tunnel.conn) tunnel.conn.end();
|
||||
// Don't delete here — let the conn.on('close') handler delete
|
||||
// the entry so it can read tunnel.cancelled first.
|
||||
console.log(`[PortForward] Stopped tunnel ${tunnelId} for rule ${ruleId}`);
|
||||
stopped++;
|
||||
} catch (err) {
|
||||
console.warn(`[PortForward] Failed to stop tunnel ${tunnelId}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { stopped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for port forwarding operations
|
||||
*/
|
||||
@@ -393,6 +467,8 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:portforward:stop", stopPortForward);
|
||||
ipcMain.handle("netcatty:portforward:status", getPortForwardStatus);
|
||||
ipcMain.handle("netcatty:portforward:list", listPortForwards);
|
||||
ipcMain.handle("netcatty:portforward:stopAll", () => stopAllPortForwards());
|
||||
ipcMain.handle("netcatty:portforward:stopByRuleId", stopPortForwardByRuleId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -402,4 +478,5 @@ module.exports = {
|
||||
getPortForwardStatus,
|
||||
listPortForwards,
|
||||
stopAllPortForwards,
|
||||
stopPortForwardByRuleId,
|
||||
};
|
||||
|
||||
@@ -29,6 +29,7 @@ const {
|
||||
applyAuthToConnOpts,
|
||||
safeSend: authSafeSend,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
getAvailableAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// SFTP clients storage - shared reference passed from main
|
||||
@@ -127,7 +128,102 @@ const encodePathForSession = (sftpId, inputPath, requestedEncoding) => {
|
||||
return encodePath(inputPath, encoding);
|
||||
};
|
||||
|
||||
const getSftpChannel = (client) => client?.sftp || client?.client?.sftp;
|
||||
const hasSftpChannelApi = (value) =>
|
||||
!!value &&
|
||||
typeof value.readdir === "function" &&
|
||||
typeof value.stat === "function" &&
|
||||
typeof value.mkdir === "function" &&
|
||||
typeof value.unlink === "function";
|
||||
|
||||
const SFTP_CHANNEL_OPEN_TIMEOUT_MS = 10_000;
|
||||
|
||||
const tryOpenSftpChannel = (client) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const sshClient = client?.client;
|
||||
if (!sshClient || typeof sshClient.sftp !== "function") {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
let settled = false;
|
||||
const timer = setTimeout(() => {
|
||||
settled = true;
|
||||
reject(new Error("SFTP channel open timed out"));
|
||||
}, SFTP_CHANNEL_OPEN_TIMEOUT_MS);
|
||||
try {
|
||||
sshClient.sftp((err, sftp) => {
|
||||
clearTimeout(timer);
|
||||
if (settled) {
|
||||
// Timeout already fired — close the orphaned channel to prevent leaks
|
||||
try { sftp?.end?.(); } catch { }
|
||||
return;
|
||||
}
|
||||
if (err) return reject(err);
|
||||
resolve(sftp || null);
|
||||
});
|
||||
} catch (err) {
|
||||
clearTimeout(timer);
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
const getSftpChannel = async (client) => {
|
||||
if (!client) return null;
|
||||
|
||||
if (hasSftpChannelApi(client.sftp)) {
|
||||
return client.sftp;
|
||||
}
|
||||
|
||||
// sudo sessions must keep using the sudo-bootstrapped SFTP wrapper.
|
||||
// Reopening with sshClient.sftp() would silently downgrade permissions.
|
||||
if (client.__netcattySudoMode) {
|
||||
console.warn("[SFTP] Sudo SFTP channel is unavailable; automatic recovery is disabled for sudo sessions. Please reconnect.");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Do not treat ssh2's "client.sftp" method as a channel object.
|
||||
// Re-open a fresh channel when the cached channel is stale.
|
||||
if (!client.client || typeof client.client.sftp !== "function") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Deduplicate per-client: avoid concurrent channel re-open attempts
|
||||
if (client._reopeningPromise) {
|
||||
try {
|
||||
return await client._reopeningPromise;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
client._reopeningPromise = (async () => {
|
||||
try {
|
||||
const reopened = await tryOpenSftpChannel(client);
|
||||
if (hasSftpChannelApi(reopened)) {
|
||||
client.sftp = reopened;
|
||||
return reopened;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("[SFTP] Failed to recover SFTP channel", err?.message || String(err));
|
||||
}
|
||||
return null;
|
||||
})();
|
||||
|
||||
try {
|
||||
return await client._reopeningPromise;
|
||||
} finally {
|
||||
client._reopeningPromise = null;
|
||||
}
|
||||
};
|
||||
|
||||
const requireSftpChannel = async (client) => {
|
||||
const sftp = await getSftpChannel(client);
|
||||
if (!sftp) {
|
||||
throw new Error("SFTP session lost. Please reconnect.");
|
||||
}
|
||||
return sftp;
|
||||
};
|
||||
|
||||
const statAsync = (sftp, targetPath) =>
|
||||
new Promise((resolve, reject) => {
|
||||
@@ -167,9 +263,20 @@ const normalizeRemotePathString = async (client, inputPath) => {
|
||||
return inputPath;
|
||||
};
|
||||
|
||||
const isWindowsRemotePath = (dirPath) => /^[A-Za-z]:[\\/]/.test(dirPath) || /^[A-Za-z]:$/.test(dirPath);
|
||||
|
||||
const normalizeRemoteDirPath = (dirPath) => {
|
||||
if (isWindowsRemotePath(dirPath)) {
|
||||
const normalized = dirPath.replace(/\//g, "\\").replace(/\\+/g, "\\");
|
||||
if (/^[A-Za-z]:$/.test(normalized)) return `${normalized}\\`;
|
||||
return normalized;
|
||||
}
|
||||
return path.posix.normalize(dirPath);
|
||||
};
|
||||
|
||||
const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
|
||||
if (!dirPath || dirPath === ".") return;
|
||||
const normalized = path.posix.normalize(dirPath);
|
||||
const normalized = normalizeRemoteDirPath(dirPath);
|
||||
if (!normalized || normalized === ".") return;
|
||||
|
||||
// Optimization: Check if the full path already exists to avoid O(N) round trips
|
||||
@@ -184,12 +291,22 @@ const ensureRemoteDirInternal = async (sftp, dirPath, encoding) => {
|
||||
// If path doesn't exist or other error, proceed to recursive check
|
||||
}
|
||||
|
||||
const isWindowsPath = isWindowsRemotePath(normalized);
|
||||
const isAbsolute = normalized.startsWith("/");
|
||||
const parts = normalized.split("/").filter(Boolean);
|
||||
let current = isAbsolute ? "/" : "";
|
||||
const parts = isWindowsPath
|
||||
? normalized.slice(2).replace(/^[\\]+/, "").split(/[\\]+/).filter(Boolean)
|
||||
: normalized.split("/").filter(Boolean);
|
||||
let current = isWindowsPath
|
||||
? `${normalized.slice(0, 2)}\\`
|
||||
: (isAbsolute ? "/" : "");
|
||||
|
||||
for (const part of parts) {
|
||||
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
|
||||
if (isWindowsPath) {
|
||||
const base = current.replace(/[\\]+$/, "");
|
||||
current = `${base}\\${part}`;
|
||||
} else {
|
||||
current = current === "/" ? `/${part}` : (current ? `${current}/${part}` : part);
|
||||
}
|
||||
const encodedCurrent = encodePath(current, encoding);
|
||||
try {
|
||||
const stats = await statAsync(sftp, encodedCurrent);
|
||||
@@ -240,15 +357,11 @@ const ensureRemoteDirForSession = async (sftpId, dirPath, requestedEncoding) =>
|
||||
if (!dirPath || dirPath === ".") return true;
|
||||
|
||||
const encoding = resolveEncodingForRequest(sftpId, requestedEncoding);
|
||||
if (encoding === "utf-8") {
|
||||
const encodedPath = encodePath(dirPath, encoding);
|
||||
await client.mkdir(encodedPath, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
const sftp = getSftpChannel(client);
|
||||
if (!sftp) throw new Error("SFTP channel not ready");
|
||||
const sftp = await requireSftpChannel(client);
|
||||
|
||||
// Always walk the path segment-by-segment. This lets sftp.stat() follow
|
||||
// symlinked directory segments before deciding whether the next mkdir is
|
||||
// valid, which avoids recursive mkdir failures on paths like /link/subdir.
|
||||
const normalizedPath = await normalizeRemotePathString(client, dirPath);
|
||||
await ensureRemoteDirInternal(sftp, normalizedPath, encoding);
|
||||
return true;
|
||||
@@ -315,7 +428,7 @@ function init(deps) {
|
||||
/**
|
||||
* Connect through a chain of jump hosts for SFTP
|
||||
*/
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId) {
|
||||
async function connectThroughChainForSftp(event, options, jumpHosts, targetHost, targetPort, connId, agentSocket) {
|
||||
const sender = event.sender;
|
||||
const connections = [];
|
||||
let currentSocket = null;
|
||||
@@ -386,6 +499,7 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
logPrefix: `[SFTP Chain] Hop ${i + 1}`,
|
||||
unlockedEncryptedKeys: options._unlockedEncryptedKeys || [],
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
});
|
||||
applyAuthToConnOpts(connOpts, authConfig);
|
||||
|
||||
@@ -409,6 +523,11 @@ async function connectThroughChainForSftp(event, options, jumpHosts, targetHost,
|
||||
resolve();
|
||||
});
|
||||
conn.on('error', (err) => {
|
||||
// Filter out non-fatal agent auth errors (same as in openSftp)
|
||||
if (err.level === 'agent') {
|
||||
console.log(`[SFTP Chain] Hop ${i + 1} non-fatal agent auth error (will try next method):`, err.message);
|
||||
return;
|
||||
}
|
||||
console.error(`[SFTP Chain] Hop ${i + 1}/${jumpHosts.length}: ${hopLabel} error:`, err.message);
|
||||
reject(err);
|
||||
});
|
||||
@@ -716,6 +835,10 @@ async function openSftp(event, options) {
|
||||
let chainConnections = [];
|
||||
let connectionSocket = null;
|
||||
|
||||
// Pre-fetch agent socket (async check for Windows SSH Agent service)
|
||||
// This is used by both jump host chain auth and final host auth
|
||||
const agentSocket = await getAvailableAgentSocket();
|
||||
|
||||
// Handle chain/proxy connections
|
||||
if (hasJumpHosts) {
|
||||
console.log(`[SFTP] Opening connection through ${jumpHosts.length} jump host(s) to ${options.hostname}:${options.port || 22}`);
|
||||
@@ -729,7 +852,8 @@ async function openSftp(event, options) {
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22,
|
||||
connId
|
||||
connId,
|
||||
agentSocket
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
@@ -783,6 +907,7 @@ async function openSftp(event, options) {
|
||||
if (options.password) connectOpts.password = options.password;
|
||||
|
||||
// Build auth handler using shared helper
|
||||
// Use pre-fetched agentSocket (validated async, including Windows service check)
|
||||
const authConfig = buildAuthHandler({
|
||||
privateKey: connectOpts.privateKey,
|
||||
password: connectOpts.password,
|
||||
@@ -791,6 +916,7 @@ async function openSftp(event, options) {
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[SFTP]",
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
@@ -810,44 +936,104 @@ async function openSftp(event, options) {
|
||||
connectOpts.readyTimeout = 120000; // 2 minutes for 2FA input
|
||||
|
||||
try {
|
||||
if (options.sudo) {
|
||||
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
|
||||
const sshClient = client.client;
|
||||
// IMPORTANT: We bypass ssh2-sftp-client's connect() method and use the
|
||||
// underlying ssh2 Client directly. This is because ssh2-sftp-client adds
|
||||
// temporary error listeners that reject the entire connect promise on ANY
|
||||
// error, including non-fatal auth errors (e.g. 'Failed to connect to agent'
|
||||
// when ssh2 tries agent auth and falls through to the next method).
|
||||
// By connecting directly, we can filter these non-fatal errors and allow
|
||||
// the auth flow to continue to keyboard-interactive/password/etc.
|
||||
const sshClient = client.client;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
// Set up error handler for initial connection
|
||||
const onConnectError = (err) => reject(err);
|
||||
sshClient.once('error', onConnectError);
|
||||
await new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
const settle = (fn, val) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
fn(val);
|
||||
};
|
||||
|
||||
sshClient.once('ready', async () => {
|
||||
sshClient.removeListener('error', onConnectError);
|
||||
try {
|
||||
// Use provided password or try empty if using key auth (and hope for nopasswd sudo)
|
||||
const sudoPass = options.password || "";
|
||||
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
|
||||
const onError = (err) => {
|
||||
// Filter out non-fatal authentication errors.
|
||||
// ssh2 sets err.level = 'agent' when agent auth fails — it then
|
||||
// internally calls tryNextAuth() to proceed with the next method.
|
||||
// We must NOT reject here, or the fallback won't execute.
|
||||
if (err.level === 'agent') {
|
||||
console.log('[SFTP] Non-fatal agent auth error (will try next method):', err.message);
|
||||
return;
|
||||
}
|
||||
settle(reject, err);
|
||||
};
|
||||
|
||||
// Inject into sftp-client
|
||||
client.sftp = sftpWrapper;
|
||||
const onEnd = () => {
|
||||
settle(reject, new Error('Connection closed before SFTP session was ready'));
|
||||
};
|
||||
|
||||
// Important: attach cleanup listener expected by sftp-client
|
||||
client.sftp.on('close', () => client.end());
|
||||
const onClose = () => {
|
||||
settle(reject, new Error('Connection closed before SFTP session was ready'));
|
||||
};
|
||||
|
||||
const cleanup = () => {
|
||||
sshClient.removeListener('error', onError);
|
||||
sshClient.removeListener('end', onEnd);
|
||||
sshClient.removeListener('close', onClose);
|
||||
};
|
||||
|
||||
sshClient.on('error', onError);
|
||||
sshClient.on('end', onEnd);
|
||||
sshClient.on('close', onClose);
|
||||
|
||||
sshClient.once('ready', () => {
|
||||
cleanup();
|
||||
|
||||
if (options.sudo) {
|
||||
console.log(`[SFTP] Using sudo mode for connection: ${connId}`);
|
||||
(async () => {
|
||||
try {
|
||||
const sudoPass = options.password || "";
|
||||
const sftpWrapper = await connectSudoSftp(sshClient, sudoPass);
|
||||
client.sftp = sftpWrapper;
|
||||
client.sftp.on('close', () => client.end());
|
||||
resolve();
|
||||
} catch (e) {
|
||||
// Fallback: if sftp-server binary is missing (exit code 127),
|
||||
// try standard SFTP subsystem instead of failing completely.
|
||||
// This handles systems like ESXi that don't have sftp-server
|
||||
// but support the SFTP subsystem natively.
|
||||
if (e.message && e.message.includes('exit code 127')) {
|
||||
console.warn('[SFTP] sftp-server not found, falling back to standard SFTP subsystem');
|
||||
options.sudo = false; // Mark as non-sudo for downstream logic
|
||||
sshClient.sftp((sftpErr, sftp) => {
|
||||
if (sftpErr) {
|
||||
sshClient.end();
|
||||
return reject(sftpErr);
|
||||
}
|
||||
client.sftp = sftp;
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
sshClient.end();
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
})();
|
||||
} else {
|
||||
// Open standard SFTP subsystem channel
|
||||
sshClient.sftp((err, sftp) => {
|
||||
if (err) return reject(err);
|
||||
client.sftp = sftp;
|
||||
resolve();
|
||||
} catch (e) {
|
||||
sshClient.end();
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
sshClient.connect(connectOpts);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
await client.connect(connectOpts);
|
||||
}
|
||||
|
||||
try {
|
||||
sshClient.connect(connectOpts);
|
||||
} catch (e) {
|
||||
settle(reject, e);
|
||||
}
|
||||
});
|
||||
// Increase max listeners AFTER connect, when the internal ssh2 Client exists
|
||||
// This prevents Node.js MaxListenersExceededWarning when performing many operations
|
||||
// ssh2-sftp-client adds temporary listeners for each operation, so we need a high limit
|
||||
@@ -891,10 +1077,7 @@ async function listSftp(event, payload) {
|
||||
const pathEncoding = resolveEncodingForRequest(payload.sftpId, requestedEncoding);
|
||||
const encodedPath = encodePath(basePath, pathEncoding);
|
||||
|
||||
const sftp = getSftpChannel(client);
|
||||
if (!sftp) {
|
||||
throw new Error("SFTP channel not ready");
|
||||
}
|
||||
const sftp = await requireSftpChannel(client);
|
||||
|
||||
let list;
|
||||
try {
|
||||
@@ -1015,6 +1198,7 @@ async function readSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const buffer = await client.get(encodedPath);
|
||||
@@ -1028,6 +1212,7 @@ async function readSftpBinary(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const buffer = await client.get(encodedPath);
|
||||
@@ -1042,6 +1227,7 @@ async function writeSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
await client.put(Buffer.from(payload.content, "utf-8"), encodedPath);
|
||||
@@ -1055,6 +1241,7 @@ async function writeSftpBinary(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
await client.put(Buffer.from(payload.content), encodedPath);
|
||||
@@ -1071,6 +1258,7 @@ async function writeSftpBinaryWithProgress(event, payload) {
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
const { sftpId, path: remotePath, content, transferId } = payload;
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(remotePath, encoding);
|
||||
|
||||
@@ -1305,6 +1493,7 @@ async function deleteSftp(event, payload) {
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
|
||||
if (encoding === "utf-8") {
|
||||
await requireSftpChannel(client);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const stat = await client.stat(encodedPath);
|
||||
if (stat.isDirectory) {
|
||||
@@ -1342,8 +1531,7 @@ async function deleteSftp(event, payload) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const sftp = getSftpChannel(client);
|
||||
if (!sftp) throw new Error("SFTP channel not ready");
|
||||
const sftp = await requireSftpChannel(client);
|
||||
const normalizedPath = await normalizeRemotePathString(client, payload.path);
|
||||
await removeRemotePathInternal(sftp, normalizedPath, encoding);
|
||||
return true;
|
||||
@@ -1356,6 +1544,7 @@ async function renameSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedOldPath = encodePath(payload.oldPath, encoding);
|
||||
const encodedNewPath = encodePath(payload.newPath, encoding);
|
||||
@@ -1370,6 +1559,7 @@ async function statSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
const stat = await client.stat(encodedPath);
|
||||
@@ -1389,6 +1579,7 @@ async function chmodSftp(event, payload) {
|
||||
const client = sftpClients.get(payload.sftpId);
|
||||
if (!client) throw new Error("SFTP session not found");
|
||||
|
||||
await requireSftpChannel(client);
|
||||
const encoding = resolveEncodingForRequest(payload.sftpId, payload.encoding);
|
||||
const encodedPath = encodePath(payload.path, encoding);
|
||||
await client.chmod(encodedPath, parseInt(payload.mode, 8));
|
||||
@@ -1426,6 +1617,7 @@ module.exports = {
|
||||
init,
|
||||
registerHandlers,
|
||||
getSftpClients,
|
||||
requireSftpChannel,
|
||||
encodePathForSession,
|
||||
ensureRemoteDirForSession,
|
||||
openSftp,
|
||||
|
||||
@@ -1,38 +1,39 @@
|
||||
/**
|
||||
* SSH Authentication Helper - Shared authentication logic for SSH connections
|
||||
* Used by sshBridge, sftpBridge, and portForwardingBridge
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
/**
|
||||
* SSH Authentication Helper - Shared authentication logic for SSH connections
|
||||
* Used by sshBridge, sftpBridge, and portForwardingBridge
|
||||
*/
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { exec } = require("node:child_process");
|
||||
const keyboardInteractiveHandler = require("./keyboardInteractiveHandler.cjs");
|
||||
const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
|
||||
// Default SSH key names in priority order
|
||||
const DEFAULT_KEY_NAMES = ["id_ed25519", "id_ecdsa", "id_rsa"];
|
||||
|
||||
/**
|
||||
* Check if an SSH private key is encrypted (requires passphrase)
|
||||
* @param {string} keyContent - The content of the private key file
|
||||
* @returns {boolean} - True if the key is encrypted
|
||||
*/
|
||||
function isKeyEncrypted(keyContent) {
|
||||
if (!keyContent || typeof keyContent !== "string") return false;
|
||||
|
||||
// Check for PKCS#8 encrypted format (-----BEGIN ENCRYPTED PRIVATE KEY-----)
|
||||
if (keyContent.includes("-----BEGIN ENCRYPTED PRIVATE KEY-----")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check for legacy PEM format encryption (e.g., RSA PRIVATE KEY with encryption)
|
||||
if (keyContent.includes("Proc-Type:") && keyContent.includes("ENCRYPTED")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
// Check for DEK-Info header (legacy PEM encryption indicator)
|
||||
if (keyContent.includes("DEK-Info:")) return true;
|
||||
|
||||
if (keyContent.includes("DEK-Info:")) return true;
|
||||
|
||||
// Check for OpenSSH format keys
|
||||
if (keyContent.includes("-----BEGIN OPENSSH PRIVATE KEY-----")) {
|
||||
try {
|
||||
@@ -43,7 +44,7 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
if (base64Match) {
|
||||
const base64Content = base64Match[1].replace(/\s/g, "");
|
||||
const keyBuffer = Buffer.from(base64Content, "base64");
|
||||
|
||||
|
||||
// OpenSSH key format: "openssh-key-v1\0" followed by cipher name
|
||||
// If ciphername is "none", the key is not encrypted
|
||||
const authMagic = "openssh-key-v1\0";
|
||||
@@ -61,132 +62,179 @@ const passphraseHandler = require("./passphraseHandler.cjs");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
|
||||
*/
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find default SSH private key from user's ~/.ssh directory
|
||||
* Skips encrypted keys that require a passphrase
|
||||
* @returns {Promise<{ privateKey: string, keyPath: string, keyName: string } | null>}
|
||||
*/
|
||||
async function findDefaultPrivateKey() {
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
for (const name of DEFAULT_KEY_NAMES) {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
if (isKeyEncrypted(privateKey)) {
|
||||
continue;
|
||||
}
|
||||
return { privateKey, keyPath, keyName: name };
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find ALL default SSH private keys from user's ~/.ssh directory
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.includeEncrypted=false] - If true, include encrypted keys with isEncrypted flag
|
||||
* @returns {Promise<Array<{ privateKey: string, keyPath: string, keyName: string, isEncrypted?: boolean }>>}
|
||||
*/
|
||||
async function findAllDefaultPrivateKeys(options = {}) {
|
||||
const { includeEncrypted = false } = options;
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
const sshDir = path.join(os.homedir(), ".ssh");
|
||||
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
const promises = DEFAULT_KEY_NAMES.map(async (name) => {
|
||||
const keyPath = path.join(sshDir, name);
|
||||
try {
|
||||
await fs.promises.access(keyPath, fs.constants.F_OK);
|
||||
const privateKey = await fs.promises.readFile(keyPath, "utf8");
|
||||
const encrypted = isKeyEncrypted(privateKey);
|
||||
if (encrypted && !includeEncrypted) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
privateKey,
|
||||
keyPath,
|
||||
keyName: name,
|
||||
...(includeEncrypted ? { isEncrypted: encrypted } : {})
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Windows SSH Agent service is running
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function checkWindowsSshAgentRunning() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== "win32") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
exec("sc query ssh-agent", (err, stdout) => {
|
||||
if (err) {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
resolve(stdout.includes("RUNNING"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform (synchronous, best-effort)
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
// On Windows, always return the pipe path; the caller should use
|
||||
// getAvailableAgentSocket() for a reliable async check.
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
const agentSocket = process.env.SSH_AUTH_SOCK;
|
||||
if (!agentSocket) return null;
|
||||
|
||||
try {
|
||||
const stats = fs.statSync(agentSocket);
|
||||
return typeof stats.isSocket === "function" && stats.isSocket()
|
||||
? agentSocket
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path with async validation (checks Windows service status)
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function getAvailableAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
const running = await checkWindowsSshAgentRunning();
|
||||
return running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
|
||||
}
|
||||
return getSshAgentSocket();
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
* @param {string} [options.privateKey] - Explicitly configured private key
|
||||
* @param {string} [options.password] - Password for authentication
|
||||
* @param {string} [options.passphrase] - Passphrase for encrypted private key
|
||||
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
|
||||
* @param {string} options.username - SSH username
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getSshAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
return "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
}
|
||||
return process.env.SSH_AUTH_SOCK || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build authentication handler with default key fallback support
|
||||
* @param {Object} options
|
||||
* @param {string} [options.privateKey] - Explicitly configured private key
|
||||
* @param {string} [options.password] - Password for authentication
|
||||
* @param {string} [options.passphrase] - Passphrase for encrypted private key
|
||||
* @param {Object} [options.agent] - SSH agent (NetcattyAgent or socket path)
|
||||
* @param {string} options.username - SSH username
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {{ authHandler: Function|Array, privateKey: string|null, agent: string|Object|null, usedDefaultKeys: boolean }}
|
||||
* @param {Array} [options.unlockedEncryptedKeys] - Array of unlocked encrypted keys with passphrases
|
||||
*/
|
||||
function buildAuthHandler(options) {
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [] } = options;
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
const hasExplicitPassword = !!password;
|
||||
const hasExplicitAgent = !!agent;
|
||||
const hasExplicitAuth = hasExplicitKey || hasExplicitPassword || hasExplicitAgent;
|
||||
|
||||
|
||||
// Determine if this is a password-only or key-only connection
|
||||
const isPasswordOnly = hasExplicitPassword && !hasExplicitKey && !hasExplicitAgent;
|
||||
const isKeyOnly = hasExplicitKey && !hasExplicitAgent;
|
||||
|
||||
const sshAgentSocket = getSshAgentSocket();
|
||||
|
||||
|
||||
// Allow callers to pass in a pre-validated agent socket (e.g. from async
|
||||
// getAvailableAgentSocket). Fall back to synchronous getSshAgentSocket()
|
||||
// which on Windows always returns the pipe path without checking the service.
|
||||
const sshAgentSocket = sshAgentSocketOverride !== undefined ? sshAgentSocketOverride : getSshAgentSocket();
|
||||
|
||||
// Only use system ssh-agent BEFORE user's auth when:
|
||||
// - User explicitly configured agent, OR
|
||||
// - No explicit auth is configured (pure fallback mode)
|
||||
// When user configured key/password, system agent should only be used AFTER as fallback
|
||||
const useAgentFirst = hasExplicitAgent || !hasExplicitAuth;
|
||||
|
||||
|
||||
// Determine effective agent
|
||||
const effectiveAgent = agent || (useAgentFirst ? sshAgentSocket : null);
|
||||
|
||||
|
||||
// Determine effective privateKey (user-provided takes priority)
|
||||
const effectivePrivateKey = privateKey || (!hasExplicitAuth && defaultKeys.length > 0 ? defaultKeys[0].privateKey : null);
|
||||
|
||||
|
||||
// Determine fallback keys (keys to try after user's primary auth fails)
|
||||
// - If user provided a key: all default keys are fallbacks
|
||||
// - If no explicit auth: first default key is primary, rest are fallbacks
|
||||
// - If password-only or agent-only: all default keys are fallbacks (tried after primary)
|
||||
const fallbackKeys = hasExplicitKey
|
||||
? defaultKeys
|
||||
: !hasExplicitAuth
|
||||
? defaultKeys.slice(1)
|
||||
const fallbackKeys = hasExplicitKey
|
||||
? defaultKeys
|
||||
: !hasExplicitAuth
|
||||
? defaultKeys.slice(1)
|
||||
: defaultKeys;
|
||||
|
||||
|
||||
// Check if we need dynamic handler (have fallback options)
|
||||
const hasFallbackOptions = fallbackKeys.length > 0 ||
|
||||
(!hasExplicitAgent && sshAgentSocket) ||
|
||||
const hasFallbackOptions = fallbackKeys.length > 0 ||
|
||||
(!hasExplicitAgent && sshAgentSocket) ||
|
||||
(isPasswordOnly && defaultKeys.length > 0);
|
||||
|
||||
|
||||
// If only simple auth methods and no fallback keys needed, use array-based handler
|
||||
if (hasExplicitAuth && !hasFallbackOptions) {
|
||||
const authMethods = [];
|
||||
@@ -194,15 +242,15 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
if (privateKey) authMethods.push("publickey");
|
||||
if (password) authMethods.push("password");
|
||||
authMethods.push("keyboard-interactive");
|
||||
|
||||
|
||||
return {
|
||||
authHandler: authMethods,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: effectiveAgent,
|
||||
usedDefaultKeys: false,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Build comprehensive authMethods array with all auth options
|
||||
// Order depends on what user explicitly configured:
|
||||
// - Password-only: password -> agent -> default keys -> keyboard-interactive
|
||||
@@ -210,144 +258,132 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
// - Agent configured: agent -> user key -> password -> default keys -> keyboard-interactive
|
||||
// - No explicit auth: agent -> default keys -> keyboard-interactive
|
||||
const authMethods = [];
|
||||
|
||||
|
||||
if (isPasswordOnly) {
|
||||
// Password-only: password first, then fallbacks
|
||||
// Password-only: respect user's explicit choice, no key/agent fallback
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
|
||||
// Add agent and default keys AFTER password as fallback
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
for (const keyInfo of defaultKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else if (isKeyOnly) {
|
||||
// Key-only: user key first, then password (if any), then agent/default keys as fallback
|
||||
|
||||
|
||||
// 1. User-provided key first
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
id: "publickey-user"
|
||||
});
|
||||
|
||||
|
||||
// 2. Password (if configured alongside key)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
|
||||
// 3. System agent as fallback (AFTER user's key)
|
||||
if (sshAgentSocket) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Agent configured or no explicit auth: agent -> user key -> password -> default keys
|
||||
|
||||
|
||||
// 1. Agent (user-provided or system)
|
||||
if (effectiveAgent) {
|
||||
authMethods.push({ type: "agent", id: "agent" });
|
||||
}
|
||||
|
||||
|
||||
// 2. User-provided key
|
||||
if (privateKey) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: privateKey,
|
||||
passphrase: passphrase,
|
||||
id: "publickey-user"
|
||||
id: "publickey-user"
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 3. Password (if configured)
|
||||
if (password) {
|
||||
authMethods.push({ type: "password", id: "password" });
|
||||
}
|
||||
|
||||
|
||||
// 4. Default keys as fallback
|
||||
for (const keyInfo of fallbackKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
id: `publickey-default-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// 5. If no user key provided, add first default key at the beginning (after agent)
|
||||
if (!privateKey && defaultKeys.length > 0) {
|
||||
const insertIndex = effectiveAgent ? 1 : 0;
|
||||
authMethods.splice(insertIndex, 0, {
|
||||
type: "publickey",
|
||||
key: defaultKeys[0].privateKey,
|
||||
id: `publickey-default-${defaultKeys[0].keyName}`
|
||||
authMethods.splice(insertIndex, 0, {
|
||||
type: "publickey",
|
||||
key: defaultKeys[0].privateKey,
|
||||
id: `publickey-default-${defaultKeys[0].keyName}`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add unlocked encrypted default keys (user provided passphrases for these)
|
||||
for (const keyInfo of unlockedEncryptedKeys) {
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
authMethods.push({
|
||||
type: "publickey",
|
||||
key: keyInfo.privateKey,
|
||||
passphrase: keyInfo.passphrase,
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
id: `publickey-encrypted-${keyInfo.keyName}`
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// Keyboard-interactive as last resort
|
||||
authMethods.push({ type: "keyboard-interactive", id: "keyboard-interactive" });
|
||||
|
||||
console.log(`${logPrefix} Auth methods configured`, {
|
||||
isPasswordOnly,
|
||||
hasUserKey: !!privateKey,
|
||||
hasPassword: !!password,
|
||||
hasAgent: !!effectiveAgent,
|
||||
methodCount: authMethods.length,
|
||||
methods: authMethods.map(m => m.id),
|
||||
});
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
|
||||
if (attemptedMethodIds.has(method.id)) continue;
|
||||
attemptedMethodIds.add(method.id);
|
||||
|
||||
|
||||
console.log(`${logPrefix} Auth methods configured`, {
|
||||
isPasswordOnly,
|
||||
hasUserKey: !!privateKey,
|
||||
hasPassword: !!password,
|
||||
hasAgent: !!effectiveAgent,
|
||||
methodCount: authMethods.length,
|
||||
methods: authMethods.map(m => m.id),
|
||||
});
|
||||
|
||||
// Use dynamic authHandler to try all keys
|
||||
let authIndex = 0;
|
||||
const attemptedMethodIds = new Set();
|
||||
|
||||
const authHandler = (methodsLeft, partialSuccess, callback) => {
|
||||
const availableMethods = methodsLeft || ["publickey", "password", "keyboard-interactive", "agent"];
|
||||
|
||||
while (authIndex < authMethods.length) {
|
||||
const method = authMethods[authIndex];
|
||||
authIndex++;
|
||||
|
||||
if (attemptedMethodIds.has(method.id)) continue;
|
||||
attemptedMethodIds.add(method.id);
|
||||
|
||||
if (method.type === "agent" && (availableMethods.includes("publickey") || availableMethods.includes("agent"))) {
|
||||
console.log(`${logPrefix} Trying agent auth`);
|
||||
return callback("agent");
|
||||
} else if (method.type === "publickey" && availableMethods.includes("publickey")) {
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
key: method.key,
|
||||
};
|
||||
if (method.passphrase) {
|
||||
pubkeyAuth.passphrase = method.passphrase;
|
||||
}
|
||||
return callback(pubkeyAuth);
|
||||
console.log(`${logPrefix} Trying publickey auth:`, method.id);
|
||||
const pubkeyAuth = {
|
||||
type: "publickey",
|
||||
username,
|
||||
key: method.key,
|
||||
};
|
||||
if (method.passphrase) {
|
||||
pubkeyAuth.passphrase = method.passphrase;
|
||||
}
|
||||
return callback(pubkeyAuth);
|
||||
} else if (method.type === "password" && availableMethods.includes("password")) {
|
||||
console.log(`${logPrefix} Trying password auth`);
|
||||
return callback({
|
||||
@@ -355,107 +391,107 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
username,
|
||||
password,
|
||||
});
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
} else if (method.type === "keyboard-interactive" && availableMethods.includes("keyboard-interactive")) {
|
||||
return callback("keyboard-interactive");
|
||||
}
|
||||
}
|
||||
return callback(false);
|
||||
};
|
||||
|
||||
// Determine the agent to return - if authMethods includes agent, we need to provide the socket
|
||||
// even if effectiveAgent is null (for fallback scenarios)
|
||||
const hasAgentInMethods = authMethods.some(m => m.type === "agent");
|
||||
const returnAgent = effectiveAgent || (hasAgentInMethods ? sshAgentSocket : null);
|
||||
|
||||
return {
|
||||
authHandler,
|
||||
|
||||
return {
|
||||
authHandler,
|
||||
privateKey: effectivePrivateKey,
|
||||
agent: returnAgent,
|
||||
usedDefaultKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard-interactive event handler
|
||||
* @param {Object} options
|
||||
* @param {Object} options.sender - Electron webContents sender
|
||||
* @param {string} options.sessionId - Session/connection ID
|
||||
* @param {string} options.hostname - Host being connected to
|
||||
* @param {string} [options.password] - Saved password for fill button
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {Function} - Event handler for 'keyboard-interactive' event
|
||||
*/
|
||||
function createKeyboardInteractiveHandler(options) {
|
||||
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || hostname,
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
usedDefaultKeys: true,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a keyboard-interactive event handler
|
||||
* @param {Object} options
|
||||
* @param {Object} options.sender - Electron webContents sender
|
||||
* @param {string} options.sessionId - Session/connection ID
|
||||
* @param {string} options.hostname - Host being connected to
|
||||
* @param {string} [options.password] - Saved password for fill button
|
||||
* @param {string} [options.logPrefix] - Log prefix for debugging
|
||||
* @returns {Function} - Event handler for 'keyboard-interactive' event
|
||||
*/
|
||||
function createKeyboardInteractiveHandler(options) {
|
||||
const { sender, sessionId, hostname, password, logPrefix = "[SSH]" } = options;
|
||||
|
||||
return (name, instructions, instructionsLang, prompts, finish) => {
|
||||
console.log(`${logPrefix} ${hostname} keyboard-interactive auth requested`, {
|
||||
name,
|
||||
instructions,
|
||||
promptCount: prompts?.length || 0,
|
||||
});
|
||||
|
||||
// If there are no prompts, just call finish with empty array
|
||||
if (!prompts || prompts.length === 0) {
|
||||
console.log(`${logPrefix} No prompts, finishing keyboard-interactive`);
|
||||
finish([]);
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward prompts to user via IPC
|
||||
const requestId = keyboardInteractiveHandler.generateRequestId('ssh');
|
||||
keyboardInteractiveHandler.storeRequest(requestId, (userResponses) => {
|
||||
console.log(`${logPrefix} Received user responses, finishing keyboard-interactive`);
|
||||
finish(userResponses);
|
||||
}, sender.id, sessionId);
|
||||
|
||||
const promptsData = prompts.map((p) => ({
|
||||
prompt: p.prompt,
|
||||
echo: p.echo,
|
||||
}));
|
||||
|
||||
console.log(`${logPrefix} Showing modal for ${promptsData.length} prompts`);
|
||||
|
||||
safeSend(sender, "netcatty:keyboard-interactive", {
|
||||
requestId,
|
||||
sessionId,
|
||||
name: name || hostname,
|
||||
instructions: instructions || "",
|
||||
prompts: promptsData,
|
||||
hostname: hostname,
|
||||
savedPassword: password || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
* Convenience function that combines buildAuthHandler results with connOpts
|
||||
* @param {Object} connOpts - SSH connection options to modify
|
||||
* @param {Object} authConfig - Auth configuration from buildAuthHandler
|
||||
*/
|
||||
function applyAuthToConnOpts(connOpts, authConfig) {
|
||||
connOpts.authHandler = authConfig.authHandler;
|
||||
if (authConfig.privateKey) {
|
||||
connOpts.privateKey = authConfig.privateKey;
|
||||
}
|
||||
if (authConfig.agent) {
|
||||
connOpts.agent = authConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
savedPassword: password || null,
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message to renderer safely
|
||||
*/
|
||||
function safeSend(sender, channel, payload) {
|
||||
try {
|
||||
if (!sender || sender.isDestroyed()) return;
|
||||
sender.send(channel, payload);
|
||||
} catch {
|
||||
// Ignore destroyed webContents during shutdown.
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply auth configuration to connection options
|
||||
* Convenience function that combines buildAuthHandler results with connOpts
|
||||
* @param {Object} connOpts - SSH connection options to modify
|
||||
* @param {Object} authConfig - Auth configuration from buildAuthHandler
|
||||
*/
|
||||
function applyAuthToConnOpts(connOpts, authConfig) {
|
||||
connOpts.authHandler = authConfig.authHandler;
|
||||
if (authConfig.privateKey) {
|
||||
connOpts.privateKey = authConfig.privateKey;
|
||||
}
|
||||
if (authConfig.agent) {
|
||||
connOpts.agent = authConfig.agent;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Request passphrases for encrypted default keys
|
||||
* Shows a modal for each encrypted key and collects passphrases
|
||||
@@ -466,16 +502,16 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
const allKeys = await findAllDefaultPrivateKeys({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeys.filter(k => k.isEncrypted);
|
||||
|
||||
|
||||
if (encryptedKeys.length === 0) {
|
||||
return { keys: [], cancelled: false };
|
||||
}
|
||||
|
||||
|
||||
console.log(`[SSHAuth] Found ${encryptedKeys.length} encrypted default key(s), requesting passphrases`);
|
||||
|
||||
|
||||
const unlockedKeys = [];
|
||||
let wasCancelled = false;
|
||||
|
||||
|
||||
for (const keyInfo of encryptedKeys) {
|
||||
const result = await passphraseHandler.requestPassphrase(
|
||||
sender,
|
||||
@@ -483,27 +519,27 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
keyInfo.keyName,
|
||||
hostname
|
||||
);
|
||||
|
||||
|
||||
// Handle different response types
|
||||
if (!result) {
|
||||
// Timeout or error - continue with next key
|
||||
console.log(`[SSHAuth] No response for ${keyInfo.keyName}, continuing...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (result.cancelled) {
|
||||
// User clicked Cancel - stop the entire flow
|
||||
console.log(`[SSHAuth] User cancelled passphrase flow at ${keyInfo.keyName}`);
|
||||
wasCancelled = true;
|
||||
break;
|
||||
}
|
||||
|
||||
|
||||
if (result.skipped) {
|
||||
// User clicked Skip - continue with next key
|
||||
console.log(`[SSHAuth] User skipped passphrase for ${keyInfo.keyName}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
if (result.passphrase) {
|
||||
// User provided passphrase
|
||||
unlockedKeys.push({
|
||||
@@ -514,19 +550,20 @@ async function requestPassphrasesForEncryptedKeys(sender, hostname) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return { keys: unlockedKeys, cancelled: wasCancelled };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
module.exports = {
|
||||
DEFAULT_KEY_NAMES,
|
||||
isKeyEncrypted,
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
getAvailableAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
safeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
safeSend: authSafeSend,
|
||||
requestPassphrasesForEncryptedKeys,
|
||||
findAllDefaultPrivateKeys: findAllDefaultPrivateKeysFromHelper,
|
||||
getSshAgentSocket,
|
||||
} = require("./sshAuthHelper.cjs");
|
||||
|
||||
// Default SSH key names in priority order
|
||||
@@ -165,6 +166,16 @@ function checkWindowsSshAgent() {
|
||||
});
|
||||
}
|
||||
|
||||
async function getAvailableAgentSocket() {
|
||||
if (process.platform === "win32") {
|
||||
const agentStatus = await checkWindowsSshAgent();
|
||||
log("Windows SSH Agent check", agentStatus);
|
||||
return agentStatus.running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
|
||||
}
|
||||
|
||||
return getSshAgentSocket();
|
||||
}
|
||||
|
||||
const DEBUG_SSH = process.env.NETCATTY_SSH_DEBUG === "1";
|
||||
|
||||
// Debug logger (disabled by default)
|
||||
@@ -227,6 +238,31 @@ let electronModule = null;
|
||||
// Cache persists until auth failure, then cleared to retry all methods
|
||||
const authMethodCache = new Map();
|
||||
|
||||
// Per-session terminal encoding (default: utf-8)
|
||||
const sessionEncodings = new Map();
|
||||
// Per-session stateful iconv decoders (keyed by sessionId, value: { stdout, stderr })
|
||||
const sessionDecoders = new Map();
|
||||
const iconv = require("iconv-lite");
|
||||
|
||||
function getSessionDecoder(sessionId, stream) {
|
||||
let decoders = sessionDecoders.get(sessionId);
|
||||
if (!decoders) {
|
||||
decoders = { stdout: null, stderr: null };
|
||||
sessionDecoders.set(sessionId, decoders);
|
||||
}
|
||||
if (!decoders[stream]) {
|
||||
const enc = sessionEncodings.get(sessionId) || "utf-8";
|
||||
decoders[stream] = iconv.getDecoder(enc);
|
||||
}
|
||||
return decoders[stream];
|
||||
}
|
||||
|
||||
function resetSessionDecoders(sessionId) {
|
||||
const enc = sessionEncodings.get(sessionId) || "utf-8";
|
||||
const decoders = { stdout: iconv.getDecoder(enc), stderr: iconv.getDecoder(enc) };
|
||||
sessionDecoders.set(sessionId, decoders);
|
||||
}
|
||||
|
||||
function getAuthCacheKey(username, hostname, port) {
|
||||
return `${username}@${hostname}:${port || 22}`;
|
||||
}
|
||||
@@ -567,9 +603,7 @@ async function startSSHSession(event, options) {
|
||||
// If no primary auth method configured, try ssh-agent first, then ALL default keys
|
||||
if (!connectOpts.privateKey && !connectOpts.password && !connectOpts.agent) {
|
||||
// First, try to use ssh-agent if available (this is what regular SSH does)
|
||||
const sshAgentSocket = process.platform === "win32"
|
||||
? "\\\\.\\pipe\\openssh-ssh-agent"
|
||||
: process.env.SSH_AUTH_SOCK;
|
||||
const sshAgentSocket = await getAvailableAgentSocket();
|
||||
|
||||
if (sshAgentSocket) {
|
||||
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
|
||||
@@ -596,13 +630,14 @@ async function startSSHSession(event, options) {
|
||||
|
||||
// Agent forwarding
|
||||
if (options.agentForwarding) {
|
||||
connectOpts.agentForward = true;
|
||||
if (!connectOpts.agent) {
|
||||
if (process.platform === "win32") {
|
||||
connectOpts.agent = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
} else {
|
||||
connectOpts.agent = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
connectOpts.agent = await getAvailableAgentSocket();
|
||||
}
|
||||
// Only enable forwarding when an agent is actually available
|
||||
if (connectOpts.agent) {
|
||||
connectOpts.agentForward = true;
|
||||
} else {
|
||||
log("Agent forwarding requested but no agent available, skipping");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -962,11 +997,13 @@ async function startSSHSession(event, options) {
|
||||
};
|
||||
|
||||
stream.on("data", (data) => {
|
||||
bufferData(data.toString("utf8"));
|
||||
const decoder = getSessionDecoder(sessionId, "stdout");
|
||||
bufferData(decoder.write(data));
|
||||
});
|
||||
|
||||
stream.stderr?.on("data", (data) => {
|
||||
bufferData(data.toString("utf8"));
|
||||
const decoder = getSessionDecoder(sessionId, "stderr");
|
||||
bufferData(decoder.write(data));
|
||||
});
|
||||
|
||||
stream.on("close", () => {
|
||||
@@ -978,12 +1015,19 @@ async function startSSHSession(event, options) {
|
||||
const contents = event.sender;
|
||||
safeSend(contents, "netcatty:exit", { sessionId, exitCode: 0 });
|
||||
sessions.delete(sessionId);
|
||||
sessionEncodings.delete(sessionId);
|
||||
sessionDecoders.delete(sessionId);
|
||||
conn.end();
|
||||
for (const c of chainConnections) {
|
||||
try { c.end(); } catch { }
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-seed encoding from host charset if it's a GB variant
|
||||
if (options.charset && /^gb/i.test(String(options.charset).trim())) {
|
||||
sessionEncodings.set(sessionId, "gb18030");
|
||||
}
|
||||
|
||||
// Run startup command if specified
|
||||
if (options.startupCommand) {
|
||||
setTimeout(() => {
|
||||
@@ -1325,7 +1369,9 @@ async function startSSHSessionWrapper(event, options) {
|
||||
if (isAuthError) {
|
||||
// Check if there are encrypted default keys we haven't tried yet
|
||||
// Only offer retry if no unlocked keys were provided in this attempt
|
||||
if (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0) {
|
||||
const hasJumpHosts = options.jumpHosts && options.jumpHosts.length > 0;
|
||||
const isPasswordOnly = !hasJumpHosts && !options.agentForwarding && !!options.password && !options.privateKey && !options.certificate;
|
||||
if (!isPasswordOnly && (!options._unlockedEncryptedKeys || options._unlockedEncryptedKeys.length === 0)) {
|
||||
const allKeysWithEncrypted = await findAllDefaultPrivateKeysFromHelper({ includeEncrypted: true });
|
||||
const encryptedKeys = allKeysWithEncrypted.filter(k => k.isEncrypted);
|
||||
|
||||
@@ -1786,6 +1832,24 @@ async function getServerStats(event, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set terminal encoding for an active SSH session
|
||||
*/
|
||||
async function setSessionEncoding(_event, { sessionId, encoding }) {
|
||||
const session = sessions?.get(sessionId);
|
||||
if (!session || !session.stream) {
|
||||
return { ok: false, encoding: encoding || "utf-8" };
|
||||
}
|
||||
const enc = String(encoding || "utf-8").toLowerCase();
|
||||
if (!iconv.encodingExists(enc)) {
|
||||
return { ok: false, encoding: enc };
|
||||
}
|
||||
sessionEncodings.set(sessionId, enc);
|
||||
// Reset stateful decoders so new data uses the updated encoding
|
||||
resetSessionDecoders(sessionId);
|
||||
return { ok: true, encoding: enc };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for SSH operations
|
||||
*/
|
||||
@@ -1795,6 +1859,7 @@ function registerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:ssh:pwd", getSessionPwd);
|
||||
ipcMain.handle("netcatty:ssh:stats", getServerStats);
|
||||
ipcMain.handle("netcatty:key:generate", generateKeyPair);
|
||||
ipcMain.handle("netcatty:ssh:setEncoding", setSessionEncoding);
|
||||
ipcMain.handle("netcatty:ssh:check-agent", async () => {
|
||||
return await checkWindowsSshAgent();
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ let electronModule = null;
|
||||
|
||||
const DEFAULT_UTF8_LOCALE = "en_US.UTF-8";
|
||||
const LOGIN_SHELLS = new Set(["bash", "zsh", "fish", "ksh"]);
|
||||
const POWERSHELL_SHELLS = new Set(["powershell", "powershell.exe", "pwsh", "pwsh.exe"]);
|
||||
|
||||
const getLoginShellArgs = (shellPath) => {
|
||||
if (!shellPath || process.platform === "win32") return [];
|
||||
@@ -35,15 +36,34 @@ function init(deps) {
|
||||
/**
|
||||
* Find executable path on Windows
|
||||
*/
|
||||
function isWindowsAppExecutionAlias(filePath) {
|
||||
if (!filePath || process.platform !== "win32") return false;
|
||||
|
||||
const normalizedPath = path.normalize(filePath).toLowerCase();
|
||||
const windowsAppsDir = path.join(
|
||||
process.env.LOCALAPPDATA || "",
|
||||
"Microsoft",
|
||||
"WindowsApps",
|
||||
).toLowerCase();
|
||||
|
||||
return !!windowsAppsDir && normalizedPath.startsWith(`${windowsAppsDir}${path.sep}`);
|
||||
}
|
||||
|
||||
function findExecutable(name) {
|
||||
if (process.platform !== "win32") return name;
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
try {
|
||||
const result = execFileSync("where.exe", [name], { encoding: "utf8" });
|
||||
const firstLine = result.split(/\r?\n/)[0].trim();
|
||||
if (firstLine && fs.existsSync(firstLine)) {
|
||||
return firstLine;
|
||||
const candidates = result
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!fs.existsSync(candidate)) continue;
|
||||
if (name === "pwsh" && isWindowsAppExecutionAlias(candidate)) continue;
|
||||
return candidate;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`Could not find ${name} via where.exe:`, err.message);
|
||||
@@ -51,11 +71,32 @@ function findExecutable(name) {
|
||||
|
||||
// Fallback to common locations
|
||||
const path = require("node:path");
|
||||
const commonPaths = [
|
||||
const commonPaths = [];
|
||||
|
||||
if (name === "pwsh") {
|
||||
commonPaths.push(
|
||||
path.join(process.env.ProgramFiles || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
|
||||
path.join(process.env.ProgramW6432 || "C:\\Program Files", "PowerShell", "7", "pwsh.exe"),
|
||||
);
|
||||
}
|
||||
|
||||
if (name === "powershell") {
|
||||
commonPaths.push(
|
||||
path.join(
|
||||
process.env.SystemRoot || "C:\\Windows",
|
||||
"System32",
|
||||
"WindowsPowerShell",
|
||||
"v1.0",
|
||||
"powershell.exe",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
commonPaths.push(
|
||||
path.join(process.env.SystemRoot || "C:\\Windows", "System32", "OpenSSH", `${name}.exe`),
|
||||
path.join(process.env.ProgramFiles || "C:\\Program Files", "Git", "usr", "bin", `${name}.exe`),
|
||||
path.join(process.env.ProgramFiles || "C:\\Program Files", "OpenSSH", `${name}.exe`),
|
||||
];
|
||||
);
|
||||
|
||||
for (const p of commonPaths) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
@@ -64,6 +105,39 @@ function findExecutable(name) {
|
||||
return name;
|
||||
}
|
||||
|
||||
function getDefaultLocalShell() {
|
||||
if (process.platform !== "win32") {
|
||||
return process.env.SHELL || "/bin/bash";
|
||||
}
|
||||
|
||||
const pwsh = findExecutable("pwsh");
|
||||
if (pwsh && pwsh.toLowerCase() !== "pwsh") {
|
||||
return pwsh;
|
||||
}
|
||||
|
||||
const powershell = findExecutable("powershell");
|
||||
if (powershell && powershell.toLowerCase() !== "powershell") {
|
||||
return powershell;
|
||||
}
|
||||
|
||||
return "powershell.exe";
|
||||
}
|
||||
|
||||
function getLocalShellArgs(shellPath) {
|
||||
if (!shellPath) return [];
|
||||
|
||||
if (process.platform !== "win32") {
|
||||
return getLoginShellArgs(shellPath);
|
||||
}
|
||||
|
||||
const shellName = path.basename(shellPath).toLowerCase();
|
||||
if (POWERSHELL_SHELLS.has(shellName)) {
|
||||
return ["-NoLogo"];
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
const isUtf8Locale = (value) => typeof value === "string" && /utf-?8/i.test(value);
|
||||
|
||||
const isEmptyLocale = (value) => {
|
||||
@@ -97,11 +171,9 @@ function startLocalSession(event, payload) {
|
||||
const sessionId =
|
||||
payload?.sessionId ||
|
||||
`${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const defaultShell = process.platform === "win32"
|
||||
? findExecutable("powershell") || "powershell.exe"
|
||||
: process.env.SHELL || "/bin/bash";
|
||||
const defaultShell = getDefaultLocalShell();
|
||||
const shell = payload?.shell || defaultShell;
|
||||
const shellArgs = getLoginShellArgs(shell);
|
||||
const shellArgs = getLocalShellArgs(shell);
|
||||
const env = applyLocaleDefaults({
|
||||
...process.env,
|
||||
...(payload?.env || {}),
|
||||
@@ -129,6 +201,7 @@ function startLocalSession(event, payload) {
|
||||
}
|
||||
|
||||
const proc = pty.spawn(shell, shellArgs, {
|
||||
name: env.TERM || "xterm-256color",
|
||||
cols: payload?.cols || 80,
|
||||
rows: payload?.rows || 24,
|
||||
env,
|
||||
@@ -666,10 +739,7 @@ function registerHandlers(ipcMain) {
|
||||
* Get the default shell for the current platform
|
||||
*/
|
||||
function getDefaultShell() {
|
||||
if (process.platform === "win32") {
|
||||
return findExecutable("powershell") || "powershell.exe";
|
||||
}
|
||||
return process.env.SHELL || "/bin/bash";
|
||||
return getDefaultLocalShell();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const os = require("node:os");
|
||||
const { encodePathForSession, ensureRemoteDirForSession } = require("./sftpBridge.cjs");
|
||||
const { encodePathForSession, ensureRemoteDirForSession, requireSftpChannel } = require("./sftpBridge.cjs");
|
||||
|
||||
// ── Transfer performance tuning ──────────────────────────────────────────────
|
||||
// ssh2's fastPut/fastGet send multiple SFTP read/write requests in parallel,
|
||||
@@ -52,6 +52,7 @@ async function openIsolatedSftpChannel(client) {
|
||||
* Falls back to sequential stream piping if fastPut is unavailable.
|
||||
*/
|
||||
async function uploadFile(localPath, remotePath, client, fileSize, transfer, sendProgress) {
|
||||
await requireSftpChannel(client);
|
||||
const sftp = client.sftp;
|
||||
if (!sftp) throw new Error("SFTP client not ready");
|
||||
|
||||
@@ -159,6 +160,7 @@ async function uploadFile(localPath, remotePath, client, fileSize, transfer, sen
|
||||
* Falls back to sequential stream piping if fastGet is unavailable.
|
||||
*/
|
||||
async function downloadFile(remotePath, localPath, client, fileSize, transfer, sendProgress) {
|
||||
await requireSftpChannel(client);
|
||||
const sftp = client.sftp;
|
||||
if (!sftp) throw new Error("SFTP client not ready");
|
||||
|
||||
@@ -404,6 +406,7 @@ async function startTransfer(event, payload, onProgress) {
|
||||
} else if (sourceType === 'sftp') {
|
||||
const client = sftpClients.get(sourceSftpId);
|
||||
if (!client) throw new Error("Source SFTP session not found");
|
||||
await requireSftpChannel(client);
|
||||
const encodedSourcePath = encodePathForSession(sourceSftpId, sourcePath, sourceEncoding);
|
||||
const stat = await client.stat(encodedSourcePath);
|
||||
fileSize = stat.size;
|
||||
|
||||
@@ -82,6 +82,7 @@ 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 autoUpdateBridge = require("./bridges/autoUpdateBridge.cjs");
|
||||
const windowManager = require("./bridges/windowManager.cjs");
|
||||
|
||||
// GPU settings
|
||||
@@ -405,6 +406,8 @@ const registerBridges = (win) => {
|
||||
compressUploadBridge.registerHandlers(ipcMain);
|
||||
globalShortcutBridge.registerHandlers(ipcMain);
|
||||
credentialBridge.registerHandlers(ipcMain, electronModule);
|
||||
autoUpdateBridge.init(deps);
|
||||
autoUpdateBridge.registerHandlers(ipcMain);
|
||||
|
||||
// Settings window handler
|
||||
ipcMain.handle("netcatty:settings:open", async () => {
|
||||
@@ -641,6 +644,49 @@ const registerBridges = (win) => {
|
||||
return localPath;
|
||||
});
|
||||
|
||||
// Download SFTP file to temp with progress reporting via transfer events.
|
||||
// Progress/complete/cancelled events are delivered via the netcatty:transfer:*
|
||||
// channels (handled by transferBridge.startTransfer), so the IPC return value
|
||||
// only carries the resolved temp path. Cancellation is NOT an error here —
|
||||
// the UI already transitions the task to "cancelled" via the dedicated event.
|
||||
ipcMain.handle("netcatty:sftp:downloadToTempWithProgress", async (event, { sftpId, remotePath, fileName, encoding, transferId }) => {
|
||||
const localPath = await tempDirBridge.getTempFilePath(fileName);
|
||||
const cleanupPartialDownload = async () => {
|
||||
try {
|
||||
await fs.promises.rm(localPath, { force: true });
|
||||
} catch (err) {
|
||||
console.warn(`[Main] Failed to clean temp download after interruption: ${localPath}`, err);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
transferId,
|
||||
sourcePath: remotePath,
|
||||
targetPath: localPath,
|
||||
sourceType: "sftp",
|
||||
targetType: "local",
|
||||
sourceSftpId: sftpId,
|
||||
sourceEncoding: encoding,
|
||||
totalBytes: 0,
|
||||
};
|
||||
|
||||
const result = await transferBridge.startTransfer(event, payload);
|
||||
|
||||
if (result.error) {
|
||||
await cleanupPartialDownload();
|
||||
if (result.error === "Transfer cancelled") {
|
||||
return { localPath, cancelled: true };
|
||||
}
|
||||
throw new Error(result.error);
|
||||
}
|
||||
return { localPath, cancelled: false };
|
||||
} catch (err) {
|
||||
await cleanupPartialDownload();
|
||||
throw err;
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a temp file (for cleanup when editors close)
|
||||
ipcMain.handle("netcatty:deleteTempFile", async (_event, { filePath }) => {
|
||||
try {
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
const { ipcRenderer, contextBridge, webUtils } = require("electron");
|
||||
const os = require("node:os");
|
||||
|
||||
const dataListeners = new Map();
|
||||
const exitListeners = new Map();
|
||||
const transferProgressListeners = new Map();
|
||||
const transferCompleteListeners = new Map();
|
||||
const transferErrorListeners = new Map();
|
||||
const transferCancelledListeners = new Map();
|
||||
const chainProgressListeners = new Map();
|
||||
const authFailedListeners = new Map();
|
||||
const languageChangeListeners = new Set();
|
||||
@@ -12,6 +14,16 @@ const fullscreenChangeListeners = new Set();
|
||||
const keyboardInteractiveListeners = new Set();
|
||||
const passphraseListeners = new Set();
|
||||
const passphraseTimeoutListeners = new Set();
|
||||
const updateDownloadProgressListeners = new Set();
|
||||
const updateDownloadedListeners = new Set();
|
||||
const updateErrorListeners = new Set();
|
||||
|
||||
function cleanupTransferListeners(transferId) {
|
||||
transferProgressListeners.delete(transferId);
|
||||
transferCompleteListeners.delete(transferId);
|
||||
transferErrorListeners.delete(transferId);
|
||||
transferCancelledListeners.delete(transferId);
|
||||
}
|
||||
|
||||
ipcRenderer.on("netcatty:data", (_event, payload) => {
|
||||
const set = dataListeners.get(payload.sessionId);
|
||||
@@ -122,6 +134,37 @@ ipcRenderer.on("netcatty:passphrase-timeout", (_event, payload) => {
|
||||
});
|
||||
});
|
||||
|
||||
// Auto-update events
|
||||
ipcRenderer.on("netcatty:update:download-progress", (_event, payload) => {
|
||||
updateDownloadProgressListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Update download-progress callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:update:downloaded", () => {
|
||||
updateDownloadedListeners.forEach((cb) => {
|
||||
try {
|
||||
cb();
|
||||
} catch (err) {
|
||||
console.error("Update downloaded callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:update:error", (_event, payload) => {
|
||||
updateErrorListeners.forEach((cb) => {
|
||||
try {
|
||||
cb(payload);
|
||||
} catch (err) {
|
||||
console.error("Update error callback failed", err);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Transfer progress events
|
||||
ipcRenderer.on("netcatty:transfer:progress", (_event, payload) => {
|
||||
const cb = transferProgressListeners.get(payload.transferId);
|
||||
@@ -143,10 +186,7 @@ ipcRenderer.on("netcatty:transfer:complete", (_event, payload) => {
|
||||
console.error("Transfer complete callback failed", err);
|
||||
}
|
||||
}
|
||||
// Cleanup listeners
|
||||
transferProgressListeners.delete(payload.transferId);
|
||||
transferCompleteListeners.delete(payload.transferId);
|
||||
transferErrorListeners.delete(payload.transferId);
|
||||
cleanupTransferListeners(payload.transferId);
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:transfer:error", (_event, payload) => {
|
||||
@@ -158,17 +198,15 @@ ipcRenderer.on("netcatty:transfer:error", (_event, payload) => {
|
||||
console.error("Transfer error callback failed", err);
|
||||
}
|
||||
}
|
||||
// Cleanup listeners
|
||||
transferProgressListeners.delete(payload.transferId);
|
||||
transferCompleteListeners.delete(payload.transferId);
|
||||
transferErrorListeners.delete(payload.transferId);
|
||||
cleanupTransferListeners(payload.transferId);
|
||||
});
|
||||
|
||||
ipcRenderer.on("netcatty:transfer:cancelled", (_event, payload) => {
|
||||
// Just cleanup listeners, the UI already knows it's cancelled
|
||||
transferProgressListeners.delete(payload.transferId);
|
||||
transferCompleteListeners.delete(payload.transferId);
|
||||
transferErrorListeners.delete(payload.transferId);
|
||||
const cb = transferCancelledListeners.get(payload.transferId);
|
||||
if (cb) {
|
||||
try { cb(); } catch { }
|
||||
}
|
||||
cleanupTransferListeners(payload.transferId);
|
||||
});
|
||||
|
||||
// Upload with progress listeners
|
||||
@@ -320,6 +358,19 @@ ipcRenderer.on("netcatty:trayPanel:setMenuData", (_event, data) => {
|
||||
});
|
||||
|
||||
const api = {
|
||||
getWindowsPtyInfo: () => {
|
||||
if (process.platform !== "win32") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const releaseParts = os.release().split(".");
|
||||
const buildNumber = Number.parseInt(releaseParts[2] || "", 10);
|
||||
const hasBuildNumber = Number.isFinite(buildNumber);
|
||||
const backend =
|
||||
hasBuildNumber && buildNumber < 18309 ? "winpty" : "conpty";
|
||||
|
||||
return hasBuildNumber ? { backend, buildNumber } : { backend };
|
||||
},
|
||||
startSSHSession: async (options) => {
|
||||
const result = await ipcRenderer.invoke("netcatty:start", options);
|
||||
return result.sessionId;
|
||||
@@ -376,6 +427,8 @@ const api = {
|
||||
closeSession: (sessionId) => {
|
||||
ipcRenderer.send("netcatty:close", { sessionId });
|
||||
},
|
||||
setSessionEncoding: (sessionId, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:ssh:setEncoding", { sessionId, encoding }),
|
||||
onSessionData: (sessionId, cb) => {
|
||||
if (!dataListeners.has(sessionId)) dataListeners.set(sessionId, new Set());
|
||||
dataListeners.get(sessionId).add(cb);
|
||||
@@ -542,10 +595,7 @@ const api = {
|
||||
return ipcRenderer.invoke("netcatty:transfer:start", options);
|
||||
},
|
||||
cancelTransfer: async (transferId) => {
|
||||
// Cleanup listeners
|
||||
transferProgressListeners.delete(transferId);
|
||||
transferCompleteListeners.delete(transferId);
|
||||
transferErrorListeners.delete(transferId);
|
||||
cleanupTransferListeners(transferId);
|
||||
return ipcRenderer.invoke("netcatty:transfer:cancel", { transferId });
|
||||
},
|
||||
// Compressed folder upload
|
||||
@@ -640,6 +690,12 @@ const api = {
|
||||
listPortForwards: async () => {
|
||||
return ipcRenderer.invoke("netcatty:portforward:list");
|
||||
},
|
||||
stopAllPortForwards: async () => {
|
||||
return ipcRenderer.invoke("netcatty:portforward:stopAll");
|
||||
},
|
||||
stopPortForwardByRuleId: async (ruleId) => {
|
||||
return ipcRenderer.invoke("netcatty:portforward:stopByRuleId", { ruleId });
|
||||
},
|
||||
onPortForwardStatus: (tunnelId, cb) => {
|
||||
if (!portForwardStatusListeners.has(tunnelId)) {
|
||||
portForwardStatusListeners.set(tunnelId, new Set());
|
||||
@@ -712,6 +768,18 @@ const api = {
|
||||
ipcRenderer.invoke("netcatty:openWithApplication", { filePath, appPath }),
|
||||
downloadSftpToTemp: (sftpId, remotePath, fileName, encoding) =>
|
||||
ipcRenderer.invoke("netcatty:sftp:downloadToTemp", { sftpId, remotePath, fileName, encoding }),
|
||||
downloadSftpToTempWithProgress: (sftpId, remotePath, fileName, encoding, transferId, onProgress, onComplete, onError, onCancelled) => {
|
||||
if (onProgress) transferProgressListeners.set(transferId, onProgress);
|
||||
if (onComplete) transferCompleteListeners.set(transferId, onComplete);
|
||||
if (onError) transferErrorListeners.set(transferId, onError);
|
||||
if (onCancelled) transferCancelledListeners.set(transferId, onCancelled);
|
||||
return ipcRenderer
|
||||
.invoke("netcatty:sftp:downloadToTempWithProgress", { sftpId, remotePath, fileName, encoding, transferId })
|
||||
.catch((err) => {
|
||||
cleanupTransferListeners(transferId);
|
||||
throw err;
|
||||
});
|
||||
},
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog: (defaultPath, filters) =>
|
||||
@@ -850,6 +918,23 @@ const api = {
|
||||
credentialsAvailable: () => ipcRenderer.invoke("netcatty:credentials:available"),
|
||||
credentialsEncrypt: (plaintext) => ipcRenderer.invoke("netcatty:credentials:encrypt", plaintext),
|
||||
credentialsDecrypt: (value) => ipcRenderer.invoke("netcatty:credentials:decrypt", value),
|
||||
|
||||
// Auto-update
|
||||
checkForUpdate: () => ipcRenderer.invoke("netcatty:update:check"),
|
||||
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
|
||||
onUpdateDownloadProgress: (cb) => {
|
||||
updateDownloadProgressListeners.add(cb);
|
||||
return () => updateDownloadProgressListeners.delete(cb);
|
||||
},
|
||||
onUpdateDownloaded: (cb) => {
|
||||
updateDownloadedListeners.add(cb);
|
||||
return () => updateDownloadedListeners.delete(cb);
|
||||
},
|
||||
onUpdateError: (cb) => {
|
||||
updateErrorListeners.add(cb);
|
||||
return () => updateErrorListeners.delete(cb);
|
||||
},
|
||||
};
|
||||
|
||||
// Merge with existing netcatty (if any) to avoid stale objects on hot reload
|
||||
|
||||
40
global.d.ts
vendored
@@ -124,9 +124,15 @@ declare global {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
interface NetcattyWindowsPtyInfo {
|
||||
backend: 'conpty' | 'winpty';
|
||||
buildNumber?: number;
|
||||
}
|
||||
|
||||
type PortForwardStatusCallback = (status: 'inactive' | 'connecting' | 'active' | 'error', error?: string) => void;
|
||||
|
||||
interface NetcattyBridge {
|
||||
getWindowsPtyInfo?(): NetcattyWindowsPtyInfo | null;
|
||||
startSSHSession(options: NetcattySSHOptions): Promise<string>;
|
||||
startTelnetSession?(options: {
|
||||
sessionId?: string;
|
||||
@@ -229,6 +235,7 @@ declare global {
|
||||
}>;
|
||||
};
|
||||
}>;
|
||||
setSessionEncoding?(sessionId: string, encoding: string): Promise<{ ok: boolean; encoding: string }>;
|
||||
writeToSession(sessionId: string, data: string): void;
|
||||
resizeSession(sessionId: string, cols: number, rows: number): void;
|
||||
closeSession(sessionId: string): void;
|
||||
@@ -422,6 +429,8 @@ declare global {
|
||||
stopPortForward?(tunnelId: string): Promise<PortForwardResult>;
|
||||
getPortForwardStatus?(tunnelId: string): Promise<PortForwardStatusResult>;
|
||||
listPortForwards?(): Promise<{ tunnelId: string; type: string; status: string }[]>;
|
||||
stopAllPortForwards?(): Promise<void>;
|
||||
stopPortForwardByRuleId?(ruleId: string): Promise<{ stopped: number }>;
|
||||
onPortForwardStatus?(tunnelId: string, cb: PortForwardStatusCallback): () => void;
|
||||
|
||||
// Known Hosts
|
||||
@@ -541,6 +550,17 @@ declare global {
|
||||
selectApplication?(): Promise<{ path: string; name: string } | null>;
|
||||
openWithApplication?(filePath: string, appPath: string): Promise<boolean>;
|
||||
downloadSftpToTemp?(sftpId: string, remotePath: string, fileName: string, encoding?: SftpFilenameEncoding): Promise<string>;
|
||||
downloadSftpToTempWithProgress?(
|
||||
sftpId: string,
|
||||
remotePath: string,
|
||||
fileName: string,
|
||||
encoding: SftpFilenameEncoding | undefined,
|
||||
transferId: string,
|
||||
onProgress?: (transferred: number, total: number, speed: number) => void,
|
||||
onComplete?: () => void,
|
||||
onError?: (error: string) => void,
|
||||
onCancelled?: () => void
|
||||
): Promise<{ localPath: string; cancelled: boolean }>;
|
||||
|
||||
// Save dialog for file downloads
|
||||
showSaveDialog?(defaultPath: string, filters?: Array<{ name: string; extensions: string[] }>): Promise<string | null>;
|
||||
@@ -591,6 +611,26 @@ declare global {
|
||||
credentialsEncrypt?(plaintext: string): Promise<string>;
|
||||
credentialsDecrypt?(value: string): Promise<string>;
|
||||
|
||||
// Auto-update
|
||||
checkForUpdate?(): Promise<{
|
||||
available: boolean;
|
||||
supported?: boolean;
|
||||
version?: string;
|
||||
releaseNotes?: string;
|
||||
releaseDate?: string | null;
|
||||
error?: string;
|
||||
}>;
|
||||
downloadUpdate?(): Promise<{ success: boolean; error?: string }>;
|
||||
installUpdate?(): void;
|
||||
onUpdateDownloadProgress?(cb: (progress: {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}) => void): () => void;
|
||||
onUpdateDownloaded?(cb: () => void): () => void;
|
||||
onUpdateError?(cb: (payload: { error: string }) => void): () => void;
|
||||
|
||||
// Global Toggle Hotkey (Quake Mode)
|
||||
registerGlobalHotkey?(hotkey: string): Promise<{ success: boolean; enabled?: boolean; error?: string; accelerator?: string }>;
|
||||
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
|
||||
|
||||
11
index.html
@@ -158,9 +158,14 @@
|
||||
var lang = localStorage.getItem('netcatty_ui_language_v1');
|
||||
var root = document.documentElement;
|
||||
|
||||
if (theme === 'dark' || theme === 'light') {
|
||||
// Resolve 'system' (or absent — default is 'system') via OS preference
|
||||
var resolved = theme;
|
||||
if (!theme || theme === 'system') {
|
||||
resolved = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light';
|
||||
}
|
||||
if (resolved === 'dark' || resolved === 'light') {
|
||||
root.classList.remove('light', 'dark');
|
||||
root.classList.add(theme);
|
||||
root.classList.add(resolved);
|
||||
}
|
||||
|
||||
if (accentMode === 'custom' && accentColor) {
|
||||
@@ -169,7 +174,7 @@
|
||||
root.style.setProperty('--ring', accentColor);
|
||||
var parts = accentColor.split(/\s+/);
|
||||
var lightness = parseFloat((parts[2] || '').replace('%', ''));
|
||||
var accentForeground = theme === 'dark'
|
||||
var accentForeground = resolved === 'dark'
|
||||
? '220 40% 96%'
|
||||
: (!isNaN(lightness) && lightness < 55 ? '0 0% 98%' : '222 47% 12%');
|
||||
root.style.setProperty('--accent-foreground', accentForeground);
|
||||
|
||||
@@ -41,6 +41,9 @@ export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_v
|
||||
// SFTP File Opener Associations
|
||||
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
|
||||
|
||||
// SFTP Local Bookmarks
|
||||
export const STORAGE_KEY_SFTP_LOCAL_BOOKMARKS = 'netcatty_sftp_local_bookmarks_v1';
|
||||
|
||||
// SFTP Settings
|
||||
export const STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR = 'netcatty_sftp_double_click_behavior_v1';
|
||||
export const STORAGE_KEY_SFTP_AUTO_SYNC = 'netcatty_sftp_auto_sync_v1';
|
||||
|
||||
@@ -83,6 +83,15 @@ export class CloudSyncManager {
|
||||
private autoSyncTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private masterPassword: string | null = null; // In memory only!
|
||||
private hasStorageListener = false;
|
||||
// Promise that resolves once startup provider secret decryption finishes.
|
||||
// Awaited by getConnectedAdapter() to prevent using still-encrypted tokens.
|
||||
private decryptionReady: Promise<void>;
|
||||
// Per-provider flag: true once that provider's secrets have been
|
||||
// successfully decrypted. When false, getConnectedAdapter() will
|
||||
// retry decryption before using the tokens.
|
||||
private providerDecrypted: Record<CloudProvider, boolean> = {
|
||||
github: false, google: false, onedrive: false, webdav: false, s3: 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.
|
||||
@@ -101,7 +110,7 @@ export class CloudSyncManager {
|
||||
this.stateSnapshot = { ...this.state };
|
||||
this.setupCrossWindowSync();
|
||||
// Decrypt provider secrets asynchronously after initial load
|
||||
this.initProviderDecryption();
|
||||
this.decryptionReady = this.initProviderDecryption();
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
@@ -201,10 +210,15 @@ export class CloudSyncManager {
|
||||
// Only apply if no newer update has occurred during the async gap
|
||||
if (seq === this.providerDecryptSeq[p]) {
|
||||
this.state.providers[p] = decrypted;
|
||||
this.providerDecrypted[p] = true;
|
||||
}
|
||||
} else {
|
||||
// No secrets to decrypt — mark as done
|
||||
this.providerDecrypted[p] = true;
|
||||
}
|
||||
} catch {
|
||||
// Decryption failure is non-fatal; the adapter will fail on use
|
||||
// Decryption failed — likely the Electron IPC handler is not yet
|
||||
// registered. getConnectedAdapter() will retry for this provider.
|
||||
}
|
||||
}
|
||||
this.notifyStateChange();
|
||||
@@ -399,6 +413,35 @@ export class CloudSyncManager {
|
||||
};
|
||||
|
||||
private async getConnectedAdapter(provider: CloudProvider): Promise<CloudAdapter> {
|
||||
// Ensure startup decryption has finished before reading tokens
|
||||
await this.decryptionReady;
|
||||
|
||||
// If this provider's secrets were not successfully decrypted at
|
||||
// startup (IPC handler not registered yet), retry now.
|
||||
if (!this.providerDecrypted[provider]) {
|
||||
const conn = this.state.providers[provider];
|
||||
if (conn.tokens || conn.config) {
|
||||
try {
|
||||
const seq = ++this.providerDecryptSeq[provider];
|
||||
const decrypted = await decryptProviderSecrets(conn);
|
||||
if (seq === this.providerDecryptSeq[provider]) {
|
||||
this.state.providers[provider] = decrypted;
|
||||
this.providerDecrypted[provider] = true;
|
||||
// Evict any adapter cached with the old (encrypted) tokens
|
||||
// so a fresh one is built from the decrypted credentials below.
|
||||
const stale = this.adapters.get(provider);
|
||||
if (stale) {
|
||||
stale.signOut();
|
||||
this.adapters.delete(provider);
|
||||
}
|
||||
this.notifyStateChange();
|
||||
}
|
||||
} catch {
|
||||
// Still failing — will surface when adapter tries to use tokens
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const connection = this.state.providers[provider];
|
||||
const tokens = connection?.tokens;
|
||||
const config = connection?.config;
|
||||
@@ -868,7 +911,6 @@ export class CloudSyncManager {
|
||||
* Helper: Check for conflicts with a specific provider
|
||||
*/
|
||||
private async checkProviderConflict(
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter
|
||||
): Promise<{
|
||||
conflict: boolean;
|
||||
@@ -1027,7 +1069,7 @@ export class CloudSyncManager {
|
||||
|
||||
try {
|
||||
// 1. Check for conflict
|
||||
const checkResult = await this.checkProviderConflict(provider, adapter);
|
||||
const checkResult = await this.checkProviderConflict(adapter);
|
||||
|
||||
if (checkResult.error) {
|
||||
throw new Error(checkResult.error);
|
||||
@@ -1210,7 +1252,18 @@ export class CloudSyncManager {
|
||||
}
|
||||
|
||||
const connectedProviders = Object.entries(this.state.providers)
|
||||
.filter(([_, conn]) => conn.status === 'connected')
|
||||
.filter(([p, conn]) => {
|
||||
if (conn.status === 'connected') return true;
|
||||
// Auto-recover: retry providers stuck in 'error' if tokens/config still exist
|
||||
if (conn.status === 'error' && (conn.tokens || conn.config)) {
|
||||
this.state.providers[p as CloudProvider].status = 'connected';
|
||||
this.state.providers[p as CloudProvider].error = undefined;
|
||||
// Clear cached adapter so a fresh one is created with current (decrypted) tokens
|
||||
this.adapters.delete(p as CloudProvider);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})
|
||||
.map(([p]) => p as CloudProvider);
|
||||
|
||||
if (connectedProviders.length === 0) {
|
||||
@@ -1227,7 +1280,7 @@ export class CloudSyncManager {
|
||||
this.updateProviderStatus(provider, 'syncing');
|
||||
this.emit({ type: 'SYNC_STARTED', provider });
|
||||
|
||||
const check = await this.checkProviderConflict(provider, adapter);
|
||||
const check = await this.checkProviderConflict(adapter);
|
||||
return { provider, adapter, check };
|
||||
} catch (error) {
|
||||
return { provider, error: String(error) };
|
||||
|
||||
@@ -563,7 +563,6 @@ export class OneDriveAdapter {
|
||||
}
|
||||
|
||||
private async runWithAuthRetry<T>(
|
||||
context: string,
|
||||
operation: (accessToken: string) => Promise<T>
|
||||
): Promise<T> {
|
||||
const accessToken = await this.ensureValidToken();
|
||||
@@ -593,7 +592,7 @@ export class OneDriveAdapter {
|
||||
* Initialize or find sync file
|
||||
*/
|
||||
async initializeSync(): Promise<string | null> {
|
||||
return this.runWithAuthRetry('initializeSync', async (accessToken) => {
|
||||
return this.runWithAuthRetry(async (accessToken) => {
|
||||
this.fileId = await findSyncFile(accessToken);
|
||||
return this.fileId;
|
||||
});
|
||||
@@ -603,7 +602,7 @@ export class OneDriveAdapter {
|
||||
* Upload sync file
|
||||
*/
|
||||
async upload(syncedFile: SyncedFile): Promise<string> {
|
||||
return this.runWithAuthRetry('upload', async (accessToken) => {
|
||||
return this.runWithAuthRetry(async (accessToken) => {
|
||||
this.fileId = await uploadSyncFile(accessToken, syncedFile);
|
||||
return this.fileId;
|
||||
});
|
||||
@@ -613,7 +612,7 @@ export class OneDriveAdapter {
|
||||
* Download sync file
|
||||
*/
|
||||
async download(): Promise<SyncedFile | null> {
|
||||
return this.runWithAuthRetry('download', async (accessToken) => {
|
||||
return this.runWithAuthRetry(async (accessToken) => {
|
||||
if (!this.fileId) {
|
||||
this.fileId = await findSyncFile(accessToken);
|
||||
}
|
||||
@@ -629,7 +628,7 @@ export class OneDriveAdapter {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.runWithAuthRetry('deleteSync', async (accessToken) => {
|
||||
await this.runWithAuthRetry(async (accessToken) => {
|
||||
await deleteSyncFile(accessToken, this.fileId as string);
|
||||
this.fileId = null;
|
||||
});
|
||||
|
||||
@@ -52,6 +52,53 @@ export const clearReconnectTimer = (ruleId: string): void => {
|
||||
}
|
||||
};
|
||||
|
||||
// Cross-window reconnect cancellation via localStorage broadcast.
|
||||
// When one window deletes/replaces a rule, it writes to this key so
|
||||
// other windows (with pending reconnect timers) can cancel them.
|
||||
const RECONNECT_CANCEL_KEY = '__netcatty_pf_cancel_reconnect';
|
||||
|
||||
const broadcastReconnectCancel = (ruleId: string): void => {
|
||||
try {
|
||||
// Write then immediately remove so the storage event fires on
|
||||
// other windows without leaving stale data.
|
||||
window.localStorage.setItem(RECONNECT_CANCEL_KEY, ruleId);
|
||||
window.localStorage.removeItem(RECONNECT_CANCEL_KEY);
|
||||
} catch {
|
||||
// localStorage may be unavailable in some contexts
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Start listening for cross-window reconnect cancellation events.
|
||||
* Should be called once at app init (e.g. in the port-forwarding state hook).
|
||||
* Returns a cleanup function.
|
||||
*/
|
||||
export const initReconnectCancelListener = (): (() => void) => {
|
||||
const handler = (e: StorageEvent) => {
|
||||
if (e.key !== RECONNECT_CANCEL_KEY || !e.newValue) return;
|
||||
const ruleId = e.newValue;
|
||||
clearReconnectTimer(ruleId);
|
||||
|
||||
const conn = activeConnections.get(ruleId);
|
||||
if (conn) {
|
||||
conn.unsubscribe?.();
|
||||
activeConnections.delete(ruleId);
|
||||
}
|
||||
|
||||
// Also ask the backend to stop any tunnel for this rule.
|
||||
// This catches tunnels still in SSH handshake that aren't yet
|
||||
// in the renderer's activeConnections or the backend's list output.
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.stopPortForwardByRuleId) {
|
||||
bridge.stopPortForwardByRuleId(ruleId).catch((err: unknown) => {
|
||||
logger.warn(`[PortForwardingService] Cross-window stopByRuleId failed for ${ruleId}:`, err);
|
||||
});
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', handler);
|
||||
return () => window.removeEventListener('storage', handler);
|
||||
};
|
||||
|
||||
/**
|
||||
* Helper function to schedule a reconnection attempt
|
||||
* Returns true if a reconnect was scheduled, false otherwise
|
||||
@@ -69,16 +116,22 @@ const scheduleReconnectIfNeeded = (
|
||||
const attempts = (currentConn?.reconnectAttempts ?? 0) + 1;
|
||||
|
||||
if (attempts <= MAX_RECONNECT_ATTEMPTS) {
|
||||
// If the activeConnections entry was already deleted (e.g. by
|
||||
// stopAndCleanupRule while the handshake was in-flight), we
|
||||
// can't actually schedule a reconnect. Return false so the
|
||||
// caller transitions to 'inactive' instead of stuck 'connecting'.
|
||||
if (!currentConn) {
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info(`[PortForwardingService] Scheduling reconnect ${attempts}/${MAX_RECONNECT_ATTEMPTS}`);
|
||||
|
||||
if (currentConn) {
|
||||
currentConn.reconnectAttempts = attempts;
|
||||
currentConn.reconnectTimeoutId = setTimeout(() => {
|
||||
if (reconnectCallback) {
|
||||
reconnectCallback(ruleId, onStatusChange);
|
||||
}
|
||||
}, RECONNECT_DELAY_MS);
|
||||
}
|
||||
currentConn.reconnectAttempts = attempts;
|
||||
currentConn.reconnectTimeoutId = setTimeout(() => {
|
||||
if (reconnectCallback) {
|
||||
reconnectCallback(ruleId, onStatusChange);
|
||||
}
|
||||
}, RECONNECT_DELAY_MS);
|
||||
|
||||
onStatusChange('connecting', `Reconnecting (${attempts}/${MAX_RECONNECT_ATTEMPTS})...`);
|
||||
return true;
|
||||
@@ -108,6 +161,39 @@ export const getActiveRuleIds = (): string[] => {
|
||||
.map(([ruleId]) => ruleId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Stop and clean up a single rule's tunnel.
|
||||
* Used when a rule is deleted or replaced via import, where we need to ensure
|
||||
* the backend tunnel is torn down and all reconnect timers are cancelled.
|
||||
* This is a fire-and-forget cleanup — errors are logged but not propagated.
|
||||
*/
|
||||
export const stopAndCleanupRule = (ruleId: string): void => {
|
||||
clearReconnectTimer(ruleId);
|
||||
|
||||
// Broadcast to other windows so they cancel any pending reconnect
|
||||
// timers for this rule (e.g. main window has a reconnect scheduled
|
||||
// but settings window just deleted the rule).
|
||||
broadcastReconnectCancel(ruleId);
|
||||
|
||||
const conn = activeConnections.get(ruleId);
|
||||
if (conn) {
|
||||
// Unsubscribe from status events
|
||||
conn.unsubscribe?.();
|
||||
activeConnections.delete(ruleId);
|
||||
}
|
||||
|
||||
// Use stopPortForwardByRuleId exclusively — it sets tunnel.cancelled = true
|
||||
// before conn.end(), so the close handler resolves gracefully. The old
|
||||
// stopPortForward(tunnelId) IPC deletes the tunnel entry immediately,
|
||||
// which makes the cancelled flag invisible to the close handler.
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.stopPortForwardByRuleId) {
|
||||
bridge.stopPortForwardByRuleId(ruleId).catch((err: unknown) => {
|
||||
logger.warn(`[PortForwardingService] Backend stopByRuleId failed for ${ruleId}:`, err);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Tunnel ID prefix and UUID regex pattern for parsing
|
||||
const TUNNEL_ID_PREFIX = 'pf-';
|
||||
// UUID format: 8-4-4-4-12 hexadecimal characters
|
||||
@@ -166,7 +252,7 @@ export const syncWithBackend = async (): Promise<void> => {
|
||||
activeConnections.set(ruleId, {
|
||||
ruleId,
|
||||
tunnelId: tunnel.tunnelId,
|
||||
status: 'active',
|
||||
status: (tunnel.status === 'active' ? 'active' : 'connecting') as 'active' | 'connecting',
|
||||
});
|
||||
|
||||
logger.info(`[PortForwardingService] Synced active tunnel for rule ${ruleId}`);
|
||||
@@ -177,6 +263,93 @@ export const syncWithBackend = async (): Promise<void> => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconcile renderer-side connection state with the backend (heartbeat).
|
||||
*
|
||||
* Returns the set of ruleIds whose status changed so the caller can update
|
||||
* React state accordingly.
|
||||
*
|
||||
* Cases handled:
|
||||
* 1. Renderer thinks a tunnel is active, but backend says it's gone
|
||||
* → clean up activeConnections, return ruleId as "gone"
|
||||
* 2. Backend has an active tunnel that the renderer doesn't track
|
||||
* → add to activeConnections, return ruleId as "appeared"
|
||||
*/
|
||||
export const reconcileWithBackend = async (): Promise<{
|
||||
gone: string[];
|
||||
appeared: string[];
|
||||
}> => {
|
||||
const result = { gone: [] as string[], appeared: [] as string[] };
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
if (!bridge?.listPortForwards) return result;
|
||||
|
||||
try {
|
||||
const backendTunnels = await bridge.listPortForwards();
|
||||
const backendRuleIds = new Set<string>();
|
||||
|
||||
for (const tunnel of backendTunnels) {
|
||||
const ruleId = parseRuleIdFromTunnelId(tunnel.tunnelId);
|
||||
if (ruleId) {
|
||||
backendRuleIds.add(ruleId);
|
||||
|
||||
// Case 2: backend has it, renderer doesn't — insert it
|
||||
if (!activeConnections.has(ruleId)) {
|
||||
activeConnections.set(ruleId, {
|
||||
ruleId,
|
||||
tunnelId: tunnel.tunnelId,
|
||||
status: (tunnel.status === 'active' ? 'active' : 'connecting') as 'active' | 'connecting',
|
||||
});
|
||||
result.appeared.push(ruleId);
|
||||
} else {
|
||||
// Case 3: renderer tracks it, but status may have changed
|
||||
// (e.g. connecting → active after SSH handshake completed
|
||||
// in another window).
|
||||
const existing = activeConnections.get(ruleId)!;
|
||||
const backendStatus = (tunnel.status === 'active' ? 'active' : 'connecting') as 'active' | 'connecting';
|
||||
if (existing.status !== backendStatus) {
|
||||
existing.status = backendStatus;
|
||||
existing.tunnelId = tunnel.tunnelId;
|
||||
result.appeared.push(ruleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 1: renderer thinks tunnel is active/connecting, but backend
|
||||
// says it's gone. For 'connecting' entries seeded by a previous
|
||||
// reconcile (observing another window's handshake), also evict if the
|
||||
// backend no longer reports them — the handshake failed or was
|
||||
// cancelled. Only skip 'connecting' entries that this renderer
|
||||
// initiated itself (they have an unsubscribe callback because this
|
||||
// renderer called startPortForward and registered a status listener).
|
||||
for (const [ruleId, conn] of activeConnections) {
|
||||
if (!backendRuleIds.has(ruleId)) {
|
||||
// Skip locally-initiated connecting tunnels (have unsubscribe)
|
||||
// — the backend hasn't reported them yet because the handshake
|
||||
// is still in progress.
|
||||
if (conn.status === 'connecting' && conn.unsubscribe) {
|
||||
continue;
|
||||
}
|
||||
conn.unsubscribe?.();
|
||||
clearReconnectTimer(ruleId);
|
||||
activeConnections.delete(ruleId);
|
||||
result.gone.push(ruleId);
|
||||
}
|
||||
}
|
||||
|
||||
if (result.gone.length || result.appeared.length) {
|
||||
logger.info(
|
||||
`[PortForwardingService] Reconcile: ${result.gone.length} gone, ${result.appeared.length} appeared`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[PortForwardingService] Reconcile failed:', err);
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* Start a port forwarding tunnel
|
||||
* @param enableReconnect - If true, will automatically attempt to reconnect on disconnect
|
||||
@@ -259,6 +432,15 @@ export const startPortForward = async (
|
||||
});
|
||||
|
||||
if (!result.success) {
|
||||
// Intentional cancellation (rule deleted/replaced during handshake).
|
||||
// Clean up quietly — no error state, no reconnect.
|
||||
if ((result as { cancelled?: boolean }).cancelled) {
|
||||
activeConnections.delete(rule.id);
|
||||
unsubscribe?.();
|
||||
onStatusChange('inactive');
|
||||
return { success: false, error: undefined };
|
||||
}
|
||||
|
||||
// Check if we should attempt reconnect
|
||||
const reconnectScheduled = scheduleReconnectIfNeeded(rule.id, enableReconnect, onStatusChange);
|
||||
if (reconnectScheduled) {
|
||||
@@ -360,6 +542,7 @@ export const isBackendAvailable = (): boolean => {
|
||||
export const stopAllPortForwards = async (): Promise<void> => {
|
||||
const bridge = netcattyBridge.get();
|
||||
|
||||
// Stop everything the renderer knows about
|
||||
for (const [ruleId, conn] of activeConnections) {
|
||||
// Clear any pending reconnect timer
|
||||
clearReconnectTimer(ruleId);
|
||||
@@ -375,6 +558,18 @@ export const stopAllPortForwards = async (): Promise<void> => {
|
||||
}
|
||||
|
||||
activeConnections.clear();
|
||||
|
||||
// Also ask the backend to stop ALL tunnels it knows about.
|
||||
// This covers tunnels that were started by other windows or that
|
||||
// this renderer doesn't have in its activeConnections map (e.g.
|
||||
// settings window opened before initializeStore finished).
|
||||
if (bridge?.stopAllPortForwards) {
|
||||
try {
|
||||
await bridge.stopAllPortForwards();
|
||||
} catch (err) {
|
||||
logger.warn('[PortForwardingService] Backend stopAllPortForwards failed:', err);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Host,SSHKey,Snippet } from '../../domain/models';
|
||||
|
||||
interface BackupData {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
timestamp: number;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export const syncToGist = async (token: string, gistId: string | undefined, data: Omit<BackupData, 'timestamp' | 'version'>): Promise<string> => {
|
||||
const payload = {
|
||||
description: "Netcatty SSH Config Backup",
|
||||
public: false,
|
||||
files: {
|
||||
"netcatty-config.json": {
|
||||
content: JSON.stringify({ ...data, timestamp: Date.now(), version: 1 }, null, 2)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const url = gistId
|
||||
? `https://api.github.com/gists/${gistId}`
|
||||
: `https://api.github.com/gists`;
|
||||
|
||||
const method = gistId ? 'PATCH' : 'POST';
|
||||
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to sync: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.id;
|
||||
};
|
||||
|
||||
export const loadFromGist = async (token: string, gistId: string): Promise<BackupData> => {
|
||||
const response = await fetch(`https://api.github.com/gists/${gistId}`, {
|
||||
headers: {
|
||||
'Authorization': `token ${token}`,
|
||||
'Accept': 'application/vnd.github.v3+json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
const file = result.files["netcatty-config.json"];
|
||||
|
||||
if (!file || !file.content) {
|
||||
throw new Error("Invalid Gist format: netcatty-config.json not found");
|
||||
}
|
||||
|
||||
return JSON.parse(file.content);
|
||||
};
|
||||
@@ -1,7 +1,17 @@
|
||||
/**
|
||||
* Update Service - Checks GitHub releases for new versions
|
||||
* Update Service
|
||||
*
|
||||
* Combines two update mechanisms:
|
||||
* 1. GitHub API-based version comparison (used by useUpdateCheck for notification banner)
|
||||
* 2. electron-updater bridge (used by SettingsSystemTab for download/install)
|
||||
*/
|
||||
|
||||
import { netcattyBridge } from "./netcattyBridge";
|
||||
|
||||
// ================================
|
||||
// Part 1: GitHub API Version Check
|
||||
// ================================
|
||||
|
||||
const GITHUB_API_URL = 'https://api.github.com/repos/binaricat/Netcatty/releases/latest';
|
||||
const RELEASES_PAGE_URL = 'https://github.com/binaricat/Netcatty/releases';
|
||||
|
||||
@@ -60,69 +70,46 @@ export function compareVersions(a: string, b: string): number {
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the latest release info from GitHub
|
||||
* Check for updates via GitHub API (compares version strings).
|
||||
* Used by useUpdateCheck for the notification banner.
|
||||
*/
|
||||
export async function fetchLatestRelease(): Promise<ReleaseInfo | null> {
|
||||
export async function checkForUpdates(currentVersion: string): Promise<UpdateCheckResult> {
|
||||
try {
|
||||
const response = await fetch(GITHUB_API_URL, {
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github.v3+json',
|
||||
// Using anonymous access - rate limited to 60 requests/hour
|
||||
},
|
||||
headers: { Accept: 'application/vnd.github.v3+json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
if (response.status === 404) {
|
||||
// No releases yet
|
||||
return null;
|
||||
}
|
||||
throw new Error(`GitHub API error: ${response.status}`);
|
||||
throw new Error(`GitHub API returned ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const latestVersion = (data.tag_name as string).replace(/^v/i, '');
|
||||
|
||||
return {
|
||||
version: data.tag_name?.replace(/^v/i, '') || '0.0.0',
|
||||
tagName: data.tag_name || '',
|
||||
name: data.name || data.tag_name || '',
|
||||
const latestRelease: ReleaseInfo = {
|
||||
version: latestVersion,
|
||||
tagName: data.tag_name,
|
||||
name: data.name || data.tag_name,
|
||||
body: data.body || '',
|
||||
htmlUrl: data.html_url || RELEASES_PAGE_URL,
|
||||
publishedAt: data.published_at || '',
|
||||
assets: (data.assets || []).map((asset: { name?: string; browser_download_url?: string; size?: number }) => ({
|
||||
name: asset.name || '',
|
||||
browserDownloadUrl: asset.browser_download_url || '',
|
||||
size: asset.size || 0,
|
||||
htmlUrl: data.html_url,
|
||||
publishedAt: data.published_at,
|
||||
assets: (data.assets || []).map((a: { name: string; browser_download_url: string; size: number }) => ({
|
||||
name: a.name,
|
||||
browserDownloadUrl: a.browser_download_url,
|
||||
size: a.size,
|
||||
})),
|
||||
};
|
||||
|
||||
const hasUpdate = compareVersions(latestVersion, currentVersion) > 0;
|
||||
|
||||
return { hasUpdate, currentVersion, latestRelease };
|
||||
} catch (error) {
|
||||
console.warn('[UpdateService] Failed to fetch latest release:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for updates
|
||||
*/
|
||||
export async function checkForUpdates(currentVersion: string): Promise<UpdateCheckResult> {
|
||||
const result: UpdateCheckResult = {
|
||||
hasUpdate: false,
|
||||
currentVersion,
|
||||
latestRelease: null,
|
||||
};
|
||||
|
||||
try {
|
||||
const release = await fetchLatestRelease();
|
||||
if (!release) {
|
||||
return result;
|
||||
}
|
||||
|
||||
result.latestRelease = release;
|
||||
result.hasUpdate = compareVersions(release.version, currentVersion) > 0;
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
result.error = error instanceof Error ? error.message : 'Unknown error';
|
||||
return result;
|
||||
return {
|
||||
hasUpdate: false,
|
||||
currentVersion,
|
||||
latestRelease: null,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +131,7 @@ export function getDownloadUrlForPlatform(
|
||||
platform: string
|
||||
): string | null {
|
||||
const assets = release.assets;
|
||||
|
||||
|
||||
// Platform-specific file patterns
|
||||
const patterns: Record<string, RegExp[]> = {
|
||||
win32: [/\.exe$/i, /win.*\.zip$/i, /windows/i],
|
||||
@@ -153,7 +140,7 @@ export function getDownloadUrlForPlatform(
|
||||
};
|
||||
|
||||
const platformPatterns = patterns[platform] || [];
|
||||
|
||||
|
||||
for (const pattern of platformPatterns) {
|
||||
const asset = assets.find((a) => pattern.test(a.name));
|
||||
if (asset) {
|
||||
@@ -164,3 +151,73 @@ export function getDownloadUrlForPlatform(
|
||||
// Fallback to release page
|
||||
return null;
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// Part 2: electron-updater Bridge (IPC-based)
|
||||
// =============================================
|
||||
|
||||
export interface ElectronUpdateCheckResult {
|
||||
available: boolean;
|
||||
supported?: boolean;
|
||||
version?: string;
|
||||
releaseNotes?: string;
|
||||
releaseDate?: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface UpdateDownloadProgress {
|
||||
percent: number;
|
||||
bytesPerSecond: number;
|
||||
transferred: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export async function checkForUpdate(): Promise<ElectronUpdateCheckResult> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.checkForUpdate) {
|
||||
return { available: false, supported: false, error: "Bridge unavailable" };
|
||||
}
|
||||
try {
|
||||
return await bridge.checkForUpdate();
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
return { available: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
export async function downloadUpdate(): Promise<{ success: boolean; error?: string }> {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.downloadUpdate) {
|
||||
return { success: false, error: "Bridge unavailable" };
|
||||
}
|
||||
return bridge.downloadUpdate();
|
||||
}
|
||||
|
||||
export function installUpdate(): void {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.installUpdate?.();
|
||||
}
|
||||
|
||||
export function onDownloadProgress(
|
||||
cb: (progress: UpdateDownloadProgress) => void,
|
||||
): (() => void) | undefined {
|
||||
return netcattyBridge.get()?.onUpdateDownloadProgress?.(cb);
|
||||
}
|
||||
|
||||
export function onDownloaded(cb: () => void): (() => void) | undefined {
|
||||
return netcattyBridge.get()?.onUpdateDownloaded?.(cb);
|
||||
}
|
||||
|
||||
export function onError(
|
||||
cb: (payload: { error: string }) => void,
|
||||
): (() => void) | undefined {
|
||||
return netcattyBridge.get()?.onUpdateError?.(cb);
|
||||
}
|
||||
|
||||
/** Returns the GitHub Releases page URL, optionally for a specific version tag. */
|
||||
export function getReleasesUrl(version?: string): string {
|
||||
if (version) {
|
||||
return `${RELEASES_PAGE_URL}/tag/v${version}`;
|
||||
}
|
||||
return `${RELEASES_PAGE_URL}/latest`;
|
||||
}
|
||||
|
||||
@@ -342,7 +342,7 @@ export async function uploadFromDataTransfer(
|
||||
|
||||
if (folderEntries.length > 0) {
|
||||
try {
|
||||
const compressedResults = await uploadFoldersCompressed(folderEntries, entries, targetPath, sftpId, callbacks, controller);
|
||||
const compressedResults = await uploadFoldersCompressed(folderEntries, targetPath, sftpId, callbacks, controller);
|
||||
|
||||
// Check if any folders failed due to lack of compression support
|
||||
const failedFolders = compressedResults.filter(result =>
|
||||
@@ -429,7 +429,7 @@ export async function uploadFromFileList(
|
||||
|
||||
if (folderEntries.length > 0) {
|
||||
try {
|
||||
const compressedResults = await uploadFoldersCompressed(folderEntries, entries, targetPath, sftpId, callbacks, controller);
|
||||
const compressedResults = await uploadFoldersCompressed(folderEntries, targetPath, sftpId, callbacks, controller);
|
||||
|
||||
// Check if any folders failed due to lack of compression support
|
||||
const failedFolders = compressedResults.filter(result =>
|
||||
@@ -931,7 +931,6 @@ export async function uploadEntriesDirect(
|
||||
*/
|
||||
async function uploadFoldersCompressed(
|
||||
folderEntries: Array<[string, DropEntry[]]>,
|
||||
allEntries: DropEntry[],
|
||||
targetPath: string,
|
||||
sftpId: string,
|
||||
callbacks?: UploadCallbacks,
|
||||
|
||||
78
package-lock.json
generated
@@ -32,6 +32,7 @@
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"clsx": "2.1.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"lucide-react": "0.560.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
@@ -6297,7 +6298,6 @@
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||
"dev": true,
|
||||
"license": "Python-2.0"
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
@@ -6609,7 +6609,6 @@
|
||||
"version": "9.5.1",
|
||||
"resolved": "https://registry.npmjs.org/builder-util-runtime/-/builder-util-runtime-9.5.1.tgz",
|
||||
"integrity": "sha512-qt41tMfgHTllhResqM5DcnHyDIWNgzHvuY2jDcYP9iaGpkWxTUzV6GQjDeLnlR1/DtdlcsWQbA7sByMpmJFTLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "^4.3.4",
|
||||
@@ -7685,6 +7684,57 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/electron-updater": {
|
||||
"version": "6.8.3",
|
||||
"resolved": "https://registry.npmjs.org/electron-updater/-/electron-updater-6.8.3.tgz",
|
||||
"integrity": "sha512-Z6sgw3jgbikWKXei1ENdqFOxBP0WlXg3TtKfz0rgw2vIZFJUyI4pD7ZN7jrkm7EoMK+tcm/qTnPUdqfZukBlBQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"builder-util-runtime": "9.5.1",
|
||||
"fs-extra": "^10.1.0",
|
||||
"js-yaml": "^4.1.0",
|
||||
"lazy-val": "^1.0.5",
|
||||
"lodash.escaperegexp": "^4.1.2",
|
||||
"lodash.isequal": "^4.5.0",
|
||||
"semver": "~7.7.3",
|
||||
"tiny-typed-emitter": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/fs-extra": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz",
|
||||
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"graceful-fs": "^4.2.0",
|
||||
"jsonfile": "^6.0.1",
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/jsonfile": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
|
||||
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"universalify": "^2.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"graceful-fs": "^4.1.6"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-updater/node_modules/universalify": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
|
||||
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/electron-winstaller": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.0.tgz",
|
||||
@@ -8818,7 +8868,6 @@
|
||||
"version": "4.2.11",
|
||||
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
|
||||
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/gtoken": {
|
||||
@@ -9318,7 +9367,6 @@
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
|
||||
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1"
|
||||
@@ -9482,7 +9530,6 @@
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/lazy-val/-/lazy-val-1.0.5.tgz",
|
||||
"integrity": "sha512-0/BnGCCfyUMkBpeDgWihanIAF9JmZhHBgUhEqzvf+adhNGLoP6TaiI5oF8oyb3I45P+PcnrqihSf01M0l0G5+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
@@ -9783,6 +9830,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.escaperegexp": {
|
||||
"version": "4.1.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz",
|
||||
"integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.isequal": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@@ -11318,7 +11378,6 @@
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.4.4.tgz",
|
||||
"integrity": "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
@@ -11334,7 +11393,6 @@
|
||||
"version": "7.7.3",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
|
||||
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver.js"
|
||||
@@ -11926,6 +11984,12 @@
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-typed-emitter": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tiny-typed-emitter/-/tiny-typed-emitter-2.1.0.tgz",
|
||||
"integrity": "sha512-qVtvMxeXbVej0cQWKqVSSAHmKZEHAvxdF8HEUBFWts8h+xEo5m/lEiPakuyZ3BnCBjOD8i24kzNOiOLLgsSxhA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
"@xterm/addon-webgl": "^0.18.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"clsx": "2.1.1",
|
||||
"electron-updater": "^6.8.3",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"lucide-react": "0.560.0",
|
||||
"monaco-editor": "^0.55.1",
|
||||
@@ -87,4 +88,4 @@
|
||||
"cpu-features": "npm:empty-npm-package@1.0.0",
|
||||
"axios": "1.13.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Fix Quarantine</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>FixQuarantine</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.netcatty.fixquarantine</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Fix Quarantine</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>FixQuarantine.icns</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
APP_PATH="/Applications/Netcatty.app"
|
||||
|
||||
if [ ! -d "$APP_PATH" ]; then
|
||||
/usr/bin/osascript <<'EOF'
|
||||
display alert "Netcatty.app not found" message "Drag Netcatty.app into /Applications, then run this tool again." as critical buttons {"OK"} default button "OK"
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
/usr/bin/osascript <<'EOF'
|
||||
do shell script "xattr -dr com.apple.quarantine /Applications/Netcatty.app" with administrator privileges
|
||||
EOF
|
||||
|
||||
open "$APP_PATH"
|
||||
@@ -1,10 +0,0 @@
|
||||
# 1) 准备一张 1024x1024 PNG,例如放在 public/dmg-fix-icon.png
|
||||
# 2) 生成 iconset 并转 icns
|
||||
ICONSET="scripts/fixquarantine.iconset"
|
||||
mkdir -p "$ICONSET"
|
||||
for size in 16 32 128 256 512; do
|
||||
sips -z "$size" "$size" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}.png" >/dev/null
|
||||
sips -z "$((size*2))" "$((size*2))" public/dmg-fix-icon.png --out "$ICONSET/icon_${size}x${size}@2x.png" >/dev/null
|
||||
done
|
||||
iconutil -c icns "$ICONSET" -o scripts/FixQuarantine.app/Contents/Resources/FixQuarantine.icns
|
||||
rm -rf $ICONSET
|
||||