Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6e6a0240a7 | ||
|
|
2e2360a9fc | ||
|
|
8011f4e2e8 | ||
|
|
970037682c | ||
|
|
42b58efc5c | ||
|
|
b20163d762 | ||
|
|
e0a56cbb14 | ||
|
|
8dae851ea3 | ||
|
|
03ba9595c0 | ||
|
|
4b07b4826a | ||
|
|
80d9b33c59 | ||
|
|
3be3c14912 |
@@ -129,6 +129,8 @@ export function AppView({ ctx }: { ctx: AppViewContext }) {
|
||||
onOpenQuickSwitcher={handleOpenQuickSwitcher}
|
||||
onToggleTheme={handleToggleTheme}
|
||||
onOpenSettings={handleOpenSettings}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
onSyncNow={handleSyncNowManual}
|
||||
isImmersiveActive={activeTerminalTheme !== null}
|
||||
onStartSessionDrag={setDraggingSessionId}
|
||||
|
||||
@@ -234,6 +234,7 @@ export const enAiMessages: Messages = {
|
||||
'topTabs.openQuickSwitcher': 'Open quick switcher',
|
||||
'topTabs.moreTabs': 'More tabs',
|
||||
'topTabs.aiAssistant': 'AI Assistant',
|
||||
'topTabs.windowOpacity': 'Window opacity',
|
||||
'topTabs.toggleTheme': 'Toggle theme',
|
||||
'topTabs.openSettings': 'Open Settings',
|
||||
'ai.chat.sessionHistory': 'Session history',
|
||||
|
||||
@@ -264,14 +264,15 @@ export const enCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Dark palette',
|
||||
'settings.appearance.customCss': 'Custom CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
|
||||
'Add custom CSS to personalize the app appearance. Changes apply immediately. Major UI regions expose a [data-section="..."] attribute you can target — e.g. snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (focus-mode terminal list), terminal-side-panel (SFTP/Scripts/Theme/AI panel), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Make snippet sidebar text larger */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Custom terminal background */\n.terminal { background: #1a1a2e !important; }\n\n/* Tweak global border radius */\n:root { --radius: 0.25rem; }',
|
||||
'/* Examples — use !important to beat Tailwind utility specificity */\n\n/* Border around the SFTP / side panel (not the focus-mode terminal list) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Thicker split dividers */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Highlight the focused split pane */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Or use Settings → Terminal → Workspace Focus Indicator → Border on focused pane */',
|
||||
'settings.appearance.language': 'Language',
|
||||
'settings.appearance.language.desc': 'Choose the UI language',
|
||||
'settings.appearance.uiFont': 'Interface Font',
|
||||
'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 > Terminal
|
||||
'settings.terminal.section.theme': 'Terminal Theme',
|
||||
'settings.terminal.themeModal.title': 'Select Theme',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const enTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Press Enter to paste sudo password',
|
||||
// Terminal toolbar / search / context menu / auth
|
||||
'terminal.toolbar.openSftp': 'Open SFTP',
|
||||
'terminal.toolbar.availableAfterConnect': 'Available after connect',
|
||||
|
||||
@@ -440,9 +440,6 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo mode requires a password. Configure one above, or ensure the server allows passwordless sudo.',
|
||||
'hostDetails.sftp.encoding': 'Filename Encoding',
|
||||
'hostDetails.sftp.encoding.desc': 'Select the encoding used to decode and send SFTP filenames.',
|
||||
'hostDetails.terminal.sudoAutoFill': 'Auto-fill sudo password',
|
||||
'hostDetails.terminal.sudoAutoFill.desc': 'When this host asks for a sudo password in the terminal, automatically send the saved password.',
|
||||
'hostDetails.terminal.sudoAutoFill.passwordWarning': 'Auto-fill requires a saved password for this host.',
|
||||
'hostDetails.label.placeholder': 'Label (e.g., Production Server)',
|
||||
'hostDetails.notes.label': 'Notes',
|
||||
'hostDetails.notes.placeholder': 'Hardware, project, customer, region, role...',
|
||||
|
||||
@@ -234,6 +234,7 @@ export const ruAiMessages: Messages = {
|
||||
'topTabs.openQuickSwitcher': 'Открыть быстрый переключатель',
|
||||
'topTabs.moreTabs': 'Больше вкладок',
|
||||
'topTabs.aiAssistant': 'AI-помощник',
|
||||
'topTabs.windowOpacity': 'Прозрачность окна',
|
||||
'topTabs.toggleTheme': 'Переключить тему',
|
||||
'topTabs.openSettings': 'Открыть настройки',
|
||||
'ai.chat.sessionHistory': 'История сессий',
|
||||
|
||||
@@ -264,14 +264,15 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': 'Палитра тёмной темы',
|
||||
'settings.appearance.customCss': 'Пользовательский CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar, top-tabs.',
|
||||
'Добавьте пользовательский CSS, чтобы настроить внешний вид приложения. Изменения применяются сразу. Основные области интерфейса имеют атрибут [data-section="..."], который можно использовать для выбора элементов, например: snippets-panel, host-details-panel, group-details-panel, serial-host-details-panel, ai-chat-panel, vault-sidebar, vault-main, vault-hosts-header, vault-host-list, vault-view, terminal-workspace, terminal-workspace-sidebar (список терминалов в режиме Focus), terminal-side-panel (панель SFTP/скриптов/темы/AI), terminal-sftp-panel, terminal-split-pane, terminal-split-resizer, top-tabs.',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Сделать текст в боковой панели сниппетов крупнее */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* Пользовательский фон терминала */\n.terminal { background: #1a1a2e !important; }\n\n/* Настройка глобального радиуса скругления */\n:root { --radius: 0.25rem; }',
|
||||
'/* Примеры — используйте !important, чтобы переопределить специфичность утилит Tailwind */\n\n/* Рамка вокруг боковой панели SFTP (не список терминалов Focus) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* Более заметные разделители сплита */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* Подсветка активной панели сплита */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* Или: Настройки → Терминал → Индикатор фокуса → Рамка вокруг активной панели */',
|
||||
'settings.appearance.language': 'Язык',
|
||||
'settings.appearance.language.desc': 'Выберите язык интерфейса',
|
||||
'settings.appearance.uiFont': 'Шрифт интерфейса',
|
||||
'settings.appearance.uiFont.desc': 'Выберите шрифт для интерфейса приложения',
|
||||
|
||||
'settings.appearance.windowOpacity': 'Прозрачность окна',
|
||||
'settings.appearance.windowOpacity.desc': 'Настройте прозрачность всего окна приложения. При низких значениях текст терминала тоже бледнеет. В некоторых средах Linux это может не поддерживаться.',
|
||||
// Settings > Terminal
|
||||
'settings.terminal.section.theme': 'Тема терминала',
|
||||
'settings.terminal.themeModal.title': 'Выберите тему',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const ruTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': 'Нажмите Enter, чтобы вставить пароль sudo',
|
||||
// Connection logs
|
||||
'logs.table.date': 'Дата',
|
||||
'logs.table.user': 'Пользователь',
|
||||
|
||||
@@ -475,9 +475,6 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Для режима sudo требуется пароль. Укажите его выше или убедитесь, что сервер разрешает sudo без пароля.',
|
||||
'hostDetails.sftp.encoding': 'Кодировка имён файлов',
|
||||
'hostDetails.sftp.encoding.desc': 'Выберите кодировку, используемую для декодирования и отправки имён файлов SFTP.',
|
||||
'hostDetails.terminal.sudoAutoFill': 'Автозаполнение пароля sudo',
|
||||
'hostDetails.terminal.sudoAutoFill.desc': 'Когда этот хост запрашивает пароль sudo в терминале, автоматически отправлять сохранённый пароль.',
|
||||
'hostDetails.terminal.sudoAutoFill.passwordWarning': 'Для автозаполнения нужен сохранённый пароль этого хоста.',
|
||||
'hostDetails.label.placeholder': 'Метка (например, Production Server)',
|
||||
'hostDetails.notes.label': 'Заметки',
|
||||
'hostDetails.notes.placeholder': 'Оборудование, проект, клиент, регион, роль...',
|
||||
|
||||
@@ -234,6 +234,7 @@ export const zhCNAiMessages: Messages = {
|
||||
'topTabs.openQuickSwitcher': '打开快速切换',
|
||||
'topTabs.moreTabs': '更多标签页',
|
||||
'topTabs.aiAssistant': 'AI 助手',
|
||||
'topTabs.windowOpacity': '窗口透明度',
|
||||
'topTabs.toggleTheme': '切换主题',
|
||||
'topTabs.openSettings': '打开设置',
|
||||
'ai.chat.sessionHistory': '会话历史',
|
||||
|
||||
@@ -248,14 +248,15 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.appearance.themeColor.dark': '深色主题',
|
||||
'settings.appearance.customCss': '自定义 CSS',
|
||||
'settings.appearance.customCss.desc':
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar、top-tabs。',
|
||||
'使用自定义 CSS 个性化界面,修改会立即生效。主要 UI 区块都暴露了 [data-section="..."] 属性供你定位,比如:snippets-panel、host-details-panel、group-details-panel、serial-host-details-panel、ai-chat-panel、vault-sidebar、vault-main、vault-hosts-header、vault-host-list、vault-view、terminal-workspace、terminal-workspace-sidebar(Focus 模式终端列表)、terminal-side-panel(SFTP/脚本/主题/AI 侧栏)、terminal-sftp-panel、terminal-split-pane、terminal-split-resizer、top-tabs。',
|
||||
'settings.appearance.customCss.placeholder':
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* 放大代码片段侧边栏字号 */\n[data-section="snippets-panel"] {\n font-size: 14px !important;\n}\n\n/* 自定义终端背景色 */\n.terminal { background: #1a1a2e !important; }\n\n/* 调整全局圆角 */\n:root { --radius: 0.25rem; }',
|
||||
'/* 示例 — 由于 Tailwind 优先级较高,需要使用 !important */\n\n/* SFTP / 操作侧栏边框(不是 Focus 模式终端列表) */\n[data-section="terminal-side-panel"] {\n border: 2px solid #00c851 !important;\n border-radius: 6px !important;\n}\n\n/* 加粗分屏分割线 */\n[data-section="terminal-split-resizer-bar"] {\n background-color: hsl(var(--primary)) !important;\n transform: scale(2) !important;\n}\n\n/* 高亮当前聚焦的分屏 */\n[data-section="terminal-split-pane"][data-focused="true"] {\n outline: 2px solid hsl(var(--primary)) !important;\n outline-offset: -2px;\n}\n\n/* 也可在 设置 → 终端 → 工作区聚焦指示 → 聚焦窗格显示边框 */',
|
||||
'settings.appearance.language': '语言',
|
||||
'settings.appearance.language.desc': '选择界面语言',
|
||||
'settings.appearance.uiFont': '界面字体',
|
||||
'settings.appearance.uiFont.desc': '选择软件界面使用的字体',
|
||||
|
||||
'settings.appearance.windowOpacity': '窗口透明度',
|
||||
'settings.appearance.windowOpacity.desc': '调节整个应用窗口的透明度,方便叠在其他内容上方。较低时终端文字也会变淡;部分 Linux 桌面环境可能不支持。',
|
||||
// Context menus / common actions
|
||||
'action.newHost': '新建主机',
|
||||
'action.newSubfolder': '新建文件夹',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Messages } from '../types';
|
||||
|
||||
export const zhCNTerminalMessages: Messages = {
|
||||
'terminal.sudoHint.pressEnter': '按 Enter 粘贴 sudo 密码',
|
||||
'terminal.connection.protocol.et': 'EternalTerminal',
|
||||
'terminal.et.proxyUnsupported': 'EternalTerminal 目前不支持 Netcatty 的代理设置。请改用 SSH,或移除该主机的代理。',
|
||||
'terminal.et.multiJumpUnsupported': 'EternalTerminal 目前在 Netcatty 中最多支持一个跳板机。',
|
||||
|
||||
@@ -33,9 +33,6 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.sftp.sudo.passwordWarning': 'Sudo 模式需要密码。请在上方配置密码,或确保服务器允许免密 sudo。',
|
||||
'hostDetails.sftp.encoding': '文件名编码',
|
||||
'hostDetails.sftp.encoding.desc': '选择用于解码和发送 SFTP 文件名的编码。',
|
||||
'hostDetails.terminal.sudoAutoFill': '自动填写 sudo 密码',
|
||||
'hostDetails.terminal.sudoAutoFill.desc': '这台主机在终端中请求 sudo 密码时,自动发送保存的密码。',
|
||||
'hostDetails.terminal.sudoAutoFill.passwordWarning': '自动填写需要先为这台主机保存密码。',
|
||||
'hostDetails.label.placeholder': '名称(例如:Production Server)',
|
||||
'hostDetails.notes.label': '备注',
|
||||
'hostDetails.notes.placeholder': '硬件配置、项目、客户、地域、角色...',
|
||||
|
||||
@@ -33,9 +33,14 @@ import {
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { isValidUiFontId, migrateIncomingTerminalFontId } from './settingsStateDefaults';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidUiFontId,
|
||||
migrateIncomingTerminalFontId,
|
||||
} from './settingsStateDefaults';
|
||||
|
||||
interface UseSettingsIpcSyncParams {
|
||||
syncAppearanceFromStorage: () => void;
|
||||
@@ -59,6 +64,7 @@ interface UseSettingsIpcSyncParams {
|
||||
applyIncomingCustomKeyBindings: (incoming: { bindings: CustomKeyBindings; version: number; origin: string }) => void;
|
||||
setIsHotkeyRecordingState: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
@@ -88,6 +94,7 @@ export function useSettingsIpcSync({
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -191,6 +198,10 @@ export function useSettingsIpcSync({
|
||||
if (key === STORAGE_KEY_GLOBAL_HOTKEY_ENABLED && typeof value === 'boolean') {
|
||||
setGlobalHotkeyEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
if (key === STORAGE_KEY_WINDOW_OPACITY && (typeof value === 'number' || typeof value === 'string')) {
|
||||
const nextOpacity = clampWindowOpacity(value);
|
||||
setWindowOpacity((prev) => (prev === nextOpacity ? prev : nextOpacity));
|
||||
}
|
||||
if (key === STORAGE_KEY_AUTO_UPDATE_ENABLED && typeof value === 'boolean') {
|
||||
setAutoUpdateEnabled((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -223,6 +234,7 @@ export function useSettingsIpcSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setIsHotkeyRecordingState,
|
||||
setSessionLogsDir,
|
||||
|
||||
@@ -8,6 +8,12 @@ import { localStorageAdapter } from '../../infrastructure/persistence/localStora
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
|
||||
export const DEFAULT_THEME: 'light' | 'dark' | 'system' = 'dark';
|
||||
export const DEFAULT_WINDOW_OPACITY = 1;
|
||||
export function clampWindowOpacity(opacity: unknown): number {
|
||||
const value = Number(opacity);
|
||||
if (!Number.isFinite(value)) return DEFAULT_WINDOW_OPACITY;
|
||||
return Math.min(1, Math.max(0.5, value));
|
||||
}
|
||||
|
||||
/** Resolve the current OS color scheme preference. */
|
||||
export const getSystemPreference = (): 'light' | 'dark' =>
|
||||
|
||||
@@ -39,8 +39,10 @@ import {
|
||||
STORAGE_KEY_UI_THEME_DARK,
|
||||
STORAGE_KEY_UI_THEME_LIGHT,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
clampWindowOpacity,
|
||||
isValidHslToken,
|
||||
isValidTheme,
|
||||
isValidUiFontId,
|
||||
@@ -79,6 +81,7 @@ interface UseSettingsStorageSyncParams {
|
||||
sshDebugLogsEnabled: boolean;
|
||||
globalHotkeyEnabled: boolean;
|
||||
autoUpdateEnabled: boolean;
|
||||
windowOpacity: number;
|
||||
setTheme: Dispatch<SetStateAction<'dark' | 'light' | 'system'>>;
|
||||
setLightUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
setDarkUiThemeId: Dispatch<SetStateAction<string>>;
|
||||
@@ -110,6 +113,7 @@ interface UseSettingsStorageSyncParams {
|
||||
setSessionLogsTimestampsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setSshDebugLogsEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setGlobalHotkeyEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWindowOpacity: Dispatch<SetStateAction<number>>;
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setWorkspaceFocusStyleState: Dispatch<SetStateAction<'dim' | 'border'>>;
|
||||
setSftpTransferConcurrencyState: Dispatch<SetStateAction<number>>;
|
||||
@@ -125,7 +129,7 @@ export function useSettingsStorageSync({
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
@@ -134,7 +138,7 @@ export function useSettingsStorageSync({
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
}: UseSettingsStorageSyncParams) {
|
||||
// Fix 4: Keep a ref snapshot of current settings so the storage event handler
|
||||
@@ -148,7 +152,7 @@ export function useSettingsStorageSync({
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
});
|
||||
settingsSnapshotRef.current = {
|
||||
theme, lightUiThemeId, darkUiThemeId, accentMode, customAccent,
|
||||
@@ -158,7 +162,7 @@ export function useSettingsStorageSync({
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
};
|
||||
|
||||
// Listen for storage changes from other windows (cross-window sync)
|
||||
@@ -372,6 +376,12 @@ export function useSettingsStorageSync({
|
||||
setAutoUpdateEnabled(newValue);
|
||||
}
|
||||
}
|
||||
if (e.key === STORAGE_KEY_WINDOW_OPACITY && e.newValue !== null) {
|
||||
const newValue = clampWindowOpacity(e.newValue);
|
||||
if (newValue !== s.windowOpacity) {
|
||||
setWindowOpacity(newValue);
|
||||
}
|
||||
}
|
||||
// Sync workspace focus style from other windows
|
||||
if (e.key === STORAGE_KEY_WORKSPACE_FOCUS_STYLE && e.newValue !== null) {
|
||||
if (e.newValue === 'dim' || e.newValue === 'border') {
|
||||
@@ -400,6 +410,7 @@ export function useSettingsStorageSync({
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setHotkeyScheme,
|
||||
setLightUiThemeId,
|
||||
setSessionLogsDir,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
@@ -12,6 +13,7 @@ interface UseSystemSettingsEffectsParams {
|
||||
toggleWindowHotkey: string;
|
||||
globalHotkeyEnabled: boolean;
|
||||
closeToTray: boolean;
|
||||
windowOpacity: number;
|
||||
autoUpdateEnabled: boolean;
|
||||
persistMountedRef: MutableRefObject<boolean>;
|
||||
setHotkeyRegistrationError: (error: string | null) => void;
|
||||
@@ -23,6 +25,7 @@ export function useSystemSettingsEffects({
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
@@ -89,6 +92,17 @@ export function useSystemSettingsEffects({
|
||||
notifySettingsChanged(STORAGE_KEY_CLOSE_TO_TRAY, closeToTray);
|
||||
}, [closeToTray, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Persist and sync window opacity
|
||||
useEffect(() => {
|
||||
const bridge = netcattyBridge.get();
|
||||
bridge?.setWindowOpacity?.(windowOpacity).catch((err) => {
|
||||
console.warn('[WindowOpacity] Failed to apply window opacity:', err);
|
||||
});
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WINDOW_OPACITY, String(windowOpacity));
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_WINDOW_OPACITY, windowOpacity);
|
||||
}, [windowOpacity, notifySettingsChanged, persistMountedRef]);
|
||||
|
||||
// Hydrate auto-update state from the main-process preference file on mount.
|
||||
// This reconciles localStorage (renderer) with auto-update-pref.json (main)
|
||||
// in case localStorage was cleared or is stale.
|
||||
|
||||
@@ -12,6 +12,91 @@ export type { UploadedFile } from '../../infrastructure/ai/types';
|
||||
/** Reject only known binary blobs that AI models can't process */
|
||||
const REJECTED_MIME_PREFIXES = ['video/', 'audio/'];
|
||||
|
||||
/**
|
||||
* Infer MIME type from file extension when the browser/Electron doesn't
|
||||
* provide one (common for .yaml, .sh, .toml, and other code/text files).
|
||||
*/
|
||||
const EXTENSION_MIME_TYPES: Record<string, string> = {
|
||||
// Code & Scripts — all use text/plain for maximum provider compatibility
|
||||
js: 'text/plain',
|
||||
mjs: 'text/plain',
|
||||
cjs: 'text/plain',
|
||||
jsx: 'text/plain',
|
||||
ts: 'text/plain',
|
||||
tsx: 'text/plain',
|
||||
py: 'text/plain',
|
||||
rb: 'text/plain',
|
||||
rs: 'text/plain',
|
||||
go: 'text/plain',
|
||||
java: 'text/plain',
|
||||
c: 'text/plain',
|
||||
h: 'text/plain',
|
||||
cpp: 'text/plain',
|
||||
hpp: 'text/plain',
|
||||
cs: 'text/plain',
|
||||
swift: 'text/plain',
|
||||
kt: 'text/plain',
|
||||
scala: 'text/plain',
|
||||
php: 'text/plain',
|
||||
pl: 'text/plain',
|
||||
sh: 'text/plain',
|
||||
bash: 'text/plain',
|
||||
zsh: 'text/plain',
|
||||
fish: 'text/plain',
|
||||
ps1: 'text/plain',
|
||||
bat: 'text/plain',
|
||||
cmd: 'text/plain',
|
||||
sql: 'text/plain',
|
||||
r: 'text/plain',
|
||||
lua: 'text/plain',
|
||||
dart: 'text/plain',
|
||||
// Web
|
||||
html: 'text/html',
|
||||
htm: 'text/html',
|
||||
css: 'text/css',
|
||||
scss: 'text/plain',
|
||||
sass: 'text/plain',
|
||||
less: 'text/plain',
|
||||
vue: 'text/plain',
|
||||
svelte: 'text/plain',
|
||||
// Config / Data
|
||||
yaml: 'text/plain',
|
||||
yml: 'text/plain',
|
||||
json: 'application/json',
|
||||
jsonc: 'application/json',
|
||||
jsonl: 'application/jsonl',
|
||||
xml: 'application/xml',
|
||||
toml: 'application/toml',
|
||||
csv: 'text/csv',
|
||||
tsv: 'text/tab-separated-values',
|
||||
ini: 'text/plain',
|
||||
cfg: 'text/plain',
|
||||
conf: 'text/plain',
|
||||
env: 'text/plain',
|
||||
// Docs
|
||||
md: 'text/markdown',
|
||||
markdown: 'text/markdown',
|
||||
txt: 'text/plain',
|
||||
tex: 'text/x-tex',
|
||||
rst: 'text/x-rst',
|
||||
log: 'text/plain',
|
||||
// Other typed files
|
||||
pdf: 'application/pdf',
|
||||
dockerfile: 'text/plain',
|
||||
};
|
||||
|
||||
function getExtension(fileName: string): string {
|
||||
const dot = fileName.lastIndexOf('.');
|
||||
if (dot === -1) return fileName.toLowerCase(); // e.g. "Dockerfile", "Makefile"
|
||||
return fileName.slice(dot + 1).toLowerCase();
|
||||
}
|
||||
|
||||
function inferMediaType(fileName: string, fileType: string): string {
|
||||
if (fileType) return fileType;
|
||||
const ext = getExtension(fileName);
|
||||
return EXTENSION_MIME_TYPES[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
function isSupportedFile(file: File): boolean {
|
||||
// Allow files with empty MIME (common in Electron for .sh, .yaml, etc.)
|
||||
if (!file.type) return true;
|
||||
@@ -39,7 +124,7 @@ export async function convertFilesToUploads(inputFiles: File[]): Promise<Uploade
|
||||
supported.map(async (file) => {
|
||||
const id = crypto.randomUUID();
|
||||
const filename = file.name || `file-${Date.now()}`;
|
||||
const mediaType = file.type || 'application/octet-stream';
|
||||
const mediaType = inferMediaType(filename, file.type);
|
||||
try {
|
||||
const result = await fileToDataUrl(file);
|
||||
const filePath = getPathForFile(file);
|
||||
|
||||
@@ -36,6 +36,7 @@ import {
|
||||
STORAGE_KEY_TOGGLE_WINDOW_HOTKEY,
|
||||
STORAGE_KEY_CLOSE_TO_TRAY,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_AUTO_UPDATE_ENABLED,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_SHOW_RECENT_HOSTS,
|
||||
@@ -83,6 +84,8 @@ import {
|
||||
DEFAULT_SSH_DEBUG_LOGS_ENABLED,
|
||||
DEFAULT_TERMINAL_THEME,
|
||||
DEFAULT_THEME,
|
||||
DEFAULT_WINDOW_OPACITY,
|
||||
clampWindowOpacity,
|
||||
applyThemeTokens,
|
||||
areTerminalSettingsEqual,
|
||||
createCustomKeyBindingsSyncOrigin,
|
||||
@@ -278,6 +281,19 @@ export const useSettingsState = () => {
|
||||
if (stored === null) return true; // Default to enabled
|
||||
return stored === 'true';
|
||||
});
|
||||
const [windowOpacity, setWindowOpacityState] = useState<number>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_WINDOW_OPACITY);
|
||||
if (stored === null) return DEFAULT_WINDOW_OPACITY;
|
||||
return clampWindowOpacity(stored);
|
||||
});
|
||||
const setWindowOpacity = useCallback((nextValue: SetStateAction<number>) => {
|
||||
setWindowOpacityState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
? (nextValue as (prevState: number) => number)(prev)
|
||||
: nextValue;
|
||||
return clampWindowOpacity(candidate);
|
||||
});
|
||||
}, []);
|
||||
const incomingTerminalSettingsSignatureRef = useRef<string | null>(null);
|
||||
const localTerminalSettingsVersionRef = useRef(0);
|
||||
const broadcastedLocalTerminalSettingsVersionRef = useRef(0);
|
||||
@@ -576,6 +592,7 @@ export const useSettingsState = () => {
|
||||
applyIncomingCustomKeyBindings,
|
||||
setIsHotkeyRecordingState,
|
||||
setGlobalHotkeyEnabled,
|
||||
setWindowOpacity,
|
||||
setAutoUpdateEnabled,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -608,7 +625,7 @@ export const useSettingsState = () => {
|
||||
sftpUseCompressedUpload, sftpAutoOpenSidebar, sftpDefaultViewMode,
|
||||
showRecentHosts, showOnlyUngroupedHostsInRoot, showSftpTab,
|
||||
editorWordWrap, sessionLogsEnabled, sessionLogsDir, sessionLogsFormat, sessionLogsTimestampsEnabled, sshDebugLogsEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled,
|
||||
globalHotkeyEnabled, autoUpdateEnabled, windowOpacity,
|
||||
setTheme, setLightUiThemeId, setDarkUiThemeId, setAccentMode, setCustomAccent,
|
||||
setCustomCSS, setUiFontFamilyId, setHotkeyScheme, setUiLanguage,
|
||||
setTerminalThemeId, setTerminalThemeDarkId, setTerminalThemeLightId,
|
||||
@@ -617,7 +634,7 @@ export const useSettingsState = () => {
|
||||
setSftpUseCompressedUpload, setSftpAutoOpenSidebar, setSftpDefaultViewMode,
|
||||
setShowRecentHostsState, setShowOnlyUngroupedHostsInRootState, setShowSftpTabState,
|
||||
setEditorWordWrapState, setSessionLogsEnabled, setSessionLogsDir, setSessionLogsFormat, setSessionLogsTimestampsEnabled, setSshDebugLogsEnabled,
|
||||
setGlobalHotkeyEnabled, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setGlobalHotkeyEnabled, setWindowOpacity, setAutoUpdateEnabled, setWorkspaceFocusStyleState,
|
||||
setSftpTransferConcurrencyState, applyIncomingCustomKeyBindings, mergeIncomingTerminalSettings,
|
||||
});
|
||||
|
||||
@@ -815,6 +832,7 @@ export const useSettingsState = () => {
|
||||
toggleWindowHotkey,
|
||||
globalHotkeyEnabled,
|
||||
closeToTray,
|
||||
windowOpacity,
|
||||
autoUpdateEnabled,
|
||||
persistMountedRef,
|
||||
setHotkeyRegistrationError,
|
||||
@@ -986,6 +1004,8 @@ export const useSettingsState = () => {
|
||||
hotkeyRegistrationError,
|
||||
globalHotkeyEnabled,
|
||||
setGlobalHotkeyEnabled,
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
rehydrateAllFromStorage,
|
||||
reapplyCurrentTheme,
|
||||
workspaceFocusStyle,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { cn } from "../lib/utils";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import { Button } from "./ui/button";
|
||||
@@ -47,10 +46,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
}) => {
|
||||
const terminalSudoAutoFillPassword = sanitizeCredentialValue(
|
||||
form.savePassword === false ? undefined : form.password,
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSection
|
||||
@@ -332,21 +327,6 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</div>
|
||||
)}
|
||||
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.terminal.sudoAutoFill")}
|
||||
hint={t("hostDetails.terminal.sudoAutoFill.desc")}
|
||||
>
|
||||
<Switch
|
||||
checked={form.terminalSudoAutoFill || false}
|
||||
onCheckedChange={(val) => update("terminalSudoAutoFill", val)}
|
||||
/>
|
||||
</HostDetailsSettingRow>
|
||||
{form.terminalSudoAutoFill && !terminalSudoAutoFillPassword && (
|
||||
<p className="text-xs text-amber-500">
|
||||
{t("hostDetails.terminal.sudoAutoFill.passwordWarning")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Local key file paths display */}
|
||||
{!selectedIdentity && !form.identityFileId && form.identityFilePaths && form.identityFilePaths.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
|
||||
@@ -325,6 +325,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState }> = ({ settings }
|
||||
setShowOnlyUngroupedHostsInRoot={settings.setShowOnlyUngroupedHostsInRoot}
|
||||
showSftpTab={settings.showSftpTab}
|
||||
setShowSftpTab={settings.setShowSftpTab}
|
||||
windowOpacity={settings.windowOpacity}
|
||||
setWindowOpacity={settings.setWindowOpacity}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -681,6 +681,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
<div
|
||||
ref={panelRootRef}
|
||||
className="h-full flex flex-col bg-background overflow-hidden"
|
||||
data-section="terminal-sftp-panel"
|
||||
style={isVisible ? undefined : { display: "none" }}
|
||||
aria-hidden={!isVisible}
|
||||
onClick={handlePaneFocus}
|
||||
|
||||
@@ -107,12 +107,14 @@ interface SyncStatusButtonProps {
|
||||
onOpenSettings?: () => void;
|
||||
onSyncNow?: () => Promise<void>; // Callback to trigger sync with current data
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
onOpenSettings,
|
||||
onSyncNow,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
@@ -177,9 +179,10 @@ export const SyncStatusButton: React.FC<SyncStatusButtonProps> = ({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn(
|
||||
"h-8 w-8 relative text-muted-foreground hover:text-foreground app-no-drag",
|
||||
"h-7 w-7 relative text-muted-foreground hover:text-foreground app-no-drag",
|
||||
className
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{getButtonIcon()}
|
||||
|
||||
|
||||
@@ -224,6 +224,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const autocompleteInputRef = useRef<((data: string) => void) | undefined>(undefined);
|
||||
const autocompleteRepositionRef = useRef<(() => void) | undefined>(undefined);
|
||||
const autocompleteCloseRef = useRef<(() => void) | undefined>(undefined);
|
||||
const sudoHintRef = useRef<((active: boolean) => boolean) | undefined>(undefined);
|
||||
|
||||
const terminalBackend = useTerminalBackend();
|
||||
const { resizeSession, setSessionEncoding } = terminalBackend;
|
||||
@@ -248,9 +249,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const statusRef = useRef<TerminalSession["status"]>(status);
|
||||
statusRef.current = status;
|
||||
const sudoAutofillRef = useRef<SudoPasswordAutofill | null>(null);
|
||||
const sudoAutofillEnabledRef = useRef(Boolean(host.terminalSudoAutoFill));
|
||||
const sudoAutofillPasswordRef = useRef(sudoAutofillPassword);
|
||||
sudoAutofillEnabledRef.current = Boolean(host.terminalSudoAutoFill);
|
||||
sudoAutofillPasswordRef.current = sudoAutofillPassword;
|
||||
|
||||
const [chainProgress, setChainProgress] = useState<{
|
||||
@@ -297,7 +296,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
if (!pastedCommand || !shouldRecordShellHistory(pastedCommand[1], termRef.current)) {
|
||||
return data;
|
||||
}
|
||||
return prepareSudoAutofillInput(data, null, sudoAutofillRef.current);
|
||||
prepareSudoAutofillInput(data, null, sudoAutofillRef.current);
|
||||
return data;
|
||||
}, []);
|
||||
|
||||
// Terminal autocomplete — onAcceptText writes directly to session (no CustomEvent)
|
||||
@@ -643,6 +643,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pendingAuthRef,
|
||||
promptLineBreakStateRef,
|
||||
sudoAutofillRef,
|
||||
onSudoHint: (active: boolean) => sudoHintRef.current?.(active) ?? false,
|
||||
updateStatus,
|
||||
setStatus,
|
||||
setError,
|
||||
@@ -690,7 +691,6 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
sessionLog,
|
||||
sshDebugLogEnabled,
|
||||
sudoAutofillPassword,
|
||||
sudoAutofillEnabledRef,
|
||||
sudoAutofillPasswordRef,
|
||||
});
|
||||
sessionStartersRef.current = sessionStarters;
|
||||
@@ -1085,7 +1085,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, captureTerminalLogData, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, host, hotkeySchemeRef, identities, inWorkspace, isBroadcastEnabledRef, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, onBroadcastInputRef, onCommandExecuted, onHotkeyActionRef, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalFontSizeChange, pendingAuthRef, pendingOutputScrollRef, prevIsResizingRef, primaryFontFamily, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setHasSelection, setIsCancelling, setIsDisconnectedDialogDismissed, setIsSearchOpen, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setSelectionOverlayPosition, setShowLogs, setStatus, setTimeLeft, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, snippetsRef, status, statusRef, sudoAutofillRef, t, teardown, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalSettings, terminalSettingsRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, xtermRuntimeRef, zmodem, zmodemToastedRef });
|
||||
|
||||
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
return <TerminalView ctx={{ ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onAddSelectionToAI, onBroadcastInput, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, scrollToBottomAfterProgrammaticInput, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText: t("terminal.sudoHint.pressEnter"), t, termRef, terminalBackend, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem }} />;
|
||||
};
|
||||
|
||||
const Terminal = memo(TerminalComponent);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { Host, KnownHost, TerminalSession } from '../types';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
|
||||
import { sanitizeCredentialValue } from '../domain/credentials';
|
||||
import { resolveHostAutofillPassword } from '../domain/sshAuth';
|
||||
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
@@ -66,12 +66,6 @@ import {
|
||||
type TerminalLayerProps,
|
||||
} from './terminalLayer/TerminalLayerSupport';
|
||||
|
||||
const resolveHostSudoAutofillPassword = (host: Host): string | undefined => {
|
||||
if (!host.terminalSudoAutoFill) return undefined;
|
||||
if (host.savePassword === false) return undefined;
|
||||
return sanitizeCredentialValue(host.password) || undefined;
|
||||
};
|
||||
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
hosts,
|
||||
groupConfigs,
|
||||
@@ -615,11 +609,14 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
for (const session of sessions) {
|
||||
const rawHost = hostMap.get(session.hostId);
|
||||
if (rawHost) {
|
||||
map.set(session.id, resolveHostSudoAutofillPassword(rawHost));
|
||||
// Resolve through identity references too (host.identityId), not just
|
||||
// host.password, so a password stored in a Keychain identity is filled
|
||||
// (issue #1284) — same resolution SSH login uses.
|
||||
map.set(session.id, resolveHostAutofillPassword({ host: rawHost, keys, identities }));
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [hostMap, sessions]);
|
||||
}, [hostMap, sessions, keys, identities]);
|
||||
|
||||
const handleTerminalFontSizeChange = useCallback((sessionId: string, nextFontSize: number) => {
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Button } from './ui/button';
|
||||
import { ContextMenuItem, ContextMenuSeparator } from './ui/context-menu';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
import { SyncStatusButton } from './SyncStatusButton';
|
||||
import { WindowOpacityButton } from './WindowOpacityButton';
|
||||
import {
|
||||
ActiveTabAutoScroller,
|
||||
EditorTopTab,
|
||||
@@ -49,6 +50,8 @@ interface TopTabsProps {
|
||||
onOpenQuickSwitcher: () => void;
|
||||
onToggleTheme: () => void;
|
||||
onOpenSettings: () => void;
|
||||
windowOpacity: number;
|
||||
setWindowOpacity: (opacity: number) => void;
|
||||
onSyncNow?: () => Promise<void>;
|
||||
isImmersiveActive?: boolean;
|
||||
onStartSessionDrag: (sessionId: string) => void;
|
||||
@@ -82,6 +85,8 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
onOpenQuickSwitcher,
|
||||
onToggleTheme,
|
||||
onOpenSettings,
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
onSyncNow,
|
||||
isImmersiveActive,
|
||||
onStartSessionDrag,
|
||||
@@ -591,14 +596,17 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Fixed right controls */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 app-drag self-center" style={dragRegionStyle}>
|
||||
{/* Fixed right controls — utility icons + window controls share one row */}
|
||||
<div
|
||||
className="flex-shrink-0 flex items-center gap-0.5 app-drag self-end h-7"
|
||||
style={dragRegionStyle}
|
||||
>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
className="h-7 w-7 shrink-0 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={() => window.dispatchEvent(new CustomEvent('netcatty:toggle-ai-panel'))}
|
||||
>
|
||||
@@ -607,13 +615,24 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.aiAssistant')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<SyncStatusButton onOpenSettings={onOpenSettings} onSyncNow={onSyncNow} />
|
||||
<WindowOpacityButton
|
||||
windowOpacity={windowOpacity}
|
||||
setWindowOpacity={setWindowOpacity}
|
||||
className="h-7 w-7 shrink-0"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<SyncStatusButton
|
||||
onOpenSettings={onOpenSettings}
|
||||
onSyncNow={onSyncNow}
|
||||
className="h-7 w-7 shrink-0"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
className="h-7 w-7 shrink-0 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onToggleTheme}
|
||||
disabled={isImmersiveActive && !followAppTerminalTheme}
|
||||
@@ -623,15 +642,12 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.toggleTheme')}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/* Settings gear button - sits to the left of WindowControls on win/linux, at the right edge on mac */}
|
||||
<div className="self-stretch flex items-center px-2 app-drag" style={dragRegionStyle}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 app-no-drag"
|
||||
className="h-7 w-7 shrink-0 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
onClick={onOpenSettings}
|
||||
>
|
||||
@@ -640,11 +656,10 @@ const TopTabsInner: React.FC<TopTabsProps> = ({
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.openSettings')}</TooltipContent>
|
||||
</Tooltip>
|
||||
{!isMacClient && <WindowControls />}
|
||||
</div>
|
||||
{/* Custom window controls for Windows/Linux */}
|
||||
{!isMacClient && <div className="self-stretch flex items-stretch"><WindowControls /></div>}
|
||||
{/* Small drag shim to the right edge (macOS only – on Windows the close button should touch the edge) */}
|
||||
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0" />}
|
||||
{isMacClient && <div className="w-2 h-9 app-drag flex-shrink-0 self-end" />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -665,6 +680,8 @@ const topTabsAreEqual = (prev: TopTabsProps, next: TopTabsProps): boolean => {
|
||||
prev.onCopySession === next.onCopySession &&
|
||||
prev.onCopySessionToNewWindow === next.onCopySessionToNewWindow &&
|
||||
prev.onOpenSettings === next.onOpenSettings &&
|
||||
prev.windowOpacity === next.windowOpacity &&
|
||||
prev.setWindowOpacity === next.setWindowOpacity &&
|
||||
prev.onSyncNow === next.onSyncNow &&
|
||||
prev.onToggleTheme === next.onToggleTheme &&
|
||||
prev.followAppTerminalTheme === next.followAppTerminalTheme &&
|
||||
|
||||
98
components/WindowOpacityButton.tsx
Normal file
98
components/WindowOpacityButton.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Droplets } from 'lucide-react';
|
||||
import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { cn } from '../lib/utils';
|
||||
import { Button } from './ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from './ui/popover';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from './ui/tooltip';
|
||||
|
||||
const OPACITY_PRESETS = [
|
||||
{ label: '100%', value: 1 },
|
||||
{ label: '85%', value: 0.85 },
|
||||
{ label: '70%', value: 0.7 },
|
||||
] as const;
|
||||
|
||||
interface WindowOpacityButtonProps {
|
||||
windowOpacity: number;
|
||||
setWindowOpacity: (opacity: number) => void;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
}
|
||||
|
||||
export const WindowOpacityButton: React.FC<WindowOpacityButtonProps> = ({
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
className,
|
||||
style,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const percent = Math.round(windowOpacity * 100);
|
||||
|
||||
return (
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('h-7 w-7 shrink-0 app-no-drag', className)}
|
||||
style={style}
|
||||
aria-label={t('topTabs.windowOpacity')}
|
||||
>
|
||||
<Droplets
|
||||
size={16}
|
||||
className={percent < 100 ? 'opacity-80' : undefined}
|
||||
/>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t('topTabs.windowOpacity')}</TooltipContent>
|
||||
</Tooltip>
|
||||
<PopoverContent
|
||||
className="w-60 p-3 app-no-drag"
|
||||
align="end"
|
||||
sideOffset={6}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">{t('topTabs.windowOpacity')}</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={50}
|
||||
max={100}
|
||||
step={5}
|
||||
value={percent}
|
||||
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
|
||||
className="flex-1 accent-primary"
|
||||
/>
|
||||
<span className="text-xs text-muted-foreground w-9 text-right tabular-nums">
|
||||
{percent}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{OPACITY_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => setWindowOpacity(preset.value)}
|
||||
className={cn(
|
||||
'flex-1 px-2 py-1 rounded-md text-xs font-medium transition-colors border',
|
||||
windowOpacity === preset.value
|
||||
? 'bg-primary text-primary-foreground border-primary'
|
||||
: 'bg-muted/50 text-muted-foreground border-border hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
export default WindowOpacityButton;
|
||||
@@ -32,6 +32,8 @@ export default function SettingsAppearanceTab(props: {
|
||||
setShowOnlyUngroupedHostsInRoot: (enabled: boolean) => void;
|
||||
showSftpTab: boolean;
|
||||
setShowSftpTab: (enabled: boolean) => void;
|
||||
windowOpacity: number;
|
||||
setWindowOpacity: (opacity: number) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const availableUIFonts = useAvailableUIFonts();
|
||||
@@ -58,8 +60,16 @@ export default function SettingsAppearanceTab(props: {
|
||||
setShowOnlyUngroupedHostsInRoot,
|
||||
showSftpTab,
|
||||
setShowSftpTab,
|
||||
windowOpacity,
|
||||
setWindowOpacity,
|
||||
} = props;
|
||||
|
||||
const WINDOW_OPACITY_PRESETS = [
|
||||
{ label: '100%', value: 1 },
|
||||
{ label: '85%', value: 0.85 },
|
||||
{ label: '70%', value: 0.7 },
|
||||
] as const;
|
||||
|
||||
const getHslStyle = useCallback((hsl: string) => ({ backgroundColor: `hsl(${hsl})` }), []);
|
||||
|
||||
const hexToHsl = useCallback((hex: string) => {
|
||||
@@ -172,6 +182,48 @@ export default function SettingsAppearanceTab(props: {
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.windowOpacity")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
label={t("settings.appearance.windowOpacity")}
|
||||
description={t("settings.appearance.windowOpacity.desc")}
|
||||
>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={50}
|
||||
max={100}
|
||||
step={5}
|
||||
value={Math.round(windowOpacity * 100)}
|
||||
onChange={(e) => setWindowOpacity(Number(e.target.value) / 100)}
|
||||
className="w-28 accent-primary"
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground w-10 text-right tabular-nums">
|
||||
{Math.round(windowOpacity * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{WINDOW_OPACITY_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
type="button"
|
||||
onClick={() => setWindowOpacity(preset.value)}
|
||||
className={cn(
|
||||
"px-2.5 py-1 rounded-md text-xs font-medium transition-colors border",
|
||||
windowOpacity === preset.value
|
||||
? "bg-primary text-primary-foreground border-primary"
|
||||
: "bg-muted/50 text-muted-foreground border-border hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</SettingRow>
|
||||
</div>
|
||||
|
||||
<SectionHeader title={t("settings.appearance.uiTheme")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
|
||||
@@ -34,6 +34,8 @@ interface TerminalAutocompleteProps {
|
||||
inputRef: HandlerRef<(data: string) => void>;
|
||||
repositionRef: HandlerRef<() => void>;
|
||||
closeRef: HandlerRef<() => void>;
|
||||
sudoHintRef: HandlerRef<(active: boolean) => boolean>;
|
||||
sudoHintText: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,6 +69,8 @@ export function TerminalAutocomplete({
|
||||
inputRef,
|
||||
repositionRef,
|
||||
closeRef,
|
||||
sudoHintRef,
|
||||
sudoHintText,
|
||||
}: TerminalAutocompleteProps) {
|
||||
const autocomplete = useTerminalAutocomplete({
|
||||
termRef,
|
||||
@@ -88,6 +92,13 @@ export function TerminalAutocomplete({
|
||||
inputRef.current = autocomplete.handleInput;
|
||||
repositionRef.current = autocomplete.repositionPopup;
|
||||
closeRef.current = autocomplete.closePopup;
|
||||
sudoHintRef.current = (active: boolean): boolean => {
|
||||
if (!active) {
|
||||
autocomplete.hideSudoHint();
|
||||
return false;
|
||||
}
|
||||
return autocomplete.showSudoHint(sudoHintText);
|
||||
};
|
||||
|
||||
const { state } = autocomplete;
|
||||
if (!visible || !state.popupVisible || state.suggestions.length === 0) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import React from 'react';
|
||||
type TerminalViewContext = Record<string, any>;
|
||||
|
||||
export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
|
||||
const { ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
const { ArrowDownToLine, ArrowUpFromLine, Button, Copy, Cpu, HardDrive, HoverCard, HoverCardContent, HoverCardTrigger, Maximize2, MemoryStick, Radio, Sparkles, TerminalAutocomplete, TerminalComposeBar, TerminalConnectionDialog, TerminalContextMenu, TerminalSearchBar, Tooltip, TooltipContent, TooltipTrigger, ZmodemOverwriteDialog, ZmodemProgressIndicator, auth, autocompleteAcceptTextRef, autocompleteCloseRef, autocompleteHostOs, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, autocompleteSettings, chainProgress, cn, containerRef, effectiveTheme, error, executeSnippet, executeSnippetCommand, formatNetSpeed, handleAddSelectionToAI, handleCancelConnect, handleCloseDisconnectedSession, handleCloseSearch, handleDismissDisconnectedDialog, handleDragEnter, handleDragLeave, handleDragOver, handleDrop, handleFindNext, handleFindPrevious, handleHostKeyAddAndContinue, handleHostKeyClose, handleHostKeyContinue, handleOsc52ReadResponse, handleRetry, handleSearch, handleTopOverlayMouseDownCapture, hasMouseTracking, hasSelection, host, hotkeyScheme, inWorkspace, isBroadcastEnabled, isCancelling, isComposeBarOpen, isDraggingOver, isFocusMode, isLocalConnection, isSearchOpen, isVisible, keyBindings, keys, knownCwdRef, needsHostKeyVerification, onCloseSession, onExpandToFocus, onSplitHorizontal, onSplitVertical, onToggleBroadcast, osc52ReadPromptVisible, pendingHostKeyInfo, progressLogs, progressValue, renderControls, searchMatchCount, selectionOverlayPosition, serverStats, sessionId, sessionRef, setIsComposeBarOpen, setShowLogs, shouldShowConnectionDialog, showLogs, snippets, status, statusDotTone, sudoHintRef, sudoHintText, t, termRef, terminalContextActions, terminalCwdTracker, terminalPreviewVars, terminalSettings, timeLeft, toast, zmodem } = ctx;
|
||||
return (
|
||||
<TerminalContextMenu
|
||||
hasSelection={hasSelection}
|
||||
@@ -547,6 +547,8 @@ export function TerminalView({ ctx }: { ctx: TerminalViewContext }) {
|
||||
inputRef={autocompleteInputRef}
|
||||
repositionRef={autocompleteRepositionRef}
|
||||
closeRef={autocompleteCloseRef}
|
||||
sudoHintRef={sudoHintRef}
|
||||
sudoHintText={sudoHintText}
|
||||
/>
|
||||
|
||||
{/* OSC-52 clipboard read prompt */}
|
||||
|
||||
@@ -50,6 +50,8 @@ function stringCellWidth(s: string): number {
|
||||
export class GhostTextAddon implements IDisposable {
|
||||
private term: XTerm | null = null;
|
||||
private ghostElement: HTMLSpanElement | null = null;
|
||||
private hintElement: HTMLSpanElement | null = null;
|
||||
private hintActive = false;
|
||||
private containerElement: HTMLDivElement | null = null;
|
||||
private currentSuggestion: string = "";
|
||||
private currentInput: string = "";
|
||||
@@ -104,6 +106,23 @@ export class GhostTextAddon implements IDisposable {
|
||||
|
||||
this.containerElement.appendChild(this.ghostElement);
|
||||
|
||||
// Read-only inline hint (e.g. sudo "press Enter to paste password"). Shown
|
||||
// independently of autocomplete suggestions and never accepted as input.
|
||||
this.hintElement = document.createElement("span");
|
||||
this.hintElement.className = "xterm-inline-hint";
|
||||
Object.assign(this.hintElement.style, {
|
||||
position: "absolute",
|
||||
opacity: "0.4",
|
||||
pointerEvents: "none",
|
||||
whiteSpace: "pre",
|
||||
fontFamily: "inherit",
|
||||
fontSize: "inherit",
|
||||
lineHeight: "inherit",
|
||||
color: "inherit",
|
||||
display: "none",
|
||||
});
|
||||
this.containerElement.appendChild(this.hintElement);
|
||||
|
||||
const screenEl = termElement.querySelector(".xterm-screen");
|
||||
if (screenEl) {
|
||||
screenEl.appendChild(this.containerElement);
|
||||
@@ -113,6 +132,7 @@ export class GhostTextAddon implements IDisposable {
|
||||
|
||||
this.disposables.push(
|
||||
term.onRender(() => {
|
||||
if (this.hintActive) this.updateHintPosition();
|
||||
if (!this.isVisible()) return;
|
||||
// Fail-safe: if the device echoed input we didn't track (some bastion
|
||||
// hosts / network OS, #1013), hide rather than draw the ghost over
|
||||
@@ -137,6 +157,7 @@ export class GhostTextAddon implements IDisposable {
|
||||
this.lastLeft = -1;
|
||||
this.lastTop = -1;
|
||||
if (this.isVisible()) this.updatePosition();
|
||||
if (this.hintActive) this.updateHintPosition();
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -185,6 +206,40 @@ export class GhostTextAddon implements IDisposable {
|
||||
this.anchorInputLength = 0;
|
||||
}
|
||||
|
||||
/** Show a read-only inline hint at the cursor (e.g. a sudo password prompt
|
||||
* hint). Independent of autocomplete suggestions; never accepted as input. */
|
||||
showHint(text: string): void {
|
||||
if (this.disposed || !this.hintElement || !this.term) return;
|
||||
this.hintActive = true;
|
||||
this.hintElement.textContent = text;
|
||||
this.hintElement.style.display = "block";
|
||||
this.hintElement.style.fontSize = `${this.term.options.fontSize}px`;
|
||||
this.hintElement.style.fontFamily = this.term.options.fontFamily || "inherit";
|
||||
this.updateHintPosition();
|
||||
}
|
||||
|
||||
hideHint(): void {
|
||||
this.hintActive = false;
|
||||
if (this.hintElement) {
|
||||
this.hintElement.style.display = "none";
|
||||
this.hintElement.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
isHintActive(): boolean {
|
||||
return this.hintActive;
|
||||
}
|
||||
|
||||
private updateHintPosition(): void {
|
||||
if (!this.term || !this.hintElement) return;
|
||||
const dims = getXTermCellDimensions(this.term);
|
||||
const buf = this.term.buffer.active;
|
||||
this.hintElement.style.left = `${buf.cursorX * dims.width}px`;
|
||||
this.hintElement.style.top = `${buf.cursorY * dims.height}px`;
|
||||
this.hintElement.style.lineHeight = `${dims.height}px`;
|
||||
this.hintElement.style.height = `${dims.height}px`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-align the ghost against a freshly-updated user input synchronously.
|
||||
* Called from handleInput on every keystroke that mutates the typed
|
||||
@@ -379,6 +434,7 @@ export class GhostTextAddon implements IDisposable {
|
||||
this.containerElement?.remove();
|
||||
this.containerElement = null;
|
||||
this.ghostElement = null;
|
||||
this.hintElement = null;
|
||||
this.term = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -133,6 +133,8 @@ export interface TerminalAutocompleteHandle {
|
||||
repositionPopup: () => void;
|
||||
closePopup: () => void;
|
||||
dispose: () => void;
|
||||
showSudoHint: (text: string) => boolean;
|
||||
hideSudoHint: () => void;
|
||||
}
|
||||
|
||||
export { getCommandToRecordOnEnter } from "./terminalAutocompletePrompt";
|
||||
@@ -910,6 +912,16 @@ export function useTerminalAutocomplete(
|
||||
ghostAddonRef.current = null;
|
||||
}, []);
|
||||
|
||||
const showSudoHint = useCallback((text: string): boolean => {
|
||||
const addon = ghostAddonRef.current;
|
||||
if (!addon) return false;
|
||||
addon.showHint(text);
|
||||
return addon.isHintActive();
|
||||
}, []);
|
||||
const hideSudoHint = useCallback(() => {
|
||||
ghostAddonRef.current?.hideHint();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => { dispose(); };
|
||||
}, [dispose]);
|
||||
@@ -923,5 +935,7 @@ export function useTerminalAutocomplete(
|
||||
repositionPopup,
|
||||
closePopup,
|
||||
dispose,
|
||||
showSudoHint,
|
||||
hideSudoHint,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -5,14 +5,11 @@ import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
const prepareSudoPrompt = (
|
||||
autofill: { prepareCommand: (command: string) => string | null } | null,
|
||||
const armSudoPrompt = (
|
||||
autofill: { armForCommand: (command: string) => void } | null,
|
||||
): string => {
|
||||
const prepared = autofill?.prepareCommand("sudo whoami");
|
||||
assert.ok(prepared);
|
||||
const prompt = prepared.match(/\s-p '([^']*)'/)?.[1];
|
||||
assert.ok(prompt);
|
||||
return prompt.replace("%p", "alice");
|
||||
autofill?.armForCommand("sudo whoami");
|
||||
return "[sudo] password for alice: ";
|
||||
};
|
||||
|
||||
const makeBackend = (
|
||||
@@ -103,14 +100,15 @@ test("startEt enables sudo autofill with the host saved password", async () => {
|
||||
const ctx = {
|
||||
...makeCtx({
|
||||
password: "saved-secret",
|
||||
terminalSudoAutoFill: true,
|
||||
}, [], backend),
|
||||
sudoAutofillRef,
|
||||
sudoAutofillPassword: "saved-secret",
|
||||
onSudoHint: () => true,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startEt(term as never);
|
||||
onData?.(prepareSudoPrompt(sudoAutofillRef.current));
|
||||
onData?.(armSudoPrompt(sudoAutofillRef.current));
|
||||
sudoAutofillRef.current?.confirmFill();
|
||||
|
||||
assert.deepEqual(sent, ["saved-secret\n"]);
|
||||
});
|
||||
|
||||
@@ -8,14 +8,11 @@ import {
|
||||
const noop = () => undefined;
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
|
||||
|
||||
const prepareSudoPrompt = (
|
||||
autofill: { prepareCommand: (command: string) => string | null } | null,
|
||||
const armSudoPrompt = (
|
||||
autofill: { armForCommand: (command: string) => void } | null,
|
||||
): string => {
|
||||
const prepared = autofill?.prepareCommand("sudo whoami");
|
||||
assert.ok(prepared);
|
||||
const prompt = prepared.match(/\s-p '([^']*)'/)?.[1];
|
||||
assert.ok(prompt);
|
||||
return prompt.replace("%p", "alice");
|
||||
autofill?.armForCommand("sudo whoami");
|
||||
return "[sudo] password for alice: ";
|
||||
};
|
||||
|
||||
test("startMosh enables sudo autofill with the host saved password", async () => {
|
||||
@@ -51,7 +48,6 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "saved-secret",
|
||||
terminalSudoAutoFill: true,
|
||||
},
|
||||
keys: [],
|
||||
identities: [],
|
||||
@@ -60,6 +56,7 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sudoAutofillPassword: "saved-secret",
|
||||
onSudoHint: () => true,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: true },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
@@ -88,7 +85,8 @@ test("startMosh enables sudo autofill with the host saved password", async () =>
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
onData?.(prepareSudoPrompt(sudoAutofillRef.current));
|
||||
onData?.(armSudoPrompt(sudoAutofillRef.current));
|
||||
sudoAutofillRef.current?.confirmFill();
|
||||
|
||||
assert.deepEqual(sent, ["saved-secret\n"]);
|
||||
});
|
||||
|
||||
@@ -11,15 +11,12 @@ import { pasteTextIntoTerminal } from "./terminalUserPaste";
|
||||
const noop = () => undefined;
|
||||
const ENCRYPTED_CREDENTIAL_PLACEHOLDER = "enc:v1:djEwAAAA";
|
||||
|
||||
const prepareSudoPrompt = (
|
||||
autofill: { prepareCommand: (command: string) => string | null } | null,
|
||||
const armSudoPrompt = (
|
||||
autofill: { armForCommand: (command: string) => void } | null,
|
||||
command = "sudo whoami",
|
||||
): string => {
|
||||
const prepared = autofill?.prepareCommand(command);
|
||||
assert.ok(prepared);
|
||||
const prompt = prepared.match(/\s-p '([^']*)'/)?.[1];
|
||||
assert.ok(prompt);
|
||||
return prompt.replace("%p", "alice");
|
||||
autofill?.armForCommand(command);
|
||||
return "[sudo] password for alice: ";
|
||||
};
|
||||
|
||||
const createTermStub = () => ({
|
||||
@@ -31,6 +28,7 @@ const createTermStub = () => ({
|
||||
});
|
||||
|
||||
const createStarterContext = (overrides: Record<string, unknown> = {}) => ({
|
||||
onSudoHint: () => true,
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
@@ -195,14 +193,14 @@ test("startSSH enables sudo autofill only with the host saved password", async (
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "saved-secret",
|
||||
terminalSudoAutoFill: true,
|
||||
},
|
||||
terminalBackend,
|
||||
sudoAutofillPassword: "saved-secret",
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
onData?.(prepareSudoPrompt(ctx.sudoAutofillRef.current));
|
||||
onData?.(armSudoPrompt(ctx.sudoAutofillRef.current));
|
||||
ctx.sudoAutofillRef.current?.confirmFill();
|
||||
|
||||
assert.deepEqual(sent, ["saved-secret\n"]);
|
||||
});
|
||||
@@ -238,7 +236,6 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
terminalSudoAutoFill: true,
|
||||
},
|
||||
pendingAuthRef: {
|
||||
current: {
|
||||
@@ -252,7 +249,7 @@ test("startSSH does not use unsaved retry passwords for sudo autofill", async ()
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.equal(ctx.sudoAutofillRef.current?.prepareCommand("sudo whoami"), null);
|
||||
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
|
||||
onData?.("[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
@@ -289,7 +286,6 @@ test("startSSH prefers latest sudo autofill password state over pending saved au
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
terminalSudoAutoFill: true,
|
||||
},
|
||||
pendingAuthRef: {
|
||||
current: {
|
||||
@@ -300,12 +296,11 @@ test("startSSH prefers latest sudo autofill password state over pending saved au
|
||||
},
|
||||
},
|
||||
terminalBackend,
|
||||
sudoAutofillEnabledRef: { current: true },
|
||||
sudoAutofillPasswordRef: { current: undefined },
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.equal(ctx.sudoAutofillRef.current?.prepareCommand("sudo whoami"), null);
|
||||
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
|
||||
onData?.("[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
@@ -343,13 +338,12 @@ test("startSSH does not use merged group default passwords for sudo autofill", a
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "group-default-secret",
|
||||
terminalSudoAutoFill: true,
|
||||
},
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.equal(ctx.sudoAutofillRef.current?.prepareCommand("sudo whoami"), null);
|
||||
ctx.sudoAutofillRef.current?.armForCommand("sudo whoami");
|
||||
onData?.("[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
@@ -386,62 +380,18 @@ test("startSSH uses the provided sudo autofill password", async () => {
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
terminalSudoAutoFill: true,
|
||||
},
|
||||
terminalBackend,
|
||||
sudoAutofillPassword: "host-secret",
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
onData?.(prepareSudoPrompt(ctx.sudoAutofillRef.current));
|
||||
onData?.(armSudoPrompt(ctx.sudoAutofillRef.current));
|
||||
ctx.sudoAutofillRef.current?.confirmFill();
|
||||
|
||||
assert.deepEqual(sent, ["host-secret\n"]);
|
||||
});
|
||||
|
||||
test("startSSH leaves sudo autofill disabled when the host switch is off", async () => {
|
||||
let onData: ((data: string) => void) | null = null;
|
||||
const sent: string[] = [];
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => "mosh-session",
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: (_id: string, cb: (data: string) => void) => {
|
||||
onData = cb;
|
||||
return noop;
|
||||
},
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: (_id: string, data: string) => sent.push(data),
|
||||
resizeSession: noop,
|
||||
};
|
||||
const ctx = createStarterContext({
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Target",
|
||||
hostname: "target.example.test",
|
||||
username: "alice",
|
||||
password: "saved-secret",
|
||||
terminalSudoAutoFill: false,
|
||||
},
|
||||
terminalBackend,
|
||||
});
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startSSH(createTermStub() as never);
|
||||
assert.equal(ctx.sudoAutofillRef.current?.prepareCommand("sudo whoami"), null);
|
||||
onData?.("[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(sent, []);
|
||||
});
|
||||
|
||||
test("startSerial captures direct connected banner in terminal log data", async () => {
|
||||
const capturedLogData: string[] = [];
|
||||
const writtenData: string[] = [];
|
||||
|
||||
@@ -45,10 +45,6 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const resolveSavedSudoAutofillPassword = (): string | undefined => {
|
||||
const isSudoAutofillEnabled = ctx.sudoAutofillEnabledRef
|
||||
? ctx.sudoAutofillEnabledRef.current
|
||||
: ctx.host.terminalSudoAutoFill;
|
||||
if (!isSudoAutofillEnabled) return undefined;
|
||||
if (ctx.sudoAutofillPasswordRef) {
|
||||
return sanitizeCredentialValue(ctx.sudoAutofillPasswordRef.current);
|
||||
}
|
||||
|
||||
@@ -110,8 +110,8 @@ export type TerminalSessionStartersContext = {
|
||||
sessionLog?: SessionLogConfig;
|
||||
sshDebugLogEnabled?: boolean;
|
||||
sudoAutofillPassword?: string;
|
||||
sudoAutofillEnabledRef?: RefObject<boolean>;
|
||||
sudoAutofillPasswordRef?: RefObject<string | undefined>;
|
||||
onSudoHint?: (active: boolean) => boolean;
|
||||
isVisibleRef?: RefObject<boolean>;
|
||||
pendingOutputScrollRef?: RefObject<boolean>;
|
||||
|
||||
|
||||
@@ -8,9 +8,6 @@ import {
|
||||
import { recordTerminalCommandExecution } from "./terminalCommandExecution";
|
||||
import { createPromptLineBreakState } from "./promptLineBreak";
|
||||
|
||||
const TEST_MARKER = "__NETCATTY_SUDO_test__";
|
||||
const TEST_PREPARED_SUDO_WHOAMI = `sudo -p '[sudo] password for %p: ${TEST_MARKER}' whoami`;
|
||||
|
||||
function createFakeTerm(lineText = "$ echo ok", cursorX = lineText.length) {
|
||||
return {
|
||||
buffer: {
|
||||
@@ -55,36 +52,37 @@ function createWrappedFakeTerm(rows: string[], cursorY: number, cursorX: number,
|
||||
};
|
||||
}
|
||||
|
||||
test("sudo autofill input preparation rewrites submitted sudo commands for the remote side", () => {
|
||||
test("sudo autofill input preparation arms on a submitted sudo command without altering input", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: () => {},
|
||||
write: (data) => writes.push(data),
|
||||
onHint: () => true,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
prepareSudoAutofillInput("\r", "sudo whoami", autofill),
|
||||
`\x15${TEST_PREPARED_SUDO_WHOAMI}\r`,
|
||||
);
|
||||
assert.equal(prepareSudoAutofillInput("\r", "sudo whoami", autofill), "\r");
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
autofill.confirmFill();
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
});
|
||||
|
||||
test("sudo autofill input preparation rewrites single-line pasted sudo commands", () => {
|
||||
test("sudo autofill input preparation arms on a single-line pasted sudo command", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: () => {},
|
||||
write: (data) => writes.push(data),
|
||||
onHint: () => true,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
prepareSudoAutofillInput("sudo whoami\n", null, autofill),
|
||||
`\x15${TEST_PREPARED_SUDO_WHOAMI}\n`,
|
||||
);
|
||||
assert.equal(prepareSudoAutofillInput("sudo whoami\n", null, autofill), "sudo whoami\n");
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
autofill.confirmFill();
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
});
|
||||
|
||||
test("sudo autofill input preparation preserves bracketed pasted sudo commands", () => {
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: () => {},
|
||||
});
|
||||
|
||||
|
||||
@@ -618,6 +618,28 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sudo password hint: while a hint is pending, Enter confirms (paste the
|
||||
// saved password + submit); any other visible key dismisses it so the user
|
||||
// can type the password manually. Checked before autocomplete so Enter
|
||||
// pastes the password instead of submitting an empty line.
|
||||
const sudoAutofill = ctx.sudoAutofillRef?.current;
|
||||
if (sudoAutofill?.isPromptPending()) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
sudoAutofill.confirmFill();
|
||||
return false;
|
||||
}
|
||||
if (e.key === "Escape" || e.key === "Backspace") {
|
||||
e.preventDefault();
|
||||
sudoAutofill.cancelHint();
|
||||
return false; // dismiss without forwarding the byte to the no-echo prompt
|
||||
}
|
||||
if (e.key.length === 1) {
|
||||
sudoAutofill.cancelHint();
|
||||
// fall through: key becomes the first char of the manually typed password
|
||||
}
|
||||
}
|
||||
|
||||
// Autocomplete key handler (must be checked before other handlers)
|
||||
if (ctx.onAutocompleteKeyEvent) {
|
||||
const consumed = ctx.onAutocompleteKeyEvent(e);
|
||||
@@ -804,11 +826,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
const recordedCommand = recordTerminalCommandExecution(ctx.commandBufferRef.current, ctx, term);
|
||||
handledSubmittedInput = true;
|
||||
if (!willBroadcastInput) {
|
||||
dataToWrite = prepareSudoAutofillInput(
|
||||
data,
|
||||
recordedCommand,
|
||||
ctx.sudoAutofillRef?.current,
|
||||
);
|
||||
prepareSudoAutofillInput(data, recordedCommand, ctx.sudoAutofillRef?.current);
|
||||
}
|
||||
} else if (ctx.statusRef.current === "connected" && !willBroadcastInput) {
|
||||
const pastedCommand = getSinglePastedCommand(data);
|
||||
@@ -820,13 +838,11 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
);
|
||||
handledSubmittedInput = true;
|
||||
if (recordedCommand) {
|
||||
const submittedCommand = `${recordedCommand}${pastedCommand.lineEnding}`;
|
||||
const preparedInput = prepareSudoAutofillInput(
|
||||
submittedCommand,
|
||||
prepareSudoAutofillInput(
|
||||
`${recordedCommand}${pastedCommand.lineEnding}`,
|
||||
null,
|
||||
ctx.sudoAutofillRef?.current,
|
||||
);
|
||||
dataToWrite = preparedInput === submittedCommand ? data : preparedInput;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,13 +38,6 @@ const createContext = (showLineTimestamps: boolean) => ({
|
||||
promptLineBreakStateRef: { current: undefined },
|
||||
});
|
||||
|
||||
const extractSudoPromptFromPrepared = (prepared: string | null | undefined): string => {
|
||||
assert.ok(prepared);
|
||||
const prompt = prepared.match(/\s-p '([^']*)'/)?.[1];
|
||||
assert.ok(prompt);
|
||||
return prompt.replace("%p", "alice");
|
||||
};
|
||||
|
||||
test("writeSessionData prefixes terminal output lines when enabled", () => {
|
||||
const { term, writes } = createFakeTerm();
|
||||
writeSessionData(createContext(true) as never, term, "hello\r\nnext");
|
||||
@@ -110,9 +103,10 @@ test("attachSessionToTerminal resets timestamp state for a reused terminal", ()
|
||||
assert.ok(writes[1].endsWith("] \x1b[22;39mfresh"));
|
||||
});
|
||||
|
||||
test("attachSessionToTerminal auto-fills sudo password prompts when configured", () => {
|
||||
test("attachSessionToTerminal hints for sudo password prompts and fills on confirm", () => {
|
||||
const { term, writes } = createFakeTerm();
|
||||
const sent: Array<{ id: string; data: string; automated?: boolean }> = [];
|
||||
const hints: boolean[] = [];
|
||||
let onData: ((data: string) => void) | null = null;
|
||||
const sudoAutofillRef = { current: null };
|
||||
const ctx = {
|
||||
@@ -127,6 +121,7 @@ test("attachSessionToTerminal auto-fills sudo password prompts when configured",
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
sudoAutofillRef,
|
||||
onSudoHint: (active: boolean) => hints.push(active),
|
||||
terminalBackend: {
|
||||
onSessionData: (_id: string, cb: (data: string) => void) => {
|
||||
onData = cb;
|
||||
@@ -145,15 +140,20 @@ test("attachSessionToTerminal auto-fills sudo password prompts when configured",
|
||||
attachSessionToTerminal(ctx as never, term, "session-1", {
|
||||
sudoAutofillPassword: "secret",
|
||||
});
|
||||
const prepared = sudoAutofillRef.current?.prepareCommand("sudo whoami");
|
||||
const prompt = extractSudoPromptFromPrepared(prepared);
|
||||
onData?.(`${prepared ?? ""}\r\n`);
|
||||
onData?.(prompt);
|
||||
sudoAutofillRef.current?.armForCommand("sudo whoami");
|
||||
onData?.("sudo whoami\r\n");
|
||||
onData?.("[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(sent, [{ id: "session-1", data: "secret\n", automated: true }]);
|
||||
// Confirm-to-fill model: detecting the prompt raises a hint but never sends
|
||||
// the password on its own.
|
||||
assert.deepEqual(hints, [true]);
|
||||
assert.deepEqual(sent, []);
|
||||
assert.equal(writes[0], "sudo whoami\r\n");
|
||||
assert.equal(writes[1], "[sudo] password for alice: ");
|
||||
assert.ok(!writes.join("").includes("NETCATTY_SUDO"));
|
||||
|
||||
// The password is only written once the user confirms (presses Enter).
|
||||
sudoAutofillRef.current?.confirmFill();
|
||||
assert.deepEqual(sent, [{ id: "session-1", data: "secret\n", automated: true }]);
|
||||
});
|
||||
|
||||
test("attachSessionToTerminal does not auto-fill unarmed sudo-looking output", () => {
|
||||
|
||||
@@ -233,6 +233,7 @@ export const attachSessionToTerminal = (
|
||||
const sudoAutofill = createSudoPasswordAutofill({
|
||||
password: opts?.sudoAutofillPassword,
|
||||
write: (data) => ctx.terminalBackend.writeToSession(id, data, { automated: true }),
|
||||
onHint: (active) => ctx.onSudoHint?.(active) ?? false,
|
||||
});
|
||||
if (ctx.sudoAutofillRef) {
|
||||
ctx.sudoAutofillRef.current = sudoAutofill;
|
||||
|
||||
@@ -3,311 +3,245 @@ import assert from "node:assert/strict";
|
||||
import {
|
||||
createSudoPasswordAutofill,
|
||||
getSingleBracketedPasteLine,
|
||||
isExplicitSudoPrompt,
|
||||
isSudoPasswordPrompt,
|
||||
shouldArmSudoPasswordAutofill,
|
||||
} from "./terminalSudoAutofill";
|
||||
|
||||
const TEST_PROMPT = "[sudo] password for alice: ";
|
||||
const TEST_MARKER = "__NETCATTY_SUDO_test__";
|
||||
const TEST_MARKED_PROMPT = `[sudo] password for alice: ${TEST_MARKER}`;
|
||||
// --- isSudoPasswordPrompt: relaxed — any password/密码/口令 line ending in a
|
||||
// colon. Over-matching is safe now because filling requires explicit confirm. ---
|
||||
|
||||
const markedCommand = (commandTail: string) =>
|
||||
`sudo -p '[sudo] password for %p: ${TEST_MARKER}'${commandTail}`;
|
||||
|
||||
test("isSudoPasswordPrompt detects the standard sudo password prompt", () => {
|
||||
test("isSudoPasswordPrompt detects sudo and PAM prompts", () => {
|
||||
assert.equal(isSudoPasswordPrompt("[sudo] password for alice: "), true);
|
||||
assert.equal(isSudoPasswordPrompt("Password: "), true);
|
||||
assert.equal(isSudoPasswordPrompt("password for alice: "), true);
|
||||
assert.equal(isSudoPasswordPrompt("[sudo: [sudo] password for alice: ] Password: "), true);
|
||||
});
|
||||
|
||||
test("isSudoPasswordPrompt ignores ordinary output mentioning sudo and password", () => {
|
||||
test("isSudoPasswordPrompt detects localized prompts", () => {
|
||||
assert.equal(isSudoPasswordPrompt("[sudo] alice 的密码:"), true);
|
||||
assert.equal(isSudoPasswordPrompt("密码:"), true);
|
||||
assert.equal(isSudoPasswordPrompt("请输入密码: "), true);
|
||||
});
|
||||
|
||||
test("isSudoPasswordPrompt matches Kylin-style prompts without trailing colon", () => {
|
||||
// Kylin Professional: sudo prompt has no [sudo] tag and no trailing colon (#1293)
|
||||
assert.equal(isSudoPasswordPrompt("密码"), true);
|
||||
assert.equal(isSudoPasswordPrompt("用户 的密码"), true);
|
||||
assert.equal(isSudoPasswordPrompt("密码 "), true);
|
||||
// Exact prompts from issue #1293 screenshots (sudo -s on Kylin V10)
|
||||
assert.equal(isSudoPasswordPrompt("输入密码"), true);
|
||||
assert.equal(isSudoPasswordPrompt("Input Password"), true);
|
||||
});
|
||||
|
||||
test("isExplicitSudoPrompt matches Kylin-style prompts", () => {
|
||||
// Kylin-style [sudo] prompt without trailing colon
|
||||
assert.equal(isExplicitSudoPrompt("[sudo] 密码"), true);
|
||||
assert.equal(isExplicitSudoPrompt("[sudo] password for alice"), true);
|
||||
});
|
||||
|
||||
test("handleOutput hints on Kylin screenshot sudo prompts when armed", () => {
|
||||
const { autofill, hints, writes } = make();
|
||||
autofill.armForCommand("sudo -s");
|
||||
autofill.handleOutput("输入密码");
|
||||
assert.deepEqual(hints, [true]);
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(autofill.isPromptPending(), true);
|
||||
|
||||
const english = make();
|
||||
english.autofill.armForCommand("sudo -s");
|
||||
english.autofill.handleOutput("Input Password");
|
||||
assert.deepEqual(english.hints, [true]);
|
||||
assert.deepEqual(english.writes, []);
|
||||
});
|
||||
|
||||
test("isSudoPasswordPrompt detects color-wrapped prompts", () => {
|
||||
assert.equal(isSudoPasswordPrompt("\x1b[32m[sudo] password for alice: \x1b[0m"), true);
|
||||
});
|
||||
|
||||
test("isSudoPasswordPrompt ignores ordinary output", () => {
|
||||
assert.equal(isSudoPasswordPrompt("try sudo if the password is required\n"), false);
|
||||
assert.equal(isSudoPasswordPrompt("password for alice: "), false);
|
||||
assert.equal(isSudoPasswordPrompt("the password was changed\n"), false);
|
||||
assert.equal(isSudoPasswordPrompt("sudo: command not found\n"), false);
|
||||
});
|
||||
|
||||
test("isSudoPasswordPrompt requires the expected prompt marker when provided", () => {
|
||||
assert.equal(isSudoPasswordPrompt(TEST_MARKED_PROMPT, TEST_MARKER), true);
|
||||
assert.equal(isSudoPasswordPrompt("[sudo] password for alice: ", TEST_MARKER), false);
|
||||
test("isSudoPasswordPrompt refuses concealed prompt text", () => {
|
||||
assert.equal(isSudoPasswordPrompt("\x1b[8m[sudo] password for alice: \x1b[0m"), false);
|
||||
});
|
||||
|
||||
test("sudo autofill handles marked prompts split across chunks", () => {
|
||||
// --- arm + hint (confirm-to-fill) ---
|
||||
|
||||
const make = (password = "secret") => {
|
||||
const writes: string[] = [];
|
||||
const hints: boolean[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
now: () => 1_000,
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(autofill.prepareCommand("sudo apt update"), markedCommand(" apt update"));
|
||||
assert.equal(autofill.handleOutput("[sudo] password for alice: "), "[sudo] password for alice: ");
|
||||
assert.equal(autofill.handleOutput(TEST_MARKER), "");
|
||||
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
});
|
||||
|
||||
test("sudo autofill ignores sudo-looking output until a sudo command is submitted", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("sudo autofill sends the password once for a submitted sudo command", () => {
|
||||
const writes: string[] = [];
|
||||
let now = 1_000;
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
now: () => now,
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(autofill.prepareCommand("sudo -i"), markedCommand(" -i"));
|
||||
autofill.handleOutput(TEST_MARKED_PROMPT);
|
||||
now += 500;
|
||||
autofill.handleOutput(TEST_MARKED_PROMPT);
|
||||
now += 5_000;
|
||||
autofill.handleOutput(TEST_MARKED_PROMPT);
|
||||
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
});
|
||||
|
||||
test("sudo autofill allows target command prompt-like options", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(autofill.prepareCommand("sudo ssh -p 22 host"), markedCommand(" ssh -p 22 host"));
|
||||
assert.equal(autofill.prepareCommand("sudo useradd -p hash alice"), markedCommand(" useradd -p hash alice"));
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("sudo autofill handles sudo short options with attached arguments", () => {
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: () => {},
|
||||
});
|
||||
|
||||
assert.equal(autofill.prepareCommand("sudo -upostgres whoami"), markedCommand(" -upostgres whoami"));
|
||||
});
|
||||
|
||||
test("sudo autofill leaves commands with explicit sudo prompts unchanged", () => {
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
write: () => {},
|
||||
});
|
||||
|
||||
assert.equal(autofill.prepareCommand("sudo -p custom whoami"), null);
|
||||
assert.equal(autofill.prepareCommand("sudo --prompt=custom whoami"), null);
|
||||
});
|
||||
|
||||
test("sudo autofill extracts single-line bracketed paste content", () => {
|
||||
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\x1b[201~"), "sudo whoami");
|
||||
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\rpwd\x1b[201~"), null);
|
||||
});
|
||||
|
||||
test("sudo autofill ignores expired sudo command arms", () => {
|
||||
const writes: string[] = [];
|
||||
let now = 1_000;
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
now: () => now,
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(autofill.prepareCommand("sudo whoami"), markedCommand(" whoami"));
|
||||
now += 31_000;
|
||||
autofill.handleOutput(TEST_MARKED_PROMPT);
|
||||
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("sudo autofill ignores default sudo-looking output after a submitted command", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(autofill.prepareCommand("sudo ./program"), markedCommand(" ./program"));
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("sudo autofill ignores prompt-shaped command output after a submitted command", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
password,
|
||||
write: (d) => writes.push(d),
|
||||
onHint: (active) => {
|
||||
hints.push(active);
|
||||
return true; // hint overlay shown successfully
|
||||
},
|
||||
});
|
||||
return { autofill, writes, hints };
|
||||
};
|
||||
|
||||
test("shows a hint (not a fill) when a sudo prompt appears", () => {
|
||||
const { autofill, writes, hints } = make();
|
||||
autofill.armForCommand("sudo whoami");
|
||||
assert.equal(
|
||||
autofill.prepareCommand("sudo printf '[sudo] password for alice: '"),
|
||||
markedCommand(" printf '[sudo] password for alice: '"),
|
||||
);
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("sudo autofill stays armed after ordinary output before the password prompt", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(autofill.prepareCommand("sudo whoami"), markedCommand(" whoami"));
|
||||
assert.equal(autofill.handleOutput("sudo: this is the first time notice"), "sudo: this is the first time notice");
|
||||
assert.equal(autofill.handleOutput(TEST_MARKED_PROMPT), "[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
});
|
||||
|
||||
test("sudo autofill releases prompt-shaped warm sudo output without sending a password", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
autofill.prepareCommand("sudo printf '[sudo] password for alice: '"),
|
||||
markedCommand(" printf '[sudo] password for alice: '"),
|
||||
);
|
||||
assert.equal(autofill.handleOutput("[sudo] password for alice: "), "[sudo] password for alice: ");
|
||||
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("sudo autofill hides the prepared command and marker from output", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
const prepared = autofill.prepareCommand("sudo whoami");
|
||||
assert.equal(prepared, markedCommand(" whoami"));
|
||||
|
||||
assert.equal(autofill.handleOutput(`${prepared ?? ""}\r\n`), "sudo whoami\r\n");
|
||||
assert.equal(autofill.handleOutput(TEST_MARKED_PROMPT), "[sudo] password for alice: ");
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
});
|
||||
|
||||
test("sudo autofill hides split prepared command and marker output", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
const prepared = autofill.prepareCommand("sudo whoami");
|
||||
assert.equal(prepared, markedCommand(" whoami"));
|
||||
const preparedText = prepared ?? "";
|
||||
const preparedSplitIndex = Math.floor(preparedText.length / 2);
|
||||
assert.equal(autofill.handleOutput(preparedText.slice(0, preparedSplitIndex)), "");
|
||||
assert.equal(
|
||||
autofill.handleOutput(`${preparedText.slice(preparedSplitIndex)}\r\n`),
|
||||
"sudo whoami\r\n",
|
||||
);
|
||||
|
||||
const promptSplitIndex = TEST_MARKED_PROMPT.length - 6;
|
||||
assert.equal(autofill.handleOutput(TEST_MARKED_PROMPT.slice(0, promptSplitIndex)), "");
|
||||
assert.equal(
|
||||
autofill.handleOutput(TEST_MARKED_PROMPT.slice(promptSplitIndex)),
|
||||
autofill.handleOutput("[sudo] password for alice: "),
|
||||
"[sudo] password for alice: ",
|
||||
);
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
});
|
||||
|
||||
test("sudo autofill keeps sanitizing later shell-history echoes", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
const prepared = autofill.prepareCommand("sudo whoami");
|
||||
assert.equal(prepared, markedCommand(" whoami"));
|
||||
assert.equal(autofill.handleOutput(TEST_MARKED_PROMPT), "[sudo] password for alice: ");
|
||||
assert.equal(autofill.handleOutput(`${prepared ?? ""}\r\n`), "sudo whoami\r\n");
|
||||
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
});
|
||||
|
||||
test("sudo autofill does not hide completed output when sudo timestamp is warm", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
const prepared = autofill.prepareCommand("sudo true");
|
||||
assert.equal(prepared, markedCommand(" true"));
|
||||
|
||||
assert.equal(autofill.handleOutput(`${prepared}\r\n`), "sudo true\r\n");
|
||||
assert.deepEqual(hints, [true]);
|
||||
assert.deepEqual(writes, []);
|
||||
assert.equal(autofill.isPromptPending(), true);
|
||||
});
|
||||
|
||||
test("sudo autofill releases non-prompt output when sudo timestamp is warm", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
const prepared = autofill.prepareCommand("sudo printf ok");
|
||||
assert.equal(prepared, markedCommand(" printf ok"));
|
||||
|
||||
assert.equal(autofill.handleOutput(`${prepared}\r\n`), "sudo printf ok\r\n");
|
||||
assert.equal(autofill.handleOutput("ok"), "ok");
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("sudo autofill ignores hidden control-sequence prompt text", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
createPromptMarker: () => TEST_MARKER,
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
assert.equal(autofill.prepareCommand("sudo whoami"), markedCommand(" whoami"));
|
||||
autofill.handleOutput(`\x1b[8m${TEST_PROMPT}\x1b[0m`);
|
||||
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("sudo autofill does nothing without a saved password", () => {
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "",
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
test("confirmFill writes the password and clears the hint", () => {
|
||||
const { autofill, writes, hints } = make();
|
||||
autofill.armForCommand("sudo whoami");
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
autofill.confirmFill();
|
||||
assert.deepEqual(writes, ["secret\n"]);
|
||||
assert.deepEqual(hints, [true, false]);
|
||||
assert.equal(autofill.isPromptPending(), false);
|
||||
});
|
||||
|
||||
test("cancelHint clears the hint without filling", () => {
|
||||
const { autofill, writes, hints } = make();
|
||||
autofill.armForCommand("sudo whoami");
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
autofill.cancelHint();
|
||||
assert.deepEqual(writes, []);
|
||||
assert.deepEqual(hints, [true, false]);
|
||||
assert.equal(autofill.isPromptPending(), false);
|
||||
});
|
||||
|
||||
test("confirmFill does nothing when no prompt is pending", () => {
|
||||
const { autofill, writes } = make();
|
||||
autofill.confirmFill();
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("does not arm when the hint cannot be shown (overlay unavailable)", () => {
|
||||
// If onHint reports the hint could not render (e.g. autocomplete disabled, no
|
||||
// ghost overlay), we must NOT leave a pending arm — otherwise Enter would
|
||||
// submit the sudo password with no visible confirmation.
|
||||
const writes: string[] = [];
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
write: (d) => writes.push(d),
|
||||
onHint: () => false,
|
||||
});
|
||||
autofill.armForCommand("sudo whoami");
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
assert.equal(autofill.isPromptPending(), false);
|
||||
autofill.confirmFill();
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("a bare Password prompt does not hint until a sudo command is submitted", () => {
|
||||
const { autofill, hints } = make();
|
||||
autofill.handleOutput("Password: ");
|
||||
assert.deepEqual(hints, []);
|
||||
});
|
||||
|
||||
test("an explicit [sudo] prompt hints without a recorded sudo command", () => {
|
||||
// The [sudo] tag is sudo-specific, so we hint even when arming didn't fire —
|
||||
// manual typing's recordedCommand is flaky (#1281/#1284), and the hint only
|
||||
// pastes on explicit Enter, so showing it is safe.
|
||||
const { autofill, hints } = make();
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
assert.deepEqual(hints, [true]);
|
||||
assert.equal(autofill.isPromptPending(), true);
|
||||
});
|
||||
|
||||
test("no hint without a saved password", () => {
|
||||
const { autofill, hints } = make("");
|
||||
autofill.armForCommand("sudo whoami");
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
assert.deepEqual(hints, []);
|
||||
});
|
||||
|
||||
test("hint fires once across chunked prompt output", () => {
|
||||
const { autofill, hints } = make();
|
||||
autofill.armForCommand("sudo apt update");
|
||||
autofill.handleOutput("[sudo] password ");
|
||||
autofill.handleOutput("for alice: ");
|
||||
assert.deepEqual(hints, [true]);
|
||||
});
|
||||
|
||||
test("relaxed detection still only hints, never auto-fills child prompts", () => {
|
||||
// sudo creds warm -> mysql asks for its own password. We may hint, but we
|
||||
// must not send anything without an explicit confirm.
|
||||
const { autofill, hints, writes } = make();
|
||||
autofill.armForCommand("sudo mysql -p");
|
||||
autofill.handleOutput("Enter password: ");
|
||||
assert.deepEqual(hints, [true]);
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("a later non-sudo command disarms the pending hint", () => {
|
||||
const { autofill, writes, hints } = make();
|
||||
autofill.armForCommand("sudo -n true");
|
||||
autofill.handleOutput("Password: ");
|
||||
assert.deepEqual(hints, [true]);
|
||||
autofill.armForCommand("mysql -p"); // non-sudo command clears the arm
|
||||
assert.deepEqual(hints, [true, false]);
|
||||
autofill.confirmFill();
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("clears a pending hint when output moves past the prompt", () => {
|
||||
const { autofill, writes, hints } = make();
|
||||
autofill.armForCommand("sudo whoami");
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
assert.equal(autofill.isPromptPending(), true);
|
||||
// user never pressed Enter; sudo times out and returns to the shell
|
||||
autofill.handleOutput("\r\nsudo: timed out reading password\r\nalice@host:~$ ");
|
||||
assert.equal(autofill.isPromptPending(), false);
|
||||
assert.deepEqual(hints, [true, false]); // hint was hidden
|
||||
autofill.confirmFill();
|
||||
assert.deepEqual(writes, []); // a later Enter no longer sends the password
|
||||
});
|
||||
|
||||
test("keeps the hint pending when sudo re-prompts after a wrong password", () => {
|
||||
const { autofill, hints } = make();
|
||||
autofill.armForCommand("sudo whoami");
|
||||
autofill.handleOutput("[sudo] password for alice: ");
|
||||
autofill.handleOutput("\r\nSorry, try again.\r\n[sudo] password for alice: ");
|
||||
assert.equal(autofill.isPromptPending(), true);
|
||||
assert.deepEqual(hints, [true]);
|
||||
});
|
||||
|
||||
test("an expired arm shows no hint for a bare prompt", () => {
|
||||
const writes: string[] = [];
|
||||
const hints: boolean[] = [];
|
||||
let now = 1_000;
|
||||
const autofill = createSudoPasswordAutofill({
|
||||
password: "secret",
|
||||
now: () => now,
|
||||
write: (d) => writes.push(d),
|
||||
onHint: (a) => hints.push(a),
|
||||
});
|
||||
autofill.armForCommand("sudo whoami");
|
||||
now += 31_000;
|
||||
autofill.handleOutput("Password: ");
|
||||
assert.deepEqual(hints, []);
|
||||
});
|
||||
|
||||
test("handleOutput passes data through unchanged", () => {
|
||||
const { autofill } = make();
|
||||
autofill.armForCommand("sudo whoami");
|
||||
assert.equal(
|
||||
autofill.handleOutput("Reading package lists...\r\n"),
|
||||
"Reading package lists...\r\n",
|
||||
);
|
||||
});
|
||||
|
||||
test("getSingleBracketedPasteLine extracts single-line bracketed paste content", () => {
|
||||
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\x1b[201~"), "sudo whoami");
|
||||
assert.equal(getSingleBracketedPasteLine("\x1b[200~sudo whoami\rpwd\x1b[201~"), null);
|
||||
});
|
||||
|
||||
test("shouldArmSudoPasswordAutofill only arms direct sudo commands", () => {
|
||||
|
||||
@@ -7,69 +7,55 @@ const OSC_PATTERN = new RegExp(
|
||||
`${ESCAPE_SEQUENCE}\\][^${BELL_SEQUENCE}]*(?:${BELL_SEQUENCE}|${ESCAPE_SEQUENCE}\\\\)`,
|
||||
"g",
|
||||
);
|
||||
// SGR conceal (parameter 8) hides the text it wraps. Refuse to treat concealed
|
||||
// output as a real prompt so a remote can't disguise a fake prompt and trick the
|
||||
// user into revealing the password.
|
||||
const CONCEAL_PATTERN = new RegExp(`${ESCAPE_SEQUENCE}\\[(?:[0-9]+;)*8(?:;[0-9]+)*m`);
|
||||
// A line that mentions password/密码/口令 and optionally ends in a colon.
|
||||
// Intentionally broad: filling requires the user to confirm (press Enter), so
|
||||
// over-matching only shows a dismissable hint and never leaks a password to a
|
||||
// child program. The colon is optional because Kylin's sudo prompt doesn't
|
||||
// use one (#1293).
|
||||
const SUDO_PROMPT_PATTERN =
|
||||
/(?:^|[\r\n])\s*(?:\[[^\]\r\n]*sudo[^\]\r\n]*\]\s*)?(?=.*\bsudo\b)(?:.*\bpassword\b(?:\s+for\s+[^:\r\n]+)?|.*密码(?:\s*[::前为给]\s*[^:\r\n]*)?)\s*[::]\s*$/i;
|
||||
/(?:^|[\r\n])[^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n::]*(?:[::]\s*)?$/i;
|
||||
// An explicit sudo prompt carries the sudo-specific "[sudo]" tag. No other tool
|
||||
// prompts this way, so we hint on it WITHOUT requiring an arm — keeping the hint
|
||||
// reliable even when command recording (arming) didn't fire for a manually
|
||||
// typed command (#1284; manual typing's recordedCommand is flaky).
|
||||
// Match [sudo] or [sudo: ...] variants (e.g. Chinese locale: [sudo: authenticate] 密码:, #1286).
|
||||
// Colon is optional for Kylin (#1293).
|
||||
const EXPLICIT_SUDO_PROMPT_PATTERN =
|
||||
/(?:^|[\r\n])[^\r\n]*?\[sudo[^\]]*\][^\r\n]*?(?:\bpassword\b|密\s*码|口\s*令)[^\r\n::]*(?:[::]\s*)?$/i;
|
||||
const SUDO_COMMAND_PATTERN = /^\s*(?:builtin\s+|command\s+)?sudo(?:\s|$)/;
|
||||
const SUDO_COMMAND_HEAD_PATTERN = /^(\s*(?:builtin\s+|command\s+)?sudo)(?=\s|$)(.*)$/;
|
||||
const SUDO_SHORT_OPTIONS_WITH_ARGUMENT = new Set(["C", "D", "g", "h", "p", "T", "t", "U", "u"]);
|
||||
const SUDO_LONG_OPTIONS_WITH_ARGUMENT = new Set([
|
||||
"chdir",
|
||||
"close-from",
|
||||
"command-timeout",
|
||||
"group",
|
||||
"host",
|
||||
"prompt",
|
||||
"role",
|
||||
"type",
|
||||
"user",
|
||||
]);
|
||||
|
||||
export const stripTerminalControlSequences = (data: string): string =>
|
||||
data.replace(OSC_PATTERN, "").replace(ANSI_PATTERN, "");
|
||||
|
||||
export const hasTerminalControlSequences = (data: string): boolean =>
|
||||
data.includes("\x1b") || data.includes("\x07");
|
||||
export const isSudoPasswordPrompt = (data: string): boolean => {
|
||||
if (CONCEAL_PATTERN.test(data)) return false;
|
||||
return SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
|
||||
};
|
||||
|
||||
export const isSudoPasswordPrompt = (
|
||||
data: string,
|
||||
expectedPrompt?: string,
|
||||
): boolean => {
|
||||
if (expectedPrompt) {
|
||||
if (!data.includes(expectedPrompt)) return false;
|
||||
return isSudoPasswordPrompt(data.replaceAll(expectedPrompt, ""));
|
||||
}
|
||||
if (hasTerminalControlSequences(data)) return false;
|
||||
const text = stripTerminalControlSequences(data);
|
||||
return SUDO_PROMPT_PATTERN.test(text);
|
||||
export const isExplicitSudoPrompt = (data: string): boolean => {
|
||||
if (CONCEAL_PATTERN.test(data)) return false;
|
||||
return EXPLICIT_SUDO_PROMPT_PATTERN.test(stripTerminalControlSequences(data));
|
||||
};
|
||||
|
||||
export const shouldArmSudoPasswordAutofill = (command: string): boolean =>
|
||||
SUDO_COMMAND_PATTERN.test(command);
|
||||
|
||||
export type SudoPasswordAutofill = {
|
||||
prepareCommand: (command: string) => string | null;
|
||||
armForCommand: (command: string) => void;
|
||||
handleOutput: (data: string) => string;
|
||||
confirmFill: () => void;
|
||||
cancelHint: () => void;
|
||||
isPromptPending: () => boolean;
|
||||
updatePassword: (password?: string) => void;
|
||||
};
|
||||
|
||||
export const prepareSudoAutofillInput = (
|
||||
data: string,
|
||||
recordedCommand: string | null,
|
||||
sudoAutofill: SudoPasswordAutofill | null | undefined,
|
||||
): string => {
|
||||
if (data !== "\r" && data !== "\n") {
|
||||
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
|
||||
return data;
|
||||
}
|
||||
const pastedCommand = getSinglePastedCommand(data);
|
||||
if (!pastedCommand) return data;
|
||||
const preparedCommand = sudoAutofill?.prepareCommand(pastedCommand.command);
|
||||
if (!preparedCommand) return data;
|
||||
return `\x15${preparedCommand}${pastedCommand.lineEnding}`;
|
||||
}
|
||||
if (recordedCommand) {
|
||||
const preparedCommand = sudoAutofill?.prepareCommand(recordedCommand);
|
||||
if (preparedCommand) return `\x15${preparedCommand}${data}`;
|
||||
const unwrapBracketedPaste = (data: string): string => {
|
||||
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
|
||||
return data.slice(BRACKETED_PASTE_START.length, -BRACKETED_PASTE_END.length);
|
||||
}
|
||||
return data;
|
||||
};
|
||||
@@ -94,226 +80,122 @@ export const getSingleBracketedPasteLine = (data: string): string | null => {
|
||||
return text;
|
||||
};
|
||||
|
||||
const unwrapBracketedPaste = (data: string): string => {
|
||||
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
|
||||
return data.slice(BRACKETED_PASTE_START.length, -BRACKETED_PASTE_END.length);
|
||||
// Arm the autofill when a sudo command is submitted. The user's input is sent to
|
||||
// the remote verbatim — we never rewrite it — so the terminal echo and cursor
|
||||
// stay correct.
|
||||
export const prepareSudoAutofillInput = (
|
||||
data: string,
|
||||
recordedCommand: string | null,
|
||||
sudoAutofill: SudoPasswordAutofill | null | undefined,
|
||||
): string => {
|
||||
if (!sudoAutofill) return data;
|
||||
if (data === "\r" || data === "\n") {
|
||||
if (recordedCommand) sudoAutofill.armForCommand(recordedCommand);
|
||||
return data;
|
||||
}
|
||||
if (data.startsWith(BRACKETED_PASTE_START) && data.endsWith(BRACKETED_PASTE_END)) {
|
||||
return data;
|
||||
}
|
||||
const pastedCommand = getSinglePastedCommand(data);
|
||||
if (pastedCommand) sudoAutofill.armForCommand(pastedCommand.command);
|
||||
return data;
|
||||
};
|
||||
|
||||
const shellQuote = (value: string): string =>
|
||||
`'${value.replaceAll("'", "'\\''")}'`;
|
||||
|
||||
const createDefaultSudoPromptMarker = (): string =>
|
||||
`__NETCATTY_SUDO_${Math.random().toString(36).slice(2, 14)}__`;
|
||||
|
||||
const splitShellWords = (input: string): string[] => {
|
||||
const words: string[] = [];
|
||||
let current = "";
|
||||
let quote: "'" | '"' | null = null;
|
||||
let escaped = false;
|
||||
|
||||
for (const char of input) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\" && quote !== "'") {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if ((char === "'" || char === "\"") && !quote) {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (quote === char) {
|
||||
quote = null;
|
||||
continue;
|
||||
}
|
||||
if (!quote && /\s/.test(char)) {
|
||||
if (current) {
|
||||
words.push(current);
|
||||
current = "";
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
|
||||
if (current) words.push(current);
|
||||
return words;
|
||||
};
|
||||
|
||||
const hasSudoPromptOption = (command: string): boolean => {
|
||||
const match = command.match(SUDO_COMMAND_HEAD_PATTERN);
|
||||
if (!match) return false;
|
||||
const tokens = splitShellWords(match[2]);
|
||||
for (let index = 0; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (token === "--") return false;
|
||||
if (!token.startsWith("-") || token === "-") return false;
|
||||
|
||||
if (token === "--prompt" || token.startsWith("--prompt=")) return true;
|
||||
if (token.startsWith("--")) {
|
||||
const optionName = token.slice(2).split("=")[0];
|
||||
if (SUDO_LONG_OPTIONS_WITH_ARGUMENT.has(optionName) && !token.includes("=")) {
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const shortOptions = token.slice(1);
|
||||
for (let optionIndex = 0; optionIndex < shortOptions.length; optionIndex += 1) {
|
||||
const option = shortOptions[optionIndex];
|
||||
if (option === "p") return true;
|
||||
if (SUDO_SHORT_OPTIONS_WITH_ARGUMENT.has(option)) {
|
||||
if (optionIndex === shortOptions.length - 1 && index + 1 < tokens.length) {
|
||||
index += 1;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const buildSudoCommandWithPrompt = (
|
||||
command: string,
|
||||
prompt: string,
|
||||
): string | null => {
|
||||
if (hasSudoPromptOption(command)) return null;
|
||||
const match = command.match(SUDO_COMMAND_HEAD_PATTERN);
|
||||
if (!match) return null;
|
||||
return `${match[1]} -p ${shellQuote(prompt)}${match[2]}`;
|
||||
};
|
||||
|
||||
const isPotentialPreparedCommandPrefix = (
|
||||
data: string,
|
||||
preparedCommand?: string | null,
|
||||
): boolean =>
|
||||
Boolean(preparedCommand && data && (
|
||||
preparedCommand.startsWith(data) || data.startsWith(preparedCommand)
|
||||
));
|
||||
|
||||
const hasExpectedMarkerPrefix = (data: string, marker?: string | null): boolean => {
|
||||
if (!data || !marker) return false;
|
||||
const maxPrefixLength = Math.min(data.length, marker.length - 1);
|
||||
for (let length = maxPrefixLength; length > 0; length -= 1) {
|
||||
if (data.endsWith(marker.slice(0, length))) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
// Confirm-to-fill model: when a sudo command is armed and a password prompt is
|
||||
// seen, we DON'T send the password — we raise a hint (onHint(true)) so the UI can
|
||||
// offer "press Enter to paste". The password is only written when the user
|
||||
// confirms via confirmFill(). This makes over-broad detection safe: a misfire
|
||||
// just shows a dismissable hint instead of leaking the password.
|
||||
export const createSudoPasswordAutofill = (_options: {
|
||||
password?: string;
|
||||
write: (data: string) => void;
|
||||
/** Show/hide the inline hint. Returns whether the hint actually rendered;
|
||||
* false (e.g. no overlay available) means we must not arm a confirmation. */
|
||||
onHint?: (active: boolean) => boolean;
|
||||
now?: () => number;
|
||||
createPromptMarker?: () => string;
|
||||
}): SudoPasswordAutofill => {
|
||||
const options = {
|
||||
now: () => Date.now(),
|
||||
createPromptMarker: createDefaultSudoPromptMarker,
|
||||
onHint: () => false,
|
||||
..._options,
|
||||
};
|
||||
let password = options.password ?? "";
|
||||
const cooldownMs = 3_000;
|
||||
const armWindowMs = 30_000;
|
||||
const armWindowMs = 10_000;
|
||||
let tail = "";
|
||||
let expectedPromptMarker: string | null = null;
|
||||
let originalCommand: string | null = null;
|
||||
let preparedCommand: string | null = null;
|
||||
let outputSanitizePending = "";
|
||||
let lastSentAt = Number.NEGATIVE_INFINITY;
|
||||
let armedUntil = Number.NEGATIVE_INFINITY;
|
||||
let pending = false;
|
||||
|
||||
const disarm = () => {
|
||||
armedUntil = Number.NEGATIVE_INFINITY;
|
||||
tail = "";
|
||||
outputSanitizePending = "";
|
||||
};
|
||||
const applyOutputSanitizers = (data: string): string => {
|
||||
let nextData = data;
|
||||
if (preparedCommand && originalCommand) {
|
||||
nextData = nextData.replaceAll(preparedCommand, originalCommand);
|
||||
if (pending) {
|
||||
pending = false;
|
||||
options.onHint(false);
|
||||
}
|
||||
if (expectedPromptMarker) {
|
||||
nextData = nextData.replaceAll(expectedPromptMarker, "");
|
||||
}
|
||||
return nextData;
|
||||
};
|
||||
const sanitizeOutput = (data: string, opts: { final?: boolean } = {}): string => {
|
||||
if (!preparedCommand && !expectedPromptMarker && !outputSanitizePending) return data;
|
||||
|
||||
outputSanitizePending += data;
|
||||
const lastLineBreakIndex = Math.max(
|
||||
outputSanitizePending.lastIndexOf("\n"),
|
||||
outputSanitizePending.lastIndexOf("\r"),
|
||||
);
|
||||
if (!opts.final && lastLineBreakIndex < 0) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const readyLength = opts.final
|
||||
? outputSanitizePending.length
|
||||
: lastLineBreakIndex + 1;
|
||||
const readyData = outputSanitizePending.slice(0, readyLength);
|
||||
outputSanitizePending = outputSanitizePending.slice(readyLength);
|
||||
return applyOutputSanitizers(readyData);
|
||||
};
|
||||
|
||||
return {
|
||||
prepareCommand: (command: string) => {
|
||||
if (!password || !shouldArmSudoPasswordAutofill(command)) return null;
|
||||
const promptMarker = options.createPromptMarker();
|
||||
const prompt = `[sudo] password for %p: ${promptMarker}`;
|
||||
const nextPreparedCommand = buildSudoCommandWithPrompt(command, prompt);
|
||||
if (!nextPreparedCommand) return null;
|
||||
expectedPromptMarker = promptMarker;
|
||||
originalCommand = command;
|
||||
preparedCommand = nextPreparedCommand;
|
||||
armForCommand: (command: string) => {
|
||||
// Clear any prior arm/hint first: a non-sudo command must not leave a
|
||||
// stale hint that a later prompt could satisfy.
|
||||
disarm();
|
||||
if (!password || !shouldArmSudoPasswordAutofill(command)) return;
|
||||
armedUntil = options.now() + armWindowMs;
|
||||
tail = "";
|
||||
return nextPreparedCommand;
|
||||
},
|
||||
handleOutput: (data: string) => {
|
||||
const sanitizedData = sanitizeOutput(data);
|
||||
if (!password || armedUntil === Number.NEGATIVE_INFINITY) {
|
||||
if (
|
||||
!sanitizedData &&
|
||||
outputSanitizePending &&
|
||||
!isPotentialPreparedCommandPrefix(outputSanitizePending, preparedCommand) &&
|
||||
!hasExpectedMarkerPrefix(outputSanitizePending, expectedPromptMarker)
|
||||
) {
|
||||
return sanitizeOutput("", { final: true });
|
||||
}
|
||||
return sanitizedData;
|
||||
}
|
||||
if (!password) return data;
|
||||
tail = `${tail}${data}`.slice(-1024);
|
||||
const currentTime = options.now();
|
||||
if (currentTime > armedUntil) {
|
||||
const finalSanitizedData = sanitizedData + sanitizeOutput("", { final: true });
|
||||
disarm();
|
||||
return finalSanitizedData;
|
||||
// Fast path for bulk output: a prompt line ends in a colon, so a chunk
|
||||
// with no colon can't be completing one. Skip the regex work unless a hint
|
||||
// is pending (then we must keep watching for the prompt moving on).
|
||||
// Also check for password keywords because Kylin's sudo prompt doesn't
|
||||
// end with a colon (#1293).
|
||||
if (
|
||||
!pending &&
|
||||
!data.includes(":") &&
|
||||
!data.includes(":") &&
|
||||
!/(?:\bpassword\b|密码|口令)/i.test(data)
|
||||
) {
|
||||
return data;
|
||||
}
|
||||
if (currentTime - lastSentAt < cooldownMs) return sanitizedData;
|
||||
const lastLine = tail.split(/[\r\n]/).pop() ?? tail;
|
||||
if (!expectedPromptMarker || !isSudoPasswordPrompt(lastLine, expectedPromptMarker)) {
|
||||
if (
|
||||
lastLine &&
|
||||
!isPotentialPreparedCommandPrefix(lastLine, preparedCommand) &&
|
||||
!hasExpectedMarkerPrefix(lastLine, expectedPromptMarker)
|
||||
) {
|
||||
return sanitizedData + sanitizeOutput("", { final: true });
|
||||
}
|
||||
return sanitizedData;
|
||||
const armActive =
|
||||
armedUntil !== Number.NEGATIVE_INFINITY && options.now() <= armedUntil;
|
||||
// Explicit "[sudo] …" prompts are sudo-specific → hint regardless of arm,
|
||||
// so it's reliable even when arming didn't fire (#1284). Bare "Password:"
|
||||
// only hints inside the arm window, to avoid noise on unrelated prompts
|
||||
// (ssh, mysql, …).
|
||||
const isPrompt =
|
||||
isExplicitSudoPrompt(lastLine) || (armActive && isSudoPasswordPrompt(lastLine));
|
||||
if (pending) {
|
||||
// The prompt moved on: a new line arrived and the latest line is no
|
||||
// longer a password prompt (sudo timed out / failed / returned to the
|
||||
// shell). Clear the pending hint — otherwise a later Enter would send
|
||||
// the password to whatever is now reading input.
|
||||
if (!isPrompt && /[\r\n]/.test(data)) disarm();
|
||||
return data;
|
||||
}
|
||||
|
||||
options.write(`${password}\n`);
|
||||
lastSentAt = currentTime;
|
||||
const finalSanitizedData = sanitizedData + sanitizeOutput("", { final: true });
|
||||
disarm();
|
||||
return finalSanitizedData;
|
||||
if (isPrompt) {
|
||||
// Only mark pending if the hint actually rendered. If the overlay is
|
||||
// unavailable (e.g. autocomplete disabled), don't intercept Enter — the
|
||||
// user would have no visible cue and could leak the password.
|
||||
if (options.onHint(true)) {
|
||||
pending = true;
|
||||
}
|
||||
}
|
||||
return data;
|
||||
},
|
||||
confirmFill: () => {
|
||||
if (!pending) return;
|
||||
options.write(`${password}\n`);
|
||||
disarm();
|
||||
},
|
||||
cancelHint: () => {
|
||||
if (!pending) return;
|
||||
disarm();
|
||||
},
|
||||
isPromptPending: () => pending,
|
||||
updatePassword: (nextPassword?: string) => {
|
||||
password = nextPassword ?? "";
|
||||
if (!password) disarm();
|
||||
|
||||
@@ -719,6 +719,8 @@ const TerminalPane: React.FC<TerminalPaneProps> = memo(({
|
||||
return (
|
||||
<div
|
||||
data-session-id={session.id}
|
||||
data-section="terminal-split-pane"
|
||||
data-focused={isFocusedPane ? 'true' : undefined}
|
||||
className={cn(
|
||||
"absolute bg-background",
|
||||
inActiveWorkspace && "workspace-pane",
|
||||
|
||||
@@ -31,6 +31,8 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
"flex-shrink-0 h-full relative z-20",
|
||||
sidePanelPosition === 'right' && "order-last",
|
||||
)}
|
||||
data-section="terminal-side-panel-shell"
|
||||
data-side-panel-position={sidePanelPosition}
|
||||
>
|
||||
{isSidePanelOpenForCurrentTab && (
|
||||
<div
|
||||
@@ -46,6 +48,8 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
"h-full flex flex-col overflow-hidden",
|
||||
!isSidePanelOpenForCurrentTab && "pointer-events-none",
|
||||
)}
|
||||
data-section="terminal-side-panel"
|
||||
data-side-panel-tab={isSidePanelOpenForCurrentTab ? (activeSidePanelTab ?? undefined) : undefined}
|
||||
style={{
|
||||
['--terminal-sidepanel-bg' as never]: resolvedPreviewTheme.colors.background,
|
||||
['--terminal-sidepanel-fg' as never]: resolvedPreviewTheme.colors.foreground,
|
||||
@@ -402,6 +406,8 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
<div
|
||||
key={handle.id}
|
||||
className={cn("absolute group", isVertical ? "cursor-ew-resize" : "cursor-ns-resize")}
|
||||
data-section="terminal-split-resizer"
|
||||
data-split-direction={handle.direction}
|
||||
style={{
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
@@ -431,6 +437,7 @@ export function TerminalLayerView({ ctx }: { ctx: TerminalLayerViewContext }) {
|
||||
}}
|
||||
>
|
||||
<div
|
||||
data-section="terminal-split-resizer-bar"
|
||||
className={cn(
|
||||
"absolute bg-border/70 group-hover:bg-primary/60 transition-colors",
|
||||
isVertical ? "w-px h-full left-1/2 -translate-x-1/2" : "h-px w-full top-1/2 -translate-y-1/2"
|
||||
|
||||
@@ -223,7 +223,7 @@ export function useTerminalFocusSidebar({
|
||||
// (instead of a distinct tinted panel sitting next to it).
|
||||
backgroundColor: termBg,
|
||||
color: termFg,
|
||||
borderRight: `1px solid ${separator}`,
|
||||
['--terminal-workspace-sidebar-border' as string]: `1px solid ${separator}`,
|
||||
}}
|
||||
data-section="terminal-workspace-sidebar"
|
||||
>
|
||||
|
||||
@@ -161,30 +161,19 @@ export const WindowControls: React.FC = memo(() => {
|
||||
close();
|
||||
};
|
||||
|
||||
const controlStyle = { color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' };
|
||||
const controlClassName =
|
||||
'h-7 w-10 flex items-center justify-center rounded-none hover:bg-foreground/10 transition-colors app-no-drag';
|
||||
|
||||
return (
|
||||
<div className="flex items-center app-drag h-full">
|
||||
<button
|
||||
onClick={handleMinimize}
|
||||
className="h-full w-10 flex items-center justify-center hover:bg-foreground/10 transition-all duration-150 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
>
|
||||
<div className="ml-2 flex items-center h-7">
|
||||
<button type="button" className={controlClassName} style={controlStyle} onClick={handleMinimize}>
|
||||
<Minus size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleMaximize}
|
||||
className="h-full w-10 flex items-center justify-center hover:bg-foreground/10 transition-all duration-150 app-no-drag"
|
||||
style={{ color: 'var(--top-tabs-muted, hsl(var(--muted-foreground)))' }}
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Copy size={14} />
|
||||
) : (
|
||||
<Square size={14} />
|
||||
)}
|
||||
<button type="button" className={controlClassName} style={controlStyle} onClick={handleMaximize}>
|
||||
{isMaximized ? <Copy size={14} /> : <Square size={14} />}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="h-full w-10 flex items-center justify-center text-muted-foreground hover:bg-red-500 hover:text-white transition-all duration-150 app-no-drag"
|
||||
>
|
||||
<button type="button" className={controlClassName} style={controlStyle} onClick={handleClose}>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -140,7 +140,6 @@ export interface Host {
|
||||
serialConfig?: SerialConfig;
|
||||
// SFTP specific configuration
|
||||
sftpSudo?: boolean; // Use sudo for SFTP operations (requires password)
|
||||
terminalSudoAutoFill?: boolean; // Auto-fill sudo password prompts in terminal output
|
||||
sftpEncoding?: SftpFilenameEncoding; // Filename encoding for SFTP operations
|
||||
sftpBookmarks?: SftpBookmark[]; // Bookmarked SFTP paths for quick navigation
|
||||
// Managed source: if this host is managed by an external file (e.g., ~/.ssh/config)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth } from "./sshAuth.ts";
|
||||
import type { Host, SSHKey } from "./models.ts";
|
||||
import { resolveBridgeKeyAuth, resolveHostAuth, resolveHostAutofillPassword } from "./sshAuth.ts";
|
||||
import type { Host, Identity, SSHKey } from "./models.ts";
|
||||
|
||||
const referenceKey: SSHKey = {
|
||||
id: "key-1",
|
||||
@@ -97,3 +97,59 @@ test("resolveHostAuth respects password auth over stale key selections", () => {
|
||||
assert.equal(resolved.key, undefined);
|
||||
assert.equal(resolved.keyId, undefined);
|
||||
});
|
||||
|
||||
const autofillBaseHost = {
|
||||
id: "h1",
|
||||
label: "Host",
|
||||
hostname: "h.example.test",
|
||||
username: "alice",
|
||||
} as Host;
|
||||
|
||||
test("resolveHostAutofillPassword uses the host's own saved password", () => {
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "direct-secret" }, keys: [] }),
|
||||
"direct-secret",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostAutofillPassword resolves a referenced keychain identity's password", () => {
|
||||
// host stores no password of its own; the credential lives in a Keychain
|
||||
// identity it references (host.identityId) — the #1284 scenario.
|
||||
const identity = {
|
||||
id: "id-1",
|
||||
label: "alice@prod",
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "identity-secret",
|
||||
created: 1,
|
||||
} as Identity;
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({
|
||||
host: { ...autofillBaseHost, password: undefined, identityId: "id-1" },
|
||||
keys: [],
|
||||
identities: [identity],
|
||||
}),
|
||||
"identity-secret",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostAutofillPassword returns undefined when the host opts out of saving", () => {
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "x", savePassword: false }, keys: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostAutofillPassword returns undefined when no password is available", () => {
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: undefined }, keys: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveHostAutofillPassword ignores undecryptable password placeholders", () => {
|
||||
assert.equal(
|
||||
resolveHostAutofillPassword({ host: { ...autofillBaseHost, password: "enc:v1:djEwAAAA" }, keys: [] }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -102,6 +102,22 @@ export const resolveHostAuth = (args: {
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the password to use for sudo autofill the same way SSH login does
|
||||
* (through resolveHostAuth), so a password stored in a referenced Keychain
|
||||
* identity (host.identityId) is found — not just host.password (issue #1284).
|
||||
* Returns undefined when the host opts out of saving its password, or none is
|
||||
* available (pure key auth, or an undecryptable placeholder).
|
||||
*/
|
||||
export const resolveHostAutofillPassword = (args: {
|
||||
host: Host;
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
}): string | undefined => {
|
||||
if (args.host.savePassword === false) return undefined;
|
||||
return sanitizeCredentialValue(resolveHostAuth(args).password) || undefined;
|
||||
};
|
||||
|
||||
export const resolveBridgeKeyAuth = (args: {
|
||||
key?: SSHKey | null;
|
||||
fallbackIdentityFilePaths?: string[];
|
||||
|
||||
@@ -2,10 +2,10 @@ const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const TAIL_LIMIT = 2048;
|
||||
|
||||
const ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1b\\))/g;
|
||||
const LAST_LOGIN_PATTERN = /(?:^|[\s([])(?:last|previous)\s+login\s*[:>]\s*$/i;
|
||||
const USERNAME_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:user\s*name|username|login|logon|account|userid|user\s*id|user|\u7528\u6237\u540d|\u5e10\u53f7|\u8d26\u53f7|\u767b\u5f55|\u767b\u5165)\s*[:>]\s*$/i;
|
||||
const PASSWORD_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:password|passwd|passcode|passphrase|pass\s*phrase|pin|\u5bc6\u7801|\u53e3\u4ee4)\s*[:>]\s*$/i;
|
||||
const CONTINUE_PROMPT_PATTERN = /(?:press|hit)\s+(?:[<\[(]?\s*)?(?:return|enter|any\s+key|space)\b(?:\s*[>\])])?.*(?:continue|get\s+started|start|begin|started)?\.?\s*$/i;
|
||||
const LAST_LOGIN_PATTERN = /(?:^|[\s([])(?:last|previous)\s+login(?:\s*[::>])?\s*$/i;
|
||||
const USERNAME_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:user\s*name|username|login|logon|account|userid|user\s*id|user|\u7528\u6237\u540d|\u5e10\u53f7|\u8d26\u53f7|\u767b\u5f55|\u767b\u5165)(?:\s*[::>])?\s*$/i;
|
||||
const PASSWORD_PROMPT_PATTERN = /(?:^|[^A-Za-z0-9])(?:password|passwd|passcode|passphrase|pass\s*phrase|pin|\u5bc6\u7801|\u53e3\u4ee4)(?:\s*[::>])?\s*$/i;
|
||||
const CONTINUE_PROMPT_PATTERN = /(?:press|hit)\s+(?:[<\[(]?\s*)?(?:return|enter|any\s+key|space)\b(?:\s*[>\]\)])?.*(?:continue|get\s+started|start|begin|started)?\.?\s*$/i;
|
||||
const COMMAND_PROMPT_PATTERN = /[$#>]\s*$/;
|
||||
|
||||
function stripAnsi(text) {
|
||||
|
||||
@@ -236,3 +236,47 @@ test("telnet auto-login avoids common non-prompt login text", () => {
|
||||
|
||||
assert.deepEqual(writes, []);
|
||||
});
|
||||
|
||||
test("telnet auto-login works with Kylin-style prompts without trailing colon", () => {
|
||||
// Kylin Professional prompts may lack trailing colon or angle-bracket (#1293)
|
||||
const writes = [];
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
autoLogin.handleText("Username");
|
||||
autoLogin.handleText("\r\nPassword");
|
||||
|
||||
assert.deepEqual(writes, ["admin\r", "secret\r"]);
|
||||
});
|
||||
|
||||
test("telnet auto-login works with Kylin-style Chinese prompts without trailing colon", () => {
|
||||
const writes = [];
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "admin",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
autoLogin.handleText("用户名");
|
||||
autoLogin.handleText("\r\n密码");
|
||||
|
||||
assert.deepEqual(writes, ["admin\r", "secret\r"]);
|
||||
});
|
||||
|
||||
test("telnet auto-login works with Kylin V10 Input Password prompt from issue #1293", () => {
|
||||
// Screenshot: "lybing-pc login: lybing" then "Input Password" (no trailing colon)
|
||||
const writes = [];
|
||||
const autoLogin = createTelnetAutoLogin({
|
||||
username: "lybing",
|
||||
password: "secret",
|
||||
write: (data) => writes.push(data),
|
||||
});
|
||||
|
||||
autoLogin.handleText("Kylin V10 SP1\r\nlybing-pc login: ");
|
||||
autoLogin.handleText("Input Password");
|
||||
|
||||
assert.deepEqual(writes, ["lybing\r", "secret\r"]);
|
||||
});
|
||||
|
||||
@@ -33,6 +33,9 @@ let lastFocusedMainWindow = null;
|
||||
let settingsWindow = null;
|
||||
let currentTheme = "light";
|
||||
let currentLanguage = "en";
|
||||
let currentWindowOpacity = 1;
|
||||
let cachedNativeTheme = null;
|
||||
|
||||
let handlersRegistered = false; // Prevent duplicate IPC handler registration
|
||||
let menuDeps = null;
|
||||
let electronApp = null; // Reference to Electron app for userData path
|
||||
@@ -324,6 +327,30 @@ function forEachMainWindow(callback) {
|
||||
}
|
||||
}
|
||||
|
||||
function clampWindowOpacity(opacity) {
|
||||
const value = Number(opacity);
|
||||
if (!Number.isFinite(value)) return 1;
|
||||
return Math.min(1, Math.max(0.5, value));
|
||||
}
|
||||
|
||||
function applyWindowOpacityToWindow(win) {
|
||||
if (!win || win.isDestroyed?.()) return;
|
||||
try {
|
||||
win.setOpacity?.(currentWindowOpacity);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function applyWindowOpacity(opacity) {
|
||||
currentWindowOpacity = clampWindowOpacity(opacity);
|
||||
forEachMainWindow(applyWindowOpacityToWindow);
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
applyWindowOpacityToWindow(settingsWindow);
|
||||
}
|
||||
return currentWindowOpacity;
|
||||
}
|
||||
|
||||
function getMainWindowCount() {
|
||||
return getMainWindowList().length;
|
||||
}
|
||||
@@ -809,6 +836,7 @@ const mainWindowApi = createMainWindowApi({
|
||||
registerMainWindow,
|
||||
unregisterMainWindow,
|
||||
getMainWindowCount,
|
||||
applyWindowOpacityToWindow,
|
||||
closeSettingsWindow: (...args) => closeSettingsWindow(...args),
|
||||
hideSettingsWindow: (...args) => hideSettingsWindow(...args),
|
||||
});
|
||||
@@ -852,6 +880,7 @@ const settingsWindowApi = createSettingsWindowApi({
|
||||
createAppWindowOpenHandler,
|
||||
createExternalOnlyWindowOpenHandler,
|
||||
getDevRendererBaseUrl,
|
||||
applyWindowOpacityToWindow,
|
||||
});
|
||||
const {
|
||||
restoreWindowInputFocus,
|
||||
@@ -874,6 +903,7 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
return;
|
||||
}
|
||||
handlersRegistered = true;
|
||||
cachedNativeTheme = nativeTheme;
|
||||
|
||||
ipcMain.handle("netcatty:window:minimize", (event) => {
|
||||
const win = getWindowForIpcEvent(event);
|
||||
@@ -952,7 +982,6 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
: theme;
|
||||
const themeConfig = THEME_COLORS[effectiveTheme] || THEME_COLORS.light;
|
||||
forEachMainWindow((win) => win.setBackgroundColor(themeConfig.background));
|
||||
// Also update settings window if open
|
||||
if (settingsWindow && !settingsWindow.isDestroyed()) {
|
||||
settingsWindow.setBackgroundColor(themeConfig.background);
|
||||
}
|
||||
@@ -969,6 +998,11 @@ function registerWindowHandlers(ipcMain, nativeTheme) {
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:setWindowOpacity", (_event, opacity) => {
|
||||
applyWindowOpacity(opacity);
|
||||
return true;
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:setLanguage", (_event, language) => {
|
||||
currentLanguage = typeof language === "string" && language.length ? language : "en";
|
||||
rebuildApplicationMenu();
|
||||
@@ -1165,4 +1199,7 @@ module.exports = {
|
||||
tryOpenExternalWithFallback,
|
||||
resolveSettingsWindowBounds,
|
||||
THEME_COLORS,
|
||||
clampWindowOpacity,
|
||||
applyWindowOpacity,
|
||||
applyWindowOpacityToWindow,
|
||||
};
|
||||
|
||||
@@ -279,6 +279,8 @@ function createMainWindowApi(ctx) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
applyWindowOpacityToWindow(win);
|
||||
|
||||
// Defer show until renderer is ready; use fallback timeout to avoid keeping window hidden forever.
|
||||
// Production gets a shorter timeout since the splash screen provides visual feedback.
|
||||
|
||||
@@ -233,6 +233,8 @@ function createSettingsWindowApi(ctx) {
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
applyWindowOpacityToWindow(win);
|
||||
|
||||
// Hide instead of close so the window can be reused instantly.
|
||||
// When the app is quitting, allow normal close/destroy.
|
||||
|
||||
@@ -3,6 +3,8 @@ const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
buildAppMenu,
|
||||
clampWindowOpacity,
|
||||
applyWindowOpacity,
|
||||
isWindowUsable,
|
||||
registerMainWindow,
|
||||
registerWindowHandlers,
|
||||
@@ -27,6 +29,44 @@ function createWindowStub({ destroyed = false, webContents } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
test("clampWindowOpacity keeps values within 0.5 and 1", () => {
|
||||
assert.equal(clampWindowOpacity(1), 1);
|
||||
assert.equal(clampWindowOpacity(0.85), 0.85);
|
||||
assert.equal(clampWindowOpacity(0.3), 0.5);
|
||||
assert.equal(clampWindowOpacity(1.5), 1);
|
||||
assert.equal(clampWindowOpacity("bad"), 1);
|
||||
});
|
||||
|
||||
test("applyWindowOpacity clamps and applies to registered main windows", () => {
|
||||
const opacityCalls = [];
|
||||
const win = {
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
setOpacity(value) {
|
||||
opacityCalls.push(value);
|
||||
},
|
||||
webContents: {
|
||||
id: 901,
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
};
|
||||
registerMainWindow(win);
|
||||
|
||||
try {
|
||||
assert.equal(applyWindowOpacity(0.85), 0.85);
|
||||
assert.deepEqual(opacityCalls, [0.85]);
|
||||
|
||||
assert.equal(applyWindowOpacity(0.2), 0.5);
|
||||
assert.deepEqual(opacityCalls, [0.85, 0.5]);
|
||||
} finally {
|
||||
unregisterMainWindow(win);
|
||||
applyWindowOpacity(1);
|
||||
}
|
||||
});
|
||||
|
||||
test("isWindowUsable returns false when webContents is crashed", () => {
|
||||
const win = createWindowStub({
|
||||
webContents: {
|
||||
@@ -312,6 +352,7 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
|
||||
setBackgroundColor() {}
|
||||
setOpacity() {}
|
||||
async loadURL() {}
|
||||
close() {}
|
||||
}
|
||||
@@ -358,6 +399,7 @@ test("main window asks renderer to close tabs from macOS Command+W before-input-
|
||||
return true;
|
||||
},
|
||||
shouldCloseWindowFromInput,
|
||||
applyWindowOpacityToWindow() {},
|
||||
closeSettingsWindow() {},
|
||||
hideSettingsWindow() {},
|
||||
});
|
||||
@@ -423,6 +465,7 @@ test("createWindow registers each main window as an independent app window", asy
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
|
||||
setBackgroundColor() {}
|
||||
setOpacity() {}
|
||||
async loadURL() {}
|
||||
close() {
|
||||
this._closedHandler?.();
|
||||
@@ -476,6 +519,7 @@ test("createWindow registers each main window as an independent app window", asy
|
||||
unregisterMainWindow(win) {
|
||||
unregistered.push(win);
|
||||
},
|
||||
applyWindowOpacityToWindow() {},
|
||||
closeSettingsWindow() {},
|
||||
hideSettingsWindow() {},
|
||||
});
|
||||
@@ -540,6 +584,7 @@ test("each main window close saves its own state", async () => {
|
||||
isFullScreen() { return false; }
|
||||
getBounds() { return { x: 0, y: 0, width: 1400, height: 900 }; }
|
||||
setBackgroundColor() {}
|
||||
setOpacity() {}
|
||||
async loadURL() {}
|
||||
close() {}
|
||||
}
|
||||
@@ -591,6 +636,7 @@ test("each main window close saves its own state", async () => {
|
||||
shouldCloseWindowFromInput,
|
||||
registerMainWindow() {},
|
||||
unregisterMainWindow() {},
|
||||
applyWindowOpacityToWindow() {},
|
||||
closeSettingsWindow() {},
|
||||
hideSettingsWindow() {},
|
||||
});
|
||||
@@ -679,6 +725,26 @@ test("window IPC handlers target the sender owner window", async () => {
|
||||
|
||||
assert.equal(titleResult, true);
|
||||
assert.deepEqual(titles, ["Prod SSH"]);
|
||||
|
||||
const opacityCalls = [];
|
||||
registerMainWindow({
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
setOpacity(value) {
|
||||
opacityCalls.push(value);
|
||||
},
|
||||
webContents: {
|
||||
id: 303,
|
||||
isDestroyed() {
|
||||
return false;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const opacityResult = await handlers.get("netcatty:setWindowOpacity")(null, 0.7);
|
||||
assert.equal(opacityResult, true);
|
||||
assert.deepEqual(opacityCalls, [0.7]);
|
||||
});
|
||||
|
||||
test("resolveSettingsWindowBounds centers settings on the requesting window display", () => {
|
||||
|
||||
@@ -303,6 +303,9 @@ function createPreloadApi(ctx) {
|
||||
setBackgroundColor: async (color) => {
|
||||
return ipcRenderer.invoke("netcatty:setBackgroundColor", color);
|
||||
},
|
||||
setWindowOpacity: async (opacity) => {
|
||||
return ipcRenderer.invoke("netcatty:setWindowOpacity", opacity);
|
||||
},
|
||||
setLanguage: async (language) => {
|
||||
return ipcRenderer.invoke("netcatty:setLanguage", language);
|
||||
},
|
||||
|
||||
@@ -450,6 +450,11 @@ body {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
/* Focus-mode terminal list sidebar (split view uses terminal-split-pane). */
|
||||
[data-section="terminal-workspace-sidebar"] {
|
||||
border-right: var(--terminal-workspace-sidebar-border, none);
|
||||
}
|
||||
|
||||
/* Dim terminal text in unfocused workspace panes (default) */
|
||||
.workspace-pane:not(:focus-within) .xterm-screen {
|
||||
opacity: 0.82;
|
||||
|
||||
@@ -119,7 +119,7 @@ export const STORAGE_KEY_MANAGED_SOURCES = 'netcatty_managed_sources_v1';
|
||||
export const STORAGE_KEY_TOGGLE_WINDOW_HOTKEY = 'netcatty_toggle_window_hotkey_v1';
|
||||
export const STORAGE_KEY_CLOSE_TO_TRAY = 'netcatty_close_to_tray_v1';
|
||||
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
|
||||
|
||||
export const STORAGE_KEY_WINDOW_OPACITY = 'netcatty_window_opacity_v1';
|
||||
// Custom Terminal Themes
|
||||
export const STORAGE_KEY_CUSTOM_THEMES = 'netcatty_custom_themes_v1';
|
||||
|
||||
|
||||
1
types/global/netcatty-bridge-sync.d.ts
vendored
1
types/global/netcatty-bridge-sync.d.ts
vendored
@@ -4,6 +4,7 @@ declare global {
|
||||
interface NetcattyBridge {
|
||||
setTheme?(theme: 'light' | 'dark' | 'system'): Promise<boolean>;
|
||||
setBackgroundColor?(color: string): Promise<boolean>;
|
||||
setWindowOpacity?(opacity: number): Promise<boolean>;
|
||||
setLanguage?(language: string): Promise<boolean>;
|
||||
// Window controls for custom title bar (Windows/Linux)
|
||||
windowMinimize?(): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user