Compare commits
41 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 |
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
@@ -73,12 +73,15 @@ 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).
|
||||
@@ -130,6 +133,8 @@ jobs:
|
||||
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)
|
||||
@@ -184,6 +189,8 @@ jobs:
|
||||
release/*.AppImage
|
||||
release/*.deb
|
||||
release/*.rpm
|
||||
release/*.yml
|
||||
release/*.blockmap
|
||||
if-no-files-found: ignore
|
||||
|
||||
release:
|
||||
@@ -219,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 }}
|
||||
|
||||
38
App.tsx
38
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(
|
||||
@@ -1087,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 () => {
|
||||
@@ -1162,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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '使用自定义强调色',
|
||||
@@ -1032,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': '已下载',
|
||||
@@ -1048,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': '文件夹压缩传输',
|
||||
@@ -1097,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
|
||||
};
|
||||
|
||||
|
||||
@@ -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));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -522,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
|
||||
}
|
||||
@@ -823,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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
@@ -882,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;
|
||||
|
||||
@@ -904,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) => {
|
||||
@@ -1011,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..."]);
|
||||
@@ -1144,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
|
||||
@@ -1153,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={() => {
|
||||
|
||||
@@ -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" && (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
@@ -491,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);
|
||||
@@ -611,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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
domain/syncPayload.ts
Normal file
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);
|
||||
}
|
||||
}
|
||||
@@ -90,5 +90,13 @@ module.exports = {
|
||||
}
|
||||
],
|
||||
category: 'Development'
|
||||
}
|
||||
},
|
||||
publish: [
|
||||
{
|
||||
provider: 'github',
|
||||
owner: 'binaricat',
|
||||
repo: 'Netcatty',
|
||||
releaseType: 'release'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
199
electron/bridges/autoUpdateBridge.cjs
Normal file
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
|
||||
@@ -427,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;
|
||||
@@ -498,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);
|
||||
|
||||
@@ -521,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);
|
||||
});
|
||||
@@ -828,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}`);
|
||||
@@ -841,7 +852,8 @@ async function openSftp(event, options) {
|
||||
jumpHosts,
|
||||
options.hostname,
|
||||
options.port || 22,
|
||||
connId
|
||||
connId,
|
||||
agentSocket
|
||||
);
|
||||
connectionSocket = chainResult.socket;
|
||||
chainConnections = chainResult.connections;
|
||||
@@ -895,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,
|
||||
@@ -903,6 +916,7 @@ async function openSftp(event, options) {
|
||||
username: connectOpts.username,
|
||||
logPrefix: "[SFTP]",
|
||||
defaultKeys,
|
||||
sshAgentSocketOverride: agentSocket,
|
||||
});
|
||||
applyAuthToConnOpts(connectOpts, authConfig);
|
||||
|
||||
@@ -922,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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
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");
|
||||
|
||||
@@ -123,14 +124,58 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ssh-agent socket path based on platform
|
||||
* 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";
|
||||
}
|
||||
return process.env.SSH_AUTH_SOCK || null;
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,7 +191,7 @@ function getSshAgentSocket() {
|
||||
* @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;
|
||||
const { privateKey, password, passphrase, agent, username, logPrefix = "[SSH]", unlockedEncryptedKeys = [], defaultKeys = [], sshAgentSocketOverride } = options;
|
||||
|
||||
// Determine what type of explicit auth the user configured
|
||||
const hasExplicitKey = !!privateKey;
|
||||
@@ -158,7 +203,10 @@ function buildAuthHandler(options) {
|
||||
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
|
||||
@@ -512,6 +560,7 @@ module.exports = {
|
||||
findDefaultPrivateKey,
|
||||
findAllDefaultPrivateKeys,
|
||||
getSshAgentSocket,
|
||||
getAvailableAgentSocket,
|
||||
buildAuthHandler,
|
||||
createKeyboardInteractiveHandler,
|
||||
applyAuthToConnOpts,
|
||||
|
||||
@@ -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)
|
||||
@@ -592,14 +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)
|
||||
let sshAgentSocket;
|
||||
if (process.platform === "win32") {
|
||||
const agentStatus = await checkWindowsSshAgent();
|
||||
log("Windows SSH Agent check", agentStatus);
|
||||
sshAgentSocket = agentStatus.running ? "\\\\.\\pipe\\openssh-ssh-agent" : null;
|
||||
} else {
|
||||
sshAgentSocket = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
const sshAgentSocket = await getAvailableAgentSocket();
|
||||
|
||||
if (sshAgentSocket) {
|
||||
log("No auth method configured, trying ssh-agent first", { agentSocket: sshAgentSocket });
|
||||
@@ -627,15 +631,7 @@ async function startSSHSession(event, options) {
|
||||
// Agent forwarding
|
||||
if (options.agentForwarding) {
|
||||
if (!connectOpts.agent) {
|
||||
if (process.platform === "win32") {
|
||||
const agentStatus = await checkWindowsSshAgent();
|
||||
log("Windows SSH Agent check (agentForwarding)", agentStatus);
|
||||
if (agentStatus.running) {
|
||||
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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -14,6 +14,9 @@ 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);
|
||||
@@ -131,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);
|
||||
@@ -656,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());
|
||||
@@ -878,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
|
||||
|
||||
22
global.d.ts
vendored
22
global.d.ts
vendored
@@ -429,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
|
||||
@@ -609,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
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);
|
||||
|
||||
@@ -911,7 +911,6 @@ export class CloudSyncManager {
|
||||
* Helper: Check for conflicts with a specific provider
|
||||
*/
|
||||
private async checkProviderConflict(
|
||||
provider: CloudProvider,
|
||||
adapter: CloudAdapter
|
||||
): Promise<{
|
||||
conflict: boolean;
|
||||
@@ -1070,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);
|
||||
@@ -1281,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
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user