mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-10 16:21:22 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2096e93420 | ||
|
|
56f0b55b0e | ||
|
|
2c84f9cb0f | ||
|
|
ea62a0c31e | ||
|
|
d27dbbcd1d |
No files matched your search
@@ -330,9 +330,6 @@ jobs:
|
||||
APPLE_TEAM_ID: ${{ matrix.name == 'macos' && secrets.APPLE_TEAM_ID || '' }}
|
||||
run: npm run ${{ matrix.pack_script }}
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Test tray panel layout
|
||||
run: npm run test:tray-panel-layout
|
||||
|
||||
@@ -634,9 +631,6 @@ jobs:
|
||||
done
|
||||
npm run pack:linux-x64
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify x64
|
||||
|
||||
@@ -771,9 +765,6 @@ jobs:
|
||||
ELECTRON_BUILDER_PUBLISH: "never"
|
||||
run: npm run pack:linux-arm64
|
||||
|
||||
- name: Test packaged SSH cipher negotiation
|
||||
run: node scripts/test-packaged-ssh-ciphers.cjs
|
||||
|
||||
- name: Verify packaged node-pty Linux runtime
|
||||
run: bash scripts/ensure-node-pty-linux.sh verify arm64
|
||||
|
||||
|
||||
@@ -52,11 +52,6 @@ jobs:
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
- name: Test SSH cipher negotiation in Electron's crypto runtime
|
||||
env:
|
||||
NETCATTY_SSH_TEST_RUNTIME: electron
|
||||
run: node --test electron/bridges/sshChaChaTransport.test.cjs
|
||||
|
||||
- name: Test browser first-link lifecycle
|
||||
run: xvfb-run -a ./node_modules/.bin/electron --no-sandbox electron/hostBrowser/hostBrowser.firstLink.live.test.cjs
|
||||
|
||||
@@ -143,10 +138,5 @@ jobs:
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test SSH cipher negotiation in Windows Electron's crypto runtime
|
||||
env:
|
||||
NETCATTY_SSH_TEST_RUNTIME: electron
|
||||
run: node --test electron/bridges/sshChaChaTransport.test.cjs
|
||||
|
||||
- name: Exercise two SSH password prompts in the desktop app
|
||||
run: node scripts/terminal-broadcast-ssh.live.cjs
|
||||
@@ -556,8 +556,6 @@ 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',
|
||||
|
||||
@@ -975,9 +975,6 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Seconds to wait for the network connection to this host.',
|
||||
'hostDetails.sshTimeouts.authReady': 'SSH auth timeout',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Seconds to wait after connecting for SSH negotiation and authentication to finish.',
|
||||
'hostDetails.sshInputCompatibility': 'Input compatibility: send character by character',
|
||||
'hostDetails.sshInputCompatibility.desc': 'Try enabling this if Chinese input or short pastes lose characters.',
|
||||
'hostDetails.sshInputCompatibility.reconnect': 'Saved changes take effect after reconnecting.',
|
||||
'hostDetails.backspaceBehavior': 'Backspace Behavior',
|
||||
'hostDetails.backspaceBehavior.default': 'Default',
|
||||
'hostDetails.jumpHosts': 'Proxy via Hosts',
|
||||
|
||||
@@ -556,8 +556,6 @@ 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',
|
||||
|
||||
@@ -965,9 +965,6 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Segundos de espera para la conexión de red a este host.',
|
||||
'hostDetails.sshTimeouts.authReady': 'Tiempo de espera de autenticación SSH',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Segundos de espera después de conectar para que terminen la negociación y la autenticación SSH.',
|
||||
'hostDetails.sshInputCompatibility': 'Compatibilidad de entrada: enviar carácter por carácter',
|
||||
'hostDetails.sshInputCompatibility.desc': 'Actívalo si se pierden caracteres al escribir chino o pegar textos cortos.',
|
||||
'hostDetails.sshInputCompatibility.reconnect': 'Los cambios guardados se aplican al volver a conectar.',
|
||||
'hostDetails.backspaceBehavior': 'Comportamiento de la tecla Retroceso',
|
||||
'hostDetails.backspaceBehavior.default': 'Predeterminado',
|
||||
'hostDetails.jumpHosts': 'Proxy mediante hosts',
|
||||
|
||||
@@ -525,8 +525,6 @@ 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': 'Оригинал',
|
||||
|
||||
@@ -1014,9 +1014,6 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': 'Сколько секунд ждать сетевого подключения к этому хосту.',
|
||||
'hostDetails.sshTimeouts.authReady': 'Тайм-аут SSH-входа',
|
||||
'hostDetails.sshTimeouts.authReady.desc': 'Сколько секунд ждать согласования SSH и завершения аутентификации после подключения.',
|
||||
'hostDetails.sshInputCompatibility': 'Совместимость ввода: отправка по символам',
|
||||
'hostDetails.sshInputCompatibility.desc': 'Включите, если при вводе китайского текста или короткой вставке теряются символы.',
|
||||
'hostDetails.sshInputCompatibility.reconnect': 'Сохранённые изменения применяются после повторного подключения.',
|
||||
'hostDetails.backspaceBehavior': 'Поведение Backspace',
|
||||
'hostDetails.backspaceBehavior.default': 'По умолчанию',
|
||||
'hostDetails.jumpHosts': 'Прокси через хосты',
|
||||
|
||||
@@ -560,8 +560,6 @@ 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': '原始',
|
||||
|
||||
@@ -384,9 +384,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待与该主机建立网络连接的最长时间。',
|
||||
'hostDetails.sshTimeouts.authReady': '认证超时(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '网络连接建立后,等待 SSH 协商和认证完成的最长时间。',
|
||||
'hostDetails.sshInputCompatibility': '输入兼容模式:逐字发送',
|
||||
'hostDetails.sshInputCompatibility.desc': '中文输入或短粘贴出现丢字时尝试开启。',
|
||||
'hostDetails.sshInputCompatibility.reconnect': '保存后重新连接生效。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行为',
|
||||
'hostDetails.backspaceBehavior.default': '默认',
|
||||
'hostDetails.jumpHosts': '通过主机代理',
|
||||
|
||||
@@ -562,8 +562,6 @@ 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': '原始',
|
||||
|
||||
@@ -384,9 +384,6 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.sshTimeouts.tcpConnect.desc': '等待與該主機建立網路連線的最長時間。',
|
||||
'hostDetails.sshTimeouts.authReady': '驗證逾時(秒)',
|
||||
'hostDetails.sshTimeouts.authReady.desc': '網路連線建立後,等待 SSH 交握和驗證完成的最長時間。',
|
||||
'hostDetails.sshInputCompatibility': '輸入相容模式:逐字傳送',
|
||||
'hostDetails.sshInputCompatibility.desc': '中文輸入或短貼上出現漏字時嘗試開啟。',
|
||||
'hostDetails.sshInputCompatibility.reconnect': '儲存後重新連線生效。',
|
||||
'hostDetails.backspaceBehavior': 'Backspace 行為',
|
||||
'hostDetails.backspaceBehavior.default': '預設',
|
||||
'hostDetails.jumpHosts': '透過主機代理',
|
||||
|
||||
@@ -49,7 +49,6 @@ 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,
|
||||
@@ -100,7 +99,6 @@ 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>>;
|
||||
@@ -150,7 +148,6 @@ export function useSettingsIpcSync({
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
@@ -282,9 +279,6 @@ 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));
|
||||
@@ -377,7 +371,6 @@ export function useSettingsIpcSync({
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
|
||||
@@ -53,7 +53,6 @@ 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';
|
||||
@@ -117,7 +116,6 @@ interface UseSettingsStorageSyncParams {
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
windowBackgroundBlur: boolean;
|
||||
appIconVariant: AppIconVariant;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -168,7 +166,6 @@ 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'>>;
|
||||
@@ -187,7 +184,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, windowBackgroundBlur, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
@@ -196,7 +193,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, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
@@ -211,7 +208,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, windowBackgroundBlur, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent, customAccentVersion,
|
||||
@@ -221,7 +218,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, windowBackgroundBlur, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -526,12 +523,6 @@ 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) {
|
||||
@@ -575,7 +566,6 @@ export function useSettingsStorageSync({
|
||||
setGlobalHotkeyEnabled,
|
||||
setHostClickBehaviorState,
|
||||
setWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
|
||||
@@ -7,7 +7,6 @@ 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';
|
||||
@@ -18,8 +17,6 @@ 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,
|
||||
@@ -50,7 +47,6 @@ interface UseSystemSettingsEffectsParams {
|
||||
autoLaunchEnabled: boolean;
|
||||
windowOpacityRecord: WindowOpacityRecord;
|
||||
windowOpacityMutationSourceRef: MutableRefObject<WindowOpacityMutationSource>;
|
||||
windowBackgroundBlur: boolean;
|
||||
appIconVariant: AppIconVariant;
|
||||
autoUpdateEnabled: boolean;
|
||||
httpNetworkProxy: HttpNetworkProxySettings;
|
||||
@@ -72,7 +68,6 @@ export function useSystemSettingsEffects({
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
windowBackgroundBlur,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
httpNetworkProxy,
|
||||
@@ -305,49 +300,6 @@ 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;
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
@@ -73,7 +73,6 @@ 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,
|
||||
@@ -644,9 +643,6 @@ 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);
|
||||
@@ -1278,7 +1274,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity: applyIncomingWindowOpacity,
|
||||
setWindowBackgroundBlur,
|
||||
setAppIconVariant,
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
@@ -1301,18 +1296,12 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setSshTransportIdleTtlMsState,
|
||||
});
|
||||
|
||||
// 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.
|
||||
// Detached sessions share opacity 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);
|
||||
}
|
||||
@@ -1386,7 +1375,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, windowBackgroundBlur, appIconVariant,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity, appIconVariant,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode,
|
||||
applyIncomingCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
@@ -1396,7 +1385,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, setWindowBackgroundBlur, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity: applyIncomingWindowOpacity, setAppIconVariant, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, setSshTransportIdleTtlMsState,
|
||||
applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
@@ -1905,7 +1894,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
autoLaunchEnabled,
|
||||
windowOpacityRecord,
|
||||
windowOpacityMutationSourceRef,
|
||||
windowBackgroundBlur,
|
||||
appIconVariant,
|
||||
autoUpdateEnabled,
|
||||
httpNetworkProxy,
|
||||
@@ -2285,8 +2273,6 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
toggleWindowOpacity,
|
||||
windowBackgroundBlur,
|
||||
setWindowBackgroundBlur,
|
||||
appIconVariant,
|
||||
setAppIconVariant,
|
||||
rehydrateAllFromStorage,
|
||||
|
||||
@@ -335,7 +335,6 @@ export const useTerminalBackend = () => {
|
||||
contextViewportSnapshot: string;
|
||||
contextScrollbackSnapshot: string;
|
||||
alternateScreen: boolean;
|
||||
perCharacterInput?: boolean;
|
||||
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
|
||||
kittyKeyboardProtocolEnabled?: boolean;
|
||||
},
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
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 { installBrowserLockFixture } from "../testSupport/browserLockFixture";
|
||||
import type { Host } from "../../domain/models";
|
||||
import { STORAGE_KEY_HOSTS } from "../../infrastructure/config/storageKeys";
|
||||
|
||||
// Exercise actual vault save/load/JSON import and sync boundaries, including
|
||||
// the asynchronous encrypted-field adapter (without a native safeStorage key).
|
||||
test("host input choices survive save, restart, duplicate, backup and cloud sync", async () => {
|
||||
const dom = new JSDOM("", { url: "http://localhost" });
|
||||
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
|
||||
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
Object.defineProperties(globalThis, {
|
||||
window: { value: dom.window, configurable: true },
|
||||
localStorage: { value: dom.window.localStorage, configurable: true },
|
||||
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
|
||||
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
|
||||
});
|
||||
installBrowserLockFixture();
|
||||
const { useVaultState } = await import("./useVaultState");
|
||||
const { buildSyncPayload, applySyncPayload, buildLocalVaultPayload, applyLocalVaultPayload } = await import("../syncPayload");
|
||||
const { buildVaultHostFromDraft } = await import("../../domain/vaultHostCreate");
|
||||
const legacy: Host = { id: "legacy", label: "Legacy", hostname: "localhost", username: "agent", protocol: "ssh", tags: [], os: "linux" };
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_HOSTS, JSON.stringify([legacy]));
|
||||
let state: ReturnType<typeof useVaultState>;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
|
||||
const mount = async () => {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
}
|
||||
assert.equal(state!.isInitialized, true);
|
||||
await act(async () => { await state!.readPersistedHosts(); });
|
||||
};
|
||||
const choices = () => state!.hosts.map(host => [host.id, host.sshInputCompatibility]);
|
||||
try {
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["legacy", true]]);
|
||||
const created = buildVaultHostFromDraft({ hostname: "new.example.test", username: "agent" });
|
||||
if (created.ok === false) throw Error(created.error);
|
||||
assert.equal(created.ok, true);
|
||||
const standard = { ...created.host, id: "standard" };
|
||||
await act(async () => { await state!.updateHosts([...state!.hosts, standard]); });
|
||||
assert.deepEqual(choices(), [["legacy", true], ["standard", false]]);
|
||||
const duplicate = { ...state!.hosts[0], id: "copy", label: "Legacy copy" };
|
||||
await act(async () => { await state!.updateHosts([...state!.hosts, duplicate]); });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["legacy", true], ["standard", false], ["copy", true]]);
|
||||
// A later deliberate false must survive migration, including another boot.
|
||||
await act(async () => { await state!.updateHosts(state!.hosts.map(host => host.id === "legacy" ? { ...host, sshInputCompatibility: false } : host)); });
|
||||
const expected = [["legacy", false], ["standard", false], ["copy", true]];
|
||||
const exported = JSON.stringify(state!.exportData());
|
||||
await act(async () => { await state!.updateHosts([]); await state!.importDataFromString(exported); });
|
||||
assert.deepEqual(choices(), expected);
|
||||
// Use the real sync payload builder and apply importer, not JSON alone.
|
||||
const cloud = JSON.parse(JSON.stringify(buildSyncPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
|
||||
await act(async () => { await state!.updateHosts([]); await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
|
||||
assert.deepEqual(choices(), expected);
|
||||
const backup = JSON.parse(JSON.stringify(buildLocalVaultPayload({ ...state!.exportData(), identities: state!.identities, knownHosts: state!.knownHosts })));
|
||||
await act(async () => { await state!.updateHosts([]); await applyLocalVaultPayload(backup, { importVaultData: state!.importDataFromString }); });
|
||||
assert.deepEqual(choices(), expected);
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), expected);
|
||||
assert.deepEqual((await state!.readPersistedHosts()).map(host => [host.id, host.sshInputCompatibility]), expected);
|
||||
// Older cloud snapshots enter through the same per-record migration.
|
||||
cloud.hosts = [{ ...legacy, id: "cloud-legacy" }];
|
||||
await act(async () => { await applySyncPayload(cloud, { importVaultData: state!.importDataFromString }); });
|
||||
assert.deepEqual(choices(), [["cloud-legacy", true]]);
|
||||
await act(async () => { await state!.updateHosts(state!.hosts.map(host => ({ ...host, sshInputCompatibility: false }))); });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["cloud-legacy", false]]);
|
||||
// These drafts enter HostDetailsPanel as initialData, then the real vault
|
||||
// save/normalization path. They must not be mistaken for legacy records.
|
||||
const { buildSshDeepLinkHostDraft, parseSshDeepLink } = await import("../../domain/sshDeepLink");
|
||||
const { normalizePluginImporterRecords } = await import("../../domain/pluginImporter");
|
||||
const linkDraft = buildSshDeepLinkHostDraft(parseSshDeepLink("ssh://agent@new.test")!, { id: "new-link", now: 1 });
|
||||
const imported = normalizePluginImporterRecords([undefined, false, true].map(choice => ({
|
||||
type: "draft" as const, draft: { kind: "host" as const, value: {
|
||||
hostname: `import-${choice}.test`, protocol: "ssh",
|
||||
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
|
||||
} },
|
||||
})));
|
||||
assert.deepEqual(imported.errors, []);
|
||||
const explicitCopy = { ...imported.hosts[2], id: "copy-explicit-input" };
|
||||
const drafts = [linkDraft, ...imported.hosts, explicitCopy];
|
||||
const draftChoices = drafts.map(host => [host.id, host.sshInputCompatibility]);
|
||||
assert.deepEqual(drafts.map(host => host.sshInputCompatibility), [false, false, false, true, true]);
|
||||
await act(async () => { await state!.updateHosts([...state!.hosts, ...drafts]); });
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
await mount();
|
||||
assert.deepEqual(choices(), [["cloud-legacy", false], ...draftChoices]);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
dom.window.close();
|
||||
for (const key of globals) {
|
||||
const descriptor = previous.get(key);
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1570,7 +1570,6 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
|
||||
group: "",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
sshInputCompatibility: false,
|
||||
order: getNextVaultOrder(hosts),
|
||||
};
|
||||
|
||||
|
||||
@@ -503,19 +503,6 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
enabled={!!form.showLineTimestamps}
|
||||
onToggle={() => update("showLineTimestamps", !form.showLineTimestamps)}
|
||||
/>
|
||||
{(form.protocol === undefined || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
<>
|
||||
<ToggleRow
|
||||
label={t("hostDetails.sshInputCompatibility")}
|
||||
hint={t("hostDetails.sshInputCompatibility.desc")}
|
||||
enabled={form.sshInputCompatibility ?? true}
|
||||
onToggle={() => update("sshInputCompatibility", !(form.sshInputCompatibility ?? true))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("hostDetails.sshInputCompatibility.reconnect")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
<HostDetailsSettingRow label={t("hostDetails.backspaceBehavior")}>
|
||||
<Select
|
||||
value={form.backspaceBehavior ?? "default"}
|
||||
|
||||
@@ -182,7 +182,6 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
os: "linux",
|
||||
authMethod: undefined,
|
||||
authPolicyVersion: 1,
|
||||
sshInputCompatibility: false,
|
||||
charset: groupDefaults?.charset ? undefined : "UTF-8",
|
||||
distroMode: "auto",
|
||||
createdAt: Date.now(),
|
||||
|
||||
@@ -570,8 +570,6 @@ 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}
|
||||
/>
|
||||
|
||||
+1
-11
@@ -158,8 +158,6 @@ import {
|
||||
} from "./terminal/runtime/createTerminalSessionStarters";
|
||||
import {
|
||||
createXTermRuntime,
|
||||
getTerminalPerCharacterInput,
|
||||
restoreTerminalPerCharacterInput,
|
||||
resetKittyKeyboardModeStateForSession,
|
||||
type XTermRuntime,
|
||||
} from "./terminal/runtime/createXTermRuntime";
|
||||
@@ -1685,8 +1683,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
passwordPromptActiveRef.current,
|
||||
knownCwdRef.current ?? null,
|
||||
terminalTitleRef.current ?? null,
|
||||
xtermRuntimeRef.current?.getPerCharacterInput()
|
||||
?? getTerminalPerCharacterInput(terminalSettingsRef),
|
||||
);
|
||||
}));
|
||||
}
|
||||
@@ -1708,10 +1704,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
throw new Error("Terminal output did not settle before applying the snapshot");
|
||||
}
|
||||
}
|
||||
if (typeof payload.perCharacterInput === "boolean") {
|
||||
restoreTerminalPerCharacterInput(terminalSettingsRef, payload.perCharacterInput);
|
||||
xtermRuntimeRef.current?.restorePerCharacterInput(payload.perCharacterInput);
|
||||
}
|
||||
if (typeof payload.kittyKeyboardProtocolEnabled === "boolean") {
|
||||
xtermRuntimeRef.current?.setKittyKeyboardProtocolEnabled(
|
||||
payload.kittyKeyboardProtocolEnabled,
|
||||
@@ -1881,8 +1873,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
snap,
|
||||
{
|
||||
...finalContext,
|
||||
perCharacterInput: xtermRuntimeRef.current?.getPerCharacterInput()
|
||||
?? getTerminalPerCharacterInput(terminalSettingsRef),
|
||||
kittyKeyboardModeState: xtermRuntimeRef.current?.getKittyKeyboardModeState(),
|
||||
kittyKeyboardProtocolEnabled:
|
||||
xtermRuntimeRef.current?.getKittyKeyboardProtocolEnabled(),
|
||||
@@ -4073,7 +4063,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
return;
|
||||
}
|
||||
finishReconnectPreparation();
|
||||
xtermRuntimeRef.current?.resetConnectionInputState(host);
|
||||
xtermRuntimeRef.current?.resetKittyConnectionInputState();
|
||||
resetKittyKeyboardModeStateForSession(terminalSettingsRef);
|
||||
if (effectiveTerminalProtocol.startsWith("plugin:")) {
|
||||
sessionStarters.startPluginConnection(term);
|
||||
|
||||
@@ -64,8 +64,6 @@ 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;
|
||||
@@ -81,7 +79,6 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
fileName,
|
||||
filePath,
|
||||
initialContent,
|
||||
onSave,
|
||||
editorWordWrap,
|
||||
@@ -97,7 +94,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(filePath ?? fileName));
|
||||
const [languageId, setLanguageId] = useState(() => getLanguageId(fileName));
|
||||
const contentRef = useRef(initialContent);
|
||||
const baselineContentRef = useRef(initialContent);
|
||||
const savingRef = useRef(false);
|
||||
@@ -152,13 +149,13 @@ const TextEditorModal: React.FC<TextEditorModalProps> = ({
|
||||
setBaselineContent(initialContent);
|
||||
setSaveError(null);
|
||||
setSaving(false);
|
||||
setLanguageId(getLanguageId(filePath ?? fileName));
|
||||
setLanguageId(getLanguageId(fileName));
|
||||
contentRef.current = initialContent;
|
||||
baselineContentRef.current = initialContent;
|
||||
savingRef.current = false;
|
||||
closePromptRef.current = null;
|
||||
viewStateRef.current = null;
|
||||
}, [initialContent, fileName, filePath]);
|
||||
}, [initialContent, fileName]);
|
||||
|
||||
const saveContent = useCallback(async (contentToSave = contentRef.current): Promise<boolean> => {
|
||||
return saveCoordinatorRef.current?.save(contentToSave) ?? false;
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
getTextEditorContentStats,
|
||||
isTextEditorCommandWEnabled,
|
||||
isTextEditorReadOnly,
|
||||
languageIdToMonaco,
|
||||
TextEditorPromoteButton,
|
||||
} from "./TextEditorPane.tsx";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
@@ -17,11 +16,6 @@ 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,7 +30,6 @@ 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';
|
||||
@@ -38,7 +37,7 @@ import { Combobox } from '../ui/combobox';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
|
||||
// Map our language IDs to Monaco language IDs
|
||||
export const languageIdToMonaco = (langId: string): string => {
|
||||
const languageIdToMonaco = (langId: string): string => {
|
||||
const mapping: Record<string, string> = {
|
||||
'javascript': 'javascript',
|
||||
'typescript': 'typescript',
|
||||
@@ -74,7 +73,6 @@ export const languageIdToMonaco = (langId: string): string => {
|
||||
'yaml': 'yaml',
|
||||
'toml': 'ini',
|
||||
'ini': 'ini',
|
||||
'nginx': 'nginx',
|
||||
'sql': 'sql',
|
||||
'graphql': 'graphql',
|
||||
'markdown': 'markdown',
|
||||
@@ -514,7 +512,6 @@ const TextEditorPaneInner: React.FC<TextEditorPaneProps> = ({
|
||||
language={monacoLanguage}
|
||||
value={content}
|
||||
onChange={handleEditorChange}
|
||||
beforeMount={registerNginxLanguage}
|
||||
onMount={handleEditorMount}
|
||||
theme={customThemeName}
|
||||
loading={
|
||||
|
||||
@@ -20,7 +20,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, isMacPlatform } from "../../../lib/utils";
|
||||
import { cn } from "../../../lib/utils";
|
||||
import { SectionHeader, SettingsAnchor, SettingsTabContent, SettingRow, Toggle, Select } from "../settings-ui";
|
||||
import { FontSelect } from "../FontSelect";
|
||||
import { TerminalFontSelect } from "../TerminalFontSelect";
|
||||
@@ -66,8 +66,6 @@ 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;
|
||||
}) {
|
||||
@@ -137,13 +135,10 @@ 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 },
|
||||
@@ -311,19 +306,6 @@ 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,7 +264,6 @@ 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.fullPath),
|
||||
languageId: snapshot.languageId || getLanguageId(target.file.name),
|
||||
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.fullPath),
|
||||
languageId: snapshot.languageId || getLanguageId(target.file.name),
|
||||
content: snapshot.content,
|
||||
baselineContent: snapshot.baselineContent,
|
||||
wordWrap: snapshot.wordWrap,
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
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\}/);
|
||||
});
|
||||
@@ -34,7 +34,7 @@ test("local terminals stay out of hibernate and hidden-renderer paths", () => {
|
||||
test("reconnect clears keyboard pairing before resetting protocol state", () => {
|
||||
assert.match(
|
||||
terminalSource,
|
||||
/startNewSession = \(\) => \{[\s\S]*resetConnectionInputState\(host\);[\s\S]*resetKittyKeyboardModeStateForSession/,
|
||||
/startNewSession = \(\) => \{[\s\S]*resetKittyConnectionInputState\(\);[\s\S]*resetKittyKeyboardModeStateForSession/,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
@@ -42,7 +42,7 @@ test("reconnect clears keyboard pairing before resetting protocol state", () =>
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/resetConnectionInputState: \(host\) => \{[\s\S]*clearKittyConnectionInputState\(\)/,
|
||||
/resetKittyConnectionInputState: clearKittyConnectionInputState/,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
|
||||
@@ -357,34 +357,6 @@ test("startPluginConnection displays status diagnostics when a Provider exits wi
|
||||
]);
|
||||
});
|
||||
|
||||
test("startSSH forwards a saved ChaCha20-only host setting to the transport", async () => {
|
||||
const host = JSON.parse(JSON.stringify({
|
||||
id: "host-chacha",
|
||||
label: "Synthetic ChaCha20 server",
|
||||
hostname: "127.0.0.1",
|
||||
username: "synthetic",
|
||||
algorithms: { cipher: ["chacha20-poly1305@openssh.com"] },
|
||||
}));
|
||||
let captured: Record<string, unknown> | undefined;
|
||||
const ctx = createStarterContext({
|
||||
host,
|
||||
terminalBackend: {
|
||||
backendAvailable: () => true,
|
||||
startSSHSession: async (options: Record<string, unknown>) => {
|
||||
captured = options;
|
||||
return "ssh-session";
|
||||
},
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
},
|
||||
});
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.deepEqual(captured?.algorithmOverrides, host.algorithms);
|
||||
});
|
||||
|
||||
test("startSSH forwards imported system agent authentication settings", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
const terminalBackend = {
|
||||
|
||||
@@ -985,12 +985,13 @@ test("multi-character plain text goes out as per-character writes (#3077)", asyn
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const source = readFileSync(new URL("./createXTermRuntime.ts", import.meta.url), "utf8");
|
||||
|
||||
// The legacy workaround selected IME commits and short raw pastes (#3077).
|
||||
// Host settings now gate that selection at the shared send boundary.
|
||||
// Strict bastion prompts (QAX) treat one channel write as one keystroke and
|
||||
// drop multi-character chunks, so IME commits and short raw pastes must be
|
||||
// sent one character per write instead of one write per commit.
|
||||
assert.match(source, /perCharacterWrites\?: boolean/);
|
||||
assert.match(
|
||||
source,
|
||||
/getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)/,
|
||||
/getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)/,
|
||||
);
|
||||
// Every chunk carries the same write options as the original single write.
|
||||
const writeLoopIdx = source.indexOf("for (const chunk of getTextInputWireChunks(outData");
|
||||
|
||||
@@ -252,17 +252,6 @@ type TerminalBackendApi = {
|
||||
// renderer hibernation. Weak ownership keeps negotiated keyboard state alive
|
||||
// for that session without introducing a process-wide session registry.
|
||||
const kittyKeyboardStates = createKittyKeyboardSessionStateStore();
|
||||
// The same session-owned ref also keeps the connection's input choice alive
|
||||
// when hibernation destroys xterm. Only a real reconnect refreshes this choice.
|
||||
const inputCompatibilityStates = new WeakMap<object, boolean>();
|
||||
|
||||
// The stable session owner survives xterm disposal while its home renderer sleeps.
|
||||
export const getTerminalPerCharacterInput = (owner: object): boolean | undefined =>
|
||||
inputCompatibilityStates.get(owner);
|
||||
|
||||
export const restoreTerminalPerCharacterInput = (owner: object, enabled: boolean): void => {
|
||||
inputCompatibilityStates.set(owner, enabled);
|
||||
};
|
||||
|
||||
const resolveKittyKeyboardModeState = (
|
||||
ctx: Pick<
|
||||
@@ -318,10 +307,8 @@ export type XTermRuntime = {
|
||||
* terminal reset or FIFO eviction.
|
||||
*/
|
||||
hasInlineImages: () => boolean;
|
||||
/** Reset keyboard state and snapshot endpoint settings for a fresh connection. */
|
||||
resetConnectionInputState: (host: Host) => void;
|
||||
getPerCharacterInput: () => boolean;
|
||||
restorePerCharacterInput: (enabled: boolean) => void;
|
||||
/** Clear local/per-target keyboard state before reusing this runtime. */
|
||||
resetKittyConnectionInputState: () => void;
|
||||
/** Emit any owed releases before detaching or closing this renderer. */
|
||||
flushKittyKeyboardReleases: () => void;
|
||||
/** Transfer negotiated keyboard state across renderer attach handoffs. */
|
||||
@@ -528,13 +515,6 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const rendererType = settings?.rendererType ?? "auto";
|
||||
const bridge = netcattyBridge.get();
|
||||
const isLocalTerminalHost = ctx.host.protocol === "local";
|
||||
// Capture the terminal endpoint's choice for this connection, never a chain hop.
|
||||
const usesPerCharacterInput = (host: Host) =>
|
||||
(host.protocol !== undefined && host.protocol !== "ssh") || host.moshEnabled === true
|
||||
|| host.etEnabled === true || host.sshInputCompatibility === true;
|
||||
let usePerCharacterInput = inputCompatibilityStates.get(ctx.terminalSettingsRef)
|
||||
?? usesPerCharacterInput(ctx.host);
|
||||
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
|
||||
const windowsPty =
|
||||
platform === "win32" && isLocalTerminalHost
|
||||
? bridge?.getWindowsPtyInfo?.() ?? { backend: "conpty" as const }
|
||||
@@ -1345,9 +1325,10 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
lineDelayMs?: number;
|
||||
pasteRequestId?: string;
|
||||
/**
|
||||
* Request the legacy IME/short-paste workaround reported in #3077.
|
||||
* The SSH host's compatibility choice gates the final writes below.
|
||||
* Bookkeeping still sees the whole payload once.
|
||||
* Send plain text as one write per character. Strict bastion prompts
|
||||
* (QAX) treat one channel write as a keystroke and drop multi-character
|
||||
* chunks, so IME commits and short raw pastes must go out per
|
||||
* character (#3077). Bookkeeping still sees the whole payload once.
|
||||
*/
|
||||
perCharacterWrites?: boolean;
|
||||
},
|
||||
@@ -1569,7 +1550,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
}
|
||||
|
||||
ctx.onOutputTriggerUserInputRef?.current?.(outData);
|
||||
for (const chunk of getTextInputWireChunks(outData, usePerCharacterInput && options?.perCharacterWrites === true, (ctx.host.protocol === undefined || ctx.host.protocol === "ssh") && !ctx.host.moshEnabled && !ctx.host.etEnabled)) {
|
||||
for (const chunk of getTextInputWireChunks(outData, options?.perCharacterWrites === true)) {
|
||||
ctx.terminalBackend.writeToSession(id, chunk, {
|
||||
sensitive,
|
||||
serialEraseChar,
|
||||
@@ -3159,7 +3140,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
broadcastLegacyDataPending = null;
|
||||
}
|
||||
// Raw multi-character onData is an unbracketed paste (or a committed
|
||||
// composition): selected short plain text can use compatibility writes.
|
||||
// composition): short plain text must reach strict bastions per character.
|
||||
// Decide on the sanitized payload so removed zero-width characters cannot
|
||||
// keep a qualifying paste above the split cap (#3138).
|
||||
const sanitizedRawData = sanitizeTerminalInput(data);
|
||||
@@ -3498,17 +3479,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
ligatures.apply(enabled);
|
||||
},
|
||||
hasInlineImages,
|
||||
resetConnectionInputState: (host) => {
|
||||
usePerCharacterInput = usesPerCharacterInput(host);
|
||||
inputCompatibilityStates.set(ctx.terminalSettingsRef, usePerCharacterInput);
|
||||
clearKittyConnectionInputState();
|
||||
},
|
||||
resetKittyConnectionInputState: clearKittyConnectionInputState,
|
||||
flushKittyKeyboardReleases: clearKittyTransientInputState,
|
||||
getPerCharacterInput: () => usePerCharacterInput,
|
||||
restorePerCharacterInput: (enabled) => {
|
||||
usePerCharacterInput = enabled;
|
||||
restoreTerminalPerCharacterInput(ctx.terminalSettingsRef, enabled);
|
||||
},
|
||||
getKittyKeyboardModeState: () => snapshotKittyKeyboardModeState(kittyKeyboardMode),
|
||||
restoreKittyKeyboardModeState: (state) => restoreKittyKeyboardModeState(
|
||||
kittyKeyboardMode,
|
||||
|
||||
@@ -27,7 +27,7 @@ for (const prefix of ['fresh','empty-arrow','submit','paste-submit','interrupt',
|
||||
const session={encoding, serialPort:{write(data: string | Buffer){for(const byte of Buffer.from(data)) { if(byte===127||byte===8) wire=wire.subarray(0,Math.max(0,wire.length-1)); else wire=Buffer.concat([wire,Buffer.from([byte])]);}}}};
|
||||
bridge.init({sessions:new Map([['s',session]]),electronModule:{}});
|
||||
const ctx={host:{protocol:'serial',id:'h',label:'h'},sessionId:'s',sessionRef:{current:'s'},statusRef:{current:'connected'},commandBufferRef:{current:''},onBroadcastInputRef:{current:null},isBroadcastEnabledRef:{current:false},terminalBackend:{writeToSession(id:string,data:string,opts: {serialEraseChar?: string; sensitive?: boolean}){bridge.writeToSession({}, {sessionId:id,data,...opts})}},serialByteOrientedBackspace:true,serialLocalEcho:false};
|
||||
const env={usePerCharacterInput:true,...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
|
||||
const env={...Object.assign({},...modules),api: undefined as unknown as {handleTerminalInputData: (data: string) => void; recordSerialSnippetInput: (data: string) => void},ctx,term:{buffer:{active:{cursorX:0,cursorY:0,baseY:0,getLine:()=>undefined}},cols:80},suppressNextTerminalDataBroadcast:false,handlingKittyBroadcast:false,shouldBroadcastTerminalUserInput:()=>false,prioritizeTerminalInput:()=>{},getFlowControllerForTerm:()=>null,shouldSuppressTerminalInputScrollForUserPaste:()=>true,shouldOverrideTerminalUserPasteSensitivity:()=>false,scrollToBottomAfterInput:()=>{},writeLocalTerminalData:()=>{}};
|
||||
vm.runInNewContext(code,env); const {handleTerminalInputData:input,recordSerialSnippetInput:snippet}=env.api;
|
||||
if(prefix==='empty-arrow') input('\x1b[D');
|
||||
if(prefix==='submit') {input('旧');input('\x1b[D');input('\r');}
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { buildSync } from "esbuild";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
// Real xterm + runtime input callbacks; canvas measurement is stubbed because
|
||||
// these assertions concern writes, not rendering or a native OS input method.
|
||||
test("SSH host mode gates every interactive text entry and stays fixed until reconnect", async () => {
|
||||
const root = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const bundle = buildSync({
|
||||
stdin: {
|
||||
contents: 'export {createXTermRuntime} from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal"; export {dispatchTerminalLinePaste} from "./components/terminal/runtime/terminalUserPaste"; export {wakeTerminalFromHibernate} from "./components/terminal/terminalRuntimeMount";',
|
||||
loader: "ts", resolveDir: root,
|
||||
},
|
||||
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
|
||||
alias: {
|
||||
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
|
||||
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
|
||||
},
|
||||
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
|
||||
}).outputFiles[0].text;
|
||||
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
|
||||
Object.assign(dom.window, { structuredClone });
|
||||
try {
|
||||
dom.window.eval(`
|
||||
window.matchMedia = () => ({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
|
||||
window.ResizeObserver = window.IntersectionObserver = class {observe(){} unobserve(){} disconnect(){}};
|
||||
HTMLCanvasElement.prototype.getContext = function(type) {
|
||||
if (type !== '2d') return null;
|
||||
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
|
||||
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
|
||||
{get:(target,key)=>key in target ? target[key] : ()=>{}});
|
||||
};
|
||||
`);
|
||||
dom.window.eval(bundle);
|
||||
const result = await dom.window.eval(`(async () => {
|
||||
const {createXTermRuntime, DEFAULT_TERMINAL_SETTINGS, dispatchTerminalLinePaste, wakeTerminalFromHibernate} = runtimeTest;
|
||||
const ref = current => ({current});
|
||||
const panes = [];
|
||||
const make = host => {
|
||||
const el = document.createElement('div'); document.body.appendChild(el);
|
||||
const writes = [], logical = [], broadcasts = [];
|
||||
const context = {
|
||||
container:el, host, getHost:()=>host,
|
||||
fontFamilyId:'jetbrains-mono', resolvedFontFamily:'monospace', fontSize:14,
|
||||
terminalTheme:{colors:{background:'#111111',foreground:'#eeeeee',cursor:'#ffffff',selection:'#444444'}},
|
||||
terminalSettingsRef:ref({...DEFAULT_TERMINAL_SETTINGS,rendererType:'dom',fontLigatures:false,cursorBlink:false}),
|
||||
terminalBackend:{writeToSession:(_,data)=>writes.push(data),resizeSession(){},openExternalAvailable:false},
|
||||
sessionRef:ref(host.id),hotkeySchemeRef:ref('disabled'),disableTerminalFontZoomRef:ref(false),
|
||||
keyBindingsRef:ref([]),onHotkeyActionRef:ref(undefined),isBroadcastEnabledRef:ref(true),
|
||||
onBroadcastInputRef:ref(data=>{broadcasts.push(data);return [];}),
|
||||
onOutputTriggerUserInputRef:ref(data=>logical.push(data)),
|
||||
sessionId:host.id,statusRef:ref('connected'),commandBufferRef:ref(''),requestSearchFocus(){},
|
||||
kittyKeyboardProtocolEnabled:true,
|
||||
};
|
||||
const runtime = createXTermRuntime(context);
|
||||
const pane={...runtime,writes,logical,broadcasts,context,el}; panes.push(pane); return pane;
|
||||
};
|
||||
const equal = (actual, expected, label) => {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) throw Error(label+': '+JSON.stringify({actual,expected}));
|
||||
};
|
||||
const wait = () => new Promise(resolve=>setTimeout(resolve,20));
|
||||
const check = (pane, text, split, label) => {
|
||||
equal(pane.writes.splice(0),split ? Array.from(text) : [text],label);
|
||||
equal(pane.logical.splice(0),[text],label+' logical payload once');
|
||||
pane.broadcasts.length=0;
|
||||
};
|
||||
const hosts=[false,true].map((mode,index)=>({id:'mode-'+index,label:'Mode',hostname:'localhost',protocol:'ssh',sshInputCompatibility:mode}));
|
||||
const metrics=[];
|
||||
for (const host of hosts) {
|
||||
const pane=make(host), term=pane.term, ta=term.textarea, mode=host.sshInputCompatibility;
|
||||
for (const text of ['中文','👩🏽💻é','x'.repeat(32),'x'.repeat(33)]) {
|
||||
term.paste(text); check(pane,text,mode && Array.from(text).length<=32,'raw paste '+mode);
|
||||
}
|
||||
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中国'}));
|
||||
check(pane,'中国',mode,'insertText');
|
||||
const longIme='中'.repeat(40);
|
||||
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:longIme}));
|
||||
check(pane,longIme,mode,'IME keeps its uncapped scope');
|
||||
ta.dispatchEvent(new KeyboardEvent('keydown',{bubbles:true,keyCode:229,key:'Process'}));
|
||||
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
|
||||
check(pane,'中文',mode,'IME 229 insertText');
|
||||
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
|
||||
ta.value='中文';
|
||||
ta.dispatchEvent(new CompositionEvent('compositionupdate',{bubbles:true,data:'中文'}));
|
||||
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:'中文'}));
|
||||
await wait(); check(pane,'中文',mode,'compositionend');
|
||||
// xterm's composition fallback reaches onData independently of insertText.
|
||||
ta.dispatchEvent(new CompositionEvent('compositionstart',{bubbles:true,data:''}));
|
||||
term.input('中国',true); check(pane,'中国',mode,'composition onData fallback');
|
||||
ta.dispatchEvent(new CompositionEvent('compositionend',{bubbles:true,data:''})); await wait();
|
||||
pane.writes.length=0; pane.logical.length=0;
|
||||
for (const data of ['\\x1b[A','\\x03','\\x03\\x04','\\x1b[0;;200u']) {
|
||||
term.input(data,true); check(pane,data,false,'terminal control');
|
||||
}
|
||||
await new Promise(resolve=>term.write('\\x1b[?2004h',resolve));
|
||||
term.paste('中文'); check(pane,'\\x1b[200~中文\\x1b[201~',false,'bracketed paste');
|
||||
await new Promise(resolve=>term.write('\\x1b[?2004l',resolve));
|
||||
dispatchTerminalLinePaste(term,'中文\\r',{lineDelayMs:1,sensitive:false});
|
||||
equal(pane.writes.splice(0),['中文\\r'],'existing line-paste path stays intact');
|
||||
pane.logical.length=0;
|
||||
await new Promise(resolve=>term.write('\\x1b[>25u',resolve));
|
||||
ta.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:'insertText',data:'中文'}));
|
||||
const kittyWrites=pane.writes.splice(0);
|
||||
equal(kittyWrites,['\\x1b[0;;20013:25991u'],'Kitty associated text stays in one transport write');
|
||||
pane.logical.length=0;
|
||||
await new Promise(resolve=>term.write('\\x1b[<u',resolve));
|
||||
host.sshInputCompatibility=!mode;
|
||||
term.paste('中文'); check(pane,'中文',mode,'save does not change active runtime');
|
||||
// The app reconnects with the existing runtime, so exercise that reset too.
|
||||
pane.resetConnectionInputState(host);
|
||||
term.paste('中文'); check(pane,'中文',!mode,'existing runtime reconnect');
|
||||
pane.dispose();
|
||||
const reconnected=make(host);
|
||||
reconnected.term.paste('中文'); check(reconnected,'中文',!mode,'reconnect reads saved mode');
|
||||
metrics.push({mode,entryPoints:5,reconnectedMode:!mode});
|
||||
}
|
||||
// Exercise the actual wake entry that recreates xterm but only reattaches
|
||||
// the existing backend. Its context contains the newly saved host record.
|
||||
for (const mode of [false,true]) {
|
||||
const connectedHost={id:'hibernate-'+mode,label:'Hibernate',hostname:'127.0.0.1',protocol:'ssh',sshInputCompatibility:mode};
|
||||
const savedHost={...connectedHost,sshInputCompatibility:!mode};
|
||||
let pane=make(connectedHost), reattached=0;
|
||||
const wake = async old => {
|
||||
old.dispose();
|
||||
const refs={xtermRuntimeRef:ref(null),termRef:ref(null),fitAddonRef:ref(null),
|
||||
serializeAddonRef:ref(null),searchAddonRef:ref(null),hasRuntimeRef:ref(false)};
|
||||
const context={...old.context,host:savedHost};
|
||||
const success=await wakeTerminalFromHibernate({
|
||||
refs,runtimeContext:context,container:old.el,
|
||||
getPayload:()=>({snapshot:'',alternateScreen:false,pendingBuffer:''}),
|
||||
prepareWakeFlow:async()=>true,takePendingBuffer:()=>'',
|
||||
stopHibernateDataListener(){},stopHibernateListeners(){},
|
||||
reattachSession(){reattached++;},safeFit(){},resizeSession(){},forceSyncRenderAfterResize(){},
|
||||
lastFittedSizeRef:ref(null),isBootActiveRef:ref(false),sessionId:connectedHost.id,updateStatus(){},
|
||||
});
|
||||
equal(success,true,'hibernate wake succeeds');
|
||||
equal(context.sessionRef.current,connectedHost.id,'hibernate retains backend session');
|
||||
const next={...refs.xtermRuntimeRef.current,context,el:old.el,
|
||||
writes:old.writes,logical:old.logical,broadcasts:old.broadcasts};
|
||||
panes.push(next); next.writes.length=0; next.logical.length=0; return next;
|
||||
};
|
||||
pane=await wake(pane);
|
||||
pane.term.paste('中文');check(pane,'中文',mode,'wake retains connection-time mode');
|
||||
pane.resetConnectionInputState(savedHost);
|
||||
pane.term.paste('中文');check(pane,'中文',!mode,'real reconnect refreshes mode after wake');
|
||||
pane=await wake(pane);
|
||||
pane.term.paste('中文');check(pane,'中文',!mode,'next wake retains refreshed connection mode');
|
||||
equal(reattached,2,'both wakes reattach the existing backend');
|
||||
}
|
||||
// The SSH-only field cannot change other transports' existing policy.
|
||||
for (const protocol of ['local','telnet','serial','mosh','et','ssh-mosh','ssh-et']) {
|
||||
const pane=make({id:'other-'+protocol,label:protocol,hostname:'127.0.0.1',
|
||||
protocol:protocol.startsWith('ssh-')?'ssh':protocol,
|
||||
moshEnabled:protocol==='ssh-mosh',etEnabled:protocol==='ssh-et',sshInputCompatibility:false});
|
||||
pane.term.paste('中文'); check(pane,'中文',true,protocol+' retains legacy short paste');
|
||||
pane.term.input('a\\rb',true); check(pane,'a\\rb',true,protocol+' retains legacy control-containing text');
|
||||
}
|
||||
for (const pane of panes) { try {pane.dispose();} catch {} }
|
||||
return JSON.stringify(metrics);
|
||||
})()`);
|
||||
assert.deepEqual(JSON.parse(result), [
|
||||
{ mode: false, entryPoints: 5, reconnectedMode: true },
|
||||
{ mode: true, entryPoints: 5, reconnectedMode: false },
|
||||
]);
|
||||
} finally {
|
||||
dom.window.close();
|
||||
}
|
||||
});
|
||||
@@ -1,214 +0,0 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import vm from "node:vm";
|
||||
import { buildSync } from "esbuild";
|
||||
import { JSDOM } from "jsdom";
|
||||
import React, { useEffect } from "react";
|
||||
import { act, create } from "react-test-renderer";
|
||||
import ts from "typescript";
|
||||
import type { Host } from "../../../domain/models";
|
||||
import { resolveTerminalPopupHost } from "../../TerminalPopupPage";
|
||||
import { useTerminalEffects } from "../useTerminalEffects";
|
||||
import { isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture } from "../terminalHibernateRuntime";
|
||||
|
||||
const root = fileURLToPath(new URL("../../..", import.meta.url));
|
||||
const terminalSource = readFileSync(path.join(root, "components/Terminal.tsx"), "utf8");
|
||||
const homeStart = terminalSource.indexOf(" useEffect(() => {", terminalSource.indexOf("// Home renderer for AI observe popups"));
|
||||
const homeEnd = terminalSource.indexOf(" const cleanupSession =", homeStart);
|
||||
const homeEffect = ts.transpileModule(terminalSource.slice(homeStart, homeEnd), {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const cleanupEnd = terminalSource.indexOf(" const cleanupSessionRef =", homeEnd);
|
||||
const popupCleanup = ts.transpileModule(`${terminalSource.slice(homeEnd, cleanupEnd)}
|
||||
globalThis.cleanup = cleanupSession;`, {
|
||||
compilerOptions: { target: ts.ScriptTarget.ES2022 },
|
||||
}).outputText;
|
||||
const noop = () => {};
|
||||
const ref = <T,>(current: T) => ({ current });
|
||||
|
||||
// Actual popup host resolution, home snapshot callbacks, attach boot effect,
|
||||
// and real xterm writes. Unrelated visual effects are omitted by the hook seam.
|
||||
test("attached popups retain the active mode across snapshots and return, including hibernated homes", async (t) => {
|
||||
const bundle = buildSync({
|
||||
stdin: { contents: 'export * from "./components/terminal/runtime/createXTermRuntime"; export {DEFAULT_TERMINAL_SETTINGS} from "./domain/models/terminal";', loader: "ts", resolveDir: root },
|
||||
bundle: true, format: "iife", globalName: "runtimeTest", platform: "browser", write: false,
|
||||
alias: {
|
||||
"node:diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
|
||||
"diagnostics_channel": path.join(root, "infrastructure/shims/nodeDiagnosticsChannel.ts"),
|
||||
},
|
||||
define: { "import.meta.env.DEV": "false", "import.meta.env.PROD": "true", "import.meta": "{}" },
|
||||
}).outputFiles[0].text;
|
||||
const dom = new JSDOM("<body/>", { url: "http://localhost", runScripts: "outside-only", pretendToBeVisual: true });
|
||||
const keys = ["window", "document", "navigator", "IS_REACT_ACT_ENVIRONMENT"];
|
||||
const previous = new Map(keys.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
|
||||
for (const [key, value] of Object.entries({ window: dom.window, document: dom.window.document, navigator: dom.window.navigator, IS_REACT_ACT_ENVIRONMENT: true })) {
|
||||
Object.defineProperty(globalThis, key, { configurable: true, value });
|
||||
}
|
||||
Object.assign(dom.window, { structuredClone });
|
||||
dom.window.eval(`
|
||||
window.matchMedia=()=>({matches:false,addListener(){},removeListener(){},addEventListener(){},removeEventListener(){}});
|
||||
window.ResizeObserver=window.IntersectionObserver=class {observe(){} unobserve(){} disconnect(){}};
|
||||
HTMLCanvasElement.prototype.getContext=function(type){
|
||||
if(type!=='2d')return null;
|
||||
return new Proxy({canvas:this,measureText:()=>({width:8,fontBoundingBoxAscent:12,fontBoundingBoxDescent:3}),
|
||||
getImageData:()=>({data:new Uint8ClampedArray(4)}),createLinearGradient:()=>({addColorStop(){}})},
|
||||
{get:(target,key)=>key in target?target[key]:()=>{}});
|
||||
};
|
||||
`);
|
||||
dom.window.eval(bundle);
|
||||
const runtimeModule = dom.window.eval("runtimeTest");
|
||||
try {
|
||||
for (const mode of [false, true]) for (const hibernated of [false, true]) {
|
||||
await t.test(`connected=${mode}, saved=${!mode}, home hibernated=${hibernated}`, async () => {
|
||||
const sessionId = `popup-${mode}-${hibernated}`;
|
||||
const connectedHost: Host = { id: "host", label: "Host", hostname: "127.0.0.1", port: 22379, username: "agent", protocol: "ssh" as const, tags: [], os: "linux" as const, sshInputCompatibility: mode };
|
||||
const savedHost = { ...connectedHost, sshInputCompatibility: !mode };
|
||||
const session = { id: sessionId, hostId: "host", hostLabel: "Host", hostname: "127.0.0.1", username: "agent", protocol: "ssh" as const, status: "connected" as const, createdAt: 1 };
|
||||
const popupHost = resolveTerminalPopupHost({ title: "Host", startupCommand: "", parentSessionId: sessionId, sourceSession: session, attachSessionId: sessionId }, [savedHost]);
|
||||
assert.equal(popupHost.sshInputCompatibility, !mode, "the popup sees the latest vault preference");
|
||||
const settings = { ...runtimeModule.DEFAULT_TERMINAL_SETTINGS, rendererType: "dom", fontLigatures: false, cursorBlink: false };
|
||||
const homeSettings = ref(settings);
|
||||
const writes: string[] = [];
|
||||
const makeContext = (host: Host, settingsRef = ref(settings)) => ({
|
||||
container: document.body.appendChild(document.createElement("div")), host,
|
||||
fontFamilyId: "jetbrains-mono", resolvedFontFamily: "monospace", fontSize: 14,
|
||||
terminalTheme: { colors: { background: "#111111", foreground: "#eeeeee", cursor: "#ffffff", selection: "#444444" } },
|
||||
terminalSettingsRef: settingsRef,
|
||||
terminalBackend: { writeToSession: (_id: string, data: string) => writes.push(data), resizeSession: noop, openExternalAvailable: false },
|
||||
sessionRef: ref<string | null>(sessionId), sessionId, statusRef: ref("connected"),
|
||||
hotkeySchemeRef: ref("disabled"), disableTerminalFontZoomRef: ref(false), keyBindingsRef: ref([]),
|
||||
onHotkeyActionRef: ref(undefined), isBroadcastEnabledRef: ref(false), onBroadcastInputRef: ref(undefined),
|
||||
commandBufferRef: ref(""), requestSearchFocus: noop, kittyKeyboardProtocolEnabled: false,
|
||||
});
|
||||
const homeContext = makeContext(connectedHost, homeSettings);
|
||||
const homeRuntime = runtimeModule.createXTermRuntime(homeContext);
|
||||
const homeRuntimeRef = ref(hibernated ? null : homeRuntime);
|
||||
const homeTermRef = ref(hibernated ? null : homeRuntime.term);
|
||||
if (hibernated) homeRuntime.dispose();
|
||||
let snapshotRequest: (payload: object) => Promise<void>;
|
||||
let applySnapshot: (payload: object) => Promise<boolean>;
|
||||
let snapshot: Record<string, unknown> = {};
|
||||
const bridge = {
|
||||
onTerminalSessionSnapshotRequest(callback: typeof snapshotRequest) { snapshotRequest = callback; return noop; },
|
||||
onTerminalSessionApplySnapshot(callback: typeof applySnapshot) { applySnapshot = callback; return noop; },
|
||||
respondTerminalSessionSnapshot(_request: string, text: string, keyboard: unknown, enabled: boolean, sensitive: boolean, cwd: string, title: string, perCharacterInput: boolean) {
|
||||
snapshot = { success: true, snapshot: text, kittyKeyboardModeState: keyboard, kittyKeyboardProtocolEnabled: enabled, passwordPromptActive: sensitive, cwd, title, perCharacterInput };
|
||||
},
|
||||
};
|
||||
const homeEnv = {
|
||||
useEffect: (effect: () => void) => effect(), netcattyBridge: { get: () => bridge },
|
||||
attachExistingSession: false, sessionId, terminalSettingsRef: homeSettings,
|
||||
xtermRuntimeRef: homeRuntimeRef, termRef: homeTermRef,
|
||||
serializeAddonRef: ref(hibernated ? null : homeRuntime.serializeAddon),
|
||||
hibernatedRef: ref(hibernated), softHiddenRef: ref(false),
|
||||
hibernateSnapshotRef: ref(""), hibernatePendingBufferRef: ref(""), hibernateViewportSnapshotRef: ref(""), hibernateScrollbackSnapshotRef: ref(""),
|
||||
hibernateContextSnapshotRef: ref(""), hibernateContextViewportSnapshotRef: ref(""), hibernateContextScrollbackSnapshotRef: ref(""), hibernateAlternateScreenRef: ref(false),
|
||||
kittyKeyboardProtocolEnabledForSession: false, passwordPromptActiveRef: ref(false), knownCwdRef: ref(null), terminalTitleRef: ref(null), pluginTerminalLifecycleRef: ref(null),
|
||||
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, terminalCwdTracker: { setRendererCwd: noop },
|
||||
flushPendingTerminalWritesBeforeHibernate: async () => true, resetTerminalLineTimestamps: noop,
|
||||
applyAuthoritativeHibernateSnapshot: noop, logger: { warn: noop },
|
||||
getTerminalPerCharacterInput: runtimeModule.getTerminalPerCharacterInput,
|
||||
restoreTerminalPerCharacterInput: runtimeModule.restoreTerminalPerCharacterInput,
|
||||
};
|
||||
vm.runInNewContext(homeEffect, homeEnv);
|
||||
const popupContext = makeContext(popupHost);
|
||||
popupContext.sessionRef.current = null;
|
||||
popupContext.statusRef.current = "connecting";
|
||||
const values = {
|
||||
...popupContext, host: popupHost, terminalSettings: settings,
|
||||
containerRef: ref(popupContext.container), xtermRuntimeRef: ref(null), termRef: ref(null), fitAddonRef: ref(null), serializeAddonRef: ref(null), searchAddonRef: ref(null), hasRuntimeRef: ref(false),
|
||||
attachExistingSession: true, attachAuthorization: "owned-popup", vaultInitialized: true,
|
||||
CONNECTION_TIMEOUT: 30000, Error, XTERM_PERFORMANCE_CONFIG: {}, effectiveTheme: popupContext.terminalTheme,
|
||||
effectiveFontSize: 14, effectiveFontWeight: 400, fontWeightFixupDoneRef: ref(false),
|
||||
isVisible: true, isVisibleRef: ref(true), isFocused: true, inWorkspace: true, isLocalConnection: false, isNetworkDevice: false, isSerialConnection: false,
|
||||
pluginDecorationRules: [], pluginTerminalLifecycle: new Proxy({}, { get: () => noop }),
|
||||
onTerminalCwdChange: undefined, onTerminalTitleChange: undefined, keys: [], identities: [], serialConfig: {}, zmodem: {},
|
||||
connectionLogBufferRef: ref({ reset: noop }), terminalOutputHistory: { clear: noop }, terminalDataCapturedRef: ref(false), hasConnectedRef: ref(false), pendingOutputScrollRef: ref(false), promptLineBreakStateRef: ref(null), terminalLogSanitizerRef: ref(null),
|
||||
kittyKeyboardProtocolEnabledForSession: false, terminalCwdTracker: { getRendererCwd: () => null, setRendererCwd: noop }, passwordPromptActiveRef: ref(false),
|
||||
createPromptLineBreakState: () => ({}), createReplaySafeTerminalLogSanitizer: () => ({}),
|
||||
prepareInitialCwdIntent: noop, prepareRestoredReconnect: noop,
|
||||
applyUserCursorPreference: noop, applyUserCursorWidthPreference: noop, shouldApplyUserCursorPreference: () => false,
|
||||
terminalAltKeyOptions: () => ({}), shouldStartTerminalBackend: () => true,
|
||||
createXTermRuntime: runtimeModule.createXTermRuntime,
|
||||
terminalBackend: {
|
||||
...popupContext.terminalBackend,
|
||||
acquireSessionFlowPauseLease: async () => ({ waitForPause: async () => ({ success: true }), release: noop }),
|
||||
requestSessionSnapshot: async () => { await snapshotRequest!({ sessionId, requestId: "request" }); return snapshot; },
|
||||
rebindSessionOutput: async () => ({ success: true, previousWebContentsId: 10 }),
|
||||
restoreSessionOutput: async () => ({ success: true }),
|
||||
},
|
||||
sessionStarters: { reattachSession: (term: { paste: (data: string) => void }) => { term.paste("中文"); return true; } },
|
||||
setStatus: noop, setProgressLogs: noop, setShowLogs: noop, setIsCancelling: noop, setIsDisconnectedDialogDismissed: noop,
|
||||
setError: (error: unknown) => { if (error) errors.push(error); },
|
||||
updateStatus: (status: string) => { popupContext.statusRef.current = status; },
|
||||
safeFit: noop, logger: { warn: noop, error: (...args: unknown[]) => errors.push(args) },
|
||||
onPluginRuntimeCwdChange: noop, onOpenExternalError: noop, requestSearchFocus: noop,
|
||||
requestPluginTerminalProviders: undefined, isPluginTerminalProviderAvailable: () => false,
|
||||
useEffect: (effect: React.EffectCallback, dependencies: React.DependencyList) => {
|
||||
useEffect(() => {
|
||||
if (effect.toString().includes("bootAbort")) return effect();
|
||||
// The injected hook owns this dependency list; unrelated effects stay inert.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, dependencies);
|
||||
},
|
||||
useLayoutEffect: noop,
|
||||
};
|
||||
const errors: unknown[] = [];
|
||||
const defaultRefs = new Map<PropertyKey, ReturnType<typeof ref>>();
|
||||
const context = new Proxy(values, { get: (target, key) => {
|
||||
if (key in target) return target[key as keyof typeof target];
|
||||
if (!defaultRefs.has(key)) defaultRefs.set(key, ref(null));
|
||||
return defaultRefs.get(key);
|
||||
} });
|
||||
const Harness = () => { useTerminalEffects(context); return null; };
|
||||
let renderer: ReturnType<typeof create>;
|
||||
await act(async () => { renderer = create(React.createElement(Harness)); });
|
||||
for (let attempt = 0; popupContext.statusRef.current !== "connected" && attempt < 30 && !errors.length; attempt++) {
|
||||
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
|
||||
}
|
||||
try {
|
||||
assert.deepEqual(errors, [], "actual attach boot succeeded");
|
||||
assert.equal(popupContext.sessionRef.current, sessionId, "attach retains the original SSH backend");
|
||||
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "first attached input must retain the connection mode");
|
||||
assert.equal(snapshot.perCharacterInput, mode, "home snapshot exports captured mode, including false and no live xterm");
|
||||
const popupRuntime = values.xtermRuntimeRef.current as typeof homeRuntime;
|
||||
assert.equal(popupRuntime.getPerCharacterInput(), mode);
|
||||
const cleanupEnv = {
|
||||
...homeEnv, attachExistingSession: true, attachAuthorization: "owned-popup",
|
||||
sessionRef: popupContext.sessionRef, xtermRuntimeRef: values.xtermRuntimeRef,
|
||||
terminalSettingsRef: popupContext.terminalSettingsRef, termRef: values.termRef,
|
||||
serializeAddonRef: values.serializeAddonRef, attachHomeWebContentsIdRef: ref(10),
|
||||
disposeDataRef: ref(null), disposeExitRef: ref(null), disposeTelnetEchoModeRef: ref(null),
|
||||
telnetLocalEchoRef: ref(false), sessionCleanupPromiseRef: ref(null),
|
||||
isTerminalAlternateScreenActive, readTerminalHibernateContext, resolveTerminalSnapshotCapture,
|
||||
releaseTerminalFlowBeforeHibernate: noop,
|
||||
terminalBackend: {
|
||||
...values.terminalBackend,
|
||||
applySessionSnapshot: async (_id: string, text: string, context: { perCharacterInput?: boolean }) => {
|
||||
assert.equal(context.perCharacterInput, mode, "actual close exports the popup's active choice");
|
||||
return { success: await applySnapshot!({ sessionId, snapshot: text, ...context }) };
|
||||
},
|
||||
},
|
||||
};
|
||||
vm.runInNewContext(popupCleanup, cleanupEnv);
|
||||
await (cleanupEnv as typeof cleanupEnv & { cleanup: () => Promise<void> }).cleanup();
|
||||
assert.equal(runtimeModule.getTerminalPerCharacterInput(homeSettings), mode, "actual popup close retains the home captured state");
|
||||
const restoredHome = hibernated ? runtimeModule.createXTermRuntime({ ...homeContext, host: savedHost }) : homeRuntime;
|
||||
restoredHome.term.paste("中文");
|
||||
assert.deepEqual(writes.splice(0), mode ? ["中", "文"] : ["中文"], "home input after return and wake retains the active choice");
|
||||
if (hibernated) restoredHome.dispose();
|
||||
} finally {
|
||||
values.hasConnectedRef.current = false;
|
||||
await act(async () => { renderer!.unmount(); });
|
||||
homeRuntime.dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
dom.window.close();
|
||||
for (const key of keys) { const descriptor = previous.get(key); if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
|
||||
}
|
||||
});
|
||||
@@ -42,7 +42,7 @@ test("password-prompt input is classified before prompt state reset and cannot b
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
/for \(const chunk of getTextInputWireChunks\(outData, usePerCharacterInput && options\?\.perCharacterWrites === true, \(ctx\.host\.protocol === undefined \|\| ctx\.host\.protocol === "ssh"\) && !ctx\.host\.moshEnabled && !ctx\.host\.etEnabled\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
|
||||
/for \(const chunk of getTextInputWireChunks\(outData, options\?\.perCharacterWrites === true\)\) \{\s*ctx\.terminalBackend\.writeToSession\(id, chunk, \{\s*sensitive,\s*serialEraseChar,[\s\S]*?\}\);\s*\}/u,
|
||||
);
|
||||
assert.match(
|
||||
runtimeSource,
|
||||
|
||||
@@ -127,7 +127,6 @@ for (const [protocol, lineMode, sensitive] of [
|
||||
? { isWrapped: false, translateToString: () => liveLine } : undefined } },
|
||||
};
|
||||
const env = {
|
||||
usePerCharacterInput: true,
|
||||
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
|
||||
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
|
||||
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
|
||||
@@ -342,7 +341,6 @@ test("bypassed sensitive line paste keeps the paced fan-out with sourceSensitive
|
||||
buffer: { active: { cursorX: 0, cursorY: 0, baseY: 0, getLine: () => undefined } },
|
||||
};
|
||||
const env = {
|
||||
usePerCharacterInput: true,
|
||||
...Object.assign({}, ...helpers), ...userPaste, ctx, term, crypto, isPluginHostProtocol: () => false, logger: { warn() {} },
|
||||
netcattyBridge: { get: () => ({ onTerminalPasteWrite: (listener: typeof receiptListener) => { receiptListener = listener; return () => { receiptListener = undefined; }; } }) },
|
||||
suppressNextTerminalDataBroadcast: false, handlingKittyBroadcast: false,
|
||||
|
||||
@@ -66,20 +66,4 @@ test("wire chunking honors the per-character request only for plain text", () =>
|
||||
assert.deepEqual(getTextInputWireChunks("\x1b[200~ab\x1b[201~", true), [
|
||||
"\x1b[200~ab\x1b[201~",
|
||||
]);
|
||||
});
|
||||
|
||||
test("compatibility preserves exact Unicode byte order and keeps terminal controls intact", () => {
|
||||
for (const text of ["中文", "👩🏽💻", "e\u0301", "क्ष"]) {
|
||||
assert.deepEqual(Buffer.from(getTextInputWireChunks(text, true).join("")), Buffer.from(text));
|
||||
}
|
||||
for (const data of ["\x03", "\x1b[A", "\x1b[200~中文\x1b[201~", "\x1b[0;;200u", "\x03\x04", "a\r\nb"]) {
|
||||
assert.deepEqual(getTextInputWireChunks(data, true, true), [data]);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("non-SSH callers retain legacy control-containing text chunking", () => {
|
||||
assert.equal(shouldSplitRawPasteInputForWire("a\rb"), true);
|
||||
assert.deepEqual(getTextInputWireChunks("a\rb", true), ["a", "\r", "b"]);
|
||||
assert.deepEqual(getTextInputWireChunks("a\rb", true, true), ["a\rb"]);
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,13 @@
|
||||
/**
|
||||
* Legacy workaround introduced by #3168 after #3077 reported that individual
|
||||
* hanzi worked where multi-character input did not. The remote parser and
|
||||
* other terminals' default send behavior were not verified. SSH sessions now
|
||||
* select this workaround in their host settings.
|
||||
* Strict bastion prompts (e.g. QAX/奇安信) treat one SSH channel write as a
|
||||
* single keystroke and silently drop every multi-character chunk (#3077). IME
|
||||
* commits and short raw pastes therefore have to leave the renderer as
|
||||
* per-character writes, which is what typing produces and what Xshell does.
|
||||
*/
|
||||
|
||||
/** Raw pastes longer than this keep the single-write behavior. */
|
||||
export const MAX_RAW_PASTE_PER_CHARACTER_LENGTH = 32;
|
||||
|
||||
// Terminal control bytes are intentional: never fragment shortcuts or reports.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const TERMINAL_CONTROL = /[\x00-\x1f\x7f-\x9f]/;
|
||||
const ESC = "\x1b";
|
||||
|
||||
/** True while the payload only carries plain text and no escape sequence. */
|
||||
@@ -19,7 +16,7 @@ export const isPlainTerminalInputText = (data: string): boolean => !data.include
|
||||
/** One chunk per Unicode code point so surrogate pairs stay intact. */
|
||||
export const splitTextIntoCodePointWrites = (data: string): string[] => Array.from(data);
|
||||
|
||||
/** IME commits: any plain text with more than one character splits per code point. */
|
||||
/** IME commits: any plain text with more than one character splits per glyph. */
|
||||
export const shouldSplitImeTextInputForWire = (text: string): boolean =>
|
||||
Array.from(text).length > 1 && isPlainTerminalInputText(text);
|
||||
|
||||
@@ -38,13 +35,11 @@ export const shouldSplitRawPasteInputForWire = (data: string): boolean => {
|
||||
return true;
|
||||
};
|
||||
|
||||
/** Escape sequences never split. SSH callers also preserve terminal controls. */
|
||||
/** Chunks to write for one input payload; escape sequences are never split. */
|
||||
export const getTextInputWireChunks = (
|
||||
data: string,
|
||||
perCharacterWrites: boolean,
|
||||
preserveTerminalControls = false,
|
||||
): string[] =>
|
||||
perCharacterWrites && isPlainTerminalInputText(data)
|
||||
&& !(preserveTerminalControls && TERMINAL_CONTROL.test(data))
|
||||
? splitTextIntoCodePointWrites(data)
|
||||
: [data];
|
||||
@@ -716,11 +716,6 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
|
||||
if (!snap?.success) {
|
||||
throw new Error(snap?.error || "Failed to capture terminal snapshot");
|
||||
}
|
||||
// Restore the active connection policy before the attached session
|
||||
// accepts input. Saved host changes still require a reconnect.
|
||||
if (typeof snap.perCharacterInput === "boolean") {
|
||||
runtime.restorePerCharacterInput(snap.perCharacterInput);
|
||||
}
|
||||
if (typeof snap.kittyKeyboardProtocolEnabled === "boolean") {
|
||||
runtime.setKittyKeyboardProtocolEnabled(snap.kittyKeyboardProtocolEnabled);
|
||||
}
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
# SSH input compatibility
|
||||
|
||||
The host advanced settings include **输入兼容模式:逐字发送** (input compatibility: send character by character). The help text is **中文输入或短粘贴出现丢字时尝试开启。** Saved changes take effect after reconnecting.
|
||||
|
||||
New SSH hosts use standard mode. Compatibility mode retains the interactive text workaround introduced by [PR #3168](https://github.com/binaricat/Netcatty/pull/3168):
|
||||
|
||||
- Literal IME commits with multiple Unicode code points use separate writes.
|
||||
- Plain, unbracketed pastes of at most 32 Unicode code points use separate writes.
|
||||
- Longer raw pastes, bracketed pastes, Kitty associated text, and the existing confirmed line-paste path retain their current transport behavior.
|
||||
- Terminal controls stay together. Unicode code points retain their byte order, including combining marks and emoji sequences.
|
||||
|
||||
The setting belongs to the terminal endpoint host. A host used only as an SSH network jump does not supply the target's input setting. Other transports retain their existing input behavior. Automated commands, connect scripts, and SFTP do not use this setting.
|
||||
|
||||
## Stored records
|
||||
|
||||
`Host.sshInputCompatibility` stores the choice as a boolean. `sanitizeHost` migrates an SSH record with a missing field to `true`, including records whose omitted protocol means SSH. It preserves explicit `false` and `true` values on every subsequent load, import, and save. The existing vault normalization and persistence paths save the migrated value. No global migration marker or new storage key is required.
|
||||
|
||||
New host creation paths save `false`. Duplicates copy the source value. JSON vault backups and cloud sync retain the field. CSV exports include `SshInputCompatibility`; imports accept `true` and `false`. External imports without that column create standard-mode hosts.
|
||||
|
||||
The connection captures the host's setting under its session-owned terminal settings ref. This snapshot survives xterm disposal and the actual hibernate wake/reattach path. Reconnect refreshes the setting through `resetConnectionInputState(host)`, including reconnects that reuse the existing runtime.
|
||||
|
||||
Attaching an observe popup transfers this captured choice through the existing terminal snapshot IPC. The popup restores it before attaching input to the live backend. Closing the popup returns it with the display snapshot. A hibernated home reads and restores the same session-owned state without consulting the current Vault preference. These handoffs do not reconnect or apply a newly saved choice.
|
||||
|
||||
## Historical evidence
|
||||
|
||||
[Issue #3077](https://github.com/binaricat/Netcatty/issues/3077#issuecomment-5433744875) reports that individual hanzi worked in a Qianxin search prompt where multi-character input failed. The report also says Windows with Xshell worked. Those observations do not establish the remote parser's SSH-write semantics or Xshell's default implementation.
|
||||
|
||||
The [Xshell 7 manual](https://cdn.netsarang.net/docs/Xshell7_manual.pdf), section 5.2.21, printed page 27, describes session paste character delay, line delay, and prompt waiting. [SecureCRT's echo flow control tip](https://www.vandyke.com/support/tips/echoflowctrl.html) describes a session line delay and an optional script that waits for each line's echo. These references support configurable send compatibility. They do not establish default per-character IME sends or equivalence to a Qianxin-specific fix. This feature adds no delays or echo waiting.
|
||||
|
||||
## Verification boundaries
|
||||
|
||||
`sshInputCompatibility.integration.test.ts` runs real xterm and `createXTermRuntime` callbacks under JSDOM. It covers raw paste, literal `insertText`, key code 229, composition events, composition fallback, long IME text, terminal controls, bracketed paste, Kitty text, reconnect with a reused runtime, and the actual `wakeTerminalFromHibernate` entry after a saved setting changes. Local, Telnet, serial, Mosh, and Eternal Terminal cases verify that the SSH field leaves their legacy text and control-containing payload behavior unchanged. Canvas measurement is stubbed. These tests do not exercise native input methods or application windows.
|
||||
|
||||
`sshInputCompatibility.popup.test.ts` runs the actual popup host resolver, home snapshot callbacks, attach boot effect, popup close callback, and real xterm paste handlers. Both saved-setting directions are covered with a live or hibernated home, including input after returning and waking. `terminalBridge.rebindOutput.test.cjs` exercises the preload and authorized main-process snapshot round trip, preserving explicit `false` and rejecting invalid metadata. These automated popup checks do not constitute native multi-window or OS IME validation.
|
||||
|
||||
`useVaultState.inputCompatibility.test.ts` exercises the actual vault hook's asynchronous save, reload, duplicate, JSON backup, local backup payload, and cloud payload apply paths. It uses test localStorage and no native safeStorage key. CSV and endpoint selection have separate round-trip and jump-host tests.
|
||||
|
||||
## Isolated OpenSSH timing fixture
|
||||
|
||||
The implementation-side fixture connected to an owned OpenSSH server on `127.0.0.1:22379` using public-key authentication. It executed `cat` without a PTY because the unprivileged daemon could not allocate one. Production IME/raw-paste selectors and the code-point chunker supplied an `ssh2` client's `channel.write` calls. This fixture did not include native GUI events, renderer IPC, clipboard handling, or an OS input method.
|
||||
|
||||
Each sample used 15 consecutive repetitions per mode on the same ready SSH channel. Standard mode ran before compatibility mode for each sample; the modes were not randomized or interleaved. Authentication, command startup, selector evaluation, and chunk-array construction finished before timing. Each repetition started immediately before its first `channel.write` call and ended after the awaited receive-length condition resolved: the client's echo buffer contained at least the expected UTF-8 byte length. The complete received buffer was then compared byte-for-byte with the input, outside the timer. No artificial send delay was added.
|
||||
|
||||
The table reports milliseconds. Median is the eighth sorted observation. P95 uses nearest rank, which is the largest of 15 observations here. Every repetition in all six tested sample pairs preserved the exact input bytes.
|
||||
|
||||
| Sample | UTF-8 bytes | Client writes, standard / compatibility | Echo median, standard / compatibility | Echo P95, standard / compatibility |
|
||||
|---|---:|---:|---:|---:|
|
||||
| `中文输入` (IME selector) | 12 | 1 / 4 | 47.439 / 44.572 | 57.971 / 48.418 |
|
||||
| `中文👩🏽💻é-hostname-123` (raw paste) | 37 | 1 / 21 | 46.628 / 47.957 | 51.753 / 55.012 |
|
||||
| `中` repeated 32 times (raw paste) | 96 | 1 / 32 | 44.122 / 47.536 | 49.040 / 54.734 |
|
||||
| `中文abc👩🏽💻é` repeated 4,096 times (raw paste) | 110,592 | 1 / 1 | 5.433 / 6.240 | 26.105 / 54.073 |
|
||||
| Arrow sequence `ESC [ A` | 3 | 1 / 1 | 44.320 / 47.657 | 55.726 / 52.414 |
|
||||
| Bracketed paste containing `中文` | 18 | 1 / 1 | 44.151 / 44.995 | 48.959 / 55.769 |
|
||||
|
||||
These are local end-to-end SSH echo completion times for this fixture. They are not native app paste latency, remote processing time, a throughput benchmark, or evidence that the previous behavior caused lag. Client write-call counts do not establish SSH packet boundaries, server read boundaries, or SSH channel-data block counts.
|
||||
|
||||
## Native desktop results
|
||||
|
||||
The final native report tested production code commit `bd5f27ff42d29b18c0e9cb3513415da3243dd636` against two owned loopback SSH endpoints in an isolated profile. The following checks passed:
|
||||
|
||||
- New hosts defaulted to standard mode. Duplicate, save, and application restart preserved each host's choice.
|
||||
- Saving during a connection did not change its input mode. A real reconnect adopted the saved mode in both directions.
|
||||
- Full hibernation used a 5-second hidden-tab delay and zero retained renderers, with xterm absence confirmed. Wake/reattach retained the old connection choice in both directions; a subsequent reconnect adopted the saved choice.
|
||||
- A missing-field legacy record was seeded into the isolated host store. Real application startup migrated it to `true`. After the user disabled compatibility, saved, and restarted, the value remained `false`.
|
||||
- The Chinese setting label, help text, and reconnect notice matched the required text and were captured in screenshots. Arrow and Ctrl+C bytes remained intact.
|
||||
|
||||
| Native sample | Received-content result | Observed SSH channel-data blocks, standard / compatibility |
|
||||
|---|---|---|
|
||||
| `中文测试🙂é输入兼容模式` — 37 UTF-8 bytes, 13 code points | Hashes matched between modes | 1 / 13 |
|
||||
| Long sample — 131,072 bytes | Hashes matched between modes | Four 32,768-byte frames / four 32,768-byte frames |
|
||||
| Negotiated bracketed paste — 49 bytes | Preserved | 1 / 1 |
|
||||
|
||||
The native short sample differs from the implementation fixture's 37-byte sample above. Its 1 / 13 SSH channel-data block observation is not the implementation fixture's 1 / 21 client write-call count. The native 131,072-byte long sample is also distinct from the 110,592-byte timing sample.
|
||||
|
||||
The native timing observation was one PTY first-to-last receive span per mode: standard **3.408724 ms**, compatibility **1.600044 ms**. It starts at the first received PTY payload and ends at the last received payload. It excludes the input-to-first-receive interval and is not total GUI paste latency. One observation per mode does not provide a median, P95, or causal performance comparison. These spans cannot be substituted for the 15-repetition client echo timings above. Neither run establishes throughput superiority or that the previous workaround caused lag.
|
||||
|
||||
Actual OS IME input and Qianxin hardware were not tested. Native networked cloud sync, import, and jump-chain behavior were also not tested; those data flows have automated coverage rather than claimed native validation. Synthetic DOM composition events, native GUI/paste coverage, client write calls, SSH data blocks, and receive timing remain separate evidence categories.
|
||||
|
||||
Independent review confirmed the three reported P2 findings resolved, with no new blocking findings. The native application, SSH and clipboard fixture processes, isolated profile, and test keys were cleaned up; the source tree was clean after that run.
|
||||
|
||||
## CI result
|
||||
|
||||
The `test` workflow completed successfully for documentation commit `38c5f4744cb5fc3ecdd4e624b323587a471e4333`: [run 40319492139](https://github.com/binaricat/Netcatty/actions/runs/40319492139). That workflow includes `npm run lint`, `npm test`, generated-spec checks, and `npm run build`. The production code is unchanged from the native-tested `bd5f27ff` commit. Subsequent acceptance-text edits change documentation only. The original feature was merged in [PR #3977](https://github.com/binaricat/Netcatty/pull/3977); those results predate the popup snapshot follow-up.
|
||||
@@ -793,15 +793,3 @@ test("sanitizeHost preserves named-pipe spaces and restores the authoritative se
|
||||
assert.equal(sanitizeHost(host).serialConfig?.path, path);
|
||||
assert.equal(sanitizeHost(makeHost({ hostname: 'example.com extra' })).hostname, 'example.com');
|
||||
});
|
||||
|
||||
|
||||
test("SSH input compatibility migrates legacy hosts once and preserves later choices", () => {
|
||||
const legacy = { id: "legacy", label: "Legacy", hostname: "localhost", username: "user", tags: [], os: "linux" } as Host;
|
||||
for (const protocol of [undefined, "ssh"] as const) {
|
||||
const migrated = sanitizeHost({ ...legacy, protocol });
|
||||
assert.equal(migrated.sshInputCompatibility, true);
|
||||
assert.equal(sanitizeHost(migrated).sshInputCompatibility, true);
|
||||
assert.equal(sanitizeHost({ ...migrated, sshInputCompatibility: false }).sshInputCompatibility, false);
|
||||
}
|
||||
assert.equal(sanitizeHost({ ...legacy, protocol: "telnet" }).sshInputCompatibility, undefined);
|
||||
});
|
||||
@@ -466,11 +466,6 @@ export const sanitizeHost = (host: Host, snippets: Snippet[] = []): Host => {
|
||||
? undefined
|
||||
: migrated.authMethod ?? inferredLegacyAuthMethod,
|
||||
authPolicyVersion: 1,
|
||||
// Legacy SSH records used per-character IME/short-paste writes globally.
|
||||
// Persist the choice on normalization; explicit false survives every reload.
|
||||
sshInputCompatibility: host.protocol === undefined || host.protocol === 'ssh'
|
||||
? host.sshInputCompatibility ?? true
|
||||
: host.sshInputCompatibility,
|
||||
hostname: cleanHostname,
|
||||
osOverride: osSelection,
|
||||
os: osSelection === 'linux' || osSelection === 'windows' || osSelection === 'macos' ? osSelection : host.os,
|
||||
|
||||
@@ -189,9 +189,6 @@ export interface Host {
|
||||
identityFileId?: string; // Reference to SSHKey
|
||||
protocol?: HostProtocol; // Default/primary protocol, including namespaced plugin protocols
|
||||
pluginConnection?: PluginConnectionConfig;
|
||||
// SSH interactive IME/short-paste compatibility; applied on reconnect.
|
||||
// Missing on legacy records; sanitizeHost preserves their previous behavior.
|
||||
sshInputCompatibility?: boolean;
|
||||
// Runtime marker for in-memory-only hosts (e.g. password deep links).
|
||||
// Ephemeral hosts are never persisted to the vault or session restore.
|
||||
ephemeral?: boolean;
|
||||
|
||||
@@ -820,29 +820,3 @@ test('plugin importer preview is bounded and never exposes secret or command pay
|
||||
assert.equal(JSON.stringify(preview).includes('private-key-material'), false);
|
||||
assert.equal(JSON.stringify(preview).includes('contains-sensitive-command'), false);
|
||||
});
|
||||
|
||||
|
||||
test('new imported SSH drafts default to standard input and preserve explicit boolean choices', () => {
|
||||
for (const protocol of [undefined, 'ssh'] as const) {
|
||||
for (const choice of [undefined, false, true]) {
|
||||
const result = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host', value: {
|
||||
hostname: 'new.test', ...(protocol ? { protocol } : {}),
|
||||
...(choice === undefined ? {} : { sshInputCompatibility: choice }),
|
||||
},
|
||||
} }]);
|
||||
assert.deepEqual(result.errors, []);
|
||||
assert.equal(result.hosts[0].sshInputCompatibility, choice ?? false);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('imported host drafts reject non-boolean input compatibility choices', () => {
|
||||
for (const choice of ['false', 'true', 0, null]) {
|
||||
const result = normalizePluginImporterRecords([{ type: 'draft', draft: {
|
||||
kind: 'host', value: { hostname: 'new.test', protocol: 'ssh', sshInputCompatibility: choice },
|
||||
} }]);
|
||||
assert.deepEqual(result.hosts, []);
|
||||
assert.deepEqual(result.errors, ['Importer returned an invalid host draft.']);
|
||||
}
|
||||
});
|
||||
@@ -204,7 +204,6 @@ const normalizeHost = (value: JsonValue): Host | null => {
|
||||
};
|
||||
if (Object.values(optionalStrings).some((item) => item === INVALID_FIELD)) return null;
|
||||
const optionalBooleans = {
|
||||
sshInputCompatibility: optionalBooleanValue(object, 'sshInputCompatibility'),
|
||||
moshEnabled: optionalBooleanValue(object, 'moshEnabled'),
|
||||
etEnabled: optionalBooleanValue(object, 'etEnabled'),
|
||||
telnetEnabled: optionalBooleanValue(object, 'telnetEnabled'),
|
||||
@@ -243,7 +242,6 @@ const normalizeHost = (value: JsonValue): Host | null => {
|
||||
createdAt: Date.now(),
|
||||
ephemeral: false,
|
||||
managedSourceId: undefined,
|
||||
sshInputCompatibility: false,
|
||||
...(protocol ? { protocol } : {}),
|
||||
...(pluginConnection ? { pluginConnection: structuredClone(pluginConnection) } : {}),
|
||||
...(deviceType ? { deviceType } : {}),
|
||||
|
||||
@@ -68,7 +68,6 @@ export const buildQuickConnectHost = ({
|
||||
os: "linux",
|
||||
protocol: protocol === "mosh" || protocol === "et" ? "ssh" : protocol,
|
||||
authMethod,
|
||||
sshInputCompatibility: false,
|
||||
identityId: applicableIdentityId,
|
||||
password: !applicableIdentityId && authMethod === "password" ? password : undefined,
|
||||
identityFileId:
|
||||
|
||||
@@ -5,7 +5,6 @@ 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> = {
|
||||
@@ -153,37 +152,3 @@ 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,9 +1,6 @@
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import {
|
||||
SETTINGS_SEARCH_CATALOG,
|
||||
isSettingsEntryAvailableOn,
|
||||
resolveSettingsPlatform,
|
||||
type SettingsPlatform,
|
||||
type SettingsSearchEntry,
|
||||
type SettingsTabId,
|
||||
} from "./settingsSearchCatalog";
|
||||
@@ -64,22 +61,16 @@ 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,8 +31,6 @@ 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;
|
||||
@@ -49,33 +47,8 @@ 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
|
||||
{
|
||||
@@ -135,14 +108,6 @@ 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",
|
||||
|
||||
@@ -2,7 +2,6 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import type { Host } from "./models";
|
||||
import { resolveHostAutofillPassword } from "./sshAuth";
|
||||
import { sanitizeHost } from "./host";
|
||||
import {
|
||||
buildSshDeepLinkConnectionHost,
|
||||
buildSshDeepLinkEphemeralHost,
|
||||
@@ -243,10 +242,8 @@ test("buildSshDeepLinkHostDraft prepares an editable new ssh host", () => {
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sshInputCompatibility: false,
|
||||
createdAt: 123,
|
||||
});
|
||||
assert.equal(sanitizeHost(draft).sshInputCompatibility, false);
|
||||
});
|
||||
|
||||
test("buildSshDeepLinkOpenHost falls back to a draft host when no saved host matches", () => {
|
||||
@@ -334,13 +331,3 @@ test("buildSshNoteLinkOpenHost ignores unrelated external links", () => {
|
||||
|
||||
assert.equal(openHost, null);
|
||||
});
|
||||
|
||||
|
||||
test("deep links derived from saved hosts preserve explicit input choices", () => {
|
||||
const target = parseSshDeepLink("ssh://alice:secret@example.com")!;
|
||||
for (const sshInputCompatibility of [false, true]) {
|
||||
const saved = host({ sshInputCompatibility });
|
||||
assert.equal(buildSshDeepLinkConnectionHost(saved).sshInputCompatibility, sshInputCompatibility);
|
||||
assert.equal(buildSshDeepLinkEphemeralHostFromSaved(saved, target, { id: "copy", now: 1 }).sshInputCompatibility, sshInputCompatibility);
|
||||
}
|
||||
});
|
||||
@@ -165,7 +165,6 @@ export const buildSshDeepLinkHostDraft = (
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
sshInputCompatibility: false,
|
||||
createdAt: options.now,
|
||||
});
|
||||
|
||||
|
||||
@@ -675,16 +675,3 @@ test("resolveTerminalChainHosts skips non-SSH jump candidates", () => {
|
||||
|
||||
assert.deepEqual(resolved.map((host) => host.id), ["jump-ssh", "jump-mosh"]);
|
||||
});
|
||||
|
||||
|
||||
test("input compatibility belongs to the terminal endpoint, independently of SSH jump hops", () => {
|
||||
const target: Host = { id: "target", label: "Target", hostname: "target.example.test", username: "alice",
|
||||
protocol: "ssh", tags: [], os: "linux", sshInputCompatibility: false, hostChain: { hostIds: ["bastion"] } };
|
||||
const bastion: Host = { ...target, id: "bastion", hostChain: undefined, sshInputCompatibility: true };
|
||||
const resolve = (hostId: string) => resolveTerminalSessionHost({
|
||||
session: { ...baseSession, hostId }, hosts: [target, bastion], groupConfigs: [], proxyProfiles: [], localOs: "linux",
|
||||
});
|
||||
assert.equal(resolve("target").sshInputCompatibility, false);
|
||||
assert.equal(resolve("bastion").sshInputCompatibility, true);
|
||||
assert.equal(resolveTerminalChainHosts({ host: target, hosts: [target, bastion], groupConfigs: [], proxyProfiles: [] })[0].sshInputCompatibility, true);
|
||||
});
|
||||
@@ -1956,10 +1956,3 @@ test('applyVaultHostDelete keeps jump hosts referenced by empty group defaults',
|
||||
assert.equal(result.ok, false);
|
||||
if (!result.ok) assert.match(result.error, /still used as a group jump host/i);
|
||||
});
|
||||
|
||||
|
||||
test('new SSH hosts explicitly use standard interactive input', () => {
|
||||
const created = buildVaultHostFromDraft({ hostname: 'localhost', username: 'user' });
|
||||
assert.equal(created.ok, true);
|
||||
if (created.ok) assert.equal(created.host.sshInputCompatibility, false);
|
||||
});
|
||||
@@ -329,7 +329,6 @@ export function buildVaultHostFromDraft(
|
||||
os,
|
||||
osOverride: parsedOs ?? 'auto',
|
||||
protocol,
|
||||
sshInputCompatibility: false,
|
||||
createdAt: now,
|
||||
...(keyPath
|
||||
? {
|
||||
|
||||
@@ -665,7 +665,6 @@ const importFromCsv = (
|
||||
const proxyIdx = findExactHeaderIndex(header, ["proxy", "proxyserver", "proxy server"]);
|
||||
const proxyJumpIdx = findExactHeaderIndex(header, ["proxyjump"]);
|
||||
const localShellIdx = findExactHeaderIndex(header, ["localshell"]);
|
||||
const inputCompatibilityIdx = findExactHeaderIndex(header, ["sshinputcompatibility"]);
|
||||
const passphraseIdx = keyPathIdx >= 0 ? explicitPassphraseIdx : -1;
|
||||
const exactPasswordIdx = findExactHeaderIndex(header, ["password", "pass", "passwd"]);
|
||||
const fuzzyNamedPasswordIdx = findHeaderIndex(header, ["password", "passwd"]);
|
||||
@@ -796,10 +795,6 @@ const importFromCsv = (
|
||||
tags,
|
||||
notes,
|
||||
});
|
||||
if (inputCompatibilityIdx >= 0 && (protocol === undefined || protocol === "ssh")) {
|
||||
const choice = row[inputCompatibilityIdx]?.trim().toLowerCase();
|
||||
if (choice === "true" || choice === "false") host.sshInputCompatibility = choice === "true";
|
||||
}
|
||||
if (proxyConfig) host.proxyConfig = proxyConfig;
|
||||
if (directProxy) host.directProxy = true;
|
||||
if (localShellSpec && protocol === "local") {
|
||||
|
||||
@@ -842,16 +842,3 @@ test("CSV import warns and ignores a malformed LocalShell value", () => {
|
||||
assert.equal(imported.hosts[0]?.localShell, undefined);
|
||||
assert.ok(imported.issues.some((issue) => issue.message.includes("row 2") && issue.message.includes("LocalShell")));
|
||||
});
|
||||
|
||||
|
||||
test("CSV export/import preserves independent SSH input compatibility choices", () => {
|
||||
const hosts: Host[] = [false, true].map((choice, index) => ({
|
||||
...hostDefaults, id: `mode-${index}`, label: `Mode ${index}`,
|
||||
hostname: `mode-${index}.example.test`, protocol: "ssh", sshInputCompatibility: choice,
|
||||
}));
|
||||
const { csv } = exportHostsToCsvWithStats(hosts);
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.deepEqual(imported.hosts.map(host => host.sshInputCompatibility), [false, true]);
|
||||
const second = importVaultHostsFromText("csv", exportHostsToCsvWithStats(imported.hosts).csv);
|
||||
assert.deepEqual(second.hosts.map(host => host.sshInputCompatibility), [false, true]);
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export const getVaultCsvTemplate = (
|
||||
opts: VaultCsvTemplateOptions = {},
|
||||
): string => {
|
||||
const includeExampleRows = opts.includeExampleRows !== false;
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
if (includeExampleRows) {
|
||||
rows.push(["Project/Dev", "Web Server (dev)", "dev,web", "Dev web tier", "192.168.1.10", "ssh", "22", "root", "", "~/.ssh/id_ed25519", "", "socks5://127.0.0.1:1080"]);
|
||||
@@ -115,7 +115,7 @@ const formatCsvProxyJump = (host: Host, allHosts: readonly Host[]): string => {
|
||||
};
|
||||
|
||||
const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell", "SshInputCompatibility"];
|
||||
const header = ["Groups", "Label", "Tags", "Notes", "Hostname/IP", "Protocol", "Port", "Username", "Password", "KeyPath", "Passphrase", "Proxy", "ProxyJump", "LocalShell"];
|
||||
const rows: string[][] = [header];
|
||||
let unreadableProxyCredentialCount = 0;
|
||||
|
||||
@@ -217,7 +217,6 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
encodeCsvProxy(proxyValue),
|
||||
encodeCsvProxyJump(proxyJumpValue),
|
||||
localShellSpec ? encodeCsvLocalShell(localShellSpec) : "",
|
||||
host.protocol === undefined || host.protocol === "ssh" ? String(host.sshInputCompatibility ?? true) : "",
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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);
|
||||
});
|
||||
@@ -1,6 +0,0 @@
|
||||
export function isWindowBackgroundBlurActive(
|
||||
requested: boolean,
|
||||
windowOpacity: number,
|
||||
): boolean {
|
||||
return requested && Number.isFinite(windowOpacity) && windowOpacity < 1;
|
||||
}
|
||||
@@ -108,7 +108,7 @@ test('handoffDissolvedWorkspaceAIScope retargets inherited chat when its pane is
|
||||
});
|
||||
});
|
||||
|
||||
test('handoffDissolvedWorkspaceAIScope keeps inherited chat on its surviving owner pane', () => {
|
||||
test('handoffDissolvedWorkspaceAIScope retargets inherited chat when original pane also survives', () => {
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: {
|
||||
'workspace:ws-1': 'chat-a',
|
||||
@@ -132,52 +132,12 @@ test('handoffDissolvedWorkspaceAIScope keeps inherited chat on its surviving own
|
||||
preferredTerminalId: 'term-b',
|
||||
});
|
||||
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
|
||||
assert.equal(result.sessions[0]?.scope.type, 'terminal');
|
||||
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
|
||||
assert.equal(result.sessions[0]?.updatedAt, 1);
|
||||
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
|
||||
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
|
||||
mode: 'session',
|
||||
sessionId: 'chat-a',
|
||||
});
|
||||
assert.equal(result.panelViewByScope['terminal:term-b'], undefined);
|
||||
});
|
||||
|
||||
test('handoffDissolvedWorkspaceAIScope keeps owner history when the other pane is preferred', () => {
|
||||
const result = handoffDissolvedWorkspaceAIScope({
|
||||
activeSessionIdMap: {
|
||||
'workspace:ws-1': 'chat-a',
|
||||
'terminal:term-b': 'chat-b',
|
||||
},
|
||||
sessions: [
|
||||
{
|
||||
id: 'chat-a',
|
||||
scope: { type: 'terminal', targetId: 'term-a', hostIds: ['host-a'] },
|
||||
updatedAt: 1,
|
||||
},
|
||||
{
|
||||
id: 'chat-b',
|
||||
scope: { type: 'terminal', targetId: 'term-b', hostIds: ['host-b'] },
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
workspaceId: 'ws-1',
|
||||
terminalIds: ['term-a', 'term-b'],
|
||||
preferredTerminalId: 'term-b',
|
||||
panelViewByScope: {
|
||||
'workspace:ws-1': { mode: 'session', sessionId: 'chat-a' },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.changed, true);
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-a'], 'chat-a');
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-b');
|
||||
assert.equal(result.sessions[0]?.scope.targetId, 'term-a');
|
||||
assert.equal(result.sessions[1]?.id, 'chat-b');
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-b'], 'chat-a');
|
||||
assert.equal(result.activeSessionIdMap['terminal:term-a'], null);
|
||||
assert.equal(result.sessions[0]?.scope.targetId, 'term-b');
|
||||
assert.equal(result.sessions[1]?.scope.targetId, 'term-b');
|
||||
assert.deepEqual(result.panelViewByScope['terminal:term-a'], {
|
||||
assert.deepEqual(result.panelViewByScope['terminal:term-b'], {
|
||||
mode: 'session',
|
||||
sessionId: 'chat-a',
|
||||
});
|
||||
|
||||
@@ -78,15 +78,13 @@ export function seedWorkspaceAIActiveSessionFromMembers(input: {
|
||||
}
|
||||
|
||||
/**
|
||||
* When a workspace tab dissolves, copy its active chat onto a surviving
|
||||
* terminal before the workspace scope key is pruned. An inherited chat that
|
||||
* is already terminal-scoped to a pane still in `terminalIds` stays on that
|
||||
* owner, even when focus/preferred is a different survivor. Otherwise the
|
||||
* target is the preferred survivor, or the first remaining terminal when the
|
||||
* original pane is gone. Workspace-scoped chats are reminted onto that
|
||||
* target, duplicate terminal map entries for the same chat are cleared, and
|
||||
* a session panel view is seeded so terminal scopes do not fall back to a
|
||||
* blank draft.
|
||||
* When a workspace tab dissolves, copy its active chat onto the preferred
|
||||
* surviving terminal before the workspace scope key is pruned. Also remint
|
||||
* workspace-scoped chats and the handed-off active chat when it still lives
|
||||
* under a different terminal (including one that also survives dissolve),
|
||||
* clear that previous terminal selection so ownership filters do not hide the
|
||||
* chat, and seed a session panel view so terminal scopes do not fall back to
|
||||
* a blank draft.
|
||||
*/
|
||||
export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoffLike>(input: {
|
||||
activeSessionIdMap: AIActiveSessionIdMap;
|
||||
@@ -101,7 +99,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
panelViewByScope: Record<string, AIPanelViewHandoffLike>;
|
||||
changed: boolean;
|
||||
} {
|
||||
const fallbackTerminalId = (
|
||||
const preferredTerminalId = (
|
||||
input.preferredTerminalId
|
||||
&& input.terminalIds.includes(input.preferredTerminalId)
|
||||
? input.preferredTerminalId
|
||||
@@ -111,17 +109,6 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
const workspaceKey = buildAIScopeKey('workspace', input.workspaceId);
|
||||
const workspaceActive = input.activeSessionIdMap[workspaceKey];
|
||||
const hasWorkspaceActive = typeof workspaceActive === 'string' && workspaceActive.length > 0;
|
||||
const activeSession = hasWorkspaceActive
|
||||
? input.sessions.find((session) => session.id === workspaceActive)
|
||||
: undefined;
|
||||
const survivingOwnerTerminalId = (
|
||||
activeSession?.scope.type === 'terminal'
|
||||
&& activeSession.scope.targetId
|
||||
&& input.terminalIds.includes(activeSession.scope.targetId)
|
||||
? activeSession.scope.targetId
|
||||
: null
|
||||
);
|
||||
const handoffTargetTerminalId = survivingOwnerTerminalId ?? fallbackTerminalId;
|
||||
const previousPanelViewByScope = (
|
||||
input.panelViewByScope as Record<string, AIPanelViewHandoffLike> | undefined
|
||||
) ?? {};
|
||||
@@ -131,8 +118,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
};
|
||||
let mapChanged = false;
|
||||
|
||||
if (handoffTargetTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
|
||||
if (preferredTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
|
||||
let map = nextMap;
|
||||
let changed = false;
|
||||
if (map[terminalKey] !== workspaceActive) {
|
||||
@@ -158,23 +145,24 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
|
||||
let sessionsChanged = false;
|
||||
const nextSessions = input.sessions.map((session) => {
|
||||
if (!handoffTargetTerminalId) return session;
|
||||
if (!preferredTerminalId) return session;
|
||||
|
||||
const isWorkspaceScoped = (
|
||||
session.scope.type === 'workspace'
|
||||
&& session.scope.targetId === input.workspaceId
|
||||
);
|
||||
// Keep an inherited chat on its owner when that pane survives. Remint
|
||||
// only when the handoff target is a different terminal (original pane gone).
|
||||
// Inherited member chats stay terminal-scoped under the original pane.
|
||||
// Prefer the focused survivor even when that original pane also survives
|
||||
// dissolve, otherwise B's history ownership filter excludes A's chat.
|
||||
const isInheritedActiveChatOnOtherTerminal = (
|
||||
hasWorkspaceActive
|
||||
&& session.id === workspaceActive
|
||||
&& session.scope.type === 'terminal'
|
||||
&& Boolean(session.scope.targetId)
|
||||
&& session.scope.targetId !== handoffTargetTerminalId
|
||||
&& session.scope.targetId !== preferredTerminalId
|
||||
);
|
||||
if (!isWorkspaceScoped && !isInheritedActiveChatOnOtherTerminal) return session;
|
||||
if (session.scope.type === 'terminal' && session.scope.targetId === handoffTargetTerminalId) {
|
||||
if (session.scope.type === 'terminal' && session.scope.targetId === preferredTerminalId) {
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -184,7 +172,7 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
scope: {
|
||||
...session.scope,
|
||||
type: 'terminal',
|
||||
targetId: handoffTargetTerminalId,
|
||||
targetId: preferredTerminalId,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
@@ -194,8 +182,8 @@ export function handoffDissolvedWorkspaceAIScope<T extends AISessionScopeHandoff
|
||||
const nextPanelViewByScope: Record<string, AIPanelViewHandoffLike> = {
|
||||
...previousPanelViewByScope,
|
||||
};
|
||||
if (handoffTargetTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', handoffTargetTerminalId);
|
||||
if (preferredTerminalId && hasWorkspaceActive) {
|
||||
const terminalKey = buildAIScopeKey('terminal', preferredTerminalId);
|
||||
const workspacePanelView = previousPanelViewByScope[workspaceKey];
|
||||
const terminalPanelView = previousPanelViewByScope[terminalKey];
|
||||
const nextView: AIPanelViewHandoffLike = (
|
||||
|
||||
@@ -71,17 +71,9 @@ const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s
|
||||
|
||||
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
|
||||
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
|
||||
// without a prompt marker on this line. Oh My Zsh `ys` uses a different host
|
||||
// line (`# user @ host ...`), matched separately below. A bare `$`/`#` alone
|
||||
// is not a prompt.
|
||||
// without a prompt marker on this line. A bare `$`/`#` alone is not a prompt.
|
||||
const POSIX_PROMPT_HOST_LINE_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?$/;
|
||||
|
||||
// Oh My Zsh `ys` status line: a decorative `#`, then `user @ host` with
|
||||
// spaces around `@`, then an optional remainder (directory, git, venv, time,
|
||||
// exit code). Example: `# user @ host in ~ [12:34:56]`. Arbitrary text before
|
||||
// a bare `$`/`#` still does not match.
|
||||
const OH_MY_ZSH_YS_HOST_LINE_PATTERN = /^#\s+[^\s@]+\s+@\s+[^\s]+(?:\s+[^\n\r]+)?$/;
|
||||
|
||||
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
|
||||
// Require a hostname and a home/absolute path, excluding prompt delimiters
|
||||
// within the path so partially entered commands do not count as idle prompts.
|
||||
@@ -132,15 +124,12 @@ function extractTrailingIdlePrompt(output) {
|
||||
return lastLine;
|
||||
}
|
||||
|
||||
// Two-line PS1. Classic bash is `export PS1="\u@\h: \w\n$ "`. Oh My Zsh
|
||||
// `ys` paints `# user @ host ...` and then a bare `$` or `#`. Bash emits
|
||||
// the newline as CR and the PTY's ONLCR adds another, so the bytes are
|
||||
// `\r\r\n`. The CR-to-LF normalize above turns that into blank lines
|
||||
// between the host line and `$`/`#`. Keep those blanks in the cache:
|
||||
// getFreshIdlePrompt uses the same normalize, and hasExpectedPromptSuffix
|
||||
// compares this CR-as-LF form against live PTY bytes. A lone `$`/`#` must
|
||||
// not satisfy endsWith; the previous non-empty line has to be a recognized
|
||||
// host line (classic `user@host[:path]` or Oh My Zsh `ys`).
|
||||
// Two-line PS1 (`export PS1="\u@\h: \w\n$ "`). Bash emits the newline as
|
||||
// CR and the PTY's ONLCR adds another, so the bytes are `\r\r\n`. The
|
||||
// CR-to-LF normalize above turns that into blank lines between the host
|
||||
// line and `$`/`#`. Keep those blanks in the cache: getFreshIdlePrompt
|
||||
// uses the same normalize, and hasExpectedPromptSuffix compares this
|
||||
// CR-as-LF form against live PTY bytes. A lone `$` must not satisfy endsWith.
|
||||
if (rightTrimmed === "$" || rightTrimmed === "#") {
|
||||
let hostIndex = lines.length - 1;
|
||||
while (hostIndex >= 0 && lines[hostIndex].replace(/\s+$/, "") === "") {
|
||||
@@ -148,13 +137,7 @@ function extractTrailingIdlePrompt(output) {
|
||||
}
|
||||
const previousLine = hostIndex >= 0 ? lines[hostIndex] : "";
|
||||
const previousRightTrimmed = previousLine.replace(/\s+$/, "");
|
||||
// Same optional "(name) " env prefix as isDefaultPosixPromptLine (#3947).
|
||||
// Recognition only; the returned prompt keeps the visible prefix.
|
||||
const previousHostLine = previousRightTrimmed.replace(/^\([^()\n\r]+\)\s+/, "");
|
||||
if (
|
||||
POSIX_PROMPT_HOST_LINE_PATTERN.test(previousHostLine)
|
||||
|| OH_MY_ZSH_YS_HOST_LINE_PATTERN.test(previousHostLine)
|
||||
) {
|
||||
if (POSIX_PROMPT_HOST_LINE_PATTERN.test(previousRightTrimmed)) {
|
||||
return `${lines.slice(hostIndex).join("\n")}\n${lastLine}`;
|
||||
}
|
||||
}
|
||||
@@ -219,9 +202,8 @@ function trackSessionIdlePrompt(session, chunk) {
|
||||
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
|
||||
// still ends with it. The cached prompt is updated only when
|
||||
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
|
||||
// `user@host[:path][#$]`, two-line classic `user@host[:path]` or Oh My Zsh
|
||||
// `ys` (`# user @ host ...`) + `$`/`#`, RHEL `[user@host path][#$]`, or
|
||||
// Alpine `host:path[#$]`);
|
||||
// `user@host[:path][#$]`, two-line `user@host[:path]` + `$`/`#`,
|
||||
// RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
|
||||
// a remote shell switch into another shell, an oh-my-posh / starship / custom
|
||||
// PS1, or any unrecognized prompt would
|
||||
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
|
||||
|
||||
@@ -262,83 +262,6 @@ test("trackSessionIdlePrompt and getFreshIdlePrompt accept a two-line user@host
|
||||
assert.equal(getFreshIdlePrompt(ptySession), "user@host: /home/foo\n\n\n$ ");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt recognizes an Oh My Zsh ys two-line idle prompt", () => {
|
||||
// ys paints `# user @ host ...` then a bare `$` or `#`. Spaces around `@`
|
||||
// and the leading decorative `#` are required; a lone marker is not a prompt.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ "),
|
||||
"# user @ host in ~ [12:34:56]\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\n# user @ host in ~ [12:34:56]\n# "),
|
||||
"# user @ host in ~ [12:34:56]\n# ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("# user @ host in ~/proj on git:main [12:34:56]\n$ "),
|
||||
"# user @ host in ~/proj on git:main [12:34:56]\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("welcome\r\n# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ "),
|
||||
"# ys @ ys-mbp in ~/.oh-my-zsh on git:master x [21:47:42] C:127\n$ ",
|
||||
);
|
||||
// Same CR / CRLF blank lines as the classic two-line PS1.
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\r\n$ "),
|
||||
"# user @ host in ~ [12:34:56]\n\n\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\r\n# "),
|
||||
"# user @ host in ~ [12:34:56]\n\n# ",
|
||||
);
|
||||
// Optional "(name) " env prefix is stripped only for recognition (#3947).
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("(venv) # user @ host in ~ [12:34:56]\n$ "),
|
||||
"(venv) # user @ host in ~ [12:34:56]\n$ ",
|
||||
);
|
||||
assert.equal(
|
||||
extractTrailingIdlePrompt("(venv) user@host: /home/foo\n$ "),
|
||||
"(venv) user@host: /home/foo\n$ ",
|
||||
);
|
||||
|
||||
assert.equal(extractTrailingIdlePrompt("$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("not a host line\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("user @ host in ~\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# user host in ~ [12:34:56]\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# user@host in ~ [12:34:56]\n$ "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("copied output\n# "), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56]\n$ ls"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("# user @ host in ~ [12:34:56] $"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("echo $"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("price is 5$"), "");
|
||||
assert.equal(extractTrailingIdlePrompt("(venv) not a host line\n$ "), "");
|
||||
});
|
||||
|
||||
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oh My Zsh ys two-line prompt", () => {
|
||||
const session = {};
|
||||
const prompt = "# user @ host in ~ [12:34:56]\n$ ";
|
||||
trackSessionIdlePrompt(session, `welcome\r\n${prompt}`);
|
||||
assert.equal(getFreshIdlePrompt(session), prompt);
|
||||
|
||||
const extra = {};
|
||||
trackSessionIdlePrompt(extra, "# user @ host in ~/proj on git:main [12:34:56]\r\r\n$ ");
|
||||
assert.equal(
|
||||
getFreshIdlePrompt(extra),
|
||||
"# user @ host in ~/proj on git:main [12:34:56]\n\n\n$ ",
|
||||
);
|
||||
|
||||
const root = {};
|
||||
trackSessionIdlePrompt(root, "# user @ host in ~ [12:34:56]\n# ");
|
||||
assert.equal(getFreshIdlePrompt(root), "# user @ host in ~ [12:34:56]\n# ");
|
||||
|
||||
const bare = {};
|
||||
assert.equal(trackSessionIdlePrompt(bare, "$ "), "");
|
||||
assert.equal(getFreshIdlePrompt(bare), "");
|
||||
assert.equal(trackSessionIdlePrompt(bare, "not a host line\n$ "), "");
|
||||
assert.equal(getFreshIdlePrompt(bare), "");
|
||||
});
|
||||
|
||||
test("extractTrailingIdlePrompt still recognizes a classic single-line POSIX prompt", () => {
|
||||
assert.equal(extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "), "user@host:~$ ");
|
||||
});
|
||||
|
||||
@@ -9,8 +9,7 @@ const FIXED_DH_GROUP_BY_KEX = Object.freeze({
|
||||
});
|
||||
|
||||
const CIPHER_SSL_NAME_BY_ALGORITHM = Object.freeze({
|
||||
// Patched ssh2 supplies original ChaCha20 when Electron/BoringSSL lacks it.
|
||||
// Do not filter this SSH cipher against the OpenSSL-only inventory.
|
||||
"chacha20-poly1305@openssh.com": "chacha20",
|
||||
"aes128-gcm@openssh.com": "aes-128-gcm",
|
||||
"aes256-gcm@openssh.com": "aes-256-gcm",
|
||||
"aes128-gcm": "aes-128-gcm",
|
||||
|
||||
@@ -144,17 +144,17 @@ for (const [label, buildAlgorithms] of [
|
||||
});
|
||||
});
|
||||
|
||||
test(`${label} retains chacha20-poly1305 through ssh2's fallback when the runtime lacks chacha20`, () => {
|
||||
test(`${label} drops chacha20-poly1305 when the runtime lacks chacha20`, () => {
|
||||
const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20");
|
||||
|
||||
withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => {
|
||||
const algorithms = buildAlgorithms(false);
|
||||
|
||||
assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), true);
|
||||
assert.equal(algorithms.cipher.includes("chacha20-poly1305@openssh.com"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test(`${label} preserves explicit ChaCha20 and mixed cipher overrides without native crypto`, () => {
|
||||
test(`${label} cipher override is filtered against runtime support`, () => {
|
||||
const ciphersWithoutChacha20 = crypto.getCiphers().filter((cipher) => cipher !== "chacha20");
|
||||
|
||||
withAlgorithmRuntime({ ciphers: ciphersWithoutChacha20 }, () => {
|
||||
@@ -164,10 +164,7 @@ for (const [label, buildAlgorithms] of [
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(algorithms.cipher, ["chacha20-poly1305@openssh.com", "aes256-ctr"]);
|
||||
assert.deepEqual(buildAlgorithms(false, {
|
||||
algorithmOverrides: { cipher: ["chacha20-poly1305@openssh.com"] },
|
||||
}).cipher, ["chacha20-poly1305@openssh.com"]);
|
||||
assert.deepEqual(algorithms.cipher, ["aes256-ctr"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const crypto = require("node:crypto");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawn } = require("node:child_process");
|
||||
const { Server } = require("ssh2");
|
||||
|
||||
const CHACHA = "chacha20-poly1305@openssh.com";
|
||||
const AES = "aes128-gcm@openssh.com";
|
||||
const root = path.resolve(__dirname, "../..");
|
||||
const { privateKey } = crypto.generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const hostKey = privateKey.export({ type: "pkcs1", format: "pem" });
|
||||
|
||||
async function connect(t, {
|
||||
serverCiphers = [CHACHA],
|
||||
cipher,
|
||||
entrypoint = "netcatty:start",
|
||||
withoutNativeChacha = true,
|
||||
} = {}) {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-chacha-"));
|
||||
t.after(() => fs.rmSync(homeDir, { recursive: true, force: true }));
|
||||
const offered = {};
|
||||
let negotiated;
|
||||
const connections = new Set();
|
||||
// The server uses Node/OpenSSL's ChaCha20; only the child uses the fallback.
|
||||
const server = new Server({
|
||||
hostKeys: [hostKey],
|
||||
algorithms: { cipher: serverCiphers },
|
||||
debug(line) {
|
||||
const match = /Handshake: \(remote\) (C->S|S->C) cipher: (.*)/.exec(line);
|
||||
if (match) offered[match[1]] = match[2].split(",");
|
||||
},
|
||||
}, (client) => {
|
||||
connections.add(client);
|
||||
client.on("error", () => {});
|
||||
client.on("close", () => connections.delete(client));
|
||||
client.on("handshake", (info) => { negotiated = info; });
|
||||
client.on("authentication", (ctx) => ctx.accept());
|
||||
client.on("ready", () => {
|
||||
client.on("session", (accept) => {
|
||||
const session = accept();
|
||||
session.on("pty", (acceptPty) => acceptPty?.());
|
||||
session.on("env", (acceptEnv) => acceptEnv?.());
|
||||
session.on("shell", (acceptShell) => {
|
||||
const stream = acceptShell();
|
||||
stream.on("data", (data) => stream.write(data));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
t.after(() => {
|
||||
for (const client of connections) client.end();
|
||||
server.close();
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const runtime = process.env.NETCATTY_SSH_TEST_RUNTIME === "electron"
|
||||
? require("electron")
|
||||
: path.resolve(process.env.NETCATTY_SSH_TEST_RUNTIME || process.execPath);
|
||||
const child = spawn(runtime, ["scripts/fixtures/ssh-chacha-client.cjs"], {
|
||||
cwd: root,
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
t.after(() => child.kill());
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.on("data", (data) => { stdout += data; });
|
||||
child.stderr.on("data", (data) => { stderr += data; });
|
||||
child.stdin.end(JSON.stringify({
|
||||
port: server.address().port,
|
||||
appRoot: process.env.NETCATTY_SSH_TEST_APP_ROOT,
|
||||
homeDir,
|
||||
// Cross several SSH packets, exercising both nonce/counter directions.
|
||||
input: "synthetic SSH echo\r\n".repeat(4000),
|
||||
algorithmOverrides: cipher ? { cipher } : undefined,
|
||||
entrypoint,
|
||||
withoutNativeChacha,
|
||||
}));
|
||||
const code = await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("close", resolve);
|
||||
});
|
||||
assert.equal(code, 0, stderr || stdout);
|
||||
const resultLine = stdout.split("\n").find((line) => line.startsWith("NETCATTY_TRANSPORT_RESULT="));
|
||||
assert.ok(resultLine, stderr || stdout);
|
||||
const result = JSON.parse(resultLine.slice("NETCATTY_TRANSPORT_RESULT=".length));
|
||||
if (process.env.NETCATTY_SSH_TEST_APP_ROOT && result.connected) {
|
||||
assert.equal(result.packagedDependency, "node_modules/@noble/ciphers/chacha.js");
|
||||
}
|
||||
return { ...result, offered, negotiated };
|
||||
}
|
||||
|
||||
for (const entrypoint of ["netcatty:start", "netcatty:test-connection"]) {
|
||||
test(`${entrypoint} advertises and negotiates an explicit ChaCha20-only override without native crypto`, { timeout: 15000 }, async (t) => {
|
||||
const result = await connect(t, { cipher: [CHACHA], entrypoint });
|
||||
assert.equal(result.connected, true, result.error);
|
||||
assert.equal(result.nativeChacha, false);
|
||||
assert.deepEqual(result.offered["C->S"], [CHACHA]);
|
||||
assert.deepEqual(result.offered["S->C"], [CHACHA]);
|
||||
assert.equal(result.negotiated.cs.cipher, CHACHA);
|
||||
assert.equal(result.negotiated.sc.cipher, CHACHA);
|
||||
if (entrypoint === "netcatty:start") assert.equal(result.echoed, true);
|
||||
});
|
||||
}
|
||||
|
||||
test("default ciphers can connect to a ChaCha20-only server", async (t) => {
|
||||
const result = await connect(t);
|
||||
assert.equal(result.connected, true, result.error);
|
||||
assert.equal(result.echoed, true);
|
||||
assert.ok(result.offered["C->S"].includes(CHACHA));
|
||||
assert.equal(result.negotiated.cs.cipher, CHACHA);
|
||||
});
|
||||
|
||||
test("default and mixed offers retain AES preference and explicit cipher order", async (t) => {
|
||||
for (const cipher of [undefined, [AES, CHACHA], [CHACHA, AES]]) {
|
||||
const result = await connect(t, { serverCiphers: [AES, CHACHA], cipher });
|
||||
assert.equal(result.connected, true, result.error);
|
||||
assert.equal(result.echoed, true);
|
||||
assert.equal(result.negotiated.cs.cipher, cipher?.[0] || AES);
|
||||
if (cipher) assert.deepEqual(result.offered["C->S"], cipher);
|
||||
}
|
||||
});
|
||||
|
||||
test("an explicit AES-only choice never falls back to ChaCha20", async (t) => {
|
||||
const result = await connect(t, { cipher: [AES] });
|
||||
assert.equal(result.connected, false);
|
||||
assert.match(result.error, /no matching C->S cipher/);
|
||||
assert.deepEqual(result.offered["C->S"], [AES]);
|
||||
});
|
||||
|
||||
test("the runtime's unmodified crypto path still supports ChaCha20", async (t) => {
|
||||
const result = await connect(t, { cipher: [CHACHA], withoutNativeChacha: false });
|
||||
assert.equal(result.connected, true, result.error);
|
||||
assert.equal(result.echoed, true);
|
||||
});
|
||||
@@ -257,9 +257,6 @@ function handleTerminalSessionSnapshotResponse(event, payload) {
|
||||
pending.resolve({
|
||||
success: true,
|
||||
snapshot: typeof payload?.snapshot === "string" ? payload.snapshot : "",
|
||||
perCharacterInput: typeof payload?.perCharacterInput === "boolean"
|
||||
? payload.perCharacterInput
|
||||
: undefined,
|
||||
kittyKeyboardModeState: normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState),
|
||||
kittyKeyboardProtocolEnabled: typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
|
||||
? payload.kittyKeyboardProtocolEnabled
|
||||
@@ -319,9 +316,6 @@ function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = nu
|
||||
const contextScrollbackSnapshot = hasContextScrollbackSnapshot ? payload.contextScrollbackSnapshot : "";
|
||||
const hasAlternateScreen = typeof payload?.alternateScreen === "boolean";
|
||||
const alternateScreen = payload?.alternateScreen === true;
|
||||
const perCharacterInput = typeof payload?.perCharacterInput === "boolean"
|
||||
? payload.perCharacterInput
|
||||
: undefined;
|
||||
const kittyKeyboardModeState = normalizeKittyKeyboardModeState(payload?.kittyKeyboardModeState);
|
||||
const kittyKeyboardProtocolEnabled = typeof payload?.kittyKeyboardProtocolEnabled === "boolean"
|
||||
? payload.kittyKeyboardProtocolEnabled
|
||||
@@ -374,7 +368,6 @@ function applyTerminalSessionSnapshot(event, payload, terminalWorkerManager = nu
|
||||
contextViewportSnapshot,
|
||||
contextScrollbackSnapshot,
|
||||
alternateScreen,
|
||||
perCharacterInput,
|
||||
kittyKeyboardModeState,
|
||||
kittyKeyboardProtocolEnabled,
|
||||
passwordPromptActive,
|
||||
|
||||
@@ -667,65 +667,3 @@ test("attach snapshots preserve cwd and title updates including explicit clears"
|
||||
/payload\.cwd !== undefined[\s\S]*?setRendererCwd\(payload\.cwd,\s*"snapshot"\)[\s\S]*?payload\.title !== undefined/,
|
||||
);
|
||||
});
|
||||
|
||||
test("active input policy roundtrips through preload and authorized snapshot IPC", async () => {
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { createPreloadApi } = require("../preload/api.cjs");
|
||||
const { bridge, fakeChannel } = loadTerminalBridgeWithMocks();
|
||||
const handles = new Map();
|
||||
const listeners = new Map();
|
||||
const homeIpc = new EventEmitter();
|
||||
const home = { id: 71, isDestroyed: () => false, send: (channel, payload) => homeIpc.emit(channel, {}, payload) };
|
||||
const popup = { id: 72, isDestroyed: () => false };
|
||||
const sessionId = "input-policy-snapshot";
|
||||
const authorization = "input-policy-popup";
|
||||
bridge.init({
|
||||
sessions: new Map([[sessionId, { webContentsId: home.id }]]),
|
||||
electronModule: { webContents: { fromId: (id) => id === home.id ? home : popup } },
|
||||
terminalOutputChannel: fakeChannel,
|
||||
});
|
||||
bridge.registerHandlers({
|
||||
handle: (channel, handler) => handles.set(channel, handler),
|
||||
on: (channel, handler) => listeners.set(channel, handler),
|
||||
}, { terminalWorkerManager: { getAttachHomeWebContentsId: () => home.id } });
|
||||
const registry = require("./terminalAttachRestore.cjs");
|
||||
registry.registerAttachPopupAuthorization(authorization, sessionId, popup.id);
|
||||
homeIpc.send = (channel, payload) => listeners.get(channel)({ sender: home }, payload);
|
||||
const homeApi = createPreloadApi({ ipcRenderer: homeIpc, webUtils: {} });
|
||||
const invoke = (channel, payload) => {
|
||||
const originalRequire = Module.prototype.require;
|
||||
Module.prototype.require = function(request) {
|
||||
if (request === "./windowManager.cjs") return { getMainWindows: () => [{ webContents: home }] };
|
||||
return originalRequire.apply(this, arguments);
|
||||
};
|
||||
try { return handles.get(channel)({ sender: popup }, payload); }
|
||||
finally { Module.prototype.require = originalRequire; }
|
||||
};
|
||||
const popupApi = createPreloadApi({ ipcRenderer: { invoke }, webUtils: {} });
|
||||
let mode;
|
||||
let received;
|
||||
const unsubscribeRequest = homeApi.onTerminalSessionSnapshotRequest(({ requestId }) => {
|
||||
homeApi.respondTerminalSessionSnapshot(requestId, "screen", undefined, false, false, null, null, mode);
|
||||
});
|
||||
const unsubscribeApply = homeApi.onTerminalSessionApplySnapshot((payload) => { received = payload; return true; });
|
||||
try {
|
||||
for (mode of [false, true, undefined, "false"]) {
|
||||
const expected = typeof mode === "boolean" ? mode : undefined;
|
||||
const snapshot = await popupApi.requestTerminalSessionSnapshot(sessionId, authorization);
|
||||
assert.equal(snapshot.success, true);
|
||||
assert.equal(snapshot.perCharacterInput, expected, "snapshot keeps explicit false and drops invalid metadata");
|
||||
const applied = await popupApi.applyTerminalSessionSnapshot(sessionId, "screen", {
|
||||
contextSnapshot: "", contextViewportSnapshot: "", contextScrollbackSnapshot: "", alternateScreen: false,
|
||||
perCharacterInput: mode,
|
||||
}, authorization);
|
||||
assert.equal(applied.success, true);
|
||||
assert.equal(received.perCharacterInput, expected, "return snapshot carries the same active choice");
|
||||
}
|
||||
assert.equal((await popupApi.requestTerminalSessionSnapshot(sessionId, "wrong-grant")).success, false);
|
||||
assert.equal((await popupApi.applyTerminalSessionSnapshot(sessionId, "", { perCharacterInput: false }, "wrong-grant")).success, false);
|
||||
} finally {
|
||||
unsubscribeRequest();
|
||||
unsubscribeApply();
|
||||
registry.releaseAttachPopupAuthorization(authorization);
|
||||
}
|
||||
});
|
||||
@@ -234,7 +234,7 @@ async function createSequentialTransferFixture(t) {
|
||||
payload, files, sftp, handles, clients,
|
||||
rejectedOpens: () => rejectedOpens,
|
||||
openCount: () => opens,
|
||||
async start(direction, sftpId = "first", options = {}, beforeTransfer = null) {
|
||||
async start(direction, sftpId = "first", options = {}) {
|
||||
const transferId = options.transferId || crypto.randomUUID();
|
||||
const localPath = tempDirBridge.getTempFilePath(`term-concurrent-${transferId}.bin`);
|
||||
paths.push(localPath);
|
||||
@@ -250,7 +250,6 @@ async function createSequentialTransferFixture(t) {
|
||||
const remoteStage = `/.${path.basename(targetPath)}.netcatty-${transferId}.part`;
|
||||
files.set(remoteStage, payload.subarray(0, options.uploadCheckpointBytes || 0));
|
||||
}
|
||||
if (beforeTransfer) await beforeTransfer;
|
||||
const result = await transferBridge.startTransfer({ sender: { send() {} } }, {
|
||||
transferId,
|
||||
sourcePath: direction === "upload" ? localPath : "/source.bin", targetPath,
|
||||
@@ -368,58 +367,22 @@ test("TERM-SSHD queued cancellation releases its turn without closing the owner
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
|
||||
});
|
||||
|
||||
for (const firstArrival of ["first", "alias"]) {
|
||||
test(`TERM-SSHD failed transfer releases the physical channel for the next alias (${firstArrival} arrives first)`, { timeout: 5_000 }, async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
const originalOpen = fixture.sftp.open;
|
||||
let signalFirstOpen;
|
||||
let rejectFirstOpen;
|
||||
let firstOpenPending = true;
|
||||
const firstOpened = new Promise((resolve) => { signalFirstOpen = resolve; });
|
||||
fixture.sftp.open = (...args) => {
|
||||
if (firstOpenPending) {
|
||||
firstOpenPending = false;
|
||||
rejectFirstOpen = () => args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
|
||||
signalFirstOpen();
|
||||
} else originalOpen(...args);
|
||||
};
|
||||
let releaseDelayed;
|
||||
const delayed = new Promise((resolve) => { releaseDelayed = resolve; });
|
||||
// Gate fixture admission to exercise both physical OPEN arrival orders
|
||||
// while keeping calls-array order fixed. Local I/O can reorder real calls.
|
||||
const calls = [
|
||||
fixture.start("download", "first", {}, firstArrival === "alias" ? delayed : null),
|
||||
fixture.start("download", "alias", {}, firstArrival === "first" ? delayed : null),
|
||||
];
|
||||
const settled = Promise.allSettled(calls);
|
||||
const failedIndex = firstArrival === "first" ? 0 : 1;
|
||||
let transfers;
|
||||
try {
|
||||
await Promise.race([firstOpened, calls[failedIndex].then(() => {
|
||||
throw new Error("Transfer finished before the controlled OPEN");
|
||||
})]);
|
||||
releaseDelayed();
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp) === 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 1,
|
||||
"the other alias must queue before the first OPEN fails");
|
||||
} finally {
|
||||
releaseDelayed();
|
||||
rejectFirstOpen?.();
|
||||
transfers = await settled;
|
||||
}
|
||||
for (const transfer of transfers) assert.equal(transfer.status, "fulfilled");
|
||||
const failed = transfers[failedIndex].value;
|
||||
const succeeded = transfers[1 - failedIndex].value;
|
||||
assert.match(failed.result.error || "", /Permission denied/);
|
||||
assert.equal(succeeded.result.error, undefined);
|
||||
assert.deepEqual(await fs.readFile(succeeded.targetPath), fixture.payload);
|
||||
assert.equal(fixture.rejectedOpens(), 0);
|
||||
assert.equal(transferBridge._getSessionFastDownloadWaiterCountForTests(fixture.sftp), 0);
|
||||
});
|
||||
}
|
||||
test("TERM-SSHD failed transfer releases the physical channel for the next alias", async (t) => {
|
||||
const fixture = await createSequentialTransferFixture(t);
|
||||
const originalOpen = fixture.sftp.open;
|
||||
let rejectFirst = true;
|
||||
fixture.sftp.open = (...args) => {
|
||||
if (rejectFirst) {
|
||||
rejectFirst = false;
|
||||
args.at(-1)(Object.assign(new Error("Permission denied"), { code: 3 }));
|
||||
} else originalOpen(...args);
|
||||
};
|
||||
const [first, next] = await Promise.all([fixture.start("download"), fixture.start("download", "alias")]);
|
||||
assert.match(first.result.error || "", /Permission denied/);
|
||||
assert.equal(next.result.error, undefined);
|
||||
assert.deepEqual(await fs.readFile(next.targetPath), fixture.payload);
|
||||
assert.equal(fixture.rejectedOpens(), 0);
|
||||
});
|
||||
|
||||
|
||||
for (const direction of ["download", "upload"]) {
|
||||
|
||||
@@ -39,10 +39,6 @@ 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
|
||||
@@ -361,7 +357,6 @@ 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) {
|
||||
@@ -411,84 +406,21 @@ function clampWindowOpacity(opacity) {
|
||||
function applyWindowOpacityToWindow(win) {
|
||||
if (!win || win.isDestroyed?.()) return;
|
||||
try {
|
||||
win.setOpacity?.(isWindowBackgroundBlurActive() ? 1 : currentWindowOpacity);
|
||||
win.setOpacity?.(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;
|
||||
}
|
||||
@@ -1060,12 +992,7 @@ const { createWindow } = mainWindowApi;
|
||||
const { createSettingsWindowApi } = require("./windowManager/settingsWindow.cjs");
|
||||
const settingsWindowApi = createSettingsWindowApi({
|
||||
get settingsWindow() { return settingsWindow; },
|
||||
set settingsWindow(value) {
|
||||
settingsWindow = value;
|
||||
if (value) {
|
||||
applyWindowBackgroundChromeToWindow(value, { applyBackgroundColor: true });
|
||||
}
|
||||
},
|
||||
set settingsWindow(value) { settingsWindow = value; },
|
||||
get mainWindow() { return mainWindow; },
|
||||
get currentTheme() { return currentTheme; },
|
||||
get isQuitting() { return isQuitting; },
|
||||
@@ -1254,12 +1181,21 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
? (nativeTheme?.shouldUseDarkColors ? "dark" : "light")
|
||||
: theme;
|
||||
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
|
||||
applyWindowBackgroundColor(themeConfig.background);
|
||||
forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background));
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.setBackgroundColor(themeConfig.background);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:setBackgroundColor", (_event, color) => {
|
||||
return applyWindowBackgroundColor(color);
|
||||
const normalized = normalizeBackgroundColor(color);
|
||||
if (!normalized) return false;
|
||||
forEachMainWindow((win) => win.setBackgroundColor(normalized));
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.setBackgroundColor(normalized);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:setWindowOpacity", (_event, opacity) => {
|
||||
@@ -1267,10 +1203,6 @@ 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");
|
||||
@@ -1352,7 +1284,6 @@ 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,13 +263,11 @@ 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,7 +139,6 @@ 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
|
||||
@@ -151,7 +150,6 @@ function createSettingsWindowApi(ctx) {
|
||||
frame: isMac,
|
||||
titleBarStyle: isMac ? "hiddenInset" : undefined,
|
||||
trafficLightPosition: isMac ? { x: 12, y: 12 } : undefined,
|
||||
...(isMac ? { visualEffectState: "active" } : {}),
|
||||
...windowsChrome,
|
||||
webPreferences: {
|
||||
preload,
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
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,12 +37,7 @@ 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;
|
||||
@@ -57,29 +52,17 @@ 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) {
|
||||
writeLine({
|
||||
socket.write(`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
error: { code: -32001, message: "Authentication required. Send auth/verify with valid token first." },
|
||||
});
|
||||
})}\n`);
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
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();
|
||||
socket.write(`${JSON.stringify({ jsonrpc: "2.0", id: msg.id, result: { ok: true } })}\n`);
|
||||
return;
|
||||
}
|
||||
if (msg.method === "netcatty/getContext" && onGetContext) {
|
||||
@@ -89,11 +72,11 @@ function startBridge({ token, authDelayMs = 0, onGetContext = null }) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
writeLine({
|
||||
socket.write(`${JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
id: msg.id,
|
||||
result: { ok: true, marker: token },
|
||||
});
|
||||
})}\n`);
|
||||
};
|
||||
if (msg.method === "auth/verify" && authDelayMs > 0) {
|
||||
setTimeout(respond, authDelayMs);
|
||||
|
||||
@@ -122,33 +122,19 @@ function guardWriteOperation(command, { skipBlocklist = false } = {}) {
|
||||
}
|
||||
|
||||
let tcpSocket = null;
|
||||
let pendingRequests = new Map(); // id -> { resolve, reject, sock }
|
||||
let pendingRequests = new Map(); // id -> { resolve, reject }
|
||||
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 [id, pending] of pendingRequests) {
|
||||
if (pending.sock !== sock) continue;
|
||||
pendingRequests.delete(id);
|
||||
pending.reject(new Error(message));
|
||||
for (const { reject: rej } of pendingRequests.values()) {
|
||||
rej(new Error(message));
|
||||
}
|
||||
pendingRequests.clear();
|
||||
}
|
||||
|
||||
function connectTcp() {
|
||||
@@ -212,16 +198,15 @@ function connectTcp() {
|
||||
|
||||
function sendRpc(method, params) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!isLiveSocket(tcpSocket)) {
|
||||
if (!tcpSocket || tcpSocket.destroyed) {
|
||||
reject(new Error("Not connected to Netcatty"));
|
||||
return;
|
||||
}
|
||||
const sock = tcpSocket;
|
||||
const id = nextRpcId++;
|
||||
pendingRequests.set(id, { resolve, reject, sock });
|
||||
pendingRequests.set(id, { resolve, reject });
|
||||
const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n";
|
||||
debugLog("rpcCall", { id, method });
|
||||
sock.write(msg);
|
||||
tcpSocket.write(msg);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -261,12 +246,7 @@ function dropActiveSocket() {
|
||||
let connecting = null;
|
||||
|
||||
async function connectAndAuth() {
|
||||
// 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 = "";
|
||||
}
|
||||
if (tcpSocket?.destroyed) tcpSocket = null;
|
||||
refreshExternalEndpoint();
|
||||
tcpBuffer = "";
|
||||
try {
|
||||
@@ -283,11 +263,8 @@ async function connectAndAuth() {
|
||||
}
|
||||
|
||||
function ensureConnected() {
|
||||
// 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 (tcpSocket && !tcpSocket.destroyed) return Promise.resolve();
|
||||
if (connecting) return connecting;
|
||||
if (isLiveSocket(tcpSocket)) return Promise.resolve();
|
||||
connecting = connectAndAuth().finally(() => {
|
||||
connecting = null;
|
||||
});
|
||||
|
||||
@@ -611,12 +611,10 @@ function createPreloadApi(ctx) {
|
||||
passwordPromptActive,
|
||||
cwd,
|
||||
title,
|
||||
perCharacterInput,
|
||||
) => {
|
||||
ipcRenderer.send("netcatty:terminal:snapshot-response", {
|
||||
requestId,
|
||||
snapshot: typeof snapshot === "string" ? snapshot : "",
|
||||
perCharacterInput: typeof perCharacterInput === "boolean" ? perCharacterInput : undefined,
|
||||
kittyKeyboardModeState: sanitizeKittyKeyboardModeState(kittyKeyboardModeState),
|
||||
kittyKeyboardProtocolEnabled: typeof kittyKeyboardProtocolEnabled === "boolean"
|
||||
? kittyKeyboardProtocolEnabled
|
||||
@@ -640,9 +638,6 @@ function createPreloadApi(ctx) {
|
||||
? context.contextScrollbackSnapshot
|
||||
: "",
|
||||
alternateScreen: context?.alternateScreen === true,
|
||||
perCharacterInput: typeof context?.perCharacterInput === "boolean"
|
||||
? context.perCharacterInput
|
||||
: undefined,
|
||||
kittyKeyboardModeState: sanitizeKittyKeyboardModeState(context?.kittyKeyboardModeState),
|
||||
kittyKeyboardProtocolEnabled: typeof context?.kittyKeyboardProtocolEnabled === "boolean"
|
||||
? context.kittyKeyboardProtocolEnabled
|
||||
@@ -1096,9 +1091,6 @@ 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);
|
||||
},
|
||||
|
||||
@@ -2198,12 +2198,3 @@ html[data-theme-transition] *::after {
|
||||
@container monitor-main (max-width: 760px) {
|
||||
.monitor-header-action-label { display: none; }
|
||||
}
|
||||
|
||||
html.window-background-blur,
|
||||
html.window-background-blur body {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
html.window-background-blur #root {
|
||||
opacity: var(--window-content-opacity, 1);
|
||||
}
|
||||
@@ -199,7 +199,6 @@ 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';
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
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']);
|
||||
});
|
||||
@@ -1,66 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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');
|
||||
});
|
||||
+1
-11
@@ -138,15 +138,7 @@ export function isKnownBinaryFile(fileName: string): boolean {
|
||||
* Get language ID for syntax highlighting
|
||||
*/
|
||||
export function getLanguageId(fileName: string): string {
|
||||
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';
|
||||
}
|
||||
const ext = getFileExtension(fileName);
|
||||
return EXTENSION_TO_LANGUAGE[ext] || 'plaintext';
|
||||
}
|
||||
|
||||
@@ -189,7 +181,6 @@ export function getLanguageName(languageId: string): string {
|
||||
yaml: 'YAML',
|
||||
toml: 'TOML',
|
||||
ini: 'INI',
|
||||
nginx: 'Nginx',
|
||||
sql: 'SQL',
|
||||
graphql: 'GraphQL',
|
||||
markdown: 'Markdown',
|
||||
@@ -233,7 +224,6 @@ 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) }))
|
||||
|
||||
Generated
-13
@@ -20,7 +20,6 @@
|
||||
"@netcatty/plugin-cli": "0.1.0-internal",
|
||||
"@netcatty/plugin-contract": "0.1.0-internal",
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal",
|
||||
"@noble/ciphers": "2.4.0",
|
||||
"@openai/codex-sdk": "0.144.3",
|
||||
"@smithy/node-http-handler": "^4.4.9",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
@@ -4684,18 +4683,6 @@
|
||||
"resolved": "packages/plugin-sdk",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@noble/ciphers": {
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.4.0.tgz",
|
||||
"integrity": "sha512-AnjFn0Jv92laAkvMrghlFZq4qQCIN/4DxFV/eooqtC2YTjB7kBeLMS2T9KJX4Dn+ZVXLOwK0lSgqDtx9gvxtiw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.19.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/@noble/hashes": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz",
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"@netcatty/plugin-cli": "0.1.0-internal",
|
||||
"@netcatty/plugin-contract": "0.1.0-internal",
|
||||
"@netcatty/plugin-sdk": "0.1.0-internal",
|
||||
"@noble/ciphers": "2.4.0",
|
||||
"@openai/codex-sdk": "0.144.3",
|
||||
"@smithy/node-http-handler": "^4.4.9",
|
||||
"@withfig/autocomplete": "^2.692.3",
|
||||
|
||||
+5
-120
@@ -860,10 +860,10 @@ index 9f33c02..9751164 100644
|
||||
if (names !== undefined) {
|
||||
sftp._debug && sftp._debug(
|
||||
diff --git a/node_modules/ssh2/lib/protocol/constants.js b/node_modules/ssh2/lib/protocol/constants.js
|
||||
index ad77592..03ab33b 100644
|
||||
index ad77592..a249e00 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/constants.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/constants.js
|
||||
@@ -71,14 +71,37 @@ const DEFAULT_SERVER_HOST_KEY = [
|
||||
@@ -71,9 +71,30 @@ const DEFAULT_SERVER_HOST_KEY = [
|
||||
];
|
||||
if (eddsaSupported)
|
||||
DEFAULT_SERVER_HOST_KEY.unshift('ssh-ed25519');
|
||||
@@ -894,15 +894,7 @@ index ad77592..03ab33b 100644
|
||||
|
||||
|
||||
const canUseCipher = (() => {
|
||||
const ciphers = crypto.getCiphers();
|
||||
- return (name) => ciphers.includes(CIPHER_INFO[name].sslName);
|
||||
+ // crypto.js supplies the original ChaCha20 primitive on BoringSSL too.
|
||||
+ return (name) => (name === 'chacha20-poly1305@openssh.com'
|
||||
+ || ciphers.includes(CIPHER_INFO[name].sslName));
|
||||
})();
|
||||
let DEFAULT_CIPHER = [
|
||||
// http://tools.ietf.org/html/rfc5647
|
||||
@@ -160,6 +183,7 @@ const COMPAT = {
|
||||
@@ -160,6 +181,7 @@ const COMPAT = {
|
||||
DYN_RPORT_BUG: 1 << 2,
|
||||
BUG_DHGEX_LARGE: 1 << 3,
|
||||
IMPLY_RSA_SHA2_SIGALGS: 1 << 4,
|
||||
@@ -910,7 +902,7 @@ index ad77592..03ab33b 100644
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
@@ -330,6 +354,7 @@ module.exports = {
|
||||
@@ -330,6 +352,7 @@ module.exports = {
|
||||
COMPAT_CHECKS: [
|
||||
[ 'Cisco-1.25', COMPAT.BAD_DHGEX ],
|
||||
[ /^Cisco-1[.]/, COMPAT.BUG_DHGEX_LARGE ],
|
||||
@@ -918,113 +910,6 @@ index ad77592..03ab33b 100644
|
||||
[ /^[0-9.]+$/, COMPAT.OLD_EXIT ], // old SSH.com implementations
|
||||
[ /^OpenSSH_5[.][0-9]+/, COMPAT.DYN_RPORT_BUG ],
|
||||
[ /^OpenSSH_7[.]4/, COMPAT.IMPLY_RSA_SHA2_SIGALGS ],
|
||||
diff --git a/node_modules/ssh2/lib/protocol/crypto.js b/node_modules/ssh2/lib/protocol/crypto.js
|
||||
index 851cc73..088de2a 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/crypto.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/crypto.js
|
||||
@@ -11,6 +11,7 @@ const {
|
||||
} = require('crypto');
|
||||
|
||||
const { readUInt32BE, writeUInt32BE } = require('./utils.js');
|
||||
+const { chacha20, nativeChacha20 } = require('./crypto/chacha20.js');
|
||||
|
||||
const FastBuffer = Buffer[Symbol.species];
|
||||
const MAX_SEQNO = 2 ** 32 - 1;
|
||||
@@ -198,20 +199,17 @@ class ChaChaPolyCipherNative {
|
||||
POLY1305_OUT_COMPUTE[0] = 0; // Set counter to 0 (little endian)
|
||||
writeUInt32BE(POLY1305_OUT_COMPUTE, this.outSeqno, 12);
|
||||
const polyKey =
|
||||
- createCipheriv('chacha20', this._encKeyMain, POLY1305_OUT_COMPUTE)
|
||||
- .update(POLY1305_ZEROS);
|
||||
+ chacha20(this._encKeyMain, POLY1305_OUT_COMPUTE, POLY1305_ZEROS);
|
||||
|
||||
// Encrypt packet length
|
||||
const pktLenEnc =
|
||||
- createCipheriv('chacha20', this._encKeyPktLen, POLY1305_OUT_COMPUTE)
|
||||
- .update(packet.slice(0, 4));
|
||||
+ chacha20(this._encKeyPktLen, POLY1305_OUT_COMPUTE, packet.slice(0, 4));
|
||||
this._onWrite(pktLenEnc);
|
||||
|
||||
// Encrypt rest of packet
|
||||
POLY1305_OUT_COMPUTE[0] = 1; // Set counter to 1 (little endian)
|
||||
const payloadEnc =
|
||||
- createCipheriv('chacha20', this._encKeyMain, POLY1305_OUT_COMPUTE)
|
||||
- .update(packet.slice(4));
|
||||
+ chacha20(this._encKeyMain, POLY1305_OUT_COMPUTE, packet.slice(4));
|
||||
this._onWrite(payloadEnc);
|
||||
|
||||
// Calculate Poly1305 MAC
|
||||
@@ -650,8 +648,7 @@ class ChaChaPolyDecipherNative {
|
||||
writeUInt32BE(POLY1305_OUT_COMPUTE, this.inSeqno, 12);
|
||||
|
||||
const decLenBytes =
|
||||
- createDecipheriv('chacha20', this._decKeyPktLen, POLY1305_OUT_COMPUTE)
|
||||
- .update(this._lenBuf);
|
||||
+ chacha20(this._decKeyPktLen, POLY1305_OUT_COMPUTE, this._lenBuf);
|
||||
this._len = readUInt32BE(decLenBytes, 0);
|
||||
|
||||
if (this._len > MAX_PACKET_SIZE
|
||||
@@ -706,8 +703,7 @@ class ChaChaPolyDecipherNative {
|
||||
POLY1305_OUT_COMPUTE[0] = 0; // Set counter to 0 (little endian)
|
||||
writeUInt32BE(POLY1305_OUT_COMPUTE, this.inSeqno, 12);
|
||||
const polyKey =
|
||||
- createCipheriv('chacha20', this._decKeyMain, POLY1305_OUT_COMPUTE)
|
||||
- .update(POLY1305_ZEROS);
|
||||
+ chacha20(this._decKeyMain, POLY1305_OUT_COMPUTE, POLY1305_ZEROS);
|
||||
|
||||
// Calculate and compare Poly1305 MACs
|
||||
poly1305_auth(POLY1305_RESULT_MALLOC,
|
||||
@@ -729,8 +725,7 @@ class ChaChaPolyDecipherNative {
|
||||
// Decrypt packet
|
||||
POLY1305_OUT_COMPUTE[0] = 1; // Set counter to 1 (little endian)
|
||||
const packet =
|
||||
- createDecipheriv('chacha20', this._decKeyMain, POLY1305_OUT_COMPUTE)
|
||||
- .update(this._packet);
|
||||
+ chacha20(this._decKeyMain, POLY1305_OUT_COMPUTE, this._packet);
|
||||
|
||||
const payload = new FastBuffer(packet.buffer,
|
||||
packet.byteOffset + 1,
|
||||
@@ -1496,7 +1491,7 @@ function createCipher(config) {
|
||||
? new AESGCMCipherBinding(config)
|
||||
: new AESGCMCipherNative(config));
|
||||
case 'chacha20':
|
||||
- return (ChaChaPolyCipher && !forceNative
|
||||
+ return (ChaChaPolyCipher && nativeChacha20 && !forceNative
|
||||
? new ChaChaPolyCipherBinding(config)
|
||||
: new ChaChaPolyCipherNative(config));
|
||||
default: {
|
||||
@@ -1556,7 +1551,7 @@ function createDecipher(config) {
|
||||
? new AESGCMDecipherBinding(config)
|
||||
: new AESGCMDecipherNative(config));
|
||||
case 'chacha20':
|
||||
- return (ChaChaPolyDecipher && !forceNative
|
||||
+ return (ChaChaPolyDecipher && nativeChacha20 && !forceNative
|
||||
? new ChaChaPolyDecipherBinding(config)
|
||||
: new ChaChaPolyDecipherNative(config));
|
||||
default: {
|
||||
diff --git a/node_modules/ssh2/lib/protocol/crypto/chacha20.js b/node_modules/ssh2/lib/protocol/crypto/chacha20.js
|
||||
new file mode 100644
|
||||
index 0000000..86dd731
|
||||
--- /dev/null
|
||||
+++ b/node_modules/ssh2/lib/protocol/crypto/chacha20.js
|
||||
@@ -0,0 +1,17 @@
|
||||
+// OpenSSH uses original ChaCha20 (64-bit nonce), not the IETF/TLS AEAD.
|
||||
+// Electron's BoringSSL lacks the standalone cipher, including in sshcrypto.
|
||||
+'use strict';
|
||||
+const { createCipheriv, getCiphers } = require('crypto');
|
||||
+const nativeChacha20 = getCiphers().includes('chacha20');
|
||||
+const { chacha20orig } = nativeChacha20 ? {} : require('@noble/ciphers/chacha.js');
|
||||
+
|
||||
+function chacha20(key, iv, data) {
|
||||
+ if (nativeChacha20)
|
||||
+ return createCipheriv('chacha20', key, iv).update(data);
|
||||
+ // ssh2 only uses block counters 0 (length/Poly1305 key) and 1 (payload).
|
||||
+ // The last eight IV bytes are its big-endian SSH packet sequence nonce.
|
||||
+ return Buffer.from(chacha20orig(key, iv.subarray(8), data, undefined,
|
||||
+ iv.readUInt32LE(0)));
|
||||
+}
|
||||
+
|
||||
+module.exports = { chacha20, nativeChacha20 };
|
||||
diff --git a/node_modules/ssh2/lib/protocol/kex.js b/node_modules/ssh2/lib/protocol/kex.js
|
||||
index 811e631..422a198 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/kex.js
|
||||
@@ -1242,7 +1127,7 @@ index 811e631..422a198 100644
|
||||
onKEXPayload,
|
||||
DEFAULT_KEXINIT_CLIENT: new KexInit({
|
||||
diff --git a/node_modules/ssh2/lib/protocol/keyParser.js b/node_modules/ssh2/lib/protocol/keyParser.js
|
||||
index a276c1a..57a05d6 100644
|
||||
index a276c1a..c8ff9d1 100644
|
||||
--- a/node_modules/ssh2/lib/protocol/keyParser.js
|
||||
+++ b/node_modules/ssh2/lib/protocol/keyParser.js
|
||||
@@ -35,6 +35,9 @@ const SYM_PRIV_PEM = Symbol('Private key PEM');
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
// Runs in a separate Node/Electron process so ssh2 detects its actual crypto
|
||||
// capabilities at module load. The parent owns the disposable loopback server.
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
async function main(config) {
|
||||
os.homedir = () => config.homeDir;
|
||||
delete process.env.SSH_AUTH_SOCK;
|
||||
delete process.env.SSH_AGENT_PID;
|
||||
if (config.withoutNativeChacha) {
|
||||
const getCiphers = crypto.getCiphers;
|
||||
crypto.getCiphers = () => getCiphers().filter((name) => name !== "chacha20");
|
||||
for (const name of ["createCipheriv", "createDecipheriv"]) {
|
||||
const original = crypto[name];
|
||||
crypto[name] = (algorithm, ...args) => {
|
||||
if (algorithm === "chacha20") throw new Error("Standalone ChaCha20 unavailable");
|
||||
return original(algorithm, ...args);
|
||||
};
|
||||
}
|
||||
}
|
||||
let packagedDependency;
|
||||
if (config.appRoot) {
|
||||
config.appRoot = require("node:fs").realpathSync(config.appRoot);
|
||||
// RUN_AS_NODE supports ASAR files, but does not expose Electron's main
|
||||
// process APIs. The packaged bridge imports sessionLogsBridge's dialog/shell
|
||||
// even with logging disabled. Supply only that unrelated UI host seam;
|
||||
// ssh2, crypto, algorithm selection, and sockets remain the shipped code.
|
||||
const Module = require("node:module");
|
||||
const load = Module._load;
|
||||
Module._load = function loadWithHeadlessElectron(request, ...args) {
|
||||
if (request === "electron") return { dialog: {}, shell: {} };
|
||||
return load.call(this, request, ...args);
|
||||
};
|
||||
const packagedRequire = Module.createRequire(path.join(config.appRoot, "node_modules/ssh2/lib/protocol/crypto/chacha20.js"));
|
||||
const noblePath = packagedRequire.resolve("@noble/ciphers/chacha.js");
|
||||
if (!noblePath.startsWith(`${config.appRoot}${path.sep}`)) {
|
||||
throw new Error("ChaCha20 dependency resolved outside the packaged app");
|
||||
}
|
||||
packagedDependency = path.relative(config.appRoot, noblePath).replaceAll("\\", "/");
|
||||
}
|
||||
const bridge = require(path.join(config.appRoot || path.resolve(__dirname, "../.."), "electron/bridges/sshBridge.cjs"));
|
||||
const sessions = new Map();
|
||||
let output = "";
|
||||
let resolveOutput;
|
||||
const echoed = new Promise((resolve) => { resolveOutput = resolve; });
|
||||
bridge.init({
|
||||
sessions,
|
||||
electronModule: {},
|
||||
terminalOutputChannel: {
|
||||
openSession() {},
|
||||
send(_sessionId, data) {
|
||||
output += data;
|
||||
if (output.length >= config.input.length) resolveOutput();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
const handlers = new Map();
|
||||
bridge.registerHandlers({ handle: (name, handler) => handlers.set(name, handler), on() {} });
|
||||
const sender = new (require("node:events").EventEmitter)();
|
||||
Object.assign(sender, { id: 1, isDestroyed: () => false, send() {} });
|
||||
try {
|
||||
const result = await handlers.get(config.entrypoint)({ sender }, {
|
||||
sessionId: "synthetic-chacha-test",
|
||||
hostname: "127.0.0.1",
|
||||
port: config.port,
|
||||
username: "synthetic",
|
||||
authMethod: "password",
|
||||
useSshAgent: false,
|
||||
verifyHostKeys: false,
|
||||
skipShellDiscovery: true,
|
||||
algorithmOverrides: config.algorithmOverrides,
|
||||
});
|
||||
if (config.entrypoint === "netcatty:start") {
|
||||
sessions.get(result.sessionId).stream.write(config.input);
|
||||
await echoed;
|
||||
}
|
||||
return {
|
||||
connected: true,
|
||||
echoed: output === config.input,
|
||||
electron: process.versions.electron,
|
||||
nativeChacha: crypto.getCiphers().includes("chacha20"),
|
||||
packagedDependency,
|
||||
};
|
||||
} finally {
|
||||
for (const session of sessions.values()) session.conn?.destroy();
|
||||
bridge.stopAllMonitoring();
|
||||
}
|
||||
}
|
||||
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
process.stdin.on("data", (data) => { input += data; });
|
||||
process.stdin.on("end", async () => {
|
||||
const config = JSON.parse(input);
|
||||
const timeout = setTimeout(() => process.exit(2), 10000);
|
||||
let result;
|
||||
try { result = await main(config); }
|
||||
catch (error) { result = { connected: false, error: error.message }; }
|
||||
clearTimeout(timeout);
|
||||
// Only public algorithm/runtime information leaves the synthetic client.
|
||||
console.log(`NETCATTY_TRANSPORT_RESULT=${JSON.stringify(result)}`);
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
// Exercise shipped JS/dependencies with the same Electron runtime, headlessly.
|
||||
// No BrowserWindow, real accounts, or external SSH hosts are involved.
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
|
||||
const releaseDir = path.resolve(process.argv[2] || "release");
|
||||
const archives = fs.readdirSync(releaseDir, { recursive: true })
|
||||
.filter((entry) => path.basename(entry) === "app.asar")
|
||||
.map((entry) => path.join(releaseDir, entry));
|
||||
if (archives.length === 0) throw new Error("No packaged app.asar found");
|
||||
for (const appRoot of archives) {
|
||||
// Outside the checkout, a missing packaged dependency cannot resolve from
|
||||
// the checkout's node_modules. Copy the paired unpacked payload as shipped.
|
||||
const detachedDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-packaged-ssh-"));
|
||||
try {
|
||||
const detachedRoot = path.join(detachedDir, "app.asar");
|
||||
fs.copyFileSync(appRoot, detachedRoot);
|
||||
fs.cpSync(`${appRoot}.unpacked`, `${detachedRoot}.unpacked`, { recursive: true });
|
||||
const env = {
|
||||
...process.env,
|
||||
NETCATTY_SSH_TEST_APP_ROOT: detachedRoot,
|
||||
NETCATTY_SSH_TEST_RUNTIME: require("electron"),
|
||||
};
|
||||
delete env.NODE_PATH;
|
||||
console.log(`Testing detached packaged SSH transport: ${appRoot}`);
|
||||
const result = spawnSync(process.execPath, ["--test", "electron/bridges/sshChaChaTransport.test.cjs"], {
|
||||
env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error) throw result.error;
|
||||
if (result.status !== 0) throw new Error(`Packaged SSH transport tests failed (${result.status})`);
|
||||
} finally {
|
||||
fs.rmSync(detachedDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
-5
@@ -428,8 +428,6 @@ declare global {
|
||||
requestTerminalSessionSnapshot?(sessionId: string, authorization: string): Promise<{
|
||||
success: boolean;
|
||||
snapshot?: string;
|
||||
/** Captured input policy for the existing backend connection. */
|
||||
perCharacterInput?: boolean;
|
||||
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
|
||||
kittyKeyboardProtocolEnabled?: boolean;
|
||||
passwordPromptActive?: boolean;
|
||||
@@ -450,7 +448,6 @@ declare global {
|
||||
passwordPromptActive?: boolean,
|
||||
cwd?: string | null,
|
||||
title?: string | null,
|
||||
perCharacterInput?: boolean,
|
||||
): void;
|
||||
/** Observe popup: push current state back to the home renderer before restore. */
|
||||
applyTerminalSessionSnapshot?(
|
||||
@@ -461,7 +458,6 @@ declare global {
|
||||
contextViewportSnapshot: string;
|
||||
contextScrollbackSnapshot: string;
|
||||
alternateScreen: boolean;
|
||||
perCharacterInput?: boolean;
|
||||
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
|
||||
kittyKeyboardProtocolEnabled?: boolean;
|
||||
passwordPromptActive?: boolean;
|
||||
@@ -484,7 +480,6 @@ declare global {
|
||||
contextViewportSnapshot: string;
|
||||
contextScrollbackSnapshot: string;
|
||||
alternateScreen: boolean;
|
||||
perCharacterInput?: boolean;
|
||||
kittyKeyboardModeState?: NetcattyKittyKeyboardModeState;
|
||||
kittyKeyboardProtocolEnabled?: boolean;
|
||||
passwordPromptActive?: boolean;
|
||||
|
||||
-1
@@ -47,7 +47,6 @@ 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