Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 b4b64d034d fix(terminal): keep matching tabs ahead of quick connect
An address that matches an open session or workspace was still the
default Enter target when no saved host matched. Put that row after
those tabs so Enter switches to the existing one.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:58:04 +00:00
Cursor Agentand陈大猫 bac4dee640 fix(terminal): stop word queries from opening quick connect
Show the switcher quick-connect row only for address-like input, and
place it after matching hosts so Enter still connects to a saved host.
Translate the Hosts, Tabs, and New Workspace labels.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:53:02 +00:00
Cursor Agentand陈大猫 fc795e720b fix(ssh): read temporary connection settings at connect time
VaultView keeps a stale onConnect callback because its memo ignores that
prop. Read both temporary-connection toggles from a ref when connecting
so turning them on or off applies in the same session.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:53:02 +00:00
Cursor Agentand陈大猫 83fce7cf39 fix(ssh): keep temporary Mosh and ET off network device mode
Those transports are stored as SSH plus a flag. Stamping network device
mode onto them made raw-host checks skip the copy-session working
directory probe. Plain temporary SSH still opts in.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:13:18 +00:00
Cursor Agentand陈大猫 7612142914 feat(terminal): quick connect typed addresses from the tab switcher
The tab-bar quick switcher now offers the typed address when it already
parses as a connection target. Choosing that row opens the existing
quick connect wizard, including from a terminal tab where the vault
surface is hidden.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:06:10 +00:00
Cursor Agentand陈大猫 4a828bff71 feat(ssh): opt temporary connections into network device mode
Saved hosts and group-backed clones keep their own device type. SSH
connections with no saved host, including external launches and quick
connect, can start in network device mode when the new Terminal setting
is on. The setting stays off unless the user turns it on.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 09:06:07 +00:00
28 changed files with 739 additions and 21 deletions

No files matched your search

@@ -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\]/);
});
+11 -4
View File
@@ -47,7 +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 } from '../../domain/legacyAlgorithms';
import { prepareConnectionHost, temporaryConnectionPrepareOptions } from '../../domain/legacyAlgorithms';
import { resolveHostAuth } from '../../domain/sshAuth';
import { isEncryptedCredentialPlaceholder, stripSyncPayloadEncryptedCredentials } from '../../domain/credentials';
import {
@@ -1449,11 +1449,18 @@ 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) => {
const liveTerminalSettings = temporaryConnectionSettingsRef.current;
const connectionHost = prepareConnectionHost(
host,
terminalSettings.temporaryConnectionsAllowLegacyAlgorithms,
temporaryConnectionPrepareOptions(liveTerminalSettings),
);
if (connectionHost.ephemeral) {
setEphemeralHosts((previous) => {
@@ -1476,9 +1483,9 @@ export function AppSideEffects() {
resolveHostAuth,
resolveShellSetting,
systemInfoRef,
terminalSettings,
terminalSettings: liveTerminalSettings,
}), connectionHost, hidden);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells, terminalSettings]);
}, [addConnectionLog, connectToHost, resolveEffectiveHost, identities, keys, discoveredShells]);
const handleConnectToHostWithTabName = useCallback((host: Host, tabName?: string) => {
const sessionId = handleConnectToHost(host);
+2
View File
@@ -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';
@@ -930,6 +931,7 @@ function AppViewInner({ domains }: AppViewProps) {
showSftpTab={showSftpTab}
onQueryChange={setQuickSearch}
onSelect={handleHostConnectWithProtocolCheck}
onQuickConnect={openQuickConnectFromInput}
onEditHost={(host) => {
setIsQuickSwitcherOpen(false);
setQuickSearch('');
+2
View File
@@ -838,6 +838,8 @@ Highlight the focused split pane:
'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',
+4
View File
@@ -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',
+2
View File
@@ -753,6 +753,8 @@ Resalta el panel dividido enfocado:
'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',
+4
View File
@@ -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',
+2
View File
@@ -793,6 +793,8 @@ export const ruCoreMessages: Messages = {
'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',
+4
View File
@@ -710,6 +710,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': 'Локальные оболочки',
@@ -63,6 +63,24 @@ test("localized settings include temporary-connection legacy algorithm labels",
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",
+4
View File
@@ -1139,6 +1139,10 @@ Vaults 标签保持扁平,不使用这块背景。
// Quick Switcher
'qs.search.placeholder': '搜索主机或标签页',
'qs.quickConnectTo': '快速连接到 {target}',
'qs.hosts': '主机',
'qs.tabs': '标签页',
'qs.newWorkspace': '新建工作区',
'qs.jumpTo': '跳转到',
'qs.localTerminal': '本地终端',
'qs.localShells': '本地 Shell',
@@ -456,6 +456,8 @@ export const zhCNTerminalMessages: Messages = {
'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': '无',
+4
View File
@@ -1141,6 +1141,10 @@ Vaults 分頁維持扁平,不使用這塊背景。
// Quick Switcher
'qs.search.placeholder': '搜尋主機或分頁',
'qs.quickConnectTo': '快速連線到 {target}',
'qs.hosts': '主機',
'qs.tabs': '分頁',
'qs.newWorkspace': '新增工作區',
'qs.jumpTo': '跳轉到',
'qs.localTerminal': '本機終端',
'qs.localShells': '本機 Shell',
@@ -456,6 +456,8 @@ export const zhTWTerminalMessages: Messages = {
'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': '無',
+35
View File
@@ -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();
});
+28
View File
@@ -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;
}
+9 -1
View File
@@ -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, />新建工作区</);
});
+50 -9
View File
@@ -14,6 +14,7 @@ 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 +31,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 +167,7 @@ interface QuickSwitcherProps {
paneMagnificationState?: PaneMagnificationState;
onMagnifyCurrentPane?: () => void;
onRestoreMagnifiedPane?: () => void;
onQuickConnect?: (input: string) => void;
}
const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
@@ -186,6 +189,7 @@ const QuickSwitcherInner: React.FC<QuickSwitcherProps> = ({
paneMagnificationState = 'unavailable',
onMagnifyCurrentPane,
onRestoreMagnifiedPane,
onQuickConnect,
}) => {
const { t } = useI18n();
const discoveredShells = useDiscoveredShells();
@@ -335,19 +339,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 +379,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 +468,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 +569,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 +603,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="flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground">
<Terminal size={16} />
</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 = (
+9 -1
View File
@@ -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 ===
@@ -1008,6 +1008,16 @@ function SettingsTerminalTab(props: {
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")}
+60 -6
View File
@@ -12,6 +12,19 @@ export interface ConnectionLegacyAlgorithmInput {
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
}
export interface PrepareConnectionHostOptions {
/**
* Global opt-in for SSH connections with no saved host.
* Missing or any non-true value stays off.
*/
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
/**
* Global opt-in that starts SSH connections with no saved host in network
* device mode. Missing or any non-true value leaves deviceType alone.
*/
temporaryConnectionsNetworkDeviceMode?: boolean;
}
/**
* Saved hosts keep their own legacy-algorithm choice. Connections with no
* saved host use the global temporary-connection opt-in, which defaults to
@@ -39,20 +52,61 @@ function isTemporarySshHost(
return host.protocol === undefined || host.protocol === "ssh";
}
function readPrepareConnectionHostOptions(
options?: boolean | PrepareConnectionHostOptions,
): PrepareConnectionHostOptions {
if (typeof options === "boolean") {
return { temporaryConnectionsAllowLegacyAlgorithms: options };
}
return options ?? {};
}
/** Read the two temporary-connection toggles from the settings object given now. */
export function temporaryConnectionPrepareOptions(
settings: {
temporaryConnectionsAllowLegacyAlgorithms?: boolean;
temporaryConnectionsNetworkDeviceMode?: boolean;
} | null | undefined,
): PrepareConnectionHostOptions {
return {
temporaryConnectionsAllowLegacyAlgorithms:
settings?.temporaryConnectionsAllowLegacyAlgorithms,
temporaryConnectionsNetworkDeviceMode:
settings?.temporaryConnectionsNetworkDeviceMode,
};
}
/**
* Stamp the resolved legacy-algorithm flag onto unsaved temporary SSH hosts.
* Stamp temporary-connection defaults onto unsaved temporary SSH hosts.
* Saved hosts, and ephemeral clones of saved hosts, are returned unchanged so
* their own value (including a later group default) still applies.
* their own values (including a later group default) still apply.
*
* A boolean second argument is the legacy-algorithm opt-in, matching callers
* from before network device mode existed.
*/
export function prepareConnectionHost<T extends Host>(
host: T,
temporaryConnectionsAllowLegacyAlgorithms?: boolean,
options?: boolean | PrepareConnectionHostOptions,
): T {
if (!isTemporarySshHost(host)) return host;
const resolved = readPrepareConnectionHostOptions(options);
const legacyAlgorithms = resolveConnectionLegacyAlgorithms({
hasSavedHost: false,
temporaryConnectionsAllowLegacyAlgorithms,
temporaryConnectionsAllowLegacyAlgorithms: resolved.temporaryConnectionsAllowLegacyAlgorithms,
});
if ((host.legacyAlgorithms === true) === legacyAlgorithms) return host;
return { ...host, legacyAlgorithms };
const legacyUnchanged = (host.legacyAlgorithms === true) === legacyAlgorithms;
// Mosh and ET are stored as SSH plus a transport flag. Network device mode
// is a plain-SSH CLI behavior; stamping it onto those hosts makes later
// raw-host checks (such as the copy-session cwd probe) treat them as
// network gear.
const stampNetworkDevice = resolved.temporaryConnectionsNetworkDeviceMode === true
&& host.moshEnabled !== true
&& host.etEnabled !== true;
const deviceTypeUnchanged = !stampNetworkDevice || host.deviceType === "network";
if (legacyUnchanged && deviceTypeUnchanged) return host;
return {
...host,
...(legacyUnchanged ? {} : { legacyAlgorithms }),
...(stampNetworkDevice ? { deviceType: "network" as const } : {}),
};
}
+9
View File
@@ -149,6 +149,12 @@ export interface TerminalSettings {
* this and keep their own setting. Off by default.
*/
temporaryConnectionsAllowLegacyAlgorithms: boolean;
/**
* Start SSH connections that have no saved host (external launches, deep
* links, and quick connect) in network device mode. Saved hosts and groups
* keep their own device type. Off by default.
*/
temporaryConnectionsNetworkDeviceMode: boolean;
keepaliveInterval: number; // Seconds between SSH-level keepalive packets (0 = disabled)
keepaliveCountMax: number; // Unanswered keepalives before declaring the connection dead
sshAutoReconnectEnabled: boolean; // Automatically reconnect SSH sessions after unexpected disconnects
@@ -476,6 +482,8 @@ export const normalizeTerminalSettings = (
...mergedSettings,
temporaryConnectionsAllowLegacyAlgorithms:
mergedSettings.temporaryConnectionsAllowLegacyAlgorithms === true,
temporaryConnectionsNetworkDeviceMode:
mergedSettings.temporaryConnectionsNetworkDeviceMode === true,
rendererType,
autocompleteMaxSuggestions,
multilinePasteConfirmMinLines: normalizeMultilinePasteConfirmMinLines(
@@ -577,6 +585,7 @@ const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
// own per-host keepaliveOverride and dial these values down.
verifyHostKeys: true,
temporaryConnectionsAllowLegacyAlgorithms: false,
temporaryConnectionsNetworkDeviceMode: false,
keepaliveInterval: 30,
keepaliveCountMax: 10,
// On by default: unexpected transport drops (VPN / Wi-Fi blips) schedule an
+24
View File
@@ -2,6 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict";
import {
isQuickConnectInput,
isSwitcherQuickConnectInput,
parseQuickConnectInput,
} from "./quickConnect";
@@ -119,3 +120,26 @@ test("quick connect rejects malformed multi-@ inputs", () => {
assert.equal(parseQuickConnectInput("root@10.2.0.8@"), null);
assert.equal(parseQuickConnectInput("root@bad host@jump"), null);
});
test("switcher quick connect accepts only address-like input", () => {
for (const label of ["lab", "web01", "prod", "lab-switch", "localhost"]) {
assert.equal(isQuickConnectInput(label), true);
assert.equal(isSwitcherQuickConnectInput(label), false);
}
for (const address of [
"10.1.1.1",
"root@10.2.0.8:2222",
"example.com",
"host:2222",
"user@lab",
"[::1]",
"fe80::1",
"[2001:db8::1]:2222",
]) {
assert.equal(isSwitcherQuickConnectInput(address), true, address);
}
assert.equal(isSwitcherQuickConnectInput("core switch"), false);
assert.equal(isSwitcherQuickConnectInput("host:99999"), false);
});
+27
View File
@@ -360,3 +360,30 @@ export function parseQuickConnectInput(
export function isQuickConnectInput(input: string): boolean {
return parseQuickConnectInput(input) !== null;
}
const SWITCHER_PORT_SUFFIX = /:(\d{1,5})$/;
/**
* Tab-switcher quick connect is stricter than vault search. A single saved
* label such as "lab" or "lab-switch" must stay a host search, not an address.
* Address-like means `@`, a numeric :port, an IP, or a dot.
*/
export function isSwitcherQuickConnectInput(input: string): boolean {
if (!isQuickConnectInput(input)) return false;
const trimmed = input.trim();
if (trimmed.includes("@") || trimmed.includes(".")) return true;
const portMatch = trimmed.match(SWITCHER_PORT_SUFFIX);
if (portMatch) {
const port = Number(portMatch[1]);
if (port >= 1 && port <= 65535) return true;
}
const hostToken = trimmed.split(/\s+/).pop() ?? trimmed;
const withoutUser = hostToken.includes("@")
? hostToken.slice(hostToken.lastIndexOf("@") + 1)
: hostToken;
const withoutPort = withoutUser.replace(SWITCHER_PORT_SUFFIX, "");
const unbracketed = withoutPort.replace(/^\[|\]$/g, "");
return isBareIPv6(unbracketed);
}
+12
View File
@@ -1,7 +1,9 @@
import assert from "node:assert/strict";
import test from "node:test";
import en from "../application/i18n/locales/en.ts";
import zhCN from "../application/i18n/locales/zh-CN.ts";
import zhTW from "../application/i18n/locales/zh-TW.ts";
import { filterSettingsSearchCatalog } from "./settingsSearch.ts";
import { SETTINGS_SEARCH_CATALOG } from "./settingsSearchCatalog.ts";
@@ -122,6 +124,16 @@ test("settings search finds App Lock from the zh-CN locale", () => {
assert.ok(hits.some((hit) => hit.entry.id === "system-app-lock"));
});
test("settings search finds temporary network device mode", () => {
const tEnReal = (key: string) => en[key] ?? key;
const tZhTW = (key: string) => zhTW[key] ?? key;
const english = filterSettingsSearchCatalog("network device", tEnReal);
assert.ok(english.some((hit) => hit.entry.id === "terminal-temporary-network-device-mode"));
const traditional = filterSettingsSearchCatalog("網路裝置", tZhTW);
assert.ok(traditional.some((hit) => hit.entry.id === "terminal-temporary-network-device-mode"));
});
test("filterSettingsSearchCatalog can exclude plugins tab", () => {
const hits = filterSettingsSearchCatalog("plugin", tEn, { includePlugins: false });
assert.equal(hits.some((hit) => hit.entry.tab === "plugins"), false);
+8
View File
@@ -488,6 +488,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
sectionKey: "settings.terminal.section.connection",
keywords: ["legacy", "kex", "putty", "旧版算法", "演算法", "堡垒机"],
},
{
id: "terminal-temporary-network-device-mode",
tab: "terminal",
labelKey: "settings.terminal.connection.temporaryNetworkDeviceMode",
descriptionKey: "settings.terminal.connection.temporaryNetworkDeviceMode.desc",
sectionKey: "settings.terminal.section.connection",
keywords: ["network", "device", "switch", "router", "网络设备", "網路裝置"],
},
{
id: "terminal-ssh-auto-reconnect",
tab: "terminal",
+213
View File
@@ -0,0 +1,213 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
prepareConnectionHost,
temporaryConnectionPrepareOptions,
} from "./legacyAlgorithms.ts";
import { normalizeTerminalSettings } from "./models/terminal.ts";
import type { Host } from "./models.ts";
import { buildQuickConnectHost } from "./quickConnectHost.ts";
const host = (overrides: Partial<Host> = {}): Host => ({
id: "host-1",
label: "host",
hostname: "bastion.example",
username: "root",
tags: [],
os: "linux",
protocol: "ssh",
...overrides,
});
test("a saved host keeps its own device type when temporary network device mode is on", () => {
const saved = host({
hostname: "10.0.0.8",
deviceType: "general",
});
const unset = host({ hostname: "10.0.0.9" });
const network = host({ hostname: "10.0.0.10", deviceType: "network" });
assert.equal(prepareConnectionHost(saved, {
temporaryConnectionsNetworkDeviceMode: true,
}), saved);
assert.equal(prepareConnectionHost(unset, {
temporaryConnectionsNetworkDeviceMode: true,
}), unset);
assert.equal(prepareConnectionHost(network, {
temporaryConnectionsNetworkDeviceMode: true,
}), network);
});
test("an ephemeral clone of a saved host keeps that host's device type", () => {
const cloned = host({
id: "ephemeral-from-saved",
ephemeral: true,
backedBySavedHost: true,
hostname: "10.0.0.8",
deviceType: "general",
});
assert.equal(prepareConnectionHost(cloned, {
temporaryConnectionsNetworkDeviceMode: true,
}), cloned);
assert.equal(prepareConnectionHost(host({
ephemeral: true,
backedBySavedHost: true,
deviceType: "network",
}), {
temporaryConnectionsNetworkDeviceMode: false,
}).deviceType, "network");
});
test("a temporary SSH connection uses network device mode only when the setting is on", () => {
const temporary = host({
id: "ephemeral-1",
ephemeral: true,
hostname: "10.1.1.1",
port: 22,
});
assert.equal(prepareConnectionHost(temporary, {
temporaryConnectionsNetworkDeviceMode: true,
}).deviceType, "network");
assert.equal(prepareConnectionHost(temporary, {
temporaryConnectionsNetworkDeviceMode: false,
}), temporary);
assert.equal(prepareConnectionHost(temporary).deviceType, undefined);
});
test("temporary network device mode does not clear an explicit device type when the setting is off", () => {
const temporary = host({
ephemeral: true,
hostname: "10.1.1.1",
deviceType: "network",
});
assert.equal(prepareConnectionHost(temporary, {
temporaryConnectionsNetworkDeviceMode: false,
}), temporary);
});
test("temporary network device mode can be combined with the legacy algorithm opt-in", () => {
const temporary = host({
ephemeral: true,
hostname: "192.0.2.10",
});
const prepared = prepareConnectionHost(temporary, {
temporaryConnectionsAllowLegacyAlgorithms: true,
temporaryConnectionsNetworkDeviceMode: true,
});
assert.equal(prepared.legacyAlgorithms, true);
assert.equal(prepared.deviceType, "network");
assert.equal(prepared.hostname, "192.0.2.10");
});
test("temporary Mosh and ET connections do not receive network device mode", () => {
const mosh = host({
ephemeral: true,
protocol: "ssh",
moshEnabled: true,
hostname: "10.1.1.1",
});
const et = host({
ephemeral: true,
protocol: "ssh",
etEnabled: true,
hostname: "10.1.1.1",
});
const options = {
temporaryConnectionsAllowLegacyAlgorithms: true,
temporaryConnectionsNetworkDeviceMode: true,
};
assert.equal(prepareConnectionHost(mosh, options).deviceType, undefined);
assert.equal(prepareConnectionHost(mosh, options).legacyAlgorithms, true);
assert.equal(prepareConnectionHost(et, options).deviceType, undefined);
assert.equal(prepareConnectionHost(et, options).legacyAlgorithms, true);
});
test("temporary telnet connections do not receive network device mode", () => {
const telnet = host({
ephemeral: true,
protocol: "telnet",
hostname: "10.1.1.1",
});
assert.equal(prepareConnectionHost(telnet, {
temporaryConnectionsNetworkDeviceMode: true,
}), telnet);
});
test("an unsaved quick connect host receives temporary connection defaults", () => {
const quickHost = buildQuickConnectHost({
id: "quick-1",
createdAt: 1,
target: { hostname: "10.1.1.1" },
protocol: "ssh",
port: 22,
username: "admin",
authMethod: "password",
password: "secret",
});
const prepared = prepareConnectionHost(quickHost, {
temporaryConnectionsAllowLegacyAlgorithms: true,
temporaryConnectionsNetworkDeviceMode: true,
});
const saved = prepareConnectionHost({ ...quickHost, ephemeral: false }, {
temporaryConnectionsAllowLegacyAlgorithms: true,
temporaryConnectionsNetworkDeviceMode: true,
});
assert.equal(prepared.ephemeral, true);
assert.equal(prepared.legacyAlgorithms, true);
assert.equal(prepared.deviceType, "network");
assert.equal(saved.deviceType, undefined);
assert.equal(saved.legacyAlgorithms, undefined);
});
test("temporary connection toggles are read from the settings object at connect time", () => {
const settings = {
temporaryConnectionsAllowLegacyAlgorithms: false,
temporaryConnectionsNetworkDeviceMode: false,
};
const temporary = host({
id: "ephemeral-live",
ephemeral: true,
hostname: "10.9.9.9",
});
const connect = () => prepareConnectionHost(
temporary,
temporaryConnectionPrepareOptions(settings),
);
assert.equal(connect(), temporary);
settings.temporaryConnectionsNetworkDeviceMode = true;
settings.temporaryConnectionsAllowLegacyAlgorithms = true;
const enabled = connect();
assert.equal(enabled.deviceType, "network");
assert.equal(enabled.legacyAlgorithms, true);
settings.temporaryConnectionsNetworkDeviceMode = false;
settings.temporaryConnectionsAllowLegacyAlgorithms = false;
assert.equal(connect(), temporary);
});
test("temporary connection network device mode defaults to off", () => {
assert.equal(normalizeTerminalSettings().temporaryConnectionsNetworkDeviceMode, false);
assert.equal(
normalizeTerminalSettings({ temporaryConnectionsNetworkDeviceMode: true })
.temporaryConnectionsNetworkDeviceMode,
true,
);
assert.equal(
normalizeTerminalSettings({
temporaryConnectionsNetworkDeviceMode: "yes" as never,
}).temporaryConnectionsNetworkDeviceMode,
false,
);
});