diff --git a/application/i18n/locales/en/core.ts b/application/i18n/locales/en/core.ts index 310e2ba65..e57db088e 100644 --- a/application/i18n/locales/en/core.ts +++ b/application/i18n/locales/en/core.ts @@ -582,6 +582,8 @@ Highlight the focused split pane: 'settings.appearance.uiFont.desc': 'Choose the font for the application interface', 'settings.appearance.windowOpacity': 'Window Opacity', 'settings.appearance.windowOpacity.desc': 'Adjust the transparency of the entire application window. Lower values also fade terminal text. Some Linux desktop environments may not support this.', + 'settings.appearance.windowBackgroundBlur': 'Frosted glass background', + 'settings.appearance.windowBackgroundBlur.desc': 'Blur the desktop behind the window. At 100% opacity the effect is hidden, but your choice is preserved.', 'settings.appearance.appIcon': 'App Icon', 'settings.appearance.appIcon.desc': 'Choose a dock/taskbar icon style to make Netcatty easier to spot when switching apps. On Windows, the notification area icon uses the same style. Applies at runtime; the installed app bundle icon is unchanged.', 'settings.appearance.appIcon.original': 'Original', diff --git a/application/i18n/locales/es/core.ts b/application/i18n/locales/es/core.ts index 2c7775290..6d14bb7ac 100644 --- a/application/i18n/locales/es/core.ts +++ b/application/i18n/locales/es/core.ts @@ -582,6 +582,8 @@ Resalta el panel dividido enfocado: 'settings.appearance.uiFont.desc': 'Elige la fuente para la interfaz de la aplicación', 'settings.appearance.windowOpacity': 'Opacidad de la ventana', 'settings.appearance.windowOpacity.desc': 'Ajusta la transparencia de toda la ventana de la aplicación. Los valores más bajos también atenúan el texto de la terminal. Algunos entornos de escritorio de Linux pueden no admitir esto.', + 'settings.appearance.windowBackgroundBlur': 'Fondo de cristal esmerilado', + 'settings.appearance.windowBackgroundBlur.desc': 'Desenfoca el escritorio detrás de la ventana. Con una opacidad del 100 %, el efecto no es visible, pero se conserva tu elección.', 'settings.appearance.appIcon': 'Ícono de la aplicación', 'settings.appearance.appIcon.desc': 'Elige un estilo de ícono para el dock o la barra de tareas que haga más fácil distinguir Netcatty al cambiar de aplicación. En Windows, el icono del área de notificación usa el mismo estilo. Se aplica en tiempo de ejecución; el ícono del paquete instalado no cambia.', 'settings.appearance.appIcon.original': 'Original', diff --git a/application/i18n/locales/ru/core.ts b/application/i18n/locales/ru/core.ts index 33adfbd5e..5bc9009c8 100644 --- a/application/i18n/locales/ru/core.ts +++ b/application/i18n/locales/ru/core.ts @@ -525,6 +525,8 @@ export const ruCoreMessages: Messages = { 'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения', 'settings.appearance.windowOpacity': 'Прозрачность окна', 'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.', + 'settings.appearance.windowBackgroundBlur': 'Матовое стекло', + 'settings.appearance.windowBackgroundBlur.desc': 'Размывает рабочий стол за окном. При непрозрачности 100 % эффект не виден, но ваш выбор сохраняется.', 'settings.appearance.appIcon': 'Значок приложения', 'settings.appearance.appIcon.desc': 'Выберите стиль значка для Dock/панели задач, чтобы Netcatty было проще найти при переключении приложений. В Windows значок области уведомлений использует тот же стиль. Применяется во время работы; значок установленного пакета не меняется.', 'settings.appearance.appIcon.original': 'Оригинал', diff --git a/application/i18n/locales/zh-CN/core.ts b/application/i18n/locales/zh-CN/core.ts index da8d927e1..746b2b857 100644 --- a/application/i18n/locales/zh-CN/core.ts +++ b/application/i18n/locales/zh-CN/core.ts @@ -586,6 +586,8 @@ Vaults 标签保持扁平,不使用这块背景。 'settings.appearance.uiFont.desc': '选择软件界面使用的字体', 'settings.appearance.windowOpacity': '窗口透明度', 'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。', + 'settings.appearance.windowBackgroundBlur': '毛玻璃背景', + 'settings.appearance.windowBackgroundBlur.desc': '模糊窗口后方的桌面。100% 不透明时效果不可见,但会保留你的开关设置。', 'settings.appearance.appIcon': '应用图标', 'settings.appearance.appIcon.desc': '选择 Dock/任务栏图标风格,切换应用时更容易找到 Netcatty。Windows 通知区域图标会使用同一套风格。运行时生效,安装包内的图标不会改变。', 'settings.appearance.appIcon.original': '原始', diff --git a/application/i18n/locales/zh-TW/core.ts b/application/i18n/locales/zh-TW/core.ts index d93967df8..ba3ddd0ce 100644 --- a/application/i18n/locales/zh-TW/core.ts +++ b/application/i18n/locales/zh-TW/core.ts @@ -588,6 +588,8 @@ Vaults 分頁維持扁平,不使用這塊背景。 'settings.appearance.uiFont.desc': '選擇軟體介面使用的字型', 'settings.appearance.windowOpacity': '視窗透明度', 'settings.appearance.windowOpacity.desc': '調節整個應用程式視窗的透明度,方便疊在其他內容上方。較低時終端文字也會變淡;部分 Linux 桌面環境可能不支援。', + 'settings.appearance.windowBackgroundBlur': '毛玻璃背景', + 'settings.appearance.windowBackgroundBlur.desc': '模糊視窗後方的桌面。100% 不透明時效果不可見,但會保留你的開關設定。', 'settings.appearance.appIcon': '應用程式圖示', 'settings.appearance.appIcon.desc': '選擇 Dock/工作列圖示風格,切換應用程式時更容易找到 Netcatty。Windows 通知區域圖示會使用同一套風格。執行時生效,安裝檔內的圖示不會改變。', 'settings.appearance.appIcon.original': '原始', diff --git a/application/state/settingsIpcSync.ts b/application/state/settingsIpcSync.ts index 9f515972f..01d6098f0 100644 --- a/application/state/settingsIpcSync.ts +++ b/application/state/settingsIpcSync.ts @@ -49,6 +49,7 @@ import { STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN, STORAGE_KEY_LOCAL_SHELL_SIDE_PANEL_AUTO_OPEN_TAB, STORAGE_KEY_WINDOW_OPACITY, + STORAGE_KEY_WINDOW_BACKGROUND_BLUR, STORAGE_KEY_APP_ICON_VARIANT, STORAGE_KEY_HTTP_NETWORK_PROXY, STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, @@ -99,6 +100,7 @@ interface UseSettingsIpcSyncParams { setIsHotkeyRecordingState: Dispatch>; setGlobalHotkeyEnabled: Dispatch>; setWindowOpacity: (raw: unknown) => void; + setWindowBackgroundBlur: Dispatch>; setAppIconVariant: Dispatch>; setAutoUpdateEnabled: Dispatch>; setAutoLaunchEnabled: Dispatch>; @@ -148,6 +150,7 @@ export function useSettingsIpcSync({ setIsHotkeyRecordingState, setGlobalHotkeyEnabled, setWindowOpacity, + setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setAutoLaunchEnabled, @@ -279,6 +282,9 @@ export function useSettingsIpcSync({ if (key === STORAGE_KEY_WINDOW_OPACITY) { setWindowOpacity(value); } + if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && typeof value === 'boolean') { + setWindowBackgroundBlur((prev) => (prev === value ? prev : value)); + } if (key === STORAGE_KEY_APP_ICON_VARIANT) { const nextVariant = resolveAppIconVariant(value); setAppIconVariant((prev) => (prev === nextVariant ? prev : nextVariant)); @@ -371,6 +377,7 @@ export function useSettingsIpcSync({ setFollowAppTerminalThemeState, setGlobalHotkeyEnabled, setWindowOpacity, + setWindowBackgroundBlur, setAppIconVariant, setHotkeyScheme, setIsHotkeyRecordingState, diff --git a/application/state/settingsStorageSync.ts b/application/state/settingsStorageSync.ts index ac394558d..aaf8a99dc 100644 --- a/application/state/settingsStorageSync.ts +++ b/application/state/settingsStorageSync.ts @@ -53,6 +53,7 @@ import { STORAGE_KEY_UI_LANGUAGE, STORAGE_KEY_WORKSPACE_FOCUS_STYLE, STORAGE_KEY_WINDOW_OPACITY, + STORAGE_KEY_WINDOW_BACKGROUND_BLUR, STORAGE_KEY_APP_ICON_VARIANT, } from '../../infrastructure/config/storageKeys'; import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant'; @@ -116,6 +117,7 @@ interface UseSettingsStorageSyncParams { globalHotkeyEnabled: boolean; autoUpdateEnabled: boolean; windowOpacity: number; + windowBackgroundBlur: boolean; appIconVariant: AppIconVariant; setTheme: Dispatch>; setLightUiThemeId: Dispatch>; @@ -166,6 +168,7 @@ interface UseSettingsStorageSyncParams { setExplorerContextMenuEnabledState: (enabled: boolean) => void; setGlobalHotkeyEnabled: Dispatch>; setWindowOpacity: (raw: unknown) => void; + setWindowBackgroundBlur: Dispatch>; setAppIconVariant: Dispatch>; setAutoUpdateEnabled: Dispatch>; setWorkspaceFocusStyleState: Dispatch>; @@ -184,7 +187,7 @@ export function useSettingsStorageSync({ sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode, showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled, - globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant, + globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant, setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent, setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage, setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId, @@ -193,7 +196,7 @@ export function useSettingsStorageSync({ setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode, setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState, setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState, setJmsDeepLinkEnabledState, setExplorerContextMenuEnabledState, - setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState, + setGlobalHotkeyEnabled, setWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState, setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings, }: UseSettingsStorageSyncParams) { @@ -208,7 +211,7 @@ export function useSettingsStorageSync({ sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode, showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled, - globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant, + globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant, }); settingsSnapshotRef.current = { theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion, @@ -218,7 +221,7 @@ export function useSettingsStorageSync({ sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode, showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled, - globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant, + globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant, }; // Listen for storage changes from other windows (cross-window sync) @@ -523,6 +526,12 @@ export function useSettingsStorageSync({ if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) { setWindowOpacity(e.newValue); } + if (e.key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR && e.newValue !== null) { + const newValue = e.newValue === 'true'; + if (newValue !== s.windowBackgroundBlur) { + setWindowBackgroundBlur(newValue); + } + } if (e.key === STORAGE_KEY_APP_ICON_VARIANT && e.newValue !== null) { const newValue = resolveAppIconVariant(e.newValue); if (newValue !== s.appIconVariant) { @@ -566,6 +575,7 @@ export function useSettingsStorageSync({ setGlobalHotkeyEnabled, setHostClickBehaviorState, setWindowOpacity, + setWindowBackgroundBlur, setAppIconVariant, setHotkeyScheme, setLightUiThemeId, diff --git a/application/state/systemSettingsEffects.ts b/application/state/systemSettingsEffects.ts index b71906cd7..34bc65700 100644 --- a/application/state/systemSettingsEffects.ts +++ b/application/state/systemSettingsEffects.ts @@ -7,6 +7,7 @@ import { STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, STORAGE_KEY_TOGGLE_WINDOW_HOTKEY, STORAGE_KEY_WINDOW_OPACITY, + STORAGE_KEY_WINDOW_BACKGROUND_BLUR, STORAGE_KEY_APP_ICON_VARIANT, STORAGE_KEY_HTTP_NETWORK_PROXY, } from '../../infrastructure/config/storageKeys'; @@ -17,6 +18,8 @@ import { } from '../../domain/httpNetworkProxy'; import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter'; import { netcattyBridge } from '../../infrastructure/services/netcattyBridge'; +import { isMacPlatform } from '../../lib/utils'; +import { isWindowBackgroundBlurActive } from '../../domain/windowBackgroundBlur'; import { parseWindowOpacityRecord, serializeWindowOpacityRecord, @@ -47,6 +50,7 @@ interface UseSystemSettingsEffectsParams { autoLaunchEnabled: boolean; windowOpacityRecord: WindowOpacityRecord; windowOpacityMutationSourceRef: MutableRefObject; + windowBackgroundBlur: boolean; appIconVariant: AppIconVariant; autoUpdateEnabled: boolean; httpNetworkProxy: HttpNetworkProxySettings; @@ -68,6 +72,7 @@ export function useSystemSettingsEffects({ autoLaunchEnabled, windowOpacityRecord, windowOpacityMutationSourceRef, + windowBackgroundBlur, appIconVariant, autoUpdateEnabled, httpNetworkProxy, @@ -300,6 +305,49 @@ export function useSystemSettingsEffects({ persistMountedRef, ]); + // Window chrome also applies in detached sessions, independently of the + // OS-level effects disabled there (tray, global shortcuts, autolaunch, ...). + // Only the windows that own settings persistence drive the shared native + // state; detached/editor windows consume it (see applySharedWindowSetting). + useEffect(() => { + if (!enabled) return; + localStorageAdapter.writeBoolean(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur); + if (!persistMountedRef.current) return; + notifySettingsChanged(STORAGE_KEY_WINDOW_BACKGROUND_BLUR, windowBackgroundBlur); + }, [ + enabled, + windowBackgroundBlur, + notifySettingsChanged, + persistMountedRef, + ]); + + const windowBackgroundBlurActive = isMacPlatform() && isWindowBackgroundBlurActive( + windowBackgroundBlur, + windowOpacityRecord.opacity, + ); + + // Applies in every app-content window, including detached sessions: the + // transparent backing only looks right when the renderer class matches the + // native vibrancy the main process put on that window. + useEffect(() => { + if (typeof document === 'undefined') return; + document.documentElement.classList.toggle('window-background-blur', windowBackgroundBlurActive); + document.documentElement.style.setProperty('--window-content-opacity', String(windowOpacityRecord.opacity)); + return () => { + document.documentElement.classList.remove('window-background-blur'); + document.documentElement.style.removeProperty('--window-content-opacity'); + }; + }, [windowBackgroundBlurActive, windowOpacityRecord.opacity]); + + // setWindowBackgroundBlur is a main-process handler that retargets every + // app-content window, so a single owner must drive it. + useEffect(() => { + if (!enabled) return; + netcattyBridge.get()?.setWindowBackgroundBlur?.(windowBackgroundBlur).catch((err) => { + console.warn('[WindowBackgroundBlur] Failed to apply macOS vibrancy:', err); + }); + }, [enabled, windowBackgroundBlur]); + // Persist and sync app icon variant useEffect(() => { if (!enabled) return; diff --git a/application/state/systemSettingsEffects.windowBlur.test.ts b/application/state/systemSettingsEffects.windowBlur.test.ts new file mode 100644 index 000000000..13d1db19a --- /dev/null +++ b/application/state/systemSettingsEffects.windowBlur.test.ts @@ -0,0 +1,139 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { JSDOM } from 'jsdom'; + +import { STORAGE_KEY_WINDOW_BACKGROUND_BLUR } from '../../infrastructure/config/storageKeys'; +import { DEFAULT_HTTP_NETWORK_PROXY } from '../../domain/httpNetworkProxy'; +import { useSystemSettingsEffects } from './systemSettingsEffects'; + +type Harness = { + dom: JSDOM; + params: Parameters[0]; + bridgeRequests: boolean[]; + blurNotifications: unknown[]; + restore: () => void; +}; + +function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness { + const dom = new JSDOM('
', { url: 'http://localhost' }); + Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' }); + const previousGlobals = new Map(); + const replacements = { + window: dom.window, + document: dom.window.document, + navigator: dom.window.navigator, + localStorage: dom.window.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const [key, value] of Object.entries(replacements)) { + previousGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); + Object.defineProperty(globalThis, key, { value, configurable: true }); + } + const bridgeRequests: boolean[] = []; + const blurNotifications: unknown[] = []; + Object.assign(dom.window, { + netcatty: { + setWindowBackgroundBlur: async (value: boolean) => { bridgeRequests.push(value); }, + }, + }); + const noop = () => {}; + const params: Parameters[0] = { + enabled, + toggleWindowHotkey: '', + globalHotkeyEnabled: false, + closeToTray: false, + showTrayIcon: false, + autoLaunchEnabled: false, + windowOpacityRecord: { opacity: 0.7, preferredOpacity: 0.7, version: 1 }, + windowOpacityMutationSourceRef: { current: 'local' }, + windowBackgroundBlur, + appIconVariant: 'original', + autoUpdateEnabled: false, + httpNetworkProxy: DEFAULT_HTTP_NETWORK_PROXY, + persistMountedRef: { current: true }, + setHotkeyRegistrationError: noop, + setAutoUpdateEnabled: noop, + setAppIconVariant: noop, + setAutoLaunchEnabled: noop, + setAutoLaunchSupported: noop, + notifySettingsChanged: (key, value) => { + if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) blurNotifications.push(value); + }, + }; + return { + dom, + params, + bridgeRequests, + blurNotifications, + restore: () => { + for (const [key, descriptor] of previousGlobals) { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); + } + dom.window.close(); + }, + }; +} + +const Probe = (props: Parameters[0]) => { + useSystemSettingsEffects(props); + return null; +}; + +test('opacity changes and remounts preserve the chosen blur preference', async () => { + const harness = setUp(true, true); + const { dom, params, bridgeRequests, blurNotifications, restore } = harness; + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { renderer = create(React.createElement(Probe, params)); }); + assert.equal(document.documentElement.classList.contains('window-background-blur'), true); + const opaqueParams = { ...params, windowOpacityRecord: { ...params.windowOpacityRecord, opacity: 1, version: 2 } }; + await act(async () => { renderer!.update(React.createElement(Probe, opaqueParams)); }); + assert.equal(document.documentElement.classList.contains('window-background-blur'), false); + assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true'); + assert.deepEqual(bridgeRequests, [true]); + assert.deepEqual(blurNotifications, [true]); + await act(async () => { renderer!.unmount(); }); + await act(async () => { renderer = create(React.createElement(Probe, opaqueParams)); }); + assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'true'); + await act(async () => { renderer!.update(React.createElement(Probe, params)); }); + assert.equal(document.documentElement.classList.contains('window-background-blur'), true); + await act(async () => { renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); }); + assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), 'false'); + assert.equal(document.documentElement.classList.contains('window-background-blur'), false); + assert.deepEqual(bridgeRequests, [true, true, false]); + } finally { + await act(async () => { renderer?.unmount(); }); + restore(); + } +}); + +test('detached session windows only apply the blur class, never own the setting', async () => { + // Peer session windows run with enableSystemEffects off. They must still + // paint the transparent backing, but persistence, cross-window broadcast and + // the (all-windows) native vibrancy IPC stay with the settings owners. + const harness = setUp(false, true); + const { dom, params, bridgeRequests, blurNotifications, restore } = harness; + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { renderer = create(React.createElement(Probe, params)); }); + assert.equal(document.documentElement.classList.contains('window-background-blur'), true); + assert.equal(document.documentElement.style.getPropertyValue('--window-content-opacity'), '0.7'); + assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null); + assert.deepEqual(bridgeRequests, []); + assert.deepEqual(blurNotifications, []); + + await act(async () => { + renderer!.update(React.createElement(Probe, { ...params, windowBackgroundBlur: false })); + }); + assert.equal(document.documentElement.classList.contains('window-background-blur'), false); + assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_WINDOW_BACKGROUND_BLUR), null); + assert.deepEqual(bridgeRequests, []); + assert.deepEqual(blurNotifications, []); + } finally { + await act(async () => { renderer?.unmount(); }); + restore(); + } +}); diff --git a/application/state/useSettingsState.ts b/application/state/useSettingsState.ts index ab89db572..8d6045a3d 100644 --- a/application/state/useSettingsState.ts +++ b/application/state/useSettingsState.ts @@ -74,6 +74,7 @@ import { STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, STORAGE_KEY_GLOBAL_HOTKEY_ENABLED, STORAGE_KEY_WINDOW_OPACITY, + STORAGE_KEY_WINDOW_BACKGROUND_BLUR, STORAGE_KEY_APP_ICON_VARIANT, STORAGE_KEY_AUTO_UPDATE_ENABLED, STORAGE_KEY_WORKSPACE_FOCUS_STYLE, @@ -645,6 +646,9 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable windowOpacityMutationSourceRef.current = 'local'; setWindowOpacityRecord(toggleWindowOpacityRecord); }, []); + const [windowBackgroundBlur, setWindowBackgroundBlur] = useState(() => { + return readStoredString(STORAGE_KEY_WINDOW_BACKGROUND_BLUR) === 'true'; + }); const [appIconVariant, setAppIconVariantState] = useState(() => { const stored = readStoredString(STORAGE_KEY_APP_ICON_VARIANT); return resolveAppIconVariant(stored ?? DEFAULT_APP_ICON_VARIANT); @@ -1276,6 +1280,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable setIsHotkeyRecordingState, setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, + setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setAutoLaunchEnabled, @@ -1298,12 +1303,18 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable setSshTransportIdleTtlMsState, }); - // Detached sessions share opacity and the shortcut configuration needed to - // operate it. Keep general settings listeners and OS effects disabled there. + // Detached sessions share window chrome (opacity, background blur) and the + // shortcut configuration needed to operate it. Keep general settings + // listeners and OS effects disabled there. useEffect(() => { if (enableSettingsSync) return; const applySharedWindowSetting = (key: string, value: unknown) => { if (key === STORAGE_KEY_WINDOW_OPACITY) applyIncomingWindowOpacity(value); + if (key === STORAGE_KEY_WINDOW_BACKGROUND_BLUR) { + // Storage events deliver the raw string; IPC delivers a boolean. + const incoming = typeof value === 'boolean' ? value : value === 'true'; + setWindowBackgroundBlur(incoming); + } if (key === STORAGE_KEY_HOTKEY_SCHEME && (value === 'disabled' || value === 'mac' || value === 'pc')) { setHotkeyScheme(value); } @@ -1377,7 +1388,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode, showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, restorePreviousSession, restoreTerminalCwd, startupLanding, editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled, - globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant, + globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, windowBackgroundBlur, appIconVariant, setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent, setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage, @@ -1387,7 +1398,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable setSftpUseCompressedUpload, setSftpSkipUnchanged, setSftpAutoOpenSidebar, setSftpFollowTerminalCwd, setSftpDefaultViewMode, setShowRecentHostsState, setHostClickBehaviorState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState, setShowHostTreeSidebarState, setTerminalSidePanelAutoOpenState, setTerminalSidePanelAutoOpenTabState, setLocalShellSidePanelAutoOpenState, setLocalShellSidePanelAutoOpenTabState, setShellOnlyTabNumberShortcutsState, setShowTabNumberBadgesState, setDisableTerminalFontZoomState, setRestorePreviousSessionState, setRestoreTerminalCwdState, setStartupLandingState, setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled, setSshDeepLinkEnabledState: applyIncomingSshDeepLinkEnabled, setJmsDeepLinkEnabledState: applyIncomingJmsDeepLinkEnabled, setExplorerContextMenuEnabledState: applyIncomingExplorerContextMenuEnabled, - setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState, + setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState, setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings, }); @@ -1896,6 +1907,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable autoLaunchEnabled, windowOpacityRecord, windowOpacityMutationSourceRef, + windowBackgroundBlur, appIconVariant, autoUpdateEnabled, httpNetworkProxy, @@ -2276,6 +2288,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable windowOpacity, setWindowOpacity, toggleWindowOpacity, + windowBackgroundBlur, + setWindowBackgroundBlur, appIconVariant, setAppIconVariant, rehydrateAllFromStorage, diff --git a/components/SettingsPage.tsx b/components/SettingsPage.tsx index 5e02e9dc3..64a8544ba 100644 --- a/components/SettingsPage.tsx +++ b/components/SettingsPage.tsx @@ -576,6 +576,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock setShowHostTreeSidebar={settings.setShowHostTreeSidebar} windowOpacity={settings.windowOpacity} setWindowOpacity={settings.setWindowOpacity} + windowBackgroundBlur={settings.windowBackgroundBlur} + setWindowBackgroundBlur={settings.setWindowBackgroundBlur} appIconVariant={settings.appIconVariant} setAppIconVariant={settings.setAppIconVariant} /> diff --git a/components/TextEditorModal.tsx b/components/TextEditorModal.tsx index 14be10921..3a0c0ab37 100644 --- a/components/TextEditorModal.tsx +++ b/components/TextEditorModal.tsx @@ -64,6 +64,8 @@ interface TextEditorModalProps { onClose: () => void; fileName: string; initialContent: string; + /** Full path for language detection; the title still uses fileName. */ + filePath?: string; onSave: (content: string) => Promise; editorWordWrap: boolean; onToggleWordWrap: () => void; @@ -79,6 +81,7 @@ const TextEditorModal: React.FC = ({ open, onClose, fileName, + filePath, initialContent, onSave, editorWordWrap, @@ -94,7 +97,7 @@ const TextEditorModal: React.FC = ({ const [baselineContent, setBaselineContent] = useState(initialContent); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); - const [languageId, setLanguageId] = useState(() => getLanguageId(fileName)); + const [languageId, setLanguageId] = useState(() => getLanguageId(filePath ?? fileName)); const contentRef = useRef(initialContent); const baselineContentRef = useRef(initialContent); const savingRef = useRef(false); @@ -149,13 +152,13 @@ const TextEditorModal: React.FC = ({ setBaselineContent(initialContent); setSaveError(null); setSaving(false); - setLanguageId(getLanguageId(fileName)); + setLanguageId(getLanguageId(filePath ?? fileName)); contentRef.current = initialContent; baselineContentRef.current = initialContent; savingRef.current = false; closePromptRef.current = null; viewStateRef.current = null; - }, [initialContent, fileName]); + }, [initialContent, fileName, filePath]); const saveContent = useCallback(async (contentToSave = contentRef.current): Promise => { return saveCoordinatorRef.current?.save(contentToSave) ?? false; diff --git a/components/editor/TextEditorPane.test.tsx b/components/editor/TextEditorPane.test.tsx index 72c9fda6f..036e315b1 100644 --- a/components/editor/TextEditorPane.test.tsx +++ b/components/editor/TextEditorPane.test.tsx @@ -8,6 +8,7 @@ import { getTextEditorContentStats, isTextEditorCommandWEnabled, isTextEditorReadOnly, + languageIdToMonaco, TextEditorPromoteButton, } from "./TextEditorPane.tsx"; import { TooltipProvider } from "../ui/tooltip.tsx"; @@ -16,6 +17,11 @@ import { DEFAULT_KEY_BINDINGS } from "../../domain/models/keyBindings.ts"; const wrap = (child: React.ReactElement) => React.createElement(TooltipProvider, null, child); +test("maps manual Nginx selection to the registered language", () => { + assert.equal(languageIdToMonaco('nginx'), 'nginx'); + assert.equal(languageIdToMonaco('ini'), 'ini'); +}); + test("disables promoting a modal editor to a tab while a save is running", () => { assert.equal(canPromoteTextEditor({ saving: true }), false); assert.equal(canPromoteTextEditor({ saving: false }), true); diff --git a/components/editor/TextEditorPane.tsx b/components/editor/TextEditorPane.tsx index b36a15b30..5b38eac42 100644 --- a/components/editor/TextEditorPane.tsx +++ b/components/editor/TextEditorPane.tsx @@ -30,6 +30,7 @@ import { useClipboardBackend } from '../../application/state/useClipboardBackend import { isPrimaryModifierWBinding } from '../../application/state/windowCommandClose'; import { HotkeyScheme, KeyBinding, matchesKeyBinding } from '../../domain/models'; import { pasteForMonacoEditorCommand } from '../../infrastructure/monaco/monacoClipboardPaste'; +import { registerNginxLanguage } from '../../infrastructure/monaco/nginxLanguage'; import { useNetcattyMonacoTheme } from '../../infrastructure/monaco/useNetcattyMonacoTheme'; import { getLanguageName, getSupportedLanguages } from '../../lib/sftpFileUtils'; import { Button } from '../ui/button'; @@ -37,7 +38,7 @@ import { Combobox } from '../ui/combobox'; import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip'; // Map our language IDs to Monaco language IDs -const languageIdToMonaco = (langId: string): string => { +export const languageIdToMonaco = (langId: string): string => { const mapping: Record = { 'javascript': 'javascript', 'typescript': 'typescript', @@ -73,6 +74,7 @@ const languageIdToMonaco = (langId: string): string => { 'yaml': 'yaml', 'toml': 'ini', 'ini': 'ini', + 'nginx': 'nginx', 'sql': 'sql', 'graphql': 'graphql', 'markdown': 'markdown', @@ -512,6 +514,7 @@ const TextEditorPaneInner: React.FC = ({ language={monacoLanguage} value={content} onChange={handleEditorChange} + beforeMount={registerNginxLanguage} onMount={handleEditorMount} theme={customThemeName} loading={ diff --git a/components/settings/tabs/SettingsAppearanceTab.tsx b/components/settings/tabs/SettingsAppearanceTab.tsx index 137eacf5d..1b8c293b4 100644 --- a/components/settings/tabs/SettingsAppearanceTab.tsx +++ b/components/settings/tabs/SettingsAppearanceTab.tsx @@ -23,7 +23,7 @@ import { import { resolveAppIconVariant, type AppIconVariant } from "../../../domain/appIconVariant"; import { resolveNoteFontSelectionFamily, resolveNoteFontSelectionId } from "../../../domain/noteFonts"; import { DEFAULT_AUTO_IMPORT_SYSTEM_KNOWN_HOSTS } from "../../../domain/systemKnownHostsAutoImport"; -import { cn } from "../../../lib/utils"; +import { cn, isMacPlatform } from "../../../lib/utils"; import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui"; import { FontSelect } from "../FontSelect"; import { TerminalFontSelect } from "../TerminalFontSelect"; @@ -69,6 +69,8 @@ function SettingsAppearanceTab(props: { setShowHostTreeSidebar: (enabled: boolean) => void; windowOpacity: number; setWindowOpacity: (opacity: number) => void; + windowBackgroundBlur: boolean; + setWindowBackgroundBlur: (enabled: boolean) => void; appIconVariant: AppIconVariant; setAppIconVariant: (variant: AppIconVariant) => void; }) { @@ -140,10 +142,13 @@ function SettingsAppearanceTab(props: { setShowHostTreeSidebar, windowOpacity, setWindowOpacity, + windowBackgroundBlur, + setWindowBackgroundBlur, appIconVariant, setAppIconVariant, } = props; const resolvedAppIconVariant = resolveAppIconVariant(appIconVariant); + const isMac = isMacPlatform(); const WINDOW_OPACITY_PRESETS = [ { label: '100%', value: 1 }, @@ -315,6 +320,19 @@ function SettingsAppearanceTab(props: { + {isMac && ( + + + + )} diff --git a/components/sftp/SftpOverlays.tsx b/components/sftp/SftpOverlays.tsx index 979d70899..f8ff80b3c 100644 --- a/components/sftp/SftpOverlays.tsx +++ b/components/sftp/SftpOverlays.tsx @@ -264,6 +264,7 @@ export const SftpOverlays: React.FC = React.memo(({ open={showTextEditor} onClose={closeTextEditor} fileName={textEditorFileName} + filePath={textEditorTarget?.fullPath} initialContent={textEditorContent} onSave={handleSaveTextFile} editorWordWrap={editorWordWrap} diff --git a/components/sftp/hooks/useSftpViewFileOps.ts b/components/sftp/hooks/useSftpViewFileOps.ts index 0b090d6b4..8c264166d 100644 --- a/components/sftp/hooks/useSftpViewFileOps.ts +++ b/components/sftp/hooks/useSftpViewFileOps.ts @@ -248,7 +248,7 @@ export const useSftpViewFileOps = ({ hostId: target.hostId, remotePath: target.fullPath, fileName: target.file.name, - languageId: snapshot.languageId || getLanguageId(target.file.name), + languageId: snapshot.languageId || getLanguageId(target.fullPath), content: snapshot.content, baselineContent: snapshot.baselineContent, wordWrap: snapshot.wordWrap, @@ -284,7 +284,7 @@ export const useSftpViewFileOps = ({ hostId: target.hostId, remotePath: target.fullPath, fileName: target.file.name, - languageId: snapshot.languageId || getLanguageId(target.file.name), + languageId: snapshot.languageId || getLanguageId(target.fullPath), content: snapshot.content, baselineContent: snapshot.baselineContent, wordWrap: snapshot.wordWrap, diff --git a/components/sftp/nginxEditorLanguage.test.ts b/components/sftp/nginxEditorLanguage.test.ts new file mode 100644 index 000000000..d9c69ad9c --- /dev/null +++ b/components/sftp/nginxEditorLanguage.test.ts @@ -0,0 +1,131 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import ts from 'typescript'; +import type { TextEditorPaneProps } from '../editor/TextEditorPane'; +import type { SftpStateApi } from '../../application/state/useSftpState'; +import { editorTabStore } from '../../application/state/editorTabStore'; +import { activeTabStore } from '../../application/state/activeTabStore'; +import { useSftpViewFileOps } from './hooks/useSftpViewFileOps'; + +// Keep the real modal state/save flow; replace only Monaco and visual chrome. +function loadModal(pane: React.ComponentType) { + const url = new URL('../TextEditorModal.tsx', import.meta.url); + const compiled = ts.transpileModule(readFileSync(url, 'utf8'), { compilerOptions: { + module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022, + jsx: ts.JsxEmit.ReactJSX, esModuleInterop: true, + } }).outputText; + const require = createRequire(url); + const passthrough = ({ children }: { children: React.ReactNode }) => children; + const replacements: Record = { + './editor/TextEditorPane': { TextEditorPane: pane }, + './ui/dialog': { Dialog: passthrough, DialogContent: passthrough, DialogTitle: passthrough }, + './ui/toast': { toast: { success() {}, error() {} } }, + '../application/state/windowInputFocus': { scheduleWindowInputFocus() {} }, + }; + const module = { exports: {} as { default: React.ComponentType> } }; + new Function('require', 'module', 'exports', compiled)( + (id: string) => replacements[id] ?? require(id), module, module.exports, + ); + return module.exports.default; +} + +test('SFTP modal uses full paths, preserves manual selection and saves exact content', async () => { + const globals = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }; + const previousAct = globals.IS_REACT_ACT_ENVIRONMENT; + const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window'); + const previousActiveTab = activeTabStore.getActiveTabId(); + const detached: Array<{ languageId: string; content: string; remotePath: string }> = []; + Object.defineProperty(globalThis, 'window', { configurable: true, value: { + setTimeout, clearTimeout, + netcatty: { openEditorWindow: async (snapshot: typeof detached[number]) => { + detached.push(snapshot); + return { success: true }; + } }, + } }); + globals.IS_REACT_ACT_ENVIRONMENT = true; + let paneProps: TextEditorPaneProps | undefined; + const Modal = loadModal((props) => { paneProps = props; return null; }); + let ops: ReturnType | undefined; + const original = '# fixture\r\nserver { proxy_pass http://$host; }\r\n'; + const writes: unknown[][] = []; + const connection = { id: 'nginx-fixture', hostId: 'nginx-host', currentPath: '/etc/nginx/conf.d' }; + const sftpRef = { current: { + leftPane: { id: 'nginx-pane', connection }, rightPane: { id: 'nginx-pane', connection }, + joinPath: (parent: string, name: string) => `${parent}/${name}`, + readTextFile: async () => original, + writeTextFile: async (...args: unknown[]) => { writes.push(args); }, + } as unknown as SftpStateApi }; + function Probe() { + ops = useSftpViewFileOps({ + sftpRef, behaviorRef: { current: '' }, autoSyncRef: { current: false }, + getOpenerForFileRef: { current: () => null }, setOpenerForExtension() {}, t: (key) => key, + }); + return ops.showTextEditor ? React.createElement(Modal, { + open: true, onClose() {}, fileName: ops.textEditorTarget!.file.name, + filePath: ops.textEditorTarget!.fullPath, initialContent: ops.textEditorContent, + onSave: ops.handleSaveTextFile, editorWordWrap: false, onToggleWordWrap() {}, + hotkeyScheme: 'pc', keyBindings: [], onPromoteToTab: ops.onPromoteToTab, + }) : null; + } + let renderer: ReactTestRenderer | undefined; + try { + await act(async () => { renderer = create(React.createElement(Probe)); }); + const file = { name: 'site.conf', size: original.length, type: 'file' as const, lastModified: 0, sizeFormatted: '', lastModifiedFormatted: '' }; + await act(async () => { await ops!.onEditFileLeft(file); }); + assert.equal(paneProps!.languageId, 'nginx'); + assert.equal(paneProps!.fileName, 'site.conf'); + // Keep the modal mounted: basename and content stay identical, only path changes. + await act(async () => { await ops!.onEditFileLeft(file, '/etc/other/site.conf'); }); + assert.equal(paneProps!.languageId, 'ini'); + await act(async () => { await ops!.onEditFileLeft(file, '/etc/nginx/conf.d/site.conf'); }); + assert.equal(paneProps!.languageId, 'nginx'); + await act(async () => { paneProps!.onLanguageChange('ini'); }); + await act(async () => { paneProps!.onSave(); }); + assert.deepEqual(writes, [['left', '/etc/nginx/conf.d/site.conf', original]]); + await act(async () => { paneProps!.onPromoteToTab!(); }); + let tab = editorTabStore.getTabs().find(({ sessionId }) => sessionId === connection.id); + assert.equal(tab?.languageId, 'ini', 'promotion preserves a manual override'); + assert.equal(tab?.content, original); + editorTabStore.close(tab!.id); + + // A generic config can still be switched to Nginx manually. + await act(async () => { await ops!.onEditFileLeft(file, '/etc/other/site.conf'); }); + assert.equal(paneProps!.languageId, 'ini'); + await act(async () => { paneProps!.onLanguageChange('nginx'); }); + assert.equal(paneProps!.languageId, 'nginx', 'arbitrary config supports manual Nginx selection'); + await act(async () => { paneProps!.onPromoteToTab!(); }); + tab = editorTabStore.getTabs().find(({ sessionId }) => sessionId === connection.id); + assert.equal(tab?.languageId, 'nginx'); + + // Legacy snapshots without language still infer from the full target path. + await act(async () => { await ops!.onEditFileLeft(file, '/etc/nginx/conf.d/fallback.conf'); }); + await act(async () => { ops!.onPromoteToTab({ fileName: file.name, content: original, + baselineContent: original, languageId: '', wordWrap: false, viewState: null }); }); + assert.equal(editorTabStore.getTabs().find(({ remotePath }) => remotePath.endsWith('/fallback.conf'))?.languageId, 'nginx'); + await act(async () => { await ops!.onEditFileLeft(file, '/etc/nginx/sites-enabled/example.com'); }); + await act(async () => { ops!.onPopOut({ fileName: file.name, content: original, + baselineContent: original, languageId: '', wordWrap: false, viewState: null }); }); + assert.equal(detached.at(-1)?.languageId, 'nginx', 'pop-out fallback also uses the full path'); + assert.equal(detached.at(-1)?.remotePath, '/etc/nginx/sites-enabled/example.com'); + assert.equal(detached.at(-1)?.content, original); + } finally { + await act(async () => { renderer?.unmount(); }); + for (const tab of editorTabStore.getTabs().filter(({ sessionId }) => sessionId === connection.id)) editorTabStore.close(tab.id); + activeTabStore.setActiveTabId(previousActiveTab); + await new Promise((resolve) => setTimeout(resolve, 0)); + globals.IS_REACT_ACT_ENVIRONMENT = previousAct; + if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow); + else Reflect.deleteProperty(globalThis, 'window'); + } +}); + +test('the SFTP overlay forwards path context to the lazy modal', () => { + const overlay = readFileSync(new URL('./SftpOverlays.tsx', import.meta.url), 'utf8'); + assert.match(overlay, / = { @@ -152,3 +153,37 @@ test("Sync status entries retain syncSubTab for navigation", () => { assert.ok(backups); assert.equal(backups?.entry.syncSubTab, "status"); }); + +test("macOS-only settings stay out of search on other platforms", () => { + const blurId = "appearance-window-background-blur"; + + // Both a targeted query and the default (empty query) list must skip it, + // otherwise selecting the hit jumps to a tab with no anchor to focus. + const macHits = filterSettingsSearchCatalog("", tEn, { platform: "mac", limit: 500 }); + assert.ok(macHits.some((hit) => hit.entry.id === blurId)); + + for (const platform of ["win", "linux"] as const) { + const searched = filterSettingsSearchCatalog("frosted glass", tEn, { platform }); + assert.equal(searched.some((hit) => hit.entry.id === blurId), false); + + const defaults = filterSettingsSearchCatalog("", tEn, { platform, limit: 500 }); + assert.equal(defaults.some((hit) => hit.entry.id === blurId), false); + } +}); + +test("platform-gated entries never hide cross-platform settings", () => { + for (const platform of ["mac", "win", "linux"] as const) { + const hits = filterSettingsSearchCatalog("opacity", tEn, { platform }); + assert.ok( + hits.some((hit) => hit.entry.id === "appearance-window-opacity"), + `window opacity must stay searchable on ${platform}`, + ); + } +}); + +test("resolveSettingsPlatform maps Electron-style platform strings", () => { + assert.equal(resolveSettingsPlatform("MacIntel"), "mac"); + assert.equal(resolveSettingsPlatform("Win32"), "win"); + assert.equal(resolveSettingsPlatform("Linux x86_64"), "linux"); + assert.equal(resolveSettingsPlatform(""), "linux"); +}); diff --git a/domain/settingsSearch.ts b/domain/settingsSearch.ts index 1c5ec9c2d..9db8c91fb 100644 --- a/domain/settingsSearch.ts +++ b/domain/settingsSearch.ts @@ -1,6 +1,9 @@ import { matchesSearchQuery } from "../lib/searchMatcher"; import { SETTINGS_SEARCH_CATALOG, + isSettingsEntryAvailableOn, + resolveSettingsPlatform, + type SettingsPlatform, type SettingsSearchEntry, type SettingsTabId, } from "./settingsSearchCatalog"; @@ -61,16 +64,22 @@ export function filterSettingsSearchCatalog( catalog?: readonly SettingsSearchEntry[]; includePlugins?: boolean; limit?: number; + /** Platform whose settings UI will receive the jump; defaults to the host. */ + platform?: SettingsPlatform; }, ): SettingsSearchHit[] { const catalog = options?.catalog ?? SETTINGS_SEARCH_CATALOG; const includePlugins = options?.includePlugins ?? true; const limit = options?.limit ?? 40; + const platform = options?.platform ?? resolveSettingsPlatform(); const trimmed = query.trim(); const hits: SettingsSearchHit[] = []; for (const entry of catalog) { if (!includePlugins && entry.tab === "plugins") continue; + // Platform-gated settings are hidden from search where their anchor is not + // rendered, so a hit always has a real control to focus. + if (!isSettingsEntryAvailableOn(entry, platform)) continue; const hit = buildSettingsSearchHit(entry, t); if (!trimmed) { hits.push(hit); diff --git a/domain/settingsSearchCatalog.ts b/domain/settingsSearchCatalog.ts index ae5fa4a32..805c6152c 100644 --- a/domain/settingsSearchCatalog.ts +++ b/domain/settingsSearchCatalog.ts @@ -31,6 +31,8 @@ export const SYNC_SETTINGS_SUB_TAB_IDS = ["providers", "status"] as const; export type SyncSettingsSubTabId = (typeof SYNC_SETTINGS_SUB_TAB_IDS)[number]; +export type SettingsPlatform = "mac" | "win" | "linux"; + export type SettingsSearchEntry = { /** Stable id; also used as `data-settings-anchor` on the target element. */ id: string; @@ -47,8 +49,33 @@ export type SettingsSearchEntry = { sectionKey?: string; /** Extra English/Chinese aliases that are not full i18n strings. */ keywords?: readonly string[]; + /** + * Platforms where the destination actually renders. Omit for cross-platform + * entries. Entries must not be offered on a platform whose settings UI hides + * the anchor, otherwise search jumps to a tab with nothing to highlight. + */ + platforms?: readonly SettingsPlatform[]; }; +/** Resolve the host platform in the same terms the settings UI branches on. */ +export function resolveSettingsPlatform( + platform?: string, +): SettingsPlatform { + const value = platform + ?? (typeof navigator !== "undefined" ? navigator.platform : ""); + if (/mac|iphone|ipad/i.test(value)) return "mac"; + if (/win/i.test(value)) return "win"; + return "linux"; +} + +export function isSettingsEntryAvailableOn( + entry: SettingsSearchEntry, + platform: SettingsPlatform, +): boolean { + if (!entry.platforms?.length) return true; + return entry.platforms.includes(platform); +} + export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [ // Application { @@ -108,6 +135,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [ descriptionKey: "settings.appearance.windowOpacity.desc", keywords: ["transparency", "透明"], }, + { + id: "appearance-window-background-blur", + tab: "appearance", + labelKey: "settings.appearance.windowBackgroundBlur", + descriptionKey: "settings.appearance.windowBackgroundBlur.desc", + keywords: ["blur", "frosted glass", "vibrancy", "模糊", "毛玻璃"], + platforms: ["mac"], + }, { id: "appearance-theme", tab: "appearance", diff --git a/domain/windowBackgroundBlur.test.ts b/domain/windowBackgroundBlur.test.ts new file mode 100644 index 000000000..b56412f99 --- /dev/null +++ b/domain/windowBackgroundBlur.test.ts @@ -0,0 +1,19 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + isWindowBackgroundBlurActive, +} from './windowBackgroundBlur'; + +test('window background blur is visible only with a translucent surface', () => { + assert.equal(isWindowBackgroundBlurActive(true, 1), false); + assert.equal(isWindowBackgroundBlurActive(true, 0.99), true); + assert.equal(isWindowBackgroundBlurActive(true, 0.5), true); + assert.equal(isWindowBackgroundBlurActive(true, Number.NaN), false); +}); + +test('window background blur becomes inactive without losing the request state', () => { + assert.equal(isWindowBackgroundBlurActive(true, 0.85), true); + assert.equal(isWindowBackgroundBlurActive(true, 1), false); + assert.equal(isWindowBackgroundBlurActive(false, 0.85), false); +}); diff --git a/domain/windowBackgroundBlur.ts b/domain/windowBackgroundBlur.ts new file mode 100644 index 000000000..fe23f11ba --- /dev/null +++ b/domain/windowBackgroundBlur.ts @@ -0,0 +1,6 @@ +export function isWindowBackgroundBlurActive( + requested: boolean, + windowOpacity: number, +): boolean { + return requested && Number.isFinite(windowOpacity) && windowOpacity < 1; +} diff --git a/electron/bridges/windowManager.cjs b/electron/bridges/windowManager.cjs index 054a4e177..c2b79d30a 100644 --- a/electron/bridges/windowManager.cjs +++ b/electron/bridges/windowManager.cjs @@ -39,6 +39,10 @@ let settingsWindow = null; let currentTheme = "light"; let currentLanguage = "en"; let currentWindowOpacity = 1; +let currentWindowBackgroundBlurRequested = false; +let currentWindowBackgroundColor = null; +/** Windows this module switched to a transparent backing, so restore is safe. */ +const transparentChromeWindows = new WeakSet(); let cachedNativeTheme = null; let handlersRegistered = false; // Prevent duplicate IPC handler registration @@ -363,6 +367,7 @@ function registerAppContentWindow(win, options = {}) { if (!win || win.isDestroyed?.()) return; appContentWindows.add(win); if (options.queryDirtyEditors === true) dirtyEditorWindows.add(win); + applyWindowBackgroundChromeToWindow(win, { applyBackgroundColor: true }); } function unregisterAppContentWindow(win) { @@ -412,21 +417,84 @@ function clampWindowOpacity(opacity) { function applyWindowOpacityToWindow(win) { if (!win || win.isDestroyed?.()) return; try { - win.setOpacity?.(currentWindowOpacity); + win.setOpacity?.(isWindowBackgroundBlurActive() ? 1 : currentWindowOpacity); } catch { // ignore } + applyWindowBackgroundChromeToWindow(win, { applyBackgroundColor: true }); } function applyWindowOpacity(opacity) { currentWindowOpacity = clampWindowOpacity(opacity); - for (const win of getAppContentWindowList()) applyWindowOpacityToWindow(win); + for (const win of getAppContentWindowList()) { + applyWindowOpacityToWindow(win); + } if (settingsWindow && !settingsWindow.isDestroyed()) { applyWindowOpacityToWindow(settingsWindow); } return currentWindowOpacity; } +function isWindowBackgroundBlurActive() { + return process.platform === "darwin" + && currentWindowBackgroundBlurRequested + && currentWindowOpacity < 1; +} + +function applyWindowBackgroundChromeToWindow(win, options = {}) { + if (!win || win.isDestroyed?.()) return; + try { + if (isWindowBackgroundBlurActive()) { + win.setVibrancy?.("hud"); + win.setBackgroundColor?.("#00000000"); + transparentChromeWindows.add(win); + return; + } + if (process.platform === "darwin") win.setVibrancy?.(null); + if (options.applyBackgroundColor !== true) return; + // An explicit theme/background-color push must always repaint, otherwise + // the native backing goes stale after theme changes. Registration-time + // chrome setup is different: it must not clobber the color the window was + // created with before any theme color has been pushed. + const wasTransparent = transparentChromeWindows.delete(win); + if (options.forceBackgroundColor === true || wasTransparent) { + // A window can be asked to drop vibrancy before any theme color has been + // pushed (cold start with blur already saved). Falling back to the active + // theme keeps the window from staying transparent. + const restoreColor = currentWindowBackgroundColor + || THEME_COLORS[currentTheme]?.background; + if (restoreColor) win.setBackgroundColor?.(restoreColor); + } + } catch { + // ignore platform-specific chrome failures + } +} + +function applyWindowBackgroundColor(color) { + const normalized = normalizeBackgroundColor(color); + if (!normalized) return false; + currentWindowBackgroundColor = normalized; + for (const win of getAppContentWindowList()) { + applyWindowBackgroundChromeToWindow(win, { applyBackgroundColor: true, forceBackgroundColor: true }); + } + if (settingsWindow && !settingsWindow.isDestroyed()) { + applyWindowBackgroundChromeToWindow(settingsWindow, { applyBackgroundColor: true, forceBackgroundColor: true }); + } + return true; +} + +function applyWindowBackgroundBlur(enabled) { + const supported = process.platform === "darwin"; + currentWindowBackgroundBlurRequested = supported && enabled === true; + for (const win of getAppContentWindowList()) { + applyWindowOpacityToWindow(win); + } + if (settingsWindow && !settingsWindow.isDestroyed()) { + applyWindowOpacityToWindow(settingsWindow); + } + return supported; +} + function getMainWindowCount() { return getMainWindowList().length; } @@ -998,7 +1066,12 @@ const { createWindow } = mainWindowApi; const { createSettingsWindowApi } = require("./windowManager/settingsWindow.cjs"); const settingsWindowApi = createSettingsWindowApi({ get settingsWindow() { return settingsWindow; }, - set settingsWindow(value) { settingsWindow = value; }, + set settingsWindow(value) { + settingsWindow = value; + if (value) { + applyWindowBackgroundChromeToWindow(value, { applyBackgroundColor: true }); + } + }, get mainWindow() { return mainWindow; }, get currentTheme() { return currentTheme; }, get isQuitting() { return isQuitting; }, @@ -1187,21 +1260,12 @@ function registerWindowHandlers(ipcMain, nativeTheme) { ? (nativeTheme?.shouldUseDarkColors ? "dark" : "light") : theme; const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light; - forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background)); - if (settingsWindow && !settingsWindow.isDestroyed()) { - settingsWindow.setBackgroundColor(themeConfig.background); - } + applyWindowBackgroundColor(themeConfig.background); return true; }); ipcMain.handle("netcatty:setBackgroundColor", (_event, color) => { - const normalized = normalizeBackgroundColor(color); - if (!normalized) return false; - forEachMainWindow((win) => win.setBackgroundColor(normalized)); - if (settingsWindow && !settingsWindow.isDestroyed()) { - settingsWindow.setBackgroundColor(normalized); - } - return true; + return applyWindowBackgroundColor(color); }); ipcMain.handle("netcatty:setWindowOpacity", (_event, opacity) => { @@ -1209,6 +1273,10 @@ function registerWindowHandlers(ipcMain, nativeTheme) { return true; }); + ipcMain.handle("netcatty:setWindowBackgroundBlur", (_event, enabled) => { + return applyWindowBackgroundBlur(enabled); + }); + ipcMain.handle("netcatty:setAppIconVariant", (_event, variant) => { const { app, BrowserWindow, nativeImage } = require("electron"); const appIconManager = require("./appIconManager.cjs"); @@ -1290,6 +1358,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) { try { const sharedWindowSetting = [ "netcatty_window_opacity_v1", + "netcatty_window_background_blur_v1", "netcatty_hotkey_scheme_v1", "netcatty_custom_key_bindings_v1", "netcatty_hotkey_recording_v1", diff --git a/electron/bridges/windowManager/mainWindow.cjs b/electron/bridges/windowManager/mainWindow.cjs index c02944ca5..7d27c4390 100644 --- a/electron/bridges/windowManager/mainWindow.cjs +++ b/electron/bridges/windowManager/mainWindow.cjs @@ -263,11 +263,13 @@ function createMainWindowApi(ctx) { minWidth: windowMinimumSize.width, minHeight: windowMinimumSize.height, backgroundColor, + transparent: isMac, icon: appIcon, show: false, frame: isMac, titleBarStyle: isMac ? "hiddenInset" : undefined, trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined, + ...(isMac ? { visualEffectState: "active" } : {}), ...windowsChrome, webPreferences: { preload, diff --git a/electron/bridges/windowManager/settingsWindow.cjs b/electron/bridges/windowManager/settingsWindow.cjs index 1baf73936..daf1e4456 100644 --- a/electron/bridges/windowManager/settingsWindow.cjs +++ b/electron/bridges/windowManager/settingsWindow.cjs @@ -139,6 +139,7 @@ function createSettingsWindowApi(ctx) { minWidth: 820, minHeight: 600, backgroundColor, + transparent: isMac, icon: appIcon, fullscreenable: !isMac, // NOTE: Do NOT set parent - on macOS this causes rendering issues when dragging @@ -150,6 +151,7 @@ function createSettingsWindowApi(ctx) { frame: isMac, titleBarStyle: isMac ? "hiddenInset" : undefined, trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined, + ...(isMac ? { visualEffectState: "active" } : {}), ...windowsChrome, webPreferences: { preload, diff --git a/electron/bridges/windowManager/windowBackgroundBlur.test.cjs b/electron/bridges/windowManager/windowBackgroundBlur.test.cjs new file mode 100644 index 000000000..3311a1ba6 --- /dev/null +++ b/electron/bridges/windowManager/windowBackgroundBlur.test.cjs @@ -0,0 +1,111 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { createRequire } = require('node:module'); +const vm = require('node:vm'); +const test = require('node:test'); + +function loadWindowManager(platform) { + const filename = path.resolve(__dirname, '../windowManager.cjs'); + const loadedModule = { exports: {} }; + const context = vm.createContext({ + process: { ...process, platform }, + console, + URL, + setTimeout, + clearTimeout, + }); + const load = vm.runInContext( + `(function(exports, require, module, __filename, __dirname) { ${fs.readFileSync(filename, 'utf8')}\n})`, + context, + ); + load(loadedModule.exports, createRequire(filename), loadedModule, filename, path.dirname(filename)); + const handlers = new Map(); + loadedModule.exports.registerWindowHandlers({ + handle: (channel, handler) => handlers.set(channel, handler), + on: (channel, handler) => handlers.set(channel, handler), + }, {}); + const state = { opacity: 1, vibrancy: null, background: '#123456' }; + const win = { + isDestroyed: () => false, + setOpacity: (value) => { state.opacity = value; }, + setVibrancy: (value) => { state.vibrancy = value; }, + setBackgroundColor: (value) => { state.background = value; }, + webContents: { id: 1, isDestroyed: () => false }, + }; + loadedModule.exports.registerMainWindow(win); + const invoke = (name, value) => handlers.get(`netcatty:${name}`)({}, value); + return { invoke, state }; +} + +test('macOS blur keeps native opacity at one and restores normal opacity on disable', () => { + const { invoke, state } = loadWindowManager('darwin'); + assert.equal(state.background, '#123456'); + invoke('setBackgroundColor', '#123456'); + invoke('setWindowBackgroundBlur', true); + assert.equal(state.vibrancy, null); + invoke('setWindowOpacity', 0.7); + assert.equal(state.opacity, 1); + assert.equal(state.vibrancy, 'hud'); + assert.equal(state.background, '#00000000'); + invoke('setWindowOpacity', 0.85); + assert.equal(state.opacity, 1); + invoke('setWindowBackgroundBlur', false); + assert.equal(state.opacity, 0.85); + assert.equal(state.vibrancy, null); + assert.equal(state.background, '#123456'); +}); + +test('restoring 100% removes native vibrancy and restores the theme background', () => { + const { invoke, state } = loadWindowManager('darwin'); + invoke('setWindowOpacity', 0.7); + invoke('setWindowBackgroundBlur', true); + invoke('setBackgroundColor', '#abcdef'); + assert.equal(state.background, '#00000000'); + invoke('setWindowOpacity', 1); + assert.equal(state.opacity, 1); + assert.equal(state.vibrancy, null); + assert.equal(state.background, '#abcdef'); + invoke('setWindowOpacity', 0.7); + assert.equal(state.opacity, 1); + assert.equal(state.vibrancy, 'hud'); +}); + +test('unsupported platforms retain the existing opacity behavior', () => { + for (const platform of ['win32', 'linux']) { + const { invoke, state } = loadWindowManager(platform); + invoke('setWindowOpacity', 0.7); + assert.equal(invoke('setWindowBackgroundBlur', true), false); + assert.equal(state.opacity, 0.7); + assert.equal(state.vibrancy, null); + } +}); + +test('disabling blur before any theme color push restores the current theme background', () => { + const { invoke, state } = loadWindowManager('darwin'); + // No setBackgroundColor / setTheme call yet: currentWindowBackgroundColor is + // still null, so the fallback must come from the active theme. + invoke('setWindowOpacity', 0.7); + invoke('setWindowBackgroundBlur', true); + assert.equal(state.background, '#00000000'); + invoke('setWindowBackgroundBlur', false); + assert.equal(state.vibrancy, null); + assert.notEqual(state.background, '#00000000'); + assert.match(String(state.background), /^#[0-9a-fA-F]{6}$/); +}); + +test('explicit theme and background-color pushes repaint windows that never used blur', () => { + for (const platform of ['darwin', 'win32', 'linux']) { + const { invoke, state } = loadWindowManager(platform); + assert.equal(state.background, '#123456'); + // Registration-time chrome setup must leave the creation-time color alone... + invoke('setWindowBackgroundBlur', false); + assert.equal(state.background, '#123456'); + // ...but an explicit theme color must always land. + invoke('setBackgroundColor', '#abcdef'); + assert.equal(state.background, '#abcdef'); + invoke('setTheme', 'dark'); + assert.match(String(state.background), /^#[0-9a-fA-F]{6}$/); + assert.notEqual(state.background, '#abcdef'); + } +}); diff --git a/electron/mcp/externalMcpReconnect.test.cjs b/electron/mcp/externalMcpReconnect.test.cjs index 06cf9fce7..11c732d66 100644 --- a/electron/mcp/externalMcpReconnect.test.cjs +++ b/electron/mcp/externalMcpReconnect.test.cjs @@ -37,7 +37,12 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) { sockets.add(socket); socket.on("close", () => sockets.delete(socket)); let buffer = ""; + let authenticated = false; socket.setEncoding("utf8"); + const writeLine = (payload) => { + if (socket.destroyed) return; + socket.write(`${JSON.stringify(payload)}\n`); + }; socket.on("data", (chunk) => { buffer += chunk; let newlineIdx; @@ -52,17 +57,29 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) { token: msg.params?.token, }); const respond = () => { + if (socket.destroyed) return; if (msg.method === "auth/verify") { if (msg.params?.token !== token) { - socket.write(`${JSON.stringify({ + writeLine({ jsonrpc: "2.0", id: msg.id, error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." }, - })}\n`); + }); socket.destroy(); return; } - socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } })}\n`); + authenticated = true; + writeLine({ jsonrpc: "2.0", id: msg.id, result: { ok: true } }); + return; + } + // Match the app bridge: a call before auth/verify closes the socket. + if (!authenticated) { + writeLine({ + jsonrpc: "2.0", + id: msg.id, + error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." }, + }); + socket.destroy(); return; } if (msg.method === "netcatty/getContext" && onGetContext) { @@ -72,11 +89,11 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) { return; } } - socket.write(`${JSON.stringify({ + writeLine({ jsonrpc: "2.0", id: msg.id, result: { ok: true, marker: token }, - })}\n`); + }); }; if (msg.method === "auth/verify" && authDelayMs > 0) { setTimeout(respond, authDelayMs); diff --git a/electron/mcp/netcatty-mcp-server.cjs b/electron/mcp/netcatty-mcp-server.cjs index 6c151df86..b5f288d10 100644 --- a/electron/mcp/netcatty-mcp-server.cjs +++ b/electron/mcp/netcatty-mcp-server.cjs @@ -122,19 +122,33 @@ function guardWriteOperation(command, { skipBlocklist = false } = {}) { } let tcpSocket = null; -let pendingRequests = new Map(); // id -> { resolve, reject } +let pendingRequests = new Map(); // id -> { resolve, reject, sock } let nextRpcId = 1; let tcpBuffer = ""; +// `destroyed` stays false after the peer FIN has already moved readyState to +// "closed". Writing in that window hits writeAfterFIN and rejects the call +// with "TCP connection lost" instead of reconnecting. +function isLiveSocket(sock) { + return Boolean( + sock + && !sock.destroyed + && sock.readyState === "open" + && sock.writable + && !sock.readableEnded + && !sock.writableEnded + ); +} + function rejectPendingOnSocket(sock, externalMessage) { - if (tcpSocket && tcpSocket !== sock) return; const managed = !externalDiscoveryPath; if (managed && tcpSocket === sock) tcpSocket = null; const message = managed ? "Not connected to Netcatty" : externalMessage; - for (const { reject: rej } of pendingRequests.values()) { - rej(new Error(message)); + for (const [id, pending] of pendingRequests) { + if (pending.sock !== sock) continue; + pendingRequests.delete(id); + pending.reject(new Error(message)); } - pendingRequests.clear(); } function connectTcp() { @@ -198,15 +212,16 @@ function connectTcp() { function sendRpc(method, params) { return new Promise((resolve, reject) => { - if (!tcpSocket || tcpSocket.destroyed) { + if (!isLiveSocket(tcpSocket)) { reject(new Error("Not connected to Netcatty")); return; } + const sock = tcpSocket; const id = nextRpcId++; - pendingRequests.set(id, { resolve, reject }); + pendingRequests.set(id, { resolve, reject, sock }); const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n"; debugLog("rpcCall", { id, method }); - tcpSocket.write(msg); + sock.write(msg); }); } @@ -246,7 +261,12 @@ function dropActiveSocket() { let connecting = null; async function connectAndAuth() { - if (tcpSocket?.destroyed) tcpSocket = null; + // Drop the reference only. In-flight requests stay bound to the old socket + // and are rejected when that socket closes; they are not replayed. + if (!isLiveSocket(tcpSocket)) { + tcpSocket = null; + tcpBuffer = ""; + } refreshExternalEndpoint(); tcpBuffer = ""; try { @@ -263,8 +283,11 @@ async function connectAndAuth() { } function ensureConnected() { - if (tcpSocket && !tcpSocket.destroyed) return Promise.resolve(); + // Join an in-flight reconnect before trusting tcpSocket. connectTcp assigns + // the socket before auth/verify returns; a second caller must wait for that + // re-auth instead of sending on an unauthenticated socket. if (connecting) return connecting; + if (isLiveSocket(tcpSocket)) return Promise.resolve(); connecting = connectAndAuth().finally(() => { connecting = null; }); diff --git a/electron/preload/api.cjs b/electron/preload/api.cjs index 947a14abb..866dde9c9 100644 --- a/electron/preload/api.cjs +++ b/electron/preload/api.cjs @@ -1149,6 +1149,9 @@ function createPreloadApi(ctx) { setWindowOpacity: async (opacity) => { return ipcRenderer.invoke("netcatty:setWindowOpacity", opacity); }, + setWindowBackgroundBlur: async (enabled) => { + return ipcRenderer.invoke("netcatty:setWindowBackgroundBlur", enabled); + }, setAppIconVariant: async (variant) => { return ipcRenderer.invoke("netcatty:setAppIconVariant", variant); }, diff --git a/index.css b/index.css index 5cdbd8322..ba491e89f 100644 --- a/index.css +++ b/index.css @@ -2245,3 +2245,12 @@ html[data-theme-transition] *::after { background-color: hsl(var(--background)) !important; border-color: hsl(var(--border)) !important; } + +html.window-background-blur, +html.window-background-blur body { + background: transparent !important; +} + +html.window-background-blur #root { + opacity: var(--window-content-opacity, 1); +} diff --git a/infrastructure/config/storageKeys.ts b/infrastructure/config/storageKeys.ts index b0da22c78..4d66433a2 100644 --- a/infrastructure/config/storageKeys.ts +++ b/infrastructure/config/storageKeys.ts @@ -199,6 +199,7 @@ export const STORAGE_KEY_HTTP_NETWORK_PROXY = 'netcatty_http_network_proxy_v1'; export const STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE = 'netcatty_global_default_proxy_profile_v1'; export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1'; export const STORAGE_KEY_WINDOW_OPACITY = 'netcatty_window_opacity_v1'; +export const STORAGE_KEY_WINDOW_BACKGROUND_BLUR = 'netcatty_window_background_blur_v1'; export const STORAGE_KEY_APP_ICON_VARIANT = 'netcatty_app_icon_variant_v1'; // Custom Terminal Themes export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1'; diff --git a/infrastructure/monaco/nginxLanguage.test.ts b/infrastructure/monaco/nginxLanguage.test.ts new file mode 100644 index 000000000..d064a6a49 --- /dev/null +++ b/infrastructure/monaco/nginxLanguage.test.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; +import test from 'node:test'; +import type { Monaco } from '@monaco-editor/react'; +import { nginxLanguage, nginxLanguageConfiguration, registerNginxLanguage } from './nginxLanguage'; + +const require = createRequire(import.meta.url); +const { compile } = require('monaco-editor/esm/vs/editor/standalone/common/monarch/monarchCompile.js'); +const { MonarchTokenizer } = require('monaco-editor/esm/vs/editor/standalone/common/monarch/monarchLexer.js'); + +// Exercise Monaco's real Monarch engine rather than approximating its regex/state rules. +function tokenize(content: string) { + const tokenizer = new MonarchTokenizer({}, {}, 'nginx', compile('nginx', nginxLanguage), { + getValue: () => 20000, + onDidChangeConfiguration: () => ({ dispose() {} }), + }); + let state = tokenizer.getInitialState(); + try { + return content.split('\n').map((line) => { + const result = tokenizer.tokenize(line, true, state); + state = result.endState; + return result.tokens.map((token: { offset: number; type: string }, index: number) => ({ + text: line.slice(token.offset, result.tokens[index + 1]?.offset ?? line.length), + type: token.type, + })); + }); + } finally { + tokenizer.dispose(); + } +} + +test('highlights directives, block structure, comments, strings and variables', () => { + const tokens = tokenize(`upstream backend { server 127.0.0.1:8080; } +server { + # location ignored; + location / { proxy_pass http://backend/$uri; } + set $message "hello \${host} #still string"; + add_header X-Value 'literal; { # text }'; +}`).flat(); + for (const directive of ['upstream', 'server', 'location', 'proxy_pass', 'set', 'add_header']) { + assert.ok(tokens.some(({ text, type }) => text === directive && type === 'keyword.nginx'), directive); + } + for (const variable of ['$uri', '$message', '${host}']) { + assert.ok(tokens.some(({ text, type }) => text === variable && type === 'variable.nginx'), variable); + } + assert.ok(tokens.some(({ text, type }) => text === '# location ignored;' && type === 'comment.nginx')); + assert.ok(tokens.some(({ text, type }) => text.includes('#still string') && type === 'string.nginx')); + assert.ok(tokens.some(({ text, type }) => text.includes('literal; { # text }') && type === 'string.nginx')); + assert.ok(tokens.some(({ text, type }) => text === '{' && type === 'delimiter.curly.nginx')); + assert.ok(tokens.some(({ text, type }) => text === ';' && type === 'delimiter.nginx')); +}); + +test('retains argument and quote states across lines and respects escapes', () => { + const lines = tokenize(String.raw`proxy_set_header + Host $host; custom_module_directive on; +set $message "first + second \" #literal"; # comment +set $escaped value\#literal;`); + assert.ok(lines[1].some(({ text, type }) => text === 'custom_module_directive' && type === 'keyword.nginx')); + assert.ok(lines[1].every(({ text, type }) => text !== 'Host' || type !== 'keyword.nginx')); + assert.ok(lines[3].some(({ text, type }) => text.includes('#literal') && type === 'string.nginx')); + assert.ok(lines[3].some(({ text, type }) => text === '# comment' && type === 'comment.nginx')); + assert.ok(lines[4].some(({ text, type }) => text === '\\#' && type === 'string.escape.nginx')); +}); + +test('registers once per Monaco instance and exposes only highlighting/configuration', () => { + const calls: string[] = []; + const createMonaco = () => ({ languages: { + register: ({ id }: { id: string }) => calls.push(id), + setMonarchTokensProvider: (id: string, grammar: unknown) => { + assert.equal(grammar, nginxLanguage); + calls.push(`${id}:tokens`); + }, + setLanguageConfiguration: (id: string, configuration: unknown) => { + assert.equal(configuration, nginxLanguageConfiguration); + calls.push(`${id}:configuration`); + }, + } }) as unknown as Monaco; + const first = createMonaco(); + registerNginxLanguage(first); + registerNginxLanguage(first); + registerNginxLanguage(createMonaco()); + assert.deepEqual(calls, ['nginx', 'nginx:tokens', 'nginx:configuration', 'nginx', 'nginx:tokens', 'nginx:configuration']); +}); diff --git a/infrastructure/monaco/nginxLanguage.ts b/infrastructure/monaco/nginxLanguage.ts new file mode 100644 index 000000000..407ec91f8 --- /dev/null +++ b/infrastructure/monaco/nginxLanguage.ts @@ -0,0 +1,66 @@ +import type { Monaco } from '@monaco-editor/react'; +import type { languages } from 'monaco-editor'; + +// Original Monarch grammar for Netcatty (same license as the project). +// Syntax reference: https://nginx.org/en/docs/beginners_guide.html#conf_structure +// Recognize directive positions rather than maintaining a list of module names. +export const nginxLanguage: languages.IMonarchLanguage = { + defaultToken: '', + tokenPostfix: '.nginx', + tokenizer: { + root: [ + { include: '@whitespace' }, + [/[{}]/, 'delimiter.curly'], + [/;/, 'delimiter'], + [/[^\s{};#"'\\$]+/, 'keyword', '@arguments'], + { include: '@values' }, + ], + arguments: [ + { include: '@whitespace' }, + [/[{}]/, 'delimiter.curly', '@pop'], + [/;/, 'delimiter', '@pop'], + { include: '@values' }, + [/[^\s{};#"'\\$]+/, ''], + ], + whitespace: [ + [/\s+/, ''], + [/#.*$/, 'comment'], + ], + values: [ + [/\\./, 'string.escape'], + [/\$(?:\{[\w]+\}|[\w]+)/, 'variable'], + [/"/, 'string', '@doubleQuoted'], + [/'/, 'string', '@singleQuoted'], + ], + doubleQuoted: [ + [/\\./, 'string.escape'], + [/\$(?:\{[\w]+\}|[\w]+)/, 'variable'], + [/"/, 'string', '@pop'], + [/[^"\\$]+/, 'string'], + [/\$/, 'string'], + ], + singleQuoted: [ + [/\\./, 'string.escape'], + [/\$(?:\{[\w]+\}|[\w]+)/, 'variable'], + [/'/, 'string', '@pop'], + [/[^'\\$]+/, 'string'], + [/\$/, 'string'], + ], + }, +}; + +export const nginxLanguageConfiguration: languages.LanguageConfiguration = { + comments: { lineComment: '#' }, + brackets: [['{', '}']], +}; + +const registeredInstances = new WeakSet(); + +/** Called before model creation, including when Monaco is loaded lazily. */ +export function registerNginxLanguage(monaco: Monaco): void { + if (registeredInstances.has(monaco)) return; + monaco.languages.register({ id: 'nginx', aliases: ['Nginx', 'nginx'], filenames: ['nginx.conf'] }); + monaco.languages.setMonarchTokensProvider('nginx', nginxLanguage); + monaco.languages.setLanguageConfiguration('nginx', nginxLanguageConfiguration); + registeredInstances.add(monaco); +} diff --git a/lib/sftpFileUtils.language.test.ts b/lib/sftpFileUtils.language.test.ts new file mode 100644 index 000000000..afb30aca3 --- /dev/null +++ b/lib/sftpFileUtils.language.test.ts @@ -0,0 +1,30 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { getLanguageId, getLanguageName, getSupportedLanguages } from './sftpFileUtils'; + +test('recognizes Nginx by filename and recognizable config directories', () => { + for (const path of [ + 'nginx.conf', '/etc/nginx/nginx.conf', '/usr/local/nginx/conf/nginx.conf', + '/etc/nginx/conf.d/proxy.conf', '/usr/local/etc/nginx/conf.d/site.conf', + '/usr/local/nginx/conf/conf.d/site.conf', '/etc/nginx/snippets/tls.conf', + '/etc/nginx/sites-available/default', '/etc/nginx/sites-enabled/example.com', + 'C:\\nginx\\conf.d\\site.conf', + ]) assert.equal(getLanguageId(path), 'nginx', path); +}); + +test('keeps ambiguous configs and unrelated languages unchanged', () => { + for (const path of [ + 'site.conf', 'other.conf', '/etc/ssh/sshd.conf', '/etc/conf.d/site.conf', + '/etc/not-nginx/conf.d/site.conf', '/etc/nginx-not/conf.d/site.conf', + '/etc/nginx/conf.d/site.conf.bak', '/etc/nginx/conf.d/settings.ini', + ]) assert.equal(getLanguageId(path), path.endsWith('.bak') ? 'plaintext' : 'ini', path); + assert.equal(getLanguageId('/folder.with.dots/default'), 'plaintext'); + assert.equal(getLanguageId('/etc/nginx/readme.md'), 'markdown'); + assert.equal(getLanguageId('settings.ini'), 'ini'); +}); + +test('offers Nginx for manual selection without assigning all .conf files to it', () => { + assert.equal(getLanguageName('nginx'), 'Nginx'); + assert.deepEqual(getSupportedLanguages().filter(({ id }) => id === 'nginx'), [{ id: 'nginx', name: 'Nginx' }]); + assert.equal(getLanguageId('arbitrary-site.conf'), 'ini'); +}); diff --git a/lib/sftpFileUtils.ts b/lib/sftpFileUtils.ts index 12764d389..811ca9892 100644 --- a/lib/sftpFileUtils.ts +++ b/lib/sftpFileUtils.ts @@ -138,7 +138,15 @@ export function isKnownBinaryFile(fileName: string): boolean { * Get language ID for syntax highlighting */ export function getLanguageId(fileName: string): string { - const ext = getFileExtension(fileName); + const path = fileName.replace(/\\/g, '/'); + const baseName = path.slice(path.lastIndexOf('/') + 1); + const ext = getFileExtension(baseName); + // Keep generic .conf files as INI. Only infer Nginx with filename/path context. + if (baseName.toLowerCase() === 'nginx.conf' + || /(?:^|\/)nginx\/(?:conf\/)?(?:conf\.d|snippets)\/.+\.conf$/i.test(path) + || /(?:^|\/)nginx\/(?:conf\/)?sites-(?:available|enabled)\/[^/]+$/i.test(path)) { + return 'nginx'; + } return EXTENSION_TO_LANGUAGE[ext] || 'plaintext'; } @@ -181,6 +189,7 @@ export function getLanguageName(languageId: string): string { yaml: 'YAML', toml: 'TOML', ini: 'INI', + nginx: 'Nginx', sql: 'SQL', graphql: 'GraphQL', markdown: 'Markdown', @@ -224,6 +233,7 @@ export interface FileAssociation { export function getSupportedLanguages(): { id: string; name: string }[] { const languageIds = new Set(Object.values(EXTENSION_TO_LANGUAGE)); languageIds.add('plaintext'); + languageIds.add('nginx'); return Array.from(languageIds) .map(id => ({ id, name: getLanguageName(id) })) diff --git a/types/global/netcatty-bridge-sync.d.ts b/types/global/netcatty-bridge-sync.d.ts index 1fdfdf05c..a8559144c 100644 --- a/types/global/netcatty-bridge-sync.d.ts +++ b/types/global/netcatty-bridge-sync.d.ts @@ -47,6 +47,7 @@ declare global { setTheme?(theme: 'light' | 'dark' | 'system'): Promise; setBackgroundColor?(color: string): Promise; setWindowOpacity?(opacity: number): Promise; + setWindowBackgroundBlur?(enabled: boolean): Promise; setAppIconVariant?(variant: import('../../domain/appIconVariant').AppIconVariant): Promise; setLanguage?(language: string): Promise; // Window controls for custom title bar (Windows/Linux)