Files
Netcatty/application/state/systemSettingsEffects.ts
T
bincxz 7cba6bae2a feat(settings): add runtime Dock/taskbar icon variants (#1716)
Let users pick icon styles in Appearance settings with live Dock/window updates, dev public asset resolution, and cross-window sync.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-26 23:14:57 +08:00

206 lines
7.9 KiB
TypeScript

import { useEffect, useRef, type MutableRefObject } from 'react';
import {
STORAGE_KEY_AUTO_UPDATE_ENABLED,
STORAGE_KEY_CLOSE_TO_TRAY,
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
STORAGE_KEY_WINDOW_OPACITY,
STORAGE_KEY_APP_ICON_VARIANT,
} from '../../infrastructure/config/storageKeys';
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
interface UseSystemSettingsEffectsParams {
enabled?: boolean;
toggleWindowHotkey: string;
globalHotkeyEnabled: boolean;
closeToTray: boolean;
windowOpacity: number;
appIconVariant: AppIconVariant;
autoUpdateEnabled: boolean;
persistMountedRef: MutableRefObject<boolean>;
setHotkeyRegistrationError: (error: string | null) => void;
setAutoUpdateEnabled: (enabled: boolean | ((prev: boolean) => boolean)) => void;
setAppIconVariant: (variant: AppIconVariant | ((prev: AppIconVariant) => AppIconVariant)) => void;
notifySettingsChanged: (key: string, value: unknown) => void;
}
export function useSystemSettingsEffects({
enabled = true,
toggleWindowHotkey,
globalHotkeyEnabled,
closeToTray,
windowOpacity,
appIconVariant,
autoUpdateEnabled,
persistMountedRef,
setHotkeyRegistrationError,
setAutoUpdateEnabled,
setAppIconVariant,
notifySettingsChanged,
}: UseSystemSettingsEffectsParams) {
const appIconApplyRequestIdRef = useRef(0);
// Persist and sync toggle window hotkey setting
useEffect(() => {
if (!enabled) return;
// Register/unregister the global hotkey in main process (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.registerGlobalHotkey) {
if (toggleWindowHotkey && globalHotkeyEnabled) {
setHotkeyRegistrationError(null);
bridge
.registerGlobalHotkey(toggleWindowHotkey)
.then((result) => {
if (result?.success === false) {
console.warn('[GlobalHotkey] Hotkey registration failed:', result.error);
setHotkeyRegistrationError(result.error || 'Failed to register hotkey');
}
})
.catch((err) => {
console.warn('[GlobalHotkey] Failed to register hotkey:', err);
setHotkeyRegistrationError(err?.message || 'Failed to register hotkey');
});
} else {
setHotkeyRegistrationError(null);
bridge.unregisterGlobalHotkey?.().catch((err) => {
console.warn('[GlobalHotkey] Failed to unregister hotkey:', err);
});
}
}
localStorageAdapter.writeString(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, toggleWindowHotkey);
}, [
toggleWindowHotkey,
enabled,
globalHotkeyEnabled,
notifySettingsChanged,
persistMountedRef,
setHotkeyRegistrationError,
]);
// Persist global hotkey enabled setting
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, globalHotkeyEnabled);
}, [enabled, globalHotkeyEnabled, notifySettingsChanged, persistMountedRef]);
// Persist and sync close to tray setting
useEffect(() => {
if (!enabled) return;
// Update main process tray behavior (needed on mount)
const bridge = netcattyBridge.get();
if (bridge?.setCloseToTray) {
bridge.setCloseToTray(closeToTray).catch((err) => {
console.warn('[SystemTray] Failed to set close-to-tray:', err);
});
}
localStorageAdapter.writeString(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray ? 'true' : 'false');
// Skip IPC on initial mount
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
}, [enabled, closeToTray, notifySettingsChanged, persistMountedRef]);
// Persist and sync window opacity
useEffect(() => {
if (!enabled) return;
const bridge = netcattyBridge.get();
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
});
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
}, [enabled, windowOpacity, notifySettingsChanged, persistMountedRef]);
// Persist and sync app icon variant
useEffect(() => {
if (!enabled) return;
const storedBefore = resolveAppIconVariant(
localStorageAdapter.readString(STORAGE_KEY_APP_ICON_VARIANT) ?? '',
);
localStorageAdapter.writeString(STORAGE_KEY_APP_ICON_VARIANT, appIconVariant);
if (!persistMountedRef.current) {
// Still apply on initial mount before cross-window notify is enabled.
} else {
notifySettingsChanged(STORAGE_KEY_APP_ICON_VARIANT, appIconVariant);
}
const bridge = netcattyBridge.get();
if (!bridge?.setAppIconVariant) return;
const requestId = ++appIconApplyRequestIdRef.current;
let cancelled = false;
const revertVariant = () => {
localStorageAdapter.writeString(STORAGE_KEY_APP_ICON_VARIANT, storedBefore);
if (appIconVariant !== storedBefore) {
setAppIconVariant(storedBefore);
}
if (persistMountedRef.current) {
notifySettingsChanged(STORAGE_KEY_APP_ICON_VARIANT, storedBefore);
}
};
void bridge.setAppIconVariant(appIconVariant)
.then((applied) => {
if (cancelled || requestId !== appIconApplyRequestIdRef.current) return;
if (applied === false && storedBefore !== appIconVariant) {
console.warn('[AppIcon] Failed to apply app icon variant:', appIconVariant);
revertVariant();
}
})
.catch((err) => {
if (cancelled || requestId !== appIconApplyRequestIdRef.current) return;
if (storedBefore !== appIconVariant) {
console.warn('[AppIcon] Failed to apply app icon variant:', err);
revertVariant();
}
});
return () => {
cancelled = true;
};
}, [enabled, appIconVariant, notifySettingsChanged, persistMountedRef, setAppIconVariant]);
// 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(() => {
if (!enabled) return;
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 */ });
}, [enabled, setAutoUpdateEnabled]);
// Persist auto-update enabled setting.
// Initial mount still writes localStorage, but skips cross-window/main-process IPC.
useEffect(() => {
if (!enabled) return;
localStorageAdapter.writeString(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled ? 'true' : 'false');
if (!persistMountedRef.current) return;
notifySettingsChanged(STORAGE_KEY_AUTO_UPDATE_ENABLED, autoUpdateEnabled);
// 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);
});
}, [enabled, autoUpdateEnabled, notifySettingsChanged, persistMountedRef]);
}