Compare commits

...

10 Commits

Author SHA1 Message Date
bincxz
f77c2b2de9 fix: resolve ESLint errors blocking dev startup
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
- Add release/** to ESLint ignores (build artifacts were being linted)
- Remove unused eslint-disable directives in useAutoSync and useSettingsState
- Add missing setTerminalSettings dependency to rehydrateAllFromStorage

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 16:09:00 +08:00
陈大猫
f79f27d737 feat: add settings cloud sync support (#353)
* feat: add settings cloud sync support (closes #347)

Expand SyncPayload.settings to include all syncable user preferences
(theme, appearance, terminal, keyboard, editor, SFTP). Add
collectSyncableSettings/applySyncableSettings helpers in syncPayload.ts,
wire rehydrateAllFromStorage through App.tsx and SettingsPage.tsx so
in-memory React state updates after a cloud download.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: include settings in auto-sync uploads and sync empty customCSS

P1: useAutoSync.buildPayload now includes collectSyncableSettings()
so settings are uploaded alongside vault data.

P2: customCSS uses != null check instead of truthy, so clearing CSS
on one device is properly synced to others.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: include settings in auto-sync change detection hash

Settings-only changes (theme, terminal options, etc.) now trigger
auto-sync uploads. The data hash comparison includes the settings
snapshot alongside vault data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: trigger auto-sync on settings changes and sync custom terminal themes

P1: Added settingsVersion (derived from all synced settings via useMemo)
to useAutoSync debounce effect dependencies. Settings-only changes now
trigger auto-sync uploads.

P2: Custom terminal themes (STORAGE_KEY_CUSTOM_THEMES) are now included
in the sync payload so custom themes are available on other devices.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: reload custom theme store after sync, include in change detection

P1: customThemeStore.loadFromStorage() is now called in
rehydrateAllFromStorage so synced custom themes are immediately
reflected in the live theme store.

P2a: customThemes added to settingsVersion dependencies so custom
theme edits trigger auto-sync.

P2b: Empty custom themes array is now preserved in sync payload
to properly propagate theme deletion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: notify subscribers after custom theme store reload

loadFromStorage now calls notify() to trigger useSyncExternalStore
subscribers, so synced custom terminal themes are immediately
visible in all windows after apply.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 15:57:41 +08:00
陈大猫
ec35daa0dd feat: add auto-update toggle setting (#351)
* feat: add auto-update toggle setting (closes #346)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: re-check auto-update toggle when startup timer fires

Address review feedback: the startup check effect now re-reads the
toggle from localStorage when the delayed timer fires, so toggling
off after launch cancels the pending check. Also avoids setting
hasCheckedOnStartupRef when disabled, allowing re-enable to trigger
a check without restart.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address review feedback on auto-update toggle

P1: When autoDownload=false, onUpdateAvailable no longer transitions
to 'downloading' status. Instead keeps autoDownloadStatus idle so
the manual download link surfaces correctly.

P2: Added reactive autoUpdateEnabled state (synced via storage event)
as a dependency to the startup check effect. Re-enabling the toggle
mid-session now re-triggers the deferred startup check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: address P1/P2 review feedback on auto-update toggle

P1: Main process update-available handler now checks updater.autoDownload
before setting _lastStatus to 'downloading'. When autoDownload=false,
status stays 'idle' so late-opened windows don't hydrate to a stuck
0% download state.

P2: useUpdateCheck now accepts autoUpdateEnabled as a prop from the
caller instead of relying solely on storage events (which don't fire
in the same window). SettingsPage passes settings.autoUpdateEnabled
directly, so toggling in the current window takes effect immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve update-available info for late-opening windows

When autoDownload is off, use status 'available' (instead of 'idle')
in the main process snapshot so late-opening windows can hydrate
version info. The renderer maps 'available' to hasUpdate=true while
keeping autoDownloadStatus='idle' for the manual download path.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: re-schedule auto-check on re-enable and guard startup timer

- IPC handler now calls startAutoCheck(2000) when re-enabling so the
  user gets automatic checks without restarting the app.
- startAutoCheck timer checks updater.autoDownload at fire time, so
  if the renderer disables auto-update via IPC before the 5s startup
  timer fires, the check is skipped.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: deduplicate auto-check scheduling and clear error on fallback success

P1: startAutoCheck now cancels any existing timer before scheduling
a new one, preventing duplicate concurrent checks from multiple
windows or re-enable toggles.

P2: checkNow fallback now clears manualCheckStatus='error' when
electron-updater successfully finds an update (res.available=true),
so the UI shows 'available' instead of a stale error state.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: only reschedule on actual re-enable and hydrate cache before toggle check

P2: Track previous autoDownload state in IPC handler so startAutoCheck
is only called on actual false→true transitions, not on every window
mount that syncs the current value.

P3: Move cache hydration (STORAGE_KEY_UPDATE_LATEST_RELEASE) before
the auto-update toggle check so cached update info is always visible
even when automatic updates are disabled.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: persist auto-update preference in main process across restarts

Read/write auto-update preference to a JSON file in userData so the
main process honors it on next launch without waiting for renderer IPC.
getAutoUpdater() now initializes autoDownload from the persisted value.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: suppress cached update toast when disabled and update IPC types

P2: Cache hydration now gates hasUpdate on autoUpdateEnabled so the
App.tsx toast doesn't fire when automatic updates are disabled.

P3: Updated global.d.ts to include 'available' in getUpdateStatus
status union and 'checking' in checkForUpdate return type.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: preserve dismissed releases, show cached updates in Settings, guard concurrent checks

P2a: Updater fallback now checks STORAGE_KEY_UPDATE_DISMISSED_VERSION
before re-surfacing a release found by electron-updater.

P2b: Cache hydration always sets hasUpdate truthfully so Settings
shows the available update. Toast suppression for disabled auto-update
moved to App.tsx (reads localStorage directly).

P3: Re-enable IPC handler checks _isChecking before scheduling
startAutoCheck to prevent concurrent electron-updater calls.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use localStorageAdapter for lint compliance, skip IPC on initial mount

P1: Replace direct localStorage access with localStorageAdapter in
App.tsx toast guard to fix no-restricted-globals lint error.

P2: Skip setAutoUpdate IPC on initial mount to prevent overwriting
the main-process preference file when renderer localStorage has been
cleared (where the default would be true).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: hydrate auto-update state from main-process preference on mount

Add getAutoUpdate IPC handler so the renderer can query the persisted
preference from auto-update-pref.json. On mount, useSettingsState
reconciles localStorage with the main-process truth, preventing the
toggle from showing 'enabled' when the user had previously disabled
it and localStorage was cleared.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 14:54:40 +08:00
陈大猫
ed0775d9d2 Merge pull request #352 from binaricat/feat/global-hotkey-toggle
feat: add global hotkey enable/disable toggle
2026-03-16 12:41:54 +08:00
bincxz
1f31629ce0 feat: add global hotkey enable/disable toggle (closes #349)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 12:36:37 +08:00
陈大猫
cc4a904dea Merge pull request #350 from binaricat/fix/gemini-empty-function-response-name
fix: resolve Gemini API error caused by empty functionResponse name
2026-03-16 11:56:57 +08:00
bincxz
e9e1d87ff5 fix: resolve Gemini API error caused by empty functionResponse name
When rebuilding SDK messages from conversation history, tool-result
messages had toolName hardcoded to an empty string. This works for
OpenAI/Claude APIs but Gemini requires functionResponse.name to be
non-empty, causing AI_APICallError on every follow-up message.

Now looks up the tool name from the matching assistant tool call
via toolCallId.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 11:43:28 +08:00
陈大猫
a6b07f39ad Merge pull request #348 from yuzifu/fix-dropdown-lists-height
enable scrollbar in dropdown lists when content exceeds max-height
2026-03-16 11:23:36 +08:00
yuzifu
6892e11952 enable scrollbar in dropdown lists when content exceeds max-height 2026-03-16 11:07:56 +08:00
bincxz
ec9be922cb fix: unpack MCP server transitive dependencies from asar
Some checks failed
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
The MCP server runs as a standalone Node process (not Electron), so it
cannot access modules inside app.asar. Add missing transitive deps
(zod-to-json-schema, ajv, ajv-formats, fast-deep-equal, fast-uri,
json-schema-traverse) to asarUnpack so they are available on disk.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 10:10:56 +08:00
20 changed files with 621 additions and 78 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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?.();
}
}

View File

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

View File

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

View File

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

View File

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

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

View File

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