mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-30 02:21:47 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5931eb14f0 | ||
|
|
ca2280ea8b | ||
|
|
7e7b05620f | ||
|
|
1f0741698d | ||
|
|
50fda91424 | ||
|
|
fc37ac48e1 | ||
|
|
d035cbea38 | ||
|
|
b3895a477d | ||
|
|
db1223d161 | ||
|
|
14ed19d2ed | ||
|
|
a36a906292 | ||
|
|
05a773b426 | ||
|
|
2796d1113a | ||
|
|
c743456a0b | ||
|
|
336ab5ab17 | ||
|
|
ce9fcdfec6 | ||
|
|
de06a6aa4d | ||
|
|
dcf642cf39 | ||
|
|
1caf4a6479 |
No files matched your search
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Оригинал',
|
||||
|
||||
@@ -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': '原始',
|
||||
|
||||
@@ -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': '原始',
|
||||
|
||||
@@ -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<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -166,6 +168,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setExplorerContextMenuEnabledState: (enabled: boolean) => void;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: (raw: unknown) => void;
|
||||
setWindowBackgroundBlur: Dispatch<SetStateAction<boolean>>;
|
||||
setAppIconVariant: Dispatch<SetStateAction<AppIconVariant>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
@@ -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,
|
||||
|
||||
@@ -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<WindowOpacityMutationSource>;
|
||||
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;
|
||||
|
||||
@@ -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<typeof useSystemSettingsEffects>[0];
|
||||
bridgeRequests: boolean[];
|
||||
blurNotifications: unknown[];
|
||||
restore: () => void;
|
||||
};
|
||||
|
||||
function setUp(enabled: boolean, windowBackgroundBlur: boolean): Harness {
|
||||
const dom = new JSDOM('<html><body><div id="root"></div></body></html>', { url: 'http://localhost' });
|
||||
Object.defineProperty(dom.window.navigator, 'platform', { value: 'MacIntel' });
|
||||
const previousGlobals = new Map<string, PropertyDescriptor | undefined>();
|
||||
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<typeof useSystemSettingsEffects>[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<typeof useSystemSettingsEffects>[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();
|
||||
}
|
||||
});
|
||||
@@ -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<boolean>(() => {
|
||||
return readStoredString(STORAGE_KEY_WINDOW_BACKGROUND_BLUR) === 'true';
|
||||
});
|
||||
const [appIconVariant, setAppIconVariantState] = useState<AppIconVariant>(() => {
|
||||
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,
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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<void>;
|
||||
editorWordWrap: boolean;
|
||||
onToggleWordWrap: () => void;
|
||||
@@ -79,6 +81,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
filePath,
|
||||
initialContent,
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
@@ -94,7 +97,7 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
const [baselineContent, setBaselineContent] = useState(initialContent);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(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<TextEditorModalProps> = ({
|
||||
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<boolean> => {
|
||||
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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<TextEditorPaneProps> = ({
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
beforeMount={registerNginxLanguage}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import React from 'react';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { PluginBrandIcon } from './PluginBrandIcon';
|
||||
|
||||
/** Shared installed-plugin composition; navigation never wraps the quick actions. */
|
||||
export function PluginListRow({ plugin, name, description, metadata, actions, openLabel, onOpen }: {
|
||||
plugin: NetcattyInstalledPlugin;
|
||||
name: string;
|
||||
description: string;
|
||||
metadata?: React.ReactNode;
|
||||
actions: React.ReactNode;
|
||||
openLabel?: string;
|
||||
onOpen?: () => void;
|
||||
}) {
|
||||
const heading = <span className="flex min-w-0 flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
<span className="min-w-0 max-w-full text-sm font-medium leading-5 [overflow-wrap:anywhere]">{name}</span>
|
||||
{plugin.activeVersion && <span className="inline-flex max-w-full items-center rounded-full border-0 bg-muted/60 px-2 py-0 text-[11px] font-normal leading-5 text-muted-foreground hover:bg-muted/60 [overflow-wrap:anywhere]" data-plugin-row-tag>{plugin.activeVersion}</span>}
|
||||
</span>;
|
||||
return <article aria-label={name} data-plugin-row={plugin.id} className="grid min-w-0 grid-cols-[3rem_minmax(0,1fr)_auto] items-start gap-x-3 rounded-lg py-4 sm:gap-x-4">
|
||||
<div data-plugin-icon-slot className="flex h-12 w-12 shrink-0 items-center justify-center text-muted-foreground"><PluginBrandIcon plugin={plugin} size={32} /></div>
|
||||
<div className="min-w-0 space-y-1.5">
|
||||
{onOpen ? <button type="button" data-plugin-card={plugin.id} aria-label={openLabel} aria-expanded={false} onClick={onOpen}
|
||||
className="group block w-full min-w-0 rounded-md text-left transition-colors hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
|
||||
<span className="flex min-w-0 items-center gap-2"><span className="min-w-0 flex-1">{heading}</span><ChevronRight size={14} className="shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" aria-hidden="true" /></span>
|
||||
<span className="mt-1 block line-clamp-2 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]" title={description}>{description}</span>
|
||||
</button> : <div>{heading}<p className="mt-1 line-clamp-2 text-sm leading-5 text-muted-foreground [overflow-wrap:anywhere]" title={description}>{description}</p></div>}
|
||||
{metadata && <div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs leading-5 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [overflow-wrap:anywhere]">{metadata}</div>}
|
||||
</div>
|
||||
<div className="flex min-h-12 shrink-0 items-center justify-end gap-2 self-center" data-plugin-row-actions>{actions}</div>
|
||||
</article>;
|
||||
}
|
||||
@@ -51,7 +51,7 @@ test('mounted installed authors follow actual catalog/update/source entrypoints
|
||||
changeMarketSource: async (change: () => unknown) => change() };
|
||||
let avatarReads = 0;
|
||||
const service = new MarketSourcesService({ database, manager,
|
||||
fetch: async (url: string) => { assert.equal(new URL(url).origin, OFFICIAL_SOURCE.origin); return new Response(files.get(new URL(url).pathname)); },
|
||||
fetch: async (url: string) => { assert.equal(new URL(url).origin, OFFICIAL_SOURCE.origin); assert.ok(!new URL(url).pathname.endsWith('.ncpkg'), 'rejected selection never downloads a package'); return new Response(files.get(new URL(url).pathname)); },
|
||||
createSourceFetch: (origin: string) => async (url: string) => { assert.equal(new URL(url).origin, origin); return new Response(files.get(new URL(url).pathname)); },
|
||||
resolveAvatar: async () => { avatarReads++; return 'data:image/png;base64,eA=='; } });
|
||||
const ipcMain = { handlers: new Map(), handle(channel: string, handler: unknown) { this.handlers.set(channel, handler); }, on() {} };
|
||||
@@ -87,10 +87,13 @@ test('mounted installed authors follow actual catalog/update/source entrypoints
|
||||
files = filesFor([{ ...entry, author: 'Updated project name' }], false);
|
||||
await act(async () => { await api.listPluginCatalog('official'); });
|
||||
assert.equal(row().dataset.author, 'Updated project name'); assert.equal(row().dataset.avatar, undefined);
|
||||
const beforeWithdrawal = (await api.listPluginCatalog('official')).entries[0];
|
||||
files = filesFor([]);
|
||||
await act(async () => { await api.listPluginCatalog('official'); });
|
||||
await act(async () => {
|
||||
await assert.rejects(api.installCatalogPlugin(entry.id, beforeWithdrawal.selectionToken, 'official'), /not in the curated catalog|changed/);
|
||||
});
|
||||
assert.equal(row().dataset.author, entry.manifest.publisher); assert.equal(row().dataset.official, 'false');
|
||||
assert.equal(row().dataset.avatar, undefined, 'withdrawal removes enriched identity without a remount');
|
||||
assert.equal(row().dataset.avatar, undefined, 'rejected install refresh removes withdrawn enrichment without another list or remount');
|
||||
|
||||
const source = await api.addPluginSource({ name: 'Community', address: 'https://community.example.com' });
|
||||
const community = structuredClone(entry);
|
||||
|
||||
@@ -1,16 +1,12 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { JSDOM } from 'jsdom';
|
||||
import { PluginListRow } from '../../plugins/PluginListRow';
|
||||
import { Toggle } from '../settings-ui';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import type { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
|
||||
test('installed rows filter, open one detail and restore search, scroll and row focus without toggle navigation', async () => {
|
||||
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
|
||||
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const raf = globalThis.requestAnimationFrame;
|
||||
@@ -39,14 +35,11 @@ test('installed rows filter, open one detail and restore search, scroll and row
|
||||
const authorRow = alphaCard.findByProps({ 'data-plugin-author-row': true });
|
||||
assert.ok(authorRow.findAllByType('span').some((node) => node.children.includes('Example')), 'author has its own compact row');
|
||||
assert.ok(authorRow.findByProps({ 'data-plugin-author-placeholder': true }), 'an unverified installed publisher has a default avatar');
|
||||
assert.ok(alphaCard.findAllByType('span').some((node) => node.props.className?.includes('line-clamp-2')), 'row description is bounded');
|
||||
assert.ok(alphaCard.props['data-plugin-row'], 'installed entries use the shared row composition');
|
||||
assert.match(alphaCard.findByProps({ 'data-plugin-row-tag': true }).props.className, /border-0/);
|
||||
assert.doesNotMatch(alphaCard.props.className, /bg-card|border|divide/);
|
||||
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
|
||||
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
|
||||
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
|
||||
assert.equal(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')), false, 'installed rows no longer inherit the marketplace grid');
|
||||
const pill = renderer.root.findAllByType('button').find((node) => node.props.role === 'switch')!;
|
||||
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
|
||||
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
|
||||
await act(async () => pill.props.onClick());
|
||||
assert.deepEqual(toggles, [[plugins[0].id, true]]); assert.deepEqual(selections, []);
|
||||
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'Alpha' } }));
|
||||
@@ -64,39 +57,9 @@ test('installed rows filter, open one detail and restore search, scroll and row
|
||||
assert.equal(cards().length, 0);
|
||||
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: '' } }));
|
||||
await act(async () => renderer.update(<I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, statusKnown: false, error: 'synthetic state unavailable' }} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>));
|
||||
const unknown = renderer.root.findAllByType('button').filter((node) => node.props.role === 'switch');
|
||||
const unknown = renderer.root.findAllByType('button').filter((node) => node.children.includes('Status unknown'));
|
||||
assert.equal(unknown.length, 2);
|
||||
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-label'].startsWith('Status unknown')));
|
||||
assert.ok(unknown.every((node) => node.props.disabled === true && node.props['aria-pressed'] === undefined));
|
||||
await act(async () => renderer.unmount());
|
||||
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
|
||||
});
|
||||
|
||||
|
||||
test('long installed-row text keeps baseline tags and independent native-button actions', () => {
|
||||
const name = `Long plugin ${'unbroken'.repeat(30)}`;
|
||||
const description = `Description ${'long description '.repeat(30)}`;
|
||||
const plugin = { id: 'com.test.long', activeVersion: '1.2.3-preview.4', enabled: false, runtime: {} } as NetcattyInstalledPlugin;
|
||||
const html = renderToStaticMarkup(<PluginListRow plugin={plugin} name={name} description={description}
|
||||
metadata={<span>{'https://source.example/'.repeat(20)}</span>} openLabel={`Details ${name}`} onOpen={() => {}}
|
||||
actions={<Toggle checked={false} ariaLabel={`Enable ${name}`} onChange={() => {}} />} />);
|
||||
const dom = new JSDOM(html);
|
||||
try {
|
||||
const row = dom.window.document.querySelector('[data-plugin-row]')!;
|
||||
const navigation = row.querySelector<HTMLButtonElement>('[data-plugin-card]')!;
|
||||
const toggle = row.querySelector<HTMLButtonElement>('[role=switch]')!;
|
||||
const tag = row.querySelector('[data-plugin-row-tag]')!;
|
||||
assert.equal(navigation.type, 'button');
|
||||
assert.equal(toggle.type, 'button');
|
||||
assert.equal(navigation.contains(toggle), false, 'Enter/Space on the switch cannot invoke row navigation');
|
||||
assert.match(navigation.className, /focus-visible:ring-2/);
|
||||
assert.match(toggle.className, /focus-visible:ring-2/);
|
||||
assert.match(tag.parentElement!.className, /items-baseline/);
|
||||
assert.equal(tag.previousElementSibling!.textContent, name, 'long names stay available without title truncation');
|
||||
assert.match(tag.previousElementSibling!.className, /overflow-wrap:anywhere/);
|
||||
assert.match(tag.className, /border-0/);
|
||||
assert.equal(navigation.querySelector('[title]')!.getAttribute('title'), description, 'clamped descriptions retain the full text');
|
||||
assert.match(row.className, /minmax\(0,1fr\)/);
|
||||
assert.doesNotMatch(row.querySelector('[data-plugin-icon-slot]')!.className, /border|ring|bg-/);
|
||||
assert.equal(row.querySelector('button button'), null);
|
||||
} finally { dom.window.close(); }
|
||||
});
|
||||
@@ -4,8 +4,6 @@ import React, { useRef, useState } from 'react';
|
||||
import { PluginPermissionPanel } from './PluginPermissionPanel';
|
||||
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
|
||||
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
|
||||
import { PluginListRow } from '../../plugins/PluginListRow';
|
||||
import { Toggle } from '../settings-ui';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { useI18n } from '../../../application/i18n/I18nProvider';
|
||||
@@ -82,25 +80,13 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
|
||||
if (!manager.available) return null;
|
||||
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
|
||||
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 max-w-full rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <select aria-label={t('settings.plugins.sources.source')} value={sourceFilter} onChange={(event) => setSourceFilter(event.target.value)} className="h-9 rounded-md border border-input bg-background px-3 text-sm"><option value="">{t('settings.plugins.sources.all')}</option>{[...new Map(manager.plugins.map((plugin) => [plugin.source?.id ?? 'local', plugin.source])).entries()].map(([id, source]) => <option key={id} value={id}>{source?.origin ?? t('settings.plugins.sources.local')}</option>)}</select>}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
|
||||
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
|
||||
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
|
||||
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
|
||||
<div className={selectedPlugin ? 'space-y-4' : 'space-y-1'} data-plugin-row-list={!selectedPlugin || undefined}>
|
||||
{visible.map((plugin) => !selectedPlugin ? <PluginListRow key={plugin.id} plugin={plugin} name={nameOf(plugin)} description={descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}
|
||||
openLabel={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`} onOpen={() => choose(plugin.id)}
|
||||
metadata={<>
|
||||
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
|
||||
<span>{plugin.source ? `${t('settings.plugins.sources.source')}: ${t(plugin.source.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}${plugin.source.origin ? ` · ${plugin.source.origin}` : ''}` : t('settings.plugins.landing.sourceUnknown')}</span>
|
||||
{plugin.manifest?.license && <span>{plugin.manifest.license}</span>}
|
||||
{manager.statusKnown === false && <span>{t('settings.plugins.permissions.statusUnknown')}</span>}
|
||||
{plugin.runtime.lastError && <span role="status" className="w-full text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</span>}
|
||||
</>}
|
||||
actions={<Toggle checked={plugin.enabled} disabled={manager.busy || manager.statusKnown === false}
|
||||
ariaLabel={`${t(manager.statusKnown === false ? 'settings.plugins.permissions.statusUnknown' : plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${nameOf(plugin)}`}
|
||||
onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />}
|
||||
/> : <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className="min-w-0 space-y-4 rounded-lg focus:outline-none">
|
||||
<div className={selectedPlugin ? 'space-y-4' : 'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3'}>
|
||||
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg focus:outline-none ${selectedPlugin ? 'space-y-4' : 'plugin-card flex min-h-[200px] flex-col gap-3 bg-card p-4'}`}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
|
||||
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
|
||||
@@ -120,7 +106,10 @@ export function PluginPackageManager({ manager, onSelectionChange, initialSelect
|
||||
<p className="break-all text-xs text-muted-foreground">{t('settings.plugins.sources.source')}: {t(plugin.source?.kind === 'official' ? 'settings.plugins.sources.official' : plugin.source?.kind === 'third-party' ? 'settings.plugins.sources.thirdparty' : 'settings.plugins.sources.local')}{plugin.source?.origin ? ` · ${plugin.source.origin}` : ''}</p>
|
||||
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
|
||||
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
|
||||
<p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
|
||||
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
|
||||
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
|
||||
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
|
||||
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
|
||||
{selected === plugin.id && <>
|
||||
<PluginPackageDetails plugin={plugin} />
|
||||
|
||||
@@ -41,13 +41,6 @@ test('landing rows expose real sources/status/settings and quick toggles preserv
|
||||
await render();
|
||||
assert.equal(document.querySelector('[role=dialog]'), null);
|
||||
assert.equal(document.querySelector('input[type=checkbox]'), null);
|
||||
assert.equal(document.querySelectorAll('[data-plugin-row]').length, 2);
|
||||
assert.equal(document.querySelectorAll('[data-plugin-icon-slot]').length, 2);
|
||||
assert.equal(document.querySelector('[data-plugin-row-list]')!.className.includes('border'), false);
|
||||
const alphaRow = document.querySelector('[data-plugin-row="com.example.alpha"]')!;
|
||||
assert.ok(alphaRow.querySelector('[data-plugin-row-tag]'));
|
||||
assert.equal(alphaRow.querySelector('[data-plugin-row-actions] [role=switch]')?.getAttribute('aria-label'), 'Disable Alpha');
|
||||
assert.equal(alphaRow.querySelector('button button'), null, 'quick actions are never nested inside navigation');
|
||||
assert.equal(document.querySelectorAll('[role=switch]').length, 2, 'only real plugin toggles appear on landing');
|
||||
assert.match(document.body.textContent!, /Running/);
|
||||
assert.match(document.body.textContent!, /Source: Official/);
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { usePluginUpdateCheck } from '../../../application/state/usePluginU
|
||||
import type { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Input } from '../../ui/input';
|
||||
import { PluginListRow } from '../../plugins/PluginListRow';
|
||||
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
|
||||
import { PluginAuthorRow } from '../../plugins/PluginAuthorRow';
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from '../settings-ui';
|
||||
import { PluginLogSettings } from './PluginLogSettings';
|
||||
@@ -63,24 +63,28 @@ export function PluginSettingsLanding({ manager, settingsAvailable, settingsErro
|
||||
{manager.statusKnown && manager.available && manager.plugins.length === 0 && <p className="text-sm text-muted-foreground">{t('settings.plugins.landing.empty')}</p>}
|
||||
{manager.plugins.length > 0 && <>
|
||||
<Input aria-label={t('settings.plugins.landing.search')} placeholder={t('settings.plugins.landing.search')} value={search} onChange={(event) => setSearch(event.target.value)} />
|
||||
{visible.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <div className="space-y-1" data-plugin-row-list>
|
||||
{visible.length === 0 ? <p role="status" className="text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p> : <SettingCard divided>
|
||||
{visible.map((plugin) => {
|
||||
const name = nameOf(plugin);
|
||||
const description = localized(plugin.manifest?.description);
|
||||
const availableUpdate = updates?.result?.updates.find((item) => item.pluginId === plugin.id && item.sourceId === plugin.source?.id);
|
||||
const declaredSettings = Boolean(plugin.manifest?.contributes?.settings?.length || plugin.manifest?.contributes?.views?.some((view) => view.location === 'settings'));
|
||||
return <PluginListRow key={plugin.id} plugin={plugin} name={name} description={description || t('settings.plugins.manager.noDescription')} metadata={<>
|
||||
return <SettingRow key={plugin.id} leading={<PluginBrandIcon plugin={plugin} size={24} />} label={<span className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="truncate" title={name}>{name}</span>
|
||||
{plugin.activeVersion && <span className="shrink-0 text-xs font-normal text-muted-foreground">{plugin.activeVersion}</span>}
|
||||
</span>} description={<>
|
||||
{plugin.manifest?.publisher && <PluginAuthorRow plugin={plugin} />}
|
||||
<span>{[sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
|
||||
{availableUpdate && <span className="text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
|
||||
</>} actions={
|
||||
<span className="block break-words">{[sourceOf(plugin), statusOf(plugin)].filter(Boolean).join(' · ')}</span>
|
||||
{availableUpdate && <span className="block text-primary">{t('settings.plugins.landing.updateAvailable').replace('{version}', availableUpdate.availableVersion)}</span>}
|
||||
{description && <span className="line-clamp-2 break-words">{description}</span>}
|
||||
</>}>
|
||||
<div className="flex items-center gap-3">
|
||||
{declaredSettings && <Button className="focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" variant="ghost" size="icon" disabled={manager.busy || !manager.statusKnown || !settingsAvailable.has(plugin.id)} aria-label={t('settings.plugins.landing.settingsFor').replace('{name}', name)} title={t(settingsAvailable.has(plugin.id) ? 'common.settings' : !plugin.enabled ? 'settings.plugins.landing.enableSettings' : 'settings.plugins.landing.settingsUnavailable')} onClick={(event) => onManage(plugin.id, event.currentTarget)}><Settings2 size={16} /></Button>}
|
||||
<Toggle checked={plugin.enabled} disabled={manager.busy || !manager.available || !manager.statusKnown} ariaLabel={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${name}`} onChange={(enabled) => void manager.setEnabled(plugin.id, enabled)} />
|
||||
</div>
|
||||
} />;
|
||||
</SettingRow>;
|
||||
})}
|
||||
</div>}
|
||||
</SettingCard>}
|
||||
</>}
|
||||
</SettingsSection>
|
||||
</>;
|
||||
|
||||
@@ -6,14 +6,14 @@ import { I18nProvider } from '../../../application/i18n/I18nProvider';
|
||||
import { PluginPackageManager } from './PluginPackageManager';
|
||||
import type { usePluginManager } from '../../../application/state/usePluginManager';
|
||||
|
||||
test('detail state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
|
||||
test('state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
|
||||
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
|
||||
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
const calls: unknown[] = [];
|
||||
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill', main: { browser: 'index.js' }, engines: { api: '0.1.0-internal', netcatty: '*' } }, runtime: {} } as NetcattyInstalledPlugin;
|
||||
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill' }, runtime: {} } as NetcattyInstalledPlugin;
|
||||
const manager = { available: true, plugins: [plugin], busy: false, setEnabled: async (...args: unknown[]) => { calls.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
|
||||
let renderer: ReactTestRenderer;
|
||||
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager initialSelectedId={plugin.id} manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
|
||||
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
|
||||
try {
|
||||
await act(async () => { renderer = create(render(true)); });
|
||||
const pill = () => renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
|
||||
|
||||
@@ -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: {
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
{isMac && (
|
||||
<SettingRow
|
||||
anchorId="appearance-window-background-blur"
|
||||
label={t("settings.appearance.windowBackgroundBlur")}
|
||||
description={t("settings.appearance.windowBackgroundBlur.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={windowBackgroundBlur}
|
||||
onChange={setWindowBackgroundBlur}
|
||||
ariaLabel={t("settings.appearance.windowBackgroundBlur")}
|
||||
/>
|
||||
</SettingRow>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.uiTheme")} />
|
||||
|
||||
@@ -264,6 +264,7 @@ export const SftpOverlays: React.FC<SftpOverlaysProps> = React.memo(({
|
||||
open={showTextEditor}
|
||||
onClose={closeTextEditor}
|
||||
fileName={textEditorFileName}
|
||||
filePath={textEditorTarget?.fullPath}
|
||||
initialContent={textEditorContent}
|
||||
onSave={handleSaveTextFile}
|
||||
editorWordWrap={editorWordWrap}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<TextEditorPaneProps>) {
|
||||
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<string, unknown> = {
|
||||
'./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<Record<string, unknown>> } };
|
||||
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<typeof useSftpViewFileOps> | 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<void>((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, /<LazyTextEditorModal[\s\S]*?filePath=\{textEditorTarget\?\.fullPath\}/);
|
||||
const pane = readFileSync(new URL('../editor/TextEditorPane.tsx', import.meta.url), 'utf8');
|
||||
assert.match(pane, /beforeMount=\{registerNginxLanguage\}/);
|
||||
});
|
||||
@@ -450,6 +450,13 @@ new child to an opened parent directory without a path race.
|
||||
`readDirectory()` likewise keeps its stable SDK/RPC method but fails closed
|
||||
unless the main process supplies a native adapter whose inode checks and entry
|
||||
enumeration are bound to the same directory handle.
|
||||
The host advertises `filesystem.directory-enumeration` only when it supplies that
|
||||
adapter. Declare the feature as required, or optional and check
|
||||
`context.enabledFeatures.has(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)`.
|
||||
The default host supplies no adapter and does not enable this feature. Both the
|
||||
SDK and direct RPC return `unsupported` before a permission request or path probe.
|
||||
An enabled feature still requires the declared `filesystem.read` directory
|
||||
resource and the normal permission, root, symlink and same-handle checks.
|
||||
|
||||
Permission names already use the phase-3 enforcement boundaries: clipboard
|
||||
read/write, terminal metadata/output/input and input/output interception, Vault
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# Plugin foundation landing candidate
|
||||
|
||||
Draft #4011 is the single landing vehicle for the accumulated plugin foundation.
|
||||
Earlier integration records describe frozen evidence, not the current acceptance
|
||||
status. This candidate preserves their source history through ordinary merges.
|
||||
The user cancelled the reference-driven row redesign. Installed grid and Settings
|
||||
quick-toggle layout are restored exactly to b14, while author presentation and all
|
||||
functional/security corrections remain. Cancelled visual commits stay in history
|
||||
with attributable reverts; they are not effective changes in the landing tree.
|
||||
It does not include global Agent/custom Chat #4006/#4012, model-picker #4002,
|
||||
or terminal diagnostic #3999. No older source PR is closed by this preparation.
|
||||
|
||||
## Included commits
|
||||
|
||||
| Input | Exact input | Ordinary integration |
|
||||
| --- | --- | --- |
|
||||
| Prior #4011 | `d40b0d3c25ed00a33679e83be948fd1a5aa10bfa` | Starting point; all inherited contributions retained |
|
||||
| Current main | `2796d1113a6310fae1779931f242e208a69b95a5` | `db1223d16`; Nginx #4010, macOS appearance #4013, closed-socket MCP reconnect #4016 |
|
||||
| Author metadata and preserved visual history | `e4eb33856b335fe0b1d302827b1325ddfc23bdde` | `b3895a477`; preserves original avatar/badge, canonical v2 schema, b14 invalidation, visual-worker patch subsequently reverted |
|
||||
| Directory availability | `a36a906292cbe8033bc19b596dcaf7f853a804c7` | `d035cbea3`; no native adapter or expanded permission |
|
||||
| Two-profile sync composition | `14ed19d2ed7cd1ecc201c9c910b112e97cc098ff` | `fc37ac48e`; tests only |
|
||||
| Restore pre-row layout | `7e7b05620ff800ae459d0bf007bbace6aaebbdbc` | Normal revert of e4 row commit; all six layout paths match parent `b14b65679ffd9c617bb0aa1215d8653c53430243` |
|
||||
| Rejected-install catalog refresh | `50fda91424c915c1497e53a87a210e3943cb1e39` | Separate correction after bounded review; service and mounted-row regression only |
|
||||
|
||||
The separately published icon branch first reverted its outline correction
|
||||
(`530ff0318`), then the e4 row commit (`6e4465f47`). Neither outline correction nor
|
||||
those isolated reverts are imported here; foundation never included the outline.
|
||||
The e4 and 1f renderer artifacts are historical pre-cancellation evidence, not
|
||||
accepted final UI. No further cancelled-design build or screenshot is requested.
|
||||
|
||||
The only manual merge conflict was the end of `index.css`. Resolution retains
|
||||
both the prior host-security theme block and main's window-background-blur block,
|
||||
with their declarations unchanged. Automatic combinations retain main appearance
|
||||
wiring alongside plugin settings/theme wiring, all five locales, preload methods,
|
||||
the host services and SDK exports. No permission, source, endpoint, sandbox or
|
||||
selected-resource restriction is relaxed. Package and lock inputs are unchanged.
|
||||
|
||||
`50fda9142` reuses the same selection-token comparison after an install-triggered
|
||||
catalog refresh, including an install rejected for a changed/withdrawn selection.
|
||||
The rejection is preserved; mounted authors no longer retain withdrawn enrichment.
|
||||
The regression makes no package download or execution. It also preserves the
|
||||
original synchronous unavailable-source rejection in `list()`: the first aggregate
|
||||
runtime run caught that b14 changed it into a Promise rejection. The assertion was
|
||||
not weakened. Corrected aggregate runtime: 711 pass, one native skip, zero failures
|
||||
or cancellations. The original failed run and fail-before regression are retained.
|
||||
|
||||
## Checks and packages
|
||||
|
||||
Registered runtime, SDK/contract, Settings and actual connected host entrypoints,
|
||||
changed-main MCP/appearance/editor regressions, example preparation, code generation,
|
||||
lint and the normal production renderer build are run on this candidate. Exact
|
||||
head, commands, counts and CI links are recorded in #4011. Package provenance is
|
||||
in [the bounded sample manifest](foundation-packages.json): each archive is built
|
||||
twice and whole-byte compared. Assistant is byte-identical to d40; Host changes
|
||||
only README wording, with manifest/entrypoint unchanged. Database-owned, Rfam and
|
||||
terminal archives change only `browser.js` minified bound identifier spelling.
|
||||
Rebuilding the same sources with d40's generated SDK/contract artifacts reproduces
|
||||
the exact old archive hashes. Parsing and normalizing lexical bindings yields
|
||||
identical ASTs for all three bundles; manifests, protocol and permissions do not
|
||||
change. Preparation does not connect, activate or query.
|
||||
|
||||
Local checks at pre-restoration1f: runtime 711 pass/one existing native skip; SDK91; Settings and
|
||||
connected host/security78; changed-main MCP/appearance/editor71; examples five
|
||||
pass/one Docker opt-in skip. Contract/capability/Codex generation has no drift;
|
||||
lint zero errors/seven existing warnings; normal production build36.13s. These
|
||||
checks do not claim native UI, packaging or encrypted transport acceptance.
|
||||
Restoration-focused layout/author tests: six pass; changed-file lint and diff
|
||||
checks clean. Two broader Settings runs each had38pass/one PluginLogSettings
|
||||
file-entry failure, without a detailed assertion; that file alone passed. Their
|
||||
cause remains unclassified and both failed runs are retained. No production log
|
||||
change or baseline attribution is made. Every one of the six layout paths exactly matches b14; fourteen functional
|
||||
paths (identity/provenance, permissions, directory availability and sync included)
|
||||
remain byte-identical to1f. The five sample records remain unchanged. Pre-restoration1f exact CI completed:
|
||||
[test40505644082](https://github.com/binaricat/Netcatty/actions/runs/40505644082)
|
||||
records15,372pass/50skip/zero failures or cancellations on its successful retry;
|
||||
[packages40505644200](https://github.com/binaricat/Netcatty/actions/runs/40505644200)
|
||||
passed all four platforms with release skipped. Its original runner shutdown is
|
||||
retained. These results do not certify the restored head.
|
||||
|
||||
An earlier coding-executor full suite timed out after 15 minutes: 31 failures and
|
||||
482 cancellations. Seven failures reproduced on unchanged d40; 24 were not probed
|
||||
on that baseline. They remain unresolved evidence, not assumed baseline failures.
|
||||
No additional long local full suite is used to conceal them. Clean-run exact-head
|
||||
CI is a separate aggregate check and does not diagnose that historical run.
|
||||
|
||||
## Acceptance and landing order
|
||||
|
||||
1. Foundation: keep #4011 Draft until integrated CI/review and affected native UI
|
||||
acceptance settle. d40 Settings, Host/Vault policy, terminal A/B selection and
|
||||
inert multiline draft isolation, and native chat split/drag ownership passes
|
||||
are historical evidence at their stated heads. They do not certify this new
|
||||
current-main merge tree. The e4/07b reference-driven row design is cancelled;
|
||||
stop its native visual work. Restored layout retains author/avatar presentation.
|
||||
Avatar/catalog behavior and the final install-refresh correction still require
|
||||
their scoped review/acceptance; cancellation does not erase functional gates.
|
||||
2. Marketplace distribution: retain static-source identity and v2 delivery checks,
|
||||
but track reviewed registry data, actual hosting/CDN and install/update/source
|
||||
replacement/withdrawal acceptance separately. Current live source-only viewing
|
||||
is not installation or deployment acceptance. Never weaken admitted-source or
|
||||
destination guards to manufacture a pass.
|
||||
3. Global Agent: #4006/#4012 remain a separate later increment, with their own
|
||||
scope, review, CI and actual sample smoke. #4002 is also separate.
|
||||
|
||||
Historical integration vehicles #3967/#3989/#3993/#3997/#4001/#4004 are superseded
|
||||
as landing vehicles, not discarded contributions. Their ancestors and native
|
||||
receipts stay available. Unique source PRs remain intact; no premature closures.
|
||||
|
||||
Directory enumeration is intentionally unavailable in the default production
|
||||
composition until a same-handle adapter exists. The advertised feature now tells
|
||||
SDK callers the truth; filesystem permission alone does not enable it. The new
|
||||
sync composition test proves local collect/apply/hydrate with real databases, not
|
||||
encrypted cloud transport or native cloud-account sync. A native compatibility
|
||||
transition needs two properly versioned builds; two 0.0.0 checkouts do not qualify.
|
||||
|
||||
Public database acceptance remains blocked by the retained TCP refusal before
|
||||
protocol payload; the separately owned local fixture is pending. Source-policy,
|
||||
compatibility and database gates must be explicitly reconciled by the parent,
|
||||
not silently removed from requirements. Nested log-dialog overlap has source
|
||||
coverage but no normal native route in the tested UI. This is code-readiness work,
|
||||
not a claim that deployment, every original requirement or the whole project is
|
||||
complete. No main merge, Ready transition, release or live metadata publication.
|
||||
@@ -0,0 +1,177 @@
|
||||
[
|
||||
{
|
||||
"name": "assistant",
|
||||
"id": "com.netcatty.assistant",
|
||||
"version": "0.1.0",
|
||||
"sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"bytes": 17214,
|
||||
"fileCount": 6,
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
"ai.invoke",
|
||||
"context.selected.list",
|
||||
"context.selected.read"
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"ai.model-invocation",
|
||||
"context.selected-read"
|
||||
]
|
||||
},
|
||||
"repeatIdentical": true,
|
||||
"previousD40Sha256": "7943d4c3800914fe0acd3d0f3a3d7f7c1910c212a5dc58a044871a6a82485274",
|
||||
"preparedOnly": true,
|
||||
"noConnectionOrActivation": true,
|
||||
"changedEntriesFromD40": [],
|
||||
"delta": "Archive byte-identical."
|
||||
},
|
||||
{
|
||||
"name": "database-owned",
|
||||
"id": "com.netcatty.database-minimal",
|
||||
"version": "0.1.0",
|
||||
"sha256": "3c67f529cd5d2843dbf74a62d237b7b7d50d24a466ade85cce76b3adfff919a2",
|
||||
"bytes": 11660,
|
||||
"fileCount": 5,
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://10.77.0.20:3306"
|
||||
],
|
||||
"reason": "Connect only to the prepared disposable fixture."
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"repeatIdentical": true,
|
||||
"previousD40Sha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"preparedOnly": true,
|
||||
"noConnectionOrActivation": true,
|
||||
"changedEntriesFromD40": [
|
||||
"browser.js"
|
||||
],
|
||||
"delta": "Generated SDK export changes alter only minified bound identifier spelling; scope-aware normalized AST is identical.",
|
||||
"oldGeneratedRebuildSha256": "f01c6a24382897d4b0bf4130941274abacb9a9a963f9b56ecaceb2c020f36874",
|
||||
"boundIdentifierAlphaEquivalent": true
|
||||
},
|
||||
{
|
||||
"name": "database-rfam-readonly",
|
||||
"id": "com.netcatty.database-rfam-readonly",
|
||||
"version": "0.1.0",
|
||||
"sha256": "554407c067ced0a6634599b7eccc7c6dbb4f2ff7c523d0bacbbbd4fb78c04612",
|
||||
"bytes": 11003,
|
||||
"fileCount": 5,
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"views"
|
||||
],
|
||||
"optional": [
|
||||
{
|
||||
"permission": "network.stream",
|
||||
"resources": [
|
||||
"tcp://mysql-rfam-public.ebi.ac.uk:4497"
|
||||
],
|
||||
"reason": "Run the fixed public Rfam acceptance query only after explicit authorization."
|
||||
}
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"network.byte-streams"
|
||||
]
|
||||
},
|
||||
"repeatIdentical": true,
|
||||
"previousD40Sha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"preparedOnly": true,
|
||||
"noConnectionOrActivation": true,
|
||||
"changedEntriesFromD40": [
|
||||
"browser.js"
|
||||
],
|
||||
"delta": "Generated SDK export changes alter only minified bound identifier spelling; scope-aware normalized AST is identical.",
|
||||
"oldGeneratedRebuildSha256": "aaca586669db454e91d61487e576ed524d1b7190eed182bc2cd0035e59caa0d3",
|
||||
"boundIdentifierAlphaEquivalent": true
|
||||
},
|
||||
{
|
||||
"name": "host-actions",
|
||||
"id": "com.netcatty.host-actions",
|
||||
"version": "0.1.0",
|
||||
"sha256": "9713f001449b0c749f518d42f013fa72ead6dd43f8e7ddd7843cc6ecc03027f9",
|
||||
"bytes": 4789,
|
||||
"fileCount": 3,
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"vault.metadata",
|
||||
"vault.write",
|
||||
"ui.interact"
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"vault.host-actions",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"repeatIdentical": true,
|
||||
"previousD40Sha256": "cf04f82dfe25ab5cda6f5f3dad0a8fb06c37bedf9924f556bee2b8402e542ef2",
|
||||
"preparedOnly": true,
|
||||
"noConnectionOrActivation": true,
|
||||
"changedEntriesFromD40": [
|
||||
"README.md"
|
||||
],
|
||||
"delta": "README wording follows permission policy. Manifest and browser entrypoint are byte-identical."
|
||||
},
|
||||
{
|
||||
"name": "terminal-selection-draft",
|
||||
"id": "com.netcatty.terminal-selection-draft",
|
||||
"version": "0.1.0",
|
||||
"sha256": "aaadd8687b1af1d6e1f9a56c52760ac0d19e7ed4e8212fb7c8775ed052ed74f1",
|
||||
"bytes": 2954,
|
||||
"fileCount": 2,
|
||||
"permissions": {
|
||||
"required": [
|
||||
"commands",
|
||||
"menus"
|
||||
],
|
||||
"optional": [
|
||||
"terminal.selection.read",
|
||||
"terminal.input.propose"
|
||||
]
|
||||
},
|
||||
"features": {
|
||||
"required": [
|
||||
"terminal.selection-sharing",
|
||||
"terminal.input-drafts",
|
||||
"commands.interactive"
|
||||
]
|
||||
},
|
||||
"repeatIdentical": true,
|
||||
"previousD40Sha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"preparedOnly": true,
|
||||
"noConnectionOrActivation": true,
|
||||
"changedEntriesFromD40": [
|
||||
"browser.js"
|
||||
],
|
||||
"delta": "Generated SDK export changes alter only minified bound identifier spelling; scope-aware normalized AST is identical.",
|
||||
"oldGeneratedRebuildSha256": "4c9c2388b7f891156192a0b5ab7b23291beba3555e7ec8cf24fbe2dfe1242d5e",
|
||||
"boundIdentifierAlphaEquivalent": true
|
||||
}
|
||||
]
|
||||
@@ -5,6 +5,7 @@ import en from "../application/i18n/locales/en.ts";
|
||||
import zhCN from "../application/i18n/locales/zh-CN.ts";
|
||||
import zhTW from "../application/i18n/locales/zh-TW.ts";
|
||||
import { filterSettingsSearchCatalog } from "./settingsSearch.ts";
|
||||
import { resolveSettingsPlatform } from "./settingsSearchCatalog.ts";
|
||||
import { SETTINGS_SEARCH_CATALOG } from "./settingsSearchCatalog.ts";
|
||||
|
||||
const EN: Record<string, string> = {
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export function isWindowBackgroundBlurActive(
|
||||
requested: boolean,
|
||||
windowOpacity: number,
|
||||
): boolean {
|
||||
return requested && Number.isFinite(windowOpacity) && windowOpacity < 1;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
@@ -207,6 +207,12 @@ class PluginFilesystemBroker {
|
||||
};
|
||||
}
|
||||
validatePath(params) { return assertPathParams(params); }
|
||||
validateDirectory(params) {
|
||||
if (typeof this.openDirectoryHandle !== "function") {
|
||||
throw new PluginRpcError(RPC_ERRORS.unsupported, "Secure plugin directory enumeration is unavailable on this runtime");
|
||||
}
|
||||
return assertPathParams(params);
|
||||
}
|
||||
|
||||
describeReadAuthorization(params, resourceKind = "exact") {
|
||||
const value = assertPathParams(params);
|
||||
@@ -329,17 +335,11 @@ class PluginFilesystemBroker {
|
||||
}
|
||||
|
||||
async readDirectory(params, context) {
|
||||
const value = assertPathParams(params);
|
||||
const value = this.validateDirectory(params);
|
||||
assertAuthorizedPath(context, "filesystem.read", value.path);
|
||||
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
|
||||
assertAuthorizedPath(context, "filesystem.read", canonical);
|
||||
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
|
||||
if (!this.openDirectoryHandle) {
|
||||
throw new PluginRpcError(
|
||||
RPC_ERRORS.unsupported,
|
||||
"Secure plugin directory enumeration is unavailable on this runtime",
|
||||
);
|
||||
}
|
||||
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
|
||||
if (
|
||||
!directory
|
||||
|
||||
@@ -7158,6 +7158,9 @@
|
||||
"TerminalInputDraftFeature": {
|
||||
"const": "terminal.input-drafts"
|
||||
},
|
||||
"FilesystemDirectoryEnumerationFeature": {
|
||||
"const": "filesystem.directory-enumeration"
|
||||
},
|
||||
"HostTerminalInputProposalRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -36,6 +36,7 @@ const { HOST_INTERACTION_FEATURE, PluginHostInteractionService } = require("./ho
|
||||
const { PluginHostVaultService } = require("./hostVaultService.cjs");
|
||||
const { PluginAISourceBinding } = require("./ai/sourceBinding.cjs");
|
||||
const HOST_VAULT_FEATURE = require("./generated/plugin-contract.schema.json").$defs.HostVaultFeature.const;
|
||||
const DIRECTORY_ENUMERATION_FEATURE = require("./generated/plugin-contract.schema.json").$defs.FilesystemDirectoryEnumerationFeature.const;
|
||||
const { PluginPermissionEngine } = require("./permissionEngine.cjs");
|
||||
const { PluginProtocol } = require("./pluginProtocol.cjs");
|
||||
const { PluginViewHost } = require("./pluginViewHost.cjs");
|
||||
@@ -99,7 +100,9 @@ function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
|
||||
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])];
|
||||
const supportedFeatures = [...new Set(["terminal.input-drafts", "terminal.selection-sharing", "appearance.theme-packs", "metadata.brand-icon", BYTE_STREAM_FEATURE, ...PLUGIN_AI_CONTEXT_FEATURES, NETWORK_CREDENTIAL_LEASE_FEATURE, HOST_INTERACTION_FEATURE, HOST_VAULT_FEATURE, VAULT_PAGES_FEATURE, TERMINAL_SIDEBAR_FEATURE, INTERACTIVE_COMMAND_FEATURE, ...(options.supportedFeatures ?? [])])]
|
||||
.filter((feature) => feature !== DIRECTORY_ENUMERATION_FEATURE);
|
||||
if (typeof options.openPluginDirectoryHandle === "function") supportedFeatures.push(DIRECTORY_ENUMERATION_FEATURE);
|
||||
const database = new PluginDatabase(paths.database);
|
||||
let interactionService;
|
||||
let byteStreamService;
|
||||
|
||||
@@ -335,6 +335,8 @@ test("host service wires a handle-bound directory adapter without changing the R
|
||||
},
|
||||
});
|
||||
context.after(() => service.manager.shutdown());
|
||||
assert.ok(service.runtimeSupervisor.supportedFeatures.includes("filesystem.directory-enumeration"));
|
||||
assert.ok(service.packageStore.supportedFeatures.includes("filesystem.directory-enumeration"));
|
||||
const pluginManifest = {
|
||||
id: "com.example.directory",
|
||||
name: "directory",
|
||||
@@ -481,3 +483,27 @@ test("Deny for the privileged development runtime stops its activation through t
|
||||
assert.equal(stops, 1); assert.equal(current, identity); assert.equal(service.database.getActivePlugin(manifest.id).enabled, true);
|
||||
service.database.privateOwner = privateOwner;
|
||||
});
|
||||
|
||||
test("production host composition does not advertise or prompt for unavailable directory enumeration", async (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-directory-availability-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
let prompts = 0;
|
||||
const feature = "filesystem.directory-enumeration";
|
||||
// Same adapter-free composition as main/registerBridges; even an explicit extra
|
||||
// supportedFeatures entry cannot claim an adapter that was never supplied.
|
||||
const service = createPluginHostService({ ...createOptions(root), supportedFeatures: [feature],
|
||||
requestPermissionDecision: async () => { prompts++; throw new Error("must not prompt"); } });
|
||||
context.after(() => service.manager.shutdown());
|
||||
assert.equal(service.runtimeSupervisor.supportedFeatures.includes(feature), false);
|
||||
assert.equal(service.packageStore.supportedFeatures.includes(feature), false);
|
||||
let authorizations = 0, probes = 0;
|
||||
service.permissionEngine.authorize = async () => { authorizations++; throw new Error("must not authorize"); };
|
||||
service.filesystemBroker.fileSystem = new Proxy({}, { get() { probes++; throw new Error("must not probe"); } });
|
||||
const manifest = { id: "com.example.unavailable-directory", version: "1.0.0",
|
||||
permissions: { optional: [{ permission: "filesystem.read", resources: [root] }] } };
|
||||
const routes = service.rpcRegistry.createRoutes({ pluginId: manifest.id, pluginVersion: manifest.version,
|
||||
runtimeId: "runtime-unavailable", runtimeKind: "browser", manifest });
|
||||
await assert.rejects(routes.requestHandlers["filesystem.readDirectory"]({ path: root }, transportContext()),
|
||||
(error) => error.code === -32012 && /unavailable/.test(error.message));
|
||||
assert.equal(authorizations, 0); assert.equal(prompts, 0); assert.equal(probes, 0);
|
||||
});
|
||||
@@ -34,15 +34,19 @@ class MarketSourcesService {
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
async list(sourceId = "official") {
|
||||
const snapshot = await this.#catalog(sourceId).list();
|
||||
#publishCatalogIdentity(sourceId, entries) {
|
||||
// Selection tokens bind the validated entry and its delivery resources.
|
||||
const identity = JSON.stringify(snapshot.entries.map((entry) => [entry.id, entry.selectionToken]));
|
||||
const identity = JSON.stringify(entries.map((entry) => [entry.id, entry.selectionToken]));
|
||||
if (this.catalogIdentities.get(sourceId) !== identity) {
|
||||
this.catalogIdentities.set(sourceId, identity);
|
||||
this.#changed(sourceId);
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
list(sourceId = "official") {
|
||||
return this.#catalog(sourceId).list().then((snapshot) => {
|
||||
this.#publishCatalogIdentity(sourceId, snapshot.entries);
|
||||
return snapshot;
|
||||
});
|
||||
}
|
||||
readme(id, token, sourceId) { return this.#catalog(sourceId).readme(id, token); }
|
||||
async author(request) {
|
||||
@@ -74,7 +78,11 @@ class MarketSourcesService {
|
||||
}
|
||||
install(id, token, sourceId = "official", acceptedSourceId) {
|
||||
if (sourceId !== "official" && acceptedSourceId !== sourceId) throw new Error("Review and confirm this third-party source before installing");
|
||||
return this.#catalog(sourceId).install(id, token);
|
||||
const catalog = this.#catalog(sourceId);
|
||||
return catalog.install(id, token).finally(() => {
|
||||
// A rejected stale selection can still have published a newer snapshot.
|
||||
this.#publishCatalogIdentity(sourceId, catalog.publicEntries);
|
||||
});
|
||||
}
|
||||
listSources() { return this.registry.list(); }
|
||||
addSource(payload) { return this.manager.changeMarketSource(() => this.registry.add(payload?.name, payload?.address)); }
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
"use strict";
|
||||
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const test = require("node:test");
|
||||
const { PluginDatabase } = require("./database.cjs");
|
||||
const { PluginContributionService } = require("./contributionService.cjs");
|
||||
const { PluginSyncSidecarService } = require("./pluginSyncSidecarService.cjs");
|
||||
const { registerPluginBridge, CHANNELS } = require("./pluginBridge.cjs");
|
||||
const { OFFICIAL_SOURCE, thirdPartySource } = require("./sourceIdentity.cjs");
|
||||
|
||||
const id = "com.example.sync-composition";
|
||||
const synced = `${id}.synced`, local = `${id}.local`;
|
||||
const manifest = {
|
||||
manifestVersion: 1, id, name: "Synthetic sync composition", version: "1.0.0", publisher: "example",
|
||||
engines: { netcatty: ">=0.0.0", api: ">=0.1.0-internal <0.2.0" }, main: { browser: "index.js" },
|
||||
contributes: { settings: [
|
||||
{ id: synced, label: "Synced", control: "text", scope: "application", default: "default-sync", sync: true },
|
||||
{ id: local, label: "Local", control: "text", scope: "application", default: "default-local", sync: false },
|
||||
] },
|
||||
};
|
||||
function install(database, source = OFFICIAL_SOURCE, enabled = true) {
|
||||
database.installVersion({ pluginId: id, version: manifest.version, manifest, source,
|
||||
archiveSha256: "a".repeat(64), packageRelativePath: "synthetic/no-executable-package" }, { enable: enabled });
|
||||
}
|
||||
function profile(context, label, clock) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `netcatty-sync-composition-${label}-`));
|
||||
const database = new PluginDatabase(path.join(root, "plugins.sqlite"), { clock: () => clock });
|
||||
context.after(() => { database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
const runtimeSupervisor = {
|
||||
start: async () => { throw new Error("composition test must not execute a plugin"); },
|
||||
request: async () => { throw new Error("composition test must not call a plugin"); },
|
||||
notify: async () => {},
|
||||
};
|
||||
const contributions = new PluginContributionService({ database, runtimeSupervisor, getLocale: () => "en" });
|
||||
const sidecars = new PluginSyncSidecarService({ database, contributionService: contributions });
|
||||
const handlers = new Map();
|
||||
registerPluginBridge({ handle: (name, handler) => handlers.set(name, handler), on() {} }, {
|
||||
manager: { initialize: async () => {}, database }, contributionService: contributions,
|
||||
syncSidecarService: sidecars, isTrustedSender: (event) => event?.trusted === true,
|
||||
});
|
||||
return { database, contributions, sidecars,
|
||||
collect: () => handlers.get(CHANNELS.syncSidecarsCollect)({ trusted: true }),
|
||||
apply: (bundle) => handlers.get(CHANNELS.syncSidecarsApply)({ trusted: true }, bundle),
|
||||
untrustedApply: (bundle) => handlers.get(CHANNELS.syncSidecarsApply)({ trusted: false }, bundle) };
|
||||
}
|
||||
|
||||
test("two real profiles collect/apply/hydrate only opted-in source-bound settings through registered host entries", async (context) => {
|
||||
for (const state of ["enabled", "disabled", "missing", "other-source"]) {
|
||||
await context.test(state, async (context) => {
|
||||
const a = profile(context, "sender", 200), b = profile(context, "recipient", 100);
|
||||
install(a.database);
|
||||
await a.contributions.updateSetting(id, synced, "synthetic-shared-value");
|
||||
await a.contributions.updateSetting(id, local, "synthetic-device-only-value");
|
||||
const bundle = await a.collect();
|
||||
assert.equal(bundle.entries.filter((entry) => entry.kind === "settings").length, 1);
|
||||
assert.equal(bundle.entries.some((entry) => entry.value === "synthetic-device-only-value"), false);
|
||||
const owner = a.database.privateOwner(id);
|
||||
assert.equal(bundle.entries[0].pluginId, owner);
|
||||
if (state !== "missing") install(b.database,
|
||||
state === "other-source" ? thirdPartySource("Synthetic community", "https://community.example.com") : OFFICIAL_SOURCE,
|
||||
state !== "disabled");
|
||||
await assert.rejects(b.untrustedApply(bundle), /Untrusted/);
|
||||
assert.equal(b.database.listAllSyncSidecars().length, 0, "untrusted sender never applies remote rows");
|
||||
const result = await b.apply(bundle);
|
||||
assert.equal(result.applied, true);
|
||||
assert.equal(b.database.getSetting(id, local, "application", "application"), undefined);
|
||||
if (state === "enabled") {
|
||||
assert.equal(await b.contributions.getSetting(id, synced), "synthetic-shared-value");
|
||||
assert.equal(await b.contributions.getSetting(id, local), "default-local");
|
||||
} else {
|
||||
assert.equal(b.database.getSetting(id, synced, "application", "application"), undefined,
|
||||
"missing, disabled or different-source plugins cannot receive active settings");
|
||||
assert.ok(b.database.listAllSyncSidecars().some((entry) => entry.pluginId === owner), "remote row is retained without execution");
|
||||
await b.sidecars.hydrateInstalledPluginSettings(id);
|
||||
assert.equal(b.database.getSetting(id, synced, "application", "application"), undefined);
|
||||
if (state === "disabled") b.database.setEnabled(id, true);
|
||||
if (state === "missing") install(b.database);
|
||||
await b.sidecars.hydrateInstalledPluginSettings(id);
|
||||
assert.equal(await b.contributions.getSetting(id, synced), state === "other-source" ? "default-sync" : "synthetic-shared-value");
|
||||
if (state === "other-source") assert.notEqual(b.database.privateOwner(id), owner);
|
||||
}
|
||||
const collected = await b.collect();
|
||||
assert.equal(collected.entries.some((entry) => entry.value === "synthetic-device-only-value"), false);
|
||||
assert.equal(await a.contributions.getSetting(id, local), "synthetic-device-only-value", "sender's local-only setting stays local");
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
PLUGIN_ERROR_WIRE_CODES,
|
||||
pluginErrorToRpcError,
|
||||
} from "@netcatty/plugin-sdk";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS } from "@netcatty/plugin-contract";
|
||||
import { createMessagePortStreamEnvelope, PLUGIN_HOST_INTERACTION_LIMITS, PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";
|
||||
import { createPluginStreamEndpoint } from "./pluginStreamEndpoint.mjs";
|
||||
import { connectByteStream } from "./byteStreamClient.mjs";
|
||||
|
||||
@@ -412,8 +412,12 @@ function createPluginContext(config, client, runtimeApi) {
|
||||
...options,
|
||||
}).then(() => undefined),
|
||||
stat: (filePath) => client.request("filesystem.stat", { path: filePath }),
|
||||
readDirectory: (directoryPath) => client.request("filesystem.readDirectory", { path: directoryPath })
|
||||
.then((result) => result.entries),
|
||||
readDirectory: async (directoryPath) => {
|
||||
if (!config.enabledFeatures?.includes(PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE)) {
|
||||
throw new PluginError("unsupported", "Secure plugin directory enumeration is unavailable on this runtime");
|
||||
}
|
||||
return (await client.request("filesystem.readDirectory", { path: directoryPath })).entries;
|
||||
},
|
||||
};
|
||||
const companions = {
|
||||
start: async (companionId) => {
|
||||
|
||||
@@ -78,7 +78,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
|
||||
pluginVersion: "1.0.0",
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
enabledFeatures: [],
|
||||
enabledFeatures: ["filesystem.directory-enumeration"],
|
||||
},
|
||||
async loadPlugin() {
|
||||
return {
|
||||
@@ -128,7 +128,7 @@ test("runtime peer exposes secure host capabilities over the canonical RPC trans
|
||||
const initialized = await host.request("plugin.initialize", {
|
||||
netcattyVersion: "1.0.0",
|
||||
apiVersion: "0.1.0-internal",
|
||||
supportedFeatures: [],
|
||||
supportedFeatures: ["filesystem.directory-enumeration"],
|
||||
});
|
||||
assert.equal(initialized.pluginId, "com.example.peer");
|
||||
const unhandledStream = await host.streams.openOutgoing("unhandled", 1024);
|
||||
@@ -616,3 +616,23 @@ test("runtime peer routes connection and importer Provider operation maps", asyn
|
||||
await peer.dispose();
|
||||
host.close();
|
||||
});
|
||||
|
||||
test("actual SDK directory call rejects an unnegotiated feature without sending host RPC", async (context) => {
|
||||
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
|
||||
const { port1, port2 } = new MessageChannel();
|
||||
let requests = 0;
|
||||
const host = new PluginRpcRouter({ pluginId: "com.example.no-directory", send: (message) => port1.postMessage(message),
|
||||
handlers: { "filesystem.readDirectory": async () => { requests++; return { entries: [] }; }, "log.write": async () => null } });
|
||||
port1.on("message", (message) => host.accept(message));
|
||||
const peer = await startPluginRuntime({ port: port2,
|
||||
config: { pluginId: "com.example.no-directory", pluginVersion: "1.0.0", netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", enabledFeatures: [] },
|
||||
async loadPlugin() { return { default: { async activate(pluginContext) {
|
||||
assert.equal(pluginContext.enabledFeatures.has("filesystem.directory-enumeration"), false);
|
||||
await assert.rejects(pluginContext.filesystem.readDirectory("/not-probed"),
|
||||
(error) => error.code === "unsupported" && /unavailable/.test(error.message));
|
||||
} } }; } });
|
||||
context.after(async () => { await peer.dispose(); host.close(); port1.close(); port2.close(); });
|
||||
await host.request("plugin.initialize", { netcattyVersion: "1.0.0", apiVersion: "0.1.0-internal", supportedFeatures: [] });
|
||||
await host.request("plugin.activate", {});
|
||||
assert.equal(requests, 0);
|
||||
});
|
||||
@@ -127,7 +127,7 @@ function registerSecurePluginCapabilities(registry, options) {
|
||||
options.filesystemBroker.readDirectory(params, context)
|
||||
), {
|
||||
metadata: { capability: "filesystem", mutating: false, permission: "filesystem.read" },
|
||||
validateParams: (params) => options.filesystemBroker.validatePath(params),
|
||||
validateParams: (params) => options.filesystemBroker.validateDirectory(params),
|
||||
authorization: (params) => options.filesystemBroker.describeReadAuthorization(params, "directory"),
|
||||
});
|
||||
registry.registerRequest("filesystem.writeFile", (params, context) => (
|
||||
|
||||
@@ -1155,6 +1155,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);
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test, { type TestContext } from 'node:test';
|
||||
import { createRequire } from 'node:module';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { PluginAIHostRequest, PluginSelectedContextContent, PluginSelectedContextListResult, PluginSelectedContextRef, PluginSelectedContextSummary } from '@netcatty/plugin-contract';
|
||||
import type { TerminalSession, Workspace } from '../../../domain/models';
|
||||
import { buildSelectedPluginSources, type SelectedSnapshot } from './selectedSources';
|
||||
import { createTrustedPluginAIHostRuntime } from './trustedHostRuntime';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
const { PluginDatabase } = require('../../../electron/plugins/database.cjs');
|
||||
const { PluginPermissionEngine } = require('../../../electron/plugins/permissionEngine.cjs');
|
||||
const { PluginAIContextService } = require('../../../electron/plugins/ai/aiContextService.cjs');
|
||||
const { PluginAITrustedHostAdapter, PLUGIN_AI_HOST_CHANNELS: CHANNELS } = require('../../../electron/plugins/ai/trustedHostAdapter.cjs');
|
||||
const { PluginHostRpcRegistry } = require('../../../electron/plugins/hostRpcRegistry.cjs');
|
||||
const { RPC_ERRORS } = require('../../../electron/plugins/rpcRouter.cjs');
|
||||
const staleContextError = { code: RPC_ERRORS.permissionDenied, message: /^Selected context (changed|is unavailable)$/ };
|
||||
|
||||
function fixture(t: TestContext) {
|
||||
const a: TerminalSession = { id: 'a', hostId: 'host-a', hostLabel: 'Synthetic A', hostname: 'a.example.invalid', username: 'synthetic-a', status: 'connected' };
|
||||
const b: TerminalSession = { id: 'b', hostId: 'host-b', hostLabel: 'Synthetic B', hostname: 'b.example.invalid', username: 'synthetic-b', status: 'connected' };
|
||||
const workspace: Workspace = { id: 'workspace', title: 'Synthetic workspace', focusedSessionId: 'a',
|
||||
root: { id: 'split', type: 'split', direction: 'horizontal', children: [{ id: 'pane-a', type: 'pane', sessionId: a.id }, { id: 'pane-b', type: 'pane', sessionId: b.id }] } };
|
||||
let snapshot: SelectedSnapshot = { activeTabId: workspace.id, sessions: [a, b], workspaces: [workspace],
|
||||
note: { id: 'note', title: 'Synthetic Note', content: 'Synthetic Note text', createdAt: 1, updatedAt: 1 } };
|
||||
const renderer = createTrustedPluginAIHostRuntime({ getModel: () => null, prepareModel: async () => {}, isActive: () => true,
|
||||
chooseSelected: async () => buildSelectedPluginSources(() => snapshot) });
|
||||
const requests: string[] = [];
|
||||
const sender = Object.assign(new EventEmitter(), { isDestroyed: () => false,
|
||||
send: (channel: string, request: PluginAIHostRequest) => {
|
||||
assert.equal(channel, CHANNELS.request); requests.push(request.kind);
|
||||
void renderer.handle(request.kind, request.payload, request.runtimeId).then(
|
||||
(result) => host.respond(sender, { requestId: request.requestId, actionToken: request.actionToken, result }),
|
||||
() => host.respond(sender, { requestId: request.requestId, actionToken: request.actionToken, error: true }),
|
||||
).catch(() => {});
|
||||
} });
|
||||
const window = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
|
||||
const host = new PluginAITrustedHostAdapter({ getWindow: () => window, isLocked: () => false, cancelProviderStreams: () => {}, timeoutMs: 1000 });
|
||||
host.registerIpc({ handle() {}, removeHandler() {} }); host.setReady(sender, true);
|
||||
let invoked = 0;
|
||||
// Only inference is synthetic; selection, IPC nonce/canonical validation,
|
||||
// source ownership, permission middleware and both public RPC routes are real.
|
||||
host.selectModel = async () => ({ label: 'Synthetic model', isCurrent: () => true, invoke: async () => {
|
||||
invoked++; return (async function* () { yield { kind: 'text', text: 'Synthetic response' }; })();
|
||||
} });
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-selected-focus-'));
|
||||
const database = new PluginDatabase(path.join(root, 'plugins.sqlite'));
|
||||
const manifest = { manifestVersion: 1, id: 'com.example.selected-focus', name: 'Selected focus fixture', version: '1.0.0', publisher: 'example',
|
||||
engines: { netcatty: '>=0.0.0', api: '>=0.1.0-internal <0.2.0' }, main: { browser: 'no-plugin-is-executed.js' },
|
||||
features: { required: ['ai.model-invocation', 'context.selected-read'] },
|
||||
permissions: { optional: ['ai.invoke', 'context.selected.list', 'context.selected.read'] } };
|
||||
database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: 'a'.repeat(64), packageRelativePath: 'synthetic' }, { enable: true });
|
||||
database.setRuntimeState(manifest.id, 'running', { runtimeKind: 'browser' });
|
||||
const identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: 'synthetic-runtime', runtimeKind: 'browser', manifest };
|
||||
const permissions = new PluginPermissionEngine({ database });
|
||||
const service = new PluginAIContextService({ database, permissionEngine: permissions, hostAdapter: host, getRuntimeIdentity: () => identity });
|
||||
const registry = new PluginHostRpcRegistry(); registry.use(permissions.createMiddleware()); service.registerRpcCapabilities(registry);
|
||||
const routes = registry.createRoutes(identity);
|
||||
const call = (method: string, params: Record<string, unknown> = {}) => routes.requestHandlers[method](params, { signal: new AbortController().signal });
|
||||
const reference = (item: PluginSelectedContextSummary): PluginSelectedContextRef => ({ handle: item.handle, version: item.version });
|
||||
t.after(() => { service.shutdown(); host.shutdown(); renderer.shutdown(); permissions.shutdown(); database.close(); fs.rmSync(root, { recursive: true, force: true }); });
|
||||
return { call, requests, invoked: () => invoked, snapshot: () => snapshot,
|
||||
list: async () => (await call('context.selected.list') as PluginSelectedContextListResult).items,
|
||||
read: async (item: PluginSelectedContextSummary) => await call('context.selected.read', reference(item)) as PluginSelectedContextContent,
|
||||
start: async (item: PluginSelectedContextSummary) => { const chat = await call('ai.openChat'); return call('ai.startTurn', { chatHandle: chat.chatHandle, prompt: 'Synthetic summary', contexts: [reference(item)] }); },
|
||||
focus: (id: string) => { snapshot = { ...snapshot, workspaces: snapshot.workspaces.map((item) => ({ ...item, focusedSessionId: id })) }; },
|
||||
updateWorkspace: (change: Partial<Workspace>) => { snapshot = { ...snapshot, workspaces: snapshot.workspaces.map((item) => ({ ...item, ...change })) }; },
|
||||
};
|
||||
}
|
||||
|
||||
for (const scope of ['session', 'host']) for (const operation of ['read', 'start']) {
|
||||
test(`same-workspace A-to-B focus invalidates selected ${scope} through registered ${operation}`, async (t) => {
|
||||
const f = fixture(t); const item = (await f.list()).find((entry) => entry.scope === scope)!;
|
||||
assert.equal(JSON.parse((await f.read(item)).text).label, 'Synthetic A');
|
||||
const before = f.snapshot(); f.focus('b');
|
||||
assert.equal(f.snapshot().activeTabId, before.activeTabId);
|
||||
assert.equal(f.snapshot().sessions, before.sessions, 'real focus mutation leaves session objects unchanged');
|
||||
await assert.rejects(operation === 'read' ? f.read(item) : f.start(item), staleContextError);
|
||||
assert.equal(f.invoked(), 0, 'a stale metadata handle must not reach inference');
|
||||
assert.ok(f.requests.includes('context.select') && f.requests.includes('context.current'));
|
||||
});
|
||||
}
|
||||
|
||||
test('focus validation remains kind-specific and host re-selection yields current B metadata', async (t) => {
|
||||
const f = fixture(t); const items = await f.list();
|
||||
const byScope = (scope: string) => items.find((item) => item.scope === scope)!;
|
||||
f.updateWorkspace({ viewMode: 'focus' });
|
||||
for (const scope of ['session', 'host']) assert.equal(JSON.parse((await f.read(byScope(scope))).text).label, 'Synthetic A');
|
||||
await assert.rejects(f.read(byScope('workspace')), staleContextError, 'existing workspace revision semantics remain');
|
||||
f.focus('b');
|
||||
for (const scope of ['session', 'host']) await assert.rejects(f.read(byScope(scope)), staleContextError);
|
||||
assert.equal(JSON.parse((await f.read(byScope('tab'))).text).kind, 'workspace', 'the workspace tab remains selected');
|
||||
assert.equal((await f.read(byScope('note'))).text, 'Synthetic Note text', 'explicit Note choice is independent of terminal focus');
|
||||
const fresh = await f.list();
|
||||
for (const scope of ['session', 'host']) {
|
||||
const item = fresh.find((entry) => entry.scope === scope)!;
|
||||
assert.notEqual(item.handle, byScope(scope).handle);
|
||||
assert.equal(JSON.parse((await f.read(item)).text).label, 'Synthetic B');
|
||||
}
|
||||
await f.start(fresh.find((entry) => entry.scope === 'session')!);
|
||||
assert.equal(f.invoked(), 1, 'fresh current context still reaches the synthetic model through the registered route');
|
||||
});
|
||||
|
||||
test('implicit first-pane fallback is re-resolved when the selected workspace pane changes', async (t) => {
|
||||
const f = fixture(t); f.updateWorkspace({ focusedSessionId: undefined });
|
||||
const item = (await f.list()).find((entry) => entry.scope === 'session')!;
|
||||
assert.equal(JSON.parse((await f.read(item)).text).label, 'Synthetic A');
|
||||
f.focus('b'); await assert.rejects(f.read(item), staleContextError);
|
||||
});
|
||||
@@ -9,12 +9,17 @@ export interface SelectedSnapshot {
|
||||
note: VaultNote | null;
|
||||
}
|
||||
|
||||
function selectedSessionId(snapshot: SelectedSnapshot): string | undefined {
|
||||
const workspace = snapshot.workspaces.find((item) => item.id === snapshot.activeTabId);
|
||||
return workspace?.focusedSessionId ?? (workspace ? collectSessionIds(workspace.root)[0] : snapshot.activeTabId);
|
||||
}
|
||||
|
||||
// Receives host-selected snapshots only. No Host/Vault DTO serialization,
|
||||
// inventory reader, terminal scrollback, credential fields, or command access.
|
||||
export function buildSelectedPluginSources(getSnapshot: () => SelectedSnapshot): HostSelectedSource[] {
|
||||
const snapshot = getSnapshot();
|
||||
const workspace = snapshot.workspaces.find((item) => item.id === snapshot.activeTabId);
|
||||
const sessionId = workspace?.focusedSessionId ?? (workspace ? collectSessionIds(workspace.root)[0] : snapshot.activeTabId);
|
||||
const sessionId = selectedSessionId(snapshot);
|
||||
const session = snapshot.sessions.find((item) => item.id === sessionId);
|
||||
const sources: HostSelectedSource[] = [];
|
||||
const add = (scope: HostSelectedSource['scope'], key: string, title: string, value: unknown, current: () => boolean) => {
|
||||
@@ -29,7 +34,11 @@ export function buildSelectedPluginSources(getSnapshot: () => SelectedSnapshot):
|
||||
if (workspace || session) add('tab', snapshot.activeTabId, workspace?.title ?? session.hostLabel,
|
||||
{ title: workspace?.title ?? session.hostLabel, kind: workspace ? 'workspace' : 'terminal' }, tabCurrent);
|
||||
if (session) {
|
||||
const current = () => tabCurrent() && getSnapshot().sessions.find((item) => item.id === session.id) === session;
|
||||
const current = () => {
|
||||
const latest = getSnapshot();
|
||||
return latest.activeTabId === snapshot.activeTabId && selectedSessionId(latest) === session.id
|
||||
&& latest.sessions.find((item) => item.id === session.id) === session;
|
||||
};
|
||||
add('session', session.id, session.hostLabel, { label: session.hostLabel, status: session.status, protocol: session.protocol ?? 'ssh' }, current);
|
||||
add('host', session.hostId || session.id, session.hostLabel,
|
||||
{ label: session.hostLabel, hostname: session.hostname, username: session.username, port: session.port, protocol: session.protocol ?? 'ssh' }, current);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
@@ -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<Monaco>();
|
||||
|
||||
/** 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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
+11
-1
@@ -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) }))
|
||||
|
||||
@@ -4976,6 +4976,9 @@
|
||||
"TerminalInputDraftFeature": {
|
||||
"const": "terminal.input-drafts"
|
||||
},
|
||||
"FilesystemDirectoryEnumerationFeature": {
|
||||
"const": "filesystem.directory-enumeration"
|
||||
},
|
||||
"HostTerminalInputProposalRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
|
||||
@@ -194,6 +194,8 @@ export type CredentialRef = {
|
||||
|
||||
export type FeatureId = string;
|
||||
|
||||
export type FilesystemDirectoryEnumerationFeature = "filesystem.directory-enumeration";
|
||||
|
||||
export type HostClipboardReadRequest = {
|
||||
kind: "clipboard.read";
|
||||
title: string;
|
||||
|
||||
@@ -69,3 +69,4 @@ export const PLUGIN_THEME_PACK_FEATURE = "appearance.theme-packs" as const satis
|
||||
export const PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE = "terminal.selection-sharing" as const satisfies import("./generated/plugin-contract.js").TerminalSelectionSharingFeature;
|
||||
|
||||
export const PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE = "terminal.input-drafts" as const satisfies import("./generated/plugin-contract.js").TerminalInputDraftFeature;
|
||||
export const PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE = "filesystem.directory-enumeration" as const satisfies import("./generated/plugin-contract.js").FilesystemDirectoryEnumerationFeature;
|
||||
@@ -712,6 +712,7 @@ export interface PluginFilesystemClient {
|
||||
overwrite: true;
|
||||
}>): Promise<void>;
|
||||
stat(path: string): Promise<PluginFilesystemStat>;
|
||||
/** Requires filesystem.directory-enumeration and an authorized filesystem.read directory. */
|
||||
readDirectory(path: string): Promise<readonly PluginFilesystemEntry[]>;
|
||||
}
|
||||
|
||||
@@ -982,3 +983,4 @@ export { PLUGIN_THEME_PACK_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_TERMINAL_SELECTION_SHARING_FEATURE } from "@netcatty/plugin-contract";
|
||||
|
||||
export { PLUGIN_TERMINAL_INPUT_DRAFT_FEATURE } from "@netcatty/plugin-contract";
|
||||
export { PLUGIN_FILESYSTEM_DIRECTORY_ENUMERATION_FEATURE } from "@netcatty/plugin-contract";
|
||||
+1
@@ -47,6 +47,7 @@ declare global {
|
||||
setTheme?(theme: 'light' | 'dark' | 'system'): Promise<boolean>;
|
||||
setBackgroundColor?(color: string): Promise<boolean>;
|
||||
setWindowOpacity?(opacity: number): Promise<boolean>;
|
||||
setWindowBackgroundBlur?(enabled: boolean): Promise<boolean>;
|
||||
setAppIconVariant?(variant: import('../../domain/appIconVariant').AppIconVariant): Promise<boolean>;
|
||||
setLanguage?(language: string): Promise<boolean>;
|
||||
// Window controls for custom title bar (Windows/Linux)
|
||||
|
||||
Reference in new issue
Block a user