Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f77c2b2de9 | ||
|
|
f79f27d737 | ||
|
|
ec35daa0dd | ||
|
|
ed0775d9d2 | ||
|
|
1f31629ce0 | ||
|
|
cc4a904dea | ||
|
|
e9e1d87ff5 | ||
|
|
a6b07f39ad | ||
|
|
6892e11952 | ||
|
|
ec9be922cb |
5
App.tsx
5
App.tsx
@@ -17,6 +17,7 @@ import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { applySyncPayload } from './domain/syncPayload';
|
||||
import { getCredentialProtectionAvailability } from './infrastructure/services/credentialProtection';
|
||||
import { netcattyBridge } from './infrastructure/services/netcattyBridge';
|
||||
import { localStorageAdapter } from './infrastructure/persistence/localStorageAdapter';
|
||||
import { TopTabs } from './components/TopTabs';
|
||||
import { Button } from './components/ui/button';
|
||||
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from './components/ui/dialog';
|
||||
@@ -292,10 +293,12 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRulesForSync,
|
||||
knownHosts,
|
||||
settingsVersion: settings.settingsVersion,
|
||||
onApplyPayload: (payload) => {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied: settings.rehydrateAllFromStorage,
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -320,6 +323,8 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
useEffect(() => {
|
||||
// Skip "update available" toast if auto-download has already started or completed
|
||||
if (updateState.autoDownloadStatus !== 'idle') return;
|
||||
// Don't show automatic notification when auto-update is disabled
|
||||
if (localStorageAdapter.readString('netcatty_auto_update_enabled_v1') === 'false') return;
|
||||
if (updateState.hasUpdate && updateState.latestRelease) {
|
||||
const version = updateState.latestRelease.version;
|
||||
toast.info(
|
||||
|
||||
@@ -115,6 +115,8 @@ const en: Messages = {
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} min ago',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} hr ago',
|
||||
'settings.update.lastCheckedPrefix': 'Last checked: ',
|
||||
'settings.update.autoUpdateEnabled': 'Automatic Updates',
|
||||
'settings.update.autoUpdateEnabledDesc': 'Automatically check and download updates when available.',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': 'Session Logs',
|
||||
@@ -142,6 +144,8 @@ const en: Messages = {
|
||||
'settings.globalHotkey.reset': 'Reset to default',
|
||||
'settings.globalHotkey.closeToTray': 'Close to System Tray',
|
||||
'settings.globalHotkey.closeToTrayDesc': 'When enabled, closing the window will minimize to the system tray instead of quitting.',
|
||||
'settings.globalHotkey.enabled': 'Enable Global Hotkey',
|
||||
'settings.globalHotkey.enabledDesc': 'Register system-wide keyboard shortcuts. When disabled, all global hotkeys are unregistered.',
|
||||
'settings.globalHotkey.hint': 'Global hotkey works system-wide to quickly show or hide the window (Quake-style terminal).',
|
||||
|
||||
// Tray Panel
|
||||
|
||||
@@ -99,6 +99,8 @@ const zhCN: Messages = {
|
||||
'settings.update.lastCheckedMinutesAgo': '{n} 分钟前',
|
||||
'settings.update.lastCheckedHoursAgo': '{n} 小时前',
|
||||
'settings.update.lastCheckedPrefix': '上次检查:',
|
||||
'settings.update.autoUpdateEnabled': '自动更新',
|
||||
'settings.update.autoUpdateEnabledDesc': '有新版本时自动检查并下载更新。',
|
||||
|
||||
// Settings > Session Logs
|
||||
'settings.sessionLogs.title': '会话日志',
|
||||
@@ -126,6 +128,8 @@ const zhCN: Messages = {
|
||||
'settings.globalHotkey.reset': '恢复默认',
|
||||
'settings.globalHotkey.closeToTray': '关闭时最小化到托盘',
|
||||
'settings.globalHotkey.closeToTrayDesc': '启用后,关闭窗口将最小化到系统托盘而不是退出程序。',
|
||||
'settings.globalHotkey.enabled': '启用全局快捷键',
|
||||
'settings.globalHotkey.enabledDesc': '注册系统级键盘快捷键。禁用后将取消所有全局快捷键注册。',
|
||||
'settings.globalHotkey.hint': '全局快捷键在系统范围内工作,可快速显示或隐藏窗口(下拉式终端风格)。',
|
||||
|
||||
// Tray Panel
|
||||
|
||||
@@ -30,7 +30,8 @@ class CustomThemeStore {
|
||||
this.setupCrossWindowSync();
|
||||
}
|
||||
|
||||
private loadFromStorage = () => {
|
||||
/** Reload themes from localStorage. Called internally and after sync apply. */
|
||||
loadFromStorage = () => {
|
||||
try {
|
||||
const parsed = localStorageAdapter.read<TerminalTheme[]>(STORAGE_KEY_CUSTOM_THEMES);
|
||||
if (Array.isArray(parsed)) {
|
||||
@@ -39,7 +40,7 @@ class CustomThemeStore {
|
||||
} catch {
|
||||
// ignore corrupt data
|
||||
}
|
||||
this.cachedAllThemes = null; // invalidate cache
|
||||
this.notify();
|
||||
};
|
||||
|
||||
private saveToStorage = () => {
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
findSyncPayloadEncryptedCredentialPaths,
|
||||
} from '../../domain/credentials';
|
||||
import { isProviderReadyForSync, type CloudProvider, type SyncPayload } from '../../domain/sync';
|
||||
import { collectSyncableSettings } from '../../domain/syncPayload';
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { getEffectiveKnownHosts } from '../../infrastructure/syncHelpers';
|
||||
@@ -31,7 +32,9 @@ interface AutoSyncConfig {
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
portForwardingRules?: SyncPayload['portForwardingRules'];
|
||||
knownHosts?: SyncPayload['knownHosts'];
|
||||
|
||||
/** Opaque token that changes whenever a synced setting changes. */
|
||||
settingsVersion?: number;
|
||||
|
||||
// Callbacks
|
||||
onApplyPayload: (payload: SyncPayload) => void;
|
||||
}
|
||||
@@ -97,13 +100,14 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
const buildPayload = useCallback((): SyncPayload => {
|
||||
return {
|
||||
...getSyncSnapshot(),
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Create a hash of current data for comparison
|
||||
// Create a hash of current data for comparison (includes settings)
|
||||
const getDataHash = useCallback(() => {
|
||||
return JSON.stringify(getSyncSnapshot());
|
||||
return JSON.stringify({ ...getSyncSnapshot(), settings: collectSyncableSettings() });
|
||||
}, [getSyncSnapshot]);
|
||||
|
||||
// Sync now handler - get fresh state directly from manager
|
||||
@@ -255,7 +259,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
clearTimeout(syncTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow]);
|
||||
}, [sync.hasAnyConnectedProvider, sync.autoSyncEnabled, sync.isUnlocked, sync.isSyncing, getDataHash, syncNow, config.settingsVersion]);
|
||||
|
||||
// Check remote version on startup/unlock
|
||||
useEffect(() => {
|
||||
|
||||
@@ -27,10 +27,12 @@ import {
|
||||
STORAGE_KEY_SESSION_LOGS_FORMAT,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { DEFAULT_UI_LOCALE, resolveSupportedLocale } from '../../infrastructure/config/i18n';
|
||||
import { TERMINAL_THEMES } from '../../infrastructure/config/terminalThemes';
|
||||
import { useCustomThemes } from '../state/customThemeStore';
|
||||
import { customThemeStore, useCustomThemes } from '../state/customThemeStore';
|
||||
import { DEFAULT_FONT_SIZE } from '../../infrastructure/config/fonts';
|
||||
import { DARK_UI_THEMES, LIGHT_UI_THEMES, UiThemeTokens, getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { UI_FONTS, DEFAULT_UI_FONT_ID } from '../../infrastructure/config/uiFonts';
|
||||
@@ -264,7 +266,17 @@ export const useSettingsState = () => {
|
||||
if (stored === null) return true;
|
||||
return stored === 'true';
|
||||
});
|
||||
const [autoUpdateEnabled, setAutoUpdateEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const [hotkeyRegistrationError, setHotkeyRegistrationError] = useState<string | null>(null);
|
||||
const [globalHotkeyEnabled, setGlobalHotkeyEnabled] = useState<boolean>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED);
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
@@ -332,6 +344,60 @@ export const useSettingsState = () => {
|
||||
setCustomCSS((prev) => (prev === storedCss ? prev : storedCss));
|
||||
}, []);
|
||||
|
||||
const rehydrateAllFromStorage = useCallback(() => {
|
||||
// Theme & appearance (already have helper)
|
||||
syncAppearanceFromStorage();
|
||||
syncCustomCssFromStorage();
|
||||
|
||||
// UI Font
|
||||
const storedFont = readStoredString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (storedFont) setUiFontFamilyId(storedFont);
|
||||
|
||||
// Language
|
||||
const storedLang = readStoredString(STORAGE_KEY_UI_LANGUAGE);
|
||||
if (storedLang) setUiLanguage(storedLang as UILanguage);
|
||||
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
const storedTermFont = readStoredString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (storedTermFont) setTerminalFontFamilyId(storedTermFont);
|
||||
const storedTermSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (storedTermSize != null) setTerminalFontSize(storedTermSize);
|
||||
const storedTermSettings = readStoredString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (storedTermSettings) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedTermSettings);
|
||||
setTerminalSettings(parsed);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
const storedKb = readStoredString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
if (storedKb) {
|
||||
try {
|
||||
setCustomKeyBindings(JSON.parse(storedKb));
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Editor
|
||||
const storedWrap = readStoredString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
if (storedWrap === 'true' || storedWrap === 'false') setEditorWordWrapState(storedWrap === 'true');
|
||||
|
||||
// SFTP
|
||||
const storedDblClick = readStoredString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
if (storedDblClick === 'open' || storedDblClick === 'transfer') setSftpDoubleClickBehavior(storedDblClick);
|
||||
const storedAutoSync = readStoredString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
if (storedAutoSync === 'true' || storedAutoSync === 'false') setSftpAutoSync(storedAutoSync === 'true');
|
||||
const storedHidden = readStoredString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
if (storedHidden === 'true' || storedHidden === 'false') setSftpShowHiddenFiles(storedHidden === 'true');
|
||||
const storedCompress = readStoredString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (storedCompress === 'true' || storedCompress === 'false') setSftpUseCompressedUpload(storedCompress === 'true');
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
}, [syncAppearanceFromStorage, syncCustomCssFromStorage, setTerminalSettings]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const tokens = getUiThemeById(resolvedTheme, resolvedTheme === 'dark' ? darkUiThemeId : lightUiThemeId).tokens;
|
||||
applyThemeTokens(theme, resolvedTheme, tokens, accentMode, customAccent);
|
||||
@@ -457,6 +523,12 @@ export const useSettingsState = () => {
|
||||
if (key === STORAGE_KEY_HOTKEY_RECORDING && typeof value === 'boolean') {
|
||||
setIsHotkeyRecordingState(value);
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
try {
|
||||
@@ -622,11 +694,25 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload(newValue);
|
||||
}
|
||||
}
|
||||
// Sync global hotkey enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== globalHotkeyEnabled) {
|
||||
setGlobalHotkeyEnabled(newValue);
|
||||
}
|
||||
}
|
||||
// Sync auto-update enabled setting from other windows
|
||||
if (e.key === STORAGE_KEY_AUTO_UPDATE_ENABLED && e.newValue !== null) {
|
||||
const newValue = e.newValue === 'true';
|
||||
if (newValue !== autoUpdateEnabled) {
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('storage', handleStorageChange);
|
||||
return () => window.removeEventListener('storage', handleStorageChange);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, mergeIncomingTerminalSettings]);
|
||||
}, [theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customCSS, uiFontFamilyId, hotkeyScheme, uiLanguage, terminalThemeId, terminalFontFamilyId, terminalFontSize, sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, globalHotkeyEnabled, autoUpdateEnabled, mergeIncomingTerminalSettings]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, terminalThemeId);
|
||||
@@ -734,7 +820,7 @@ export const useSettingsState = () => {
|
||||
// Register/unregister the global hotkey in main process
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.registerGlobalHotkey) {
|
||||
if (toggleWindowHotkey) {
|
||||
if (toggleWindowHotkey && globalHotkeyEnabled) {
|
||||
setHotkeyRegistrationError(null);
|
||||
bridge
|
||||
.registerGlobalHotkey(toggleWindowHotkey)
|
||||
@@ -755,7 +841,13 @@ export const useSettingsState = () => {
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [toggleWindowHotkey, notifySettingsChanged]);
|
||||
}, [toggleWindowHotkey, globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist global hotkey enabled setting
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
|
||||
}, [globalHotkeyEnabled, notifySettingsChanged]);
|
||||
|
||||
// Persist and sync close to tray setting
|
||||
useEffect(() => {
|
||||
@@ -770,6 +862,41 @@ export const useSettingsState = () => {
|
||||
}
|
||||
}, [closeToTray, notifySettingsChanged]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
void bridge?.getAutoUpdate?.().then((result) => {
|
||||
if (result && typeof result.enabled === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => {
|
||||
if (prev === result.enabled) return prev;
|
||||
// Sync localStorage with the main-process truth
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, result.enabled ? 'true' : 'false');
|
||||
return result.enabled;
|
||||
});
|
||||
}
|
||||
}).catch(() => { /* bridge unavailable */ });
|
||||
}, []);
|
||||
|
||||
// Persist auto-update enabled setting.
|
||||
// Skip IPC on initial mount to avoid overwriting the main-process preference
|
||||
// file when localStorage has been cleared (where the default is true).
|
||||
const autoUpdateMountedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
|
||||
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
|
||||
if (!autoUpdateMountedRef.current) {
|
||||
autoUpdateMountedRef.current = true;
|
||||
return; // Skip IPC on initial mount
|
||||
}
|
||||
// Notify main process on user-initiated changes
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setAutoUpdate?.(autoUpdateEnabled).catch((err: unknown) => {
|
||||
console.warn('[AutoUpdate] Failed to set auto-update:', err);
|
||||
});
|
||||
}, [autoUpdateEnabled, notifySettingsChanged]);
|
||||
|
||||
// Get merged key bindings (defaults + custom overrides)
|
||||
const keyBindings = useMemo((): KeyBinding[] => {
|
||||
return DEFAULT_KEY_BINDINGS.map(binding => {
|
||||
@@ -912,6 +1039,21 @@ export const useSettingsState = () => {
|
||||
setToggleWindowHotkey,
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
rehydrateAllFromStorage,
|
||||
// Opaque version that changes when any synced setting changes, used by useAutoSync.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
settingsVersion: useMemo(() => Math.random(), [
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
uiFontFamilyId, uiLanguage, customCSS,
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload,
|
||||
customThemes,
|
||||
]),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { checkForUpdates, getReleaseUrl, type ReleaseInfo, type UpdateCheckResult } from '../../infrastructure/services/updateService';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_UPDATE_DISMISSED_VERSION, STORAGE_KEY_UPDATE_LAST_CHECK, STORAGE_KEY_UPDATE_LATEST_RELEASE, STORAGE_KEY_AUTO_UPDATE_ENABLED } from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
// Check for updates at most once per hour
|
||||
@@ -56,7 +56,13 @@ export interface UseUpdateCheckResult {
|
||||
* - Respects dismissed version to avoid nagging
|
||||
* - Provides manual check capability
|
||||
*/
|
||||
export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
export function useUpdateCheck(options?: { autoUpdateEnabled?: boolean }): UseUpdateCheckResult {
|
||||
// Accept auto-update toggle from the caller (e.g. useSettingsState) so it
|
||||
// reacts immediately in the same window. Falls back to reading localStorage
|
||||
// when no caller provides the value (e.g. in non-settings contexts).
|
||||
const autoUpdateEnabled = options?.autoUpdateEnabled ??
|
||||
(localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) !== 'false');
|
||||
|
||||
const [updateState, setUpdateState] = useState<UpdateState>({
|
||||
isChecking: false,
|
||||
hasUpdate: false,
|
||||
@@ -136,14 +142,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
return;
|
||||
}
|
||||
|
||||
// 'available' means an update was found but auto-download is disabled.
|
||||
// Surface the version info (hasUpdate + latestRelease) but keep
|
||||
// autoDownloadStatus at 'idle' so the manual download path shows.
|
||||
const isAvailableOnly = snapshot.status === 'available';
|
||||
|
||||
setUpdateState((prev) => {
|
||||
// Don't overwrite if the renderer already has a newer state
|
||||
if (prev.autoDownloadStatus !== 'idle') return prev;
|
||||
return {
|
||||
...prev,
|
||||
autoDownloadStatus: snapshot.status,
|
||||
downloadPercent: snapshot.percent,
|
||||
downloadError: snapshot.error,
|
||||
hasUpdate: isAvailableOnly ? true : prev.hasUpdate,
|
||||
autoDownloadStatus: isAvailableOnly ? 'idle' : snapshot.status,
|
||||
downloadPercent: isAvailableOnly ? 0 : snapshot.percent,
|
||||
downloadError: isAvailableOnly ? null : snapshot.error,
|
||||
// Use snapshot version if no release data or if versions differ
|
||||
latestRelease: (!prev.latestRelease || (snapshot.version && prev.latestRelease.version !== snapshot.version)) ? (snapshot.version ? {
|
||||
version: snapshot.version,
|
||||
@@ -186,15 +198,18 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
if (isDismissed) {
|
||||
dismissedAutoDownloadRef.current = true;
|
||||
}
|
||||
// When auto-update is disabled, autoDownload=false in the main process
|
||||
// so no download will start. Don't transition to 'downloading' or the
|
||||
// UI will be stuck at 0%. Keep status idle and let the manual download
|
||||
// link surface instead.
|
||||
const isAutoUpdateOff = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED) === 'false';
|
||||
const shouldTrackDownload = !isDismissed && !isAutoUpdateOff;
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
hasUpdate: !isDismissed,
|
||||
// Only transition to 'downloading' if the user hasn't dismissed this
|
||||
// version — otherwise leave the status at 'idle' so no download
|
||||
// progress/ready toast appears for a release they don't want.
|
||||
autoDownloadStatus: isDismissed ? prev.autoDownloadStatus : 'downloading',
|
||||
downloadPercent: isDismissed ? prev.downloadPercent : 0,
|
||||
downloadError: isDismissed ? prev.downloadError : null,
|
||||
autoDownloadStatus: shouldTrackDownload ? 'downloading' : prev.autoDownloadStatus,
|
||||
downloadPercent: shouldTrackDownload ? 0 : prev.downloadPercent,
|
||||
downloadError: shouldTrackDownload ? null : prev.downloadError,
|
||||
// Use electron-updater's version if GitHub API hasn't resolved yet or
|
||||
// if the updater reports a different version than the cached release.
|
||||
latestRelease: (!prev.latestRelease || prev.latestRelease.version !== info.version) ? {
|
||||
@@ -439,6 +454,20 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
} else if (res?.checking) {
|
||||
// Another check is already in flight — don't change status; the
|
||||
// in-flight check will resolve via IPC events.
|
||||
} else if (nextStatus === 'error' && res?.available) {
|
||||
// GitHub API failed but electron-updater found an update.
|
||||
// Respect dismissed versions before surfacing.
|
||||
const dismissed = localStorageAdapter.readString(STORAGE_KEY_UPDATE_DISMISSED_VERSION);
|
||||
if (res.version && res.version === dismissed) {
|
||||
// User dismissed this version — don't re-surface
|
||||
} else {
|
||||
setUpdateState((prev) => ({
|
||||
...prev,
|
||||
manualCheckStatus: 'available',
|
||||
hasUpdate: true,
|
||||
error: null,
|
||||
}));
|
||||
}
|
||||
} else if (nextStatus === 'error' && !res?.error && !res?.available) {
|
||||
// GitHub API failed but electron-updater says no update available.
|
||||
// Clear the error status so Settings doesn't stay stuck in error state.
|
||||
@@ -519,12 +548,12 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
if (IS_UPDATE_DEMO_MODE) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugLog('Version check effect', {
|
||||
hasChecked: hasCheckedOnStartupRef.current,
|
||||
|
||||
debugLog('Version check effect', {
|
||||
hasChecked: hasCheckedOnStartupRef.current,
|
||||
currentVersion: updateState.currentVersion
|
||||
});
|
||||
|
||||
|
||||
if (hasCheckedOnStartupRef.current) {
|
||||
return;
|
||||
}
|
||||
@@ -533,12 +562,11 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've checked recently
|
||||
// Hydrate cached release info so update status is visible across windows.
|
||||
// When auto-update is disabled, hydrate release data (for the Settings UI)
|
||||
// but don't set hasUpdate (which would trigger the toast in App.tsx).
|
||||
const lastCheck = localStorageAdapter.readNumber(STORAGE_KEY_UPDATE_LAST_CHECK);
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
// Hydrate cached release info so late-opening windows show the result
|
||||
if (lastCheck) {
|
||||
const cachedRelease = localStorageAdapter.readString(STORAGE_KEY_UPDATE_LATEST_RELEASE);
|
||||
if (cachedRelease) {
|
||||
try {
|
||||
@@ -556,6 +584,19 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
// Ignore corrupted cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Respect auto-update toggle — skip automatic check when disabled.
|
||||
// Don't set hasCheckedOnStartupRef so re-enabling (which changes the
|
||||
// autoUpdateEnabled dependency) can re-trigger this effect.
|
||||
if (!autoUpdateEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if we've checked recently
|
||||
const now = Date.now();
|
||||
if (lastCheck && now - lastCheck < UPDATE_CHECK_INTERVAL_MS) {
|
||||
hasCheckedOnStartupRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -563,6 +604,13 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
debugLog('Starting delayed update check for version:', updateState.currentVersion);
|
||||
|
||||
startupCheckTimeoutRef.current = setTimeout(async () => {
|
||||
// Re-check the toggle at fire time — the user may have toggled it
|
||||
// after the timer was scheduled.
|
||||
const stillEnabled = localStorageAdapter.readString(STORAGE_KEY_AUTO_UPDATE_ENABLED);
|
||||
if (stillEnabled === 'false') {
|
||||
debugLog('Skipping startup check — auto-update disabled after timer was scheduled');
|
||||
return;
|
||||
}
|
||||
// If electron-updater's auto-check already started a download, skip the
|
||||
// redundant GitHub API check to avoid duplicate toast notifications.
|
||||
if (autoDownloadStatusRef.current !== 'idle') {
|
||||
@@ -601,7 +649,7 @@ export function useUpdateCheck(): UseUpdateCheckResult {
|
||||
clearTimeout(startupCheckTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [updateState.currentVersion, performCheck]);
|
||||
}, [updateState.currentVersion, autoUpdateEnabled, performCheck]);
|
||||
|
||||
return {
|
||||
updateState,
|
||||
|
||||
@@ -51,7 +51,7 @@ type SettingsState = ReturnType<typeof useSettingsState> & {
|
||||
|
||||
const SettingsSyncTab = React.lazy(() => import("./settings/tabs/SettingsSyncTab"));
|
||||
|
||||
const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = ({ onSettingsApplied }) => {
|
||||
const {
|
||||
hosts,
|
||||
keys,
|
||||
@@ -90,6 +90,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
importDataFromString={importDataFromString}
|
||||
importPortForwardingRules={importPortForwardingRules}
|
||||
clearVaultData={clearVaultData}
|
||||
onSettingsApplied={onSettingsApplied}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -97,7 +98,7 @@ const SettingsSyncTabWithVault: React.FC = () => {
|
||||
const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }) => {
|
||||
const { t } = useI18n();
|
||||
const { notifyRendererReady, closeSettingsWindow } = useWindowControls();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck();
|
||||
const { updateState, checkNow, installUpdate, openReleasePage } = useUpdateCheck({ autoUpdateEnabled: settings.autoUpdateEnabled });
|
||||
const aiState = useAIState();
|
||||
const [activeTab, setActiveTab] = useState("application");
|
||||
const [mountedTabs, setMountedTabs] = useState(() => new Set(["application"]));
|
||||
@@ -290,7 +291,7 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
|
||||
{mountedTabs.has("sync") && (
|
||||
<React.Suspense fallback={null}>
|
||||
<SettingsSyncTabWithVault />
|
||||
<SettingsSyncTabWithVault onSettingsApplied={settings.rehydrateAllFromStorage} />
|
||||
</React.Suspense>
|
||||
)}
|
||||
|
||||
@@ -307,6 +308,10 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
closeToTray={settings.closeToTray}
|
||||
setCloseToTray={settings.setCloseToTray}
|
||||
hotkeyRegistrationError={settings.hotkeyRegistrationError}
|
||||
globalHotkeyEnabled={settings.globalHotkeyEnabled}
|
||||
setGlobalHotkeyEnabled={settings.setGlobalHotkeyEnabled}
|
||||
autoUpdateEnabled={settings.autoUpdateEnabled}
|
||||
setAutoUpdateEnabled={settings.setAutoUpdateEnabled}
|
||||
updateState={updateState}
|
||||
checkNow={checkNow}
|
||||
installUpdate={installUpdate}
|
||||
|
||||
@@ -684,12 +684,23 @@ export function useAIChatStreaming({
|
||||
}
|
||||
} else if (m.role === 'tool' && m.toolResults?.length) {
|
||||
// Map tool results to SDK tool message format
|
||||
// Gemini requires functionResponse.name to be non-empty,
|
||||
// so we look up the toolName from the preceding assistant tool calls.
|
||||
const findToolName = (toolCallId: string): string => {
|
||||
for (const prev of currentSession?.messages ?? []) {
|
||||
if (prev.role === 'assistant' && prev.toolCalls) {
|
||||
const tc = prev.toolCalls.find(t => t.id === toolCallId);
|
||||
if (tc) return tc.name;
|
||||
}
|
||||
}
|
||||
return 'unknown';
|
||||
};
|
||||
sdkMessages.push({
|
||||
role: 'tool',
|
||||
content: m.toolResults.map(tr => ({
|
||||
type: 'tool-result' as const,
|
||||
toolCallId: tr.toolCallId,
|
||||
toolName: '',
|
||||
toolName: findToolName(tr.toolCallId),
|
||||
output: { type: 'text' as const, value: tr.content },
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ export default function SettingsSyncTab(props: {
|
||||
importDataFromString: (data: string) => void;
|
||||
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
clearVaultData: () => void;
|
||||
onSettingsApplied?: () => void;
|
||||
}) {
|
||||
const {
|
||||
vault,
|
||||
@@ -22,6 +23,7 @@ export default function SettingsSyncTab(props: {
|
||||
importDataFromString,
|
||||
importPortForwardingRules,
|
||||
clearVaultData,
|
||||
onSettingsApplied,
|
||||
} = props;
|
||||
|
||||
const onBuildPayload = useCallback((): SyncPayload => {
|
||||
@@ -56,9 +58,10 @@ export default function SettingsSyncTab(props: {
|
||||
applySyncPayload(payload, {
|
||||
importVaultData: importDataFromString,
|
||||
importPortForwardingRules,
|
||||
onSettingsApplied,
|
||||
});
|
||||
},
|
||||
[importDataFromString, importPortForwardingRules],
|
||||
[importDataFromString, importPortForwardingRules, onSettingsApplied],
|
||||
);
|
||||
|
||||
const clearAllLocalData = useCallback(() => {
|
||||
|
||||
@@ -55,6 +55,10 @@ interface SettingsSystemTabProps {
|
||||
closeToTray: boolean;
|
||||
setCloseToTray: (enabled: boolean) => void;
|
||||
hotkeyRegistrationError: string | null;
|
||||
globalHotkeyEnabled: boolean;
|
||||
setGlobalHotkeyEnabled: (enabled: boolean) => void;
|
||||
autoUpdateEnabled: boolean;
|
||||
setAutoUpdateEnabled: (enabled: boolean) => void;
|
||||
// Unified update state — from useUpdateCheck hook in SettingsPageContent
|
||||
updateState: UpdateState;
|
||||
checkNow: () => Promise<unknown>;
|
||||
@@ -74,6 +78,10 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
closeToTray,
|
||||
setCloseToTray,
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
updateState,
|
||||
checkNow,
|
||||
installUpdate,
|
||||
@@ -367,6 +375,15 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<SettingRow
|
||||
label={t('settings.update.autoUpdateEnabled')}
|
||||
description={t('settings.update.autoUpdateEnabledDesc')}
|
||||
>
|
||||
<Toggle
|
||||
checked={autoUpdateEnabled}
|
||||
onChange={setAutoUpdateEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updateState.lastCheckedAt && (
|
||||
<span>
|
||||
@@ -599,42 +616,55 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg p-4 space-y-4">
|
||||
{/* Toggle Window Hotkey */}
|
||||
{/* Enable/Disable Global Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
label={t('settings.globalHotkey.enabled')}
|
||||
description={t('settings.globalHotkey.enabledDesc')}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<Toggle
|
||||
checked={globalHotkeyEnabled}
|
||||
onChange={setGlobalHotkeyEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
|
||||
<div className={cn(!globalHotkeyEnabled && "opacity-50 pointer-events-none")}>
|
||||
{/* Toggle Window Hotkey */}
|
||||
<SettingRow
|
||||
label={t("settings.globalHotkey.toggleWindow")}
|
||||
description={t("settings.globalHotkey.toggleWindowDesc")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setIsRecordingHotkey(true);
|
||||
}}
|
||||
className={cn(
|
||||
"px-3 py-1.5 text-sm font-mono rounded border transition-colors min-w-[100px] text-center",
|
||||
isRecordingHotkey
|
||||
? "border-primary bg-primary/10 animate-pulse"
|
||||
: "border-border hover:border-primary/50",
|
||||
)}
|
||||
>
|
||||
{isRecordingHotkey
|
||||
? t("settings.shortcuts.recording")
|
||||
: toggleWindowHotkey || t("settings.globalHotkey.notSet")}
|
||||
</button>
|
||||
{toggleWindowHotkey && (
|
||||
<button
|
||||
onClick={handleResetHotkey}
|
||||
className="p-1 hover:bg-muted rounded"
|
||||
title={t("settings.globalHotkey.reset")}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
{(hotkeyError || hotkeyRegistrationError) && (
|
||||
<p className="text-sm text-destructive mt-2">{hotkeyError || hotkeyRegistrationError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Close to Tray */}
|
||||
<SettingRow
|
||||
|
||||
@@ -12,7 +12,7 @@ const ScrollArea = React.forwardRef<
|
||||
className={cn("relative overflow-hidden", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full rounded-[inherit]">
|
||||
<ScrollAreaPrimitive.Viewport className="h-full w-full max-h-[inherit] rounded-[inherit]">
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
|
||||
@@ -172,13 +172,30 @@ export interface SyncPayload {
|
||||
|
||||
// Settings
|
||||
settings?: {
|
||||
// Theme & Appearance
|
||||
theme?: 'light' | 'dark' | 'system';
|
||||
accentColor?: string;
|
||||
lightUiThemeId?: string;
|
||||
darkUiThemeId?: string;
|
||||
accentMode?: 'theme' | 'custom';
|
||||
customAccent?: string;
|
||||
uiFontFamilyId?: string;
|
||||
uiLanguage?: string;
|
||||
customCSS?: string;
|
||||
// Terminal
|
||||
terminalTheme?: string;
|
||||
terminalFontFamily?: string;
|
||||
terminalFontSize?: number;
|
||||
hotkeyScheme?: string;
|
||||
terminalSettings?: Record<string, unknown>;
|
||||
customTerminalThemes?: Array<{ id: string; name: string; colors: Record<string, string> }>;
|
||||
// Keyboard
|
||||
customKeyBindings?: Record<string, { mac?: string; pc?: string }>;
|
||||
// Editor
|
||||
editorWordWrap?: boolean;
|
||||
// SFTP
|
||||
sftpDoubleClickBehavior?: 'open' | 'transfer';
|
||||
sftpAutoSync?: boolean;
|
||||
sftpShowHiddenFiles?: boolean;
|
||||
sftpUseCompressedUpload?: boolean;
|
||||
};
|
||||
|
||||
// Sync metadata
|
||||
|
||||
@@ -16,6 +16,28 @@ import type {
|
||||
SSHKey,
|
||||
} from './models';
|
||||
import type { SyncPayload } from './sync';
|
||||
import { localStorageAdapter } from '../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
STORAGE_KEY_THEME,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_ACCENT_MODE,
|
||||
STORAGE_KEY_COLOR,
|
||||
STORAGE_KEY_UI_FONT_FAMILY,
|
||||
STORAGE_KEY_UI_LANGUAGE,
|
||||
STORAGE_KEY_CUSTOM_CSS,
|
||||
STORAGE_KEY_TERM_THEME,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_FONT_SIZE,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_CUSTOM_KEY_BINDINGS,
|
||||
STORAGE_KEY_EDITOR_WORD_WRAP,
|
||||
STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR,
|
||||
STORAGE_KEY_SFTP_AUTO_SYNC,
|
||||
STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES,
|
||||
STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD,
|
||||
STORAGE_KEY_CUSTOM_THEMES,
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Input types
|
||||
@@ -38,6 +60,157 @@ export interface SyncPayloadImporters {
|
||||
importVaultData: (jsonString: string) => void;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
||||
/** Called after synced settings have been written to localStorage. */
|
||||
onSettingsApplied?: () => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings sync helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Terminal settings keys that are safe to sync (platform-agnostic). */
|
||||
const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollback', 'drawBoldInBrightColors', 'fontLigatures', 'fontWeight', 'fontWeightBold',
|
||||
'linePadding', 'cursorShape', 'cursorBlink', 'minimumContrastRatio',
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Collect all syncable settings from localStorage.
|
||||
*/
|
||||
export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const settings: SyncPayload['settings'] = {};
|
||||
|
||||
// Theme & Appearance
|
||||
const theme = localStorageAdapter.readString(STORAGE_KEY_THEME);
|
||||
if (theme === 'light' || theme === 'dark' || theme === 'system') settings.theme = theme;
|
||||
const lightUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_LIGHT);
|
||||
if (lightUi) settings.lightUiThemeId = lightUi;
|
||||
const darkUi = localStorageAdapter.readString(STORAGE_KEY_UI_THEME_DARK);
|
||||
if (darkUi) settings.darkUiThemeId = darkUi;
|
||||
const accentMode = localStorageAdapter.readString(STORAGE_KEY_ACCENT_MODE);
|
||||
if (accentMode === 'theme' || accentMode === 'custom') settings.accentMode = accentMode;
|
||||
const accent = localStorageAdapter.readString(STORAGE_KEY_COLOR);
|
||||
if (accent) settings.customAccent = accent;
|
||||
const uiFont = localStorageAdapter.readString(STORAGE_KEY_UI_FONT_FAMILY);
|
||||
if (uiFont) settings.uiFontFamilyId = uiFont;
|
||||
const lang = localStorageAdapter.readString(STORAGE_KEY_UI_LANGUAGE);
|
||||
if (lang) settings.uiLanguage = lang;
|
||||
const css = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_CSS);
|
||||
if (css != null) settings.customCSS = css;
|
||||
|
||||
// Terminal
|
||||
const termTheme = localStorageAdapter.readString(STORAGE_KEY_TERM_THEME);
|
||||
if (termTheme) settings.terminalTheme = termTheme;
|
||||
const termFont = localStorageAdapter.readString(STORAGE_KEY_TERM_FONT_FAMILY);
|
||||
if (termFont) settings.terminalFontFamily = termFont;
|
||||
const termSize = localStorageAdapter.readNumber(STORAGE_KEY_TERM_FONT_SIZE);
|
||||
if (termSize != null) settings.terminalFontSize = termSize;
|
||||
|
||||
// Terminal settings (syncable subset only)
|
||||
const termSettingsRaw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (termSettingsRaw) {
|
||||
try {
|
||||
const full = JSON.parse(termSettingsRaw);
|
||||
const subset: Record<string, unknown> = {};
|
||||
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
||||
if (key in full) subset[key] = full[key];
|
||||
}
|
||||
if (Object.keys(subset).length > 0) settings.terminalSettings = subset;
|
||||
} catch { /* ignore corrupt data */ }
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
const customThemesRaw = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_THEMES);
|
||||
if (customThemesRaw) {
|
||||
try {
|
||||
const parsed = JSON.parse(customThemesRaw);
|
||||
if (Array.isArray(parsed)) settings.customTerminalThemes = parsed;
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
const kb = localStorageAdapter.readString(STORAGE_KEY_CUSTOM_KEY_BINDINGS);
|
||||
if (kb) {
|
||||
try {
|
||||
settings.customKeyBindings = JSON.parse(kb);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Editor
|
||||
const wordWrap = localStorageAdapter.readString(STORAGE_KEY_EDITOR_WORD_WRAP);
|
||||
if (wordWrap === 'true' || wordWrap === 'false') settings.editorWordWrap = wordWrap === 'true';
|
||||
|
||||
// SFTP
|
||||
const dblClick = localStorageAdapter.readString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR);
|
||||
if (dblClick === 'open' || dblClick === 'transfer') settings.sftpDoubleClickBehavior = dblClick;
|
||||
const autoSync = localStorageAdapter.readString(STORAGE_KEY_SFTP_AUTO_SYNC);
|
||||
if (autoSync === 'true' || autoSync === 'false') settings.sftpAutoSync = autoSync === 'true';
|
||||
const hidden = localStorageAdapter.readString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES);
|
||||
if (hidden === 'true' || hidden === 'false') settings.sftpShowHiddenFiles = hidden === 'true';
|
||||
const compress = localStorageAdapter.readString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD);
|
||||
if (compress === 'true' || compress === 'false') settings.sftpUseCompressedUpload = compress === 'true';
|
||||
|
||||
return Object.keys(settings).length > 0 ? settings : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply synced settings to localStorage. Merges terminal settings
|
||||
* to preserve platform-specific fields.
|
||||
*/
|
||||
export function applySyncableSettings(settings: NonNullable<SyncPayload['settings']>): void {
|
||||
// Theme & Appearance
|
||||
if (settings.theme != null) localStorageAdapter.writeString(STORAGE_KEY_THEME, settings.theme);
|
||||
if (settings.lightUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_LIGHT, settings.lightUiThemeId);
|
||||
if (settings.darkUiThemeId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_THEME_DARK, settings.darkUiThemeId);
|
||||
if (settings.accentMode != null) localStorageAdapter.writeString(STORAGE_KEY_ACCENT_MODE, settings.accentMode);
|
||||
if (settings.customAccent != null) localStorageAdapter.writeString(STORAGE_KEY_COLOR, settings.customAccent);
|
||||
if (settings.uiFontFamilyId != null) localStorageAdapter.writeString(STORAGE_KEY_UI_FONT_FAMILY, settings.uiFontFamilyId);
|
||||
if (settings.uiLanguage != null) localStorageAdapter.writeString(STORAGE_KEY_UI_LANGUAGE, settings.uiLanguage);
|
||||
if (settings.customCSS != null) localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_CSS, settings.customCSS);
|
||||
|
||||
// Terminal
|
||||
if (settings.terminalTheme != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_THEME, settings.terminalTheme);
|
||||
if (settings.terminalFontFamily != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_FAMILY, settings.terminalFontFamily);
|
||||
if (settings.terminalFontSize != null) localStorageAdapter.writeString(STORAGE_KEY_TERM_FONT_SIZE, String(settings.terminalFontSize));
|
||||
|
||||
// Terminal settings — merge with existing to preserve platform-specific keys
|
||||
if (settings.terminalSettings) {
|
||||
let existing: Record<string, unknown> = {};
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (raw) {
|
||||
try { existing = JSON.parse(raw); } catch { /* ignore */ }
|
||||
}
|
||||
const merged = { ...existing };
|
||||
for (const key of SYNCABLE_TERMINAL_KEYS) {
|
||||
if (key in settings.terminalSettings) {
|
||||
merged[key] = settings.terminalSettings[key];
|
||||
}
|
||||
}
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERM_SETTINGS, JSON.stringify(merged));
|
||||
}
|
||||
|
||||
// Custom terminal themes
|
||||
if (settings.customTerminalThemes != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_THEMES, JSON.stringify(settings.customTerminalThemes));
|
||||
}
|
||||
|
||||
// Keyboard
|
||||
if (settings.customKeyBindings != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_CUSTOM_KEY_BINDINGS, JSON.stringify(settings.customKeyBindings));
|
||||
}
|
||||
|
||||
// Editor
|
||||
if (settings.editorWordWrap != null) localStorageAdapter.writeString(STORAGE_KEY_EDITOR_WORD_WRAP, String(settings.editorWordWrap));
|
||||
|
||||
// SFTP
|
||||
if (settings.sftpDoubleClickBehavior != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_DOUBLE_CLICK_BEHAVIOR, settings.sftpDoubleClickBehavior);
|
||||
if (settings.sftpAutoSync != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_AUTO_SYNC, String(settings.sftpAutoSync));
|
||||
if (settings.sftpShowHiddenFiles != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_SHOW_HIDDEN_FILES, String(settings.sftpShowHiddenFiles));
|
||||
if (settings.sftpUseCompressedUpload != null) localStorageAdapter.writeString(STORAGE_KEY_SFTP_USE_COMPRESSED_UPLOAD, String(settings.sftpUseCompressedUpload));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -64,6 +237,7 @@ export function buildSyncPayload(
|
||||
snippetPackages: vault.snippetPackages,
|
||||
knownHosts: vault.knownHosts,
|
||||
portForwardingRules,
|
||||
settings: collectSyncableSettings(),
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -105,4 +279,10 @@ export function applySyncPayload(
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,12 @@ module.exports = {
|
||||
'node_modules/@zed-industries/codex-acp-*/**/*',
|
||||
'node_modules/@modelcontextprotocol/sdk/**/*',
|
||||
'node_modules/zod/**/*',
|
||||
'node_modules/zod-to-json-schema/**/*',
|
||||
'node_modules/ajv/**/*',
|
||||
'node_modules/ajv-formats/**/*',
|
||||
'node_modules/fast-deep-equal/**/*',
|
||||
'node_modules/fast-uri/**/*',
|
||||
'node_modules/json-schema-traverse/**/*',
|
||||
'electron/mcp/**/*'
|
||||
],
|
||||
mac: {
|
||||
|
||||
@@ -12,6 +12,40 @@
|
||||
|
||||
let _deps = null;
|
||||
|
||||
/**
|
||||
* Read the persisted auto-update preference from a JSON file in userData.
|
||||
* Returns true (default) if the file doesn't exist or is unreadable.
|
||||
*/
|
||||
function readAutoUpdatePreference() {
|
||||
try {
|
||||
const { app } = _deps?.electronModule || {};
|
||||
if (!app) return true;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
|
||||
const data = JSON.parse(fs.readFileSync(prefPath, 'utf8'));
|
||||
return data.enabled !== false;
|
||||
} catch {
|
||||
return true; // default to enabled
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the auto-update preference to a JSON file in userData.
|
||||
*/
|
||||
function writeAutoUpdatePreference(enabled) {
|
||||
try {
|
||||
const { app } = _deps?.electronModule || {};
|
||||
if (!app) return;
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const prefPath = path.join(app.getPath('userData'), 'auto-update-pref.json');
|
||||
fs.writeFileSync(prefPath, JSON.stringify({ enabled }), 'utf8');
|
||||
} catch (err) {
|
||||
console.warn('[AutoUpdate] Failed to write preference:', err?.message || err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the current packaging format supports electron-updater
|
||||
* (macOS zip/dmg, Windows NSIS, Linux AppImage).
|
||||
@@ -51,7 +85,7 @@ function getAutoUpdater() {
|
||||
if (_autoUpdater) return _autoUpdater;
|
||||
try {
|
||||
const { autoUpdater } = require("electron-updater");
|
||||
autoUpdater.autoDownload = true;
|
||||
autoUpdater.autoDownload = readAutoUpdatePreference();
|
||||
autoUpdater.autoInstallOnAppQuit = false;
|
||||
// Silence the default electron-log transport (we log ourselves).
|
||||
autoUpdater.logger = null;
|
||||
@@ -84,9 +118,12 @@ function setupGlobalListeners() {
|
||||
|
||||
updater.on("update-available", (info) => {
|
||||
_isChecking = false;
|
||||
// autoDownload=true means the download begins immediately after this event
|
||||
_isDownloading = true;
|
||||
_lastStatus = { status: 'downloading', percent: 0, error: null, version: info.version || null, isChecking: false };
|
||||
// Only track as downloading when autoDownload is enabled — otherwise no
|
||||
// download will actually start and the status would be stuck at 0%.
|
||||
// Use 'available' so late-opening windows can still hydrate the version.
|
||||
const willDownload = updater.autoDownload !== false;
|
||||
_isDownloading = willDownload;
|
||||
_lastStatus = { status: willDownload ? 'downloading' : 'available', percent: 0, error: null, version: info.version || null, isChecking: false };
|
||||
broadcastToAllWindows("netcatty:update:update-available", {
|
||||
version: info.version || "",
|
||||
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
|
||||
@@ -144,6 +181,9 @@ function startAutoCheck(delayMs = 5000) {
|
||||
console.log("[AutoUpdate] Platform does not support auto-update, skipping auto-check");
|
||||
return;
|
||||
}
|
||||
// Cancel any existing timer to avoid duplicate concurrent checks
|
||||
// (e.g. from multiple windows initializing or re-enable toggle).
|
||||
cancelAutoCheck();
|
||||
_autoCheckTimer = setTimeout(async () => {
|
||||
_autoCheckTimer = null;
|
||||
const updater = getAutoUpdater();
|
||||
@@ -151,6 +191,12 @@ function startAutoCheck(delayMs = 5000) {
|
||||
console.warn("[AutoUpdate] Auto-check skipped — updater not available");
|
||||
return;
|
||||
}
|
||||
// Respect autoDownload flag — the renderer may have disabled it via IPC
|
||||
// before this timer fires.
|
||||
if (updater.autoDownload === false) {
|
||||
console.log("[AutoUpdate] Auto-check skipped — autoDownload is disabled");
|
||||
return;
|
||||
}
|
||||
_isChecking = true;
|
||||
_lastStatus = { ..._lastStatus, isChecking: true };
|
||||
try {
|
||||
@@ -317,6 +363,34 @@ function registerHandlers(ipcMain) {
|
||||
updater.quitAndInstall(false, true);
|
||||
});
|
||||
|
||||
// ---- Get auto-update preference -----------------------------------------
|
||||
ipcMain.handle("netcatty:update:getAutoUpdate", () => {
|
||||
return { enabled: readAutoUpdatePreference() };
|
||||
});
|
||||
|
||||
// ---- Enable/disable auto-update ----------------------------------------
|
||||
let _prevAutoDownloadEnabled = readAutoUpdatePreference();
|
||||
ipcMain.handle("netcatty:update:setAutoUpdate", (_event, { enabled }) => {
|
||||
const wasEnabled = _prevAutoDownloadEnabled;
|
||||
_prevAutoDownloadEnabled = !!enabled;
|
||||
const updater = getAutoUpdater();
|
||||
if (updater) {
|
||||
updater.autoDownload = !!enabled;
|
||||
console.log("[AutoUpdate] autoDownload set to:", !!enabled);
|
||||
}
|
||||
// Persist so the preference survives app restarts
|
||||
writeAutoUpdatePreference(!!enabled);
|
||||
if (!enabled) {
|
||||
cancelAutoCheck();
|
||||
} else if (!wasEnabled && !_isChecking) {
|
||||
// Only re-schedule when actually re-enabling (not on every mount sync),
|
||||
// to avoid duplicate checks from multiple windows initializing.
|
||||
// Skip if a check is already in flight to prevent concurrent calls.
|
||||
startAutoCheck(2000);
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
console.log("[AutoUpdate] Handlers registered");
|
||||
}
|
||||
|
||||
|
||||
@@ -967,6 +967,8 @@ const api = {
|
||||
downloadUpdate: () => ipcRenderer.invoke("netcatty:update:download"),
|
||||
installUpdate: () => ipcRenderer.invoke("netcatty:update:install"),
|
||||
getUpdateStatus: () => ipcRenderer.invoke("netcatty:update:getStatus"),
|
||||
setAutoUpdate: (enabled) => ipcRenderer.invoke("netcatty:update:setAutoUpdate", { enabled }),
|
||||
getAutoUpdate: () => ipcRenderer.invoke("netcatty:update:getAutoUpdate"),
|
||||
onUpdateAvailable: (cb) => {
|
||||
updateAvailableListeners.add(cb);
|
||||
return () => updateAvailableListeners.delete(cb);
|
||||
|
||||
@@ -7,7 +7,7 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||
export default [
|
||||
js.configs.recommended,
|
||||
{
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**"],
|
||||
ignores: ["node_modules/**", "dist/**", "electron/**", "scripts/**", "public/monaco/**", ".github/**", ".claude/**", "release/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
|
||||
7
global.d.ts
vendored
7
global.d.ts
vendored
@@ -703,6 +703,7 @@ declare global {
|
||||
checkForUpdate?(): Promise<{
|
||||
available: boolean;
|
||||
supported?: boolean;
|
||||
checking?: boolean;
|
||||
version?: string;
|
||||
releaseNotes?: string;
|
||||
releaseDate?: string | null;
|
||||
@@ -710,7 +711,7 @@ declare global {
|
||||
}>;
|
||||
downloadUpdate?(): Promise<{ success: boolean; error?: string }>;
|
||||
installUpdate?(): void;
|
||||
getUpdateStatus?(): Promise<{ status: 'idle' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
|
||||
getUpdateStatus?(): Promise<{ status: 'idle' | 'available' | 'downloading' | 'ready' | 'error'; percent: number; error: string | null; version: string | null; isChecking?: boolean }>;
|
||||
|
||||
onUpdateDownloadProgress?(cb: (progress: {
|
||||
percent: number;
|
||||
@@ -732,6 +733,10 @@ declare global {
|
||||
unregisterGlobalHotkey?(): Promise<{ success: boolean }>;
|
||||
getGlobalHotkeyStatus?(): Promise<{ enabled: boolean; hotkey: string | null }>;
|
||||
|
||||
// Auto-Update toggle
|
||||
getAutoUpdate?(): Promise<{ enabled: boolean }>;
|
||||
setAutoUpdate?(enabled: boolean): Promise<{ success: boolean }>;
|
||||
|
||||
// System Tray / Close to Tray
|
||||
setCloseToTray?(enabled: boolean): Promise<{ success: boolean; enabled: boolean }>;
|
||||
isCloseToTray?(): Promise<{ enabled: boolean }>;
|
||||
|
||||
@@ -38,6 +38,7 @@ export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hos
|
||||
export const STORAGE_KEY_UPDATE_LAST_CHECK = 'netcatty_update_last_check_v1';
|
||||
export const STORAGE_KEY_UPDATE_DISMISSED_VERSION = 'netcatty_update_dismissed_version_v1';
|
||||
export const STORAGE_KEY_UPDATE_LATEST_RELEASE = 'netcatty_update_latest_release_v1';
|
||||
export const STORAGE_KEY_AUTO_UPDATE_ENABLED = 'netcatty_auto_update_enabled_v1';
|
||||
|
||||
// SFTP File Opener Associations
|
||||
export const STORAGE_KEY_SFTP_FILE_ASSOCIATIONS = 'netcatty_sftp_file_associations_v1';
|
||||
@@ -68,6 +69,7 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
|
||||
// Global Toggle Window Settings (Quake Mode)
|
||||
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
|
||||
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
|
||||
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
|
||||
|
||||
// Custom Terminal Themes
|
||||
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';
|
||||
|
||||
Reference in New Issue
Block a user