mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b3bf7c25fc | ||
|
|
d350b02f78 | ||
|
|
d8f43a7138 | ||
|
|
34c3431483 | ||
|
|
faeaa238e9 | ||
|
|
5be4731c4d | ||
|
|
ebc1ac7598 | ||
|
|
570b211e71 | ||
|
|
e61992a911 | ||
|
|
0e79f2193d | ||
|
|
c27d904f23 | ||
|
|
cd7f182401 | ||
|
|
260b06540f | ||
|
|
57089a3abf | ||
|
|
3c67392455 | ||
|
|
a01563931b | ||
|
|
0ccafbabf7 | ||
|
|
cd0fa39f49 | ||
|
|
c78121888b | ||
|
|
f1be47adc4 | ||
|
|
2638d199f2 | ||
|
|
44b970fc72 | ||
|
|
a03d41ebf5 | ||
|
|
00c4095e59 | ||
|
|
aebe9944ed | ||
|
|
8fdca2baea | ||
|
|
39e4e08d9c | ||
|
|
3ef402cc71 | ||
|
|
ee7b8fae6a | ||
|
|
604e11d239 | ||
|
|
04d71be651 | ||
|
|
5ca36721b5 |
No files matched your search
@@ -130,6 +130,7 @@ Catty Agent にサーバーの健全性チェックを依頼すると、適切
|
||||
- **分割ペイン** — 水平・垂直分割でマルチタスク
|
||||
- **セッション管理** — 複数の接続を並行して扱う
|
||||
- **インライン画像** — リモートプログラムが出力する Kitty グラフィックス、SIXEL、iTerm インライン画像を描画
|
||||
- **アーカイブショートカット** — Linux セッションでは、出力された `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz`、`.zip` の名前を Ctrl/Cmd+クリックすると、アーカイブの絶対パスを含む展開コマンドがカーソル位置に挿入されます(入力済みの文字は残ります)。Enter で実行します。カレントディレクトリからの相対名にはディレクトリ追跡(OSC 7、ツールバーの Configure directory tracking)が必要です。絶対パスは常に対象です。Windows、WSL、macOS のセッションは対象外です。
|
||||
|
||||
### 📁 SFTP + 内蔵エディタ
|
||||
- **ファイル作業** — ドラッグ&ドロップでアップロード/ダウンロード
|
||||
|
||||
@@ -9,6 +9,12 @@
|
||||
<a href="https://netcatty.app"><strong>netcatty.app</strong></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://trendshift.io/repositories/18391?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-18391" target="_blank" rel="noopener noreferrer">
|
||||
<img src="https://trendshift.io/api/badge/trendshift/repositories/18391/daily?language=TypeScript" alt="binaricat/Netcatty | Trendshift" width="250" height="55"/>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
A beautiful, feature-rich SSH workspace built with Electron, React, and xterm.js.<br/>
|
||||
🔥 Built-in AI Agent · Split terminals · Vault views · SFTP workflows · Custom themes — all in one.
|
||||
@@ -87,6 +93,7 @@ Watch Catty Agent orchestrate a Docker Swarm cluster across two servers in one c
|
||||
- [Split Terminals](#split-terminals)
|
||||
- [Supported Distros](#supported-distros)
|
||||
- [Getting Started](#getting-started)
|
||||
- [Open a local terminal from the command line](#open-a-local-terminal-from-the-command-line)
|
||||
- [Build & Package](#build--package)
|
||||
- [Tech Stack](#tech-stack)
|
||||
- [Contributing](#contributing)
|
||||
@@ -132,6 +139,7 @@ If you regularly work with a fleet of servers, Netcatty is built for speed and f
|
||||
- **Session management** — run multiple connections side-by-side
|
||||
- **Save current screen** — right-click a terminal to save the visible final text, without replaying keystrokes or overwritten output; scroll first to save an earlier screen.
|
||||
- **Inline images** — render Kitty graphics, SIXEL and iTerm inline images from remote programs
|
||||
- **Archive shortcuts** — on Linux sessions, Ctrl/Cmd+click a printed `.tar`, `.tar.gz`/`.tgz`, `.tar.bz2`/`.tbz2`, `.tar.xz` or `.zip` name to fill an extraction command with the archive's absolute path into the prompt (kept next to any typed text); press Enter to run it. Names relative to the current directory need directory tracking (OSC 7, see the toolbar's Configure directory tracking); absolute paths always work. Windows, WSL, and macOS sessions are excluded.
|
||||
|
||||
### 📁 SFTP + Built-in Editor
|
||||
- **File workflows** — drag & drop uploads/downloads
|
||||
@@ -241,6 +249,24 @@ application and integration are complete.
|
||||
|
||||
> **macOS Users:** Current releases are expected to be code-signed and notarized. If Gatekeeper still warns, make sure you downloaded the latest official build from GitHub Releases.
|
||||
|
||||
<a name="open-a-local-terminal-from-the-command-line"></a>
|
||||
### Open a local terminal from the command line
|
||||
|
||||
`--open-terminal-path <dir>` opens a local terminal in that folder. `--open-terminal-shell <id-or-path>` selects the shell: a discovered id such as `pwsh`, `cmd`, or `wsl-ubuntu`, or a path to a shell executable. Either flag is enough. A shell alone opens a local tab in the default directory. A path alone uses the configured local shell. If Netcatty is already running, the tab opens in that window.
|
||||
|
||||
Windows needs an extra `--` so Electron forwards the flags:
|
||||
|
||||
```bat
|
||||
Netcatty.exe -- --open-terminal-shell=pwsh --open-terminal-path="D:\\proj"
|
||||
```
|
||||
|
||||
On macOS, start a new instance with `open`, or run the binary directly:
|
||||
|
||||
```bash
|
||||
open -n -a Netcatty --args --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
|
||||
/Applications/Netcatty.app/Contents/MacOS/Netcatty --open-terminal-shell=zsh --open-terminal-path="$HOME/proj"
|
||||
```
|
||||
|
||||
### Scoop (Windows x64, community-maintained)
|
||||
|
||||
With [Scoop](https://scoop.sh/) installed, you can install Netcatty from the community-maintained [lemon bucket](https://github.com/hoilc/scoop-lemon/blob/master/bucket/netcatty.json), which downloads the installer from Netcatty's official GitHub Releases:
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
- **分屏** —— 水平/垂直分割,多任务并行
|
||||
- **多会话管理** —— 多连接并排处理
|
||||
- **内联图片** —— 直接渲染远端程序输出的 Kitty 图形、SIXEL 与 iTerm 内联图片
|
||||
- **压缩包快捷填充** —— 在 Linux 会话里,按住 Ctrl/Cmd 点击输出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 文件名,会在光标处填入带绝对路径的解压命令(保留已经输入的文字);按 Enter 才会执行。相对当前目录的文件名需要目录追踪(OSC 7,见工具栏「配置目录追踪」);绝对路径始终可点。Windows、WSL 与 macOS 会话不在范围内。
|
||||
|
||||
### 📁 SFTP + 内置编辑器
|
||||
- **文件工作流** —— 拖拽上传/下载更直观
|
||||
|
||||
@@ -130,6 +130,7 @@
|
||||
- **分割窗格** —— 水平與垂直分割,方便多工
|
||||
- **工作階段管理** —— 多條連線並排處理
|
||||
- **內嵌圖片** —— 直接顯示遠端程式輸出的 Kitty 圖形、SIXEL 與 iTerm 內嵌圖片
|
||||
- **壓縮檔捷徑** —— 在 Linux 工作階段的終端機裡,按住 Ctrl/Cmd 點選輸出中的 `.tar`、`.tar.gz`/`.tgz`、`.tar.bz2`/`.tbz2`、`.tar.xz` 或 `.zip` 檔名,會在游標處填入含絕對路徑的解壓縮命令(保留已輸入的文字);按 Enter 才會執行。相對於目前目錄的檔名需要目錄追蹤(OSC 7,見工具列「設定目錄追蹤」);絕對路徑一律可點。Windows、WSL 與 macOS 工作階段不支援。
|
||||
|
||||
### 📁 SFTP + 內建編輯器
|
||||
- **檔案工作流** —— 拖放上傳/下載
|
||||
|
||||
@@ -534,9 +534,10 @@ async function captureCtxInheritedCwd(getCtx: AppContextGetter, sessionId: strin
|
||||
const source = sessions?.find((s: { id: string }) => s.id === sessionId);
|
||||
if (!source) return undefined;
|
||||
|
||||
// Freshest cwd: the live OSC 7 value tracked in terminal state (the only
|
||||
// source that reflects `cd`s in a running local terminal — lastCwd is a
|
||||
// startup snapshot). Falls through to the SSH probe / lastCwd when absent.
|
||||
// Freshest cwd: the live value tracked in terminal state (OSC 7 or the
|
||||
// post-command probe, including local shells). lastCwd on the session object
|
||||
// is only the startup snapshot. Falls through to the SSH probe / lastCwd
|
||||
// when absent.
|
||||
const liveCwd: string | undefined = getSessionRestoreCwd?.(sessionId);
|
||||
|
||||
const bridge = netcattyBridge?.get?.();
|
||||
|
||||
@@ -15,4 +15,14 @@ test("open-terminal requests wait behind App Lock and resume after unlock", () =
|
||||
assert.match(handlerSource, /shouldDeferExternalActionWhileAppLocked/);
|
||||
assert.match(handlerSource, /kind: 'open-terminal-path'/);
|
||||
assert.match(drainSource, /_processOpenTerminalPath\(item\.payload\)/);
|
||||
|
||||
const processStart = source.indexOf("const _processOpenTerminalPath");
|
||||
const processSource = source.slice(processStart, handlerStart);
|
||||
assert.match(processSource, /handleCreateLocalTerminal\(undefined, \{ localStartDir \}\)/);
|
||||
assert.match(processSource, /resolveShellSetting\(shellSetting, shells\)/);
|
||||
assert.match(source, /const openTerminalQueueRef = useRef\(createOpenTerminalQueue\(\)\)/);
|
||||
const enqueueAt = processSource.indexOf("openTerminalQueueRef.current.enqueue(() => {");
|
||||
const createAt = processSource.indexOf("handleCreateLocalTerminal");
|
||||
assert.ok(enqueueAt >= 0 && createAt > enqueueAt);
|
||||
assert.match(processSource, /await ensureDiscoveredShells\(\)/);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
|
||||
const source = readFileSync(new URL("./AppSideEffects.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("handleConnectToHost reads temporary connection settings at call time", () => {
|
||||
assert.match(source, /const temporaryConnectionSettingsRef = useRef\(terminalSettings\)/);
|
||||
assert.match(source, /temporaryConnectionSettingsRef\.current = terminalSettings/);
|
||||
|
||||
const handlerStart = source.indexOf("const handleConnectToHost = useCallback");
|
||||
const handlerEnd = source.indexOf("const handleConnectToHostWithTabName");
|
||||
assert.ok(handlerStart >= 0 && handlerEnd > handlerStart);
|
||||
const handler = source.slice(handlerStart, handlerEnd);
|
||||
|
||||
assert.match(handler, /temporaryConnectionSettingsRef\.current/);
|
||||
assert.match(handler, /temporaryConnectionPrepareOptions\(liveTerminalSettings\)/);
|
||||
assert.match(handler, /terminalSettings: liveTerminalSettings/);
|
||||
assert.doesNotMatch(handler, /terminalSettings\.temporaryConnections/);
|
||||
assert.doesNotMatch(handler, /\[addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings\]/);
|
||||
});
|
||||
@@ -47,6 +47,7 @@ import { buildSshDeepLinkConnectionHost, buildSshDeepLinkEphemeralHost, buildSsh
|
||||
import { buildTelnetDeepLinkConnectionHost, buildTelnetDeepLinkEphemeralHostFromSaved, buildTelnetDeepLinkOpenHost, findTelnetDeepLinkHost, materializeTelnetDeepLinkMatchHost, parseTelnetDeepLink } from '../../domain/telnetDeepLink';
|
||||
import { buildJmsDeepLinkEphemeralHost, isSupportedJmsProtocol, parseJmsDeepLink } from '../../domain/jmsDeepLink';
|
||||
import { applyEphemeralHostDistroUpdate, applyEphemeralHostsUpdate, splitHostsUpdateByEphemeral } from '../../domain/ephemeralHosts';
|
||||
import { prepareConnectionHost, temporaryConnectionPrepareOptions } from '../../domain/legacyAlgorithms';
|
||||
import { resolveHostAuth } from '../../domain/sshAuth';
|
||||
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
|
||||
import {
|
||||
@@ -94,6 +95,7 @@ import { resolveSnippetCommand } from '../../components/SnippetExecutionProvider
|
||||
import { isScriptSnippet } from '../../domain/snippetScript.ts';
|
||||
import { collectSnippetDeleteIds } from '../../domain/snippetSelection.ts';
|
||||
import { shouldOpenLocalTerminalOnStartup, resolveStartupLandingSetting } from '../../domain/startupLanding';
|
||||
import { createOpenTerminalQueue } from './openTerminalQueue';
|
||||
import { useAppStartupEffects } from './useAppStartupEffects';
|
||||
import { handleTrayJumpToSessionImpl, handleTrayTogglePortForwardImpl, handleTrayPanelConnectImpl, handleTrayPanelConnectRequestImpl, flushQueuedTrayPanelConnectHostsImpl, handleGlobalHotkeyKeyDownImpl, handleEscapeKeyDownImpl, handleKeyboardInteractiveSubmitImpl, handleKeyboardInteractiveCancelImpl, handlePassphraseSubmitImpl, handlePassphraseCancelImpl, handlePassphraseSkipImpl, handleFidoPromptSubmitImpl, handleFidoPromptCancelImpl, createLocalTerminalWithCurrentShellImpl, splitSessionWithCurrentShellImpl, copySessionWithCurrentShellImpl, duplicateSessionWithCurrentShellImpl, copyWorkspaceWithCurrentShellImpl, copySessionToNewWindowWithCurrentShellImpl, confirmIfBusyLocalTerminalImpl, closeTabsBatchImpl, executeHotkeyActionImpl, handleCreateLocalTerminalImpl, handleConnectToHostImpl, handleTerminalDataCaptureImpl, hasMultipleProtocolsImpl, handleHostConnectWithProtocolCheckImpl, handleProtocolSelectImpl, handleRootContextMenuImpl, markForwardedNativeShortcutEvent } from './AppHandlers';
|
||||
|
||||
@@ -1447,13 +1449,24 @@ export function AppSideEffects() {
|
||||
name,
|
||||
), [createWorkspaceFromTargets, resolveEffectiveHost]);
|
||||
|
||||
// VaultView memo ignores onConnect identity, so this callback can stay
|
||||
// closed over an older render. Temporary-connection toggles are read here,
|
||||
// at connect time, so turning them on or off applies without a reload.
|
||||
const temporaryConnectionSettingsRef = useRef(terminalSettings);
|
||||
temporaryConnectionSettingsRef.current = terminalSettings;
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host, alreadyEffective = false, hidden = false) => {
|
||||
if (host.ephemeral) {
|
||||
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
|
||||
const connectionHost = prepareConnectionHost(
|
||||
host,
|
||||
temporaryConnectionPrepareOptions(liveTerminalSettings),
|
||||
);
|
||||
if (connectionHost.ephemeral) {
|
||||
setEphemeralHosts((previous) => {
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === host.id);
|
||||
if (existingIndex < 0) return [...previous, host];
|
||||
return previous.map((candidate, index) => index === existingIndex ? host : candidate);
|
||||
const existingIndex = previous.findIndex((candidate) => candidate.id === connectionHost.id);
|
||||
if (existingIndex < 0) return [...previous, connectionHost];
|
||||
return previous.map((candidate, index) => index === existingIndex ? connectionHost : candidate);
|
||||
});
|
||||
}
|
||||
const effectiveHostResolver = alreadyEffective
|
||||
@@ -1463,16 +1476,16 @@ export function AppSideEffects() {
|
||||
addConnectionLog,
|
||||
connectToHost,
|
||||
discoveredShells,
|
||||
host,
|
||||
host: connectionHost,
|
||||
identities,
|
||||
keys,
|
||||
resolveEffectiveHost: effectiveHostResolver,
|
||||
resolveHostAuth,
|
||||
resolveShellSetting,
|
||||
systemInfoRef,
|
||||
terminalSettings,
|
||||
}), host, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
|
||||
terminalSettings: liveTerminalSettings,
|
||||
}), connectionHost, hidden);
|
||||
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
|
||||
|
||||
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
|
||||
const sessionId = handleConnectToHost(host);
|
||||
@@ -1557,7 +1570,7 @@ export function AppSideEffects() {
|
||||
| { kind: 'ssh'; payload: DeepLinkPayload }
|
||||
| { kind: 'telnet'; payload: DeepLinkPayload }
|
||||
| { kind: 'jms'; payload: { url?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string } }
|
||||
| { kind: 'open-terminal-path'; payload: { path?: string; shell?: string } }
|
||||
>>([]);
|
||||
|
||||
const _processSshDeepLink = useEffectEvent((payload: DeepLinkPayload) => {
|
||||
@@ -1721,14 +1734,44 @@ export function AppSideEffects() {
|
||||
});
|
||||
}, [isPeerSessionWindow]);
|
||||
|
||||
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
// Shell discovery is part of this queue. A later path-only request, including
|
||||
// one drained after App Lock, cannot create its tab ahead of an in-flight shell.
|
||||
const openTerminalQueueRef = useRef(createOpenTerminalQueue());
|
||||
|
||||
const _processOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
|
||||
startupLaunchIntentReceivedRef.current = true;
|
||||
const localStartDir = typeof payload?.path === 'string' ? payload.path : '';
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
const shellSetting = typeof payload?.shell === 'string' ? payload.shell.trim() : '';
|
||||
void openTerminalQueueRef.current.enqueue(() => {
|
||||
if (!shellSetting) {
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
return;
|
||||
}
|
||||
|
||||
return (async () => {
|
||||
const shells = await ensureDiscoveredShells();
|
||||
const resolved = resolveShellSetting(shellSetting, shells);
|
||||
if (!resolved?.command) {
|
||||
if (!localStartDir.trim()) return;
|
||||
handleCreateLocalTerminal(undefined, { localStartDir });
|
||||
return;
|
||||
}
|
||||
const matchedShell = shells.find((shell) => shell.id === shellSetting);
|
||||
handleCreateLocalTerminal(
|
||||
{
|
||||
command: resolved.command,
|
||||
args: resolved.args,
|
||||
name: matchedShell?.name,
|
||||
icon: matchedShell?.icon,
|
||||
},
|
||||
localStartDir.trim() ? { localStartDir } : undefined,
|
||||
);
|
||||
})();
|
||||
});
|
||||
});
|
||||
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string }) => {
|
||||
const _handleOpenTerminalPath = useEffectEvent((payload: { path?: string; shell?: string }) => {
|
||||
if (shouldDeferExternalActionWhileAppLocked({ locked: appLockLocked })) {
|
||||
pendingDeepLinksWhileLockedRef.current.push({
|
||||
kind: 'open-terminal-path',
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Input } from '../../components/ui/input';
|
||||
import { Label } from '../../components/ui/label';
|
||||
import { LazyLoadBoundary } from '../../components/ui/lazy-load-boundary';
|
||||
import { toast } from '../../components/ui/toast';
|
||||
import { openQuickConnectFromInput } from '../quickConnectBridge';
|
||||
import { AppHostTreeLayer } from './AppHostTreeLayer';
|
||||
import { AppHostEditorLayer } from './AppHostEditorLayer';
|
||||
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
|
||||
@@ -376,6 +377,14 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
appendHostToWorkspace(workspaceId, resolveWorkspaceAppendHost(host), rootDir);
|
||||
}, [appendHostToWorkspace, hosts, resolveWorkspaceAppendHost, workspaces]);
|
||||
|
||||
const pendingKeyboardRequest = keyboardInteractiveQueue[0] || null;
|
||||
const pendingTotpUri = pendingKeyboardRequest?.totpUri;
|
||||
const handleFillTotp = useCallback(async () => {
|
||||
if (!pendingTotpUri) return null;
|
||||
const result = await netcattyBridge.get()?.previewTotp?.({ uri: pendingTotpUri });
|
||||
return result?.ok && result.code ? result.code : null;
|
||||
}, [pendingTotpUri]);
|
||||
|
||||
const isPeerSessionWindow = typeof window !== 'undefined'
|
||||
&& window.location.hash.startsWith('#/session-window');
|
||||
const externalMcpToggle = useExternalMcpToggleState();
|
||||
@@ -930,6 +939,7 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
showSftpTab={showSftpTab}
|
||||
onQueryChange={setQuickSearch}
|
||||
onSelect={handleHostConnectWithProtocolCheck}
|
||||
onQuickConnect={openQuickConnectFromInput}
|
||||
onEditHost={(host) => {
|
||||
setIsQuickSwitcherOpen(false);
|
||||
setQuickSearch('');
|
||||
@@ -1022,6 +1032,8 @@ function AppViewInner({ domains }: AppViewProps) {
|
||||
request={keyboardInteractiveQueue[0] || null}
|
||||
onSubmit={handleKeyboardInteractiveSubmit}
|
||||
onCancel={handleKeyboardInteractiveCancel}
|
||||
canFillTotp={Boolean(pendingTotpUri)}
|
||||
onFillTotp={handleFillTotp}
|
||||
/>
|
||||
{/* Indicator when more 2FA requests are pending */}
|
||||
{keyboardInteractiveQueue.length > 1 && (
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createOpenTerminalQueue } from './openTerminalQueue';
|
||||
|
||||
test('idle open-terminal work stays in call order on the caller turn', () => {
|
||||
const queue = createOpenTerminalQueue();
|
||||
const order: string[] = [];
|
||||
|
||||
queue.enqueue(() => { order.push('path-a'); });
|
||||
queue.enqueue(() => { order.push('path-b'); });
|
||||
|
||||
assert.deepEqual(order, ['path-a', 'path-b']);
|
||||
});
|
||||
|
||||
test('a path-only open waits for an in-flight shell discovery', async () => {
|
||||
const queue = createOpenTerminalQueue();
|
||||
const order: string[] = [];
|
||||
let releaseDiscovery: () => void = () => {};
|
||||
const discovery = new Promise<void>((resolve) => {
|
||||
releaseDiscovery = resolve;
|
||||
});
|
||||
|
||||
const shellRequest = queue.enqueue(async () => {
|
||||
order.push('shell');
|
||||
await discovery;
|
||||
order.push('shell-tab');
|
||||
});
|
||||
const pathRequest = queue.enqueue(() => {
|
||||
order.push('path-tab');
|
||||
});
|
||||
|
||||
assert.deepEqual(order, ['shell']);
|
||||
releaseDiscovery();
|
||||
await shellRequest;
|
||||
await pathRequest;
|
||||
assert.deepEqual(order, ['shell', 'shell-tab', 'path-tab']);
|
||||
});
|
||||
|
||||
test('a failed open-terminal request does not block the next one', async () => {
|
||||
const queue = createOpenTerminalQueue();
|
||||
const order: string[] = [];
|
||||
|
||||
const failed = queue.enqueue(async () => {
|
||||
order.push('shell');
|
||||
throw new Error('discovery failed');
|
||||
});
|
||||
const next = queue.enqueue(() => {
|
||||
order.push('path-tab');
|
||||
});
|
||||
|
||||
await assert.rejects(failed, /discovery failed/);
|
||||
await next;
|
||||
assert.deepEqual(order, ['shell', 'path-tab']);
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
type OpenTerminalTask = () => Promise<void> | void;
|
||||
|
||||
function isPromise(value: Promise<void> | void): value is Promise<void> {
|
||||
return Boolean(value) && typeof (value as Promise<void>).then === 'function';
|
||||
}
|
||||
|
||||
/**
|
||||
* Ordered launcher queue. An idle queue runs the task in the caller turn so a
|
||||
* path-only open still creates its tab synchronously. A task that awaits (shell
|
||||
* discovery) holds the tail, and later requests cannot pass it.
|
||||
*/
|
||||
export function createOpenTerminalQueue() {
|
||||
let tail: Promise<void> = Promise.resolve();
|
||||
let depth = 0;
|
||||
|
||||
return {
|
||||
enqueue(task: OpenTerminalTask): Promise<void> {
|
||||
depth += 1;
|
||||
let release: () => void;
|
||||
const finished = new Promise<void>((resolve) => {
|
||||
release = () => {
|
||||
depth -= 1;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
const previous = tail;
|
||||
tail = finished;
|
||||
|
||||
const run = (): Promise<void> => {
|
||||
try {
|
||||
const result = task();
|
||||
if (isPromise(result)) {
|
||||
return result.then(release, (error: unknown) => {
|
||||
release();
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
release();
|
||||
return Promise.resolve();
|
||||
} catch (error) {
|
||||
release();
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
if (depth === 1) return run();
|
||||
return previous.then(run, run);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -554,6 +554,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
hostname: request.hostname,
|
||||
savedPassword: request.savedPassword,
|
||||
allowSavePassword: request.allowSavePassword !== false,
|
||||
...(typeof request.totpUri === "string" && request.totpUri && !request.totpUri.startsWith("enc:v1:")
|
||||
? { totpUri: request.totpUri }
|
||||
: {}),
|
||||
}]);
|
||||
});
|
||||
const unsubscribeCancelled = bridge.onKeyboardInteractiveCancelled?.((event) => {
|
||||
|
||||
@@ -289,6 +289,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': 'e.g. /usr/local/bin/opencode',
|
||||
'ai.opencode.check': 'Check',
|
||||
'ai.opencode.resetPath': 'Reset',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 did not become ready in time. Confirm `opencode serve` works in a terminal, then retry.',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 stopped before the chat could start.',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 refused the connection. Retry the chat.',
|
||||
'ai.opencode.v2.unavailable': 'OpenCode 2 could not be reached. Check that it is installed and try again.',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 could not finish this turn.',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 returned an empty response. Run `opencode` in a terminal to configure authentication and models.',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 could not apply the Netcatty conversation context.',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 could not attach an MCP tool. The chat will continue without it.',
|
||||
|
||||
// AI MiMo Code (Xiaomi MiMo Code CLI — OpenCode fork)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -376,6 +384,54 @@ export const enAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'External MCP help',
|
||||
'ai.externalMcp.security': 'Security',
|
||||
'ai.externalMcp.security.description': 'Listens on 127.0.0.1 with a rotating token, reuses AI Permission Mode for writes, and removes discovery when disabled. No OAuth — this is a local desktop bridge.',
|
||||
"ai.privacy.title": "AI privacy",
|
||||
"ai.privacy.description": "Filters known sensitive text patterns in Netcatty-managed context and tool results for Catty, integrated agents and external MCP clients. Off by default. It cannot detect every secret. Binary tool responses are blocked while enabled.",
|
||||
"ai.privacy.boundary": "Only Netcatty-managed data and tools are covered. Third-party agents' native tools, direct file access and other MCP servers remain outside this boundary.",
|
||||
"ai.privacy.enable": "Enable text redaction",
|
||||
"ai.privacy.privateIps": "Redact private IP addresses",
|
||||
"ai.privacy.privateIps.description": "Also hide private and local IP addresses in returned text.",
|
||||
"ai.privacy.cidrs": "Additional IP ranges (CIDR)",
|
||||
"ai.privacy.cidrs.description": "One IPv4 or IPv6 CIDR per line. Leave empty to use only built-in rules.",
|
||||
"ai.privacy.patterns": "Custom regular expressions",
|
||||
"ai.privacy.patterns.description": "One RE2-compatible pattern per line, without / delimiters or flags. Unsupported or unsafe patterns are rejected.",
|
||||
"ai.privacy.connectionScope": "Placeholders stay stable within one managed conversation or external MCP connection. Original values are restored only through Netcatty-managed tool calls in that same scope.",
|
||||
"ai.privacy.reconnect": "Apply changes, then start a new managed conversation and reconnect external MCP clients. Existing conversations and connections must not reuse placeholders.",
|
||||
"ai.privacy.apply": "Apply",
|
||||
"ai.privacy.applying": "Applying...",
|
||||
"ai.privacy.applied": "AI privacy settings applied. Start a new managed conversation and reconnect external MCP clients.",
|
||||
"ai.privacy.failed": "AI privacy settings were not applied.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Serve the same External MCP tools over Streamable HTTP for clients that cannot launch the stdio CLI, such as Codex inside WSL. Off by default.',
|
||||
'ai.externalMcp.http.enable': 'Enable HTTP',
|
||||
'ai.externalMcp.http.port': 'Port',
|
||||
'ai.externalMcp.http.portHint': 'Fixed port, default 47391. Client URLs stay the same across restarts. Allowed range: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Listen on another address',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'By default the endpoint binds to 127.0.0.1. Turn this on for WSL2 NAT and set 0.0.0.0 or the vEthernet IP.',
|
||||
'ai.externalMcp.http.listenAddress': 'Listen address',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Client address',
|
||||
'ai.externalMcp.http.clientHost.description': 'Used in copied configs when the listener is 0.0.0.0 or ::. Enter the IP clients can reach, such as the Windows host IP.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Enter the IP clients should connect to. 0.0.0.0 is not a reachable address.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'Client address must be a reachable IP, not 0.0.0.0 or ::.',
|
||||
'ai.externalMcp.http.warning': 'Listening beyond 127.0.0.1 exposes External MCP to other machines that can reach this address. Keep the bearer token private and do not publish this port.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Regenerate',
|
||||
'ai.externalMcp.http.regenerateHint': 'Issues a new bearer token. HTTP clients must use the new token. The stdio discovery file is updated; clients that are already connected over stdio stay connected.',
|
||||
'ai.externalMcp.http.wslHint': 'With WSL mirrored networking, localhost works. Otherwise listen on 0.0.0.0 or the vEthernet IP and set Client address to the Windows host IP.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'Generic JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token env',
|
||||
'ai.externalMcp.http.notListening': 'Not listening',
|
||||
'ai.externalMcp.http.listening': 'Listening',
|
||||
'ai.externalMcp.http.portInUse': 'Port {port} is already in use.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Turn on External MCP before this endpoint will listen.',
|
||||
'ai.externalMcp.http.invalidPort': 'Enter a port from 1024 to 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'Listen address must be an IP, such as 0.0.0.0 or the vEthernet IP.',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP failed to listen.',
|
||||
'ai.externalMcp.http.legacySseNote': 'Legacy HTTP+SSE (2024-11-05) is not offered. Use this Streamable HTTP URL.',
|
||||
'ai.externalMcp.permissionMode': 'Current permission mode: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Write permission mode',
|
||||
'ai.externalMcp.permissionMode.hint': 'Same setting as Settings → AI → Safety. Auto runs Netcatty write tools without Netcatty approval prompts; Confirm asks each time. External clients (Codex / Claude / Grok) may still show their own tool approval UI.',
|
||||
|
||||
@@ -836,6 +836,10 @@ Highlight the focused split pane:
|
||||
'settings.terminal.section.connection': 'Connection',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verify SSH host keys',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Ask before connecting to a new or changed SSH host key. Turn this off only for trusted private networks.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Allow legacy algorithms for temporary connections',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH connections with no saved host, including PuTTY-style launches and quick connect, offer the same deprecated algorithms as the per-host option. Saved hosts keep their own setting. Off by default; these algorithms have known weaknesses.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Network device mode for temporary connections',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH connections with no saved host, including external launches and quick connect, start in network device mode. Saved hosts and groups keep their own device type. Off by default.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Automatically reconnect SSH sessions',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'When turned on, established SSH sessions that drop unexpectedly try to reconnect every 5 seconds until the tab is closed or the connection succeeds.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Keepalive Interval',
|
||||
@@ -845,6 +849,9 @@ Highlight the focused split pane:
|
||||
'settings.terminal.connection.x11Display': 'X11 display',
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Default proxy',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Saved proxy used when a host has no proxy of its own and does not inherit one from a group. None connects directly.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'None',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Show host information bar',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
|
||||
@@ -857,8 +864,8 @@ Highlight the focused split pane:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'How often to refresh the process list in the system manager side panel.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux session refresh',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'How often to refresh the tmux session list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh the Docker container list.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Container list refresh',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'How often to refresh container and pod lists.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker stats refresh',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'How often to refresh Docker container CPU/memory/network stats.',
|
||||
'settings.terminal.serverStats.show': 'Show Server Stats',
|
||||
@@ -1173,6 +1180,7 @@ Highlight the focused split pane:
|
||||
'vault.hosts.moveSelected': 'Move to group ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Move the selected hosts to an existing group, or leave them ungrouped. Selected groups are not moved.',
|
||||
'vault.hosts.moveMultiple.success': 'Moved {count} hosts to {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Moved {host} to {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Host name is required.',
|
||||
'vault.hosts.empty.title': 'Set up your hosts',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Failed to load containers',
|
||||
'systemManager.errors.loadDockerStats': 'Failed to load container stats',
|
||||
'systemManager.errors.loadDockerImages': 'Failed to load images',
|
||||
'systemManager.errors.loadKubernetes': 'Failed to load Kubernetes resources',
|
||||
'systemManager.errors.loadPods': 'Failed to load pods',
|
||||
'systemManager.errors.nerdctlUnreachable': "nerdctl can't reach containerd",
|
||||
'systemManager.errors.clusterUnreachable': "Can't reach the cluster for context {{context}}",
|
||||
'systemManager.errors.clusterUnreachableGeneric': "Can't reach the cluster",
|
||||
'systemManager.errors.loadOverview': 'Failed to load system overview',
|
||||
'systemManager.errors.loadGpu': 'Failed to load GPU / NPU stats',
|
||||
'systemManager.errors.loadPorts': 'Failed to load listening ports',
|
||||
@@ -132,7 +137,7 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Window name (optional)',
|
||||
'systemManager.tmux.noWindows': 'No windows',
|
||||
'systemManager.tmux.unavailable': 'tmux is not available on this host',
|
||||
'systemManager.docker.unavailable': 'Docker is not available on this host',
|
||||
'systemManager.docker.unavailable': 'No container runtime (Docker, Podman, nerdctl, or kubectl) was detected on this host',
|
||||
'systemManager.tmux.windowsMismatch': 'Session reports {{count}} window(s) but list-windows returned none',
|
||||
'systemManager.tmux.lastCommand': 'last command: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No panes',
|
||||
@@ -194,6 +199,36 @@ export const enSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} image(s)',
|
||||
'systemManager.docker.start': 'Start',
|
||||
'systemManager.docker.stop': 'Stop',
|
||||
'systemManager.runtime.switch': 'Container runtime',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Context',
|
||||
'systemManager.k8s.namespace': 'Namespace',
|
||||
'systemManager.k8s.empty': 'No pods found',
|
||||
'systemManager.k8s.search': 'Search pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describe',
|
||||
'systemManager.k8s.delete': 'Delete pod',
|
||||
'systemManager.k8s.confirmDelete': 'Delete pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} restarts',
|
||||
'systemManager.k8s.chooseContainer': 'Choose a container',
|
||||
'systemManager.k8s.noContext': 'No Kubernetes context on this host',
|
||||
'systemManager.k8s.status.Running': 'Running',
|
||||
'systemManager.k8s.status.Pending': 'Pending',
|
||||
'systemManager.k8s.status.Completed': 'Completed',
|
||||
'systemManager.k8s.status.Succeeded': 'Succeeded',
|
||||
'systemManager.k8s.status.Failed': 'Failed',
|
||||
'systemManager.k8s.status.Unknown': 'Unknown',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': 'CrashLoopBackOff',
|
||||
'systemManager.k8s.status.ContainerCreating': 'ContainerCreating',
|
||||
'systemManager.k8s.status.Terminating': 'Terminating',
|
||||
'systemManager.k8s.status.ImagePullBackOff': 'ImagePullBackOff',
|
||||
'systemManager.k8s.status.ErrImagePull': 'ErrImagePull',
|
||||
'systemManager.k8s.status.Error': 'Error',
|
||||
'systemManager.k8s.status.Evicted': 'Evicted',
|
||||
'systemManager.k8s.status.OOMKilled': 'OOMKilled',
|
||||
|
||||
'systemManager.ports.unavailable': 'No listening-port tools (ss / netstat) detected on this host.',
|
||||
'systemManager.ports.loading': 'Loading listening ports…',
|
||||
|
||||
@@ -109,6 +109,8 @@ export const enTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU Usage',
|
||||
'terminal.serverStats.cpuCores': 'CPU Core Usage',
|
||||
'terminal.serverStats.gpu': 'GPU Usage',
|
||||
'terminal.serverStats.gpuDevices': 'GPU Device Usage',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Memory Usage',
|
||||
'terminal.serverStats.memoryDetails': 'Memory Details',
|
||||
'terminal.serverStats.memUsed': 'Used',
|
||||
@@ -910,11 +912,13 @@ export const enTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'The server requires additional authentication.',
|
||||
'keyboard.interactive.descWithHost': 'The server {hostname} requires additional authentication.',
|
||||
'keyboard.interactive.response': 'Response',
|
||||
'keyboard.interactive.verificationCode': 'Verification code',
|
||||
'keyboard.interactive.enterCode': 'Enter verification code',
|
||||
'keyboard.interactive.enterResponse': 'Enter response',
|
||||
'keyboard.interactive.submit': 'Submit',
|
||||
'keyboard.interactive.verifying': 'Verifying...',
|
||||
'keyboard.interactive.savePassword': 'Save password',
|
||||
'keyboard.interactive.fillTotp': 'Fill verification code',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH Key Passphrase',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const enVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Search hosts or tabs',
|
||||
'qs.quickConnectTo': 'Quick connect to {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Tabs',
|
||||
'qs.newWorkspace': 'New Workspace',
|
||||
'qs.jumpTo': 'Jump To',
|
||||
'qs.localTerminal': 'Local Terminal',
|
||||
'qs.localShells': 'Local Shells',
|
||||
@@ -876,6 +880,35 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Use the selected SSH certificate, with the saved password as a fallback when present.',
|
||||
'hostDetails.auth.mfaFirst': 'Prefer interactive auth',
|
||||
'hostDetails.auth.mfaFirst.desc': 'For servers that require password and MFA through keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Authenticator (TOTP)',
|
||||
'hostDetails.totp.enable': 'Save a TOTP secret for this host',
|
||||
'hostDetails.totp.setup': 'Set up…',
|
||||
'hostDetails.totp.edit': 'Edit',
|
||||
'hostDetails.totp.modalTitle': 'Set up authenticator',
|
||||
'hostDetails.totp.replacePlaceholder': 'Paste a new secret to replace the current one',
|
||||
'hostDetails.totp.warning': 'Anyone who can open this vault gets this code and the password. Use it only on a personal device.',
|
||||
'hostDetails.totp.secret': 'Secret or otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': 'Paste otpauth://totp/... or a Base32 secret',
|
||||
'hostDetails.totp.importQr': 'Import QR image',
|
||||
'hostDetails.totp.scanScreen': 'Scan QR code from screen',
|
||||
'hostDetails.totp.scanning': 'Scanning screens…',
|
||||
'hostDetails.totp.importedAccount': 'Imported {account}',
|
||||
'hostDetails.totp.preview': 'Current code',
|
||||
'hostDetails.totp.countdown': '{seconds}s remaining',
|
||||
'hostDetails.totp.autoSubmit': 'Auto-submit on known TOTP prompts',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Submits once per connection, and only when the prompt exactly matches JumpServer, Google Authenticator PAM, or your pattern.',
|
||||
'hostDetails.totp.promptPattern': 'Custom prompt pattern',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Optional RE2 regular expression',
|
||||
'hostDetails.totp.clear': 'Remove authenticator',
|
||||
'hostDetails.totp.removeTitle': 'Remove authenticator?',
|
||||
'hostDetails.totp.removeMessage': 'This host will no longer store this TOTP secret.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP is not supported. Use a TOTP secret.',
|
||||
'hostDetails.totp.error.migration': 'otpauth-migration QR codes are not supported. Use the service binding QR code for a single account.',
|
||||
'hostDetails.totp.error.invalid': 'Could not read a TOTP secret from that value.',
|
||||
'hostDetails.totp.error.notFound': 'No TOTP QR code was found.',
|
||||
'hostDetails.totp.error.permission': 'Screen recording permission is required. Allow Netcatty to record the screen in System Settings, then try again.',
|
||||
'hostDetails.totp.error.unavailable': 'Screen capture is not available.',
|
||||
'hostDetails.totp.error.pattern': 'The custom prompt pattern is not a valid RE2 regular expression.',
|
||||
'hostDetails.auth.credentialRequired': 'Select a key or certificate before saving this login method.',
|
||||
'hostDetails.identity.suggestions': 'Identities',
|
||||
'hostDetails.identity.missing': 'Identity not found',
|
||||
@@ -949,6 +982,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.direct': 'Direct (no proxy)',
|
||||
'hostDetails.proxy.directHint': 'Does not use a group or global default proxy',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
|
||||
|
||||
@@ -367,6 +367,54 @@ export const esAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Ayuda del MCP externo',
|
||||
'ai.externalMcp.security': 'Seguridad',
|
||||
'ai.externalMcp.security.description': 'Escucha en 127.0.0.1 con un token rotativo, reutiliza el modo de permisos de IA para las escrituras y elimina el descubrimiento al desactivarse. Sin OAuth: este es un puente local de escritorio.',
|
||||
"ai.privacy.title": "Privacidad de IA",
|
||||
"ai.privacy.description": "Filtra patrones de texto sensibles conocidos en el contexto y los resultados de herramientas gestionados por Netcatty para Catty, agentes integrados y clientes MCP externos. Desactivado de forma predeterminada. No detecta todos los secretos. Las respuestas binarias de herramientas se bloquean mientras está activo.",
|
||||
"ai.privacy.boundary": "Solo cubre los datos y las herramientas gestionados por Netcatty. Las herramientas nativas de agentes de terceros, su acceso directo a archivos y otros servidores MCP quedan fuera de esta protección.",
|
||||
"ai.privacy.enable": "Activar ocultación de texto",
|
||||
"ai.privacy.privateIps": "Ocultar direcciones IP privadas",
|
||||
"ai.privacy.privateIps.description": "Oculta también las direcciones IP privadas y locales en el texto devuelto.",
|
||||
"ai.privacy.cidrs": "Rangos IP adicionales (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Un CIDR IPv4 o IPv6 por línea. Déjalo vacío para usar solo las reglas integradas.",
|
||||
"ai.privacy.patterns": "Expresiones regulares personalizadas",
|
||||
"ai.privacy.patterns.description": "Un patrón compatible con RE2 por línea, sin delimitadores / ni indicadores. Los patrones incompatibles o inseguros se rechazan.",
|
||||
"ai.privacy.connectionScope": "Los marcadores son estables dentro de una conversación gestionada o una conexión MCP externa. Los valores originales solo se restauran mediante llamadas a herramientas gestionadas por Netcatty dentro del mismo ámbito.",
|
||||
"ai.privacy.reconnect": "Aplica los cambios, inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos. Las conversaciones y conexiones existentes no deben reutilizar marcadores.",
|
||||
"ai.privacy.apply": "Aplicar",
|
||||
"ai.privacy.applying": "Aplicando...",
|
||||
"ai.privacy.applied": "Configuración de privacidad de IA aplicada. Inicia una nueva conversación gestionada y vuelve a conectar los clientes MCP externos.",
|
||||
"ai.privacy.failed": "No se aplicó la configuración de privacidad de IA.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Expone las mismas herramientas de MCP externo por Streamable HTTP para clientes que no pueden iniciar el CLI stdio, como Codex dentro de WSL. Desactivado de forma predeterminada.',
|
||||
'ai.externalMcp.http.enable': 'Activar HTTP',
|
||||
'ai.externalMcp.http.port': 'Puerto',
|
||||
'ai.externalMcp.http.portHint': 'Puerto fijo, predeterminado 47391. Las URL de los clientes se mantienen al reiniciar. Rango permitido: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Escuchar en otra dirección',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'De forma predeterminada el extremo se enlaza a 127.0.0.1. Actívalo para NAT de WSL2 y usa 0.0.0.0 o la IP de vEthernet.',
|
||||
'ai.externalMcp.http.listenAddress': 'Dirección de escucha',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Dirección del cliente',
|
||||
'ai.externalMcp.http.clientHost.description': 'Se usa en las configuraciones copiadas cuando la escucha es 0.0.0.0 o ::. Introduce una IP alcanzable, como la IP del host de Windows.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Introduce la IP a la que deben conectarse los clientes. 0.0.0.0 no es una dirección alcanzable.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'La dirección del cliente debe ser una IP alcanzable, no 0.0.0.0 ni ::.',
|
||||
'ai.externalMcp.http.warning': 'Escuchar fuera de 127.0.0.1 expone el MCP externo a otros equipos que puedan alcanzar esta dirección. Mantén el bearer token en privado y no publiques este puerto.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Regenerar',
|
||||
'ai.externalMcp.http.regenerateHint': 'Emite un bearer token nuevo. Los clientes HTTP deben usar el token nuevo. El archivo de descubrimiento stdio se actualiza; los clientes stdio ya conectados siguen conectados.',
|
||||
'ai.externalMcp.http.wslHint': 'Con la red reflejada de WSL, localhost funciona. Si no, escucha en 0.0.0.0 o la IP de vEthernet y pon la IP del host de Windows en Dirección del cliente.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'JSON genérico',
|
||||
'ai.externalMcp.http.envLine': 'Variable de entorno del token de Codex',
|
||||
'ai.externalMcp.http.notListening': 'Sin escucha',
|
||||
'ai.externalMcp.http.listening': 'Escuchando',
|
||||
'ai.externalMcp.http.portInUse': 'El puerto {port} ya está en uso.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Activa el MCP externo para que este extremo empiece a escuchar.',
|
||||
'ai.externalMcp.http.invalidPort': 'Introduce un puerto entre 1024 y 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'La dirección de escucha debe ser una IP, como 0.0.0.0 o la IP de vEthernet.',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP no pudo escuchar.',
|
||||
'ai.externalMcp.http.legacySseNote': 'No se ofrece el HTTP+SSE heredado (2024-11-05). Usa esta URL de Streamable HTTP.',
|
||||
'ai.externalMcp.permissionMode': 'Modo de permisos actual: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Modo de permiso de escritura',
|
||||
'ai.externalMcp.permissionMode.hint': 'La misma configuración que Configuración → IA → Seguridad. Automático ejecuta las herramientas de escritura de Netcatty sin solicitar aprobación de Netcatty; Confirmar pregunta cada vez. Los clientes externos (Codex / Claude / Grok) aún pueden mostrar su propia interfaz de aprobación de herramientas.',
|
||||
|
||||
@@ -751,6 +751,10 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.section.connection': 'Conexión',
|
||||
'settings.terminal.connection.verifyHostKeys': 'Verificar las claves de host SSH',
|
||||
'settings.terminal.connection.verifyHostKeys.desc': 'Pregunta antes de conectarse a una clave de host SSH nueva o modificada. Desactívalo solo en redes privadas de confianza.',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Permitir algoritmos heredados en conexiones temporales',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios al estilo PuTTY y la conexión rápida, ofrecen los mismos algoritmos obsoletos que la opción por host. Los hosts guardados conservan su propio ajuste. Desactivado de forma predeterminada; estos algoritmos tienen debilidades conocidas.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Modo de dispositivo de red para conexiones temporales',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'Las conexiones SSH sin un host guardado, incluidos los inicios externos y la conexión rápida, se inician en modo de dispositivo de red. Los hosts y grupos guardados conservan su propio tipo de dispositivo. Desactivado de forma predeterminada.',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'Reconectar sesiones SSH automáticamente',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': 'Cuando está activado, las sesiones SSH establecidas que se caen inesperadamente intentan reconectarse cada 5 segundos hasta que la pestaña se cierra o la conexión se establece.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Intervalo de keepalive',
|
||||
@@ -760,6 +764,9 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.connection.x11Display': 'Pantalla X11',
|
||||
'settings.terminal.connection.x11Display.desc': 'Dirección de pantalla local opcional para el reenvío X11. Déjala vacía para usar el valor predeterminado del sistema.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 o DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Proxy predeterminado',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Proxy guardado que se usa cuando el host no tiene uno propio y no hereda ninguno de un grupo. Ninguno conecta directamente.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'Ninguno',
|
||||
'settings.terminal.section.serverStats': 'Estadísticas del servidor (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Mostrar barra de información del host',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Muestra la dirección del host y la información del servidor sobre la terminal. Los botones de acción siguen disponibles cuando está oculta.',
|
||||
@@ -772,8 +779,8 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de procesos en el panel lateral del administrador del sistema.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Actualización de sesiones tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de sesiones tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar la lista de contenedores Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Actualización de la lista de contenedores',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Con qué frecuencia actualizar las listas de contenedores y pods.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Actualización de estadísticas Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Con qué frecuencia actualizar las estadísticas de CPU/memoria/red de los contenedores Docker.',
|
||||
'settings.terminal.serverStats.show': 'Mostrar estadísticas del servidor',
|
||||
@@ -1132,6 +1139,7 @@ Resalta el panel dividido enfocado:
|
||||
'vault.hosts.moveSelected': 'Mover al grupo ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Mueve los hosts seleccionados a un grupo existente o los deja sin grupo. Los grupos seleccionados no se mueven.',
|
||||
'vault.hosts.moveMultiple.success': 'Se movieron {count} hosts a {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} hosts',
|
||||
'vault.hosts.moveToGroup.success': 'Se movió {host} a {group}',
|
||||
'vault.hosts.errors.nameRequired': 'Se requiere el nombre del host.',
|
||||
'vault.hosts.empty.title': 'Configura tus hosts',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'No se pudieron cargar los contenedores',
|
||||
'systemManager.errors.loadDockerStats': 'No se pudieron cargar las estadísticas de los contenedores',
|
||||
'systemManager.errors.loadDockerImages': 'No se pudieron cargar las imágenes',
|
||||
'systemManager.errors.loadKubernetes': 'No se pudieron cargar los recursos de Kubernetes',
|
||||
'systemManager.errors.loadPods': 'No se pudieron cargar los pods',
|
||||
'systemManager.errors.loadOverview': 'No se pudo cargar el resumen del sistema',
|
||||
'systemManager.errors.loadGpu': 'No se pudieron cargar las estadísticas de GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
|
||||
@@ -132,7 +134,7 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Nombre de la ventana (opcional)',
|
||||
'systemManager.tmux.noWindows': 'No hay ventanas',
|
||||
'systemManager.tmux.unavailable': 'tmux no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'Docker no está disponible en este host',
|
||||
'systemManager.docker.unavailable': 'No se detectó ningún runtime de contenedores (Docker, Podman, nerdctl o kubectl) en este host',
|
||||
'systemManager.tmux.windowsMismatch': 'La sesión reporta {{count}} ventana(s), pero list-windows no devolvió ninguna',
|
||||
'systemManager.tmux.lastCommand': 'último comando: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'No hay paneles',
|
||||
@@ -194,6 +196,22 @@ export const esSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} imagen(es)',
|
||||
'systemManager.docker.start': 'Iniciar',
|
||||
'systemManager.docker.stop': 'Detener',
|
||||
'systemManager.runtime.switch': 'Runtime de contenedores',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Contexto',
|
||||
'systemManager.k8s.namespace': 'Espacio de nombres',
|
||||
'systemManager.k8s.empty': 'No se encontraron pods',
|
||||
'systemManager.k8s.search': 'Buscar pods…',
|
||||
'systemManager.k8s.meta': '{{count}} pod(s)',
|
||||
'systemManager.k8s.describe': 'Describir',
|
||||
'systemManager.k8s.delete': 'Eliminar pod',
|
||||
'systemManager.k8s.confirmDelete': '¿Eliminar el pod "{{name}}"?',
|
||||
'systemManager.k8s.restarts': '{{count}} reinicios',
|
||||
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
|
||||
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
|
||||
|
||||
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
|
||||
'systemManager.ports.loading': 'Cargando puertos en escucha…',
|
||||
|
||||
@@ -109,6 +109,8 @@ export const esTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'Uso de CPU',
|
||||
'terminal.serverStats.cpuCores': 'Uso de núcleos de CPU',
|
||||
'terminal.serverStats.gpu': 'Uso de GPU',
|
||||
'terminal.serverStats.gpuDevices': 'Uso de cada GPU',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Uso de memoria',
|
||||
'terminal.serverStats.memoryDetails': 'Detalles de memoria',
|
||||
'terminal.serverStats.memUsed': 'Usada',
|
||||
@@ -872,11 +874,13 @@ export const esTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'El servidor requiere autenticación adicional.',
|
||||
'keyboard.interactive.descWithHost': 'El servidor {hostname} requiere autenticación adicional.',
|
||||
'keyboard.interactive.response': 'Respuesta',
|
||||
'keyboard.interactive.verificationCode': 'Código de verificación',
|
||||
'keyboard.interactive.enterCode': 'Ingresa el código de verificación',
|
||||
'keyboard.interactive.enterResponse': 'Ingresa la respuesta',
|
||||
'keyboard.interactive.submit': 'Enviar',
|
||||
'keyboard.interactive.verifying': 'Verificando...',
|
||||
'keyboard.interactive.savePassword': 'Guardar contraseña',
|
||||
'keyboard.interactive.fillTotp': 'Rellenar código de verificación',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Frase de contraseña de la clave SSH',
|
||||
|
||||
@@ -668,6 +668,10 @@ export const esVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Buscar hosts o pestañas',
|
||||
'qs.quickConnectTo': 'Conexión rápida a {target}',
|
||||
'qs.hosts': 'Hosts',
|
||||
'qs.tabs': 'Pestañas',
|
||||
'qs.newWorkspace': 'Nuevo espacio de trabajo',
|
||||
'qs.jumpTo': 'Ir a',
|
||||
'qs.localTerminal': 'Terminal local',
|
||||
'qs.localShells': 'Shells locales',
|
||||
@@ -866,6 +870,35 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Usa el certificado SSH seleccionado, con la contraseña guardada como respaldo cuando esté presente.',
|
||||
'hostDetails.auth.mfaFirst': 'Preferir autenticación interactiva',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Para servidores que requieren contraseña y MFA a través de keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Autenticador (TOTP)',
|
||||
'hostDetails.totp.enable': 'Guardar un secreto TOTP para este host',
|
||||
'hostDetails.totp.setup': 'Configurar…',
|
||||
'hostDetails.totp.edit': 'Editar',
|
||||
'hostDetails.totp.modalTitle': 'Configurar autenticador',
|
||||
'hostDetails.totp.replacePlaceholder': 'Pega un secreto nuevo para reemplazar el actual',
|
||||
'hostDetails.totp.warning': 'Quien abra esta caja fuerte obtiene este código y la contraseña. Úsalo solo en un dispositivo personal.',
|
||||
'hostDetails.totp.secret': 'Secreto o URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Pega otpauth://totp/... o un secreto Base32',
|
||||
'hostDetails.totp.importQr': 'Importar imagen QR',
|
||||
'hostDetails.totp.scanScreen': 'Escanear código QR de la pantalla',
|
||||
'hostDetails.totp.scanning': 'Escaneando pantallas…',
|
||||
'hostDetails.totp.importedAccount': 'Importado: {account}',
|
||||
'hostDetails.totp.preview': 'Código actual',
|
||||
'hostDetails.totp.countdown': 'Quedan {seconds} s',
|
||||
'hostDetails.totp.autoSubmit': 'Enviar automáticamente en avisos TOTP conocidos',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Se envía una sola vez por conexión y solo si el aviso coincide exactamente con JumpServer, Google Authenticator PAM o tu patrón.',
|
||||
'hostDetails.totp.promptPattern': 'Patrón de aviso personalizado',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Expresión regular RE2 opcional',
|
||||
'hostDetails.totp.clear': 'Quitar autenticador',
|
||||
'hostDetails.totp.removeTitle': '¿Quitar el autenticador?',
|
||||
'hostDetails.totp.removeMessage': 'Este host ya no guardará este secreto TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP no es compatible. Usa un secreto TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Los códigos otpauth-migration no son compatibles. Usa el código QR de vinculación de una sola cuenta.',
|
||||
'hostDetails.totp.error.invalid': 'No se pudo leer un secreto TOTP de ese valor.',
|
||||
'hostDetails.totp.error.notFound': 'No se encontró un código QR TOTP.',
|
||||
'hostDetails.totp.error.permission': 'Se necesita permiso de grabación de pantalla. Permite que Netcatty grabe la pantalla en Ajustes del sistema e inténtalo de nuevo.',
|
||||
'hostDetails.totp.error.unavailable': 'La captura de pantalla no está disponible.',
|
||||
'hostDetails.totp.error.pattern': 'El patrón personalizado no es una expresión regular RE2 válida.',
|
||||
'hostDetails.auth.credentialRequired': 'Selecciona una clave o un certificado antes de guardar este método de inicio de sesión.',
|
||||
'hostDetails.identity.suggestions': 'Identidades',
|
||||
'hostDetails.identity.missing': 'Identidad no encontrada',
|
||||
@@ -939,6 +972,8 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
|
||||
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
'hostDetails.proxy.none': 'Ninguno',
|
||||
'hostDetails.proxy.direct': 'Directo (sin proxy)',
|
||||
'hostDetails.proxy.directHint': 'No usa el proxy del grupo ni el predeterminado global',
|
||||
'hostDetails.proxy.edit': 'Editar proxy',
|
||||
'hostDetails.proxy.configure': 'Configurar proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import en from "./en.ts";
|
||||
import ru from "./ru.ts";
|
||||
import es from "./es.ts";
|
||||
import zhCN from "./zh-CN.ts";
|
||||
import zhTW from "./zh-TW.ts";
|
||||
|
||||
const KEYS = [
|
||||
"ai.externalMcp.http.title",
|
||||
"ai.externalMcp.http.description",
|
||||
"ai.externalMcp.http.enable",
|
||||
"ai.externalMcp.http.port",
|
||||
"ai.externalMcp.http.portHint",
|
||||
"ai.externalMcp.http.allowCustomBind",
|
||||
"ai.externalMcp.http.allowCustomBind.description",
|
||||
"ai.externalMcp.http.listenAddress",
|
||||
"ai.externalMcp.http.listenAddress.placeholder",
|
||||
"ai.externalMcp.http.clientHost",
|
||||
"ai.externalMcp.http.clientHost.description",
|
||||
"ai.externalMcp.http.clientHost.placeholder",
|
||||
"ai.externalMcp.http.clientHostRequired",
|
||||
"ai.externalMcp.http.invalidClientHost",
|
||||
"ai.externalMcp.http.warning",
|
||||
"ai.externalMcp.http.url",
|
||||
"ai.externalMcp.http.token",
|
||||
"ai.externalMcp.http.regenerate",
|
||||
"ai.externalMcp.http.regenerateHint",
|
||||
"ai.externalMcp.http.wslHint",
|
||||
"ai.externalMcp.http.codexSnippet",
|
||||
"ai.externalMcp.http.claudeCommand",
|
||||
"ai.externalMcp.http.genericSnippet",
|
||||
"ai.externalMcp.http.envLine",
|
||||
"ai.externalMcp.http.notListening",
|
||||
"ai.externalMcp.http.listening",
|
||||
"ai.externalMcp.http.portInUse",
|
||||
"ai.externalMcp.http.requiresExternalMcp",
|
||||
"ai.externalMcp.http.invalidPort",
|
||||
"ai.externalMcp.http.invalidAddress",
|
||||
"ai.externalMcp.http.listenFailed",
|
||||
"ai.externalMcp.http.legacySseNote",
|
||||
] as const;
|
||||
|
||||
test("External MCP HTTP strings exist in every locale", () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, "zh-CN": zhCN, "zh-TW": zhTW, ru })) {
|
||||
const missing = KEYS.filter((key) => !messages[key]);
|
||||
assert.deepEqual(missing, [], `${name} is missing HTTP MCP labels`);
|
||||
assert.match(messages["ai.externalMcp.http.portInUse"], /\{port\}/);
|
||||
}
|
||||
assert.match(zhTW["ai.externalMcp.http.port"], /連接埠/);
|
||||
assert.match(zhTW["ai.externalMcp.http.listenAddress"], /位址/);
|
||||
assert.match(zhTW["ai.externalMcp.http.clientHost"], /位址/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.clientHost"], /地址/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.port"], /端口/);
|
||||
assert.doesNotMatch(zhTW["ai.externalMcp.http.listenAddress"], /地址/);
|
||||
assert.match(zhTW["ai.externalMcp.http.wslHint"], /鏡像網路/);
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import en from './en.ts';
|
||||
import es from './es.ts';
|
||||
import ru from './ru.ts';
|
||||
import zhCN from './zh-CN.ts';
|
||||
import zhTW from './zh-TW.ts';
|
||||
|
||||
const KEYS = [
|
||||
'title', 'description', 'boundary', 'enable', 'privateIps', 'privateIps.description',
|
||||
'cidrs', 'cidrs.description', 'patterns', 'patterns.description',
|
||||
'connectionScope', 'reconnect', 'apply', 'applying', 'applied', 'failed',
|
||||
];
|
||||
|
||||
test('Shared AI privacy settings are localized in every supported locale', () => {
|
||||
for (const [name, messages] of Object.entries({ en, es, ru, 'zh-CN': zhCN, 'zh-TW': zhTW })) {
|
||||
assert.deepEqual(
|
||||
KEYS.filter((key) => !messages[`ai.privacy.${key}`]),
|
||||
[],
|
||||
`${name} is missing privacy settings labels`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import en from './en';
|
||||
import zhCN from './zh-CN';
|
||||
import zhTW from './zh-TW';
|
||||
|
||||
const KEYS = [
|
||||
'ai.opencode.v2.startupTimeout',
|
||||
'ai.opencode.v2.exited',
|
||||
'ai.opencode.v2.unauthorized',
|
||||
'ai.opencode.v2.unavailable',
|
||||
'ai.opencode.v2.sessionFailed',
|
||||
'ai.opencode.v2.empty',
|
||||
'ai.opencode.v2.contextSkipped',
|
||||
'ai.opencode.v2.mcpSkipped',
|
||||
];
|
||||
|
||||
test('OpenCode 2 strings exist in English, Simplified Chinese, and Taiwan Chinese', () => {
|
||||
for (const key of KEYS) {
|
||||
assert.equal(typeof en[key], 'string', `en missing ${key}`);
|
||||
assert.equal(typeof zhCN[key], 'string', `zh-CN missing ${key}`);
|
||||
assert.equal(typeof zhTW[key], 'string', `zh-TW missing ${key}`);
|
||||
assert.notEqual(en[key], zhCN[key]);
|
||||
assert.notEqual(en[key], zhTW[key]);
|
||||
assert.notEqual(zhCN[key], zhTW[key]);
|
||||
}
|
||||
});
|
||||
|
||||
test('Taiwan OpenCode 2 wording uses Taiwan terms', () => {
|
||||
const text = KEYS.map((key) => zhTW[key]).join('\n');
|
||||
assert.match(text, /終端機/);
|
||||
assert.match(text, /連線/);
|
||||
assert.match(text, /設定/);
|
||||
assert.match(text, /傳回/);
|
||||
assert.equal(text.includes('终端'), false);
|
||||
assert.equal(text.includes('连接'), false);
|
||||
assert.equal(text.includes('设置'), false);
|
||||
assert.equal(text.includes('返回'), false);
|
||||
});
|
||||
@@ -323,6 +323,54 @@ export const ruAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': 'Справка External MCP',
|
||||
'ai.externalMcp.security': 'Безопасность',
|
||||
'ai.externalMcp.security.description': 'Слушает только 127.0.0.1 с ротацией token, использует AI Permission Mode для записи и удаляет discovery при отключении.',
|
||||
"ai.privacy.title": "Конфиденциальность ИИ",
|
||||
"ai.privacy.description": "Фильтрует известные шаблоны конфиденциального текста в контексте и результатах инструментов, управляемых Netcatty, для Catty, встроенных агентов и внешних MCP-клиентов. По умолчанию отключено. Не обнаруживает все секреты. При включении двоичные ответы инструментов блокируются.",
|
||||
"ai.privacy.boundary": "Защищаются только данные и инструменты, управляемые Netcatty. Собственные инструменты сторонних агентов, их прямой доступ к файлам и другие MCP-серверы не входят в эту область.",
|
||||
"ai.privacy.enable": "Включить скрытие текста",
|
||||
"ai.privacy.privateIps": "Скрывать частные IP-адреса",
|
||||
"ai.privacy.privateIps.description": "Также скрывает частные и локальные IP-адреса в возвращаемом тексте.",
|
||||
"ai.privacy.cidrs": "Дополнительные диапазоны IP (CIDR)",
|
||||
"ai.privacy.cidrs.description": "Один CIDR IPv4 или IPv6 на строку. Оставьте пустым для использования только встроенных правил.",
|
||||
"ai.privacy.patterns": "Пользовательские регулярные выражения",
|
||||
"ai.privacy.patterns.description": "Один RE2-совместимый шаблон на строку, без разделителей / и флагов. Неподдерживаемые или небезопасные шаблоны отклоняются.",
|
||||
"ai.privacy.connectionScope": "Маркеры постоянны в пределах одного управляемого диалога или внешнего MCP-подключения. Исходные значения восстанавливаются только через вызовы инструментов, управляемых Netcatty, в той же области.",
|
||||
"ai.privacy.reconnect": "Примените изменения, начните новый управляемый диалог и переподключите внешние MCP-клиенты. Существующие диалоги и подключения не должны повторно использовать маркеры.",
|
||||
"ai.privacy.apply": "Применить",
|
||||
"ai.privacy.applying": "Применение...",
|
||||
"ai.privacy.applied": "Настройки конфиденциальности ИИ применены. Начните новый управляемый диалог и переподключите внешние MCP-клиенты.",
|
||||
"ai.privacy.failed": "Настройки конфиденциальности ИИ не применены.",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': 'Отдает те же инструменты External MCP по Streamable HTTP клиентам, которые не могут запустить stdio CLI, например Codex в WSL. По умолчанию выключено.',
|
||||
'ai.externalMcp.http.enable': 'Включить HTTP',
|
||||
'ai.externalMcp.http.port': 'Порт',
|
||||
'ai.externalMcp.http.portHint': 'Фиксированный порт, по умолчанию 47391. URL клиентов не меняется после перезапуска. Допустимый диапазон: 1024–65535.',
|
||||
'ai.externalMcp.http.allowCustomBind': 'Слушать другой адрес',
|
||||
'ai.externalMcp.http.allowCustomBind.description': 'По умолчанию endpoint привязан к 127.0.0.1. Включите для WSL2 NAT и укажите 0.0.0.0 или IP vEthernet.',
|
||||
'ai.externalMcp.http.listenAddress': 'Адрес прослушивания',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': 'Адрес клиента',
|
||||
'ai.externalMcp.http.clientHost.description': 'Подставляется в копируемые конфиги, если слушатель 0.0.0.0 или ::. Укажите доступный IP, например IP узла Windows.',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': 'Укажите IP, к которому должны подключаться клиенты. 0.0.0.0 недоступен для подключения.',
|
||||
'ai.externalMcp.http.invalidClientHost': 'Адрес клиента должен быть доступным IP, а не 0.0.0.0 или ::.',
|
||||
'ai.externalMcp.http.warning': 'Прослушивание не только 127.0.0.1 открывает External MCP другим машинам, которые могут достичь этого адреса. Храните bearer token в секрете и не публикуйте этот порт.',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': 'Создать заново',
|
||||
'ai.externalMcp.http.regenerateHint': 'Выпускает новый bearer token. HTTP-клиенты должны использовать новый token. Файл discovery для stdio обновляется; уже подключенные stdio-клиенты остаются подключенными.',
|
||||
'ai.externalMcp.http.wslHint': 'При mirrored networking в WSL достаточно localhost. Иначе слушайте 0.0.0.0 или IP vEthernet и укажите IP узла Windows как адрес клиента.',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': 'Общий JSON',
|
||||
'ai.externalMcp.http.envLine': 'Переменная окружения token для Codex',
|
||||
'ai.externalMcp.http.notListening': 'Не слушает',
|
||||
'ai.externalMcp.http.listening': 'Слушает',
|
||||
'ai.externalMcp.http.portInUse': 'Порт {port} уже занят.',
|
||||
'ai.externalMcp.http.requiresExternalMcp': 'Сначала включите External MCP, чтобы endpoint начал слушать.',
|
||||
'ai.externalMcp.http.invalidPort': 'Введите порт от 1024 до 65535.',
|
||||
'ai.externalMcp.http.invalidAddress': 'Адрес прослушивания должен быть IP, например 0.0.0.0 или IP vEthernet.',
|
||||
'ai.externalMcp.http.listenFailed': 'Не удалось начать прослушивание HTTP.',
|
||||
'ai.externalMcp.http.legacySseNote': 'Устаревший HTTP+SSE (2024-11-05) не предоставляется. Используйте этот URL Streamable HTTP.',
|
||||
'ai.externalMcp.permissionMode': 'Текущий режим разрешений: {mode}',
|
||||
'ai.externalMcp.permissionMode.label': 'Режим разрешений на запись',
|
||||
'ai.externalMcp.permissionMode.hint': 'Тот же параметр, что Settings → AI → Safety. Auto выполняет write-инструменты Netcatty без запросов; Confirm спрашивает каждый раз. Внешние клиенты (Codex / Claude / Grok) могут показывать своё подтверждение инструментов.',
|
||||
|
||||
@@ -791,6 +791,10 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.localShellSidePanel.autoOpenPane.desc': 'Выберите, какую панель открывать в первую очередь.',
|
||||
'settings.terminal.localShellSidePanel.pane.files': 'Файлы',
|
||||
'settings.terminal.section.connection': 'Подключение',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': 'Устаревшие алгоритмы для временных подключений',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': 'SSH-подключения без сохранённого хоста, включая запуск в стиле PuTTY и быстрое подключение, предлагают те же устаревшие алгоритмы, что и настройка хоста. Сохранённые хосты используют свою настройку. По умолчанию выключено: у этих алгоритмов есть известные слабости.',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': 'Режим сетевого устройства для временных подключений',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': 'SSH-подключения без сохранённого хоста, включая внешний запуск и быстрое подключение, открываются в режиме сетевого устройства. Сохранённые хосты и группы сохраняют свой тип устройства. По умолчанию выключено.',
|
||||
'settings.terminal.connection.keepaliveInterval': 'Интервал keepalive',
|
||||
'settings.terminal.connection.keepaliveInterval.desc': 'Как часто (в секундах) отправлять keepalive-пакеты на уровне SSH. Установите 0, чтобы отключить глобально. Учтите, что отдельные хосты могут переопределять это значение в своих настройках.',
|
||||
'settings.terminal.connection.keepaliveCountMax': 'Макс. число пропущенных keepalive',
|
||||
@@ -798,6 +802,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display': 'Дисплей X11',
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Прокси по умолчанию',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Сохраненный прокси, если у хоста нет своего и он не наследует прокси группы. «Нет» — прямое подключение.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'Нет',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
|
||||
@@ -810,8 +817,8 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': 'Как часто обновлять список процессов в боковой панели системного менеджера.',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'Обновление сессий tmux',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'Как часто обновлять список сессий tmux.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров Docker',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять список контейнеров Docker.',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Обновление списка контейнеров',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Как часто обновлять списки контейнеров и подов.',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Обновление статистики Docker',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Как часто обновлять CPU/память/сеть контейнеров Docker.',
|
||||
'settings.terminal.serverStats.show': 'Показывать статистику сервера',
|
||||
|
||||
@@ -26,6 +26,8 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': 'Не удалось загрузить контейнеры',
|
||||
'systemManager.errors.loadDockerStats': 'Не удалось загрузить статистику контейнеров',
|
||||
'systemManager.errors.loadDockerImages': 'Не удалось загрузить образы',
|
||||
'systemManager.errors.loadKubernetes': 'Не удалось загрузить ресурсы Kubernetes',
|
||||
'systemManager.errors.loadPods': 'Не удалось загрузить поды',
|
||||
'systemManager.errors.loadOverview': 'Не удалось загрузить обзор системы',
|
||||
'systemManager.errors.loadGpu': 'Не удалось загрузить статистику GPU / NPU',
|
||||
'systemManager.errors.loadPorts': 'Не удалось загрузить слушающие порты',
|
||||
@@ -132,7 +134,7 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': 'Имя окна (необязательно)',
|
||||
'systemManager.tmux.noWindows': 'Нет окон',
|
||||
'systemManager.tmux.unavailable': 'tmux недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'Docker недоступен на этом хосте',
|
||||
'systemManager.docker.unavailable': 'На этом хосте не обнаружена среда контейнеров (Docker, Podman, nerdctl или kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': 'В сессии указано {{count}} окон, но list-windows ничего не вернул',
|
||||
'systemManager.tmux.lastCommand': 'последняя команда: {{command}}',
|
||||
'systemManager.tmux.noPanes': 'Нет панелей',
|
||||
@@ -194,6 +196,22 @@ export const ruSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} образов',
|
||||
'systemManager.docker.start': 'Запустить',
|
||||
'systemManager.docker.stop': 'Остановить',
|
||||
'systemManager.runtime.switch': 'Среда выполнения контейнеров',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': 'Контекст',
|
||||
'systemManager.k8s.namespace': 'Пространство имён',
|
||||
'systemManager.k8s.empty': 'Поды не найдены',
|
||||
'systemManager.k8s.search': 'Поиск подов…',
|
||||
'systemManager.k8s.meta': '{{count}} под(ов)',
|
||||
'systemManager.k8s.describe': 'Описание',
|
||||
'systemManager.k8s.delete': 'Удалить под',
|
||||
'systemManager.k8s.confirmDelete': 'Удалить под «{{name}}»?',
|
||||
'systemManager.k8s.restarts': '{{count}} перезапусков',
|
||||
'systemManager.k8s.chooseContainer': 'Выберите контейнер',
|
||||
'systemManager.k8s.noContext': 'На этом хосте нет контекста Kubernetes',
|
||||
|
||||
'systemManager.ports.unavailable': 'На этом хосте не обнаружены инструменты портов (ss / netstat).',
|
||||
'systemManager.ports.loading': 'Загрузка слушающих портов…',
|
||||
|
||||
@@ -130,6 +130,8 @@ export const ruTerminalMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'Использование CPU',
|
||||
'terminal.serverStats.cpuCores': 'Использование ядер CPU',
|
||||
'terminal.serverStats.gpu': 'Использование GPU',
|
||||
'terminal.serverStats.gpuDevices': 'Использование видеокарт',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': 'Использование памяти',
|
||||
'terminal.serverStats.memoryDetails': 'Сведения о памяти',
|
||||
'terminal.serverStats.memUsed': 'Использовано',
|
||||
@@ -917,11 +919,13 @@ export const ruTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': 'Сервер требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.descWithHost': 'Сервер {hostname} требует дополнительную аутентификацию.',
|
||||
'keyboard.interactive.response': 'Ответ',
|
||||
'keyboard.interactive.verificationCode': 'Код подтверждения',
|
||||
'keyboard.interactive.enterCode': 'Введите код подтверждения',
|
||||
'keyboard.interactive.enterResponse': 'Введите ответ',
|
||||
'keyboard.interactive.submit': 'Отправить',
|
||||
'keyboard.interactive.verifying': 'Проверка...',
|
||||
'keyboard.interactive.savePassword': 'Сохранить пароль',
|
||||
'keyboard.interactive.fillTotp': 'Подставить код подтверждения',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'Парольная фраза SSH-ключа',
|
||||
|
||||
@@ -131,6 +131,7 @@ export const ruVaultMessages: Messages = {
|
||||
'vault.hosts.moveSelected': 'Переместить в группу ({count})',
|
||||
'vault.hosts.moveSelected.description': 'Переместить выбранные хосты в существующую группу или оставить без группы. Выбранные группы не перемещаются.',
|
||||
'vault.hosts.moveMultiple.success': 'Перемещено {count} хостов в {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} хостов',
|
||||
'vault.hosts.moveToGroup.success': 'Хост {host} перемещён в {group}',
|
||||
'vault.hosts.empty.title': 'Настройте свои хосты',
|
||||
'vault.hosts.empty.desc': 'Сохраняйте хосты, чтобы быстро подключаться к серверам, виртуальным машинам и контейнерам.',
|
||||
@@ -710,6 +711,10 @@ export const ruVaultMessages: Messages = {
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': 'Поиск хостов или вкладок',
|
||||
'qs.quickConnectTo': 'Быстрое подключение к {target}',
|
||||
'qs.hosts': 'Хосты',
|
||||
'qs.tabs': 'Вкладки',
|
||||
'qs.newWorkspace': 'Новая рабочая область',
|
||||
'qs.jumpTo': 'Перейти к',
|
||||
'qs.localTerminal': 'Локальный терминал',
|
||||
'qs.localShells': 'Локальные оболочки',
|
||||
@@ -914,6 +919,35 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': 'Использовать выбранный SSH-сертификат и сохранённый пароль как запасной вариант.',
|
||||
'hostDetails.auth.mfaFirst': 'Сначала интерактивная аутентификация',
|
||||
'hostDetails.auth.mfaFirst.desc': 'Для серверов, которым нужны пароль и MFA через keyboard-interactive.',
|
||||
'hostDetails.totp.title': 'Аутентификатор (TOTP)',
|
||||
'hostDetails.totp.enable': 'Сохранить секрет TOTP для этого хоста',
|
||||
'hostDetails.totp.setup': 'Настроить…',
|
||||
'hostDetails.totp.edit': 'Изменить',
|
||||
'hostDetails.totp.modalTitle': 'Настройка аутентификатора',
|
||||
'hostDetails.totp.replacePlaceholder': 'Вставьте новый секрет, чтобы заменить текущий',
|
||||
'hostDetails.totp.warning': 'Тот, кто откроет это хранилище, получит этот код и пароль. Используйте только на личном устройстве.',
|
||||
'hostDetails.totp.secret': 'Секрет или URI otpauth',
|
||||
'hostDetails.totp.secretPlaceholder': 'Вставьте otpauth://totp/... или секрет Base32',
|
||||
'hostDetails.totp.importQr': 'Импорт изображения QR',
|
||||
'hostDetails.totp.scanScreen': 'Сканировать QR-код с экрана',
|
||||
'hostDetails.totp.scanning': 'Сканирование экранов…',
|
||||
'hostDetails.totp.importedAccount': 'Импортировано: {account}',
|
||||
'hostDetails.totp.preview': 'Текущий код',
|
||||
'hostDetails.totp.countdown': 'Осталось {seconds} с',
|
||||
'hostDetails.totp.autoSubmit': 'Автоотправка на известных запросах TOTP',
|
||||
'hostDetails.totp.autoSubmitDesc': 'Отправляется один раз за подключение и только если запрос точно совпадает с JumpServer, Google Authenticator PAM или вашим шаблоном.',
|
||||
'hostDetails.totp.promptPattern': 'Свой шаблон запроса',
|
||||
'hostDetails.totp.promptPatternPlaceholder': 'Необязательное регулярное выражение RE2',
|
||||
'hostDetails.totp.clear': 'Удалить аутентификатор',
|
||||
'hostDetails.totp.removeTitle': 'Удалить аутентификатор?',
|
||||
'hostDetails.totp.removeMessage': 'Этот хост больше не будет хранить этот секрет TOTP.',
|
||||
'hostDetails.totp.error.hotp': 'HOTP не поддерживается. Используйте секрет TOTP.',
|
||||
'hostDetails.totp.error.migration': 'Коды otpauth-migration не поддерживаются. Используйте QR-код привязки одной учетной записи.',
|
||||
'hostDetails.totp.error.invalid': 'Не удалось прочитать секрет TOTP.',
|
||||
'hostDetails.totp.error.notFound': 'QR-код TOTP не найден.',
|
||||
'hostDetails.totp.error.permission': 'Нужно разрешение на запись экрана. Разрешите Netcatty запись экрана в системных настройках и повторите попытку.',
|
||||
'hostDetails.totp.error.unavailable': 'Захват экрана недоступен.',
|
||||
'hostDetails.totp.error.pattern': 'Пользовательский шаблон не является допустимым регулярным выражением RE2.',
|
||||
'hostDetails.auth.credentialRequired': 'Перед сохранением этого способа входа выберите ключ или сертификат.',
|
||||
'hostDetails.identity.suggestions': 'Идентификаторы',
|
||||
'hostDetails.identity.missing': 'Идентификатор не найден',
|
||||
@@ -987,6 +1021,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
|
||||
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'Нет',
|
||||
'hostDetails.proxy.direct': 'Напрямую (без прокси)',
|
||||
'hostDetails.proxy.directHint': 'Не использует прокси группы или глобальный прокси по умолчанию',
|
||||
'hostDetails.proxy.edit': 'Редактировать прокси',
|
||||
'hostDetails.proxy.configure': 'Настроить прокси',
|
||||
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { enTerminalMessages } from "./en/terminal";
|
||||
import { esTerminalMessages } from "./es/terminal";
|
||||
import { ruTerminalMessages } from "./ru/terminal";
|
||||
import { zhCNVaultMessages } from "./zh-CN/vault";
|
||||
import { zhTWVaultMessages } from "./zh-TW/vault";
|
||||
|
||||
test("per-GPU stats strings exist in every locale", () => {
|
||||
const labels = [
|
||||
[enTerminalMessages, "GPU Device Usage", "GPU {{index}}"],
|
||||
[esTerminalMessages, "Uso de cada GPU", "GPU {{index}}"],
|
||||
[ruTerminalMessages, "Использование видеокарт", "GPU {{index}}"],
|
||||
[zhCNVaultMessages, "GPU 设备使用率", "GPU {{index}}"],
|
||||
[zhTWVaultMessages, "GPU 裝置使用率", "GPU {{index}}"],
|
||||
] as const;
|
||||
|
||||
for (const [messages, devices, indexLabel] of labels) {
|
||||
assert.equal(messages["terminal.serverStats.gpuDevices"], devices);
|
||||
assert.equal(messages["terminal.serverStats.gpuIndex"], indexLabel);
|
||||
}
|
||||
});
|
||||
@@ -47,6 +47,56 @@ test("localized settings include workspace focus indicator labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include temporary-connection legacy algorithm labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.temporaryLegacyAlgorithms",
|
||||
"settings.terminal.connection.temporaryLegacyAlgorithms.desc",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing temporary legacy algorithm labels`);
|
||||
}
|
||||
|
||||
assert.match(zhCN["settings.terminal.connection.temporaryLegacyAlgorithms"], /临时连接允许旧版算法/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryLegacyAlgorithms"], /臨時連線允許舊版演算法/);
|
||||
});
|
||||
|
||||
test("localized settings include temporary-connection network device mode labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode",
|
||||
"settings.terminal.connection.temporaryNetworkDeviceMode.desc",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing temporary network device mode labels`);
|
||||
}
|
||||
|
||||
assert.match(zhCN["settings.terminal.connection.temporaryNetworkDeviceMode"], /临时连接使用网络设备模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode"], /臨時連線使用網路裝置模式/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /連線/);
|
||||
assert.match(zhTW["settings.terminal.connection.temporaryNetworkDeviceMode.desc"], /設定|群組|裝置/);
|
||||
});
|
||||
|
||||
test("localized settings include the global default proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.defaultProxy",
|
||||
"settings.terminal.connection.defaultProxy.desc",
|
||||
"settings.terminal.connection.defaultProxy.none",
|
||||
"hostDetails.proxy.direct",
|
||||
"hostDetails.proxy.directHint",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing global default proxy labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include network proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.system.networkProxy.title",
|
||||
|
||||
@@ -289,6 +289,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '检查',
|
||||
'ai.opencode.resetPath': '重置',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及时就绪。请先在终端确认 `opencode serve` 可以启动,然后重试。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在对话开始前已退出。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒绝了连接。请重试这次对话。',
|
||||
'ai.opencode.v2.unavailable': '无法连接到 OpenCode 2。请确认已安装后再试。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成这一轮对话。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 没有返回内容。请在终端运行 `opencode` 完成登录和模型配置。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 无法应用 Netcatty 的对话上下文。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 无法接入 MCP 工具,对话会继续,但没有该工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -376,6 +384,54 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '对外 MCP 使用说明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '仅监听 127.0.0.1,使用轮换 token,写操作复用 AI 权限模式,关闭时删除 discovery。不做 OAuth——这是本机桌面桥接。',
|
||||
"ai.privacy.title": "AI 隐私",
|
||||
"ai.privacy.description": "为 Catty、集成 Agent 和外部 MCP 客户端过滤 Netcatty 管理的上下文及工具结果中已知的敏感文本模式。默认关闭,无法识别所有机密。启用时会阻止二进制工具响应。",
|
||||
"ai.privacy.boundary": "仅覆盖 Netcatty 管理的数据和工具。第三方 Agent 的原生工具、直接读取的文件及其他 MCP 服务器不在保护范围内。",
|
||||
"ai.privacy.enable": "启用文本脱敏",
|
||||
"ai.privacy.privateIps": "隐藏私有 IP 地址",
|
||||
"ai.privacy.privateIps.description": "同时隐藏返回文本中的私有和本地 IP 地址。",
|
||||
"ai.privacy.cidrs": "附加 IP 网段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一个 IPv4 或 IPv6 CIDR。留空则仅使用内置规则。",
|
||||
"ai.privacy.patterns": "自定义正则表达式",
|
||||
"ai.privacy.patterns.description": "每行一个兼容 RE2 的模式,不含 / 分隔符或标志。不支持或不安全的模式会被拒绝。",
|
||||
"ai.privacy.connectionScope": "占位符在同一个受管对话或外部 MCP 连接内保持稳定。只有同一范围内的 Netcatty 受管工具调用才能还原原始值。",
|
||||
"ai.privacy.reconnect": "应用更改后,请新建受管对话并重新连接外部 MCP 客户端。已有对话和连接不得复用占位符。",
|
||||
"ai.privacy.apply": "应用",
|
||||
"ai.privacy.applying": "正在应用...",
|
||||
"ai.privacy.applied": "AI 隐私设置已应用。请新建受管对话并重新连接外部 MCP 客户端。",
|
||||
"ai.privacy.failed": "AI 隐私设置未应用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '用 Streamable HTTP 提供同一套对外 MCP 工具,方便无法启动 stdio CLI 的客户端,例如 WSL 里的 Codex。默认关闭。',
|
||||
'ai.externalMcp.http.enable': '启用 HTTP',
|
||||
'ai.externalMcp.http.port': '端口',
|
||||
'ai.externalMcp.http.portHint': '固定端口,默认 47391。重启后客户端地址保持不变。允许范围:1024–65535。',
|
||||
'ai.externalMcp.http.allowCustomBind': '监听其他地址',
|
||||
'ai.externalMcp.http.allowCustomBind.description': '默认只绑定 127.0.0.1。WSL2 NAT 可改为监听 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenAddress': '监听地址',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': '客户端地址',
|
||||
'ai.externalMcp.http.clientHost.description': '监听 0.0.0.0 或 :: 时,复制的配置会使用这个地址。请填写客户端能访问的 IP,例如 Windows 主机 IP。',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': '请填写客户端要连接的 IP。0.0.0.0 不是可连接的地址。',
|
||||
'ai.externalMcp.http.invalidClientHost': '客户端地址必须是可连接的 IP,不能是 0.0.0.0 或 ::。',
|
||||
'ai.externalMcp.http.warning': '监听 127.0.0.1 以外的地址会让能访问这台电脑的其他设备使用对外 MCP。请保管 bearer token,不要把这个端口暴露到公网。',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': '重新生成',
|
||||
'ai.externalMcp.http.regenerateHint': '生成新的 bearer token。HTTP 客户端必须改用新 token。stdio 的 discovery 文件会一起更新;已经通过 stdio 连上的客户端保持连接。',
|
||||
'ai.externalMcp.http.wslHint': 'WSL 若使用镜像网络,localhost 即可连接。否则请监听 0.0.0.0 或 vEthernet IP,并把客户端地址设为 Windows 主机 IP。',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': '通用 JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token 环境变量',
|
||||
'ai.externalMcp.http.notListening': '未监听',
|
||||
'ai.externalMcp.http.listening': '监听中',
|
||||
'ai.externalMcp.http.portInUse': '端口 {port} 已被占用。',
|
||||
'ai.externalMcp.http.requiresExternalMcp': '请先打开对外 MCP,HTTP 端点才会开始监听。',
|
||||
'ai.externalMcp.http.invalidPort': '请输入 1024 到 65535 的端口。',
|
||||
'ai.externalMcp.http.invalidAddress': '监听地址必须是 IP,例如 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP 监听失败。',
|
||||
'ai.externalMcp.http.legacySseNote': '不提供旧版 HTTP+SSE(2024-11-05)。请使用这个 Streamable HTTP 地址。',
|
||||
'ai.externalMcp.permissionMode': '当前权限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '写操作权限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '与「设置 → AI → 安全」为同一项。自动:Netcatty 写操作不再弹本应用审批;确认:每次询问。外部客户端(Codex / Claude / Grok)仍可能有自己的工具审批界面。',
|
||||
|
||||
@@ -785,6 +785,7 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
'vault.hosts.moveSelected': '移动到分组 ({count})',
|
||||
'vault.hosts.moveSelected.description': '将选中的主机移动到现有分组,也可移出分组。选中的分组本身不会移动。',
|
||||
'vault.hosts.moveMultiple.success': '已将 {count} 个主机移动到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 个主机',
|
||||
'vault.hosts.moveToGroup.success': '已将 {host} 移动到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主机名称不能为空。',
|
||||
'vault.hosts.empty.title': '设置你的主机',
|
||||
@@ -1139,6 +1140,10 @@ Vaults 标签保持扁平,不使用这块背景。
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜索主机或标签页',
|
||||
'qs.quickConnectTo': '快速连接到 {target}',
|
||||
'qs.hosts': '主机',
|
||||
'qs.tabs': '标签页',
|
||||
'qs.newWorkspace': '新建工作区',
|
||||
'qs.jumpTo': '跳转到',
|
||||
'qs.localTerminal': '本地终端',
|
||||
'qs.localShells': '本地 Shell',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '加载容器列表失败',
|
||||
'systemManager.errors.loadDockerStats': '加载容器性能数据失败',
|
||||
'systemManager.errors.loadDockerImages': '加载镜像列表失败',
|
||||
'systemManager.errors.loadKubernetes': '无法加载 Kubernetes 资源',
|
||||
'systemManager.errors.loadPods': '无法加载 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 无法连接 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '无法连接到上下文 {{context}} 的集群',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '无法连接到集群',
|
||||
'systemManager.errors.loadOverview': '加载系统概览失败',
|
||||
'systemManager.errors.loadGpu': '加载 GPU / NPU 状态失败',
|
||||
'systemManager.errors.loadPorts': '加载监听端口失败',
|
||||
@@ -132,7 +137,7 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '窗口名称(可选)',
|
||||
'systemManager.tmux.noWindows': '没有窗口',
|
||||
'systemManager.tmux.unavailable': '此主机未检测到 tmux',
|
||||
'systemManager.docker.unavailable': '此主机未检测到 Docker',
|
||||
'systemManager.docker.unavailable': '此主机未检测到容器运行时(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '会话显示有 {{count}} 个窗口,但 list-windows 未返回任何窗口',
|
||||
'systemManager.tmux.lastCommand': '最后执行的命令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '没有面板',
|
||||
@@ -194,6 +199,36 @@ export const zhCnSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 个镜像',
|
||||
'systemManager.docker.start': '启动',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器运行时',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '上下文',
|
||||
'systemManager.k8s.namespace': '命名空间',
|
||||
'systemManager.k8s.empty': '未找到 Pod',
|
||||
'systemManager.k8s.search': '搜索 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 个 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '删除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '确定删除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重启 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '选择容器',
|
||||
'systemManager.k8s.noContext': '此主机没有 Kubernetes 上下文',
|
||||
'systemManager.k8s.status.Running': '运行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失败',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '崩溃循环',
|
||||
'systemManager.k8s.status.ContainerCreating': '创建中',
|
||||
'systemManager.k8s.status.Terminating': '终止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '镜像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取镜像失败',
|
||||
'systemManager.k8s.status.Error': '错误',
|
||||
'systemManager.k8s.status.Evicted': '已驱逐',
|
||||
'systemManager.k8s.status.OOMKilled': '内存不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主机未检测到端口工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在加载监听端口…',
|
||||
|
||||
@@ -454,6 +454,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '临时连接允许旧版算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '没有对应已保存主机的 SSH 连接(包括外部程序以 PuTTY 参数启动,以及快速连接)会使用与主机「允许旧版算法」相同的已弃用算法。已保存主机仍使用自己的设置。默认关闭,这些算法有已知安全问题。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '临时连接使用网络设备模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '没有对应已保存主机的 SSH 连接(包括外部程序启动以及快速连接)会以网络设备模式连接。已保存主机和分组仍使用自己的设备类型。默认关闭。',
|
||||
'settings.terminal.connection.defaultProxy': '默认代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
|
||||
'settings.terminal.connection.defaultProxy.none': '无',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
@@ -480,8 +487,8 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系统管理侧栏中进程列表的刷新频率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 会话刷新间隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 会话列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表刷新间隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器与 Pod 列表的刷新频率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 性能数据刷新间隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/内存/网络指标的刷新频率。',
|
||||
|
||||
@@ -996,11 +1003,13 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '服务器需要额外的身份验证。',
|
||||
'keyboard.interactive.descWithHost': '服务器 {hostname} 需要额外的身份验证。',
|
||||
'keyboard.interactive.response': '响应',
|
||||
'keyboard.interactive.verificationCode': '验证码',
|
||||
'keyboard.interactive.enterCode': '输入验证码',
|
||||
'keyboard.interactive.enterResponse': '输入响应',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.verifying': '验证中...',
|
||||
'keyboard.interactive.savePassword': '保存密码',
|
||||
'keyboard.interactive.fillTotp': '填入验证码',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 密钥密码',
|
||||
|
||||
@@ -290,6 +290,35 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用选中的 SSH 证书;如果同时保存了密码,可在失败后尝试密码。',
|
||||
'hostDetails.auth.mfaFirst': '优先交互式认证',
|
||||
'hostDetails.auth.mfaFirst.desc': '适用于必须通过 keyboard-interactive 输入密码和 MFA 的服务器。',
|
||||
'hostDetails.totp.title': '验证器 (TOTP)',
|
||||
'hostDetails.totp.enable': '为这台主机保存 TOTP 密钥',
|
||||
'hostDetails.totp.setup': '设置…',
|
||||
'hostDetails.totp.edit': '编辑',
|
||||
'hostDetails.totp.modalTitle': '设置验证器',
|
||||
'hostDetails.totp.replacePlaceholder': '粘贴新密钥以替换当前密钥',
|
||||
'hostDetails.totp.warning': '能打开这个保险库的人会同时得到验证码和密码。请只在个人设备上使用。',
|
||||
'hostDetails.totp.secret': '密钥或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '粘贴 otpauth://totp/... 或 Base32 密钥',
|
||||
'hostDetails.totp.importQr': '导入二维码图片',
|
||||
'hostDetails.totp.scanScreen': '从屏幕扫描二维码',
|
||||
'hostDetails.totp.scanning': '正在扫描屏幕…',
|
||||
'hostDetails.totp.importedAccount': '已导入 {account}',
|
||||
'hostDetails.totp.preview': '当前验证码',
|
||||
'hostDetails.totp.countdown': '剩余 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自动提交',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次连接只提交一次,且提示必须精确匹配 JumpServer、Google Authenticator PAM,或你填写的正则。',
|
||||
'hostDetails.totp.promptPattern': '自定义提示正则',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可选的 RE2 正则表达式',
|
||||
'hostDetails.totp.clear': '移除验证器',
|
||||
'hostDetails.totp.removeTitle': '移除验证器?',
|
||||
'hostDetails.totp.removeMessage': '这台主机将不再保存这个 TOTP 密钥。',
|
||||
'hostDetails.totp.error.hotp': '不支持 HOTP。请使用 TOTP 密钥。',
|
||||
'hostDetails.totp.error.migration': '不支持 otpauth-migration 二维码。请使用服务提供的单个账号绑定二维码。',
|
||||
'hostDetails.totp.error.invalid': '无法从该内容读取 TOTP 密钥。',
|
||||
'hostDetails.totp.error.notFound': '没有找到 TOTP 二维码。',
|
||||
'hostDetails.totp.error.permission': '需要屏幕录制权限。请在系统设置中允许 Netcatty 录制屏幕后再试。',
|
||||
'hostDetails.totp.error.unavailable': '无法使用屏幕捕获。',
|
||||
'hostDetails.totp.error.pattern': '自定义提示正则不是有效的 RE2 正则表达式。',
|
||||
'hostDetails.auth.credentialRequired': '保存这种登录方式前,请先选择密钥或证书。',
|
||||
'hostDetails.identity.suggestions': '身份',
|
||||
'hostDetails.identity.missing': '身份不存在',
|
||||
@@ -363,6 +392,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.direct': '直连(不使用代理)',
|
||||
'hostDetails.proxy.directHint': '不使用分组或全局默认代理',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
'hostDetails.envVars': '环境变量',
|
||||
@@ -532,6 +563,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.gpuDevices': 'GPU 设备使用率',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': '内存使用',
|
||||
'terminal.serverStats.memoryDetails': '内存详情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
|
||||
@@ -280,6 +280,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.opencode.customPathPlaceholder': '例如 /usr/local/bin/opencode',
|
||||
'ai.opencode.check': '檢查',
|
||||
'ai.opencode.resetPath': '重設',
|
||||
'ai.opencode.v2.startupTimeout': 'OpenCode 2 未能及時就緒。請先在終端機確認 `opencode serve` 可以啟動,然後重試。',
|
||||
'ai.opencode.v2.exited': 'OpenCode 2 在對話開始前已結束。',
|
||||
'ai.opencode.v2.unauthorized': 'OpenCode 2 拒絕了連線。請重試這次對話。',
|
||||
'ai.opencode.v2.unavailable': '無法連線到 OpenCode 2。請確認已安裝後再試。',
|
||||
'ai.opencode.v2.sessionFailed': 'OpenCode 2 未能完成這一輪對話。',
|
||||
'ai.opencode.v2.empty': 'OpenCode 2 沒有傳回內容。請在終端機執行 `opencode` 完成登入和模型設定。',
|
||||
'ai.opencode.v2.contextSkipped': 'OpenCode 2 無法套用 Netcatty 的對話內容。',
|
||||
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 無法掛接 MCP 工具,對話會繼續,但沒有該工具。',
|
||||
|
||||
// AI MiMo Code(小米 MiMo Code CLI,OpenCode 分支)
|
||||
'ai.mimo.title': 'MiMo Code',
|
||||
@@ -366,6 +374,54 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.externalMcp.help.ariaLabel': '對外 MCP 使用說明',
|
||||
'ai.externalMcp.security': '安全',
|
||||
'ai.externalMcp.security.description': '僅監聽 127.0.0.1,使用輪替 token,寫入操作重用 AI 權限模式,關閉時刪除 discovery。',
|
||||
"ai.privacy.title": "AI 隱私",
|
||||
"ai.privacy.description": "為 Catty、整合 Agent 和外部 MCP 用戶端過濾 Netcatty 管理的上下文及工具結果中已知的敏感文字模式。預設關閉,無法識別所有機密。啟用時會封鎖二進位工具回應。",
|
||||
"ai.privacy.boundary": "僅涵蓋 Netcatty 管理的資料和工具。第三方 Agent 的原生工具、直接讀取的檔案及其他 MCP 伺服器不在保護範圍內。",
|
||||
"ai.privacy.enable": "啟用文字去識別化",
|
||||
"ai.privacy.privateIps": "隱藏私有 IP 位址",
|
||||
"ai.privacy.privateIps.description": "同時隱藏回傳文字中的私有和本機 IP 位址。",
|
||||
"ai.privacy.cidrs": "額外 IP 網段(CIDR)",
|
||||
"ai.privacy.cidrs.description": "每行一個 IPv4 或 IPv6 CIDR。留空則僅使用內建規則。",
|
||||
"ai.privacy.patterns": "自訂正規表示式",
|
||||
"ai.privacy.patterns.description": "每行一個相容 RE2 的模式,不含 / 分隔符號或旗標。不支援或不安全的模式會遭拒絕。",
|
||||
"ai.privacy.connectionScope": "預留位置在同一個受管對話或外部 MCP 連線內保持穩定。只有同一範圍內的 Netcatty 受管工具呼叫才能還原原始值。",
|
||||
"ai.privacy.reconnect": "套用變更後,請建立新的受管對話並重新連接外部 MCP 用戶端。現有對話和連線不得重複使用預留位置。",
|
||||
"ai.privacy.apply": "套用",
|
||||
"ai.privacy.applying": "正在套用...",
|
||||
"ai.privacy.applied": "AI 隱私設定已套用。請建立新的受管對話並重新連接外部 MCP 用戶端。",
|
||||
"ai.privacy.failed": "AI 隱私設定未套用。",
|
||||
'ai.externalMcp.http.title': 'Streamable HTTP',
|
||||
'ai.externalMcp.http.description': '以 Streamable HTTP 提供同一套對外 MCP 工具,給無法啟動 stdio CLI 的用戶端使用,例如 WSL 裡的 Codex。預設關閉。',
|
||||
'ai.externalMcp.http.enable': '啟用 HTTP',
|
||||
'ai.externalMcp.http.port': '連接埠',
|
||||
'ai.externalMcp.http.portHint': '固定連接埠,預設 47391。重新啟動後用戶端網址維持不變。允許範圍:1024–65535。',
|
||||
'ai.externalMcp.http.allowCustomBind': '監聽其他位址',
|
||||
'ai.externalMcp.http.allowCustomBind.description': '預設只綁定 127.0.0.1。WSL2 NAT 可改為監聽 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenAddress': '監聽位址',
|
||||
'ai.externalMcp.http.listenAddress.placeholder': '0.0.0.0',
|
||||
'ai.externalMcp.http.clientHost': '用戶端位址',
|
||||
'ai.externalMcp.http.clientHost.description': '監聽 0.0.0.0 或 :: 時,複製的設定會使用這個位址。請填用戶端連得到的 IP,例如 Windows 主機 IP。',
|
||||
'ai.externalMcp.http.clientHost.placeholder': '172.22.0.1',
|
||||
'ai.externalMcp.http.clientHostRequired': '請填用戶端要連線的 IP。0.0.0.0 不是可連線的位址。',
|
||||
'ai.externalMcp.http.invalidClientHost': '用戶端位址必須是可連線的 IP,不能是 0.0.0.0 或 ::。',
|
||||
'ai.externalMcp.http.warning': '監聽 127.0.0.1 以外的位址,會讓能連到這台電腦的其他裝置使用對外 MCP。請保管 bearer token,不要把這個連接埠暴露到公開網路。',
|
||||
'ai.externalMcp.http.url': 'URL',
|
||||
'ai.externalMcp.http.token': 'Bearer token',
|
||||
'ai.externalMcp.http.regenerate': '重新產生',
|
||||
'ai.externalMcp.http.regenerateHint': '產生新的 bearer token。HTTP 用戶端必須改用新 token。stdio 的 discovery 檔會一併更新;已經透過 stdio 連上的用戶端維持連線。',
|
||||
'ai.externalMcp.http.wslHint': 'WSL 若使用鏡像網路,localhost 即可連線。否則請監聽 0.0.0.0 或 vEthernet IP,並把用戶端位址設成 Windows 主機 IP。',
|
||||
'ai.externalMcp.http.codexSnippet': 'Codex config.toml',
|
||||
'ai.externalMcp.http.claudeCommand': 'Claude Code',
|
||||
'ai.externalMcp.http.genericSnippet': '通用 JSON',
|
||||
'ai.externalMcp.http.envLine': 'Codex token 環境變數',
|
||||
'ai.externalMcp.http.notListening': '未監聽',
|
||||
'ai.externalMcp.http.listening': '監聽中',
|
||||
'ai.externalMcp.http.portInUse': '連接埠 {port} 已被占用。',
|
||||
'ai.externalMcp.http.requiresExternalMcp': '請先開啟對外 MCP,HTTP 端點才會開始監聽。',
|
||||
'ai.externalMcp.http.invalidPort': '請輸入 1024 到 65535 的連接埠。',
|
||||
'ai.externalMcp.http.invalidAddress': '監聽位址必須是 IP,例如 0.0.0.0 或 vEthernet IP。',
|
||||
'ai.externalMcp.http.listenFailed': 'HTTP 監聽失敗。',
|
||||
'ai.externalMcp.http.legacySseNote': '不提供舊版 HTTP+SSE(2024-11-05)。請使用這個 Streamable HTTP 網址。',
|
||||
'ai.externalMcp.permissionMode': '目前權限模式:{mode}',
|
||||
'ai.externalMcp.permissionMode.label': '寫入權限模式',
|
||||
'ai.externalMcp.permissionMode.hint': '與「設定 → AI → 安全」為同一項。自動:Netcatty 寫入操作不再彈本應用核准;確認:每次詢問。外部用戶端(Codex / Claude / Grok)仍可能有自己的工具核准介面。',
|
||||
|
||||
@@ -787,6 +787,7 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
'vault.hosts.moveSelected': '移動到群組 ({count})',
|
||||
'vault.hosts.moveSelected.description': '將選取的主機移動到現有群組,也可移出群組。選取的群組本身不會移動。',
|
||||
'vault.hosts.moveMultiple.success': '已將 {count} 個主機移動到 {group}',
|
||||
'vault.hosts.dragSelectionCount': '{count} 個主機',
|
||||
'vault.hosts.moveToGroup.success': '已將 {host} 移動到 {group}',
|
||||
'vault.hosts.errors.nameRequired': '主機名稱不能為空。',
|
||||
'vault.hosts.empty.title': '設定你的主機',
|
||||
@@ -1141,6 +1142,10 @@ Vaults 分頁維持扁平,不使用這塊背景。
|
||||
|
||||
// Quick Switcher
|
||||
'qs.search.placeholder': '搜尋主機或分頁',
|
||||
'qs.quickConnectTo': '快速連線到 {target}',
|
||||
'qs.hosts': '主機',
|
||||
'qs.tabs': '分頁',
|
||||
'qs.newWorkspace': '新增工作區',
|
||||
'qs.jumpTo': '跳轉到',
|
||||
'qs.localTerminal': '本機終端',
|
||||
'qs.localShells': '本機 Shell',
|
||||
|
||||
@@ -26,6 +26,11 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.errors.loadDocker': '載入容器列表失敗',
|
||||
'systemManager.errors.loadDockerStats': '載入容器效能資料失敗',
|
||||
'systemManager.errors.loadDockerImages': '載入映像列表失敗',
|
||||
'systemManager.errors.loadKubernetes': '無法載入 Kubernetes 資源',
|
||||
'systemManager.errors.loadPods': '無法載入 Pod',
|
||||
'systemManager.errors.nerdctlUnreachable': 'nerdctl 無法連線到 containerd',
|
||||
'systemManager.errors.clusterUnreachable': '無法連到內容 {{context}} 的叢集',
|
||||
'systemManager.errors.clusterUnreachableGeneric': '無法連到叢集',
|
||||
'systemManager.errors.loadOverview': '載入系統概覽失敗',
|
||||
'systemManager.errors.loadGpu': '載入 GPU / NPU 狀態失敗',
|
||||
'systemManager.errors.loadPorts': '載入監聽連接埠失敗',
|
||||
@@ -132,7 +137,7 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.tmux.newWindowPlaceholder': '視窗名稱(可選)',
|
||||
'systemManager.tmux.noWindows': '沒有視窗',
|
||||
'systemManager.tmux.unavailable': '此主機未偵測到 tmux',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到 Docker',
|
||||
'systemManager.docker.unavailable': '此主機未偵測到容器執行環境(Docker、Podman、nerdctl 或 kubectl)',
|
||||
'systemManager.tmux.windowsMismatch': '工作階段顯示有 {{count}} 個視窗,但 list-windows 未傳回任何視窗',
|
||||
'systemManager.tmux.lastCommand': '最後執行的指令:{{command}}',
|
||||
'systemManager.tmux.noPanes': '沒有窗格',
|
||||
@@ -194,6 +199,36 @@ export const zhTwSystemManagerMessages: Messages = {
|
||||
'systemManager.docker.imagesMeta': '{{count}} 個映像',
|
||||
'systemManager.docker.start': '啟動',
|
||||
'systemManager.docker.stop': '停止',
|
||||
'systemManager.runtime.switch': '容器執行環境',
|
||||
'systemManager.runtime.docker': 'Docker',
|
||||
'systemManager.runtime.podman': 'Podman',
|
||||
'systemManager.runtime.nerdctl': 'nerdctl',
|
||||
'systemManager.runtime.kubernetes': 'Kubernetes',
|
||||
'systemManager.k8s.context': '內容',
|
||||
'systemManager.k8s.namespace': '命名空間',
|
||||
'systemManager.k8s.empty': '沒有 Pod',
|
||||
'systemManager.k8s.search': '搜尋 Pod…',
|
||||
'systemManager.k8s.meta': '{{count}} 個 Pod',
|
||||
'systemManager.k8s.describe': '描述',
|
||||
'systemManager.k8s.delete': '刪除 Pod',
|
||||
'systemManager.k8s.confirmDelete': '確定刪除 Pod「{{name}}」?',
|
||||
'systemManager.k8s.restarts': '重新啟動 {{count}} 次',
|
||||
'systemManager.k8s.chooseContainer': '選擇容器',
|
||||
'systemManager.k8s.noContext': '此主機沒有 Kubernetes 內容',
|
||||
'systemManager.k8s.status.Running': '執行中',
|
||||
'systemManager.k8s.status.Pending': '等待中',
|
||||
'systemManager.k8s.status.Completed': '已完成',
|
||||
'systemManager.k8s.status.Succeeded': '已成功',
|
||||
'systemManager.k8s.status.Failed': '失敗',
|
||||
'systemManager.k8s.status.Unknown': '未知',
|
||||
'systemManager.k8s.status.CrashLoopBackOff': '當機重試',
|
||||
'systemManager.k8s.status.ContainerCreating': '建立中',
|
||||
'systemManager.k8s.status.Terminating': '終止中',
|
||||
'systemManager.k8s.status.ImagePullBackOff': '映像拉取退避',
|
||||
'systemManager.k8s.status.ErrImagePull': '拉取映像失敗',
|
||||
'systemManager.k8s.status.Error': '錯誤',
|
||||
'systemManager.k8s.status.Evicted': '已驅逐',
|
||||
'systemManager.k8s.status.OOMKilled': '記憶體不足',
|
||||
|
||||
'systemManager.ports.unavailable': '此主機未偵測到連接埠工具(ss / netstat)。',
|
||||
'systemManager.ports.loading': '正在載入監聽連接埠…',
|
||||
|
||||
@@ -454,6 +454,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms': '臨時連線允許舊版演算法',
|
||||
'settings.terminal.connection.temporaryLegacyAlgorithms.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式以 PuTTY 參數啟動,以及快速連線)會使用與主機「允許舊版演算法」相同的已棄用演算法。已儲存主機仍使用自己的設定。預設關閉,這些演算法有已知安全問題。',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode': '臨時連線使用網路裝置模式',
|
||||
'settings.terminal.connection.temporaryNetworkDeviceMode.desc': '沒有對應已儲存主機的 SSH 連線(包括外部程式啟動以及快速連線)會以網路裝置模式連線。已儲存主機和群組仍使用自己的裝置型別。預設關閉。',
|
||||
'settings.terminal.connection.defaultProxy': '預設代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
|
||||
'settings.terminal.connection.defaultProxy.none': '無',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉分頁或連線恢復。',
|
||||
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
|
||||
@@ -480,8 +487,8 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.systemManager.processRefreshInterval.desc': '系統管理側欄中程序列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval': 'tmux 工作階段重新整理間隔',
|
||||
'settings.terminal.systemManager.tmuxRefreshInterval.desc': 'tmux 工作階段列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': 'Docker 容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': 'Docker 容器列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval': '容器列表重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerListRefreshInterval.desc': '容器與 Pod 列表的重新整理頻率。',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval': 'Docker 效能資料重新整理間隔',
|
||||
'settings.terminal.systemManager.dockerStatsRefreshInterval.desc': 'Docker 容器 CPU/記憶體/網路指標的重新整理頻率。',
|
||||
|
||||
@@ -996,11 +1003,13 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'keyboard.interactive.desc': '伺服器需要額外的身分驗證。',
|
||||
'keyboard.interactive.descWithHost': '伺服器 {hostname} 需要額外的身分驗證。',
|
||||
'keyboard.interactive.response': '回應',
|
||||
'keyboard.interactive.verificationCode': '驗證碼',
|
||||
'keyboard.interactive.enterCode': '輸入驗證碼',
|
||||
'keyboard.interactive.enterResponse': '輸入回應',
|
||||
'keyboard.interactive.submit': '提交',
|
||||
'keyboard.interactive.submit': '送出',
|
||||
'keyboard.interactive.verifying': '驗證中...',
|
||||
'keyboard.interactive.savePassword': '儲存密碼',
|
||||
'keyboard.interactive.fillTotp': '填入驗證碼',
|
||||
|
||||
// Passphrase Modal for encrypted SSH keys
|
||||
'passphrase.title': 'SSH 金鑰密碼',
|
||||
|
||||
@@ -290,6 +290,35 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.auth.certificate.desc': '使用選取的 SSH 憑證;如果同時儲存了密碼,可在失敗後嘗試密碼。',
|
||||
'hostDetails.auth.mfaFirst': '優先互動式驗證',
|
||||
'hostDetails.auth.mfaFirst.desc': '適用於必須透過 keyboard-interactive 輸入密碼和 MFA 的伺服器。',
|
||||
'hostDetails.totp.title': '驗證器 (TOTP)',
|
||||
'hostDetails.totp.enable': '為這台主機儲存 TOTP 金鑰',
|
||||
'hostDetails.totp.setup': '設定…',
|
||||
'hostDetails.totp.edit': '編輯',
|
||||
'hostDetails.totp.modalTitle': '設定驗證器',
|
||||
'hostDetails.totp.replacePlaceholder': '貼上新金鑰以取代目前的金鑰',
|
||||
'hostDetails.totp.warning': '能開啟這個保險庫的人會同時取得驗證碼和密碼。請只在個人裝置上使用。',
|
||||
'hostDetails.totp.secret': '金鑰或 otpauth URI',
|
||||
'hostDetails.totp.secretPlaceholder': '貼上 otpauth://totp/... 或 Base32 金鑰',
|
||||
'hostDetails.totp.importQr': '匯入 QR 碼圖片',
|
||||
'hostDetails.totp.scanScreen': '從螢幕掃描 QR 碼',
|
||||
'hostDetails.totp.scanning': '正在掃描螢幕…',
|
||||
'hostDetails.totp.importedAccount': '已匯入 {account}',
|
||||
'hostDetails.totp.preview': '目前驗證碼',
|
||||
'hostDetails.totp.countdown': '剩餘 {seconds} 秒',
|
||||
'hostDetails.totp.autoSubmit': '在已知 TOTP 提示上自動送出',
|
||||
'hostDetails.totp.autoSubmitDesc': '每次連線只送出一次,且提示必須精確符合 JumpServer、Google Authenticator PAM,或你填寫的規則運算式。',
|
||||
'hostDetails.totp.promptPattern': '自訂提示規則',
|
||||
'hostDetails.totp.promptPatternPlaceholder': '可選的 RE2 規則運算式',
|
||||
'hostDetails.totp.clear': '移除驗證器',
|
||||
'hostDetails.totp.removeTitle': '移除驗證器?',
|
||||
'hostDetails.totp.removeMessage': '這台主機將不再保存這個 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.hotp': '不支援 HOTP。請使用 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.migration': '不支援 otpauth-migration QR 碼。請使用服務提供的單一帳號綁定 QR 碼。',
|
||||
'hostDetails.totp.error.invalid': '無法從該內容讀取 TOTP 金鑰。',
|
||||
'hostDetails.totp.error.notFound': '沒有找到 TOTP QR 碼。',
|
||||
'hostDetails.totp.error.permission': '需要螢幕錄製權限。請在系統設定中允許 Netcatty 錄製螢幕後再試。',
|
||||
'hostDetails.totp.error.unavailable': '無法使用螢幕擷取。',
|
||||
'hostDetails.totp.error.pattern': '自訂提示規則不是有效的 RE2 規則運算式。',
|
||||
'hostDetails.auth.credentialRequired': '儲存這種登入方式前,請先選擇金鑰或憑證。',
|
||||
'hostDetails.identity.suggestions': '身分',
|
||||
'hostDetails.identity.missing': '身分不存在',
|
||||
@@ -363,6 +392,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': '設定代理主機',
|
||||
'hostDetails.proxy': '透過 HTTP/SOCKS5/指令代理',
|
||||
'hostDetails.proxy.none': '無',
|
||||
'hostDetails.proxy.direct': '直連(不使用代理)',
|
||||
'hostDetails.proxy.directHint': '不使用群組或全域預設代理',
|
||||
'hostDetails.proxy.edit': '編輯代理',
|
||||
'hostDetails.proxy.configure': '設定代理',
|
||||
'hostDetails.envVars': '環境變數',
|
||||
@@ -532,6 +563,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'terminal.serverStats.cpu': 'CPU 使用率',
|
||||
'terminal.serverStats.cpuCores': 'CPU 核心使用率',
|
||||
'terminal.serverStats.gpu': 'GPU 使用率',
|
||||
'terminal.serverStats.gpuDevices': 'GPU 裝置使用率',
|
||||
'terminal.serverStats.gpuIndex': 'GPU {{index}}',
|
||||
'terminal.serverStats.memory': '記憶體使用',
|
||||
'terminal.serverStats.memoryDetails': '記憶體詳情',
|
||||
'terminal.serverStats.memUsed': '已用',
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
openQuickConnectFromInput,
|
||||
registerQuickConnectOpener,
|
||||
type QuickConnectOpenRequest,
|
||||
} from "./quickConnectBridge.ts";
|
||||
|
||||
test("openQuickConnectFromInput reuses the registered quick connect wizard", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput(" root@10.1.1.1:2222 "), true);
|
||||
assert.deepEqual(seen, [{
|
||||
target: { hostname: "10.1.1.1", username: "root", port: 2222 },
|
||||
warnings: [],
|
||||
}]);
|
||||
|
||||
unregister();
|
||||
assert.equal(openQuickConnectFromInput("10.1.1.1"), false);
|
||||
});
|
||||
|
||||
test("openQuickConnectFromInput ignores text that is not an address", () => {
|
||||
const seen: QuickConnectOpenRequest[] = [];
|
||||
const unregister = registerQuickConnectOpener((request) => {
|
||||
seen.push(request);
|
||||
});
|
||||
|
||||
assert.equal(openQuickConnectFromInput("core switch"), false);
|
||||
assert.deepEqual(seen, []);
|
||||
unregister();
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import {
|
||||
parseQuickConnectInputWithWarnings,
|
||||
type QuickConnectTarget,
|
||||
} from "../domain/quickConnect";
|
||||
|
||||
export interface QuickConnectOpenRequest {
|
||||
target: QuickConnectTarget;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
type QuickConnectOpener = (request: QuickConnectOpenRequest) => void;
|
||||
|
||||
let opener: QuickConnectOpener | null = null;
|
||||
|
||||
/** Vault owns the single QuickConnectWizard. The switcher asks it to open. */
|
||||
export function registerQuickConnectOpener(next: QuickConnectOpener): () => void {
|
||||
opener = next;
|
||||
return () => {
|
||||
if (opener === next) opener = null;
|
||||
};
|
||||
}
|
||||
|
||||
export function openQuickConnectFromInput(input: string): boolean {
|
||||
const parsed = parseQuickConnectInputWithWarnings(input);
|
||||
if (!parsed.target || !opener) return false;
|
||||
opener({ target: parsed.target, warnings: parsed.warnings });
|
||||
return true;
|
||||
}
|
||||
@@ -1,10 +1,21 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createAiPrivacyService } from '../../electron/shared/aiPrivacyService.cjs';
|
||||
import {
|
||||
hasReversibleModelPrivacy,
|
||||
setReversibleModelPrivacy,
|
||||
} from '../../infrastructure/ai/harness/modelSecretRedaction';
|
||||
import type { AISession } from '../../infrastructure/ai/types';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
cleanupClosedTerminalSessions,
|
||||
cleanupDeletedAIChatSessions,
|
||||
cleanupOrphanedAISessions,
|
||||
cleanupSdkAgentSessions,
|
||||
latestAIActiveSessionMapSnapshot,
|
||||
latestAISessionsSnapshot,
|
||||
setLatestAIActiveSessionMapSnapshot,
|
||||
setLatestAISessionsSnapshot,
|
||||
} from './aiStateSnapshots';
|
||||
|
||||
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
|
||||
@@ -61,6 +72,92 @@ function makeSession(id: string, updatedAt: number, messages: unknown[]): AISess
|
||||
} as never;
|
||||
}
|
||||
|
||||
test('orphan cleanup preserves resumable privacy maps until explicit chat deletion', (t) => {
|
||||
const service = createAiPrivacyService({ maxSessions: 3 });
|
||||
service.configure({ enabled: true });
|
||||
const sessions = [
|
||||
{ ...makeSession('privacy-orphan', 1, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
{ ...makeSession('privacy-live', 2, []), scope: { type: 'terminal' as const, targetId: 'open' } },
|
||||
{ ...makeSession('privacy-resumed', 3, []), scope: { type: 'terminal' as const, targetId: 'closed' } },
|
||||
];
|
||||
const aliases = sessions.map(session => {
|
||||
setReversibleModelPrivacy(session.id, true);
|
||||
return service.redact(session.id, '8.8.8.8');
|
||||
});
|
||||
const sdkCleanups: string[] = [];
|
||||
const privacyCleanups: string[] = [];
|
||||
const outputCleanups: string[] = [];
|
||||
const previousWindow = globalThis.window;
|
||||
const previousSessions = latestAISessionsSnapshot;
|
||||
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
|
||||
t.mock.method(localStorageAdapter, 'write', () => true);
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
dispatchEvent: () => true,
|
||||
netcatty: {
|
||||
aiSdkAgentCleanup: async (id: string) => {
|
||||
sdkCleanups.push(id);
|
||||
return { ok: true };
|
||||
},
|
||||
aiPrivacyClearSession: async (id: string) => {
|
||||
privacyCleanups.push(id);
|
||||
service.clearSession(id);
|
||||
return { ok: true };
|
||||
},
|
||||
deleteChatToolOutputsTemp: async (id: string) => {
|
||||
outputCleanups.push(id);
|
||||
return { deletedCount: 1 };
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
setLatestAISessionsSnapshot(sessions);
|
||||
setLatestAIActiveSessionMapSnapshot({
|
||||
'terminal:closed': 'privacy-orphan',
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
|
||||
try {
|
||||
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
|
||||
|
||||
assert.equal(latestAISessionsSnapshot, sessions);
|
||||
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
|
||||
'terminal:open': 'privacy-live',
|
||||
'terminal:resumed-into': 'privacy-resumed',
|
||||
});
|
||||
assert.deepEqual(sdkCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(privacyCleanups, []);
|
||||
assert.deepEqual(outputCleanups, []);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.redact(session.id, '8.8.8.8'), aliases[index]);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
assert.throws(() => service.checkSession('privacy-next'), /session limit reached/);
|
||||
|
||||
cleanupDeletedAIChatSessions(['privacy-orphan']);
|
||||
|
||||
assert.deepEqual(privacyCleanups, ['privacy-orphan']);
|
||||
assert.deepEqual(outputCleanups, ['privacy-orphan']);
|
||||
assert.equal(hasReversibleModelPrivacy('privacy-orphan'), false);
|
||||
assert.equal(service.hasSession('privacy-orphan'), false);
|
||||
assert.throws(() => service.checkSession('privacy-orphan'), /start a new chat/);
|
||||
assert.equal(service.checkSession('privacy-next'), true);
|
||||
for (const [index, session] of sessions.entries()) {
|
||||
if (session.id === 'privacy-orphan') continue;
|
||||
assert.equal(hasReversibleModelPrivacy(session.id), true);
|
||||
assert.equal(service.restore(session.id, aliases[index]), '8.8.8.8');
|
||||
}
|
||||
} finally {
|
||||
for (const session of sessions) setReversibleModelPrivacy(session.id, false);
|
||||
setLatestAISessionsSnapshot(previousSessions ?? []);
|
||||
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: previousWindow });
|
||||
}
|
||||
});
|
||||
|
||||
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
|
||||
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
|
||||
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
|
||||
|
||||
@@ -44,6 +44,34 @@ test('unfocused approval alerts name the tool and host and defer focus to the ma
|
||||
}]);
|
||||
});
|
||||
|
||||
test('approval alerts show the MCP tool name for an internal rpc id', () => {
|
||||
const calls: Array<{ body: string }> = [];
|
||||
const shown = presentExternalMcpApprovalNotification({
|
||||
toolName: 'netcatty/exec',
|
||||
hostLabel: '127.0.0.1',
|
||||
dedupeKey: 'mcp_approval_exec',
|
||||
mode: 'always',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(shown, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].body, /terminal_execute/);
|
||||
assert.equal(calls[0].body, 'terminal_execute on 127.0.0.1 is waiting for approval');
|
||||
});
|
||||
|
||||
test('approval alerts keep an id that has no MCP tool name', () => {
|
||||
const calls: Array<{ body: string }> = [];
|
||||
presentExternalMcpApprovalNotification({
|
||||
toolName: 'netcatty/not-a-tool',
|
||||
dedupeKey: 'mcp_approval_unknown',
|
||||
mode: 'unfocused',
|
||||
t,
|
||||
notify: (payload) => calls.push(payload),
|
||||
});
|
||||
assert.equal(calls[0].body, 'netcatty/not-a-tool is waiting for approval');
|
||||
});
|
||||
|
||||
test('always approval alerts notify even while a window may be focused', () => {
|
||||
const calls: Array<{ title: string; body: string; whenUnfocused: boolean; dedupeKey: string }> = [];
|
||||
presentExternalMcpApprovalNotification({
|
||||
|
||||
@@ -1,10 +1,33 @@
|
||||
import type { ApprovalRequest } from '../../infrastructure/ai/shared/approvalGate';
|
||||
import cattyToolSpecs from '../../infrastructure/ai/harness/generated/cattyToolSpecs.json';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
readExternalMcpApprovalAlertMode,
|
||||
type ExternalMcpApprovalAlertMode,
|
||||
} from './useExternalMcpToggleState';
|
||||
|
||||
type CatalogToolSpec = {
|
||||
toolName?: string;
|
||||
rpcMethod?: string | null;
|
||||
};
|
||||
|
||||
const mcpToolNameByRpcMethod = new Map<string, string>();
|
||||
for (const spec of cattyToolSpecs as CatalogToolSpec[]) {
|
||||
if (!spec.rpcMethod || !spec.toolName || mcpToolNameByRpcMethod.has(spec.rpcMethod)) continue;
|
||||
mcpToolNameByRpcMethod.set(spec.rpcMethod, spec.toolName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Name shown on the External MCP approval card.
|
||||
* Catalog MCP tool names win over internal RPC ids such as `netcatty/exec`.
|
||||
* An id with no catalog tool name is shown as-is.
|
||||
*/
|
||||
export function externalMcpApprovalToolLabel(toolName: string | undefined): string {
|
||||
const raw = toolName?.trim() || '';
|
||||
if (!raw) return '';
|
||||
return mcpToolNameByRpcMethod.get(raw) || raw;
|
||||
}
|
||||
|
||||
export type ExternalMcpApprovalNotification = {
|
||||
title: string;
|
||||
body: string;
|
||||
@@ -31,7 +54,7 @@ export function presentExternalMcpApprovalNotification(options: {
|
||||
notify: (payload: ExternalMcpApprovalNotification) => void;
|
||||
}): boolean {
|
||||
if (options.mode === 'off') return false;
|
||||
const tool = options.toolName || '';
|
||||
const tool = externalMcpApprovalToolLabel(options.toolName);
|
||||
const host = options.hostLabel?.trim() || '';
|
||||
const body = host
|
||||
? options.t('ai.externalMcp.approvalAlert.notificationBodyOnHost', { tool, host })
|
||||
|
||||
@@ -13,10 +13,11 @@ type CaptureSession = Pick<TerminalSession, "id" | "protocol" | "status" | "last
|
||||
|
||||
export interface CaptureInheritedCwdOptions {
|
||||
/**
|
||||
* The session's live tracked cwd (OSC 7), sourced from the terminal-state
|
||||
* cwd map rather than the session object. This is the freshest value and the
|
||||
* only one that reflects `cd`s in a running LOCAL terminal (whose live cwd is
|
||||
* never mirrored onto `TerminalSession.lastCwd`).
|
||||
* The session's live tracked cwd (OSC 7 or the post-command cwd probe),
|
||||
* sourced from the terminal-state cwd map rather than the session object.
|
||||
* This is the freshest value and the one that reflects `cd`s in a running
|
||||
* local terminal. The live value stays off `TerminalSession.lastCwd` until
|
||||
* session restore persists it.
|
||||
*/
|
||||
liveCwd?: string;
|
||||
/**
|
||||
@@ -35,7 +36,7 @@ export const DEFAULT_INHERITED_CWD_PROBE_TIMEOUT_MS = 1500;
|
||||
/**
|
||||
* Resolve the working directory a clone/split should inherit from its source.
|
||||
*
|
||||
* Priority: live tracked cwd (OSC 7) -> live SSH `/proc` probe (when allowed)
|
||||
* Priority: live tracked cwd (OSC 7 or post-command probe) -> live SSH `/proc` probe (when allowed)
|
||||
* -> tracked `lastCwd` snapshot -> local `localStartDir`. The probe is raced
|
||||
* against a short timeout so a slow/wedged connection can't block tab creation,
|
||||
* and is skipped entirely when `allowSshProbe` is false. Returns undefined when
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import {
|
||||
@@ -58,6 +59,10 @@ import {
|
||||
normalizeHttpNetworkProxySettings,
|
||||
type HttpNetworkProxySettings,
|
||||
} from '../../domain/httpNetworkProxy';
|
||||
import {
|
||||
normalizeGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
isValidUiFontId,
|
||||
@@ -98,6 +103,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
|
||||
setGlobalDefaultProxyProfileIdState: Dispatch<SetStateAction<string | undefined>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
@@ -146,6 +152,7 @@ export function useSettingsIpcSync({
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -292,6 +299,11 @@ export function useSettingsIpcSync({
|
||||
areHttpNetworkProxySettingsEqual(prev, next) ? prev : next
|
||||
));
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(value);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -354,6 +366,7 @@ export function useSettingsIpcSync({
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
resolveThemeAccentForeground,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
migrateMaterializedShiftEnterNewlineDefault,
|
||||
} from "./settingsStateDefaults.ts";
|
||||
import { TERMINAL_FONT_AUTO } from "../../infrastructure/config/fonts.ts";
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
|
||||
} from "../../infrastructure/config/storageKeys.ts";
|
||||
import type { UiThemeTokens } from "../../infrastructure/config/uiThemes.ts";
|
||||
|
||||
@@ -214,3 +216,76 @@ test("migrateMaterializedAutoCloseOnExitDefault leaves fresh installs and explic
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedShiftEnterNewlineDefault rewrites the old default once", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
|
||||
);
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
const migrated = JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!);
|
||||
assert.equal(migrated.shiftEnterNewlineText, "\\e\\r");
|
||||
assert.equal(migrated.scrollback, 3000);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
|
||||
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ scrollback: 3000, shiftEnterNewlineText: "\\n" }),
|
||||
);
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
|
||||
"\\n",
|
||||
);
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedShiftEnterNewlineDefault rewrites a raw stored newline", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ shiftEnterNewlineText: "\n" }),
|
||||
);
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
assert.equal(
|
||||
JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!).shiftEnterNewlineText,
|
||||
"\\e\\r",
|
||||
);
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedShiftEnterNewlineDefault keeps custom Shift+Enter text", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
store.set(
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
JSON.stringify({ shiftEnterNewlineText: "\\r", scrollback: 1000 }),
|
||||
);
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
assert.deepEqual(JSON.parse(store.get(STORAGE_KEY_TERM_SETTINGS)!), {
|
||||
shiftEnterNewlineText: "\\r",
|
||||
scrollback: 1000,
|
||||
});
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
|
||||
test("migrateMaterializedShiftEnterNewlineDefault leaves a fresh install without a settings blob", () => {
|
||||
const store = installMemoryLocalStorage();
|
||||
try {
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
assert.equal(store.has(STORAGE_KEY_TERM_SETTINGS), false);
|
||||
assert.equal(store.get(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED), "true");
|
||||
} finally {
|
||||
delete (globalThis as { localStorage?: unknown }).localStorage;
|
||||
}
|
||||
});
|
||||
@@ -1,8 +1,15 @@
|
||||
import type { HotkeyScheme, SessionLogFormat, TerminalSettings } from '../../domain/models';
|
||||
import {
|
||||
DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
|
||||
isLegacyDefaultShiftEnterNewlineText,
|
||||
type HotkeyScheme,
|
||||
type SessionLogFormat,
|
||||
type TerminalSettings,
|
||||
} from '../../domain/models';
|
||||
import {
|
||||
STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED,
|
||||
STORAGE_KEY_TERM_FONT_FAMILY,
|
||||
STORAGE_KEY_TERM_SETTINGS,
|
||||
STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import {
|
||||
isDeprecatedPrimaryFontId,
|
||||
@@ -91,6 +98,26 @@ export function migrateMaterializedAutoCloseOnExitDefault(): void {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_AUTO_CLOSE_EXIT_MIGRATED, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* One-time migration for the Shift+Enter default flip (#3770). Builds before
|
||||
* that change materialized `\\n` into saved settings, so upgraders kept a
|
||||
* bare newline and TUI CLIs still submitted. Rewrite only that old default
|
||||
* (escaped or raw) to ESC+CR, then persist the marker. A later explicit `\\n`
|
||||
* is left alone. Custom text is never rewritten. Sync and import do not
|
||||
* read this marker; an old `\\n` from another device stays as stored.
|
||||
*/
|
||||
export function migrateMaterializedShiftEnterNewlineDefault(): void {
|
||||
if (localStorageAdapter.readBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED)) return;
|
||||
const stored = localStorageAdapter.read<Partial<TerminalSettings>>(STORAGE_KEY_TERM_SETTINGS);
|
||||
if (stored && isLegacyDefaultShiftEnterNewlineText(stored.shiftEnterNewlineText)) {
|
||||
localStorageAdapter.write(STORAGE_KEY_TERM_SETTINGS, {
|
||||
...stored,
|
||||
shiftEnterNewlineText: DEFAULT_SHIFT_ENTER_NEWLINE_TEXT,
|
||||
});
|
||||
}
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_TERM_SHIFT_ENTER_NEWLINE_MIGRATED, true);
|
||||
}
|
||||
|
||||
// Auto-detect default hotkey scheme based on platform
|
||||
export const DEFAULT_HOTKEY_SCHEME: HotkeyScheme =
|
||||
typeof navigator !== 'undefined' && /Mac|iPhone|iPad|iPod/i.test(navigator.platform)
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
@@ -179,9 +180,11 @@ export async function openTransferSftpSession(
|
||||
// Restart/resume owns an independent SSH dial. Normal pooled transfer opens
|
||||
// leave reuseTransport enabled so the main-process registry can reuse an
|
||||
// authenticated endpoint even when no terminal tab exists.
|
||||
// No SFTP-page host-key dialog owns this session id. An untrusted key must
|
||||
// fail the transfer immediately instead of sitting on the verifier TTL.
|
||||
const openOpts = wantDedicated
|
||||
? { ...credentials, reuseTransport: false as const }
|
||||
: credentials;
|
||||
? { ...credentials, reuseTransport: false as const, hostKeyUnattended: true as const }
|
||||
: { ...credentials, hostKeyUnattended: true as const };
|
||||
|
||||
// The main-process auth driver owns method ordering, fallback, MFA and
|
||||
// retry. A renderer-side key-then-password retry performs a second full SSH
|
||||
@@ -740,7 +743,7 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
const err = error as Error & { dedicatedAttention?: boolean; resetCheckpoint?: boolean };
|
||||
return {
|
||||
success: false,
|
||||
error: err instanceof Error ? err.message : String(error),
|
||||
error: unwrapSftpIpcError(error),
|
||||
needsAttention: !!err.dedicatedAttention,
|
||||
resetCheckpoint: !!err.resetCheckpoint,
|
||||
};
|
||||
|
||||
@@ -38,6 +38,20 @@ test("runWithTransferRetry does not retry permanent errors", async () => {
|
||||
assert.equal(attempts, 1);
|
||||
});
|
||||
|
||||
test("a dead SFTP channel is not retried into another dial (#3310)", async () => {
|
||||
assert.equal(isTransientTransferError(new Error("SFTP channel closed")), false);
|
||||
assert.equal(isTransientTransferError(new Error("SFTP session not found")), true);
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
() => runWithTransferRetry(async () => {
|
||||
attempts += 1;
|
||||
throw new Error("SFTP channel closed");
|
||||
}, { retries: 1, delayMs: 0 }),
|
||||
/SFTP channel closed/,
|
||||
);
|
||||
assert.equal(attempts, 1, "channel death must not schedule a reconnect");
|
||||
});
|
||||
|
||||
test("runWithTransferRetry does not retry cancel", async () => {
|
||||
let attempts = 0;
|
||||
await assert.rejects(
|
||||
|
||||
@@ -6,6 +6,9 @@ import { isSessionError } from "./errors";
|
||||
* path) should not be retried.
|
||||
*/
|
||||
export function isTransientTransferError(err: unknown): boolean {
|
||||
// The channel is gone. Retrying opens a new dial, and an OTP host then
|
||||
// sits on keyboard-interactive while the transfer stays active.
|
||||
if (err instanceof Error && /sftp channel closed/i.test(err.message)) return false;
|
||||
if (isSessionError(err)) return true;
|
||||
if (!(err instanceof Error)) return false;
|
||||
const msg = err.message.toLowerCase();
|
||||
|
||||
@@ -4,6 +4,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import type { Host, Identity, KnownHost, SftpConnection, SftpFileEntry, SftpFilenameEncoding, SSHKey } from "../../../domain/models";
|
||||
import { createKnownHostFromHostKeyInfo } from "../../../domain/knownHosts";
|
||||
import type { SftpHostKeyInfo, SftpHostKeyVerificationState, SftpPane } from "./types";
|
||||
import { unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryListing } from "./useSftpDirectoryListing";
|
||||
import { useSftpHostCredentials } from "./useSftpHostCredentials";
|
||||
import { buildCacheKey, getSharedRemoteHostCache, setSharedRemoteHostCache } from "./sharedRemoteHostCache";
|
||||
@@ -957,7 +958,7 @@ export const useSftpConnections = ({
|
||||
if (!isTargetConnectionAtPath(startPath)) return;
|
||||
updateTargetTab((prev) => ({
|
||||
...prev,
|
||||
error: err instanceof Error ? err.message : "Failed to list directory",
|
||||
error: unwrapSftpIpcError(err) || "Failed to list directory",
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
@@ -1263,14 +1264,14 @@ export const useSftpConnections = ({
|
||||
? {
|
||||
...prev.connection,
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Connection failed",
|
||||
error: unwrapSftpIpcError(err) || "Connection failed",
|
||||
}
|
||||
: null,
|
||||
files: isReconnectAttempt ? [] : prev.files,
|
||||
selectedFiles: isReconnectAttempt ? new Set<string>() : prev.selectedFiles,
|
||||
error: isReconnectAttempt
|
||||
? "sftp.error.reconnectFailed"
|
||||
: (err instanceof Error ? err.message : "Connection failed"),
|
||||
: (unwrapSftpIpcError(err) || "Connection failed"),
|
||||
loading: false,
|
||||
reconnecting: false,
|
||||
}));
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Host, Identity, KnownHost, SSHKey, TerminalSettings } from "../../
|
||||
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from "../../../domain/credentials";
|
||||
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../../../domain/sshAuth";
|
||||
import { resolveHostKeepalive } from "../../../domain/host";
|
||||
import { connectionTotpFields } from "../../../domain/totpConfig";
|
||||
import { resolveHostSshConnectionTimeouts } from "../../../domain/sshConnectionTimeouts";
|
||||
import {
|
||||
findIncompleteProxyIdentityId,
|
||||
@@ -121,6 +122,7 @@ export const buildSftpHostCredentials = ({
|
||||
authMethod: jumpAuth.authMethod,
|
||||
requiresMfa: !!jumpHost.requiresMfa,
|
||||
password: jumpPassword,
|
||||
...connectionTotpFields(jumpHost),
|
||||
privateKey: jumpKeyAuth.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpKeyAuth.passphrase,
|
||||
@@ -182,6 +184,7 @@ export const buildSftpHostCredentials = ({
|
||||
requiresMfa: !!host.requiresMfa,
|
||||
port: host.port || 22,
|
||||
password,
|
||||
...connectionTotpFields(host),
|
||||
privateKey: keyAuth.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: keyAuth.passphrase,
|
||||
|
||||
@@ -56,6 +56,166 @@ test(`failed ${direction} connection replaces Waiting with the actual error (#36
|
||||
});
|
||||
}
|
||||
|
||||
test("upload open uses known hosts trusted after the transfer callback was created (#3310)", async () => {
|
||||
const seen: string[] = [];
|
||||
let fingerprint = "";
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
let firstStart: ReturnType<typeof useSftpTransfers>["startTransfer"] | undefined;
|
||||
const restore = installGlobals({});
|
||||
function Probe() {
|
||||
const captured = fingerprint;
|
||||
const acquireTransferSession = React.useCallback(async () => {
|
||||
seen.push(captured);
|
||||
throw new Error(`known-hosts:${captured || "empty"}`);
|
||||
}, [captured]);
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "known-hosts-after-trust-3310",
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession,
|
||||
});
|
||||
if (!firstStart) firstStart = ops.startTransfer;
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
fingerprint = "trusted-this-session";
|
||||
await act(async () => { renderer?.update(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await firstStart!(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.deepEqual(seen, ["trusted-this-session"]);
|
||||
const task = sftpTransferCenterStore.getOwnerTasks("known-hosts-after-trust-3310")[0];
|
||||
assert.equal(task?.error, "known-hosts:trusted-this-session");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("channel death fails the upload without a second session open (#3310)", async () => {
|
||||
let acquires = 0;
|
||||
let streams = 0;
|
||||
const restore = installGlobals({
|
||||
statLocal: async () => ({ size: 48, lastModified: 1 }),
|
||||
statSftp: async () => null,
|
||||
startStreamTransfer: async () => {
|
||||
streams += 1;
|
||||
return { error: "SFTP channel closed" };
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
resumeTransfer: async () => ({ success: false, reason: "Transfer is no longer active" }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "channel-death-no-reconnect-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
acquires += 1;
|
||||
return {
|
||||
sftpId: "sftp-borrowed",
|
||||
release: () => undefined,
|
||||
discard: () => undefined,
|
||||
};
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
assert.equal(streams, 1, "channel death must not start another transfer dial");
|
||||
assert.equal(acquires, 2, "work lease plus the file lease, and no retry open");
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.status, "failed");
|
||||
assert.equal(task?.error, "SFTP channel closed");
|
||||
assert.equal(
|
||||
sftpTransferCenterStore.getOwnerTasks(ownerId)
|
||||
.filter((row) => ["pending", "queued", "transferring", "pausing"].includes(row.status))
|
||||
.length,
|
||||
0,
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("transfer failure strips the Electron IPC wrapper (#3310)", async () => {
|
||||
const restore = installGlobals({});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
const ownerId = "ipc-prefix-3310";
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId,
|
||||
getActivePane: (side) => makePane(side === "left" ? "right" : "left"),
|
||||
getPaneByConnectionId: () => null,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
acquireTransferSession: async () => {
|
||||
throw new Error("Error invoking remote method 'netcatty:sftp:open': Error: Host key verification required for 127.0.0.1");
|
||||
},
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer(
|
||||
[{ name: "upload.txt", isDirectory: false }],
|
||||
"left",
|
||||
"right",
|
||||
);
|
||||
});
|
||||
const task = sftpTransferCenterStore.getOwnerTasks(ownerId)[0];
|
||||
assert.equal(task?.error, "Host key verification required for 127.0.0.1");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
type StartOptions = {
|
||||
transferId: string;
|
||||
sourcePath: string;
|
||||
@@ -712,3 +872,127 @@ test("owned cancelled directory settles recoverable children without dropping co
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
for (const liveSize of [37, 3, 0]) {
|
||||
test(`same-pane local copy discovers ${liveSize} bytes after the listed file changes`, async () => {
|
||||
const pane = makePane("right");
|
||||
pane.files = [{ ...makePane("left").files[0], name: "edited.txt", size: 15 }];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
statLocal: async (path: string) => path.endsWith("edited.txt")
|
||||
? { type: "file", size: liveSize, lastModified: 1758000002000 }
|
||||
: null,
|
||||
startStreamTransfer: async (options: StartOptions) => { started.push(options); return {}; },
|
||||
pauseTransfer: async () => ({ success: false }),
|
||||
resumeTransfer: async () => ({ success: false }),
|
||||
});
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: `local-edited-${liveSize}`,
|
||||
getActivePane: () => pane,
|
||||
getPaneByConnectionId: () => pane,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "edited.txt", isDirectory: false }], "right", "right");
|
||||
});
|
||||
assert.equal(ops!.conflicts.length, 1);
|
||||
assert.equal(ops!.conflicts[0].newSize, liveSize, "conflict must show the saved file's current size");
|
||||
assert.equal(ops!.conflicts[0].newModified, 1758000002000);
|
||||
await act(async () => {
|
||||
await ops!.resolveConflict(ops!.conflicts[0].transferId, "duplicate", false);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
});
|
||||
assert.equal(started.length, 1);
|
||||
assert.equal(started[0].totalBytes, liveSize || undefined, "copy must not retain the cached 15-byte size");
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("no-conflict local copy awaits discovery so a late stat cannot overwrite the settled total", async () => {
|
||||
const pane = makePane("right");
|
||||
pane.files = [{ ...makePane("left").files[0], name: "late.txt", size: 15 }];
|
||||
const started: StartOptions[] = [];
|
||||
const restore = installGlobals({
|
||||
// Target conflict stat: missing path means the copy has no conflict.
|
||||
lstatLocal: async () => null,
|
||||
statLocal: async (path: string) => {
|
||||
if (!path.endsWith("late.txt")) return null;
|
||||
// Let the stream finish before the stat resolves — fired-and-forgotten
|
||||
// discovery used to patch the terminal task with this stale total.
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
return { type: "file", size: 9_999, lastModified: 1758000002000 };
|
||||
},
|
||||
startStreamTransfer: async (options: StartOptions) => {
|
||||
started.push(options);
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "completed",
|
||||
transferId: options.transferId,
|
||||
transferred: 15,
|
||||
totalBytes: 15,
|
||||
lifecycleEpoch: 0,
|
||||
});
|
||||
return {};
|
||||
},
|
||||
pauseTransfer: async () => ({ success: false }),
|
||||
resumeTransfer: async () => ({ success: false }),
|
||||
});
|
||||
|
||||
let ops: ReturnType<typeof useSftpTransfers> | undefined;
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
function Probe() {
|
||||
ops = useSftpTransfers({
|
||||
ownerId: "local-late-stat",
|
||||
getActivePane: () => pane,
|
||||
getPaneByConnectionId: () => pane,
|
||||
getTabByConnectionId: () => null,
|
||||
updateTab: () => undefined,
|
||||
refresh: async () => undefined,
|
||||
clearCacheForConnection: () => undefined,
|
||||
handleSessionError: () => undefined,
|
||||
sftpSessionsRef: { current: new Map() },
|
||||
connectionCacheKeyMapRef: { current: new Map() },
|
||||
listLocalFiles: async () => [],
|
||||
listRemoteFiles: async () => [],
|
||||
});
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
await act(async () => { renderer = create(React.createElement(Probe)); });
|
||||
await act(async () => {
|
||||
await ops!.startTransfer([{ name: "late.txt", isDirectory: false }], "right", "right");
|
||||
});
|
||||
// Give the no longer pending deferred stat time to land if it leaked.
|
||||
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 600)); });
|
||||
const row = sftpTransferCenterStore.getSnapshot().tasks.find(
|
||||
(candidate) => candidate.originalFileName === "late.txt",
|
||||
);
|
||||
assert.ok(row);
|
||||
assert.equal(row.status, "completed");
|
||||
assert.equal(
|
||||
row.transferredBytes,
|
||||
row.totalBytes,
|
||||
"a deferred stat must not desync the completed row's total from its transferred bytes",
|
||||
);
|
||||
} finally {
|
||||
await act(async () => { renderer?.unmount(); });
|
||||
restore();
|
||||
}
|
||||
});
|
||||
@@ -24,7 +24,7 @@ import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge"
|
||||
import { logger } from "../../../lib/logger";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
import { SftpPane } from "./types";
|
||||
import { isMissingStatError } from "./errors";
|
||||
import { isMissingStatError, unwrapSftpIpcError } from "./errors";
|
||||
import { useSftpDirectoryTransferOps } from "./transferDirectoryOps";
|
||||
import { useSftpTransferConflictOps } from "./transferConflictOps";
|
||||
import { useSftpTransferTaskOps } from "./transferTaskOps";
|
||||
@@ -191,6 +191,21 @@ export const useSftpTransfers = ({
|
||||
const { t } = useI18n();
|
||||
const tRef = useRef(t);
|
||||
tRef.current = t;
|
||||
// startTransfer keeps the processTransfer closure from its first render.
|
||||
// Read the latest acquire fn so a host key trusted after that render is
|
||||
// included in the transfer open, instead of the empty list from mount.
|
||||
const acquireTransferSessionRef = useRef(acquireTransferSession);
|
||||
acquireTransferSessionRef.current = acquireTransferSession;
|
||||
const acquireTransferSessionLive = useCallback<NonNullable<UseSftpTransfersParams["acquireTransferSession"]>>(
|
||||
(hostId, transferId, connectHost) => {
|
||||
const acquire = acquireTransferSessionRef.current;
|
||||
if (!acquire) {
|
||||
return Promise.reject(new Error("SFTP transfer session acquisition is unavailable"));
|
||||
}
|
||||
return acquire(hostId, transferId, connectHost);
|
||||
},
|
||||
[],
|
||||
);
|
||||
// Ref so progress flushes always see the latest visibility without rebinding.
|
||||
const surfaceVisibleRef = useRef(surfaceVisible);
|
||||
surfaceVisibleRef.current = surfaceVisible;
|
||||
@@ -468,7 +483,7 @@ export const useSftpTransfers = ({
|
||||
setTransfers,
|
||||
listLocalFiles,
|
||||
listRemoteFiles,
|
||||
acquireTransferSession,
|
||||
acquireTransferSession: acquireTransferSession ? acquireTransferSessionLive : undefined,
|
||||
});
|
||||
|
||||
const processTransfer = async (
|
||||
@@ -602,8 +617,9 @@ export const useSftpTransfers = ({
|
||||
// survive browse park and tab hide (pool holders, not panel map ids).
|
||||
if (!sourcePane.connection?.isLocal) {
|
||||
const sourceHostId = task.sourceHostId || sourcePane.connection?.hostId;
|
||||
if (acquireTransferSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(sourceHostId, `${task.id}:work-source`);
|
||||
const acquireSourceSession = acquireTransferSessionRef.current;
|
||||
if (acquireSourceSession && sourceHostId) {
|
||||
sourceWorkLease = await acquireSourceSession(sourceHostId, `${task.id}:work-source`);
|
||||
sourceSftpId = sourceWorkLease.sftpId;
|
||||
}
|
||||
if (!sourceSftpId) {
|
||||
@@ -615,8 +631,9 @@ export const useSftpTransfers = ({
|
||||
|
||||
if (!targetPane.connection?.isLocal) {
|
||||
const targetHostId = task.targetHostId || targetPane.connection?.hostId;
|
||||
if (acquireTransferSession && targetHostId) {
|
||||
targetWorkLease = await acquireTransferSession(targetHostId, `${task.id}:work-target`);
|
||||
const acquireTargetSession = acquireTransferSessionRef.current;
|
||||
if (acquireTargetSession && targetHostId) {
|
||||
targetWorkLease = await acquireTargetSession(targetHostId, `${task.id}:work-target`);
|
||||
targetSftpId = targetWorkLease.sftpId;
|
||||
}
|
||||
if (!targetSftpId) {
|
||||
@@ -811,8 +828,8 @@ export const useSftpTransfers = ({
|
||||
})();
|
||||
|
||||
// For single files: start size discovery without blocking the conflict
|
||||
// check, but keep the promise so an actual conflict can await fresh
|
||||
// size metadata before the dialog is shown.
|
||||
// check, but always settle the promise before any dialog or transfer
|
||||
// launch (bounded per-task; the task is already visible/cancellable).
|
||||
const sizeDiscoveryPromise = task.isDirectory ? null : discoverTransferSize();
|
||||
|
||||
// Only await conflict check (fast single stat call)
|
||||
@@ -823,19 +840,23 @@ export const useSftpTransfers = ({
|
||||
// null means the plan size is still unknown. A verified 0-byte stat is 0,
|
||||
// not null, so an empty file does not pick up a stale listing size.
|
||||
let verifiedPlanBytes: number | null = task.totalBytes > 0 ? task.totalBytes : null;
|
||||
if (conflict && sizeDiscoveryPromise) {
|
||||
if (sizeDiscoveryPromise) {
|
||||
// The task's totalBytes may still be 0 here because discovery ran
|
||||
// deferred and updateTask never mutates the local task copy. Await
|
||||
// discovery (bounded per-task; the task is already visible/cancellable)
|
||||
// so the overwrite dialog shows the real source size, not 0 (#3559).
|
||||
// discovery even without a conflict so its statLocal updateTask cannot
|
||||
// resolve after the transfer finished and overwrite the bridge's
|
||||
// authoritative total with a stale late size (the conflict dialog also
|
||||
// needs it: #3559).
|
||||
const discovered = await sizeDiscoveryPromise;
|
||||
// Cancel during this stat must win before an apply-to-all Replace
|
||||
// deletes an existing symlink.
|
||||
if (cancelledTasksRef.current.has(task.id)) return "cancelled";
|
||||
if (discovered && discovered.size >= 0) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
if (conflict) {
|
||||
conflict.newSize = discovered.size;
|
||||
if (discovered.lastModified) conflict.newModified = discovered.lastModified;
|
||||
if (discovered.plan) verifiedPlanBytes = discovered.size;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1118,7 +1139,7 @@ export const useSftpTransfers = ({
|
||||
|
||||
updateTask({
|
||||
status: "failed",
|
||||
error: err instanceof Error ? err.message : "Transfer failed",
|
||||
error: unwrapSftpIpcError(err) || "Transfer failed",
|
||||
endTime: Date.now(),
|
||||
speed: 0,
|
||||
});
|
||||
@@ -1267,25 +1288,24 @@ export const useSftpTransfers = ({
|
||||
// Prefer the fresh re-stat for remote files: a cached listing size can
|
||||
// be stale and undersized, which would silently truncate downloads
|
||||
// (#3559). Remote files without a live stat use plan 0 so the bridge
|
||||
// measures the live size; local sources keep the pane metadata.
|
||||
// measures the live size. Local files also start unknown so deferred
|
||||
// discovery reads their current size after edits, not the cached listing.
|
||||
const fileEntry = fileEntryMap?.get(file.name);
|
||||
const freshMetadata = freshRemoteMetadata.get(file.name);
|
||||
const fileSize = file.isDirectory
|
||||
? 0
|
||||
: freshMetadata
|
||||
? freshMetadata.size
|
||||
: sourcePane.connection!.isLocal
|
||||
? (fileEntry?.size ?? 0)
|
||||
: 0;
|
||||
// Only keep an mtime that is paired with trusted metadata (live re-stat,
|
||||
// local source size, or directory totals). A remote file whose re-stat
|
||||
// failed must stay fully unknown (size 0 AND mtime 0 in the plan):
|
||||
: 0;
|
||||
// Only keep an mtime paired with a live re-stat or directory totals.
|
||||
// A file without a live stat must stay fully unknown
|
||||
// (size 0 AND mtime 0 in the plan):
|
||||
// discoverTransferSize and the conflict check both treat a nonzero
|
||||
// sourceLastModified as proof the size is already known, so a stale
|
||||
// mtime would suppress the deferred source re-stat. The overwrite
|
||||
// dialog still gets the pane listing's cached entry via
|
||||
// discoverTransferSize's cachedListingStatForDialog fallback.
|
||||
const sourceLastModified = !file.isDirectory && !sourcePane.connection!.isLocal && !freshMetadata
|
||||
const sourceLastModified = !file.isDirectory && !freshMetadata
|
||||
? 0
|
||||
: (freshMetadata?.lastModified ?? fileEntry?.lastModified ?? 0);
|
||||
|
||||
@@ -1504,8 +1524,8 @@ export const useSftpTransfers = ({
|
||||
try {
|
||||
const result = await retryExternalDragDropFileUpload(task, {
|
||||
getBrowseSftpId: (connectionId) => sftpSessionsRef.current.get(connectionId),
|
||||
acquireTransferSession: acquireTransferSession
|
||||
? (hostId, id) => acquireTransferSession(hostId, id)
|
||||
acquireTransferSession: acquireTransferSessionRef.current
|
||||
? (hostId, id) => acquireTransferSessionLive(hostId, id)
|
||||
: undefined,
|
||||
startStreamTransfer: async (options) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
@@ -1997,8 +2017,9 @@ export const useSftpTransfers = ({
|
||||
let sourceWorkLease: TransferConnectionLease | null = null;
|
||||
let workingSourceSftpId = params.sftpId;
|
||||
try {
|
||||
if (acquireTransferSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireTransferSession(
|
||||
const acquireDownloadSession = acquireTransferSessionRef.current;
|
||||
if (acquireDownloadSession && params.sourceHostId) {
|
||||
sourceWorkLease = await acquireDownloadSession(
|
||||
params.sourceHostId,
|
||||
`${task.id}:work-source`,
|
||||
);
|
||||
@@ -2064,7 +2085,7 @@ export const useSftpTransfers = ({
|
||||
const isCancelled = cancelledTasksRef.current.has(task.id);
|
||||
// Clean up cancelled task tracking to prevent memory leak
|
||||
if (isCancelled) cancelledTasksRef.current.delete(task.id);
|
||||
const errMsg = err instanceof Error ? err.message : String(err);
|
||||
const errMsg = unwrapSftpIpcError(err);
|
||||
patchDownload({
|
||||
status: isCancelled ? "cancelled" : "failed",
|
||||
error: isCancelled ? undefined : errMsg,
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { ContainerRuntimeId, ContainerSourceId } from '../../../domain/systemManager/containerRuntime';
|
||||
import { isContainerSource } from '../../../domain/systemManager/containerRuntime';
|
||||
import { STORAGE_KEY_CONTAINER_RUNTIME } from '../../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../../infrastructure/persistence/localStorageAdapter';
|
||||
|
||||
export interface ContainerRuntimePreference {
|
||||
source: ContainerSourceId;
|
||||
engine?: ContainerRuntimeId;
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
}
|
||||
|
||||
type PreferenceStore = Record<string, ContainerRuntimePreference>;
|
||||
|
||||
const CONTAINER_ENGINES = new Set<ContainerRuntimeId>(['docker', 'podman', 'nerdctl']);
|
||||
|
||||
function readStore(): PreferenceStore {
|
||||
const value = localStorageAdapter.read<PreferenceStore>(STORAGE_KEY_CONTAINER_RUNTIME);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
export function readContainerRuntimePreference(hostId: string): ContainerRuntimePreference | null {
|
||||
if (!hostId) return null;
|
||||
const entry = readStore()[hostId];
|
||||
if (!entry || !isContainerSource(entry.source)) return null;
|
||||
return {
|
||||
source: entry.source,
|
||||
engine: entry.engine && CONTAINER_ENGINES.has(entry.engine) ? entry.engine : undefined,
|
||||
context: typeof entry.context === 'string' ? entry.context : undefined,
|
||||
namespace: typeof entry.namespace === 'string' ? entry.namespace : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function writeContainerRuntimePreference(hostId: string, entry: ContainerRuntimePreference): void {
|
||||
if (!hostId || !isContainerSource(entry.source)) return;
|
||||
const store = readStore();
|
||||
store[hostId] = entry;
|
||||
localStorageAdapter.write(STORAGE_KEY_CONTAINER_RUNTIME, store);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { useSystemManagerBackend } from '../useSystemManagerBackend';
|
||||
import { useStableTranslate } from '../useSystemManager';
|
||||
import type {
|
||||
ContainerRuntimeId,
|
||||
ContainerSourceId,
|
||||
KubeContextInfo,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import {
|
||||
describeRuntimeFailure,
|
||||
firstContainerRuntime,
|
||||
isAutomaticRuntimeFallback,
|
||||
isKubectlForbiddenError,
|
||||
reconcileContainerSource,
|
||||
sourcesFromCapabilities,
|
||||
} from '../../../domain/systemManager/containerRuntime';
|
||||
import type { SessionCapabilities } from '../../../domain/systemManager/types';
|
||||
import {
|
||||
readContainerRuntimePreference,
|
||||
writeContainerRuntimePreference,
|
||||
} from './containerRuntimePreference';
|
||||
|
||||
type Backend = ReturnType<typeof useSystemManagerBackend>;
|
||||
|
||||
interface UseContainerRuntimeSelectionOptions {
|
||||
sessionId: string;
|
||||
hostKey: string;
|
||||
capabilities?: SessionCapabilities;
|
||||
backend: Backend;
|
||||
}
|
||||
|
||||
export function useContainerRuntimeSelection({
|
||||
sessionId,
|
||||
hostKey,
|
||||
capabilities,
|
||||
backend,
|
||||
}: UseContainerRuntimeSelectionOptions) {
|
||||
const stableT = useStableTranslate();
|
||||
const detected = sourcesFromCapabilities(capabilities);
|
||||
const detectedKey = detected.join('|');
|
||||
const [source, setSource] = useState<ContainerSourceId | null>(() => {
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
return reconcileContainerSource(detected, null, remembered);
|
||||
});
|
||||
const [contextName, setContextName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.context ?? null
|
||||
));
|
||||
const [namespaceName, setNamespaceName] = useState<string | null>(() => (
|
||||
readContainerRuntimePreference(hostKey)?.namespace ?? null
|
||||
));
|
||||
const [contexts, setContexts] = useState<KubeContextInfo[]>([]);
|
||||
const [namespaces, setNamespaces] = useState<string[]>([]);
|
||||
const [scopeError, setScopeError] = useState<string | null>(null);
|
||||
const [scopeNonce, setScopeNonce] = useState(0);
|
||||
const skipPersist = useRef(true);
|
||||
const namespaceNameRef = useRef(namespaceName);
|
||||
namespaceNameRef.current = namespaceName;
|
||||
|
||||
useEffect(() => {
|
||||
skipPersist.current = true;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
setSource(reconcileContainerSource(detected, null, remembered));
|
||||
setContextName(remembered?.context ?? null);
|
||||
setNamespaceName(remembered?.namespace ?? null);
|
||||
setContexts([]);
|
||||
setNamespaces([]);
|
||||
setScopeError(null);
|
||||
// Detection is reconciled by the next effect so a host switch does not
|
||||
// keep the previous host's in-memory selection.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
setSource((current) => reconcileContainerSource(
|
||||
available,
|
||||
current,
|
||||
readContainerRuntimePreference(hostKey),
|
||||
));
|
||||
}, [capabilities, detectedKey, hostKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (skipPersist.current) {
|
||||
skipPersist.current = false;
|
||||
return;
|
||||
}
|
||||
if (!source) return;
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
if (isAutomaticRuntimeFallback(remembered, source, available)) return;
|
||||
const engine = source === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: source;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName, source]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes') return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({ sessionId, op: 'contexts' }).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.contexts) {
|
||||
setContexts([]);
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
const available = result.contexts;
|
||||
const kept = contextName && available.some((item) => item.name === contextName)
|
||||
? contextName
|
||||
: null;
|
||||
const next = kept
|
||||
?? available.find((item) => item.current)?.name
|
||||
?? available[0]?.name
|
||||
?? null;
|
||||
setContexts(available);
|
||||
setContextName(next);
|
||||
if (!kept) {
|
||||
const configured = available.find((item) => item.name === next)?.namespace || 'default';
|
||||
setNamespaceName(next ? configured : null);
|
||||
setNamespaces([]);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'kubernetes' || !contextName) return undefined;
|
||||
let cancelled = false;
|
||||
void backend.kubectl({
|
||||
sessionId,
|
||||
op: 'namespaces',
|
||||
context: contextName,
|
||||
}).then((result) => {
|
||||
if (cancelled) return;
|
||||
if (!result.success || !result.namespaces) {
|
||||
const message = result.error || '';
|
||||
if (isKubectlForbiddenError(message)) {
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace || 'default';
|
||||
const fallback = namespaceNameRef.current || configured;
|
||||
setScopeError(null);
|
||||
setNamespaceName((current) => current || fallback);
|
||||
setNamespaces([fallback]);
|
||||
return;
|
||||
}
|
||||
setScopeError(describeRuntimeFailure(
|
||||
result,
|
||||
stableT,
|
||||
stableT('systemManager.errors.loadKubernetes'),
|
||||
contextName,
|
||||
));
|
||||
return;
|
||||
}
|
||||
setScopeError(null);
|
||||
setNamespaces(result.namespaces);
|
||||
setNamespaceName((current) => {
|
||||
if (current && result.namespaces?.includes(current)) return current;
|
||||
const configured = contexts.find((item) => item.name === contextName)?.namespace;
|
||||
if (configured && result.namespaces?.includes(configured)) return configured;
|
||||
if (result.namespaces?.includes('default')) return 'default';
|
||||
return result.namespaces?.[0] ?? null;
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [backend, contextName, contexts, sessionId, source, scopeNonce, stableT]);
|
||||
|
||||
const selectSource = useCallback((value: string) => {
|
||||
if (value !== 'docker' && value !== 'podman' && value !== 'nerdctl' && value !== 'kubernetes') return;
|
||||
setSource(value);
|
||||
const remembered = readContainerRuntimePreference(hostKey);
|
||||
const available = sourcesFromCapabilities(capabilities);
|
||||
const engine = value === 'kubernetes'
|
||||
? (remembered?.engine ?? firstContainerRuntime(available) ?? undefined)
|
||||
: value;
|
||||
writeContainerRuntimePreference(hostKey, {
|
||||
source: value,
|
||||
engine,
|
||||
context: contextName || undefined,
|
||||
namespace: namespaceName || undefined,
|
||||
});
|
||||
}, [capabilities, contextName, hostKey, namespaceName]);
|
||||
|
||||
const selectContext = useCallback((value: string) => {
|
||||
setContextName(value);
|
||||
const configured = contexts.find((item) => item.name === value)?.namespace ?? null;
|
||||
setNamespaceName(configured);
|
||||
}, [contexts]);
|
||||
|
||||
const reloadScopes = useCallback(() => {
|
||||
setScopeNonce((value) => value + 1);
|
||||
}, []);
|
||||
|
||||
const runtime: ContainerRuntimeId = source && source !== 'kubernetes' ? source : 'docker';
|
||||
const showRuntimeSwitcher = detected.length > 1;
|
||||
|
||||
return {
|
||||
source,
|
||||
selectSource,
|
||||
contextName,
|
||||
selectContext,
|
||||
namespaceName,
|
||||
setNamespaceName,
|
||||
contexts,
|
||||
namespaces,
|
||||
scopeError,
|
||||
reloadScopes,
|
||||
runtime,
|
||||
detected,
|
||||
showRuntimeSwitcher,
|
||||
};
|
||||
}
|
||||
@@ -1,10 +1,25 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { createSessionIdleManager } from '../../electron/bridges/mcpServerBridge/sessionIdleManager.cjs';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES } from '../../infrastructure/config/storageKeys';
|
||||
import { STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES, STORAGE_KEY_AI_PRIVACY, STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, STORAGE_KEY_AI_EXTERNAL_MCP_MODE } from '../../infrastructure/config/storageKeys';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
readExternalMcpHttpAllowCustomBind,
|
||||
readExternalMcpHttpEnabled,
|
||||
readExternalMcpHttpListenAddress,
|
||||
readExternalMcpHttpPort,
|
||||
readExternalMcpHttpSettings,
|
||||
writeExternalMcpHttpAllowCustomBind,
|
||||
writeExternalMcpHttpEnabled,
|
||||
writeExternalMcpHttpListenAddress,
|
||||
writeExternalMcpHttpPort,
|
||||
} from "./useExternalMcpToggleState.ts";
|
||||
import {
|
||||
EXTERNAL_MCP_RUNTIME_STATUS_POLL_MS,
|
||||
applyAIPrivacySettings,
|
||||
readAIPrivacySettings,
|
||||
applyExternalMcpRedactionSettings,
|
||||
readExternalMcpRedactionSettings,
|
||||
bumpExternalMcpEnableGenerationForTests,
|
||||
createExternalMcpStartupSyncPlan,
|
||||
getExternalMcpEnableGenerationForTests,
|
||||
@@ -105,11 +120,12 @@ describe('useExternalMcpToggleState helpers', () => {
|
||||
externalMcpSetConfig: (config: { sessionIdleTimeoutMinutes: number }) => {
|
||||
received.push(config.sessionIdleTimeoutMinutes);
|
||||
manager.setTimeoutMinutes(config.sessionIdleTimeoutMinutes);
|
||||
return { ok: true };
|
||||
},
|
||||
};
|
||||
await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'startup keeps the intended timeout');
|
||||
syncExternalMcpConfig(bridge);
|
||||
await syncExternalMcpConfig(bridge);
|
||||
assert.deepEqual(received, [expected, expected]);
|
||||
assert.equal(manager.getTimeoutMinutes(), expected, 'config sync keeps the intended timeout');
|
||||
assert.equal(
|
||||
@@ -414,3 +430,361 @@ describe('syncExternalMcpStartupState generation guard', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("external MCP HTTP settings", () => {
|
||||
it("defaults HTTP off on loopback and round-trips the explicit listen option", () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpHttpSettings(), {
|
||||
enabled: false,
|
||||
port: 47391,
|
||||
allowCustomBind: false,
|
||||
listenAddress: "0.0.0.0",
|
||||
clientHost: "",
|
||||
});
|
||||
assert.equal(readExternalMcpHttpEnabled(), false);
|
||||
writeExternalMcpHttpEnabled(true);
|
||||
assert.equal(writeExternalMcpHttpPort(80), 47391);
|
||||
assert.equal(writeExternalMcpHttpPort(48080), 48080);
|
||||
writeExternalMcpHttpAllowCustomBind(true);
|
||||
writeExternalMcpHttpListenAddress("172.22.0.1");
|
||||
assert.equal(readExternalMcpHttpEnabled(), true);
|
||||
assert.equal(readExternalMcpHttpPort(), 48080);
|
||||
assert.equal(readExternalMcpHttpAllowCustomBind(), true);
|
||||
assert.equal(readExternalMcpHttpListenAddress(), "172.22.0.1");
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('Shared AI privacy settings', () => {
|
||||
const settings = {
|
||||
enabled: true,
|
||||
redactPrivateIps: true,
|
||||
cidrs: ['192.0.2.0/24'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
};
|
||||
|
||||
it('defaults off and persists only after successful bridge validation', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), {
|
||||
enabled: false, redactPrivateIps: false, cidrs: [], patterns: [],
|
||||
});
|
||||
let resolveConfig: (value: unknown) => void;
|
||||
const apply = applyExternalMcpRedactionSettings(settings, {
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config, { redaction: settings });
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
});
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null);
|
||||
resolveConfig!({ ok: true });
|
||||
await apply;
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), null);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('reads legacy privacy preferences until a validated shared preference is applied', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), null, 'reads must not migrate or discard data');
|
||||
const changed = { ...settings, redactPrivateIps: false };
|
||||
await applyAIPrivacySettings(changed, { externalMcpSetConfig: async () => ({ ok: true }) });
|
||||
assert.deepEqual(readAIPrivacySettings(), changed);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), changed, 'legacy API must read the same shared policy');
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_PRIVACY), changed);
|
||||
assert.deepEqual(localStorageAdapter.read(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION), settings, 'keep legacy storage intact');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('never falls back to legacy settings when a shared preference exists, even if corrupt', () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, settings);
|
||||
const disabled = { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, disabled);
|
||||
assert.deepEqual(readAIPrivacySettings(), disabled);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
localStorageAdapter.remove(STORAGE_KEY_AI_PRIVACY);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION, '{');
|
||||
assert.throws(readAIPrivacySettings, /Invalid saved AI privacy settings/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('restores the shared policy even when External MCP is disabled', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, false);
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('shared-policy');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`external:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['shared-policy', 'external:false']);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.deepEqual(readAIPrivacySettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not persist rejected, unverified or failed privacy changes', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const changed = { ...settings, patterns: ['['] };
|
||||
for (const result of [{ ok: false, error: 'Invalid regular expression' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => result,
|
||||
}));
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
await assert.rejects(applyExternalMcpRedactionSettings(changed, {
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
}), /Disconnected/);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('syncs saved privacy settings before persistent startup enable', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const calls: string[] = [];
|
||||
await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async (config) => {
|
||||
assert.deepEqual(config.redaction, settings);
|
||||
calls.push('config');
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(`enabled:${enabled}`); },
|
||||
});
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed at startup when saved privacy settings are malformed', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const saved of ['{', 'null', '{}', '{"enabled":"true"}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[2]}', '{"enabled":false,"redactPrivateIps":false,"cidrs":[],"patterns":[],"unexpected":true}']) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, saved);
|
||||
const enabledCalls: boolean[] = [];
|
||||
let configCalls = 0;
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
configCalls += 1;
|
||||
return { ok: false, error: 'Shared AI privacy configuration is unavailable.' };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { enabledCalls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.match(plan.configError!, /Invalid saved/);
|
||||
assert.equal(configCalls, 1, 'invalidate shared privacy instead of applying unprotected defaults');
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved/);
|
||||
assert.equal(configCalls, 2);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed when the backend rejects or cannot verify startup config', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
for (const result of [{ ok: false, error: 'Invalid CIDR' }, { ok: false, error: '' }, undefined, {}]) {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
const enabledCalls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => result,
|
||||
externalMcpSetEnabled: async (enabled) => { enabledCalls.push(enabled); },
|
||||
});
|
||||
assert.equal(plan.runtimeEnabled, false);
|
||||
assert.ok(plan.configError);
|
||||
assert.deepEqual(enabledCalls, [false]);
|
||||
assert.deepEqual(readExternalMcpRedactionSettings(), settings);
|
||||
}
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves the corrupt-settings error if shared policy invalidation cannot reach the bridge', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
const calls: boolean[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async (config: unknown) => {
|
||||
assert.deepEqual(config, { redaction: null });
|
||||
throw new Error('Disconnected');
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => { calls.push(enabled); },
|
||||
};
|
||||
const plan = await syncExternalMcpStartupState(bridge);
|
||||
assert.match(plan.configError!, /Invalid saved AI privacy settings/);
|
||||
assert.deepEqual(calls, [false]);
|
||||
await assert.rejects(syncExternalMcpConfig(bridge), /Invalid saved AI privacy settings/);
|
||||
assert.equal(localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('does not enable from a stale privacy snapshot changed during config sync', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings);
|
||||
return { ok: true };
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.match(plan.configError!, /changed during startup/);
|
||||
await assert.rejects(syncExternalMcpConfig({
|
||||
externalMcpSetConfig: async () => {
|
||||
localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, { ...settings, cidrs: [] });
|
||||
return { ok: true };
|
||||
},
|
||||
}), /settings changed/);
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('fails closed on a startup config transport failure', async () => {
|
||||
const restore = installMemoryLocalStorage();
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, true);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_MODE, 'persistent');
|
||||
const calls: boolean[] = [];
|
||||
const plan = await syncExternalMcpStartupState({
|
||||
externalMcpSetConfig: async () => { throw new Error('Disconnected'); },
|
||||
externalMcpSetEnabled: async (enabled) => { calls.push(enabled); },
|
||||
});
|
||||
assert.deepEqual(calls, [false]);
|
||||
assert.equal(plan.configError, 'Disconnected');
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('External MCP manual enable privacy gate', () => {
|
||||
it('waits for validated config and keeps rejected or superseded enables off', async () => {
|
||||
const React = await import('react');
|
||||
const { act, create } = await import('react-test-renderer');
|
||||
const { useExternalMcpToggleState } = await import('./useExternalMcpToggleState.ts');
|
||||
const restoreStorage = installMemoryLocalStorage();
|
||||
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
const previousAct = Object.getOwnPropertyDescriptor(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
const events = new EventTarget();
|
||||
let resolveConfig!: (value: unknown) => void;
|
||||
const calls: string[] = [];
|
||||
const bridge = {
|
||||
externalMcpSetConfig: async () => {
|
||||
calls.push('config');
|
||||
return new Promise((resolve) => { resolveConfig = resolve; });
|
||||
},
|
||||
externalMcpSetEnabled: async (enabled: boolean) => {
|
||||
calls.push(`enabled:${enabled}`);
|
||||
return { ok: true, enabled };
|
||||
},
|
||||
};
|
||||
Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', { value: true, configurable: true });
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
value: {
|
||||
netcatty: bridge,
|
||||
location: { hash: '#/settings' },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
setInterval,
|
||||
clearInterval,
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
let hook!: ReturnType<typeof useExternalMcpToggleState>;
|
||||
const Capture = () => { hook = useExternalMcpToggleState(); return null; };
|
||||
let renderer: ReturnType<typeof create> | undefined;
|
||||
try {
|
||||
resetExternalMcpStartupReadyForTests();
|
||||
await act(async () => { renderer = create(React.createElement(Capture)); });
|
||||
let pending!: Promise<void>;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
assert.deepEqual(calls, ['config']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.equal(readExternalMcpStoredEnabled(), false);
|
||||
await act(async () => { resolveConfig({ ok: false, error: 'Invalid privacy rules' }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false);
|
||||
assert.match(hook.error!, /Invalid privacy rules/);
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { await hook.setEnabled(false); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:false']);
|
||||
assert.equal(hook.enabled, false, 'a newer disable wins over a pending enable');
|
||||
|
||||
calls.length = 0;
|
||||
await act(async () => { pending = hook.setEnabled(true); });
|
||||
await act(async () => { resolveConfig({ ok: true }); await pending; });
|
||||
assert.deepEqual(calls, ['config', 'enabled:true']);
|
||||
assert.equal(hook.enabled, true);
|
||||
assert.equal(readExternalMcpStoredEnabled(), true);
|
||||
assert.equal(hook.error, null);
|
||||
} finally {
|
||||
if (renderer) await act(async () => renderer!.unmount());
|
||||
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow);
|
||||
else Reflect.deleteProperty(globalThis, 'window');
|
||||
if (previousAct) Object.defineProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT', previousAct);
|
||||
else Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
|
||||
restoreStorage();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -3,13 +3,21 @@ import {
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_APPROVAL_ALERT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_FOCUS_ON_HOST_OPEN,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_IDLE_TIMEOUT_MINUTES,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_MODE,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION,
|
||||
STORAGE_KEY_AI_EXTERNAL_MCP_SILENT_SESSIONS,
|
||||
STORAGE_KEY_AI_PERMISSION_MODE,
|
||||
STORAGE_KEY_AI_PRIVACY,
|
||||
STORAGE_KEY_AI_SESSION_IDLE_TIMEOUT_MINUTES,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import type { AIPermissionMode } from '../../infrastructure/ai/types';
|
||||
import { DEFAULT_EXTERNAL_MCP_HTTP_PORT } from '../../domain/externalMcpHttp';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
|
||||
@@ -33,6 +41,7 @@ let externalMcpStartupReadyWaitPromise: Promise<void> | null = null;
|
||||
let externalMcpStartupReadyWaitResolve: (() => void) | null = null;
|
||||
/** Bumps on every intentional enable/disable so stale startup applies lose. */
|
||||
let externalMcpEnableGeneration = 0;
|
||||
let externalMcpConfigError: string | null = null;
|
||||
|
||||
function getWindowHash(hash?: string): string {
|
||||
if (typeof hash === 'string') return hash;
|
||||
@@ -70,6 +79,7 @@ export function resetExternalMcpStartupReadyForTests(): void {
|
||||
externalMcpStartupReadyWaitPromise = null;
|
||||
externalMcpStartupReadyWaitResolve = null;
|
||||
externalMcpEnableGeneration = 0;
|
||||
externalMcpConfigError = null;
|
||||
externalMcpStartupSyncPromise = null;
|
||||
}
|
||||
|
||||
@@ -104,10 +114,157 @@ export function waitForExternalMcpStartupReady(hash?: string): Promise<void> {
|
||||
return externalMcpStartupReadyWaitPromise;
|
||||
}
|
||||
|
||||
export type AIPrivacySettings = {
|
||||
enabled: boolean;
|
||||
redactPrivateIps: boolean;
|
||||
cidrs: string[];
|
||||
patterns: string[];
|
||||
};
|
||||
|
||||
export function defaultAIPrivacySettings(): AIPrivacySettings {
|
||||
return { enabled: false, redactPrivateIps: false, cidrs: [], patterns: [] };
|
||||
}
|
||||
|
||||
/** Missing settings are opt-out; corrupt settings must never silently opt out. */
|
||||
export function readAIPrivacySettings(): AIPrivacySettings {
|
||||
// The shared key wins, including invalid data: never fall back to a weaker legacy policy.
|
||||
// Leave the legacy value intact for older installs; Apply writes only the validated shared key.
|
||||
const stored = localStorageAdapter.readString(STORAGE_KEY_AI_PRIVACY)
|
||||
?? localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION);
|
||||
if (stored === null) return defaultAIPrivacySettings();
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(stored);
|
||||
} catch {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
const settings = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(settings).some((key) => !['enabled', 'redactPrivateIps', 'cidrs', 'patterns'].includes(key))
|
||||
|| typeof settings.enabled !== 'boolean'
|
||||
|| typeof settings.redactPrivateIps !== 'boolean'
|
||||
|| !Array.isArray(settings.cidrs) || !settings.cidrs.every((item) => typeof item === 'string')
|
||||
|| !Array.isArray(settings.patterns) || !settings.patterns.every((item) => typeof item === 'string')
|
||||
) {
|
||||
throw new Error('Invalid saved AI privacy settings. Review and apply the settings again.');
|
||||
}
|
||||
return {
|
||||
enabled: settings.enabled,
|
||||
redactPrivateIps: settings.redactPrivateIps,
|
||||
cidrs: [...settings.cidrs],
|
||||
patterns: [...settings.patterns],
|
||||
};
|
||||
}
|
||||
|
||||
function configResponseError(
|
||||
result: unknown,
|
||||
unverifiedMessage = 'External MCP settings could not be verified. The bridge has not been enabled.',
|
||||
): string | null {
|
||||
if (result && typeof result === 'object' && 'ok' in result && result.ok === true) return null;
|
||||
if (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error) {
|
||||
return result.error;
|
||||
}
|
||||
return unverifiedMessage;
|
||||
}
|
||||
|
||||
/** Validate in the main process before committing any privacy preference. */
|
||||
export async function applyAIPrivacySettings(
|
||||
settings: AIPrivacySettings,
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const result = await bridge?.externalMcpSetConfig?.({ redaction: settings });
|
||||
const error = configResponseError(result, 'AI privacy settings could not be verified. Try applying them again.');
|
||||
if (error) throw new Error(error);
|
||||
if (!localStorageAdapter.write(STORAGE_KEY_AI_PRIVACY, settings)) {
|
||||
throw new Error('AI privacy settings could not be saved. Apply them again before restarting.');
|
||||
}
|
||||
externalMcpConfigError = null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
}
|
||||
|
||||
function readAIPrivacyState() {
|
||||
try {
|
||||
return { settings: readAIPrivacySettings(), error: null as string | null };
|
||||
} catch (error) {
|
||||
return {
|
||||
settings: defaultAIPrivacySettings(),
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function useAIPrivacyState() {
|
||||
const [state, setState] = useState(readAIPrivacyState);
|
||||
const [applying, setApplying] = useState(false);
|
||||
const apply = useCallback(async (settings: AIPrivacySettings): Promise<boolean> => {
|
||||
setApplying(true);
|
||||
try {
|
||||
await applyAIPrivacySettings(settings);
|
||||
setState({ settings, error: null });
|
||||
return true;
|
||||
} catch (error) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return false;
|
||||
} finally {
|
||||
setApplying(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key === STORAGE_KEY_AI_PRIVACY || event.key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
const onLocalChange = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key === STORAGE_KEY_AI_PRIVACY || key === STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) {
|
||||
setState(readAIPrivacyState());
|
||||
}
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
window.addEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
return () => {
|
||||
window.removeEventListener('storage', onStorage);
|
||||
window.removeEventListener(AI_STATE_CHANGED_EVENT, onLocalChange);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { ...state, applying, apply };
|
||||
}
|
||||
|
||||
/** Compatibility exports for existing External MCP configuration callers. One shared policy only. */
|
||||
export type ExternalMcpRedactionSettings = AIPrivacySettings;
|
||||
export const defaultExternalMcpRedactionSettings = defaultAIPrivacySettings;
|
||||
export const readExternalMcpRedactionSettings = readAIPrivacySettings;
|
||||
export const applyExternalMcpRedactionSettings = applyAIPrivacySettings;
|
||||
export const useExternalMcpRedactionState = useAIPrivacyState;
|
||||
|
||||
export type ExternalMcpHttpSettings = {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
allowCustomBind: boolean;
|
||||
listenAddress: string;
|
||||
clientHost: string;
|
||||
};
|
||||
|
||||
type ExternalMcpConfig = {
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
mode?: ExternalMcpMode;
|
||||
idleTimeoutMinutes?: number;
|
||||
sessionIdleTimeoutMinutes?: number;
|
||||
/** null invalidates shared privacy when persisted settings cannot be read safely. */
|
||||
redaction?: AIPrivacySettings | null;
|
||||
httpEnabled?: boolean;
|
||||
httpPort?: number;
|
||||
httpAllowCustomBind?: boolean;
|
||||
httpListenAddress?: string;
|
||||
httpClientHost?: string;
|
||||
};
|
||||
|
||||
type ExternalMcpBridge = {
|
||||
@@ -127,6 +284,7 @@ export type ExternalMcpStartupSyncPlan = {
|
||||
runtimeEnabled: boolean;
|
||||
storedEnabled: boolean;
|
||||
shouldPersistStoredEnabled: boolean;
|
||||
configError?: string;
|
||||
};
|
||||
|
||||
export function normalizeExternalMcpMode(value: string | null): ExternalMcpMode {
|
||||
@@ -241,6 +399,109 @@ export function writeSessionIdleTimeoutMinutes(minutes: number): number {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const MIN_EXTERNAL_MCP_HTTP_PORT = 1024;
|
||||
const MAX_EXTERNAL_MCP_HTTP_PORT = 65535;
|
||||
|
||||
export function normalizeExternalMcpHttpPort(value: number | null): number {
|
||||
if (!Number.isInteger(value)) return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
|
||||
if (value < MIN_EXTERNAL_MCP_HTTP_PORT || value > MAX_EXTERNAL_MCP_HTTP_PORT) {
|
||||
return DEFAULT_EXTERNAL_MCP_HTTP_PORT;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpHttpListenAddress(value: string | null): string {
|
||||
const address = (value ?? '').trim();
|
||||
if (!address) return '0.0.0.0';
|
||||
return address;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpEnabled(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED) ?? false;
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpEnabled(enabled: boolean): boolean {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED, enabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ENABLED);
|
||||
return enabled;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpPort(): number {
|
||||
return normalizeExternalMcpHttpPort(
|
||||
localStorageAdapter.readNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpPort(port: number): number {
|
||||
const normalized = normalizeExternalMcpHttpPort(port);
|
||||
localStorageAdapter.writeNumber(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_PORT);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpAllowCustomBind(): boolean {
|
||||
return localStorageAdapter.readBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND) ?? false;
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpAllowCustomBind(allowCustomBind: boolean): boolean {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND, allowCustomBind);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_ALLOW_CUSTOM_BIND);
|
||||
return allowCustomBind;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpListenAddress(): string {
|
||||
return normalizeExternalMcpHttpListenAddress(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpListenAddress(listenAddress: string): string {
|
||||
const normalized = normalizeExternalMcpHttpListenAddress(listenAddress);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_LISTEN_ADDRESS);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function normalizeExternalMcpHttpClientHost(value: string | null): string {
|
||||
return (value ?? '').trim();
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpClientHost(): string {
|
||||
return normalizeExternalMcpHttpClientHost(
|
||||
localStorageAdapter.readString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST),
|
||||
);
|
||||
}
|
||||
|
||||
export function writeExternalMcpHttpClientHost(clientHost: string): string {
|
||||
const normalized = normalizeExternalMcpHttpClientHost(clientHost);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST, normalized);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_HTTP_CLIENT_HOST);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function readExternalMcpHttpSettings(): ExternalMcpHttpSettings {
|
||||
return {
|
||||
enabled: readExternalMcpHttpEnabled(),
|
||||
port: readExternalMcpHttpPort(),
|
||||
allowCustomBind: readExternalMcpHttpAllowCustomBind(),
|
||||
listenAddress: readExternalMcpHttpListenAddress(),
|
||||
clientHost: readExternalMcpHttpClientHost(),
|
||||
};
|
||||
}
|
||||
|
||||
function httpConfigFromSettings(settings: ExternalMcpHttpSettings): Pick<
|
||||
ExternalMcpConfig,
|
||||
'httpEnabled' | 'httpPort' | 'httpAllowCustomBind' | 'httpListenAddress' | 'httpClientHost'
|
||||
> {
|
||||
return {
|
||||
httpEnabled: settings.enabled,
|
||||
httpPort: settings.port,
|
||||
httpAllowCustomBind: settings.allowCustomBind,
|
||||
httpListenAddress: settings.listenAddress,
|
||||
httpClientHost: settings.clientHost,
|
||||
};
|
||||
}
|
||||
|
||||
export function shouldStartExternalMcpOnStartup({
|
||||
enabled,
|
||||
mode,
|
||||
@@ -263,11 +524,21 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction = defaultAIPrivacySettings(),
|
||||
http = {
|
||||
enabled: false,
|
||||
port: DEFAULT_EXTERNAL_MCP_HTTP_PORT,
|
||||
allowCustomBind: false,
|
||||
listenAddress: '0.0.0.0',
|
||||
clientHost: '',
|
||||
},
|
||||
}: {
|
||||
enabled: boolean;
|
||||
mode: ExternalMcpMode;
|
||||
idleTimeoutMinutes: number;
|
||||
sessionIdleTimeoutMinutes: number;
|
||||
http?: ExternalMcpHttpSettings;
|
||||
redaction?: AIPrivacySettings;
|
||||
}): ExternalMcpStartupSyncPlan {
|
||||
const runtimeEnabled = shouldStartExternalMcpOnStartup({ enabled, mode });
|
||||
const storedEnabled = runtimeEnabled;
|
||||
@@ -276,6 +547,8 @@ export function createExternalMcpStartupSyncPlan({
|
||||
mode,
|
||||
idleTimeoutMinutes,
|
||||
sessionIdleTimeoutMinutes,
|
||||
redaction,
|
||||
...httpConfigFromSettings(http),
|
||||
},
|
||||
runtimeEnabled,
|
||||
storedEnabled,
|
||||
@@ -284,20 +557,44 @@ export function createExternalMcpStartupSyncPlan({
|
||||
}
|
||||
|
||||
export function readExternalMcpStartupSyncPlan(): ExternalMcpStartupSyncPlan {
|
||||
return createExternalMcpStartupSyncPlan({
|
||||
let redaction = defaultAIPrivacySettings();
|
||||
let configError: string | undefined;
|
||||
try {
|
||||
redaction = readAIPrivacySettings();
|
||||
} catch (error) {
|
||||
configError = error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
const plan = createExternalMcpStartupSyncPlan({
|
||||
enabled: readExternalMcpStoredEnabled(),
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
http: readExternalMcpHttpSettings(),
|
||||
redaction,
|
||||
});
|
||||
return configError ? { ...plan, runtimeEnabled: false, configError } : plan;
|
||||
}
|
||||
|
||||
export function syncExternalMcpConfig(bridge: ExternalMcpBridge | undefined = netcattyBridge.get()): void {
|
||||
void bridge?.externalMcpSetConfig?.({
|
||||
mode: readExternalMcpMode(),
|
||||
idleTimeoutMinutes: readExternalMcpIdleTimeoutMinutes(),
|
||||
sessionIdleTimeoutMinutes: readSessionIdleTimeoutMinutes(),
|
||||
});
|
||||
export async function syncExternalMcpConfig(
|
||||
bridge: ExternalMcpBridge | undefined = netcattyBridge.get(),
|
||||
): Promise<void> {
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (plan.configError) {
|
||||
// A corrupt saved policy must block all managed agents, not only External MCP.
|
||||
// Keep the original recovery error even if notifying the main process fails.
|
||||
try {
|
||||
await bridge?.externalMcpSetConfig?.({ redaction: null });
|
||||
} catch {
|
||||
// The saved policy still needs user repair regardless of bridge availability.
|
||||
}
|
||||
throw new Error(plan.configError);
|
||||
}
|
||||
const result = await bridge?.externalMcpSetConfig?.(plan.config);
|
||||
const error = configResponseError(result);
|
||||
if (error) throw new Error(error);
|
||||
if (JSON.stringify(readAIPrivacySettings()) !== JSON.stringify(plan.config.redaction)) {
|
||||
throw new Error('AI privacy settings changed. Try enabling the bridge again.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,10 +616,18 @@ export async function syncExternalMcpStartupState(
|
||||
} catch {
|
||||
// Permission sync is best-effort; the main process keeps its safe default.
|
||||
}
|
||||
let configError = initialPlan.configError;
|
||||
try {
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
} catch {
|
||||
// Config sync is best-effort; continue with enable/disable reconcile.
|
||||
if (configError) {
|
||||
// Explicit invalidation is distinct from a rejected draft Apply, which keeps
|
||||
// the previous working policy. Never replace corrupt saved data with defaults.
|
||||
await Promise.resolve(bridge?.externalMcpSetConfig?.({ redaction: null }));
|
||||
} else {
|
||||
const result = await Promise.resolve(bridge?.externalMcpSetConfig?.(initialPlan.config));
|
||||
configError = configResponseError(result) ?? undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
configError ??= error instanceof Error ? error.message : String(error);
|
||||
}
|
||||
|
||||
// A user toggle during config await invalidates this startup apply.
|
||||
@@ -331,6 +636,18 @@ export async function syncExternalMcpStartupState(
|
||||
}
|
||||
|
||||
const plan = readExternalMcpStartupSyncPlan();
|
||||
if (JSON.stringify(plan.config.redaction) !== JSON.stringify(initialPlan.config.redaction)) {
|
||||
configError = 'AI privacy settings changed during startup. Enable the bridge again.';
|
||||
}
|
||||
if (configError || plan.configError) {
|
||||
// A rejected or unverified config must never expose an unprotected runtime.
|
||||
plan.configError = configError || plan.configError;
|
||||
plan.runtimeEnabled = false;
|
||||
plan.storedEnabled = false;
|
||||
plan.shouldPersistStoredEnabled = readExternalMcpStoredEnabled();
|
||||
}
|
||||
externalMcpConfigError = plan.configError ?? null;
|
||||
emitAIStateChanged(STORAGE_KEY_AI_PRIVACY);
|
||||
if (plan.shouldPersistStoredEnabled) {
|
||||
localStorageAdapter.writeBoolean(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, plan.storedEnabled);
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
@@ -382,24 +699,47 @@ export function useExternalMcpToggleState() {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
}, []);
|
||||
|
||||
const setEnabled = useCallback((nextEnabled: boolean) => {
|
||||
persistEnabled(nextEnabled);
|
||||
// Peer session windows can mirror the stored switch, but they must not own
|
||||
// the main-process External MCP lifecycle.
|
||||
if (isPeerSessionWindow) return;
|
||||
externalMcpEnableGeneration += 1;
|
||||
// Re-emit after the lifecycle IPC settles so consumers that refetch runtime
|
||||
// status (e.g. Tool Access guidance) do not act on the pre-IPC storage
|
||||
// event, which can still observe the previous enabled state.
|
||||
void Promise.resolve(netcattyBridge.get()?.externalMcpSetEnabled?.(nextEnabled)).finally(() => {
|
||||
const [error, setError] = useState<string | null>(() => externalMcpConfigError);
|
||||
|
||||
const setEnabled = useCallback(async (nextEnabled: boolean) => {
|
||||
// Peer session windows mirror storage, but never own the main lifecycle.
|
||||
if (isPeerSessionWindow) {
|
||||
persistEnabled(nextEnabled);
|
||||
return;
|
||||
}
|
||||
if (!nextEnabled) persistEnabled(false);
|
||||
const generation = ++externalMcpEnableGeneration;
|
||||
setError(null);
|
||||
externalMcpConfigError = null;
|
||||
const bridge = netcattyBridge.get();
|
||||
try {
|
||||
// Even a manual enable must apply and verify saved privacy settings first.
|
||||
if (nextEnabled) await syncExternalMcpConfig(bridge);
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
const result = await bridge?.externalMcpSetEnabled?.(nextEnabled);
|
||||
if (nextEnabled && result && typeof result === 'object' && 'ok' in result && result.ok === false) {
|
||||
throw new Error('error' in result && typeof result.error === 'string' && result.error
|
||||
? result.error : 'External MCP could not be enabled.');
|
||||
}
|
||||
if (generation === externalMcpEnableGeneration) persistEnabled(nextEnabled);
|
||||
} catch (cause) {
|
||||
if (generation !== externalMcpEnableGeneration) return;
|
||||
externalMcpConfigError = cause instanceof Error ? cause.message : String(cause);
|
||||
setError(externalMcpConfigError);
|
||||
if (nextEnabled) {
|
||||
persistEnabled(false);
|
||||
await Promise.resolve(bridge?.externalMcpSetEnabled?.(false)).catch(() => undefined);
|
||||
}
|
||||
} finally {
|
||||
emitAIStateChanged(STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED);
|
||||
});
|
||||
}
|
||||
}, [isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFromStorage = () => {
|
||||
const nextEnabled = readExternalMcpStoredEnabled();
|
||||
setEnabledRaw(nextEnabled);
|
||||
setError(externalMcpConfigError);
|
||||
};
|
||||
|
||||
const handleStorage = (event: StorageEvent) => {
|
||||
@@ -408,7 +748,7 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
const handleLocalStateChanged = (event: Event) => {
|
||||
const key = (event as CustomEvent<{ key?: string }>).detail?.key;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED) return;
|
||||
if (key !== STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED && key !== STORAGE_KEY_AI_EXTERNAL_MCP_REDACTION) return;
|
||||
syncFromStorage();
|
||||
};
|
||||
|
||||
@@ -453,5 +793,5 @@ export function useExternalMcpToggleState() {
|
||||
};
|
||||
}, [enabled, isPeerSessionWindow, persistEnabled]);
|
||||
|
||||
return { enabled, setEnabled };
|
||||
return { enabled, setEnabled, error };
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import {
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
subscribeActiveGlobalDefaultProxyProfileId,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
|
||||
export const useActiveGlobalDefaultProxyProfileId = (): string | undefined => {
|
||||
return useSyncExternalStore(
|
||||
subscribeActiveGlobalDefaultProxyProfileId,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
);
|
||||
};
|
||||
@@ -24,6 +24,14 @@ export interface ProcessInfo {
|
||||
command: string;
|
||||
}
|
||||
|
||||
export interface GpuDeviceStats {
|
||||
index: number;
|
||||
name: string;
|
||||
utilization: number | null; // percent, null when nvidia-smi reports N/A
|
||||
memoryUsed: number | null; // MiB
|
||||
memoryTotal: number | null; // MiB
|
||||
}
|
||||
|
||||
export interface ServerStats {
|
||||
hostname?: string; // Remote hostname
|
||||
osName?: string; // Remote operating system name
|
||||
@@ -37,6 +45,7 @@ export interface ServerStats {
|
||||
gpuName: string | null; // First GPU name (tooltip)
|
||||
gpuMemUsed: number | null; // Summed VRAM used in MB
|
||||
gpuMemTotal: number | null; // Summed VRAM total in MB
|
||||
gpus: GpuDeviceStats[]; // Per-GPU utilization and VRAM
|
||||
memTotal: number | null; // Total memory in MB
|
||||
memUsed: number | null; // Used memory in MB (excluding buffers/cache)
|
||||
memFree: number | null; // Free memory in MB
|
||||
@@ -116,6 +125,7 @@ function createEmptyServerStats(): ServerStats {
|
||||
gpuName: null,
|
||||
gpuMemUsed: null,
|
||||
gpuMemTotal: null,
|
||||
gpus: [],
|
||||
memTotal: null,
|
||||
memUsed: null,
|
||||
memFree: null,
|
||||
@@ -218,6 +228,7 @@ function normalizeServerStats(stats: Partial<ServerStats>): ServerStats {
|
||||
gpuName: stats.gpuName ?? null,
|
||||
gpuMemUsed: stats.gpuMemUsed ?? null,
|
||||
gpuMemTotal: stats.gpuMemTotal ?? null,
|
||||
gpus: Array.isArray(stats.gpus) ? stats.gpus : [],
|
||||
memTotal: stats.memTotal ?? null,
|
||||
memUsed: stats.memUsed ?? null,
|
||||
memFree: stats.memFree ?? null,
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
clearActiveGlobalDefaultProxyProfile,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
} from "../../domain/globalDefaultProxy.ts";
|
||||
import { STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE } from "../../infrastructure/config/storageKeys.ts";
|
||||
import { useSettingsState } from "./useSettingsState.ts";
|
||||
|
||||
type Notification = { key: string; value: unknown };
|
||||
|
||||
const withSettingsDom = async (
|
||||
run: (env: { dom: JSDOM; root: Root; notifications: Notification[] }) => Promise<void>,
|
||||
) => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
|
||||
const globalAny = globalThis as Record<string, unknown>;
|
||||
const saved = {
|
||||
window: globalAny.window,
|
||||
document: globalAny.document,
|
||||
localStorage: globalAny.localStorage,
|
||||
actEnv: globalAny.IS_REACT_ACT_ENVIRONMENT,
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
|
||||
const notifications: Notification[] = [];
|
||||
dom.window.netcatty = {
|
||||
notifySettingsChanged: (payload: Notification) => {
|
||||
notifications.push(payload);
|
||||
},
|
||||
} as Window["netcatty"];
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-1");
|
||||
|
||||
const container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await run({ dom, root, notifications });
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
publishActiveGlobalDefaultProxyProfileId(undefined);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
for (const key of ["window", "document", "localStorage"] as const) {
|
||||
if (saved[key] === undefined) delete globalAny[key];
|
||||
else {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: saved[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
configurable: true,
|
||||
value: saved.actEnv,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const proxyNotifications = (notifications: Notification[]) => (
|
||||
notifications.filter((entry) => entry.key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE)
|
||||
);
|
||||
|
||||
test("closing a second settings instance still persists a cleared global default proxy", async () => {
|
||||
await withSettingsDom(async ({ dom, root, notifications }) => {
|
||||
let showSecond = true;
|
||||
function SettingsProbe() {
|
||||
useSettingsState({ enableSystemEffects: false });
|
||||
return null;
|
||||
}
|
||||
function Harness() {
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(SettingsProbe),
|
||||
showSecond ? React.createElement(SettingsProbe) : null,
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-1");
|
||||
|
||||
showSecond = false;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
notifications.length = 0;
|
||||
await act(async () => {
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
});
|
||||
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
|
||||
assert.deepEqual(proxyNotifications(notifications), [
|
||||
{ key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, value: "" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("a peer session window follows global default proxy storage without settings sync", async () => {
|
||||
await withSettingsDom(async ({ dom, root, notifications }) => {
|
||||
function PeerSettings() {
|
||||
useSettingsState({ enableSettingsSync: false, enableSystemEffects: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(PeerSettings));
|
||||
});
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
|
||||
|
||||
notifications.length = 0;
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-2");
|
||||
await act(async () => {
|
||||
dom.window.dispatchEvent(new dom.window.StorageEvent("storage", {
|
||||
key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
newValue: "profile-2",
|
||||
storageArea: dom.window.localStorage,
|
||||
}));
|
||||
});
|
||||
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-2");
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-2");
|
||||
assert.deepEqual(proxyNotifications(notifications), []);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
STORAGE_KEY_SHOW_TRAY_ICON,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
@@ -104,6 +105,11 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
normalizeGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
registerGlobalDefaultProxyProfileSetter,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
resolveSftpTransferConcurrency,
|
||||
@@ -151,6 +157,7 @@ import {
|
||||
isValidUiThemeId,
|
||||
migrateIncomingTerminalFontId,
|
||||
migrateMaterializedAutoCloseOnExitDefault,
|
||||
migrateMaterializedShiftEnterNewlineDefault,
|
||||
readStoredString,
|
||||
serializeTerminalSettings,
|
||||
type HostClickBehavior,
|
||||
@@ -360,6 +367,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// One-time reset of the pre-flip materialized autoCloseOnExit: true before
|
||||
// it is ingested; afterwards stored values are the user's own and win.
|
||||
migrateMaterializedAutoCloseOnExitDefault();
|
||||
migrateMaterializedShiftEnterNewlineDefault();
|
||||
const stored = localStorageAdapter.read<TerminalSettings>(STORAGE_KEY_TERM_SETTINGS);
|
||||
return normalizeTerminalSettings(stored);
|
||||
});
|
||||
@@ -574,6 +582,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
|
||||
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
|
||||
});
|
||||
const [globalDefaultProxyProfileId, setGlobalDefaultProxyProfileIdState] = useState<string | undefined>(() => {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(
|
||||
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
|
||||
);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
return next;
|
||||
});
|
||||
const setHttpNetworkProxy = useCallback((nextValue: SetStateAction<HttpNetworkProxySettings>) => {
|
||||
setHttpNetworkProxyState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
@@ -796,6 +811,37 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}
|
||||
}, [enableSettingsSync]);
|
||||
|
||||
const setGlobalDefaultProxyProfileId = useCallback((value: string | undefined) => {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(value);
|
||||
setGlobalDefaultProxyProfileIdState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
publishActiveGlobalDefaultProxyProfileId(globalDefaultProxyProfileId);
|
||||
}, [globalDefaultProxyProfileId]);
|
||||
|
||||
useEffect(
|
||||
() => registerGlobalDefaultProxyProfileSetter(setGlobalDefaultProxyProfileId),
|
||||
[setGlobalDefaultProxyProfileId],
|
||||
);
|
||||
|
||||
// Peer session windows set enableSettingsSync false, which skips IPC sync.
|
||||
// This setting still has to follow localStorage or an already-open session
|
||||
// window keeps the proxy id it read at mount.
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) return;
|
||||
const next = normalizeGlobalDefaultProxyProfileId(event.newValue);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const setSftpTransferConcurrency = useCallback((value: number) => {
|
||||
const clamped = Math.max(1, Math.min(16, Math.round(value)));
|
||||
@@ -1079,6 +1125,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
localStorageAdapter.read(STORAGE_KEY_HTTP_NETWORK_PROXY) ?? DEFAULT_HTTP_NETWORK_PROXY,
|
||||
),
|
||||
);
|
||||
const nextGlobalDefaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(
|
||||
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
|
||||
);
|
||||
publishActiveGlobalDefaultProxyProfileId(nextGlobalDefaultProxyProfileId);
|
||||
setGlobalDefaultProxyProfileIdState(nextGlobalDefaultProxyProfileId);
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
@@ -1227,6 +1278,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -2211,6 +2263,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
autoLaunchSupported,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
hotkeyRegistrationError,
|
||||
@@ -2233,7 +2287,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, globalDefaultProxyProfileId,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import type { ContainerRuntimeId } from '../../domain/systemManager/containerRuntime';
|
||||
import type {
|
||||
DockerContainerAction,
|
||||
DockerImageManageAction,
|
||||
@@ -94,23 +95,27 @@ export function useSystemManagerBackend() {
|
||||
return bridge.tmuxAction(options);
|
||||
}, []);
|
||||
|
||||
const listDockerContainers = useCallback(async (sessionId: string) => {
|
||||
const listDockerContainers = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerContainers) {
|
||||
return { success: false as const, error: 'listDockerContainers unavailable' };
|
||||
}
|
||||
return bridge.listDockerContainers(sessionId);
|
||||
return bridge.listDockerContainers(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const listDockerImages = useCallback(async (sessionId: string) => {
|
||||
const listDockerImages = useCallback(async (sessionId: string, runtime?: ContainerRuntimeId) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.listDockerImages) {
|
||||
return { success: false as const, error: 'listDockerImages unavailable' };
|
||||
}
|
||||
return bridge.listDockerImages(sessionId);
|
||||
return bridge.listDockerImages(sessionId, runtime);
|
||||
}, []);
|
||||
|
||||
const getDockerStats = useCallback(async (options: { sessionId: string; ids?: string[] }) => {
|
||||
const getDockerStats = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
ids?: string[];
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.getDockerStats) {
|
||||
return { success: false as const, error: 'getDockerStats unavailable' };
|
||||
@@ -155,7 +160,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.systemServiceAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerInspect = useCallback(async (options: { sessionId: string; containerId: string }) => {
|
||||
const dockerInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
containerId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerInspect) {
|
||||
return { success: false as const, error: 'dockerInspect unavailable' };
|
||||
@@ -163,7 +172,11 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerInspect(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageInspect = useCallback(async (options: { sessionId: string; imageId: string }) => {
|
||||
const dockerImageInspect = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
imageId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageInspect) {
|
||||
return { success: false as const, error: 'dockerImageInspect unavailable' };
|
||||
@@ -176,6 +189,7 @@ export function useSystemManagerBackend() {
|
||||
containerId: string;
|
||||
action: DockerContainerAction;
|
||||
newName?: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerAction) {
|
||||
@@ -184,7 +198,10 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerAction(options);
|
||||
}, []);
|
||||
|
||||
const dockerImageAction = useCallback(async (options: { sessionId: string } & DockerImageManageAction) => {
|
||||
const dockerImageAction = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
runtime?: ContainerRuntimeId;
|
||||
} & DockerImageManageAction) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.dockerImageAction) {
|
||||
return { success: false as const, error: 'dockerImageAction unavailable' };
|
||||
@@ -192,6 +209,21 @@ export function useSystemManagerBackend() {
|
||||
return bridge.dockerImageAction(options);
|
||||
}, []);
|
||||
|
||||
const kubectl = useCallback(async (options: {
|
||||
sessionId: string;
|
||||
op: 'contexts' | 'namespaces' | 'pods' | 'delete';
|
||||
context?: string;
|
||||
namespace?: string;
|
||||
pod?: string;
|
||||
shell?: string;
|
||||
}) => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.kubectl) {
|
||||
return { success: false as const, error: 'kubectl unavailable' };
|
||||
}
|
||||
return bridge.kubectl(options);
|
||||
}, []);
|
||||
|
||||
const openTerminalPopup = useCallback(async (
|
||||
payload: Parameters<NonNullable<NetcattyBridge['openTerminalPopup']>>[0],
|
||||
) => {
|
||||
@@ -223,6 +255,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
}), [
|
||||
probeSystemCapabilities,
|
||||
@@ -245,6 +278,7 @@ export function useSystemManagerBackend() {
|
||||
dockerImageInspect,
|
||||
dockerAction,
|
||||
dockerImageAction,
|
||||
kubectl,
|
||||
openTerminalPopup,
|
||||
]);
|
||||
}
|
||||
@@ -454,6 +454,30 @@ test("note appearance settings survive sync and trigger auto-sync", async () =>
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
|
||||
});
|
||||
|
||||
test("buildSyncPayload round-trips the global default proxy profile", async () => {
|
||||
localStorage.clear();
|
||||
const absent = buildSyncPayload(vault([]));
|
||||
assert.equal(absent.settings?.defaultProxyProfileId, undefined);
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "proxy-1");
|
||||
const selected = buildSyncPayload(vault([]));
|
||||
assert.equal(selected.settings?.defaultProxyProfileId, "proxy-1");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "");
|
||||
const cleared = buildSyncPayload(vault([]));
|
||||
assert.equal(cleared.settings?.defaultProxyProfileId, "");
|
||||
|
||||
localStorage.clear();
|
||||
await applySyncPayload(selected, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "proxy-1");
|
||||
|
||||
await applySyncPayload(cleared, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
|
||||
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
));
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
|
||||
import { normalizeGlobalDefaultProxyProfileId } from '../domain/globalDefaultProxy';
|
||||
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
||||
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
|
||||
import {
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
@@ -345,6 +347,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
@@ -592,6 +595,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
|
||||
settings.workspaceFocusStyle = workspaceFocusStyle;
|
||||
}
|
||||
const storedDefaultProxyProfileId = localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE);
|
||||
if (storedDefaultProxyProfileId !== null) {
|
||||
settings.defaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(storedDefaultProxyProfileId) ?? '';
|
||||
}
|
||||
|
||||
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
|
||||
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
||||
@@ -906,6 +913,12 @@ async function applySyncableSettings(
|
||||
if (settings.workspaceFocusStyle != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
|
||||
}
|
||||
if (typeof settings.defaultProxyProfileId === 'string') {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
normalizeGlobalDefaultProxyProfileId(settings.defaultProxyProfileId) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
const ai = settings.ai;
|
||||
if (ai) {
|
||||
|
||||
@@ -15,6 +15,11 @@ test("GroupDetailsPanel treats an empty SSH identity as an explicit setting", ()
|
||||
assert.equal(hasGroupSshFields({}), false);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel keeps the SSH section when X11 forwarding is set", () => {
|
||||
assert.equal(hasGroupSshFields({ x11Forwarding: true }), true);
|
||||
assert.equal(hasGroupSshFields({ x11Forwarding: false }), true);
|
||||
});
|
||||
|
||||
test("GroupDetailsPanel treats cleared telnet credentials as explicit settings", () => {
|
||||
assert.equal(hasGroupTelnetFields({ telnetUsername: "" }), true);
|
||||
assert.equal(hasGroupTelnetFields({ telnetPassword: "" }), true);
|
||||
|
||||
@@ -102,7 +102,7 @@ export const hasGroupSshFields = (c: Partial<GroupConfig>): boolean =>
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.deviceType !== undefined ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
|
||||
c.agentForwarding !== undefined || c.x11Forwarding !== undefined || c.authMethod !== undefined || c.identityId !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.startupCommandRunMode !== undefined || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
Boolean(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
@@ -262,6 +262,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
delete next.identityFilePaths;
|
||||
delete next.deviceType;
|
||||
delete next.agentForwarding;
|
||||
delete next.x11Forwarding;
|
||||
delete next.startupCommand;
|
||||
delete next.startupCommandRunMode;
|
||||
delete next.legacyAlgorithms;
|
||||
@@ -499,6 +500,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).skipEcdsaHostKey;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const inheritedX11Forwarding = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return false;
|
||||
return !!resolveGroupDefaults(parentGroup, groupConfigs).x11Forwarding;
|
||||
}, [groupConfigs, parentGroup]);
|
||||
const inheritedStartupCommandRunMode = useMemo(() => {
|
||||
if (!parentGroup || groupConfigs.length === 0) return "paste";
|
||||
return resolveGroupDefaults(parentGroup, groupConfigs).startupCommandRunMode ?? "paste";
|
||||
@@ -568,6 +573,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
...(form.identityFilePaths !== undefined && { identityFilePaths: form.identityFilePaths }),
|
||||
...(form.deviceType !== undefined && { deviceType: form.deviceType }),
|
||||
...(form.agentForwarding !== undefined && { agentForwarding: form.agentForwarding }),
|
||||
...(form.x11Forwarding !== undefined && { x11Forwarding: form.x11Forwarding }),
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.startupCommandRunMode !== undefined && { startupCommandRunMode: form.startupCommandRunMode }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
@@ -778,6 +784,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
setNewKeyFilePath={setNewKeyFilePath}
|
||||
inheritedLegacyAlgorithms={inheritedLegacyAlgorithms}
|
||||
inheritedSkipEcdsaHostKey={inheritedSkipEcdsaHostKey}
|
||||
inheritedX11Forwarding={inheritedX11Forwarding}
|
||||
inheritedStartupCommandRunMode={inheritedStartupCommandRunMode}
|
||||
showAlgorithmOverrides={showAlgorithmOverrides}
|
||||
setShowAlgorithmOverrides={setShowAlgorithmOverrides}
|
||||
|
||||
@@ -47,6 +47,7 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
setNewKeyFilePath,
|
||||
inheritedLegacyAlgorithms,
|
||||
inheritedSkipEcdsaHostKey,
|
||||
inheritedX11Forwarding,
|
||||
inheritedStartupCommandRunMode,
|
||||
showAlgorithmOverrides,
|
||||
setShowAlgorithmOverrides,
|
||||
@@ -395,6 +396,16 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
|
||||
onToggle={() => update("agentForwarding", !form.agentForwarding)}
|
||||
/>
|
||||
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
hint={t("hostDetails.x11Forwarding.desc")}
|
||||
enabled={!!(form.x11Forwarding ?? inheritedX11Forwarding)}
|
||||
onToggle={() => update(
|
||||
"x11Forwarding",
|
||||
!(form.x11Forwarding ?? inheritedX11Forwarding),
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Startup Command — Textarea so multi-line sequences are typeable
|
||||
here just like on the per-host details panel (#1083 follow-up). */}
|
||||
<Textarea
|
||||
|
||||
@@ -30,6 +30,17 @@ test('enabling SSH agent login clears an imported none sentinel', () => {
|
||||
assert.match(source, /resolveSshAgentToggleUpdate/);
|
||||
});
|
||||
|
||||
test('X11 forwarding display follows the effective host after group defaults', () => {
|
||||
assert.match(source, /applyGroupDefaults\(form, effectiveGroupDefaults\)/);
|
||||
assert.match(source, /const showX11Forwarding = \(!effectiveConnectionHost\.protocol \|\| effectiveConnectionHost\.protocol === "ssh"\)/);
|
||||
assert.match(source, /!effectiveConnectionHost\.moshEnabled/);
|
||||
assert.match(source, /!effectiveConnectionHost\.etEnabled/);
|
||||
assert.match(source, /const effectiveX11Forwarding = !!effectiveConnectionHost\.x11Forwarding/);
|
||||
assert.match(source, /\{showX11Forwarding && \(/);
|
||||
assert.match(source, /enabled=\{effectiveX11Forwarding\}/);
|
||||
assert.doesNotMatch(source, /effectiveGroupDefaults\?\.x11Forwarding/);
|
||||
});
|
||||
|
||||
test('login and forwarding warnings use independent agent status', () => {
|
||||
assert.match(source, /systemSshAgentEnabled && sshAgentStatus && !sshAgentStatus\.running/);
|
||||
assert.match(source, /form\.agentForwarding && sshForwardingAgentStatus && !sshForwardingAgentStatus\.running/);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getHostOsSelection, HOST_OS_SELECTIONS, resolveHostOs } from '../domain/host';
|
||||
import { applyGroupDefaults } from "../domain/groupConfig";
|
||||
import React from "react";
|
||||
import { AlertTriangle, ChevronDown, ChevronUp, Forward, Globe, HeartPulse, KeyRound, Link2, Palette, Plus, Router, ShieldAlert, TerminalSquare, Timer, Wifi, X, Variable } from "lucide-react";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
@@ -63,6 +64,15 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
const systemSshAgentEnabled = effectiveAuthMethod === "auto"
|
||||
? form.useSshAgent !== false
|
||||
: effectiveAuthMethod === "key" && form.useSshAgent === true;
|
||||
// Match the connection: inherited X11 is dropped when the effective transport
|
||||
// is Mosh, Eternal Terminal, or anything other than plain SSH.
|
||||
const effectiveConnectionHost = effectiveGroupDefaults
|
||||
? applyGroupDefaults(form, effectiveGroupDefaults)
|
||||
: form;
|
||||
const showX11Forwarding = (!effectiveConnectionHost.protocol || effectiveConnectionHost.protocol === "ssh")
|
||||
&& !effectiveConnectionHost.moshEnabled
|
||||
&& !effectiveConnectionHost.etEnabled;
|
||||
const effectiveX11Forwarding = !!effectiveConnectionHost.x11Forwarding;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -337,7 +347,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
</HostDetailsSection>
|
||||
|
||||
{/* X11 Forwarding */}
|
||||
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
|
||||
{showX11Forwarding && (
|
||||
<HostDetailsSection
|
||||
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
|
||||
title={t("hostDetails.section.x11Forwarding")}
|
||||
@@ -345,8 +355,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
<ToggleRow
|
||||
label={t("hostDetails.x11Forwarding")}
|
||||
hint={t("hostDetails.x11Forwarding.desc")}
|
||||
enabled={!!form.x11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !form.x11Forwarding)}
|
||||
enabled={effectiveX11Forwarding}
|
||||
onToggle={() => update("x11Forwarding", !effectiveX11Forwarding)}
|
||||
/>
|
||||
</HostDetailsSection>
|
||||
)}
|
||||
@@ -687,7 +697,32 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
title={t("hostDetails.proxy")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
{form.directProxy ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t("hostDetails.proxy.direct")}
|
||||
</Badge>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{t("hostDetails.proxy.directHint")}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={t("hostDetails.proxyPanel.remove")}
|
||||
onClick={clearProxyConfig}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from "react";
|
||||
import { ChevronDown, Eye, EyeOff, FileKey, FolderLock, FolderOpen, Key, KeyRound, MapPin, Plus, Shapes, Shield, Trash2, User, X } from "lucide-react";
|
||||
import type { Host } from "../types";
|
||||
import { applyHostAuthMethodSelection } from "../domain/sshAuth";
|
||||
import { HostTotpFields } from "./HostTotpFields";
|
||||
import { HostIconPicker } from "./HostIconPicker";
|
||||
import { Button } from "./ui/button";
|
||||
import { Combobox } from "./ui/combobox";
|
||||
@@ -101,6 +102,7 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
distroOptions,
|
||||
effectiveFormDistro,
|
||||
getDistroOptionLabel,
|
||||
totpFlushRef,
|
||||
}) => {
|
||||
const selectAuthMethod = (authMethod: "auto" | "password" | "key" | "certificate") => {
|
||||
setForm((previous: Host) => applyEffectiveHostAuthMethodSelection(
|
||||
@@ -739,6 +741,13 @@ export const HostDetailsConnectionSections: React.FC<HostDetailsConnectionSectio
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
effectiveProtocol={effectiveProtocol}
|
||||
flushRef={totpFlushRef}
|
||||
/>
|
||||
</div>
|
||||
</HostDetailsSection>
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ test("HostDetailsPanel labels command proxy summaries consistently", () => {
|
||||
assert.match(markup, /ProxyCommand/);
|
||||
assert.doesNotMatch(markup, /COMMAND/);
|
||||
assert.doesNotMatch(markup, /cloudflared access ssh/);
|
||||
assert.doesNotMatch(markup, /secret/);
|
||||
assert.doesNotMatch(markup, /--token secret/);
|
||||
});
|
||||
|
||||
test("HostDetailsPanel keeps explicitly cleared telnet credentials empty", () => {
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
} from "./ui/aside-panel";
|
||||
import { HostDetailsAdvancedSections } from "./HostDetailsAdvancedSections";
|
||||
import { detachEffectiveHostIdentity, HostDetailsConnectionSections } from "./HostDetailsConnectionSections";
|
||||
import type { HostTotpFlushResult } from "./HostTotpFields";
|
||||
import {
|
||||
LINUX_DISTRO_OPTION_IDS,
|
||||
parseOptionalPortInput,
|
||||
@@ -163,6 +164,8 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
};
|
||||
const { checkSshAgent } = useApplicationBackend();
|
||||
const baselineSnippetsRef = useRef(snippets);
|
||||
const totpFlushRef = useRef<(() => Promise<HostTotpFlushResult>) | null>(null);
|
||||
const savingHostRef = useRef(false);
|
||||
const baselineHydratedRef = useRef(snippets.length > 0);
|
||||
const openingQueueIdsRef = useRef<string[]>([]);
|
||||
const [form, setForm] = useState<Host>(
|
||||
@@ -453,7 +456,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
||||
@@ -465,19 +468,26 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const clearProxyConfig = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
return rest as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
if (!profileId) return rest as Host;
|
||||
return { ...rest, proxyProfileId: profileId } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectDirectProxy = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return { ...rest, directProxy: true } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
@@ -527,7 +537,12 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
? pluginConnectionValid && Boolean(sanitizePluginConnection(form.pluginConnection, form.protocol))
|
||||
: hasHostname;
|
||||
|
||||
const handleSubmit = () => {
|
||||
const handleSubmit = async () => {
|
||||
if (savingHostRef.current) return;
|
||||
savingHostRef.current = true;
|
||||
try {
|
||||
const flushed = await totpFlushRef.current?.();
|
||||
if (flushed && !flushed.ok) return;
|
||||
const hostname = form.hostname.trim()
|
||||
|| (pluginProtocolActive ? form.label.trim() || form.pluginConnection?.providerId || "plugin" : "");
|
||||
if (!hostname || !canSaveHost) return;
|
||||
@@ -608,6 +623,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
authPolicyVersion: 1,
|
||||
managedSourceId: finalManagedSourceId,
|
||||
};
|
||||
if (flushed?.ok) {
|
||||
if (flushed.totp) cleaned.totp = flushed.totp;
|
||||
else delete cleaned.totp;
|
||||
}
|
||||
if (pluginProtocolActive) {
|
||||
const pluginConnection = sanitizePluginConnection(cleaned.pluginConnection, cleaned.protocol);
|
||||
if (!pluginConnection) return;
|
||||
@@ -735,6 +754,9 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
}
|
||||
}
|
||||
onSave(stripBuiltInConnectionFieldsForPluginHost(cleaned));
|
||||
} finally {
|
||||
savingHostRef.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateGroup = () => {
|
||||
@@ -886,8 +908,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
proxyProfiles={proxyProfiles}
|
||||
identities={identities}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
directProxy={form.directProxy}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
onSelectDirectProxy={selectDirectProxy}
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
@@ -1128,6 +1152,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
t={t}
|
||||
form={form}
|
||||
setForm={setForm}
|
||||
totpFlushRef={totpFlushRef}
|
||||
update={update}
|
||||
groupDefaults={effectiveGroupDefaults}
|
||||
effectiveAuthMethod={effectiveAuthMethod}
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Host } from "../domain/models";
|
||||
|
||||
test("host totp setup stays on the shared setting row and opens a dialog", async () => {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
url: "http://localhost",
|
||||
pretendToBeVisual: true,
|
||||
});
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
const install = (key: string, value: unknown) => {
|
||||
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
||||
Object.defineProperty(globalThis, key, { configurable: true, writable: true, value });
|
||||
};
|
||||
class ResizeObserverStub {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
install("window", dom.window);
|
||||
install("document", dom.window.document);
|
||||
install("navigator", dom.window.navigator);
|
||||
install("HTMLElement", dom.window.HTMLElement);
|
||||
install("HTMLInputElement", dom.window.HTMLInputElement);
|
||||
install("HTMLButtonElement", dom.window.HTMLButtonElement);
|
||||
install("Element", dom.window.Element);
|
||||
install("Node", dom.window.Node);
|
||||
install("MutationObserver", dom.window.MutationObserver);
|
||||
install("Event", dom.window.Event);
|
||||
install("CustomEvent", dom.window.CustomEvent);
|
||||
install("MouseEvent", dom.window.MouseEvent);
|
||||
install("KeyboardEvent", dom.window.KeyboardEvent);
|
||||
install("FocusEvent", dom.window.FocusEvent);
|
||||
install("PointerEvent", dom.window.PointerEvent);
|
||||
install("getComputedStyle", dom.window.getComputedStyle.bind(dom.window));
|
||||
install("requestAnimationFrame", dom.window.requestAnimationFrame.bind(dom.window));
|
||||
install("cancelAnimationFrame", dom.window.cancelAnimationFrame.bind(dom.window));
|
||||
install("ResizeObserver", ResizeObserverStub);
|
||||
install("IS_REACT_ACT_ENVIRONMENT", true);
|
||||
Object.assign(dom.window, { netcatty: {} });
|
||||
|
||||
const { act } = await import("react");
|
||||
const { createRoot } = await import("react-dom/client");
|
||||
const { HostTotpFields } = await import("./HostTotpFields.tsx");
|
||||
const { TooltipProvider } = await import("./ui/tooltip.tsx");
|
||||
|
||||
const form = {
|
||||
id: "host-1",
|
||||
label: "Jump",
|
||||
hostname: "127.0.0.1",
|
||||
port: 22,
|
||||
username: "root",
|
||||
protocol: "ssh",
|
||||
} as Host;
|
||||
let latest = form;
|
||||
const root = createRoot(dom.window.document.getElementById("root")!);
|
||||
const t = (key: string) => key;
|
||||
try {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<HostTotpFields
|
||||
t={t}
|
||||
form={latest}
|
||||
setForm={(updater) => {
|
||||
latest = typeof updater === "function" ? updater(latest) : updater;
|
||||
}}
|
||||
effectiveProtocol="ssh"
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
});
|
||||
const markup = dom.window.document.body.innerHTML;
|
||||
assert.match(markup, /hostDetails\.totp\.setup/);
|
||||
assert.doesNotMatch(markup, /rounded-md border border-border\/60 p-3/);
|
||||
assert.equal(dom.window.document.querySelector("[role='dialog']"), null);
|
||||
assert.equal(dom.window.document.querySelector("[role='switch']"), null);
|
||||
|
||||
const button = [...dom.window.document.querySelectorAll("button")]
|
||||
.find((node) => node.textContent?.includes("hostDetails.totp.setup"));
|
||||
assert.ok(button);
|
||||
await act(async () => { button.click(); });
|
||||
assert.ok(dom.window.document.body.textContent?.includes("hostDetails.totp.modalTitle"));
|
||||
assert.ok(dom.window.document.body.innerHTML.includes("hostDetails.totp.secretPlaceholder"));
|
||||
assert.equal(latest.totp, undefined);
|
||||
const fields = readFileSync(new URL("./HostTotpFields.tsx", import.meta.url), "utf8");
|
||||
assert.match(fields, /label=\{t\("hostDetails\.totp\.title"\)\}/);
|
||||
assert.match(fields, /setConfirmClear\(true\)/);
|
||||
assert.match(fields, /variant="secondary"/);
|
||||
const sections = readFileSync(new URL("./HostDetailsConnectionSections.tsx", import.meta.url), "utf8");
|
||||
const passwordAt = sections.indexOf("hostDetails.password.placeholder");
|
||||
const totpAt = sections.indexOf("<HostTotpFields");
|
||||
assert.ok(passwordAt > 0 && totpAt > passwordAt);
|
||||
} finally {
|
||||
await act(async () => { root.unmount(); });
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
for (const [key, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else delete (globalThis as Record<string, unknown>)[key];
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,568 @@
|
||||
import React, { useEffect, useRef, useState } from "react";
|
||||
import jsQR from "jsqr";
|
||||
import { RE2JS } from "re2js";
|
||||
import type { Host, HostTotpConfig } from "../domain/models";
|
||||
import { netcattyBridge } from "@/infrastructure/services/netcattyBridge.ts";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { HostDetailsSettingRow } from "./host-details";
|
||||
import { SystemPanelConfirmDialog } from "./systemManager/SystemPanelConfirmDialog";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
import { Switch } from "./ui/switch";
|
||||
|
||||
type Preview = {
|
||||
uri: string;
|
||||
code: string;
|
||||
secondsRemaining: number;
|
||||
issuer?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type HostTotpFlushResult =
|
||||
| { ok: true; totp: HostTotpConfig | undefined }
|
||||
| { ok: false };
|
||||
|
||||
type HostTotpFieldsProps = {
|
||||
t: (key: string, params?: Record<string, string | number>) => string;
|
||||
form: Host;
|
||||
setForm: (updater: Host | ((host: Host) => Host)) => void;
|
||||
effectiveProtocol?: string;
|
||||
flushRef?: React.MutableRefObject<(() => Promise<HostTotpFlushResult>) | null>;
|
||||
};
|
||||
|
||||
const HIDDEN_PROTOCOLS = new Set(["telnet", "serial"]);
|
||||
|
||||
function errorKey(error: string | undefined): string {
|
||||
switch (error) {
|
||||
case "hotp":
|
||||
return "hostDetails.totp.error.hotp";
|
||||
case "migration":
|
||||
return "hostDetails.totp.error.migration";
|
||||
case "not-found":
|
||||
return "hostDetails.totp.error.notFound";
|
||||
case "screen-permission-denied":
|
||||
return "hostDetails.totp.error.permission";
|
||||
case "unavailable":
|
||||
return "hostDetails.totp.error.unavailable";
|
||||
case "pattern":
|
||||
return "hostDetails.totp.error.pattern";
|
||||
default:
|
||||
return "hostDetails.totp.error.invalid";
|
||||
}
|
||||
}
|
||||
|
||||
async function decodeQrFile(file: File): Promise<string> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
if (!context) throw new Error("unavailable");
|
||||
context.drawImage(bitmap, 0, 0);
|
||||
const image = context.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return jsQR(image.data, image.width, image.height)?.data || "";
|
||||
} finally {
|
||||
bitmap.close?.();
|
||||
}
|
||||
}
|
||||
|
||||
function validPattern(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
if (trimmed.length > 200) return null;
|
||||
try {
|
||||
RE2JS.compile(trimmed);
|
||||
return trimmed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function importedAccount(preview: Preview): string {
|
||||
const issuer = preview.issuer?.trim() || "";
|
||||
const label = preview.label?.trim() || "";
|
||||
if (issuer && label && !label.toLowerCase().startsWith(issuer.toLowerCase())) {
|
||||
return `${issuer} (${label})`;
|
||||
}
|
||||
return label || issuer;
|
||||
}
|
||||
|
||||
function useLiveTotp(source: string | undefined, hostId: string): Preview | null {
|
||||
const [preview, setPreview] = useState<Preview | null>(null);
|
||||
const hostRef = useRef(hostId);
|
||||
hostRef.current = hostId;
|
||||
|
||||
useEffect(() => {
|
||||
if (!source) {
|
||||
setPreview(null);
|
||||
return undefined;
|
||||
}
|
||||
const expectedHostId = hostId;
|
||||
let cancelled = false;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) return undefined;
|
||||
const endsAtRef = { current: 0 };
|
||||
let refreshed = false;
|
||||
const apply = (next: Preview) => {
|
||||
if (cancelled || hostRef.current !== expectedHostId) return;
|
||||
endsAtRef.current = Date.now() + next.secondsRemaining * 1000;
|
||||
refreshed = false;
|
||||
setPreview(next);
|
||||
};
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
const timer = setInterval(() => {
|
||||
if (cancelled || !endsAtRef.current) return;
|
||||
const left = Math.max(0, Math.ceil((endsAtRef.current - Date.now()) / 1000));
|
||||
setPreview((current) => (
|
||||
current && current.secondsRemaining !== left
|
||||
? { ...current, secondsRemaining: left }
|
||||
: current
|
||||
));
|
||||
if (left !== 0 || refreshed) return;
|
||||
refreshed = true;
|
||||
void bridge.previewTotp({ text: source }).then((result) => {
|
||||
if (!result?.ok || !result.uri || !result.code) return;
|
||||
apply({
|
||||
uri: result.uri,
|
||||
code: result.code,
|
||||
secondsRemaining: result.secondsRemaining || result.period || 30,
|
||||
issuer: result.issuer,
|
||||
label: result.label,
|
||||
});
|
||||
}).catch(() => undefined);
|
||||
}, 250);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
clearInterval(timer);
|
||||
};
|
||||
}, [hostId, source]);
|
||||
|
||||
return source ? preview : null;
|
||||
}
|
||||
|
||||
export const HostTotpFields: React.FC<HostTotpFieldsProps> = ({
|
||||
t,
|
||||
form,
|
||||
setForm,
|
||||
effectiveProtocol,
|
||||
flushRef,
|
||||
}) => {
|
||||
const configured = Boolean(form.totp?.uri);
|
||||
const [setupOpen, setSetupOpen] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [pattern, setPattern] = useState(form.totp?.promptPattern || "");
|
||||
const [autoSubmit, setAutoSubmit] = useState(form.totp?.autoSubmit !== false);
|
||||
const [modalUri, setModalUri] = useState<string | undefined>(undefined);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [scanning, setScanning] = useState(false);
|
||||
const [confirmClear, setConfirmClear] = useState(false);
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const hostIdRef = useRef(form.id);
|
||||
const formRef = useRef(form);
|
||||
const setupOpenRef = useRef(setupOpen);
|
||||
const draftRef = useRef(draft);
|
||||
const patternRef = useRef(pattern);
|
||||
const autoSubmitRef = useRef(autoSubmit);
|
||||
const modalUriRef = useRef(modalUri);
|
||||
const requestRef = useRef(0);
|
||||
formRef.current = form;
|
||||
setupOpenRef.current = setupOpen;
|
||||
draftRef.current = draft;
|
||||
patternRef.current = pattern;
|
||||
autoSubmitRef.current = autoSubmit;
|
||||
modalUriRef.current = modalUri;
|
||||
|
||||
const [trackedHostId, setTrackedHostId] = useState(form.id);
|
||||
if (form.id !== trackedHostId) {
|
||||
setTrackedHostId(form.id);
|
||||
setSetupOpen(false);
|
||||
setDraft("");
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
setModalUri(undefined);
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
setConfirmClear(false);
|
||||
draftRef.current = "";
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
modalUriRef.current = undefined;
|
||||
setupOpenRef.current = false;
|
||||
}
|
||||
hostIdRef.current = form.id;
|
||||
|
||||
const rowPreview = useLiveTotp(form.totp?.uri, form.id);
|
||||
const modalPreview = useLiveTotp(setupOpen ? (modalUri || form.totp?.uri) : undefined, form.id);
|
||||
|
||||
const writeTotp = (next: HostTotpConfig | undefined) => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
setForm((current) => {
|
||||
if (current.id !== expectedHostId) return current;
|
||||
const updated = { ...current };
|
||||
if (next?.uri) updated.totp = next;
|
||||
else delete updated.totp;
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const previewText = async (text: string): Promise<string | null> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.previewTotp) {
|
||||
setError("unavailable");
|
||||
return null;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await bridge.previewTotp({ text });
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return null;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "invalid");
|
||||
return null;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
return result.uri;
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
return null;
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const commitSetup = async (options?: { requireSecret?: boolean }): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
const patternValue = validPattern(patternRef.current);
|
||||
if (patternValue == null) {
|
||||
setError("pattern");
|
||||
return { ok: false };
|
||||
}
|
||||
let uri = modalUriRef.current || formRef.current.totp?.uri;
|
||||
const text = draftRef.current.trim();
|
||||
if (text) {
|
||||
uri = await previewText(text) || undefined;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!uri) return { ok: false };
|
||||
}
|
||||
if (!uri) {
|
||||
if (options?.requireSecret) {
|
||||
setError("invalid");
|
||||
return { ok: false };
|
||||
}
|
||||
return { ok: true, totp: formRef.current.totp };
|
||||
}
|
||||
const next: HostTotpConfig = {
|
||||
uri,
|
||||
autoSubmit: autoSubmitRef.current,
|
||||
...(patternValue ? { promptPattern: patternValue } : {}),
|
||||
};
|
||||
writeTotp(next);
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
return { ok: true, totp: next };
|
||||
};
|
||||
const commitRef = useRef(commitSetup);
|
||||
commitRef.current = commitSetup;
|
||||
|
||||
useEffect(() => {
|
||||
if (!flushRef) return undefined;
|
||||
const flush = async (): Promise<HostTotpFlushResult> => {
|
||||
const expectedHostId = hostIdRef.current;
|
||||
if (hostIdRef.current !== expectedHostId) return { ok: false };
|
||||
if (!setupOpenRef.current) return { ok: true, totp: formRef.current.totp };
|
||||
return commitRef.current();
|
||||
};
|
||||
flushRef.current = flush;
|
||||
return () => {
|
||||
if (flushRef.current === flush) flushRef.current = null;
|
||||
};
|
||||
}, [flushRef]);
|
||||
|
||||
const openSetup = () => {
|
||||
setPattern(form.totp?.promptPattern || "");
|
||||
patternRef.current = form.totp?.promptPattern || "";
|
||||
setAutoSubmit(form.totp?.autoSubmit !== false);
|
||||
autoSubmitRef.current = form.totp?.autoSubmit !== false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setSetupOpen(true);
|
||||
setupOpenRef.current = true;
|
||||
};
|
||||
|
||||
const cancelSetup = () => {
|
||||
requestRef.current += 1;
|
||||
setSetupOpen(false);
|
||||
setupOpenRef.current = false;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
setModalUri(undefined);
|
||||
modalUriRef.current = undefined;
|
||||
setError(null);
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
};
|
||||
|
||||
const clearTotp = () => {
|
||||
cancelSetup();
|
||||
writeTotp(undefined);
|
||||
};
|
||||
|
||||
if (effectiveProtocol && HIDDEN_PROTOCOLS.has(effectiveProtocol)) return null;
|
||||
|
||||
const scanScreen = async () => {
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.scanTotpQrFromScreen) {
|
||||
setError("unavailable");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setScanning(true);
|
||||
try {
|
||||
const result = await bridge.scanTotpQrFromScreen();
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!result?.ok || !result.uri || !result.code) {
|
||||
setError(result?.error || "not-found");
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
setModalUri(result.uri);
|
||||
modalUriRef.current = result.uri;
|
||||
setDraft("");
|
||||
draftRef.current = "";
|
||||
} catch {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) setError("unavailable");
|
||||
} finally {
|
||||
if (hostIdRef.current === expectedHostId && requestRef.current === requestId) {
|
||||
setBusy(false);
|
||||
setScanning(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<HostDetailsSettingRow
|
||||
label={t("hostDetails.totp.title")}
|
||||
hint={t("hostDetails.totp.warning")}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{rowPreview && (
|
||||
<span
|
||||
className="font-mono text-xs font-normal tabular-nums tracking-widest text-muted-foreground"
|
||||
title={t("hostDetails.totp.countdown", { seconds: rowPreview.secondsRemaining })}
|
||||
>
|
||||
{rowPreview.code}
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={openSetup}
|
||||
>
|
||||
{configured ? t("hostDetails.totp.edit") : t("hostDetails.totp.setup")}
|
||||
</Button>
|
||||
</div>
|
||||
</HostDetailsSettingRow>
|
||||
|
||||
<Dialog open={setupOpen} onOpenChange={(open) => { if (!open) cancelSetup(); }}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("hostDetails.totp.modalTitle")}</DialogTitle>
|
||||
<DialogDescription>{t("hostDetails.totp.warning")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-secret">{t("hostDetails.totp.secret")}</Label>
|
||||
<Input
|
||||
id="host-totp-secret"
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
draftRef.current = event.target.value;
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (draftRef.current.trim()) void previewText(draftRef.current);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter") {
|
||||
event.preventDefault();
|
||||
void commitSetup({ requireSecret: true });
|
||||
}
|
||||
}}
|
||||
placeholder={configured ? t("hostDetails.totp.replacePlaceholder") : t("hostDetails.totp.secretPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.secret")}
|
||||
className="h-8 font-mono text-xs"
|
||||
disabled={busy}
|
||||
spellCheck={false}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
{t("hostDetails.totp.importQr")}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-8 focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => { void scanScreen(); }}
|
||||
>
|
||||
{scanning ? t("hostDetails.totp.scanning") : t("hostDetails.totp.scanScreen")}
|
||||
</Button>
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(event) => {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file) return;
|
||||
const expectedHostId = form.id;
|
||||
const requestId = ++requestRef.current;
|
||||
setBusy(true);
|
||||
void decodeQrFile(file)
|
||||
.then((text) => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
if (!text) {
|
||||
setError("not-found");
|
||||
setBusy(false);
|
||||
return;
|
||||
}
|
||||
return previewText(text);
|
||||
})
|
||||
.catch(() => {
|
||||
if (hostIdRef.current !== expectedHostId || requestRef.current !== requestId) return;
|
||||
setError("invalid");
|
||||
setBusy(false);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{modalPreview && (
|
||||
<div className="space-y-1">
|
||||
{importedAccount(modalPreview) ? (
|
||||
<p className="text-sm text-foreground">
|
||||
{t("hostDetails.totp.importedAccount", { account: importedAccount(modalPreview) })}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("hostDetails.totp.preview")}
|
||||
{" "}
|
||||
<span className="font-mono tracking-widest text-foreground">{modalPreview.code}</span>
|
||||
<span className="ml-2">{t("hostDetails.totp.countdown", { seconds: modalPreview.secondsRemaining })}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{t(errorKey(error))}</p>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium text-foreground">{t("hostDetails.totp.autoSubmit")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("hostDetails.totp.autoSubmitDesc")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={autoSubmit}
|
||||
onCheckedChange={(checked) => {
|
||||
setAutoSubmit(checked);
|
||||
autoSubmitRef.current = checked;
|
||||
}}
|
||||
aria-label={t("hostDetails.totp.autoSubmit")}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="host-totp-pattern">{t("hostDetails.totp.promptPattern")}</Label>
|
||||
<Input
|
||||
id="host-totp-pattern"
|
||||
value={pattern}
|
||||
onChange={(event) => {
|
||||
setPattern(event.target.value);
|
||||
patternRef.current = event.target.value;
|
||||
setError((current) => (current === "pattern" ? null : current));
|
||||
}}
|
||||
placeholder={t("hostDetails.totp.promptPatternPlaceholder")}
|
||||
aria-label={t("hostDetails.totp.promptPattern")}
|
||||
className="h-8 font-mono text-xs"
|
||||
spellCheck={false}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:justify-between">
|
||||
{configured ? (
|
||||
<Button type="button" variant="ghost" onClick={() => setConfirmClear(true)}>
|
||||
{t("hostDetails.totp.clear")}
|
||||
</Button>
|
||||
) : <span />}
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="secondary" onClick={cancelSetup}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" onClick={() => { void commitSetup({ requireSecret: true }); }} disabled={busy}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{confirmClear ? (
|
||||
<SystemPanelConfirmDialog
|
||||
open
|
||||
destructive
|
||||
title={t("hostDetails.totp.removeTitle")}
|
||||
message={t("hostDetails.totp.removeMessage")}
|
||||
confirmLabel={t("hostDetails.totp.clear")}
|
||||
onOpenChange={(open) => { if (!open) setConfirmClear(false); }}
|
||||
onConfirm={() => {
|
||||
setConfirmClear(false);
|
||||
clearTotp();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -235,6 +235,7 @@ test("HostTreeView exposes selected hosts to keyboard and assistive technology",
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*data-tree-depth="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*aria-level="1"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*tabindex="0"/);
|
||||
assert.match(markup, /data-host-id="host-1"[^>]*draggable="true"/);
|
||||
});
|
||||
|
||||
test("HostTreeView exposes only one tree item in the tab order", () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
} from '../domain/hostClickBehavior';
|
||||
import { sortByVaultOrder } from '../domain/vaultOrder';
|
||||
import { getVaultHostRowSubtitle } from '../domain/localShellHost';
|
||||
import { isVaultHostDraggable } from '../domain/vaultHostDrag';
|
||||
import { STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED } from '../infrastructure/config/storageKeys';
|
||||
import { GroupConfig, GroupNode, Host } from '../types';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from './ui/collapsible';
|
||||
@@ -25,6 +26,8 @@ import { ContextMenu, ContextMenuTrigger } from './ui/context-menu';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import { HostNotesIndicator } from './host/HostNotesIndicator';
|
||||
import { Button } from './ui/button';
|
||||
import { handleVaultHostDropToGroup } from './vault/vaultReorderDrag';
|
||||
import { beginVaultHostCardDrag } from './vault/vaultHostDragImage';
|
||||
import { VaultTreeGroupRow, VaultTreeItemRow } from './vault/VaultTreeRow';
|
||||
|
||||
const getTreeGroupDropIntent = (
|
||||
@@ -293,6 +296,7 @@ interface HostTreeViewProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -334,6 +338,7 @@ interface TreeNodeProps {
|
||||
onEditGroup: (groupPath: string) => void;
|
||||
onDeleteGroup: (groupPath: string) => void;
|
||||
moveHostToGroup: (hostId: string, groupPath: string | null) => void;
|
||||
moveHostsToGroup?: (hostIds: ReadonlySet<string>, groupPath: string | null) => void;
|
||||
moveGroup: (sourcePath: string, targetParent: string | null) => void;
|
||||
commitInlineGroupRename?: (name: string) => boolean | void | Promise<boolean | void>;
|
||||
cancelInlineGroupEdit?: () => void;
|
||||
@@ -381,6 +386,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -526,9 +532,14 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragOverDropTarget?.(null);
|
||||
const hostId = e.dataTransfer.getData("host-id");
|
||||
if (handleVaultHostDropToGroup({
|
||||
dataTransfer: e.dataTransfer,
|
||||
groupPath: node.path,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
resetHostDragState: () => {},
|
||||
})) return;
|
||||
const groupPath = e.dataTransfer.getData("group-path");
|
||||
if (hostId) moveHostToGroup(hostId, node.path);
|
||||
if (groupPath && getTreeGroupDropIntent(e.currentTarget, e.clientY) === "inside") {
|
||||
moveGroup(groupPath, node.path);
|
||||
}
|
||||
@@ -598,6 +609,7 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -774,11 +786,16 @@ const HostTreeItem: React.FC<HostTreeItemProps> = ({
|
||||
? 0
|
||||
: -1}
|
||||
onFocus={() => onActiveTreeItemChange(`host:${host.id}`)}
|
||||
draggable={!isMultiSelectMode}
|
||||
draggable={isVaultHostDraggable(Boolean(isMultiSelectMode), selectedHostIds, host.id)}
|
||||
onDragStart={(e) => {
|
||||
// copyMove: vault reorder uses move; focus-sidebar append uses copy.
|
||||
e.dataTransfer.effectAllowed = "copyMove";
|
||||
e.dataTransfer.setData("host-id", host.id);
|
||||
const ids = beginVaultHostCardDrag({
|
||||
dataTransfer: e.dataTransfer,
|
||||
hostId: host.id,
|
||||
isMultiSelectMode: Boolean(isMultiSelectMode),
|
||||
selectedHostIds,
|
||||
countLabel: (count) => t("vault.hosts.dragSelectionCount", { count }),
|
||||
});
|
||||
if (!ids) e.preventDefault();
|
||||
}}
|
||||
onClick={() => {
|
||||
const action = resolveHostActivateAction({
|
||||
@@ -909,6 +926,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup,
|
||||
onDeleteGroup,
|
||||
moveHostToGroup,
|
||||
moveHostsToGroup,
|
||||
moveGroup,
|
||||
managedGroupPaths,
|
||||
onUnmanageGroup,
|
||||
@@ -1219,6 +1237,7 @@ const HostTreeViewInner: React.FC<HostTreeViewProps> = ({
|
||||
onEditGroup={onEditGroup}
|
||||
onDeleteGroup={onDeleteGroup}
|
||||
moveHostToGroup={moveHostToGroup}
|
||||
moveHostsToGroup={moveHostsToGroup}
|
||||
moveGroup={moveGroup}
|
||||
managedGroupPaths={managedGroupPaths}
|
||||
onUnmanageGroup={onUnmanageGroup}
|
||||
@@ -1311,6 +1330,7 @@ function hostTreeViewAreEqual(prev: HostTreeViewProps, next: HostTreeViewProps):
|
||||
&& prev.onEditGroup === next.onEditGroup
|
||||
&& prev.onDeleteGroup === next.onDeleteGroup
|
||||
&& prev.moveHostToGroup === next.moveHostToGroup
|
||||
&& prev.moveHostsToGroup === next.moveHostsToGroup
|
||||
&& prev.moveGroup === next.moveGroup
|
||||
&& prev.commitInlineGroupRename === next.commitInlineGroupRename
|
||||
&& prev.cancelInlineGroupEdit === next.cancelInlineGroupEdit
|
||||
|
||||
@@ -4,48 +4,94 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
|
||||
import { formatKeyboardInteractiveServerPrompt } from "./KeyboardInteractiveModal.tsx";
|
||||
const require = createRequire(import.meta.url);
|
||||
|
||||
import {
|
||||
keyboardInteractiveFieldLabel,
|
||||
keyboardInteractiveGuidance,
|
||||
totpFillPlacement,
|
||||
visibleTotpFillPlacements,
|
||||
} from "./KeyboardInteractiveModal.tsx";
|
||||
|
||||
const modalSource = readFileSync(new URL("./KeyboardInteractiveModal.tsx", import.meta.url), "utf8");
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt preserves server instructions and prompt labels", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "Keyboard-interactive authentication prompts from server",
|
||||
instructions: "为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Secondary Authentication Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
[
|
||||
"Keyboard-interactive authentication prompts from server:",
|
||||
"| 为保障主机安全,请输入二次认证密码,如有疑问,请联系xxx,电话xxx。",
|
||||
"| Secondary Authentication Password:",
|
||||
].join("\n"),
|
||||
);
|
||||
test("keyboardInteractiveFieldLabel uses the cleaned last prompt line", () => {
|
||||
assert.equal(keyboardInteractiveFieldLabel("Secondary Authentication Password:", "Verification code"), "Secondary Authentication Password");
|
||||
assert.equal(keyboardInteractiveFieldLabel("JumpServer:\n| SMS code:", "Verification code"), "SMS code");
|
||||
assert.equal(keyboardInteractiveFieldLabel("[OTP Code]: ", "Verification code"), "OTP Code");
|
||||
assert.equal(keyboardInteractiveFieldLabel(" ", "Verification code"), "Verification code");
|
||||
});
|
||||
|
||||
test("formatKeyboardInteractiveServerPrompt omits hostname-only fallback prompts", () => {
|
||||
const text = formatKeyboardInteractiveServerPrompt({
|
||||
hostname: "192.168.9.138",
|
||||
name: "192.168.9.138",
|
||||
instructions: "",
|
||||
prompts: [
|
||||
{
|
||||
prompt: "Password:",
|
||||
echo: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
test("keyboard-interactive dialog shows a field label and an inline TOTP action", async () => {
|
||||
assert.doesNotMatch(modalSource, /<pre/);
|
||||
assert.match(modalSource, /keyboardInteractiveFieldLabel\(/);
|
||||
assert.match(modalSource, /keyboardInteractiveGuidance\(/);
|
||||
assert.match(modalSource, /data-totp-fill=\{fillPlacement\}/);
|
||||
assert.match(modalSource, /variant="secondary"/);
|
||||
assert.doesNotMatch(modalSource, /variant=\{fillPlacement/);
|
||||
assert.match(modalSource, /totpPrompt\.shared\.mjs/);
|
||||
const shared = require("../domain/totpPrompt.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const runtime = require("../domain/totp.shared.cjs") as { NON_TOTP_PROMPT: RegExp };
|
||||
const esm = await import("../domain/totpPrompt.shared.mjs");
|
||||
assert.equal(shared.NON_TOTP_PROMPT, runtime.NON_TOTP_PROMPT);
|
||||
assert.equal(esm.NON_TOTP_PROMPT.source, shared.NON_TOTP_PROMPT.source);
|
||||
assert.doesNotMatch(modalSource, /ring-primary/);
|
||||
assert.doesNotMatch(modalSource, /className="w-full"/);
|
||||
assert.doesNotMatch(modalSource, /onTypeTotp/);
|
||||
});
|
||||
|
||||
assert.equal(text, "");
|
||||
test("keyboardInteractiveGuidance keeps directions that are not already the field label", () => {
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "Duo Security",
|
||||
hostname: "vpn.example",
|
||||
instructions: "1. Duo Push\n2. SMS\nPasscode or option (1-3):",
|
||||
prompts: [{ prompt: "Passcode or option (1-3):", echo: true }],
|
||||
}), "Duo Security\n1. Duo Push\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "host",
|
||||
hostname: "other",
|
||||
instructions: "| Verification code:",
|
||||
prompts: [{ prompt: "Verification code:", echo: false }],
|
||||
}), "host");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "",
|
||||
prompts: [{ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }],
|
||||
}), "1. OTP\n2. SMS");
|
||||
assert.equal(keyboardInteractiveGuidance({
|
||||
name: "bastion",
|
||||
hostname: "bastion",
|
||||
instructions: "1. OTP",
|
||||
prompts: [{ prompt: "1. OTP\nOption>", echo: true }],
|
||||
}), "1. OTP");
|
||||
});
|
||||
|
||||
test("TOTP fill stays off password prompts and prefers verification prompts", () => {
|
||||
assert.equal(totpFillPlacement({ prompt: "Password:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "Verification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "[OTP Code]:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "One-time password:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "SMS code:", echo: false }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\n2. SMS\nOption> ", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "[Y/n]", echo: true }), "hidden");
|
||||
assert.equal(totpFillPlacement({ prompt: "1. OTP\nVerification code:", echo: false }), "preferred");
|
||||
assert.equal(totpFillPlacement({ prompt: "Response:", echo: true }), "available");
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
{ prompt: "Verification code:", echo: false },
|
||||
]), ["hidden", "preferred"]);
|
||||
assert.deepEqual(visibleTotpFillPlacements([
|
||||
{ prompt: "Password:", echo: false },
|
||||
]), ["hidden"]);
|
||||
});
|
||||
|
||||
test("keyboard-interactive modal cannot be dismissed by outside click or Escape", () => {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import { Eye, EyeOff, KeyRound, Loader2 } from "lucide-react";
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { NON_TOTP_PROMPT } from "../domain/totpPrompt.shared.mjs";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Button } from "./ui/button";
|
||||
import {
|
||||
@@ -34,35 +35,54 @@ export interface KeyboardInteractiveRequest {
|
||||
savedPassword?: string | null;
|
||||
/** When false, hide save-password UI (second-factor / EDR challenges). Default true. */
|
||||
allowSavePassword?: boolean;
|
||||
/** otpauth URI from the connection attempt that opened this dialog. */
|
||||
totpUri?: string;
|
||||
}
|
||||
|
||||
type KeyboardInteractiveServerPromptInput = Pick<
|
||||
KeyboardInteractiveRequest,
|
||||
"name" | "instructions" | "prompts" | "hostname"
|
||||
>;
|
||||
/** Last meaningful line of a server prompt, without the raw banner. */
|
||||
export function keyboardInteractiveFieldLabel(prompt: string | undefined, fallback: string): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = lines.at(-1) || "";
|
||||
const cleaned = last
|
||||
.replace(/^[\s||>•*-]+/, "")
|
||||
.replace(/[::]\s*$/, "")
|
||||
.trim()
|
||||
.replace(/^\[(.+)\]$/, "$1")
|
||||
.trim();
|
||||
return cleaned || fallback;
|
||||
}
|
||||
|
||||
/** Formats the server-supplied keyboard-interactive prompt block for display. */
|
||||
export function formatKeyboardInteractiveServerPrompt(request: KeyboardInteractiveServerPromptInput): string {
|
||||
const lines: string[] = [];
|
||||
const name = request.name?.trim();
|
||||
const hostname = request.hostname?.trim();
|
||||
const instructions = request.instructions?.trim();
|
||||
const hasServerText = !!instructions || !!(name && name !== hostname);
|
||||
if (!hasServerText) return "";
|
||||
if (name && name !== hostname) {
|
||||
lines.push(name.endsWith(":") ? name : `${name}:`);
|
||||
}
|
||||
if (instructions) {
|
||||
for (const line of instructions.split(/\r?\n/).map((part) => part.trim()).filter(Boolean)) {
|
||||
lines.push(`| ${line}`);
|
||||
}
|
||||
}
|
||||
function guidanceKeys(text: string): string[] {
|
||||
const trimmed = text.trim().toLowerCase();
|
||||
const label = keyboardInteractiveFieldLabel(text, "").toLowerCase();
|
||||
return [trimmed, label].filter(Boolean);
|
||||
}
|
||||
|
||||
/** Server directions that are not already the field label. Plain text, not a prompt dump. */
|
||||
export function keyboardInteractiveGuidance(request: Pick<KeyboardInteractiveRequest, "name" | "instructions" | "prompts" | "hostname">): string {
|
||||
const labelKeys = new Set<string>();
|
||||
const promptLines: string[] = [];
|
||||
for (const prompt of request.prompts || []) {
|
||||
const promptText = prompt.prompt?.trim();
|
||||
if (promptText) {
|
||||
lines.push(`| ${promptText}`);
|
||||
}
|
||||
const parts = String(prompt?.prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
const last = parts.at(-1) || "";
|
||||
for (const key of guidanceKeys(last)) labelKeys.add(key);
|
||||
promptLines.push(...parts.slice(0, -1));
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
const lines: string[] = [];
|
||||
const push = (text: string) => {
|
||||
const line = text.trim().replace(/^\|\s*/, "");
|
||||
if (!line) return;
|
||||
const keys = guidanceKeys(line);
|
||||
if (keys.some((key) => labelKeys.has(key) || seen.has(key))) return;
|
||||
for (const key of keys) seen.add(key);
|
||||
lines.push(line);
|
||||
};
|
||||
const hostname = (request.hostname || "").trim().toLowerCase();
|
||||
const name = (request.name || "").trim();
|
||||
if (name && name.toLowerCase() !== hostname) push(name);
|
||||
for (const part of String(request.instructions || "").split(/\r?\n/)) push(part);
|
||||
for (const part of promptLines) push(part);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
@@ -107,12 +127,43 @@ interface KeyboardInteractiveModalProps {
|
||||
request: KeyboardInteractiveRequest | null;
|
||||
onSubmit: (requestId: string, responses: string[], savePassword?: string) => boolean | Promise<boolean>;
|
||||
onCancel: (requestId: string) => boolean | Promise<boolean>;
|
||||
canFillTotp?: boolean;
|
||||
onFillTotp?: () => Promise<string | null> | string | null;
|
||||
}
|
||||
|
||||
const OTP_PROMPT = /otp|one[-\s]?time|totp|verification|passcode|authenticator|\bmfa\b|\b2fa\b|two[-\s]?factor|验证码|驗證碼|动态口令|動態口令|二次认证|二次認證|二次验证|二次驗證/i;
|
||||
|
||||
export type TotpFillPlacement = "preferred" | "available" | "hidden";
|
||||
|
||||
function lastPromptLine(prompt: string | undefined): string {
|
||||
const lines = String(prompt || "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
return lines.at(-1) || "";
|
||||
}
|
||||
|
||||
/** Where a TOTP fill action may appear. Password prompts never receive the code. */
|
||||
export function totpFillPlacement(prompt: KeyboardInteractivePrompt): TotpFillPlacement {
|
||||
const rawLast = lastPromptLine(prompt.prompt);
|
||||
const label = keyboardInteractiveFieldLabel(prompt.prompt, "");
|
||||
if (!label || NON_TOTP_PROMPT.test(rawLast) || NON_TOTP_PROMPT.test(label)) return "hidden";
|
||||
if (OTP_PROMPT.test(label)) return "preferred";
|
||||
if (isAPasswordPrompt({ prompt: label, echo: prompt.echo })) return "hidden";
|
||||
if (/\b(password|passwd|passphrase)\b/i.test(label)) return "hidden";
|
||||
return "available";
|
||||
}
|
||||
|
||||
export function visibleTotpFillPlacements(prompts: KeyboardInteractivePrompt[]): TotpFillPlacement[] {
|
||||
const placements = prompts.map(totpFillPlacement);
|
||||
const hasPreferred = placements.some((placement) => placement === "preferred");
|
||||
if (!hasPreferred) return placements;
|
||||
return placements.map((placement) => (placement === "preferred" ? placement : "hidden"));
|
||||
}
|
||||
|
||||
export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> = ({
|
||||
request,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
canFillTotp = false,
|
||||
onFillTotp,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [responses, setResponses] = useState<string[]>([]);
|
||||
@@ -185,6 +236,15 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
}
|
||||
}, [request, onCancel, isSubmitting]);
|
||||
|
||||
const handleFillTotp = useCallback(async (index: number) => {
|
||||
if (!request || isSubmitting || !onFillTotp) return;
|
||||
const prompt = request.prompts[index];
|
||||
if (!prompt || totpFillPlacement(prompt) === "hidden") return;
|
||||
const code = await onFillTotp();
|
||||
if (!code) return;
|
||||
handleResponseChange(index, code);
|
||||
}, [request, isSubmitting, onFillTotp, handleResponseChange]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && !isSubmitting) {
|
||||
@@ -197,12 +257,14 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
|
||||
if (!request) return null;
|
||||
|
||||
const serverPromptText = formatKeyboardInteractiveServerPrompt(request);
|
||||
const title = t("keyboard.interactive.title");
|
||||
const description =
|
||||
request.hostname
|
||||
? t("keyboard.interactive.descWithHost", { hostname: request.hostname })
|
||||
: t("keyboard.interactive.desc");
|
||||
const fillPlacements = canFillTotp ? visibleTotpFillPlacements(request.prompts) : [];
|
||||
const preferredFillIndex = fillPlacements.findIndex((placement) => placement === "preferred");
|
||||
const guidance = keyboardInteractiveGuidance(request);
|
||||
|
||||
return (
|
||||
<Dialog open={!!request} onOpenChange={() => {/* intentionally non-dismissable */}}>
|
||||
@@ -227,26 +289,40 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-2">
|
||||
{serverPromptText && (
|
||||
<pre className="max-h-36 overflow-auto rounded-md border border-border/60 bg-muted/45 px-3 py-2 text-xs leading-relaxed text-foreground whitespace-pre-wrap [overflow-wrap:anywhere]">
|
||||
{serverPromptText}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{guidance ? (
|
||||
<p className="max-h-36 overflow-auto whitespace-pre-wrap text-sm leading-relaxed text-muted-foreground [overflow-wrap:anywhere]">
|
||||
{guidance}
|
||||
</p>
|
||||
) : null}
|
||||
{request.prompts.map((prompt, index) => {
|
||||
const isPassword = !prompt.echo;
|
||||
const showPassword = showPasswords[index];
|
||||
const showPromptLabel = !(serverPromptText && request.prompts.length === 1);
|
||||
// Clean up prompt text (remove trailing colon and whitespace)
|
||||
const promptLabel = prompt.prompt.replace(/:\s*$/, "").trim();
|
||||
const promptLabel = keyboardInteractiveFieldLabel(
|
||||
prompt.prompt,
|
||||
t("keyboard.interactive.verificationCode"),
|
||||
);
|
||||
const fillPlacement = fillPlacements[index] ?? "hidden";
|
||||
|
||||
return (
|
||||
<div key={index} className="space-y-2">
|
||||
{showPromptLabel && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Label htmlFor={`ki-prompt-${index}`}>
|
||||
{promptLabel || t("keyboard.interactive.response")}
|
||||
{promptLabel}
|
||||
</Label>
|
||||
)}
|
||||
{fillPlacement !== "hidden" && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 px-2 font-normal focus:bg-secondary focus:text-secondary-foreground focus-visible:bg-secondary focus-visible:text-secondary-foreground"
|
||||
data-totp-fill={fillPlacement}
|
||||
onClick={() => { void handleFillTotp(index); }}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
{t("keyboard.interactive.fillTotp")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id={`ki-prompt-${index}`}
|
||||
@@ -256,7 +332,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder=""
|
||||
className={isPassword ? "pr-10" : undefined}
|
||||
autoFocus={index === 0}
|
||||
autoFocus={preferredFillIndex >= 0 ? index === preferredFillIndex : index === 0}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
{isPassword && (
|
||||
@@ -288,7 +364,7 @@ export const KeyboardInteractiveModal: React.FC<KeyboardInteractiveModalProps> =
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Button
|
||||
|
||||
@@ -4,6 +4,7 @@ import { applyGroupDefaults, resolveGroupDefaults } from "../domain/groupConfig"
|
||||
import { sanitizeCredentialValue } from "../domain/credentials";
|
||||
import { hasBridgeSshCredentials, resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from "../domain/sshAuth";
|
||||
import { resolveHostSshConnectionTimeouts } from "../domain/sshConnectionTimeouts";
|
||||
import { connectionTotpFields } from "../domain/totpConfig";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Button } from "./ui/button";
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "./ui/collapsible";
|
||||
@@ -258,6 +259,7 @@ export const KeychainExportPanel: React.FC<KeychainExportPanelProps> = ({
|
||||
sshAuthReadyTimeoutMs: connectionTimeouts.authReadyTimeoutSeconds * 1000,
|
||||
enableKeyboardInteractive: true,
|
||||
requiresMfa: !!effectiveExportHost.requiresMfa,
|
||||
...connectionTotpFields(effectiveExportHost),
|
||||
sessionId: `export-key:${effectiveExportHost.id}:${panel.key.id}`,
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
removeProxyProfileReferences,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
clearActiveGlobalDefaultProxyProfile,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
} from "../domain/globalDefaultProxy";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
@@ -391,6 +395,9 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
if (getActiveGlobalDefaultProxyProfileId() === deleteTarget.id) {
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
}
|
||||
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
|
||||
hosts,
|
||||
groupConfigs,
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import type { QuickConnectTarget } from "../domain/quickConnect";
|
||||
import { formatHostPort } from "../domain/host";
|
||||
@@ -671,7 +672,7 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
: `${protocolLabel} ${hostEndpoint}`
|
||||
);
|
||||
|
||||
return (
|
||||
const wizard = (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center"
|
||||
onClick={onClose}
|
||||
@@ -798,6 +799,13 @@ const QuickConnectWizard: React.FC<QuickConnectWizardProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The vault surface is visibility:hidden while a terminal tab is active.
|
||||
// Portal so the same wizard opened from the tab-bar switcher stays visible.
|
||||
if (typeof document !== "undefined" && document.body) {
|
||||
return createPortal(wizard, document.body);
|
||||
}
|
||||
return wizard;
|
||||
};
|
||||
|
||||
export default QuickConnectWizard;
|
||||
@@ -0,0 +1,163 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider";
|
||||
import type { Host, TerminalSession } from "../types";
|
||||
import { QuickSwitcher } from "./QuickSwitcher";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "saved-1",
|
||||
label: "core-switch",
|
||||
hostname: "10.1.1.1",
|
||||
username: "admin",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
}) as Host;
|
||||
|
||||
const renderSwitcher = (
|
||||
query: string,
|
||||
results: Host[] = [],
|
||||
locale = "en",
|
||||
) => renderToStaticMarkup(
|
||||
<I18nProvider locale={locale}>
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query={query}
|
||||
results={results}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
test("quick switcher keeps a matching host ahead of an address row", () => {
|
||||
const markup = renderSwitcher("10.1.1.1", [host()]);
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.1.1.1");
|
||||
const hostAt = markup.indexOf("core-switch");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(hostAt > selectedAt);
|
||||
assert.ok(quickConnectAt > hostAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher does not steal Enter from a saved host label", () => {
|
||||
for (const label of ["lab", "web01", "prod", "lab-switch"]) {
|
||||
const markup = renderSwitcher(label, [host({ id: label, label, hostname: "10.0.0.5" })]);
|
||||
const hostAt = markup.indexOf(`>${label}<`);
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.equal(markup.includes("Quick connect to"), false, label);
|
||||
assert.ok(selectedAt >= 0 && hostAt > selectedAt, label);
|
||||
}
|
||||
});
|
||||
|
||||
test("quick switcher keeps a matching open session ahead of an address row", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="en">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.4.4.4"
|
||||
results={[]}
|
||||
sessions={[{
|
||||
id: "sess-1",
|
||||
hostId: "host-1",
|
||||
hostLabel: "open-core",
|
||||
username: "admin",
|
||||
hostname: "10.4.4.4",
|
||||
status: "connected",
|
||||
} as TerminalSession]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
const sessionAt = markup.indexOf("open-core");
|
||||
const quickConnectAt = markup.indexOf("Quick connect to 10.4.4.4");
|
||||
const selectedAt = markup.indexOf("bg-primary/15");
|
||||
|
||||
assert.ok(selectedAt >= 0);
|
||||
assert.ok(sessionAt > selectedAt);
|
||||
assert.ok(quickConnectAt > sessionAt);
|
||||
assert.equal(markup.indexOf("bg-primary/15", selectedAt + 1), -1);
|
||||
});
|
||||
|
||||
test("quick switcher offers quick connect when nothing else matches", () => {
|
||||
const markup = renderSwitcher("root@10.2.0.8:2222");
|
||||
|
||||
assert.match(markup, /Quick connect to root@10\.2\.0\.8:2222/);
|
||||
assert.match(markup, /bg-primary\/15[\s\S]*Quick connect to root@10\.2\.0\.8:2222/);
|
||||
});
|
||||
|
||||
test("quick switcher does not offer quick connect for ordinary search text", () => {
|
||||
const markup = renderSwitcher("core switch");
|
||||
|
||||
assert.doesNotMatch(markup, /Quick connect to/);
|
||||
});
|
||||
|
||||
test("quick switcher quick connect row uses zh-TW wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-TW">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query="10.0.0.8"
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, /快速連線到 10\.0\.0\.8/);
|
||||
assert.match(markup, />主機</);
|
||||
assert.match(markup, />新增工作區</);
|
||||
assert.doesNotMatch(markup, /New Workspace/);
|
||||
});
|
||||
|
||||
test("quick switcher section labels use zh-CN wording", () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<I18nProvider locale="zh-CN">
|
||||
<QuickSwitcher
|
||||
isOpen
|
||||
query=""
|
||||
results={[host()]}
|
||||
sessions={[]}
|
||||
workspaces={[]}
|
||||
onQueryChange={() => undefined}
|
||||
onSelect={() => undefined}
|
||||
onSelectTab={() => undefined}
|
||||
onClose={() => undefined}
|
||||
onQuickConnect={() => undefined}
|
||||
onCreateWorkspace={() => undefined}
|
||||
showSftpTab={false}
|
||||
/>
|
||||
</I18nProvider>,
|
||||
);
|
||||
|
||||
assert.match(markup, />主机</);
|
||||
assert.match(markup, />标签页</);
|
||||
assert.match(markup, />新建工作区</);
|
||||
});
|
||||
@@ -8,12 +8,14 @@ import {
|
||||
Search,
|
||||
Terminal,
|
||||
TerminalSquare,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, TerminalSession, TerminalSettings, Workspace } from "../types";
|
||||
import { KeyBinding } from "../domain/models";
|
||||
import type { PaneMagnificationState } from "../domain/paneMagnification";
|
||||
import { isSwitcherQuickConnectInput } from "../domain/quickConnect";
|
||||
import { matchesSearchQuery } from "../lib/searchMatcher";
|
||||
import { buildQuickSwitcherShells, useDiscoveredShells, getShellIconPath, isMonochromeShellIcon } from "../lib/useDiscoveredShells";
|
||||
import { usePluginContributions } from "../application/state/usePluginContributions";
|
||||
@@ -30,11 +32,12 @@ type QuickSwitcherItemBase = {
|
||||
shortcut?: string;
|
||||
pluginId?: string;
|
||||
icon?: NetcattyPluginIconReference;
|
||||
quickConnectInput?: string;
|
||||
};
|
||||
|
||||
type QuickSwitcherItem = QuickSwitcherItemBase & (
|
||||
| { type: "plugin-command"; commandId: string }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view"; commandId?: never }
|
||||
| { type: "host" | "tab" | "workspace" | "action" | "shell" | "plugin-view" | "quick-connect"; commandId?: never }
|
||||
);
|
||||
|
||||
export function buildPaneMagnificationPaletteAction(
|
||||
@@ -165,6 +168,7 @@ interface QuickSwitcherProps {
|
||||
paneMagnificationState?: PaneMagnificationState;
|
||||
onMagnifyCurrentPane?: () => void;
|
||||
onRestoreMagnifiedPane?: () => void;
|
||||
onQuickConnect?: (input: string) => void;
|
||||
}
|
||||
|
||||
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
@@ -186,6 +190,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
paneMagnificationState = 'unavailable',
|
||||
onMagnifyCurrentPane,
|
||||
onRestoreMagnifiedPane,
|
||||
onQuickConnect,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const discoveredShells = useDiscoveredShells();
|
||||
@@ -335,19 +340,37 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
});
|
||||
};
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", "Hosts");
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
}
|
||||
const quickConnectItem: QuickSwitcherItem | null =
|
||||
trimmedQuery && isSwitcherQuickConnectInput(trimmedQuery)
|
||||
? {
|
||||
type: "quick-connect",
|
||||
id: "quick-connect",
|
||||
title: t("qs.quickConnectTo", { target: trimmedQuery }),
|
||||
quickConnectInput: trimmedQuery,
|
||||
}
|
||||
: null;
|
||||
|
||||
const hasTabsSection =
|
||||
builtInTabs.length > 0
|
||||
|| filteredOrphanSessions.length > 0
|
||||
|| filteredWorkspaces.length > 0;
|
||||
|
||||
// The address row is the default Enter target only when nothing else matches.
|
||||
// Matching hosts and open tabs stay ahead of it.
|
||||
if (quickConnectItem && results.length === 0 && !hasTabsSection) {
|
||||
pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (results.length > 0) {
|
||||
pushHeader("header:hosts", t("qs.hosts"));
|
||||
results.forEach((host) =>
|
||||
pushItem({ type: "host", id: host.id, data: host }),
|
||||
);
|
||||
if (quickConnectItem) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (hasTabsSection) {
|
||||
pushHeader("header:tabs", "Tabs");
|
||||
pushHeader("header:tabs", t("qs.tabs"));
|
||||
builtInTabs.forEach((tabId) => {
|
||||
pushItem({ type: "tab", id: tabId });
|
||||
});
|
||||
@@ -357,6 +380,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
filteredOrphanSessions.forEach((s) =>
|
||||
pushItem({ type: "tab", id: s.id, data: s }),
|
||||
);
|
||||
if (quickConnectItem && results.length === 0) pushItem(quickConnectItem);
|
||||
}
|
||||
|
||||
if (filteredShells.length > 0) {
|
||||
@@ -445,6 +469,10 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
|
||||
const handleItemSelect = (item: QuickSwitcherItem, useAlternate = false) => {
|
||||
switch (item.type) {
|
||||
case "quick-connect":
|
||||
if (item.quickConnectInput) onQuickConnect?.(item.quickConnectInput);
|
||||
onClose();
|
||||
break;
|
||||
case "host":
|
||||
onSelect(item.data as Host);
|
||||
break;
|
||||
@@ -542,7 +570,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
className="ml-auto inline-flex items-center gap-1 text-[11px] text-muted-foreground hover:text-foreground border border-border rounded px-1.5 py-0.5 transition-colors hover:bg-muted/50"
|
||||
>
|
||||
<Plus size={11} />
|
||||
<span>New Workspace</span>
|
||||
<span>{t("qs.newWorkspace")}</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -576,6 +604,20 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
|
||||
// Fixed virtual slots: keep content single-line (or plugin two-line) and clip overflow.
|
||||
const rowClass = `flex h-full min-h-0 items-center gap-3 overflow-hidden px-4 py-2.5 cursor-pointer transition-colors ${getQuickSwitcherRowStateClass(isSelected, isKeyboardNavigating)}`;
|
||||
|
||||
if (item.type === "quick-connect") {
|
||||
return (
|
||||
<div
|
||||
className={rowClass}
|
||||
onClick={() => handleItemSelect(item)}
|
||||
>
|
||||
<div className="shrink-0 flex items-center justify-center bg-primary text-primary-foreground h-5 w-5 rounded-[5px]">
|
||||
<Zap className="h-3 w-3" fill="currentColor" />
|
||||
</div>
|
||||
<span className="min-w-0 truncate text-sm font-medium">{item.title}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === "host") {
|
||||
const host = item.data as Host;
|
||||
const hostRow = (
|
||||
|
||||
@@ -141,6 +141,8 @@ type TerminalTabSettingsProps = Pick<
|
||||
| 'setLocalShellSidePanelAutoOpenTab'
|
||||
| 'workspaceFocusStyle'
|
||||
| 'setWorkspaceFocusStyle'
|
||||
| 'globalDefaultProxyProfileId'
|
||||
| 'setGlobalDefaultProxyProfileId'
|
||||
>;
|
||||
|
||||
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
|
||||
@@ -171,6 +173,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
setLocalShellSidePanelAutoOpenTab,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
}) {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
@@ -204,6 +208,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
|
||||
globalDefaultProxyProfileId={globalDefaultProxyProfileId}
|
||||
setGlobalDefaultProxyProfileId={setGlobalDefaultProxyProfileId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -599,6 +605,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setLocalShellSidePanelAutoOpenTab={settings.setLocalShellSidePanelAutoOpenTab}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
globalDefaultProxyProfileId={settings.globalDefaultProxyProfileId}
|
||||
setGlobalDefaultProxyProfileId={settings.setGlobalDefaultProxyProfileId}
|
||||
/>
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { useActiveGlobalDefaultProxyProfileId } from "../application/state/useGlobalDefaultProxyProfileId";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
@@ -161,16 +162,19 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
resolveTransferSourceSessionId,
|
||||
]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
// Memoized on vault props, so this subscription is what refreshes the list
|
||||
// after the global default changes.
|
||||
const globalDefaultProxyProfileId = useActiveGlobalDefaultProxyProfileId();
|
||||
const effectiveHosts = useMemo(() => {
|
||||
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
const inheritanceOptions = { validProxyProfileIds, globalProxyProfileId: globalDefaultProxyProfileId };
|
||||
return hosts.map(h => {
|
||||
const withGroupDefaults = h.group
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
|
||||
: applyGroupDefaults(h, {}, { validProxyProfileIds });
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, inheritanceOptions), inheritanceOptions)
|
||||
: applyGroupDefaults(h, {}, inheritanceOptions);
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
});
|
||||
}, [hosts, groupConfigs, proxyProfiles]);
|
||||
}, [hosts, groupConfigs, proxyProfiles, globalDefaultProxyProfileId]);
|
||||
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
|
||||
|
||||
+45
-14
@@ -59,7 +59,7 @@ import { MULTILINE_PASTE_CONFIRM_MIN_LINES_DEFAULT } from "../domain/terminalPas
|
||||
import { isPluginHostProtocol } from "../domain/pluginConnection";
|
||||
import { clearTerminalBootEpoch, setTerminalBootEpoch } from "../domain/terminalBootEpoch";
|
||||
import {
|
||||
appendTerminalPromptSecurityTail,
|
||||
foldTerminalPromptSecurityOutput,
|
||||
isConfirmedTerminalShellPrompt,
|
||||
isUntrustedTerminalInputPrompt,
|
||||
} from "../domain/terminalPromptSecurity";
|
||||
@@ -163,8 +163,12 @@ import {
|
||||
import { clearKittyKeyboardBroadcastSession } from "./terminal/runtime/kittyKeyboardBroadcast";
|
||||
import { registerTerminalSensitiveInputReader } from "./terminal/runtime/terminalSensitiveInputRegistry";
|
||||
import { registerTerminalCommandInjectionReadyReader } from "./terminal/runtime/terminalCommandInjectionReadyRegistry";
|
||||
import { isIdleShellReadyForCommandInjection } from "../domain/terminalCommandInjectionReady";
|
||||
import {
|
||||
isIdleShellReadyForCommandInjection,
|
||||
isShellPromptReadyForInsertion,
|
||||
} from "../domain/terminalCommandInjectionReady";
|
||||
import { detectPrompt } from "./terminal/autocomplete/promptDetector";
|
||||
import type { SensitiveInputActiveRef } from "./terminal/autocomplete/terminalAutocompletePrompt";
|
||||
import {
|
||||
applyUserCursorPreference,
|
||||
applyUserCursorWidthPreference,
|
||||
@@ -439,7 +443,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const recorder = useScriptRecorder(sessionId);
|
||||
const recorderRef = useRef(recorder);
|
||||
recorderRef.current = recorder;
|
||||
const passwordPromptActiveRef = useRef(false);
|
||||
const passwordPromptActiveRef = useRef(false) as SensitiveInputActiveRef;
|
||||
const terminalTitleRef = useRef<string | undefined>(undefined);
|
||||
const pluginTerminalLifecycleRef = useRef<ReturnType<typeof usePluginTerminalSessionLifecycle> | null>(null);
|
||||
useEffect(() => registerTerminalSensitiveInputReader(
|
||||
@@ -448,6 +452,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
), [sessionId]);
|
||||
useEffect(() => () => clearTerminalBroadcastUserInput(sessionId), [sessionId]);
|
||||
const sensitivePromptOutputTailRef = useRef("");
|
||||
const heredocPromptOriginRef = useRef("");
|
||||
const promptSecurityPendingCrRef = useRef(false);
|
||||
const [activeScriptRun, setActiveScriptRun] = useState<import('@/types/global/netcatty-bridge-script.d.ts').ScriptRun | undefined>(undefined);
|
||||
const dismissedScriptRunIdsRef = useRef(new Set<string>());
|
||||
|
||||
@@ -599,18 +605,21 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const onTerminalDataCaptureRef = useRef(onTerminalDataCapture);
|
||||
const onSessionExitRef = useRef(onSessionExit);
|
||||
const commandBufferRef = useRef<string>("");
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, () => {
|
||||
useEffect(() => registerTerminalCommandInjectionReadyReader(sessionId, (options) => {
|
||||
const term = termRef.current;
|
||||
if (!term) return false;
|
||||
const prompt = detectPrompt(term);
|
||||
return isIdleShellReadyForCommandInjection({
|
||||
const readiness = {
|
||||
sensitiveInputActive: passwordPromptActiveRef.current,
|
||||
hasLiveTerminal: true,
|
||||
alternateScreenActive: isTerminalAlternateScreenActive(term),
|
||||
isAtPrompt: prompt.isAtPrompt,
|
||||
userInputLength: prompt.userInput.length,
|
||||
pendingTypedInputLength: commandBufferRef.current.length,
|
||||
});
|
||||
};
|
||||
return options?.allowDraft
|
||||
? isShellPromptReadyForInsertion(readiness)
|
||||
: isIdleShellReadyForCommandInjection(readiness);
|
||||
}), [sessionId]);
|
||||
const promptLineBreakStateRef = useRef<PromptLineBreakState>(createPromptLineBreakState());
|
||||
const [hasMouseTracking, setHasMouseTracking] = useState(false);
|
||||
@@ -1695,6 +1704,9 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}
|
||||
if (typeof payload.passwordPromptActive === "boolean") {
|
||||
passwordPromptActiveRef.current = payload.passwordPromptActive;
|
||||
// The snapshot boolean has no source. A restored true latch stays
|
||||
// explicit so Email:/Username: cannot bypass a plugin-marked field.
|
||||
if (payload.passwordPromptActive) passwordPromptActiveRef.source = "explicit";
|
||||
}
|
||||
if (payload.cwd !== undefined) {
|
||||
const cwd = terminalCwdTracker.setRendererCwd(payload.cwd, "snapshot");
|
||||
@@ -2001,10 +2013,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
chunk: string,
|
||||
meta?: TerminalSessionDataMeta,
|
||||
) => {
|
||||
sensitivePromptOutputTailRef.current = appendTerminalPromptSecurityTail(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
chunk,
|
||||
);
|
||||
const folded = foldTerminalPromptSecurityOutput({
|
||||
origin: heredocPromptOriginRef.current,
|
||||
current: sensitivePromptOutputTailRef.current,
|
||||
pendingCr: promptSecurityPendingCrRef.current,
|
||||
}, chunk);
|
||||
heredocPromptOriginRef.current = folded.origin;
|
||||
sensitivePromptOutputTailRef.current = folded.current;
|
||||
promptSecurityPendingCrRef.current = folded.pendingCr;
|
||||
const promptSecurityOptions = {
|
||||
allowHostStyleGreaterThan: isNetworkDevice,
|
||||
alternateScreen: termRef.current
|
||||
@@ -2013,23 +2029,30 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
};
|
||||
if (typeof meta?.pluginPipelineSensitiveInput === "boolean") {
|
||||
passwordPromptActiveRef.current = meta.pluginPipelineSensitiveInput;
|
||||
// Authoritative metadata must not be downgraded to the heuristic latch,
|
||||
// or trusted labels such as Email: would bypass snippet blocking.
|
||||
passwordPromptActiveRef.source = meta.pluginPipelineSensitiveInput ? "explicit" : "heuristic";
|
||||
if (meta.pluginPipelineSensitiveInput) {
|
||||
autocompleteCloseRef.current?.();
|
||||
} else {
|
||||
sensitivePromptOutputTailRef.current = "";
|
||||
heredocPromptOriginRef.current = "";
|
||||
promptSecurityPendingCrRef.current = false;
|
||||
}
|
||||
return;
|
||||
} else if (isUntrustedTerminalInputPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = true;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
autocompleteCloseRef.current?.();
|
||||
} else if (isConfirmedTerminalShellPrompt(
|
||||
sensitivePromptOutputTailRef.current,
|
||||
folded.promptText,
|
||||
promptSecurityOptions,
|
||||
)) {
|
||||
passwordPromptActiveRef.current = false;
|
||||
passwordPromptActiveRef.source = "heuristic";
|
||||
}
|
||||
}, [isNetworkDevice]);
|
||||
|
||||
@@ -2341,6 +2364,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
}), []);
|
||||
|
||||
const xTermRuntimeContextRef = useRef<Omit<CreateXTermRuntimeContext, "container" | "initiallyVisible"> | null>(null);
|
||||
const archiveHostRef = useRef(host);
|
||||
archiveHostRef.current = host;
|
||||
const pluginDecorationRulesRef = useRef<readonly PluginTerminalDecorationRule[]>(Object.freeze([]));
|
||||
const pluginDecorationRefreshRef = useRef<(reason: string) => void>(() => {});
|
||||
|
||||
@@ -2430,7 +2455,12 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const pluginAwareOnCommandCompleted = useCallback(() => {
|
||||
pluginTerminalLifecycle.onCommandCompleted();
|
||||
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
|
||||
}, [pluginTerminalLifecycle]);
|
||||
// Hibernate wake builds its runtime from this callback. Same local
|
||||
// prompt-return refresh as useTerminalEffects (#1952).
|
||||
if (host.protocol === "local") {
|
||||
onCommandSubmitted?.("", host.id, host.label, sessionId);
|
||||
}
|
||||
}, [host.id, host.label, host.protocol, onCommandSubmitted, pluginTerminalLifecycle, sessionId]);
|
||||
const pluginAwareOnTerminalCwdChange = useCallback((
|
||||
changedSessionId: string,
|
||||
cwd: string | null,
|
||||
@@ -4331,6 +4361,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
|
||||
xTermRuntimeContextRef.current = {
|
||||
host,
|
||||
getHost: () => archiveHostRef.current,
|
||||
fontFamilyId,
|
||||
resolvedFontFamily,
|
||||
fontSize: effectiveFontSize,
|
||||
@@ -4654,7 +4685,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
onWake: wakeFromHibernateRuntime,
|
||||
});
|
||||
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
useTerminalEffects({ CONNECTION_TIMEOUT, Error, XTERM_PERFORMANCE_CONFIG, applyUserCursorPreference, applyUserCursorWidthPreference, shouldApplyUserCursorPreference, snapshotUserCursorPreference, auth, autocompleteCloseRef, autocompleteInputRef, autocompleteKeyEventRef, autocompleteRepositionRef, captureTerminalLogData, chainHosts: resolvedChainHosts, chainProgress, clearTerminalCwd, commandBufferRef, connectionLogBufferRef, containerRef, createPromptLineBreakState, createReplaySafeTerminalLogSanitizer, createXTermRuntime, deferTerminalResizeRef, disableTerminalFontZoomRef, effectiveFontSize, effectiveFontWeight, effectiveTheme, error, executeSnippetCommand, finalizeTerminalLogData, fitAddonRef, fontFamilyId, fontSize, fontWeightFixupDoneRef, forceCloseHibernatedSession, forceSyncRenderAfterResize, handleOsc52ReadRequest, handleTerminalDataCaptureOnce, hasConnectedRef, hasRuntimeRef, host, hotkeySchemeRef, hibernatedRef, identities, inWorkspace, isBootActiveRef, bootEpochRef, isBroadcastEnabledRef, broadcastPasswordBypassRef, isComposeBarOpen: effectiveComposeBarOpen, isConnectionAwaitingUserInput, isConnectionPastTcpDial, isFocusMode, isFocused, isLocalConnection, isNetworkDevice, isResizing: deferTerminalResize, isRestoringSelectionRef, isSearchOpen, isSerialConnection, isVisible, isVisibleRef, keyBindingsRef, keys, kittyKeyboardProtocolEnabledForSession, knownCwdRef, lastFittedSizeRef, lastToastedErrorRef, logger, mouseTrackingRef, needsHostKeyVerification, onBroadcastInputRef, onBroadcastInterruptPriorityChange, onCommandExecuted, onCommandSubmitted: cwdAwareOnCommandSubmitted, onCommandSettled: onCommandSubmitted, onHotkeyActionRef, onOpenExternalError, onOutputTriggerUserInputRef: noteOutputTriggerUserInputRef, onPluginRuntimeCwdChange: pluginAwareOnRuntimeCwdChange, onSnippetShortkeyRef, onSnippetExecutorChange, onTerminalCwdChange, onTerminalTitleChange, onTerminalBell, onTerminalFontSizeChange, paneLayoutKey, passwordPromptActiveRef, pendingAuthRef, pendingOutputScrollRef, pluginDecorationRefreshRef, pluginDecorationRules, pluginDecorationRulesRef, pluginTerminalLifecycle, pluginTerminalProviderRevision, isPluginTerminalProviderAvailable, requestPluginTerminalProviders, prepareRestoredReconnect, prepareInitialCwdIntent, prevIsResizingRef, promptLineBreakStateRef, resizeSession, resolveHostAuth, resolvedFontFamily, safeFit, scriptRecorderRef: recorderRef, searchAddonRef, serialConfig, serialLineBufferRef, serializeAddonRef, sessionId, sessionRef, sessionStarters, setError, setHasMouseTracking, setIsCancelling, setIsDisconnectedDialogDismissed, requestSearchFocus, setNeedsHostKeyVerification, setPendingHostKeyInfo, setPendingHostKeyRequestId, setProgressLogs, setProgressValue, setShowLogs, setStatus, setTimeLeft, shellType, shouldEnableNativeUserInputAutoScroll, shouldProbeSessionCwd, shouldStartTerminalBackend, vaultInitialized, attachExistingSession, attachAuthorization, attachHomeWebContentsIdRef, snippetsRef, splitResizeActive: isResizing, status, statusRef, sudoAutofillRef, t, teardown, telnetLocalEchoRef, termRef, terminalAltKeyOptions, terminalBackend, terminalContextActionsRef, terminalCwdTracker, terminalDataCapturedRef, terminalLogSanitizerRef, terminalOutputHistory: terminalOutputHistoryRef.current, terminalSettings, terminalSettingsRef, terminalTitleRef, toHostKeyInfo, toast, updateStatus, useEffect, useLayoutEffect, workspaceId, xtermRuntimeRef, zmodem, zmodemToastedRef, restoreState });
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -6,7 +6,6 @@ import { FolderTree, History, MessageSquare, PanelLeft, PanelRight, Palette, X,
|
||||
import React, { memo, startTransition, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { activeTabStore } from '../application/state/activeTabStore';
|
||||
import { getScriptRecordingSnapshot } from '../application/state/scriptRecordingStore.ts';
|
||||
import { canReuseTerminalConnection } from '../application/state/terminalConnectionReuse';
|
||||
import { resolveTerminalSessionExitIntent, type TerminalSessionExitEvent } from '../application/state/resolveTerminalSessionExitIntent';
|
||||
import { prewarmAIStateStorageSnapshots } from '../application/state/aiStateSnapshots';
|
||||
import {
|
||||
@@ -114,7 +113,7 @@ import {
|
||||
DEFAULT_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB,
|
||||
resolveSessionSidePanelAutoOpen,
|
||||
} from '../domain/terminalSidePanelAutoOpen';
|
||||
import { shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import { canScheduleCommandCwdProbe, shouldProbeCommandCwd } from './terminalLayer/commandCwdProbe';
|
||||
import {
|
||||
resolvePreferredTerminalCwd,
|
||||
scheduleBackendCwdProbeAfterCommand,
|
||||
@@ -1348,7 +1347,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
|
||||
const tabId = activeTabIdRef.current;
|
||||
const session = sessionsRef.current.find((candidate) => candidate.id === sessionId);
|
||||
if (!session || !canReuseTerminalConnection(session)) return;
|
||||
// Local POSIX shells do not emit OSC 7. They share this probe so session
|
||||
// restore can `cd` back after restart (#1952). Prompt return calls this
|
||||
// again with an empty command once the shell is idle, so a slow command
|
||||
// such as `sleep 1; cd /tmp` is not recorded at the 150ms snapshot.
|
||||
// SSH keeps the extra-exec eligibility check inside canScheduleCommandCwdProbe.
|
||||
if (!session || !canScheduleCommandCwdProbe(session)) return;
|
||||
const sessionHost = sessionHostsMapRef.current.get(sessionId);
|
||||
const visibleSftpHost = tabId && sidePanelLayoutHasTool(sidePanelLayoutsRef.current.get(tabId), 'sftp')
|
||||
? sftpHostForTabRef.current.get(tabId) ?? null
|
||||
@@ -1372,6 +1376,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
getSessionPwd: (id, options) => terminalBackend.getSessionPwd(id, options),
|
||||
canProbe: async () => {
|
||||
if (cwdProbeGenerationRef.current.get(sessionId) !== probeGeneration) return false;
|
||||
// Local cwd is the PTY process directory, not an extra SSH channel.
|
||||
if (session.protocol === "local") return true;
|
||||
const host = sessionHostsMapRef.current.get(sessionId);
|
||||
if (!host) return false;
|
||||
const hostDeviceClass = classifyDistroId(host.distro);
|
||||
|
||||
@@ -44,6 +44,7 @@ import React, {
|
||||
useState,
|
||||
} from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { registerQuickConnectOpener } from "../application/quickConnectBridge";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { useStoredBoolean } from "../application/state/useStoredBoolean";
|
||||
import { useStoredNumber } from "../application/state/useStoredNumber";
|
||||
@@ -561,6 +562,12 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => registerQuickConnectOpener(({ target, warnings }) => {
|
||||
setQuickConnectTarget(target);
|
||||
setQuickConnectWarnings(warnings);
|
||||
setIsQuickConnectOpen(true);
|
||||
}), []);
|
||||
|
||||
// Protocol select state (for hosts with multiple protocols)
|
||||
const [protocolSelectHost, setProtocolSelectHost] = useState<Host | null>(
|
||||
null,
|
||||
@@ -1792,7 +1799,8 @@ export const vaultViewAreEqual = (
|
||||
// Only these terminal connection settings are forwarded to
|
||||
// PortForwarding inside the vault, so compare them directly. Other
|
||||
// terminal settings (fonts, themes, etc.) don't affect this subtree
|
||||
// and we don't want to re-render for them.
|
||||
// and we don't want to re-render for them. onConnect is also omitted:
|
||||
// temporary-connection toggles are read from a ref at connect time.
|
||||
prev.terminalSettings?.verifyHostKeys ===
|
||||
next.terminalSettings?.verifyHostKeys &&
|
||||
prev.terminalSettings?.keepaliveInterval ===
|
||||
|
||||
@@ -47,7 +47,30 @@ test("plugin importer merge and commit workflow is owned by application state",
|
||||
assert.match(pluginImporterCommitHookSource, /buildPluginImporterSafePreview/);
|
||||
});
|
||||
|
||||
test("host list section receives marquee selection and batched group move", () => {
|
||||
const sectionStart = vaultViewLayoutSource.indexOf("<VaultHostListSection");
|
||||
const sectionEnd = vaultViewLayoutSource.indexOf("/>", sectionStart);
|
||||
const section = vaultViewLayoutSource.slice(sectionStart, sectionEnd);
|
||||
assert.match(section, /\bmoveHostsToGroup,/);
|
||||
assert.match(section, /\bsetIsMultiSelectMode,/);
|
||||
assert.match(section, /\bsetSelectedHostIds,/);
|
||||
});
|
||||
|
||||
test("Vault import entry is disabled while an import is running", () => {
|
||||
assert.match(vaultViewLayoutSource, /disabled=\{importProgress\?\.status === "running"\}/);
|
||||
assert.match(vaultViewLayoutSource, /if \(importProgress\?\.status === "running"\) return;/);
|
||||
});
|
||||
|
||||
test("new host menu keeps the local shell icon at the same size as its siblings", () => {
|
||||
const menuStart = vaultViewLayoutSource.indexOf('<DropdownContent className="flex w-max min-w-44 flex-col" align="end" alignToParent>');
|
||||
assert.ok(menuStart >= 0);
|
||||
const menuEnd = vaultViewLayoutSource.indexOf("</DropdownContent>", menuStart);
|
||||
const menu = vaultViewLayoutSource.slice(menuStart, menuEnd);
|
||||
assert.match(menu, /className="flex w-max min-w-44 flex-col"/);
|
||||
assert.doesNotMatch(menu, /className="w-44"/);
|
||||
assert.match(menu, /<TerminalSquare size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<FolderTree size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Upload size=\{14\} className="shrink-0" \/>/);
|
||||
assert.match(menu, /<Download size=\{14\} className="shrink-0" \/>/);
|
||||
assert.doesNotMatch(menu, /className="w-full justify-start gap-2"/);
|
||||
});
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { AI_STATE_CHANGED_EVENT } from '../../application/state/aiStateEvents';
|
||||
import {
|
||||
externalMcpApprovalToolLabel,
|
||||
notifyExternalMcpApproval,
|
||||
publishExternalMcpApprovalAlertMode,
|
||||
} from '../../application/state/externalMcpApprovalAlert';
|
||||
@@ -153,7 +154,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
{entries.map(([id, req]) => (
|
||||
<div key={id} className="space-y-1.5">
|
||||
<ToolCall
|
||||
name={req.toolName}
|
||||
name={externalMcpApprovalToolLabel(req.toolName)}
|
||||
args={req.args}
|
||||
approvalTarget={req.target}
|
||||
isLoading={false}
|
||||
|
||||
@@ -27,8 +27,10 @@ export interface ProxyPanelProps {
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
selectedProxyProfileId?: string;
|
||||
directProxy?: boolean;
|
||||
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
|
||||
onSelectProxyProfile?: (profileId: string | undefined) => void;
|
||||
onSelectDirectProxy?: () => void;
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -42,8 +44,10 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
proxyProfiles = [],
|
||||
identities = [],
|
||||
selectedProxyProfileId,
|
||||
directProxy = false,
|
||||
onUpdateProxy,
|
||||
onSelectProxyProfile,
|
||||
onSelectDirectProxy,
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
@@ -54,13 +58,18 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const customValue = '__custom__';
|
||||
const directValue = '__direct__';
|
||||
const selectedProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === selectedProxyProfileId),
|
||||
[proxyProfiles, selectedProxyProfileId],
|
||||
);
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
|
||||
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile);
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile && !directProxy);
|
||||
const selectedValue = directProxy
|
||||
? directValue
|
||||
: selectedProfile
|
||||
? selectedProfile.id
|
||||
: customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile) && !directProxy;
|
||||
const isCommandProxy = isProxyCommandConfig(proxyConfig);
|
||||
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
|
||||
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
|
||||
@@ -71,8 +80,8 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
|
||||
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
|
||||
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
|
||||
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const canSave = directProxy || (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && !directProxy && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const manualCredentialsValue = '__manual_credentials__';
|
||||
const missingIdentityValue = '__missing_identity__';
|
||||
const selectedIdentity = useMemo(
|
||||
@@ -103,7 +112,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile || onSelectDirectProxy) && onSelectProxyProfile && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
@@ -111,7 +120,13 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</div>
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
|
||||
onValueChange={(value) => {
|
||||
if (value === directValue) {
|
||||
onSelectDirectProxy?.();
|
||||
return;
|
||||
}
|
||||
onSelectProxyProfile(value === customValue ? undefined : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('hostDetails.proxyPanel.savedProxy')}
|
||||
@@ -120,6 +135,9 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{onSelectDirectProxy && (
|
||||
<SelectItem value={directValue}>{t('hostDetails.proxy.direct')}</SelectItem>
|
||||
)}
|
||||
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
|
||||
{proxyProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
@@ -163,7 +181,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isUsingProfile && (
|
||||
{!isUsingProfile && !directProxy && (
|
||||
<>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="space-y-2">
|
||||
@@ -328,7 +346,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
|
||||
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
|
||||
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
|
||||
</Button>
|
||||
|
||||
@@ -51,6 +51,7 @@ import { CopilotCliCard } from "./ai/CopilotCliCard";
|
||||
import { CodebuddyCard } from "./ai/CodebuddyCard";
|
||||
import { SafetySettings } from "./ai/SafetySettings";
|
||||
import { ExternalMcpCard } from "./ai/ExternalMcpCard";
|
||||
import { AIPrivacySection } from "./ai/AIPrivacySection";
|
||||
import { ToolAccessGuidance } from "./ai/ToolAccessGuidance";
|
||||
import { PermissionGrantsSettings } from "./ai/PermissionGrantsSettings";
|
||||
import { useAIPermissionGrantsState } from "../../../application/state/useAIPermissionGrantsState";
|
||||
@@ -1297,6 +1298,8 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
|
||||
</SettingCard>
|
||||
</SettingsSection>
|
||||
|
||||
<AIPrivacySection />
|
||||
|
||||
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
|
||||
<ExternalMcpCard />
|
||||
</SettingsSection>
|
||||
|
||||
@@ -5,10 +5,13 @@ import type {
|
||||
CursorShape,
|
||||
HostInfoBarTitleMode,
|
||||
PasswordPromptAssistMode,
|
||||
ProxyProfile,
|
||||
TerminalEmulationType,
|
||||
TerminalSettings,
|
||||
} from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_PROXY_PROFILES } from "../../../infrastructure/config/storageKeys";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE, resolveTerminalFontFamilyId, type TerminalFont } from "../../../infrastructure/config/fonts";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
|
||||
@@ -43,6 +46,34 @@ import {
|
||||
TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MIN,
|
||||
} from "../../../domain/terminalInlineImages";
|
||||
|
||||
const GLOBAL_DEFAULT_PROXY_NONE = "__none__";
|
||||
|
||||
const readSavedProxyProfiles = (): ProxyProfile[] => {
|
||||
const stored = localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
};
|
||||
|
||||
const useSavedProxyProfiles = (): ProxyProfile[] => {
|
||||
const [profiles, setProfiles] = useState<ProxyProfile[]>(readSavedProxyProfiles);
|
||||
useEffect(() => {
|
||||
const refresh = (key?: string | null) => {
|
||||
if (key && key !== STORAGE_KEY_PROXY_PROFILES) return;
|
||||
setProfiles(readSavedProxyProfiles());
|
||||
};
|
||||
const onAdapterChange = (event: Event) => {
|
||||
refresh((event as CustomEvent<{ key?: string }>).detail?.key);
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => refresh(event.key);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}, []);
|
||||
return profiles;
|
||||
};
|
||||
|
||||
const FONT_WEIGHT_OPTIONS = [
|
||||
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
|
||||
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
|
||||
@@ -87,6 +118,8 @@ function SettingsTerminalTab(props: {
|
||||
availableFonts: TerminalFont[];
|
||||
workspaceFocusStyle: 'dim' | 'border';
|
||||
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
|
||||
globalDefaultProxyProfileId?: string;
|
||||
setGlobalDefaultProxyProfileId: (id: string | undefined) => void;
|
||||
}) {
|
||||
const {
|
||||
terminalThemeId,
|
||||
@@ -117,8 +150,14 @@ function SettingsTerminalTab(props: {
|
||||
availableFonts,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const proxyProfiles = useSavedProxyProfiles();
|
||||
const defaultProxyValue = proxyProfiles.some((profile) => profile.id === globalDefaultProxyProfileId)
|
||||
? globalDefaultProxyProfileId ?? GLOBAL_DEFAULT_PROXY_NONE
|
||||
: GLOBAL_DEFAULT_PROXY_NONE;
|
||||
|
||||
// Local shell settings state
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
@@ -932,6 +971,23 @@ function SettingsTerminalTab(props: {
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.connection")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
anchorId="terminal-default-proxy"
|
||||
label={t("settings.terminal.connection.defaultProxy")}
|
||||
description={t("settings.terminal.connection.defaultProxy.desc")}
|
||||
>
|
||||
<Select
|
||||
value={defaultProxyValue}
|
||||
onChange={(value) => {
|
||||
setGlobalDefaultProxyProfileId(value === GLOBAL_DEFAULT_PROXY_NONE ? undefined : value);
|
||||
}}
|
||||
options={[
|
||||
{ value: GLOBAL_DEFAULT_PROXY_NONE, label: t("settings.terminal.connection.defaultProxy.none") },
|
||||
...proxyProfiles.map((profile) => ({ value: profile.id, label: profile.label })),
|
||||
]}
|
||||
className="w-56"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-verify-host-keys"
|
||||
label={t("settings.terminal.connection.verifyHostKeys")}
|
||||
@@ -942,6 +998,26 @@ function SettingsTerminalTab(props: {
|
||||
onChange={(v) => updateTerminalSetting("verifyHostKeys", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-temporary-legacy-algorithms"
|
||||
label={t("settings.terminal.connection.temporaryLegacyAlgorithms")}
|
||||
description={t("settings.terminal.connection.temporaryLegacyAlgorithms.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.temporaryConnectionsAllowLegacyAlgorithms}
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsAllowLegacyAlgorithms", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-temporary-network-device-mode"
|
||||
label={t("settings.terminal.connection.temporaryNetworkDeviceMode")}
|
||||
description={t("settings.terminal.connection.temporaryNetworkDeviceMode.desc")}
|
||||
>
|
||||
<Toggle
|
||||
checked={terminalSettings.temporaryConnectionsNetworkDeviceMode}
|
||||
onChange={(v) => updateTerminalSetting("temporaryConnectionsNetworkDeviceMode", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-ssh-auto-reconnect"
|
||||
label={t("settings.terminal.connection.sshAutoReconnectEnabled")}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import React from 'react';
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
|
||||
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
|
||||
import { AIPrivacySection } from './AIPrivacySection';
|
||||
|
||||
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
|
||||
const values = new Map<string, string>();
|
||||
const events = new EventTarget();
|
||||
const globals = {
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
localStorage: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => values.set(key, value),
|
||||
},
|
||||
window: {
|
||||
netcatty: { externalMcpSetConfig: setConfig },
|
||||
addEventListener: events.addEventListener.bind(events),
|
||||
removeEventListener: events.removeEventListener.bind(events),
|
||||
dispatchEvent: events.dispatchEvent.bind(events),
|
||||
},
|
||||
};
|
||||
const previous = new Map<string, PropertyDescriptor | undefined>();
|
||||
for (const [name, value] of Object.entries(globals)) {
|
||||
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
|
||||
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
|
||||
}
|
||||
return {
|
||||
values,
|
||||
restore: () => {
|
||||
for (const [name, descriptor] of previous) {
|
||||
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, name);
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function view() {
|
||||
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
|
||||
}
|
||||
|
||||
test('privacy settings default off and explain their limits and connection scope', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const markup = JSON.stringify(renderer!.toJSON());
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
|
||||
assert.match(markup, /cannot detect every secret/);
|
||||
assert.match(markup, /AI privacy/);
|
||||
assert.match(markup, /Catty, integrated agents and external MCP clients/);
|
||||
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
|
||||
assert.match(markup, /native tools, direct file access and other MCP servers/);
|
||||
assert.match(markup, /Binary tool responses are blocked/);
|
||||
assert.match(markup, /one managed conversation or external MCP connection/);
|
||||
assert.match(markup, /reconnect external MCP clients/);
|
||||
assert.match(markup, /start a new managed conversation/);
|
||||
assert.match(markup, /Additional IP ranges/);
|
||||
assert.match(markup, /Custom regular expressions/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
|
||||
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
|
||||
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
|
||||
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
|
||||
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
|
||||
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
|
||||
});
|
||||
|
||||
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
|
||||
const calls: unknown[] = [];
|
||||
let accepted = false;
|
||||
const env = installEnvironment(async (config) => {
|
||||
calls.push(config);
|
||||
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
|
||||
});
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
const privacySwitch = () => root.findAllByType('button').find((node) => (
|
||||
node.props['aria-label'] === 'Enable text redaction'
|
||||
))!;
|
||||
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
|
||||
await act(async () => { privacySwitch().props.onClick(); });
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
|
||||
});
|
||||
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
|
||||
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
|
||||
accepted = true;
|
||||
await act(async () => {
|
||||
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
|
||||
});
|
||||
await act(async () => { apply().props.onClick(); });
|
||||
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
|
||||
assert.deepEqual(saved, {
|
||||
enabled: true,
|
||||
redactPrivateIps: false,
|
||||
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
|
||||
patterns: ['INTERNAL_[A-Z0-9]+'],
|
||||
});
|
||||
assert.deepEqual(calls[1], { redaction: saved });
|
||||
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
|
||||
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
|
||||
await act(async () => { renderer = create(view()); });
|
||||
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
|
||||
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('privacy apply disables draft controls until shared validation settles', async () => {
|
||||
let resolveConfig!: (result: unknown) => void;
|
||||
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const root = renderer!.root;
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
|
||||
});
|
||||
await act(async () => {
|
||||
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
|
||||
});
|
||||
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
|
||||
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
|
||||
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
|
||||
await act(async () => { resolveConfig({ ok: true }); });
|
||||
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
|
||||
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
|
||||
test('shared privacy controls refresh when another window changes the shared preference', async () => {
|
||||
const env = installEnvironment(async () => ({ ok: true }));
|
||||
let renderer: ReactTestRenderer | undefined;
|
||||
try {
|
||||
await act(async () => { renderer = create(view()); });
|
||||
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
|
||||
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
|
||||
const event = new Event('storage');
|
||||
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
|
||||
await act(async () => { window.dispatchEvent(event); });
|
||||
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
|
||||
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
|
||||
} finally {
|
||||
if (renderer) await act(async () => { renderer!.unmount(); });
|
||||
env.restore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { useI18n } from "../../../../application/i18n/I18nProvider";
|
||||
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
|
||||
import { Button } from "../../../ui/button";
|
||||
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
|
||||
|
||||
export const AIPrivacySection: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const { settings, error, applying, apply } = useAIPrivacyState();
|
||||
const [enabled, setEnabled] = useState(settings.enabled);
|
||||
const [privateIps, setPrivateIps] = useState(settings.redactPrivateIps);
|
||||
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
|
||||
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
|
||||
const [applied, setApplied] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(settings.enabled);
|
||||
setPrivateIps(settings.redactPrivateIps);
|
||||
setCidrs(settings.cidrs.join("\n"));
|
||||
setPatterns(settings.patterns.join("\n"));
|
||||
}, [settings]);
|
||||
|
||||
const onApply = async () => {
|
||||
setApplied(false);
|
||||
const saved = await apply({
|
||||
enabled,
|
||||
redactPrivateIps: privateIps,
|
||||
cidrs: cidrs.split(/\r?\n/).map((line) => line.trim()).filter(Boolean),
|
||||
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
|
||||
});
|
||||
setApplied(saved);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
|
||||
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
|
||||
<SettingRow label={t("ai.privacy.enable")}>
|
||||
<Toggle
|
||||
checked={enabled}
|
||||
onChange={(next) => { setEnabled(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.enable")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
label={t("ai.privacy.privateIps")}
|
||||
description={t("ai.privacy.privateIps.description")}
|
||||
>
|
||||
<Toggle
|
||||
checked={privateIps}
|
||||
onChange={(next) => { setPrivateIps(next); setApplied(false); }}
|
||||
ariaLabel={t("ai.privacy.privateIps")}
|
||||
disabled={applying}
|
||||
/>
|
||||
</SettingRow>
|
||||
</SettingCard>
|
||||
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
|
||||
<textarea
|
||||
value={cidrs}
|
||||
onChange={(event) => { setCidrs(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.cidrs")}
|
||||
placeholder={"192.0.2.0/24\n2001:db8::/32"}
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
|
||||
<textarea
|
||||
value={patterns}
|
||||
onChange={(event) => { setPatterns(event.currentTarget.value); setApplied(false); }}
|
||||
aria-label={t("ai.privacy.patterns")}
|
||||
placeholder="INTERNAL_[A-Z0-9]+"
|
||||
rows={3}
|
||||
spellCheck={false}
|
||||
disabled={applying}
|
||||
className="w-full resize-y rounded-md border border-border/60 bg-background px-2 py-1.5 font-mono text-xs focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
|
||||
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
|
||||
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
|
||||
</Button>
|
||||
</div>
|
||||
{error ? (
|
||||
<p role="alert" className="text-xs text-destructive">
|
||||
{t("ai.privacy.failed")} {error}
|
||||
</p>
|
||||
) : applied ? (
|
||||
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
Loaded 100 of 376 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user