Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43097c43b1 | ||
|
|
329e94752b | ||
|
|
b6a34131f6 | ||
|
|
3f16818d8d | ||
|
|
3efc9ada8e | ||
|
|
8efdd1c9cb | ||
|
|
585a654668 | ||
|
|
72e305fb7a | ||
|
|
012a6bf521 | ||
|
|
4c72d5e0af | ||
|
|
cedc7f6c5f | ||
|
|
155463f77c | ||
|
|
e5a74058ad | ||
|
|
4ced32257e | ||
|
|
64e7719715 | ||
|
|
04b5aba62d | ||
|
|
9f97f3870d | ||
|
|
6bfd0e17a2 | ||
|
|
1ac538eedc | ||
|
|
d34e23c7b3 | ||
|
|
31bf5396cb | ||
|
|
2feecaa9b6 |
61
.github/workflows/build-mosh-binaries.yml
vendored
61
.github/workflows/build-mosh-binaries.yml
vendored
@@ -9,7 +9,7 @@ name: build-mosh-binaries
|
||||
# (`binaricat/Netcatty-mosh-bin` by default).
|
||||
#
|
||||
# `paths` keeps unrelated commits (UI, bridges, etc) from rebuilding
|
||||
# mosh on every push — this workflow is expensive (~30min Cygwin leg).
|
||||
# or refreshing mosh binaries on every push.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
@@ -129,48 +129,22 @@ jobs:
|
||||
path: out/
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows x64 — in-CI Cygwin build from upstream mobile-shell/mosh
|
||||
# source. Cygwin's POSIX runtime can't be fully statically linked, so
|
||||
# we accept the dynamic Cygwin DLL deps and bundle them alongside the
|
||||
# exe (cygcheck-discovered, ~10 MB total). The pinned-FluentTerminal
|
||||
# path is preserved as `fetch-windows.sh` for emergency fallback.
|
||||
# Windows x64 pinned standalone client.
|
||||
# Do not compile this in CI: the upstream Cygwin build can clear the
|
||||
# terminal and never render output on Windows. Ship the SHA256-pinned
|
||||
# FluentTerminal standalone binary verified by fetch-windows.sh.
|
||||
# ------------------------------------------------------------------
|
||||
build-windows-x64:
|
||||
name: build-windows-x64
|
||||
runs-on: windows-latest
|
||||
fetch-windows-x64:
|
||||
name: fetch-windows-x64
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install Cygwin
|
||||
uses: cygwin/cygwin-install-action@v5
|
||||
with:
|
||||
add-to-path: false
|
||||
# Keep package signature checks, but avoid the setup.exe hash
|
||||
# fetch path that currently fails on windows-latest runners.
|
||||
check-hash: false
|
||||
packages: >
|
||||
gcc-g++ make autoconf automake libtool perl perl_pods pkg-config git
|
||||
openssl-devel libssl-devel libprotobuf-devel libncurses-devel
|
||||
libncursesw-devel zlib-devel protobuf-compiler
|
||||
- name: Build mosh-client.exe (win32-x64)
|
||||
shell: pwsh
|
||||
- name: Fetch pinned mosh-client.exe (win32-x64)
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
$cygwinBin = "C:\cygwin\bin"
|
||||
$workspace = (& "$cygwinBin\cygpath.exe" -u "$env:GITHUB_WORKSPACE").Trim()
|
||||
$scriptPath = Join-Path $env:RUNNER_TEMP "build-mosh-windows.sh"
|
||||
$script = @'
|
||||
set -euo pipefail
|
||||
cd "__WORKSPACE__"
|
||||
export MOSH_REF="${MOSH_REF:?missing MOSH_REF}"
|
||||
export ARCH=x64
|
||||
export OUT_DIR="__WORKSPACE__/out"
|
||||
export OUT_DIR="${GITHUB_WORKSPACE}/out"
|
||||
mkdir -p "$OUT_DIR"
|
||||
bash scripts/build-mosh/build-windows.sh
|
||||
'@
|
||||
$script = $script.Replace("__WORKSPACE__", $workspace).Replace("`r`n", "`n")
|
||||
Set-Content -Path $scriptPath -Value $script -NoNewline -Encoding utf8
|
||||
$scriptPathCygwin = (& "$cygwinBin\cygpath.exe" -u "$scriptPath").Trim()
|
||||
& "$cygwinBin\bash.exe" --login "$scriptPathCygwin"
|
||||
bash scripts/build-mosh/fetch-windows.sh
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
@@ -179,12 +153,8 @@ jobs:
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Windows arm64 — intentionally not built.
|
||||
# Cygwin's arm64 port is still experimental (no stable cygwin1.dll
|
||||
# release for aarch64 as of this commit), so we don't attempt an
|
||||
# arm64 mosh build. arm64 Windows installs fall through to the
|
||||
# legacy `mosh` wrapper path in terminalBridge.startMoshSession.
|
||||
# When upstream Cygwin ships a stable arm64 build, drop the same
|
||||
# cygwin-install-action job below with `platform: arm64`.
|
||||
# The pinned upstream source only provides x64. arm64 Windows builds
|
||||
# should be added only after we have a tested standalone arm64 client.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -196,7 +166,7 @@ jobs:
|
||||
- build-linux-x64
|
||||
- build-linux-arm64
|
||||
- build-macos-universal
|
||||
- build-windows-x64
|
||||
- fetch-windows-x64
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event_name == 'workflow_dispatch' && inputs.release_tag != ''
|
||||
permissions:
|
||||
@@ -241,7 +211,8 @@ jobs:
|
||||
fi
|
||||
{
|
||||
printf '%s\n' 'Pre-built `mosh-client` binaries consumed by `scripts/fetch-mosh-binaries.cjs` during `npm run pack`.'
|
||||
printf 'Built from `mobile-shell/mosh` upstream ref `%s`.\n\n' "${MOSH_REF}"
|
||||
printf 'Linux/macOS artifacts are built from `mobile-shell/mosh` upstream ref `%s`.\n' "${MOSH_REF}"
|
||||
printf '%s\n\n' 'Windows x64 is the SHA256-pinned FluentTerminal standalone `mosh-client.exe` fallback.'
|
||||
printf 'Source workflow: %s/%s/actions/runs/%s\n' "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_RUN_ID}"
|
||||
printf 'Source commit: `%s`\n\n' "${GITHUB_SHA}"
|
||||
printf '%s\n' 'All artifacts are GPL-3.0; see `resources/mosh/README.md` for source provenance.'
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -66,10 +66,11 @@ build_with_vs2022.bat
|
||||
|
||||
# Bundled mosh-client binaries fetched at pack time by
|
||||
# scripts/fetch-mosh-binaries.cjs. resources/mosh/README.md is
|
||||
# committed; the actual binaries (and on Windows the Cygwin DLL
|
||||
# bundle that ships alongside mosh-client.exe) are pulled from the
|
||||
# committed; the actual binaries, the Cygwin DLL bundle (Windows),
|
||||
# and the bundled ncurses terminfo database are all pulled from the
|
||||
# dedicated mosh binary repository, never committed.
|
||||
/resources/mosh/*/mosh-client
|
||||
/resources/mosh/*/mosh-client.exe
|
||||
/resources/mosh/*/mosh-client-*-dlls/
|
||||
/resources/mosh/*/*.dll
|
||||
/resources/mosh/*/terminfo/
|
||||
|
||||
35
App.tsx
35
App.tsx
@@ -16,6 +16,7 @@ import { initializeUIFonts } from './application/state/uiFontStore';
|
||||
import { I18nProvider, useI18n } from './application/i18n/I18nProvider';
|
||||
import { matchesKeyBinding } from './domain/models';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from './domain/groupConfig';
|
||||
import { materializeHostProxyProfile } from './domain/proxyProfiles';
|
||||
import { resolveHostAuth } from './domain/sshAuth';
|
||||
import { applyCustomAccentToTerminalTheme, resolveHostTerminalThemeId } from './domain/terminalAppearance';
|
||||
import { collectSessionIds } from './domain/workspace';
|
||||
@@ -253,6 +254,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -263,6 +265,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
@@ -453,6 +456,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -467,6 +471,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts,
|
||||
identities,
|
||||
keys,
|
||||
proxyProfiles,
|
||||
knownHosts,
|
||||
portForwardingRulesForSync,
|
||||
snippetPackages,
|
||||
@@ -527,7 +532,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [isVaultInitialized, hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts]);
|
||||
}, [isVaultInitialized, hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts]);
|
||||
|
||||
// Memoized "apply a remote payload safely" callback. Stable identity
|
||||
// across renders so useAutoSync's `syncNow` useCallback doesn't rebuild
|
||||
@@ -560,6 +565,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -605,7 +611,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
if (start) {
|
||||
const effectiveHost = resolveEffectiveHost(host);
|
||||
void startTunnel(rule, effectiveHost, hosts, keys, identities, (status, error) => {
|
||||
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
return;
|
||||
@@ -808,9 +814,11 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
|
||||
// Auto-start port forwarding rules on app launch
|
||||
usePortForwardingAutoStart({
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
});
|
||||
|
||||
@@ -1501,11 +1509,21 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
});
|
||||
}, [addConnectionLog, createLocalTerminal, terminalSettings.localShell, discoveredShells]);
|
||||
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
const groupDefaults = resolveGroupDefaults(host.group, groupConfigs);
|
||||
return applyGroupDefaults(host, groupDefaults);
|
||||
}, [groupConfigs]);
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(
|
||||
host,
|
||||
resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }),
|
||||
{ validProxyProfileIds: proxyProfileIdSet },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
}, [groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
|
||||
// Wrapper to connect to host with logging
|
||||
const handleConnectToHost = useCallback((host: Host) => {
|
||||
@@ -1847,6 +1865,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
snippets={snippets}
|
||||
snippetPackages={snippetPackages}
|
||||
customGroups={customGroups}
|
||||
@@ -1870,6 +1889,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
onUpdateHosts={updateHosts}
|
||||
onUpdateKeys={updateKeys}
|
||||
onUpdateIdentities={updateIdentities}
|
||||
onUpdateProxyProfiles={updateProxyProfiles}
|
||||
onUpdateSnippets={updateSnippets}
|
||||
onUpdateSnippetPackages={updateSnippetPackages}
|
||||
onUpdateCustomGroups={updateCustomGroups}
|
||||
@@ -1895,6 +1915,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groupConfigs={groupConfigs}
|
||||
updateHosts={updateHosts}
|
||||
sftpDefaultViewMode={sftpDefaultViewMode}
|
||||
@@ -1911,6 +1932,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
<TerminalLayerMount
|
||||
hosts={hosts}
|
||||
groupConfigs={groupConfigs}
|
||||
proxyProfiles={proxyProfiles}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
snippets={snippets}
|
||||
@@ -2216,6 +2238,7 @@ function App({ settings }: { settings: SettingsState }) {
|
||||
hosts: emptyVaultConflict.hostCount,
|
||||
keys: emptyVaultConflict.keyCount,
|
||||
snippets: emptyVaultConflict.snippetCount,
|
||||
proxyProfiles: emptyVaultConflict.proxyProfileCount,
|
||||
})}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -481,7 +481,7 @@ const en: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': 'Recommended — recover your hosts, keys, and snippets from the cloud backup',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': 'Keep Empty',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': 'Start fresh with an empty vault',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} hosts, {keys} keys, {snippets} snippets, {proxyProfiles} proxies',
|
||||
'sync.autoSync.emptyVaultManual': 'Cannot sync: the local vault is empty. Restore from a local backup or enable Force Push in the sync panel first.',
|
||||
|
||||
'sync.blocked.title': 'Sync paused',
|
||||
@@ -499,6 +499,7 @@ const en: Messages = {
|
||||
'sync.entityType.hosts': 'hosts',
|
||||
'sync.entityType.keys': 'keys',
|
||||
'sync.entityType.identities': 'identities',
|
||||
'sync.entityType.proxyProfiles': 'proxy profiles',
|
||||
'sync.entityType.snippets': 'snippets',
|
||||
'sync.entityType.customGroups': 'groups',
|
||||
'sync.entityType.snippetPackages': 'snippet packages',
|
||||
@@ -514,11 +515,28 @@ const en: Messages = {
|
||||
// Vault navigation
|
||||
'vault.nav.hosts': 'Hosts',
|
||||
'vault.nav.keychain': 'Keychain',
|
||||
'vault.nav.proxies': 'Proxies',
|
||||
'vault.nav.portForwarding': 'Port Forwarding',
|
||||
'vault.nav.snippets': 'Snippets',
|
||||
'vault.nav.knownHosts': 'Known Hosts',
|
||||
'vault.nav.logs': 'Logs',
|
||||
|
||||
'proxyProfiles.action.add': 'Add Proxy',
|
||||
'proxyProfiles.search.placeholder': 'Search proxies…',
|
||||
'proxyProfiles.section.proxies': 'Proxies',
|
||||
'proxyProfiles.count.items': '{count} items',
|
||||
'proxyProfiles.empty.title': 'No Proxies',
|
||||
'proxyProfiles.empty.desc': 'Create reusable HTTP or SOCKS5 proxies and select them from host details.',
|
||||
'proxyProfiles.usage': '{count} linked',
|
||||
'proxyProfiles.copyName': '{name} Copy',
|
||||
'proxyProfiles.panel.newTitle': 'New Proxy',
|
||||
'proxyProfiles.field.name': 'Proxy name',
|
||||
'proxyProfiles.error.required': 'Name, host, and port are required.',
|
||||
'proxyProfiles.error.port': 'Port must be between 1 and 65535.',
|
||||
'proxyProfiles.viewMode': 'Proxy view mode',
|
||||
'proxyProfiles.delete.title': 'Delete proxy?',
|
||||
'proxyProfiles.delete.desc': 'Deleting "{name}" will unlink it from {count} host or group settings.',
|
||||
|
||||
'vault.groups.title': 'Groups',
|
||||
'vault.groups.total': '{count} total',
|
||||
'vault.groups.hostsCount': '{count} Hosts',
|
||||
@@ -1114,6 +1132,12 @@ const en: Messages = {
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
'hostDetails.proxyPanel.identities': 'Identities',
|
||||
'hostDetails.proxyPanel.remove': 'Remove Proxy',
|
||||
'hostDetails.proxyPanel.savedProxy': 'Saved proxy',
|
||||
'hostDetails.proxyPanel.selectSaved': 'Select saved proxy',
|
||||
'hostDetails.proxyPanel.customProxy': 'Custom proxy',
|
||||
'hostDetails.proxyPanel.missing': 'Missing',
|
||||
'hostDetails.proxyPanel.missingSaved': 'Missing saved proxy',
|
||||
'hostDetails.proxyPanel.error.required': 'Proxy host and port are required.',
|
||||
'hostDetails.envVars': 'Environment Variables',
|
||||
'hostDetails.envVars.add': 'Add Environment Variable',
|
||||
'hostDetails.envVars.title': 'Environment Variables',
|
||||
|
||||
@@ -290,7 +290,7 @@ const zhCN: Messages = {
|
||||
'sync.autoSync.emptyVaultConflict.restoreDesc': '推荐 — 从云端备份恢复主机、密钥和代码片段',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmpty': '保持为空',
|
||||
'sync.autoSync.emptyVaultConflict.keepEmptyDesc': '从头开始,使用空的主机库',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段',
|
||||
'sync.autoSync.emptyVaultConflict.cloudSummary': '{hosts} 台主机,{keys} 个密钥,{snippets} 个代码片段,{proxyProfiles} 个代理',
|
||||
'sync.autoSync.emptyVaultManual': '无法同步:本地 vault 为空。请先从本地备份恢复,或在同步面板里使用"强制推送"。',
|
||||
|
||||
'sync.blocked.title': '同步已暂停',
|
||||
@@ -308,6 +308,7 @@ const zhCN: Messages = {
|
||||
'sync.entityType.hosts': '主机',
|
||||
'sync.entityType.keys': '密钥',
|
||||
'sync.entityType.identities': '身份',
|
||||
'sync.entityType.proxyProfiles': '代理配置',
|
||||
'sync.entityType.snippets': '代码片段',
|
||||
'sync.entityType.customGroups': '分组',
|
||||
'sync.entityType.snippetPackages': '片段包',
|
||||
@@ -323,11 +324,28 @@ const zhCN: Messages = {
|
||||
// Vault navigation
|
||||
'vault.nav.hosts': '主机',
|
||||
'vault.nav.keychain': '钥匙串',
|
||||
'vault.nav.proxies': '代理',
|
||||
'vault.nav.portForwarding': '端口转发',
|
||||
'vault.nav.snippets': '代码片段',
|
||||
'vault.nav.knownHosts': '已知主机',
|
||||
'vault.nav.logs': '日志',
|
||||
|
||||
'proxyProfiles.action.add': '添加代理',
|
||||
'proxyProfiles.search.placeholder': '搜索代理…',
|
||||
'proxyProfiles.section.proxies': '代理',
|
||||
'proxyProfiles.count.items': '{count} 项',
|
||||
'proxyProfiles.empty.title': '暂无代理',
|
||||
'proxyProfiles.empty.desc': '创建可复用的 HTTP 或 SOCKS5 代理,然后在主机详情里选择。',
|
||||
'proxyProfiles.usage': '已关联 {count} 处',
|
||||
'proxyProfiles.copyName': '{name} 副本',
|
||||
'proxyProfiles.panel.newTitle': '新建代理',
|
||||
'proxyProfiles.field.name': '代理名称',
|
||||
'proxyProfiles.error.required': '名称、主机和端口不能为空。',
|
||||
'proxyProfiles.error.port': '端口必须在 1 到 65535 之间。',
|
||||
'proxyProfiles.viewMode': '代理显示方式',
|
||||
'proxyProfiles.delete.title': '删除代理?',
|
||||
'proxyProfiles.delete.desc': '删除 "{name}" 会同时从 {count} 个主机或分组设置中解除关联。',
|
||||
|
||||
'vault.groups.title': '分组',
|
||||
'vault.groups.total': '共 {count} 个',
|
||||
'vault.groups.hostsCount': '{count} 台主机',
|
||||
@@ -1539,13 +1557,19 @@ const zhCN: Messages = {
|
||||
'settings.shortcuts.binding.sftp-new-folder': '新建文件夹',
|
||||
|
||||
// Host Details (sub-panels)
|
||||
'hostDetails.proxyPanel.title': 'Proxy',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': 'Proxy host',
|
||||
'hostDetails.proxyPanel.credentials': 'Credentials',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': 'Username',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': 'Password',
|
||||
'hostDetails.proxyPanel.identities': 'Identities',
|
||||
'hostDetails.proxyPanel.remove': '移除 Proxy',
|
||||
'hostDetails.proxyPanel.title': '通过 HTTP/SOCKS5 代理',
|
||||
'hostDetails.proxyPanel.hostPlaceholder': '代理主机',
|
||||
'hostDetails.proxyPanel.credentials': '凭据',
|
||||
'hostDetails.proxyPanel.usernamePlaceholder': '用户名',
|
||||
'hostDetails.proxyPanel.passwordPlaceholder': '密码',
|
||||
'hostDetails.proxyPanel.identities': '身份',
|
||||
'hostDetails.proxyPanel.remove': '移除代理',
|
||||
'hostDetails.proxyPanel.savedProxy': '已保存代理',
|
||||
'hostDetails.proxyPanel.selectSaved': '选择已保存代理',
|
||||
'hostDetails.proxyPanel.customProxy': '自定义代理',
|
||||
'hostDetails.proxyPanel.missing': '缺失',
|
||||
'hostDetails.proxyPanel.missingSaved': '保存的代理不存在',
|
||||
'hostDetails.proxyPanel.error.required': '代理主机和端口不能为空。',
|
||||
'hostDetails.envVars.title': '环境变量',
|
||||
'hostDetails.envVars.desc': '为 {host} 设置环境变量。',
|
||||
'hostDetails.envVars.note': '部分 SSH 服务器默认只允许以 LC_ 和 LANG_ 为前缀的变量。',
|
||||
|
||||
53
application/state/sftp/useSftpHostCredentials.test.ts
Normal file
53
application/state/sftp/useSftpHostCredentials.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildSftpHostCredentials } from "./useSftpHostCredentials.ts";
|
||||
import type { Host } from "../../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing jump hosts", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["missing-jump"] } }),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Jump host "missing-jump" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing saved proxy profiles", () => {
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ proxyProfileId: "missing-proxy" }),
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Saved proxy for host "Host" is missing/,
|
||||
);
|
||||
});
|
||||
|
||||
test("buildSftpHostCredentials rejects missing saved proxy profiles on jump hosts", () => {
|
||||
const jumpHost = host({ id: "jump-1", label: "Jump", proxyProfileId: "missing-proxy" });
|
||||
|
||||
assert.throws(
|
||||
() => buildSftpHostCredentials({
|
||||
host: host({ hostChain: { hostIds: ["jump-1"] } }),
|
||||
hosts: [jumpHost],
|
||||
keys: [],
|
||||
identities: [],
|
||||
}),
|
||||
/Saved proxy for jump host "Jump" is missing/,
|
||||
);
|
||||
});
|
||||
@@ -9,94 +9,111 @@ interface UseSftpHostCredentialsParams {
|
||||
identities: Identity[];
|
||||
}
|
||||
|
||||
export const buildSftpHostCredentials = ({
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UseSftpHostCredentialsParams & { host: Host }): NetcattySSHOptions => {
|
||||
if (host.proxyProfileId && !host.proxyConfig) {
|
||||
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds.map((hostId) => {
|
||||
const jumpHost = hosts.find((candidate) => candidate.id === hostId);
|
||||
if (!jumpHost) {
|
||||
throw new Error(`Jump host "${hostId}" is missing. Open host settings and repair the jump host chain.`);
|
||||
}
|
||||
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
|
||||
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
return jumpHost;
|
||||
}).map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
port: host.port || 22,
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: resolved.passphrase || key?.passphrase,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
};
|
||||
};
|
||||
|
||||
export const useSftpHostCredentials = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
}: UseSftpHostCredentialsParams) =>
|
||||
useCallback(
|
||||
(host: Host): NetcattySSHOptions => {
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key || null;
|
||||
|
||||
const proxyConfig = host.proxyConfig
|
||||
? {
|
||||
type: host.proxyConfig.type,
|
||||
host: host.proxyConfig.host,
|
||||
port: host.proxyConfig.port,
|
||||
username: host.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(host.proxyConfig.password),
|
||||
}
|
||||
: undefined;
|
||||
let jumpHosts: NetcattyJumpHost[] | undefined;
|
||||
if (host.hostChain?.hostIds && host.hostChain.hostIds.length > 0) {
|
||||
jumpHosts = host.hostChain.hostIds
|
||||
.map((hostId) => hosts.find((h) => h.id === hostId))
|
||||
.filter((h): h is Host => !!h)
|
||||
.map((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
keys,
|
||||
identities,
|
||||
});
|
||||
const jumpKey = jumpAuth.key;
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
if (
|
||||
hasConfiguredJumpProxyEndpoint &&
|
||||
jumpHost.proxyConfig?.username &&
|
||||
isEncryptedCredentialPlaceholder(jumpHost.proxyConfig.password) &&
|
||||
!sanitizeCredentialValue(jumpHost.proxyConfig.password)
|
||||
) {
|
||||
throw new Error(`Proxy credentials for jump host "${jumpHost.label || jumpHost.hostname}" cannot be decrypted on this device. Open host settings and re-enter the proxy password.`);
|
||||
}
|
||||
return {
|
||||
hostname: jumpHost.hostname,
|
||||
port: jumpHost.port || 22,
|
||||
username: jumpAuth.username || "root",
|
||||
password: jumpAuth.password,
|
||||
privateKey: jumpKey?.privateKey,
|
||||
certificate: jumpKey?.certificate,
|
||||
passphrase: jumpAuth.passphrase || jumpKey?.passphrase,
|
||||
publicKey: jumpKey?.publicKey,
|
||||
keyId: jumpAuth.keyId,
|
||||
keySource: jumpKey?.source,
|
||||
label: jumpHost.label,
|
||||
proxy: jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port
|
||||
? {
|
||||
type: jumpHost.proxyConfig.type,
|
||||
host: jumpHost.proxyConfig.host,
|
||||
port: jumpHost.proxyConfig.port,
|
||||
username: jumpHost.proxyConfig.username,
|
||||
password: sanitizeCredentialValue(jumpHost.proxyConfig.password),
|
||||
}
|
||||
: undefined,
|
||||
identityFilePaths: jumpHost.identityFilePaths,
|
||||
};
|
||||
});
|
||||
}
|
||||
const usesTargetProxyForFirstHop = !!proxyConfig && !jumpHosts?.[0]?.proxy;
|
||||
if (usesTargetProxyForFirstHop && host.proxyConfig?.username && isEncryptedCredentialPlaceholder(host.proxyConfig.password) && !proxyConfig?.password) {
|
||||
throw new Error("Proxy credentials cannot be decrypted on this device. Open host settings and re-enter the proxy password.");
|
||||
}
|
||||
|
||||
return {
|
||||
hostname: host.hostname,
|
||||
username: resolved.username,
|
||||
port: host.port || 22,
|
||||
password: resolved.password,
|
||||
privateKey: key?.privateKey,
|
||||
certificate: key?.certificate,
|
||||
passphrase: resolved.passphrase || key?.passphrase,
|
||||
publicKey: key?.publicKey,
|
||||
keyId: resolved.keyId,
|
||||
keySource: key?.source,
|
||||
proxy: proxyConfig,
|
||||
jumpHosts: jumpHosts && jumpHosts.length > 0 ? jumpHosts : undefined,
|
||||
sudo: host.sftpSudo,
|
||||
identityFilePaths: host.identityFilePaths,
|
||||
};
|
||||
},
|
||||
(host: Host): NetcattySSHOptions => buildSftpHostCredentials({ host, hosts, keys, identities }),
|
||||
[hosts, identities, keys],
|
||||
);
|
||||
|
||||
@@ -30,6 +30,7 @@ interface AutoSyncConfig {
|
||||
hosts: SyncPayload['hosts'];
|
||||
keys: SyncPayload['keys'];
|
||||
identities?: SyncPayload['identities'];
|
||||
proxyProfiles?: SyncPayload['proxyProfiles'];
|
||||
snippets: SyncPayload['snippets'];
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
@@ -110,6 +111,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
remotePayload: SyncPayload;
|
||||
hostCount: number;
|
||||
keyCount: number;
|
||||
proxyProfileCount: number;
|
||||
snippetCount: number;
|
||||
} | null>(null);
|
||||
const emptyVaultResolveRef = useRef<((action: 'restore' | 'keep-empty') => void) | null>(null);
|
||||
@@ -142,6 +144,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
hosts: config.hosts,
|
||||
keys: config.keys,
|
||||
identities: config.identities,
|
||||
proxyProfiles: config.proxyProfiles,
|
||||
snippets: config.snippets,
|
||||
customGroups: config.customGroups,
|
||||
snippetPackages: config.snippetPackages,
|
||||
@@ -152,6 +155,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
config.hosts,
|
||||
config.keys,
|
||||
config.identities,
|
||||
config.proxyProfiles,
|
||||
config.snippets,
|
||||
config.customGroups,
|
||||
config.snippetPackages,
|
||||
@@ -444,6 +448,7 @@ export const useAutoSync = (config: AutoSyncConfig) => {
|
||||
remotePayload,
|
||||
hostCount: remotePayload.hosts?.length ?? 0,
|
||||
keyCount: remotePayload.keys?.length ?? 0,
|
||||
proxyProfileCount: remotePayload.proxyProfiles?.length ?? 0,
|
||||
snippetCount: remotePayload.snippets?.length ?? 0,
|
||||
});
|
||||
});
|
||||
|
||||
117
application/state/usePortForwardingAutoStart.test.ts
Normal file
117
application/state/usePortForwardingAutoStart.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getAutoStartRuleBlockReason, isAutoStartProxyReady } from "./usePortForwardingAutoStart.ts";
|
||||
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const proxyProfile = (id: string): ProxyProfile => ({
|
||||
id,
|
||||
label: "Proxy",
|
||||
config: { type: "http", host: "proxy.example.com", port: 3128 },
|
||||
createdAt: 1,
|
||||
});
|
||||
|
||||
const rule = (overrides: Partial<PortForwardingRule> = {}): PortForwardingRule => ({
|
||||
id: "rule-1",
|
||||
label: "Rule",
|
||||
type: "local",
|
||||
localPort: 8080,
|
||||
bindAddress: "127.0.0.1",
|
||||
remoteHost: "127.0.0.1",
|
||||
remotePort: 80,
|
||||
hostId: "host-1",
|
||||
autoStart: true,
|
||||
status: "inactive",
|
||||
createdAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("isAutoStartProxyReady waits when a host saved proxy is unresolved", () => {
|
||||
assert.equal(
|
||||
isAutoStartProxyReady(
|
||||
host({ proxyProfileId: "missing-proxy" }),
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isAutoStartProxyReady waits when a missing host proxy has a group fallback", () => {
|
||||
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "group-proxy" }];
|
||||
const currentHost = host({ group: "prod", proxyProfileId: "missing-proxy" });
|
||||
|
||||
assert.equal(
|
||||
isAutoStartProxyReady(
|
||||
currentHost,
|
||||
[currentHost],
|
||||
[proxyProfile("group-proxy")],
|
||||
groupConfigs,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isAutoStartProxyReady waits when a group saved proxy is unresolved", () => {
|
||||
const groupConfigs: GroupConfig[] = [{ path: "prod", proxyProfileId: "missing-proxy" }];
|
||||
const currentHost = host({ group: "prod" });
|
||||
|
||||
assert.equal(
|
||||
isAutoStartProxyReady(
|
||||
currentHost,
|
||||
[currentHost],
|
||||
[],
|
||||
groupConfigs,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("isAutoStartProxyReady checks group-inherited jump hosts", () => {
|
||||
const currentHost = host({ group: "prod" });
|
||||
const jumpHost = host({ id: "jump-1", proxyProfileId: "missing-proxy" });
|
||||
|
||||
assert.equal(
|
||||
isAutoStartProxyReady(
|
||||
currentHost,
|
||||
[currentHost, jumpHost],
|
||||
[],
|
||||
[{ path: "prod", hostChain: { hostIds: ["jump-1"] } }],
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoStartRuleBlockReason only blocks the affected rule", () => {
|
||||
const goodHost = host();
|
||||
const badHost = host({ id: "host-2", proxyProfileId: "missing-proxy" });
|
||||
const hosts = [goodHost, badHost];
|
||||
const isHostAuthReady = () => true;
|
||||
|
||||
assert.equal(
|
||||
getAutoStartRuleBlockReason(rule({ id: "good", hostId: "host-1" }), hosts, [], [], isHostAuthReady),
|
||||
undefined,
|
||||
);
|
||||
assert.equal(
|
||||
getAutoStartRuleBlockReason(rule({ id: "bad", hostId: "host-2" }), hosts, [], [], isHostAuthReady),
|
||||
"Proxy or jump host configuration is not ready",
|
||||
);
|
||||
});
|
||||
|
||||
test("getAutoStartRuleBlockReason marks rules without a host", () => {
|
||||
assert.equal(
|
||||
getAutoStartRuleBlockReason(rule({ hostId: undefined }), [], [], [], () => true),
|
||||
"Rule host is not configured",
|
||||
);
|
||||
});
|
||||
@@ -4,8 +4,9 @@
|
||||
* when the application starts, not when the user navigates to the port forwarding page.
|
||||
*/
|
||||
import { useCallback, useEffect, useRef } from "react";
|
||||
import { GroupConfig, Host, Identity, PortForwardingRule, SSHKey } from "../../domain/models";
|
||||
import { GroupConfig, Host, Identity, PortForwardingRule, ProxyProfile, SSHKey } from "../../domain/models";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
|
||||
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
|
||||
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
|
||||
import {
|
||||
@@ -17,26 +18,97 @@ import {
|
||||
import { logger } from "../../lib/logger";
|
||||
|
||||
export interface UsePortForwardingAutoStartOptions {
|
||||
isVaultInitialized: boolean;
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
groupConfigs: GroupConfig[];
|
||||
}
|
||||
|
||||
const AUTO_START_PROXY_NOT_READY_ERROR = "Proxy or jump host configuration is not ready";
|
||||
const AUTO_START_AUTH_NOT_READY_ERROR = "Host authentication configuration is not ready";
|
||||
|
||||
export const isAutoStartProxyReady = (
|
||||
host: Host,
|
||||
allHosts: Host[],
|
||||
proxyProfiles: ProxyProfile[],
|
||||
groupConfigs: GroupConfig[],
|
||||
seen = new Set<string>(),
|
||||
): boolean => {
|
||||
if (!host || seen.has(host.id)) return true;
|
||||
seen.add(host.id);
|
||||
|
||||
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
const rawGroupDefaults = host.group
|
||||
? resolveGroupDefaults(host.group, groupConfigs)
|
||||
: {};
|
||||
const groupDefaults = host.group
|
||||
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds })
|
||||
: {};
|
||||
const missingHostProxyProfile = Boolean(
|
||||
host.proxyProfileId && !validProxyProfileIds.has(host.proxyProfileId),
|
||||
);
|
||||
const missingGroupProxyProfile = Boolean(
|
||||
!host.proxyConfig &&
|
||||
!host.proxyProfileId &&
|
||||
rawGroupDefaults.proxyProfileId &&
|
||||
!validProxyProfileIds.has(rawGroupDefaults.proxyProfileId),
|
||||
);
|
||||
const effectiveHost = applyGroupDefaults(host, groupDefaults, { validProxyProfileIds });
|
||||
const hasProxyReplacement = Boolean(
|
||||
effectiveHost.proxyConfig ||
|
||||
(effectiveHost.proxyProfileId && validProxyProfileIds.has(effectiveHost.proxyProfileId)),
|
||||
);
|
||||
|
||||
if ((missingHostProxyProfile || missingGroupProxyProfile) && !hasProxyReplacement) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const chainIds = effectiveHost.hostChain?.hostIds || [];
|
||||
for (const chainId of chainIds) {
|
||||
const chainHost = allHosts.find((candidate) => candidate.id === chainId);
|
||||
if (!chainHost) return false;
|
||||
if (!isAutoStartProxyReady(chainHost, allHosts, proxyProfiles, groupConfigs, seen)) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export const getAutoStartRuleBlockReason = (
|
||||
rule: PortForwardingRule,
|
||||
hosts: Host[],
|
||||
proxyProfiles: ProxyProfile[],
|
||||
groupConfigs: GroupConfig[],
|
||||
isHostAuthReady: (host: Host) => boolean,
|
||||
): string | undefined => {
|
||||
if (!rule.hostId) return "Rule host is not configured";
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
if (!host) return "Host not found";
|
||||
if (!isHostAuthReady(host)) return AUTO_START_AUTH_NOT_READY_ERROR;
|
||||
if (!isAutoStartProxyReady(host, hosts, proxyProfiles, groupConfigs)) {
|
||||
return AUTO_START_PROXY_NOT_READY_ERROR;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Auto-starts port forwarding rules that have autoStart enabled.
|
||||
* This hook should be called at the App level to run on app launch.
|
||||
*/
|
||||
export const usePortForwardingAutoStart = ({
|
||||
isVaultInitialized,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
}: UsePortForwardingAutoStartOptions): void => {
|
||||
const autoStartExecutedRef = useRef(false);
|
||||
const hostsRef = useRef<Host[]>(hosts);
|
||||
const keysRef = useRef<SSHKey[]>(keys);
|
||||
const identitiesRef = useRef<Identity[]>(identities);
|
||||
const proxyProfilesRef = useRef<ProxyProfile[]>(proxyProfiles);
|
||||
const groupConfigsRef = useRef<GroupConfig[]>(groupConfigs);
|
||||
|
||||
const isHostAuthReady = useCallback((host: Host, seen = new Set<string>()): boolean => {
|
||||
@@ -77,16 +149,53 @@ export const usePortForwardingAutoStart = ({
|
||||
identitiesRef.current = identities;
|
||||
}, [identities]);
|
||||
|
||||
useEffect(() => {
|
||||
proxyProfilesRef.current = proxyProfiles;
|
||||
}, [proxyProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
groupConfigsRef.current = groupConfigs;
|
||||
}, [groupConfigs]);
|
||||
|
||||
const resolveEffectiveHost = useCallback((host: Host): Host => {
|
||||
if (!host.group) return host;
|
||||
const defaults = resolveGroupDefaults(host.group, groupConfigsRef.current);
|
||||
return applyGroupDefaults(host, defaults);
|
||||
const validProxyProfileIds: ReadonlySet<string> = new Set(proxyProfilesRef.current.map((profile) => profile.id));
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(
|
||||
host,
|
||||
resolveGroupDefaults(host.group, groupConfigsRef.current, { validProxyProfileIds }),
|
||||
{ validProxyProfileIds },
|
||||
)
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfilesRef.current);
|
||||
}, []);
|
||||
|
||||
const resolveEffectiveHosts = useCallback(
|
||||
(items: Host[]): Host[] => items.map((host) => resolveEffectiveHost(host)),
|
||||
[resolveEffectiveHost],
|
||||
);
|
||||
|
||||
const updateStoredRuleStatus = useCallback(
|
||||
(ruleId: string, status: PortForwardingRule["status"], error?: string) => {
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((rule) =>
|
||||
rule.id === ruleId
|
||||
? {
|
||||
...rule,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : rule.lastUsedAt,
|
||||
}
|
||||
: rule,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Set up the reconnect callback
|
||||
useEffect(() => {
|
||||
const handleReconnect = async (
|
||||
@@ -99,40 +208,49 @@ export const usePortForwardingAutoStart = ({
|
||||
) ?? [];
|
||||
|
||||
const rule = rules.find((r) => r.id === ruleId);
|
||||
if (!rule || !rule.hostId) {
|
||||
return { success: false, error: "Rule or host not found" };
|
||||
if (!rule) {
|
||||
const error = "Rule not found";
|
||||
onStatusChange("error", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
if (!rule.hostId) {
|
||||
const error = "Rule host is not configured";
|
||||
onStatusChange("error", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
|
||||
const rawHost = hostsRef.current.find((h) => h.id === rule.hostId);
|
||||
if (!rawHost) {
|
||||
return { success: false, error: "Host not found" };
|
||||
const error = "Host not found";
|
||||
onStatusChange("error", error);
|
||||
return { success: false, error };
|
||||
}
|
||||
const blockReason = getAutoStartRuleBlockReason(
|
||||
rule,
|
||||
hostsRef.current,
|
||||
proxyProfilesRef.current,
|
||||
groupConfigsRef.current,
|
||||
(host) => isHostAuthReady(host),
|
||||
);
|
||||
if (blockReason) {
|
||||
onStatusChange("error", blockReason);
|
||||
return { success: false, error: blockReason };
|
||||
}
|
||||
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
return startPortForward(rule, host, hostsRef.current, keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true);
|
||||
};
|
||||
|
||||
setReconnectCallback(handleReconnect);
|
||||
return () => {
|
||||
setReconnectCallback(null);
|
||||
};
|
||||
}, [resolveEffectiveHost]);
|
||||
}, [isHostAuthReady, resolveEffectiveHost, resolveEffectiveHosts]);
|
||||
|
||||
// Auto-start rules on app launch
|
||||
useEffect(() => {
|
||||
if (autoStartExecutedRef.current) return;
|
||||
if (hosts.length === 0) return;
|
||||
|
||||
const storedRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
const pendingAutoStartRules = storedRules.filter((rule) => rule.autoStart && rule.hostId);
|
||||
if (pendingAutoStartRules.some((rule) => {
|
||||
const host = hosts.find((candidate) => candidate.id === rule.hostId);
|
||||
return !host || !isHostAuthReady(host);
|
||||
})) {
|
||||
return;
|
||||
}
|
||||
if (!isVaultInitialized) return;
|
||||
|
||||
// Mark as executed immediately to prevent duplicate runs
|
||||
// (React StrictMode or dependency changes could cause re-runs)
|
||||
@@ -149,7 +267,7 @@ export const usePortForwardingAutoStart = ({
|
||||
|
||||
// Only start rules that are not already active
|
||||
const autoStartRules = rules.filter((r) => {
|
||||
if (!r.autoStart || !r.hostId) return false;
|
||||
if (!r.autoStart) return false;
|
||||
// Check if there's an active connection for this rule
|
||||
const conn = getActiveConnection(r.id);
|
||||
// Only start if not already connecting or active
|
||||
@@ -162,39 +280,45 @@ export const usePortForwardingAutoStart = ({
|
||||
// Start each auto-start rule
|
||||
for (const rule of autoStartRules) {
|
||||
const rawHost = hosts.find((h) => h.id === rule.hostId);
|
||||
if (rawHost) {
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
// Update the rule status in storage
|
||||
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
|
||||
STORAGE_KEY_PORT_FORWARDING,
|
||||
) ?? [];
|
||||
|
||||
const updatedRules = currentRules.map((r) =>
|
||||
r.id === rule.id
|
||||
? {
|
||||
...r,
|
||||
status,
|
||||
error,
|
||||
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
|
||||
}
|
||||
: r,
|
||||
);
|
||||
|
||||
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
|
||||
},
|
||||
true, // Enable reconnect for auto-start rules
|
||||
);
|
||||
const blockReason = getAutoStartRuleBlockReason(
|
||||
rule,
|
||||
hosts,
|
||||
proxyProfiles,
|
||||
groupConfigs,
|
||||
(host) => isHostAuthReady(host),
|
||||
);
|
||||
if (blockReason) {
|
||||
updateStoredRuleStatus(rule.id, "error", blockReason);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!rawHost) continue;
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startPortForward(
|
||||
rule,
|
||||
host,
|
||||
resolveEffectiveHosts(hosts),
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
updateStoredRuleStatus(rule.id, status, error);
|
||||
},
|
||||
true, // Enable reconnect for auto-start rules
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
void runAutoStart();
|
||||
}, [hosts, identities, isHostAuthReady, keys, resolveEffectiveHost]);
|
||||
}, [
|
||||
groupConfigs,
|
||||
hosts,
|
||||
identities,
|
||||
isHostAuthReady,
|
||||
isVaultInitialized,
|
||||
keys,
|
||||
proxyProfiles,
|
||||
resolveEffectiveHost,
|
||||
resolveEffectiveHosts,
|
||||
updateStoredRuleStatus,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
KeyCategory,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ProxyProfile,
|
||||
ShellHistoryEntry,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
@@ -26,6 +27,7 @@ import {
|
||||
STORAGE_KEY_KNOWN_HOSTS,
|
||||
STORAGE_KEY_LEGACY_KEYS,
|
||||
STORAGE_KEY_MANAGED_SOURCES,
|
||||
STORAGE_KEY_PROXY_PROFILES,
|
||||
STORAGE_KEY_SHELL_HISTORY,
|
||||
STORAGE_KEY_SNIPPET_PACKAGES,
|
||||
STORAGE_KEY_SNIPPETS,
|
||||
@@ -36,16 +38,19 @@ import {
|
||||
decryptHosts,
|
||||
decryptIdentities,
|
||||
decryptKeys,
|
||||
decryptProxyProfiles,
|
||||
encryptGroupConfigs,
|
||||
encryptHosts,
|
||||
encryptIdentities,
|
||||
encryptKeys,
|
||||
encryptProxyProfiles,
|
||||
} from "../../infrastructure/persistence/secureFieldAdapter";
|
||||
|
||||
type ExportableVaultData = {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
@@ -106,6 +111,7 @@ export const useVaultState = () => {
|
||||
const [hosts, setHosts] = useState<Host[]>([]);
|
||||
const [keys, setKeys] = useState<SSHKey[]>([]);
|
||||
const [identities, setIdentities] = useState<Identity[]>([]);
|
||||
const [proxyProfiles, setProxyProfiles] = useState<ProxyProfile[]>([]);
|
||||
const [snippets, setSnippets] = useState<Snippet[]>([]);
|
||||
const [customGroups, setCustomGroups] = useState<string[]>([]);
|
||||
const [snippetPackages, setSnippetPackages] = useState<string[]>([]);
|
||||
@@ -121,6 +127,7 @@ export const useVaultState = () => {
|
||||
const hostsWriteVersion = useRef(0);
|
||||
const keysWriteVersion = useRef(0);
|
||||
const identitiesWriteVersion = useRef(0);
|
||||
const proxyProfilesWriteVersion = useRef(0);
|
||||
const groupConfigsWriteVersion = useRef(0);
|
||||
|
||||
// Read-sequence counters for cross-window storage events. Each incoming
|
||||
@@ -130,13 +137,14 @@ export const useVaultState = () => {
|
||||
const hostsReadSeq = useRef(0);
|
||||
const keysReadSeq = useRef(0);
|
||||
const identitiesReadSeq = useRef(0);
|
||||
const proxyProfilesReadSeq = useRef(0);
|
||||
const groupConfigsReadSeq = useRef(0);
|
||||
|
||||
const updateHosts = useCallback((data: Host[]) => {
|
||||
const cleaned = data.map(sanitizeHost);
|
||||
setHosts(cleaned);
|
||||
const ver = ++hostsWriteVersion.current;
|
||||
encryptHosts(cleaned).then((enc) => {
|
||||
return encryptHosts(cleaned).then((enc) => {
|
||||
if (ver === hostsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_HOSTS, enc);
|
||||
});
|
||||
@@ -145,7 +153,7 @@ export const useVaultState = () => {
|
||||
const updateKeys = useCallback((data: SSHKey[]) => {
|
||||
setKeys(data);
|
||||
const ver = ++keysWriteVersion.current;
|
||||
encryptKeys(data).then((enc) => {
|
||||
return encryptKeys(data).then((enc) => {
|
||||
if (ver === keysWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_KEYS, enc);
|
||||
});
|
||||
@@ -154,12 +162,21 @@ export const useVaultState = () => {
|
||||
const updateIdentities = useCallback((data: Identity[]) => {
|
||||
setIdentities(data);
|
||||
const ver = ++identitiesWriteVersion.current;
|
||||
encryptIdentities(data).then((enc) => {
|
||||
return encryptIdentities(data).then((enc) => {
|
||||
if (ver === identitiesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_IDENTITIES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateProxyProfiles = useCallback((data: ProxyProfile[]) => {
|
||||
setProxyProfiles(data);
|
||||
const ver = ++proxyProfilesWriteVersion.current;
|
||||
return encryptProxyProfiles(data).then((enc) => {
|
||||
if (ver === proxyProfilesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||||
});
|
||||
}, []);
|
||||
|
||||
const updateSnippets = useCallback((data: Snippet[]) => {
|
||||
setSnippets(data);
|
||||
localStorageAdapter.write(STORAGE_KEY_SNIPPETS, data);
|
||||
@@ -188,7 +205,7 @@ export const useVaultState = () => {
|
||||
const updateGroupConfigs = useCallback((data: GroupConfig[]) => {
|
||||
setGroupConfigs(data);
|
||||
const ver = ++groupConfigsWriteVersion.current;
|
||||
encryptGroupConfigs(data).then((enc) => {
|
||||
return encryptGroupConfigs(data).then((enc) => {
|
||||
if (ver === groupConfigsWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_GROUP_CONFIGS, enc);
|
||||
});
|
||||
@@ -198,6 +215,7 @@ export const useVaultState = () => {
|
||||
updateHosts([]);
|
||||
updateKeys([]);
|
||||
updateIdentities([]);
|
||||
updateProxyProfiles([]);
|
||||
updateSnippets([]);
|
||||
updateSnippetPackages([]);
|
||||
updateCustomGroups([]);
|
||||
@@ -209,6 +227,7 @@ export const useVaultState = () => {
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
@@ -414,6 +433,20 @@ export const useVaultState = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const savedProxyProfiles =
|
||||
localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
|
||||
if (savedProxyProfiles) {
|
||||
const proxyVer = ++proxyProfilesWriteVersion.current;
|
||||
const decryptedProfiles = await decryptProxyProfiles(savedProxyProfiles);
|
||||
if (proxyVer === proxyProfilesWriteVersion.current) {
|
||||
setProxyProfiles(decryptedProfiles);
|
||||
encryptProxyProfiles(decryptedProfiles).then((enc) => {
|
||||
if (proxyVer === proxyProfilesWriteVersion.current)
|
||||
localStorageAdapter.write(STORAGE_KEY_PROXY_PROFILES, enc);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Read remaining non-encrypted data fresh after all async gaps above
|
||||
const savedGroups = localStorageAdapter.read<string[]>(STORAGE_KEY_GROUPS);
|
||||
const savedSnippets =
|
||||
@@ -528,6 +561,18 @@ export const useVaultState = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_PROXY_PROFILES) {
|
||||
const next = safeParse<ProxyProfile[]>(event.newValue) ?? [];
|
||||
++proxyProfilesWriteVersion.current;
|
||||
const seq = ++proxyProfilesReadSeq.current;
|
||||
const writeAtStart = proxyProfilesWriteVersion.current;
|
||||
decryptProxyProfiles(next).then((dec) => {
|
||||
if (seq === proxyProfilesReadSeq.current && writeAtStart === proxyProfilesWriteVersion.current)
|
||||
setProxyProfiles(dec);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === STORAGE_KEY_SNIPPETS) {
|
||||
const next = safeParse<Snippet[]>(event.newValue) ?? [];
|
||||
setSnippets(next);
|
||||
@@ -621,30 +666,35 @@ export const useVaultState = () => {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
knownHosts,
|
||||
groupConfigs,
|
||||
}),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
);
|
||||
|
||||
const importData = useCallback(
|
||||
(payload: Partial<ExportableVaultData>) => {
|
||||
if (payload.hosts) updateHosts(payload.hosts);
|
||||
if (payload.keys) updateKeys(payload.keys);
|
||||
if (payload.identities) updateIdentities(payload.identities);
|
||||
(payload: Partial<ExportableVaultData>): Promise<void> => {
|
||||
const encryptedWrites: Promise<void>[] = [];
|
||||
if (payload.hosts) encryptedWrites.push(updateHosts(payload.hosts));
|
||||
if (payload.keys) encryptedWrites.push(updateKeys(payload.keys));
|
||||
if (payload.identities) encryptedWrites.push(updateIdentities(payload.identities));
|
||||
if (Array.isArray(payload.proxyProfiles)) encryptedWrites.push(updateProxyProfiles(payload.proxyProfiles));
|
||||
if (payload.snippets) updateSnippets(payload.snippets);
|
||||
if (payload.customGroups) updateCustomGroups(payload.customGroups);
|
||||
if (payload.snippetPackages) updateSnippetPackages(payload.snippetPackages);
|
||||
if (payload.knownHosts) updateKnownHosts(payload.knownHosts);
|
||||
if (Array.isArray(payload.groupConfigs)) updateGroupConfigs(payload.groupConfigs);
|
||||
if (Array.isArray(payload.groupConfigs)) encryptedWrites.push(updateGroupConfigs(payload.groupConfigs));
|
||||
return Promise.all(encryptedWrites).then(() => undefined);
|
||||
},
|
||||
[
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateCustomGroups,
|
||||
updateSnippetPackages,
|
||||
@@ -654,9 +704,9 @@ export const useVaultState = () => {
|
||||
);
|
||||
|
||||
const importDataFromString = useCallback(
|
||||
(jsonString: string) => {
|
||||
(jsonString: string): Promise<void> => {
|
||||
const data = JSON.parse(jsonString);
|
||||
importData(data);
|
||||
return importData(data);
|
||||
},
|
||||
[importData],
|
||||
);
|
||||
@@ -666,6 +716,7 @@ export const useVaultState = () => {
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -677,6 +728,7 @@ export const useVaultState = () => {
|
||||
updateHosts,
|
||||
updateKeys,
|
||||
updateIdentities,
|
||||
updateProxyProfiles,
|
||||
updateSnippets,
|
||||
updateSnippetPackages,
|
||||
updateCustomGroups,
|
||||
|
||||
@@ -49,7 +49,8 @@ const knownHost = (id = "kh-1"): KnownHost => ({
|
||||
hostname: `${id}.example.com`,
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: `SHA256:${id}`,
|
||||
publicKey: `SHA256:${id}`,
|
||||
discoveredAt: 1,
|
||||
});
|
||||
|
||||
const vault = (knownHosts: KnownHost[] = [knownHost()]): SyncableVaultData => ({
|
||||
@@ -73,6 +74,25 @@ test("buildSyncPayload treats known hosts as local-only data", () => {
|
||||
assert.equal("knownHosts" in payload, false);
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes reusable proxy profiles", () => {
|
||||
const proxyProfiles = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const payload = buildSyncPayload({
|
||||
...vault(),
|
||||
proxyProfiles,
|
||||
} as SyncableVaultData & { proxyProfiles: typeof proxyProfiles });
|
||||
|
||||
assert.deepEqual(payload.proxyProfiles, proxyProfiles);
|
||||
});
|
||||
|
||||
test("hasMeaningfulCloudSyncData ignores legacy cloud known hosts", () => {
|
||||
assert.equal(
|
||||
hasMeaningfulCloudSyncData({
|
||||
@@ -94,8 +114,17 @@ test("buildLocalVaultPayload preserves known hosts for local backups", () => {
|
||||
assert.deepEqual(payload.knownHosts, [knownHost("kh-local")]);
|
||||
});
|
||||
|
||||
test("applySyncPayload ignores legacy cloud known hosts", () => {
|
||||
test("applySyncPayload ignores legacy cloud known hosts", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const proxyProfiles = [
|
||||
{
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "socks5", host: "proxy.example.com", port: 1080 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
},
|
||||
];
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
@@ -103,10 +132,11 @@ test("applySyncPayload ignores legacy cloud known hosts", () => {
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
knownHosts: [knownHost("kh-legacy")],
|
||||
proxyProfiles,
|
||||
syncedAt: 1,
|
||||
};
|
||||
} as SyncPayload & { proxyProfiles: typeof proxyProfiles };
|
||||
|
||||
applySyncPayload(payload, {
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
@@ -114,9 +144,96 @@ test("applySyncPayload ignores legacy cloud known hosts", () => {
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal("knownHosts" in imported, false);
|
||||
assert.deepEqual(imported.proxyProfiles, proxyProfiles);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload restores known hosts from local backups", () => {
|
||||
test("applySyncPayload keeps missing proxy references visible to connection guards", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
proxyProfileId: "missing-proxy",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
groupConfigs: [{ path: "prod", proxyProfileId: "missing-proxy" }],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
||||
assert.equal((imported.groupConfigs as SyncPayload["groupConfigs"])?.[0]?.proxyProfileId, "missing-proxy");
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves host proxy references when group configs are absent", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
proxyProfileId: "missing-proxy",
|
||||
}],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
});
|
||||
|
||||
assert.ok(imported);
|
||||
assert.equal((imported.hosts as SyncPayload["hosts"])[0]?.proxyProfileId, "missing-proxy");
|
||||
assert.equal("groupConfigs" in imported, false);
|
||||
});
|
||||
|
||||
test("applySyncPayload waits for async vault imports", async () => {
|
||||
let finished = false;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
const promise = applySyncPayload(payload, {
|
||||
importVaultData: async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1));
|
||||
finished = true;
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(finished, false);
|
||||
await promise;
|
||||
assert.equal(finished, true);
|
||||
});
|
||||
|
||||
test("applyLocalVaultPayload restores known hosts from local backups", async () => {
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
const payload: SyncPayload = {
|
||||
hosts: [],
|
||||
@@ -128,7 +245,7 @@ test("applyLocalVaultPayload restores known hosts from local backups", () => {
|
||||
syncedAt: 1,
|
||||
};
|
||||
|
||||
applyLocalVaultPayload(payload, {
|
||||
await applyLocalVaultPayload(payload, {
|
||||
importVaultData: (json) => {
|
||||
imported = JSON.parse(json);
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
Identity,
|
||||
KnownHost,
|
||||
PortForwardingRule,
|
||||
ProxyProfile,
|
||||
SftpBookmark,
|
||||
Snippet,
|
||||
SSHKey,
|
||||
@@ -63,6 +64,7 @@ export interface SyncableVaultData {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
snippets: Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
@@ -81,6 +83,7 @@ export function hasMeaningfulSyncData(payload: SyncPayload): boolean {
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
@@ -104,6 +107,7 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
(payload.keys?.length ?? 0) > 0 ||
|
||||
(payload.snippets?.length ?? 0) > 0 ||
|
||||
(payload.identities?.length ?? 0) > 0 ||
|
||||
(payload.proxyProfiles?.length ?? 0) > 0 ||
|
||||
(payload.customGroups?.length ?? 0) > 0 ||
|
||||
(payload.snippetPackages?.length ?? 0) > 0 ||
|
||||
(payload.portForwardingRules?.length ?? 0) > 0 ||
|
||||
@@ -119,7 +123,7 @@ export function hasMeaningfulCloudSyncData(payload: SyncPayload): boolean {
|
||||
/** Callbacks used by `applySyncPayload` to import data into local state. */
|
||||
interface SyncPayloadImporters {
|
||||
/** Import vault data. Cloud sync excludes local-only known hosts by default. */
|
||||
importVaultData: (jsonString: string) => void;
|
||||
importVaultData: (jsonString: string) => void | Promise<void>;
|
||||
/** Import port-forwarding rules (lives outside the vault hook). */
|
||||
importPortForwardingRules?: (rules: PortForwardingRule[]) => void;
|
||||
/** Called after synced settings have been written to localStorage. */
|
||||
@@ -337,6 +341,7 @@ export function buildSyncPayload(
|
||||
hosts: vault.hosts,
|
||||
keys: vault.keys,
|
||||
identities: vault.identities,
|
||||
proxyProfiles: vault.proxyProfiles,
|
||||
snippets: vault.snippets,
|
||||
customGroups: vault.customGroups,
|
||||
snippetPackages: vault.snippetPackages,
|
||||
@@ -368,13 +373,14 @@ function applyPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
options: { includeLocalOnlyData: boolean },
|
||||
): void {
|
||||
): Promise<void> {
|
||||
// Build the vault import object. Cloud sync intentionally ignores
|
||||
// local-only trust records even if legacy cloud snapshots still carry them.
|
||||
const vaultImport: Record<string, unknown> = {
|
||||
hosts: payload.hosts,
|
||||
keys: payload.keys,
|
||||
identities: payload.identities,
|
||||
proxyProfiles: payload.proxyProfiles,
|
||||
snippets: payload.snippets,
|
||||
customGroups: payload.customGroups,
|
||||
};
|
||||
@@ -388,35 +394,35 @@ function applyPayload(
|
||||
vaultImport.groupConfigs = payload.groupConfigs;
|
||||
}
|
||||
|
||||
importers.importVaultData(JSON.stringify(vaultImport));
|
||||
return Promise.resolve(importers.importVaultData(JSON.stringify(vaultImport))).then(() => {
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Only import port-forwarding rules when the payload explicitly carries
|
||||
// them. Absent field = "payload was created before this feature existed",
|
||||
// so local rules are preserved. Explicitly present [] = "remote has no
|
||||
// rules, clear local state".
|
||||
if (payload.portForwardingRules !== undefined && importers.importPortForwardingRules) {
|
||||
importers.importPortForwardingRules(payload.portForwardingRules);
|
||||
}
|
||||
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
// Apply synced settings
|
||||
if (payload.settings) {
|
||||
applySyncableSettings(payload.settings);
|
||||
// Rehydrate in-memory bookmark snapshot after localStorage was updated
|
||||
if (payload.settings.sftpGlobalBookmarks != null) rehydrateGlobalBookmarks();
|
||||
importers.onSettingsApplied?.();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function applySyncPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): void {
|
||||
applyPayload(payload, importers, { includeLocalOnlyData: false });
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: false });
|
||||
}
|
||||
|
||||
export function applyLocalVaultPayload(
|
||||
payload: SyncPayload,
|
||||
importers: SyncPayloadImporters,
|
||||
): void {
|
||||
applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
): Promise<void> {
|
||||
return applyPayload(payload, importers, { includeLocalOnlyData: true });
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import { resolveGroupDefaults, resolveGroupTerminalThemeId } from "../domain/groupConfig";
|
||||
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
|
||||
import { cn } from "../lib/utils";
|
||||
import {
|
||||
EnvVar,
|
||||
@@ -29,6 +30,7 @@ import {
|
||||
Host,
|
||||
Identity,
|
||||
ProxyConfig,
|
||||
ProxyProfile,
|
||||
SSHKey,
|
||||
} from "../types";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
@@ -51,6 +53,7 @@ import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
type SubPanel = "none" | "proxy" | "chain" | "env-vars" | "theme-select";
|
||||
|
||||
@@ -59,6 +62,7 @@ interface GroupDetailsPanelProps {
|
||||
config: GroupConfig | undefined;
|
||||
availableKeys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
allHosts: Host[];
|
||||
groups: string[];
|
||||
terminalThemeId: string;
|
||||
@@ -74,6 +78,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
config,
|
||||
availableKeys,
|
||||
identities: _identities,
|
||||
proxyProfiles = [],
|
||||
allHosts,
|
||||
groups,
|
||||
terminalThemeId,
|
||||
@@ -105,7 +110,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
c.protocol === 'ssh' ||
|
||||
c.port !== undefined || !!c.username || !!c.password || !!c.identityFileId ||
|
||||
c.agentForwarding !== undefined || c.authMethod !== undefined || !!c.identityId ||
|
||||
!!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.backspaceBehavior !== undefined ||
|
||||
(c.environmentVariables && c.environmentVariables.length > 0) ||
|
||||
c.moshEnabled !== undefined || !!c.moshServerPath ||
|
||||
(c.identityFilePaths && c.identityFilePaths.length > 0);
|
||||
@@ -132,6 +137,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
// Environment variables state
|
||||
const [newEnvName, setNewEnvName] = useState("");
|
||||
const [newEnvValue, setNewEnvValue] = useState("");
|
||||
const selectedProxyProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
|
||||
[form.proxyProfileId, proxyProfiles],
|
||||
);
|
||||
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
|
||||
const proxySummaryLabel = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? selectedProxyProfile.label
|
||||
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
|
||||
const update = <K extends keyof GroupConfig>(key: K, value: GroupConfig[K] | undefined) => {
|
||||
setForm((prev) => ({ ...prev, [key]: value }));
|
||||
@@ -156,6 +171,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
delete next.startupCommand;
|
||||
delete next.legacyAlgorithms;
|
||||
delete next.backspaceBehavior;
|
||||
delete next.proxyProfileId;
|
||||
delete next.proxyConfig;
|
||||
delete next.hostChain;
|
||||
delete next.environmentVariables;
|
||||
@@ -182,27 +198,38 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
// Proxy helpers
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearProxyConfig = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return rest;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
if (!profileId) return rest;
|
||||
return { ...rest, proxyProfileId: profileId };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Chain helpers
|
||||
const chainedHosts = useMemo(() => {
|
||||
const ids = form.hostChain?.hostIds || [];
|
||||
@@ -297,6 +324,19 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
setNameError(t("vault.groups.errors.invalidChars"));
|
||||
return;
|
||||
}
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
toast.error(
|
||||
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
|
||||
);
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
if (sshEnabled && hasMissingProxyProfile) {
|
||||
toast.error(t("hostDetails.proxyPanel.missingSaved"));
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
setNameError(null);
|
||||
|
||||
const newPath = parentGroup
|
||||
@@ -320,7 +360,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
...(form.startupCommand !== undefined && { startupCommand: form.startupCommand }),
|
||||
...(form.legacyAlgorithms !== undefined && { legacyAlgorithms: form.legacyAlgorithms }),
|
||||
...(form.backspaceBehavior !== undefined && { backspaceBehavior: form.backspaceBehavior }),
|
||||
...(form.proxyConfig !== undefined && { proxyConfig: form.proxyConfig }),
|
||||
...(form.proxyProfileId !== undefined && { proxyProfileId: form.proxyProfileId }),
|
||||
...(normalizedProxyConfig !== undefined && { proxyConfig: normalizedProxyConfig }),
|
||||
...(form.hostChain !== undefined && { hostChain: form.hostChain }),
|
||||
...(form.environmentVariables !== undefined && { environmentVariables: form.environmentVariables }),
|
||||
...(form.moshEnabled !== undefined && { moshEnabled: form.moshEnabled }),
|
||||
@@ -360,7 +401,10 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
return (
|
||||
<ProxyPanel
|
||||
proxyConfig={form.proxyConfig}
|
||||
proxyProfiles={proxyProfiles}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
@@ -849,11 +893,16 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<span className="text-sm">{t("hostDetails.proxy")}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{form.proxyConfig?.host && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</Badge>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{(form.proxyConfig?.host || form.proxyProfileId) && (
|
||||
<div title={proxySummaryLabel} className="min-w-0">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="max-w-[160px] truncate text-xs"
|
||||
>
|
||||
{proxySummaryLabel}
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
<ChevronRight size={14} className="text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
51
components/HostDetailsPanel.proxyProfile.test.tsx
Normal file
51
components/HostDetailsPanel.proxyProfile.test.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { Host } from "../types.ts";
|
||||
import HostDetailsPanel from "./HostDetailsPanel.tsx";
|
||||
|
||||
const hostWithMissingProxyProfile: Host = {
|
||||
id: "host-1",
|
||||
label: "DB",
|
||||
hostname: "db.example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
port: 22,
|
||||
protocol: "ssh",
|
||||
authMethod: "password",
|
||||
proxyProfileId: "missing-proxy",
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const renderHostDetails = () =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(HostDetailsPanel, {
|
||||
initialData: hostWithMissingProxyProfile,
|
||||
availableKeys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
groups: [],
|
||||
managedSources: [],
|
||||
allTags: [],
|
||||
allHosts: [],
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
onSave: () => {},
|
||||
onCancel: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("HostDetailsPanel shows a missing saved proxy without undefined fields", () => {
|
||||
const markup = renderHostDetails();
|
||||
|
||||
assert.match(markup, /Missing saved proxy/);
|
||||
assert.doesNotMatch(markup, /undefined:undefined/);
|
||||
});
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
LINUX_DISTRO_OPTIONS,
|
||||
NETWORK_DEVICE_OPTIONS,
|
||||
} from "../domain/host";
|
||||
import { isCompleteProxyConfig, normalizeManualProxyConfig } from "../domain/proxyProfiles";
|
||||
import { customThemeStore } from "../application/state/customThemeStore";
|
||||
import {
|
||||
clearHostFontSizeOverride,
|
||||
@@ -48,7 +49,7 @@ import {
|
||||
} from "../domain/terminalAppearance";
|
||||
import { MIN_FONT_SIZE, MAX_FONT_SIZE } from "../infrastructure/config/fonts";
|
||||
import { cn } from "../lib/utils";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, SSHKey } from "../types";
|
||||
import { EnvVar, GroupConfig, Host, Identity, ManagedSource, ProxyConfig, ProxyProfile, SSHKey } from "../types";
|
||||
import { DISTRO_COLORS, DISTRO_LOGOS } from "./DistroAvatar";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import ThemeSelectPanel from "./ThemeSelectPanel";
|
||||
@@ -69,6 +70,7 @@ import { Textarea } from "./ui/textarea";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
// Import host-details sub-panels
|
||||
import {
|
||||
@@ -97,6 +99,7 @@ interface HostDetailsPanelProps {
|
||||
initialData?: Host | null;
|
||||
availableKeys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
groups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
allTags?: string[]; // All available tags for autocomplete
|
||||
@@ -117,6 +120,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
initialData,
|
||||
availableKeys,
|
||||
identities,
|
||||
proxyProfiles = [],
|
||||
groups,
|
||||
managedSources = [],
|
||||
allTags = [],
|
||||
@@ -260,6 +264,24 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
);
|
||||
|
||||
const effectiveFormDistro = getEffectiveHostDistro(form);
|
||||
const selectedProxyProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === form.proxyProfileId),
|
||||
[form.proxyProfileId, proxyProfiles],
|
||||
);
|
||||
const hasMissingProxyProfile = Boolean(form.proxyProfileId && !selectedProxyProfile);
|
||||
const proxySummaryType = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missing")
|
||||
: (selectedProxyProfile?.config.type || form.proxyConfig?.type || "http").toUpperCase();
|
||||
const proxySummaryLabel = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? selectedProxyProfile.label
|
||||
: `${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
const proxySummaryTooltip = hasMissingProxyProfile
|
||||
? t("hostDetails.proxyPanel.missingSaved")
|
||||
: selectedProxyProfile
|
||||
? `${selectedProxyProfile.label} - ${selectedProxyProfile.config.host}:${selectedProxyProfile.config.port}`
|
||||
: `${form.proxyConfig?.type?.toUpperCase()} ${form.proxyConfig?.host}:${form.proxyConfig?.port}`;
|
||||
|
||||
const handleDistroModeChange = useCallback((mode: "auto" | "manual") => {
|
||||
setForm((prev) => ({
|
||||
@@ -274,27 +296,38 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: string | number) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: {
|
||||
type: prev.proxyConfig?.type || "http",
|
||||
host: prev.proxyConfig?.host || "",
|
||||
port: prev.proxyConfig?.port || 8080,
|
||||
...prev.proxyConfig,
|
||||
[field]: value,
|
||||
},
|
||||
} as Host;
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const clearProxyConfig = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return rest as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
if (!profileId) return rest as Host;
|
||||
return { ...rest, proxyProfileId: profileId } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
@@ -342,6 +375,19 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!form.hostname) return;
|
||||
const normalizedProxyConfig = normalizeManualProxyConfig(form.proxyConfig);
|
||||
if (normalizedProxyConfig && !isCompleteProxyConfig(normalizedProxyConfig)) {
|
||||
toast.error(
|
||||
normalizedProxyConfig.host ? t("proxyProfiles.error.port") : t("hostDetails.proxyPanel.error.required"),
|
||||
);
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
if (hasMissingProxyProfile) {
|
||||
toast.error(t("hostDetails.proxyPanel.missingSaved"));
|
||||
setActiveSubPanel("proxy");
|
||||
return;
|
||||
}
|
||||
// If label is empty, use hostname as label
|
||||
let finalLabel = form.label?.trim() || form.hostname;
|
||||
const finalGroup = groupInputValue.trim() || form.group || "";
|
||||
@@ -377,8 +423,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
finalManagedSourceId = undefined;
|
||||
}
|
||||
|
||||
const { proxyConfig: _draftProxyConfig, ...formWithoutProxyDraft } = form;
|
||||
const cleaned: Host = {
|
||||
...form,
|
||||
...formWithoutProxyDraft,
|
||||
...(normalizedProxyConfig && { proxyConfig: normalizedProxyConfig }),
|
||||
label: finalLabel,
|
||||
group: finalGroup,
|
||||
tags: form.tags || [],
|
||||
@@ -536,7 +584,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
return (
|
||||
<ProxyPanel
|
||||
proxyConfig={form.proxyConfig}
|
||||
proxyProfiles={proxyProfiles}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
@@ -1758,35 +1809,40 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.proxy")}</p>
|
||||
</div>
|
||||
{form.proxyConfig?.host ? (
|
||||
<button
|
||||
className="w-full min-w-0 grid grid-cols-[auto_minmax(0,1fr)_auto] 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">
|
||||
{form.proxyConfig.type?.toUpperCase()}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{form.proxyConfig.type?.toUpperCase()} {form.proxyConfig.host}:{form.proxyConfig.port}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
<X
|
||||
size={14}
|
||||
className="text-muted-foreground hover:text-destructive flex-shrink-0"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
clearProxyConfig();
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{form.proxyConfig?.host || form.proxyProfileId ? (
|
||||
<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">
|
||||
{proxySummaryType}
|
||||
</Badge>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{proxySummaryLabel}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" align="start" className="max-w-xs break-all">
|
||||
{proxySummaryTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</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>
|
||||
) : (
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -22,7 +22,7 @@ import { resolveHostAuth } from "../domain/sshAuth";
|
||||
import { STORAGE_KEY_VAULT_KEYS_VIEW_MODE } from "../infrastructure/config/storageKeys";
|
||||
import { logger } from "../lib/logger";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KeyType, SSHKey } from "../types";
|
||||
import { Host, Identity, KeyType, ProxyProfile, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { useKeychainBackend } from "../application/state/useKeychainBackend";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
@@ -68,6 +68,7 @@ interface KeychainManagerProps {
|
||||
keys: SSHKey[];
|
||||
identities?: Identity[];
|
||||
hosts?: Host[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
customGroups?: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSave: (key: SSHKey) => void;
|
||||
@@ -84,6 +85,7 @@ const KeychainManager: React.FC<KeychainManagerProps> = ({
|
||||
keys,
|
||||
identities = [],
|
||||
hosts = [],
|
||||
proxyProfiles = [],
|
||||
customGroups = [],
|
||||
managedSources = [],
|
||||
onSave,
|
||||
@@ -1234,6 +1236,7 @@ echo $3 >> "$FILE"`);
|
||||
onBack={() => setShowHostSelector(false)}
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
Shuffle,
|
||||
Zap,
|
||||
} from "lucide-react";
|
||||
import React, { useCallback, useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { usePortForwardingState } from "../application/state/usePortForwardingState";
|
||||
import {
|
||||
@@ -19,9 +19,11 @@ import {
|
||||
ManagedSource,
|
||||
PortForwardingRule,
|
||||
PortForwardingType,
|
||||
ProxyProfile,
|
||||
SSHKey,
|
||||
} from "../domain/models";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { cn } from "../lib/utils";
|
||||
import SelectHostPanel from "./SelectHostPanel";
|
||||
import {
|
||||
@@ -69,6 +71,7 @@ interface PortForwardingProps {
|
||||
customGroups: string[];
|
||||
managedSources?: ManagedSource[];
|
||||
groupConfigs?: GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
onNewHost?: () => void;
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
@@ -81,6 +84,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
customGroups: _customGroups,
|
||||
managedSources = [],
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
onNewHost: _onNewHost,
|
||||
onSaveHost,
|
||||
onCreateGroup: _onCreateGroup,
|
||||
@@ -113,6 +117,20 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const [pendingOperations, setPendingOperations] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const resolveEffectiveHost = useCallback(
|
||||
(host: Host): Host => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
},
|
||||
[groupConfigs, proxyProfileIdSet, proxyProfiles],
|
||||
);
|
||||
|
||||
// Start a port forwarding tunnel
|
||||
const handleStartTunnel = useCallback(
|
||||
@@ -127,9 +145,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const _host = _rawHost.group
|
||||
? applyGroupDefaults(_rawHost, resolveGroupDefaults(_rawHost.group, groupConfigs))
|
||||
: _rawHost;
|
||||
const _host = resolveEffectiveHost(_rawHost);
|
||||
const effectiveHosts = hosts.map((host) => resolveEffectiveHost(host));
|
||||
|
||||
setPendingOperations((prev) => new Set([...prev, rule.id]));
|
||||
let errorShown = false;
|
||||
@@ -138,7 +155,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
const result = await startTunnel(
|
||||
rule,
|
||||
_host,
|
||||
hosts,
|
||||
effectiveHosts,
|
||||
keys,
|
||||
identities,
|
||||
(status, error) => {
|
||||
@@ -169,7 +186,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
});
|
||||
}
|
||||
},
|
||||
[hosts, identities, keys, groupConfigs, setRuleStatus, startTunnel, t],
|
||||
[hosts, identities, keys, resolveEffectiveHost, setRuleStatus, startTunnel, t],
|
||||
);
|
||||
|
||||
// Stop a port forwarding tunnel
|
||||
@@ -853,6 +870,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
|
||||
onContinue={() => setShowHostSelector(false)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={_onCreateGroup}
|
||||
|
||||
80
components/ProxyPanel.test.tsx
Normal file
80
components/ProxyPanel.test.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import type { ProxyProfile } from "../types.ts";
|
||||
import { ProxyPanel } from "./host-details/ProxyPanel.tsx";
|
||||
|
||||
const proxyProfile: ProxyProfile = {
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "socks5",
|
||||
host: "office-proxy.example.com",
|
||||
port: 1080,
|
||||
},
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const renderPanel = (props: Partial<React.ComponentProps<typeof ProxyPanel>> = {}) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(ProxyPanel, {
|
||||
proxyConfig: undefined,
|
||||
proxyProfiles: [],
|
||||
selectedProxyProfileId: undefined,
|
||||
onUpdateProxy: () => {},
|
||||
onSelectProxyProfile: () => {},
|
||||
onClearProxy: () => {},
|
||||
onBack: () => {},
|
||||
onCancel: () => {},
|
||||
layout: "inline",
|
||||
...props,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
test("ProxyPanel shows saved proxy selection when reusable profiles exist", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [proxyProfile],
|
||||
selectedProxyProfileId: proxyProfile.id,
|
||||
});
|
||||
|
||||
assert.match(markup, /Saved proxy/);
|
||||
assert.match(markup, /office-proxy\.example\.com:1080/);
|
||||
assert.doesNotMatch(markup, /Proxy host/);
|
||||
});
|
||||
|
||||
test("ProxyPanel keeps manual proxy fields available without a saved profile selection", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [proxyProfile],
|
||||
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 3128 },
|
||||
});
|
||||
|
||||
assert.match(markup, /Saved proxy/);
|
||||
assert.match(markup, /Proxy host/);
|
||||
assert.match(markup, /manual-proxy\.example\.com/);
|
||||
});
|
||||
|
||||
test("ProxyPanel shows a clear missing state for stale saved proxy selections", () => {
|
||||
const markup = renderPanel({
|
||||
proxyProfiles: [proxyProfile],
|
||||
selectedProxyProfileId: "missing-proxy",
|
||||
});
|
||||
|
||||
assert.match(markup, /Missing saved proxy/);
|
||||
assert.match(markup, /Proxy host/);
|
||||
});
|
||||
|
||||
test("ProxyPanel disables saving invalid manual proxy ports", () => {
|
||||
const markup = renderPanel({
|
||||
proxyConfig: { type: "http", host: "manual-proxy.example.com", port: 65536 },
|
||||
});
|
||||
|
||||
assert.match(markup, /Port must be between 1 and 65535/);
|
||||
assert.match(markup, /disabled=""/);
|
||||
});
|
||||
85
components/ProxyProfilesManager.test.tsx
Normal file
85
components/ProxyProfilesManager.test.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { I18nProvider } from "../application/i18n/I18nProvider.tsx";
|
||||
import { isValidProxyPort } from "../domain/proxyProfiles.ts";
|
||||
import { STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE } from "../infrastructure/config/storageKeys.ts";
|
||||
import type { ProxyProfile } from "../types.ts";
|
||||
import { ProxyProfilesManager } from "./ProxyProfilesManager.tsx";
|
||||
|
||||
const proxyProfile: ProxyProfile = {
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 8080,
|
||||
},
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const installStorageStub = (viewMode: string | null = null) => {
|
||||
const values = new Map<string, string>();
|
||||
if (viewMode) {
|
||||
values.set(STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE, viewMode);
|
||||
}
|
||||
|
||||
Object.defineProperty(globalThis, "localStorage", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => values.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem: (key: string) => {
|
||||
values.delete(key);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const renderManager = (viewMode: string | null = null) => {
|
||||
installStorageStub(viewMode);
|
||||
return renderToStaticMarkup(
|
||||
React.createElement(
|
||||
I18nProvider,
|
||||
{ locale: "en" },
|
||||
React.createElement(ProxyProfilesManager, {
|
||||
proxyProfiles: [proxyProfile],
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
onUpdateProxyProfiles: () => {},
|
||||
onUpdateHosts: () => {},
|
||||
onUpdateGroupConfigs: () => {},
|
||||
}),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
test("ProxyProfilesManager uses the shared Vault grid card style by default", () => {
|
||||
const markup = renderManager();
|
||||
|
||||
assert.match(markup, /Add Proxy/);
|
||||
assert.match(markup, /aria-label="Search proxies…"/);
|
||||
assert.match(markup, /aria-label="Office Proxy, HTTP, 127\.0\.0\.1:8080, 0 linked"/);
|
||||
assert.match(markup, /Office Proxy/);
|
||||
assert.match(markup, /127\.0\.0\.1:8080/);
|
||||
});
|
||||
|
||||
test("ProxyProfilesManager uses the shared Vault list row style when persisted", () => {
|
||||
const markup = renderManager("list");
|
||||
|
||||
assert.match(markup, /aria-label="Office Proxy, HTTP, 127\.0\.0\.1:8080, 0 linked"/);
|
||||
assert.match(markup, /Office Proxy/);
|
||||
assert.match(markup, /127\.0\.0\.1:8080/);
|
||||
});
|
||||
|
||||
test("ProxyProfilesManager validates proxy ports", () => {
|
||||
assert.equal(isValidProxyPort(1), true);
|
||||
assert.equal(isValidProxyPort(65535), true);
|
||||
assert.equal(isValidProxyPort(0), false);
|
||||
assert.equal(isValidProxyPort(65536), false);
|
||||
assert.equal(isValidProxyPort(10.5), false);
|
||||
});
|
||||
538
components/ProxyProfilesManager.tsx
Normal file
538
components/ProxyProfilesManager.tsx
Normal file
@@ -0,0 +1,538 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Check,
|
||||
ChevronDown,
|
||||
Copy,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutGrid,
|
||||
List as ListIcon,
|
||||
Pencil,
|
||||
Plus,
|
||||
Search,
|
||||
Settings2,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { useStoredViewMode } from "../application/state/useStoredViewMode";
|
||||
import { isValidProxyPort, removeProxyProfileReferences } from "../domain/proxyProfiles";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
} from "../infrastructure/config/storageKeys";
|
||||
import { cn } from "../lib/utils";
|
||||
import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "../types";
|
||||
import {
|
||||
AsidePanel,
|
||||
AsidePanelContent,
|
||||
AsidePanelFooter,
|
||||
} from "./ui/aside-panel";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Button } from "./ui/button";
|
||||
import { Card } from "./ui/card";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuTrigger,
|
||||
} from "./ui/context-menu";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "./ui/dialog";
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from "./ui/dropdown";
|
||||
import { Input } from "./ui/input";
|
||||
import { toast } from "./ui/toast";
|
||||
|
||||
interface ProxyProfilesManagerProps {
|
||||
proxyProfiles: ProxyProfile[];
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
onUpdateGroupConfigs: (configs: GroupConfig[]) => void;
|
||||
}
|
||||
|
||||
const createDraftProfile = (): ProxyProfile => {
|
||||
const now = Date.now();
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
label: "",
|
||||
config: {
|
||||
type: "http",
|
||||
host: "",
|
||||
port: 8080,
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
};
|
||||
|
||||
const getProfileUsageCount = (
|
||||
profileId: string,
|
||||
hosts: Host[],
|
||||
groupConfigs: GroupConfig[],
|
||||
): number =>
|
||||
hosts.filter((host) => host.proxyProfileId === profileId).length +
|
||||
groupConfigs.filter((config) => config.proxyProfileId === profileId).length;
|
||||
|
||||
type ProxyProfilesViewMode = "grid" | "list";
|
||||
|
||||
interface ProxyProfileCardProps {
|
||||
profile: ProxyProfile;
|
||||
usageCount: number;
|
||||
viewMode: ProxyProfilesViewMode;
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
onEdit: () => void;
|
||||
onDuplicate: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const ProxyProfileCard: React.FC<ProxyProfileCardProps> = ({
|
||||
profile,
|
||||
usageCount,
|
||||
viewMode,
|
||||
isSelected,
|
||||
onClick,
|
||||
onEdit,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const usageLabel = t("proxyProfiles.usage", { count: usageCount });
|
||||
const accessibleLabel = `${profile.label}, ${profile.config.type.toUpperCase()}, ${profile.config.host}:${profile.config.port}, ${usageLabel}`;
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={accessibleLabel}
|
||||
className={cn(
|
||||
"group w-full text-left focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none",
|
||||
viewMode === "grid"
|
||||
? "soft-card elevate rounded-xl h-[68px] px-3 py-2"
|
||||
: "h-14 px-3 py-2 hover:bg-secondary/60 rounded-lg transition-colors",
|
||||
isSelected && "ring-2 ring-primary",
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="flex items-center gap-3 h-full">
|
||||
<div className="h-11 w-11 rounded-xl bg-primary/15 text-primary flex items-center justify-center">
|
||||
<Globe size={18} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{profile.label}</div>
|
||||
<Badge variant="secondary" className="text-[10px] shrink-0">
|
||||
{profile.config.type.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono text-muted-foreground truncate">
|
||||
{profile.config.host}:{profile.config.port} -{" "}
|
||||
{usageLabel}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={onEdit}>
|
||||
<Pencil size={14} className="mr-2" />
|
||||
{t("action.edit")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={onDuplicate}>
|
||||
<Copy size={14} className="mr-2" />
|
||||
{t("action.duplicate")}
|
||||
</ContextMenuItem>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={onDelete} className="text-destructive focus:text-destructive">
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("action.delete")}
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
};
|
||||
|
||||
export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
proxyProfiles,
|
||||
hosts,
|
||||
groupConfigs,
|
||||
onUpdateProxyProfiles,
|
||||
onUpdateHosts,
|
||||
onUpdateGroupConfigs,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const [search, setSearch] = useState("");
|
||||
const [viewMode, setViewMode] = useStoredViewMode(
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
"grid",
|
||||
);
|
||||
const proxyProfilesViewMode: ProxyProfilesViewMode =
|
||||
viewMode === "list" ? "list" : "grid";
|
||||
const [draft, setDraft] = useState<ProxyProfile | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ProxyProfile | null>(null);
|
||||
|
||||
const usageByProfileId = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const profile of proxyProfiles) {
|
||||
map.set(profile.id, getProfileUsageCount(profile.id, hosts, groupConfigs));
|
||||
}
|
||||
return map;
|
||||
}, [groupConfigs, hosts, proxyProfiles]);
|
||||
|
||||
const filteredProfiles = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return proxyProfiles;
|
||||
return proxyProfiles.filter((profile) =>
|
||||
profile.label.toLowerCase().includes(q) ||
|
||||
profile.config.host.toLowerCase().includes(q) ||
|
||||
profile.config.type.toLowerCase().includes(q),
|
||||
);
|
||||
}, [proxyProfiles, search]);
|
||||
|
||||
const updateDraftConfig = (field: keyof ProxyConfig, value: string | number) => {
|
||||
setDraft((prev) => {
|
||||
if (!prev) return prev;
|
||||
return {
|
||||
...prev,
|
||||
config: {
|
||||
...prev.config,
|
||||
[field]: value,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const openCreate = () => {
|
||||
setDraft(createDraftProfile());
|
||||
};
|
||||
|
||||
const openEdit = (profile: ProxyProfile) => {
|
||||
setDraft({
|
||||
...profile,
|
||||
config: { ...profile.config },
|
||||
});
|
||||
};
|
||||
|
||||
const duplicateProfile = (profile: ProxyProfile) => {
|
||||
const now = Date.now();
|
||||
onUpdateProxyProfiles([
|
||||
...proxyProfiles,
|
||||
{
|
||||
...profile,
|
||||
id: crypto.randomUUID(),
|
||||
label: t("proxyProfiles.copyName", { name: profile.label }),
|
||||
config: { ...profile.config },
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
const saveDraft = () => {
|
||||
if (!draft) return;
|
||||
const label = draft.label.trim();
|
||||
const host = draft.config.host.trim();
|
||||
if (!label || !host || !draft.config.port) {
|
||||
toast.error(t("proxyProfiles.error.required"));
|
||||
return;
|
||||
}
|
||||
if (!isValidProxyPort(draft.config.port)) {
|
||||
toast.error(t("proxyProfiles.error.port"));
|
||||
return;
|
||||
}
|
||||
|
||||
const saved: ProxyProfile = {
|
||||
...draft,
|
||||
label,
|
||||
config: {
|
||||
...draft.config,
|
||||
host,
|
||||
port: Number(draft.config.port),
|
||||
username: draft.config.username?.trim() || undefined,
|
||||
password: draft.config.password || undefined,
|
||||
},
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
|
||||
onUpdateProxyProfiles(
|
||||
proxyProfiles.some((profile) => profile.id === saved.id)
|
||||
? proxyProfiles.map((profile) => profile.id === saved.id ? saved : profile)
|
||||
: [...proxyProfiles, saved],
|
||||
);
|
||||
setDraft(null);
|
||||
};
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
|
||||
hosts,
|
||||
groupConfigs,
|
||||
});
|
||||
onUpdateProxyProfiles(proxyProfiles.filter((profile) => profile.id !== deleteTarget.id));
|
||||
onUpdateHosts(cleaned.hosts);
|
||||
onUpdateGroupConfigs(cleaned.groupConfigs);
|
||||
if (draft?.id === deleteTarget.id) {
|
||||
setDraft(null);
|
||||
}
|
||||
setDeleteTarget(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex relative">
|
||||
<div className={cn("flex-1 flex flex-col min-h-0 transition-all duration-200", draft && "mr-[380px]")}>
|
||||
<header className="border-b border-border/50 bg-secondary/80 supports-[backdrop-filter]:backdrop-blur-sm shrink-0">
|
||||
<div className="h-14 px-4 py-2 flex items-center gap-3">
|
||||
<Button
|
||||
onClick={openCreate}
|
||||
variant="secondary"
|
||||
className="h-10 px-3 gap-2 bg-foreground/5 text-foreground hover:bg-foreground/10 border-border/40"
|
||||
>
|
||||
<Plus size={14} />
|
||||
{t("proxyProfiles.action.add")}
|
||||
</Button>
|
||||
<div className="ml-auto flex items-center gap-2 min-w-0 flex-shrink">
|
||||
<div className="relative flex-shrink min-w-[100px]">
|
||||
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label={t("proxyProfiles.search.placeholder")}
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder={t("proxyProfiles.search.placeholder")}
|
||||
className="h-10 pl-9 w-full bg-secondary border-border/60 text-sm"
|
||||
/>
|
||||
</div>
|
||||
<Dropdown>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
aria-label={t("proxyProfiles.viewMode")}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-10 w-10 flex-shrink-0"
|
||||
>
|
||||
{proxyProfilesViewMode === "grid" ? (
|
||||
<LayoutGrid size={16} />
|
||||
) : (
|
||||
<ListIcon size={16} />
|
||||
)}
|
||||
<ChevronDown size={10} className="ml-0.5" />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownContent className="w-32" align="end">
|
||||
<Button
|
||||
variant={proxyProfilesViewMode === "grid" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("grid")}
|
||||
>
|
||||
<LayoutGrid size={14} /> {t("vault.view.grid")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={proxyProfilesViewMode === "list" ? "secondary" : "ghost"}
|
||||
className="w-full justify-start gap-2 h-9"
|
||||
onClick={() => setViewMode("list")}
|
||||
>
|
||||
<ListIcon size={14} /> {t("vault.view.list")}
|
||||
</Button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="space-y-3 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold text-muted-foreground">
|
||||
{t("proxyProfiles.section.proxies")}
|
||||
</h2>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("proxyProfiles.count.items", { count: filteredProfiles.length })}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{filteredProfiles.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 text-muted-foreground">
|
||||
<div className="h-16 w-16 rounded-2xl bg-secondary/80 flex items-center justify-center mb-4">
|
||||
<Globe size={32} className="opacity-60" />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-foreground mb-2">
|
||||
{t("proxyProfiles.empty.title")}
|
||||
</h3>
|
||||
<p className="text-sm text-center max-w-sm mb-4">
|
||||
{t("proxyProfiles.empty.desc")}
|
||||
</p>
|
||||
<Button onClick={openCreate}>
|
||||
<Plus size={14} className="mr-2" />
|
||||
{t("proxyProfiles.action.add")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className={
|
||||
proxyProfilesViewMode === "grid"
|
||||
? "grid gap-3 grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||
: "flex flex-col gap-0"
|
||||
}
|
||||
>
|
||||
{filteredProfiles.map((profile) => (
|
||||
<ProxyProfileCard
|
||||
key={profile.id}
|
||||
profile={profile}
|
||||
usageCount={usageByProfileId.get(profile.id) ?? 0}
|
||||
viewMode={proxyProfilesViewMode}
|
||||
isSelected={draft?.id === profile.id}
|
||||
onClick={() => openEdit(profile)}
|
||||
onEdit={() => openEdit(profile)}
|
||||
onDuplicate={() => duplicateProfile(profile)}
|
||||
onDelete={() => setDeleteTarget(profile)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{draft && (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={() => setDraft(null)}
|
||||
title={draft.label || t("proxyProfiles.panel.newTitle")}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Settings2 size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("proxyProfiles.field.name")}</p>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t("proxyProfiles.field.name")}
|
||||
value={draft.label}
|
||||
onChange={(event) => setDraft({ ...draft, label: event.target.value })}
|
||||
placeholder={t("proxyProfiles.field.name")}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("field.type")}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={draft.config.type === "http" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", draft.config.type === "http" && "bg-primary/15")}
|
||||
onClick={() => updateDraftConfig("type", "http")}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", draft.config.type !== "http" && "opacity-0")} />
|
||||
HTTP
|
||||
</Button>
|
||||
<Button
|
||||
variant={draft.config.type === "socks5" ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", draft.config.type === "socks5" && "bg-primary/15")}
|
||||
onClick={() => updateDraftConfig("type", "socks5")}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", draft.config.type !== "socks5" && "opacity-0")} />
|
||||
SOCKS5
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
value={draft.config.host}
|
||||
onChange={(event) => updateDraftConfig("host", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.hostPlaceholder")}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.port")}
|
||||
type="number"
|
||||
value={draft.config.port || ""}
|
||||
onChange={(event) => updateDraftConfig("port", event.target.value === "" ? 0 : Number(event.target.value))}
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
className="h-10 w-24 text-center"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t("hostDetails.proxyPanel.credentials")}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">{t("common.optional")}</Badge>
|
||||
</div>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
||||
value={draft.config.username || ""}
|
||||
onChange={(event) => updateDraftConfig("username", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.usernamePlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
aria-label={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
type="password"
|
||||
value={draft.config.password || ""}
|
||||
onChange={(event) => updateDraftConfig("password", event.target.value)}
|
||||
placeholder={t("hostDetails.proxyPanel.passwordPlaceholder")}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
</AsidePanelContent>
|
||||
<AsidePanelFooter>
|
||||
<Button className="w-full" onClick={saveDraft}>
|
||||
{t("common.save")}
|
||||
</Button>
|
||||
</AsidePanelFooter>
|
||||
</AsidePanel>
|
||||
)}
|
||||
|
||||
<Dialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle size={18} className="text-destructive" />
|
||||
{t("proxyProfiles.delete.title")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteTarget
|
||||
? t("proxyProfiles.delete.desc", {
|
||||
name: deleteTarget.label,
|
||||
count: usageByProfileId.get(deleteTarget.id) ?? 0,
|
||||
})
|
||||
: ""}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>
|
||||
{t("common.cancel")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
{t("action.delete")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProxyProfilesManager;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { Host, SSHKey } from "../types";
|
||||
import { Host, ProxyProfile, SSHKey } from "../types";
|
||||
import { ManagedSource } from "../domain/models";
|
||||
import { DistroAvatar } from "./DistroAvatar";
|
||||
import HostDetailsPanel from "./HostDetailsPanel";
|
||||
@@ -37,6 +37,7 @@ interface SelectHostPanelProps {
|
||||
// Props for inline host creation
|
||||
availableKeys?: SSHKey[];
|
||||
identities?: import('../domain/models').Identity[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
@@ -57,6 +58,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
onNewHost,
|
||||
availableKeys = [],
|
||||
identities = [],
|
||||
proxyProfiles = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
@@ -411,6 +413,7 @@ const SelectHostPanel: React.FC<SelectHostPanelProps> = ({
|
||||
initialData={null}
|
||||
availableKeys={availableKeys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groups={customGroups}
|
||||
managedSources={managedSources}
|
||||
allHosts={hosts}
|
||||
|
||||
@@ -113,6 +113,7 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
@@ -137,8 +138,8 @@ const SettingsSyncTabWithVault: React.FC<{ onSettingsApplied?: () => void }> = (
|
||||
);
|
||||
|
||||
const vault = useMemo(
|
||||
() => ({ hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
|
||||
[hosts, keys, identities, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
() => ({ hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs }),
|
||||
[hosts, keys, identities, proxyProfiles, snippets, customGroups, snippetPackages, knownHosts, groupConfigs],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,6 +41,7 @@ import { KeyBinding, HotkeyScheme } from "../domain/models";
|
||||
|
||||
interface SftpSidePanelProps {
|
||||
hosts: Host[];
|
||||
writableHosts?: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
@@ -74,6 +75,7 @@ interface SftpSidePanelProps {
|
||||
|
||||
const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
hosts,
|
||||
writableHosts,
|
||||
keys,
|
||||
identities,
|
||||
updateHosts,
|
||||
@@ -98,6 +100,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
onRequestTerminalFocus,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
|
||||
const fileWatchHandlers = useMemo(() => ({
|
||||
onFileWatchSynced: (payload: { remotePath: string }) => {
|
||||
@@ -622,6 +625,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
writableHosts={hostWriteSource}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
@@ -741,6 +745,7 @@ const SftpSidePanelInner: React.FC<SftpSidePanelProps> = ({
|
||||
|
||||
const sidePanelAreEqual = (prev: SftpSidePanelProps, next: SftpSidePanelProps): boolean =>
|
||||
prev.hosts === next.hosts &&
|
||||
prev.writableHosts === next.writableHosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.updateHosts === next.updateHosts &&
|
||||
|
||||
@@ -24,8 +24,9 @@ import { logger } from "../lib/logger";
|
||||
import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useInstantThemeSwitch } from "../lib/useInstantThemeSwitch";
|
||||
import { Host, Identity, SSHKey } from "../types";
|
||||
import { Host, Identity, ProxyProfile, SSHKey } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
import { toast } from "./ui/toast";
|
||||
@@ -54,6 +55,7 @@ interface SftpViewProps {
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
groupConfigs?: import('../domain/models').GroupConfig[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
sftpDefaultViewMode: "list" | "tree";
|
||||
sftpDoubleClickBehavior: "open" | "transfer";
|
||||
@@ -71,6 +73,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
keys,
|
||||
identities,
|
||||
groupConfigs = [],
|
||||
proxyProfiles = [],
|
||||
updateHosts,
|
||||
sftpDefaultViewMode,
|
||||
sftpDoubleClickBehavior,
|
||||
@@ -109,14 +112,15 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
}), [fileWatchHandlers, sftpUseCompressedUpload, sftpShowHiddenFiles]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
const effectiveHosts = useMemo(() =>
|
||||
hosts.map(h => {
|
||||
if (!h.group) return h;
|
||||
const defaults = resolveGroupDefaults(h.group, groupConfigs);
|
||||
return applyGroupDefaults(h, defaults);
|
||||
}),
|
||||
[hosts, groupConfigs],
|
||||
);
|
||||
const effectiveHosts = useMemo(() => {
|
||||
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
return hosts.map(h => {
|
||||
const withGroupDefaults = h.group
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
|
||||
: applyGroupDefaults(h, {}, { validProxyProfileIds });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
});
|
||||
}, [hosts, groupConfigs, proxyProfiles]);
|
||||
|
||||
const sftp = useSftpState(effectiveHosts, keys, identities, sftpOptions);
|
||||
|
||||
@@ -323,7 +327,8 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
|
||||
return (
|
||||
<SftpContextProvider
|
||||
hosts={hosts}
|
||||
hosts={effectiveHosts}
|
||||
writableHosts={hosts}
|
||||
updateHosts={updateHosts}
|
||||
draggedFiles={draggedFiles}
|
||||
dragCallbacks={dragCallbacks}
|
||||
@@ -462,7 +467,7 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
</div>
|
||||
|
||||
<SftpOverlays
|
||||
hosts={hosts}
|
||||
hosts={effectiveHosts}
|
||||
sftp={sftp}
|
||||
visibleTransfers={visibleTransfers}
|
||||
showHostPickerLeft={showHostPickerLeft}
|
||||
@@ -507,6 +512,7 @@ const sftpViewAreEqual = (prev: SftpViewProps, next: SftpViewProps): boolean =>
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.proxyProfiles === next.proxyProfiles &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from '../application/i18n/I18nProvider';
|
||||
import { useStoredViewMode } from '../application/state/useStoredViewMode';
|
||||
import { STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE } from '../infrastructure/config/storageKeys';
|
||||
import { cn, isMacPlatform } from '../lib/utils';
|
||||
import { Host, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { Host, ProxyProfile, ShellHistoryEntry, Snippet, SSHKey } from '../types';
|
||||
import { HotkeyScheme, KeyBinding, keyEventToString, ManagedSource, matchesKeyBinding, parseKeyCombo } from '../domain/models';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import SelectHostPanel from './SelectHostPanel';
|
||||
@@ -35,6 +35,7 @@ interface SnippetsManagerProps {
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
// Props for inline host creation
|
||||
availableKeys?: SSHKey[];
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
managedSources?: ManagedSource[];
|
||||
onSaveHost?: (host: Host) => void;
|
||||
onCreateGroup?: (groupPath: string) => void;
|
||||
@@ -58,6 +59,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onPackagesChange,
|
||||
onRunSnippet,
|
||||
availableKeys = [],
|
||||
proxyProfiles = [],
|
||||
managedSources = [],
|
||||
onSaveHost,
|
||||
onCreateGroup,
|
||||
@@ -723,6 +725,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
onBack={handleTargetPickerBack}
|
||||
onContinue={handleTargetPickerBack}
|
||||
availableKeys={availableKeys}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={onSaveHost}
|
||||
onCreateGroup={onCreateGroup}
|
||||
|
||||
@@ -624,8 +624,14 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
onStartSsh: (term) => {
|
||||
sessionStartersRef.current?.startSSH(term);
|
||||
onStartSession: (term) => {
|
||||
const starters = sessionStartersRef.current;
|
||||
if (!starters) return;
|
||||
if (host.moshEnabled) {
|
||||
starters.startMosh(term);
|
||||
return;
|
||||
}
|
||||
starters.startSSH(term);
|
||||
},
|
||||
setStatus: (next) => setStatus(next),
|
||||
setProgressLogs,
|
||||
|
||||
66
components/TerminalLayer.memo.test.tsx
Normal file
66
components/TerminalLayer.memo.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { terminalLayerAreEqual } from "./terminalLayerMemo.ts";
|
||||
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
groupConfigs: [],
|
||||
proxyProfiles: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
sessions: [],
|
||||
workspaces: [],
|
||||
draggingSessionId: null,
|
||||
terminalTheme: {},
|
||||
accentMode: "theme",
|
||||
customAccent: null,
|
||||
terminalSettings: {},
|
||||
fontSize: 14,
|
||||
hotkeyScheme: "default",
|
||||
keyBindings: [],
|
||||
sftpDefaultViewMode: "list",
|
||||
sftpDoubleClickBehavior: "open",
|
||||
sftpAutoSync: false,
|
||||
sftpShowHiddenFiles: false,
|
||||
sftpUseCompressedUpload: false,
|
||||
sftpAutoOpenSidebar: false,
|
||||
editorWordWrap: false,
|
||||
setEditorWordWrap: () => {},
|
||||
onHotkeyAction: () => {},
|
||||
onUpdateHost: () => {},
|
||||
onToggleWorkspaceViewMode: () => {},
|
||||
onSetWorkspaceFocusedSession: () => {},
|
||||
onSplitSession: () => {},
|
||||
toggleScriptsSidePanelRef: { current: null },
|
||||
};
|
||||
|
||||
test("TerminalLayer re-renders when group configs change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{ ...baseProps, groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }] } as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("TerminalLayer re-renders when proxy profiles change", () => {
|
||||
assert.equal(
|
||||
terminalLayerAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
proxyProfiles: [{
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "http", host: "proxy.example.com", port: 3128 },
|
||||
createdAt: 1,
|
||||
}],
|
||||
} as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -36,9 +36,10 @@ import {
|
||||
} from '../infrastructure/config/storageKeys';
|
||||
import { buildCacheKey } from '../application/state/sftp/sharedRemoteHostCache';
|
||||
import type { DropEntry } from '../lib/sftpFileUtils';
|
||||
import { GroupConfig, Host, Identity, KnownHost, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
import { GroupConfig, Host, Identity, KnownHost, ProxyProfile, SSHKey, Snippet, TerminalSession, TerminalTheme, Workspace, WorkspaceNode } from '../types';
|
||||
import type { ExecutorContext } from '../infrastructure/ai/cattyAgent/executor';
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from '../domain/groupConfig';
|
||||
import { materializeHostProxyProfile } from '../domain/proxyProfiles';
|
||||
import { DistroAvatar } from './DistroAvatar';
|
||||
import Terminal from './Terminal';
|
||||
import { SftpSidePanel } from './SftpSidePanel';
|
||||
@@ -56,6 +57,7 @@ import { RippleButton } from './ui/ripple';
|
||||
import { ScrollArea } from './ui/scroll-area';
|
||||
import { setupMcpApprovalBridge } from '../infrastructure/ai/shared/approvalGate';
|
||||
import { resolveScriptsSidePanelShortcutIntent } from '../application/state/resolveSnippetsShortcutIntent';
|
||||
import { terminalLayerAreEqual } from './terminalLayerMemo';
|
||||
|
||||
type SidePanelTab = 'sftp' | 'scripts' | 'theme' | 'ai';
|
||||
|
||||
@@ -386,6 +388,7 @@ AIChatPanelsHost.displayName = 'AIChatPanelsHost';
|
||||
interface TerminalLayerProps {
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
snippets: Snippet[];
|
||||
@@ -448,6 +451,7 @@ interface TerminalLayerProps {
|
||||
const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
hosts,
|
||||
groupConfigs,
|
||||
proxyProfiles,
|
||||
keys,
|
||||
identities,
|
||||
snippets,
|
||||
@@ -879,6 +883,22 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
for (const h of hosts) map.set(h.id, h);
|
||||
return map;
|
||||
}, [hosts]);
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
const effectiveHosts = useMemo(
|
||||
() => hosts.map((host) => {
|
||||
const groupDefaults = host.group
|
||||
? resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
|
||||
: {};
|
||||
return materializeHostProxyProfile(
|
||||
applyGroupDefaults(host, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
|
||||
proxyProfiles,
|
||||
);
|
||||
}),
|
||||
[groupConfigs, hosts, proxyProfileIdSet, proxyProfiles],
|
||||
);
|
||||
|
||||
// Pre-compute fallback hosts to avoid creating new objects on every render
|
||||
const sessionHostsMap = useMemo(() => {
|
||||
@@ -888,9 +908,12 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (rawHost) {
|
||||
// Apply group config defaults so Terminal sees the merged host
|
||||
const groupDefaults = rawHost.group
|
||||
? resolveGroupDefaults(rawHost.group, groupConfigs)
|
||||
? resolveGroupDefaults(rawHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
|
||||
: {};
|
||||
const existingHost = applyGroupDefaults(rawHost, groupDefaults);
|
||||
const existingHost = materializeHostProxyProfile(
|
||||
applyGroupDefaults(rawHost, groupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
|
||||
proxyProfiles,
|
||||
);
|
||||
|
||||
const protocol = session.protocol ?? existingHost.protocol;
|
||||
const port = session.port ?? existingHost.port;
|
||||
@@ -932,7 +955,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
}
|
||||
}
|
||||
return map;
|
||||
}, [sessions, hostMap, groupConfigs]);
|
||||
}, [sessions, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
const sessionChainHostsMap = useMemo(() => {
|
||||
const map = new Map<string, Host[]>();
|
||||
for (const session of sessions) {
|
||||
@@ -945,15 +968,18 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
const rawChainHost = hostMap.get(hostId);
|
||||
if (!rawChainHost) return undefined;
|
||||
const chainGroupDefaults = rawChainHost.group
|
||||
? resolveGroupDefaults(rawChainHost.group, groupConfigs)
|
||||
? resolveGroupDefaults(rawChainHost.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet })
|
||||
: {};
|
||||
return applyGroupDefaults(rawChainHost, chainGroupDefaults);
|
||||
return materializeHostProxyProfile(
|
||||
applyGroupDefaults(rawChainHost, chainGroupDefaults, { validProxyProfileIds: proxyProfileIdSet }),
|
||||
proxyProfiles,
|
||||
);
|
||||
})
|
||||
.filter((value): value is Host => Boolean(value)),
|
||||
);
|
||||
}
|
||||
return map;
|
||||
}, [sessions, sessionHostsMap, hostMap, groupConfigs]);
|
||||
}, [sessions, sessionHostsMap, hostMap, groupConfigs, proxyProfileIdSet, proxyProfiles]);
|
||||
|
||||
const validAIScopeTargetIds = useMemo(() => {
|
||||
const ids = new Set<string>();
|
||||
@@ -1282,9 +1308,11 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
if (activeWorkspace && focusedSessionId) {
|
||||
return sessionHostsMap.get(focusedSessionId) ?? sftpHostForTab.get(activeTabId) ?? null;
|
||||
}
|
||||
// For solo session: use stored host (from when SFTP was opened)
|
||||
if (activeSession) {
|
||||
return sessionHostsMap.get(activeSession.id) ?? sftpHostForTab.get(activeTabId) ?? null;
|
||||
}
|
||||
return sftpHostForTab.get(activeTabId) ?? null;
|
||||
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, focusedSessionId, sessionHostsMap, sftpHostForTab]);
|
||||
}, [isSftpOpenForCurrentTab, activeTabId, activeWorkspace, activeSession, focusedSessionId, sessionHostsMap, sftpHostForTab]);
|
||||
|
||||
// Keep sftpHostForTab in sync with focus changes in workspace mode
|
||||
// so that the toggle check uses the currently displayed host.
|
||||
@@ -2338,7 +2366,8 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
return (
|
||||
<SftpSidePanel
|
||||
key={tabId}
|
||||
hosts={hosts}
|
||||
hosts={effectiveHosts}
|
||||
writableHosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
updateHosts={updateHosts}
|
||||
@@ -2653,40 +2682,5 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
// Only re-render when data props change - activeTabId/isVisible are now managed internally via store subscription
|
||||
const terminalLayerAreEqual = (prev: TerminalLayerProps, next: TerminalLayerProps): boolean => {
|
||||
return (
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.snippets === next.snippets &&
|
||||
prev.snippetPackages === next.snippetPackages &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.terminalTheme === next.terminalTheme &&
|
||||
prev.accentMode === next.accentMode &&
|
||||
prev.customAccent === next.customAccent &&
|
||||
prev.terminalSettings === next.terminalSettings &&
|
||||
prev.fontSize === next.fontSize &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onHotkeyAction === next.onHotkeyAction &&
|
||||
prev.onUpdateHost === next.onUpdateHost &&
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
prev.identities === next.identities
|
||||
);
|
||||
};
|
||||
|
||||
export const TerminalLayer = memo(TerminalLayerInner, terminalLayerAreEqual);
|
||||
TerminalLayer.displayName = 'TerminalLayer';
|
||||
|
||||
@@ -11,6 +11,8 @@ import { useSettingsState } from "../application/state/useSettingsState";
|
||||
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
|
||||
import { useActiveTabId } from "../application/state/activeTabStore";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import type { Host } from "../domain/models";
|
||||
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
|
||||
import { AppLogo } from "./AppLogo";
|
||||
|
||||
@@ -117,10 +119,14 @@ const TrayPanelContent: React.FC = () => {
|
||||
onTrayPanelMenuData,
|
||||
} = useTrayPanelBackend();
|
||||
|
||||
const { hosts, keys, identities, groupConfigs } = useVaultState();
|
||||
const { hosts, keys, identities, proxyProfiles, groupConfigs } = useVaultState();
|
||||
useSessionState();
|
||||
const { rules: portForwardingRules, startTunnel, stopTunnel } = usePortForwardingState();
|
||||
const activeTabId = useActiveTabId();
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
|
||||
const [traySessions, setTraySessions] = useState<TraySession[]>([]);
|
||||
|
||||
@@ -335,10 +341,14 @@ const TrayPanelContent: React.FC = () => {
|
||||
if (isActive) {
|
||||
void stopTunnel(rule.id);
|
||||
} else {
|
||||
const host = rawHost.group
|
||||
? applyGroupDefaults(rawHost, resolveGroupDefaults(rawHost.group, groupConfigs))
|
||||
: rawHost;
|
||||
void startTunnel(rule, host, hosts, keys, identities, (status, error) => {
|
||||
const resolveEffectiveHost = (host: Host) => {
|
||||
const withGroupDefaults = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
};
|
||||
const host = resolveEffectiveHost(rawHost);
|
||||
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
|
||||
if (status === "error" && error) toast.error(error);
|
||||
}, rule.autoStart);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ test("VaultView re-renders when an external section navigation request changes",
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
@@ -30,3 +31,42 @@ test("VaultView re-renders when an external section navigation request changes",
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("VaultView re-renders when proxy profiles change", () => {
|
||||
const baseProps = {
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
snippetPackages: [],
|
||||
customGroups: [],
|
||||
knownHosts: [],
|
||||
shellHistory: [],
|
||||
connectionLogs: [],
|
||||
sessions: [],
|
||||
managedSources: [],
|
||||
groupConfigs: {},
|
||||
terminalThemeId: "default",
|
||||
terminalFontSize: 14,
|
||||
navigateToSection: null,
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
vaultViewAreEqual(
|
||||
baseProps as never,
|
||||
{
|
||||
...baseProps,
|
||||
proxyProfiles: [
|
||||
{
|
||||
id: "proxy-1",
|
||||
label: "Proxy",
|
||||
config: { type: "http", host: "proxy.example.com", port: 3128 },
|
||||
createdAt: 1,
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
FileSymlink,
|
||||
FolderPlus,
|
||||
FolderTree,
|
||||
Globe,
|
||||
Key,
|
||||
LayoutGrid,
|
||||
List,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
Identity,
|
||||
KnownHost,
|
||||
ManagedSource,
|
||||
ProxyProfile,
|
||||
SerialConfig,
|
||||
SSHKey,
|
||||
ShellHistoryEntry,
|
||||
@@ -69,6 +71,7 @@ import { HostTreeView } from "./HostTreeView";
|
||||
import KeychainManager from "./KeychainManager";
|
||||
import KnownHostsManager from "./KnownHostsManager";
|
||||
import PortForwarding from "./PortForwardingNew";
|
||||
import ProxyProfilesManager from "./ProxyProfilesManager";
|
||||
import QuickConnectWizard from "./QuickConnectWizard";
|
||||
import { isQuickConnectInput, parseQuickConnectInputWithWarnings } from "../domain/quickConnect";
|
||||
import SerialConnectModal from "./SerialConnectModal";
|
||||
@@ -104,7 +107,7 @@ import { HotkeyScheme, KeyBinding } from "../domain/models";
|
||||
const LazyProtocolSelectDialog = lazy(() => import("./ProtocolSelectDialog"));
|
||||
const LazyConnectionLogsManager = lazy(() => import("./ConnectionLogsManager"));
|
||||
|
||||
export type VaultSection = "hosts" | "keys" | "snippets" | "port" | "knownhosts" | "logs";
|
||||
export type VaultSection = "hosts" | "keys" | "proxies" | "snippets" | "port" | "knownhosts" | "logs";
|
||||
|
||||
type DropTarget =
|
||||
| { kind: "root" }
|
||||
@@ -115,6 +118,7 @@ interface VaultViewProps {
|
||||
hosts: Host[];
|
||||
keys: SSHKey[];
|
||||
identities: Identity[];
|
||||
proxyProfiles: ProxyProfile[];
|
||||
snippets: Snippet[];
|
||||
snippetPackages: string[];
|
||||
customGroups: string[];
|
||||
@@ -136,6 +140,7 @@ interface VaultViewProps {
|
||||
onUpdateHosts: (hosts: Host[]) => void;
|
||||
onUpdateKeys: (keys: SSHKey[]) => void;
|
||||
onUpdateIdentities: (identities: Identity[]) => void;
|
||||
onUpdateProxyProfiles: (profiles: ProxyProfile[]) => void;
|
||||
onUpdateSnippets: (snippets: Snippet[]) => void;
|
||||
onUpdateSnippetPackages: (pkgs: string[]) => void;
|
||||
onUpdateCustomGroups: (groups: string[]) => void;
|
||||
@@ -163,6 +168,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
hosts,
|
||||
keys,
|
||||
identities,
|
||||
proxyProfiles,
|
||||
snippets,
|
||||
snippetPackages,
|
||||
customGroups,
|
||||
@@ -184,6 +190,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
onUpdateHosts,
|
||||
onUpdateKeys,
|
||||
onUpdateIdentities,
|
||||
onUpdateProxyProfiles,
|
||||
onUpdateSnippets,
|
||||
onUpdateSnippetPackages,
|
||||
onUpdateCustomGroups,
|
||||
@@ -296,6 +303,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
if (!group) return undefined;
|
||||
return resolveGroupDefaults(group, groupConfigs);
|
||||
}, [editingHost, newHostGroupPath, selectedGroupPath, groupConfigs]);
|
||||
const proxyProfileIdSet = useMemo(
|
||||
() => new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
[proxyProfiles],
|
||||
);
|
||||
// Quick connect state
|
||||
const [quickConnectTarget, setQuickConnectTarget] = useState<{
|
||||
hostname: string;
|
||||
@@ -343,8 +354,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// Check if host has multiple protocols enabled (using effective/resolved host)
|
||||
const hasMultipleProtocols = useCallback((host: Host) => {
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
let count = 0;
|
||||
// SSH is always available as base protocol (unless explicitly set to something else)
|
||||
if (effective.protocol === "ssh" || !effective.protocol) count++;
|
||||
@@ -355,7 +366,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
// If protocol is explicitly telnet (not ssh), count it
|
||||
if (effective.protocol === "telnet" && !effective.telnetEnabled) count++;
|
||||
return count > 1;
|
||||
}, [groupConfigs]);
|
||||
}, [groupConfigs, proxyProfileIdSet]);
|
||||
|
||||
// Handle host connect with protocol selection
|
||||
const handleHostConnect = useCallback(
|
||||
@@ -363,14 +374,14 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
if (hasMultipleProtocols(host)) {
|
||||
// Pass effective host to protocol dialog so it shows correct ports/protocols
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
setProtocolSelectHost(effective);
|
||||
} else {
|
||||
onConnect(host);
|
||||
}
|
||||
},
|
||||
[hasMultipleProtocols, onConnect, groupConfigs],
|
||||
[hasMultipleProtocols, onConnect, groupConfigs, proxyProfileIdSet],
|
||||
);
|
||||
|
||||
// Handle protocol selection
|
||||
@@ -475,8 +486,8 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
const handleCopyCredentials = useCallback((host: Host) => {
|
||||
// Apply group defaults so inherited credentials are included
|
||||
const effective = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs))
|
||||
: host;
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
|
||||
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
|
||||
// Only use telnet-specific port and credentials when protocol is explicitly telnet
|
||||
// Don't treat telnetEnabled as primary - that's just an optional protocol
|
||||
const isTelnet = effective.protocol === "telnet";
|
||||
@@ -519,7 +530,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
navigator.clipboard.writeText(text).then(() => {
|
||||
toast.success(t('vault.hosts.copyCredentials.toast.success'));
|
||||
});
|
||||
}, [identities, groupConfigs, t]);
|
||||
}, [identities, groupConfigs, proxyProfileIdSet, t]);
|
||||
|
||||
const [lastPinnedId, setLastPinnedId] = useState<string | null>(null);
|
||||
const toggleHostPinned = useCallback((hostId: string) => {
|
||||
@@ -1669,6 +1680,26 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.keychain")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
variant={currentSection === "proxies" ? "secondary" : "ghost"}
|
||||
className={cn(
|
||||
"w-full h-10",
|
||||
sidebarCollapsed ? "justify-center p-0" : "justify-start gap-3",
|
||||
currentSection === "proxies" &&
|
||||
"bg-foreground/10 text-foreground hover:bg-foreground/15 border-border/40",
|
||||
)}
|
||||
onClick={() => {
|
||||
setCurrentSection("proxies");
|
||||
}}
|
||||
>
|
||||
<Globe size={16} className="flex-shrink-0" />
|
||||
{!sidebarCollapsed && t("vault.nav.proxies")}
|
||||
</RippleButton>
|
||||
</TooltipTrigger>
|
||||
{sidebarCollapsed && <TooltipContent side="right">{t("vault.nav.proxies")}</TooltipContent>}
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<RippleButton
|
||||
@@ -2826,6 +2857,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
onRunSnippet={onRunSnippet}
|
||||
availableKeys={keys}
|
||||
proxyProfiles={proxyProfiles}
|
||||
managedSources={managedSources}
|
||||
onSaveHost={(host) => onUpdateHosts([...hosts, host])}
|
||||
onCreateGroup={(groupPath) =>
|
||||
@@ -2840,6 +2872,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
hosts={hosts}
|
||||
proxyProfiles={proxyProfiles}
|
||||
customGroups={customGroups}
|
||||
managedSources={managedSources}
|
||||
onSave={(k) => onUpdateKeys([...keys, k])}
|
||||
@@ -2877,11 +2910,22 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{currentSection === "proxies" && (
|
||||
<ProxyProfilesManager
|
||||
proxyProfiles={proxyProfiles}
|
||||
hosts={hosts}
|
||||
groupConfigs={groupConfigs}
|
||||
onUpdateProxyProfiles={onUpdateProxyProfiles}
|
||||
onUpdateHosts={onUpdateHosts}
|
||||
onUpdateGroupConfigs={onUpdateGroupConfigs}
|
||||
/>
|
||||
)}
|
||||
{currentSection === "port" && (
|
||||
<PortForwarding
|
||||
hosts={hosts}
|
||||
keys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
customGroups={customGroups}
|
||||
managedSources={managedSources}
|
||||
groupConfigs={groupConfigs}
|
||||
@@ -2924,6 +2968,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
config={groupConfigs.find(c => c.path === editingGroupPath)}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
allHosts={hosts}
|
||||
groups={allGroupPaths}
|
||||
terminalThemeId={terminalThemeId}
|
||||
@@ -2944,6 +2989,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
initialData={editingHost}
|
||||
availableKeys={keys}
|
||||
identities={identities}
|
||||
proxyProfiles={proxyProfiles}
|
||||
groups={allGroupPaths}
|
||||
managedSources={managedSources}
|
||||
allTags={allTags}
|
||||
@@ -3207,6 +3253,7 @@ export const vaultViewAreEqual = (
|
||||
prev.hosts === next.hosts &&
|
||||
prev.keys === next.keys &&
|
||||
prev.identities === next.identities &&
|
||||
prev.proxyProfiles === next.proxyProfiles &&
|
||||
prev.snippets === next.snippets &&
|
||||
prev.snippetPackages === next.snippetPackages &&
|
||||
prev.customGroups === next.customGroups &&
|
||||
|
||||
@@ -2,20 +2,25 @@
|
||||
* Proxy Configuration Sub-Panel
|
||||
* Panel for configuring HTTP/SOCKS5 proxy settings
|
||||
*/
|
||||
import { Check,Trash2 } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { Check, Globe, KeyRound, Trash2 } from 'lucide-react';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { isValidProxyPort } from '../../domain/proxyProfiles';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { ProxyConfig } from '../../types';
|
||||
import { AsidePanel,AsidePanelContent,type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { ProxyConfig, ProxyProfile } from '../../types';
|
||||
import { AsidePanel, AsidePanelContent, type AsidePanelLayout } from '../ui/aside-panel';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Button } from '../ui/button';
|
||||
import { Card } from '../ui/card';
|
||||
import { Input } from '../ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
|
||||
export interface ProxyPanelProps {
|
||||
proxyConfig?: ProxyConfig;
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
selectedProxyProfileId?: string;
|
||||
onUpdateProxy: (field: keyof ProxyConfig, value: string | number) => void;
|
||||
onSelectProxyProfile?: (profileId: string | undefined) => void;
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -24,97 +29,180 @@ export interface ProxyPanelProps {
|
||||
|
||||
export const ProxyPanel: React.FC<ProxyPanelProps> = ({
|
||||
proxyConfig,
|
||||
proxyProfiles = [],
|
||||
selectedProxyProfileId,
|
||||
onUpdateProxy,
|
||||
onSelectProxyProfile,
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
layout = 'overlay',
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const customValue = '__custom__';
|
||||
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 hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
|
||||
const hasInvalidManualProxyPort = hasManualProxyHost && !isValidProxyPort(proxyConfig?.port);
|
||||
const canSave = isUsingProfile || (hasManualProxyHost && !hasInvalidManualProxyPort);
|
||||
const handleBack = useCallback(() => {
|
||||
if (hasInvalidManualProxyPort) return;
|
||||
onBack();
|
||||
}, [hasInvalidManualProxyPort, onBack]);
|
||||
|
||||
return (
|
||||
<AsidePanel
|
||||
open={true}
|
||||
onClose={onCancel}
|
||||
title={t('hostDetails.proxyPanel.title')}
|
||||
showBackButton={true}
|
||||
onBack={onBack}
|
||||
onBack={handleBack}
|
||||
layout={layout}
|
||||
actions={
|
||||
<Button size="sm" onClick={onBack} disabled={!proxyConfig?.host}>
|
||||
<Button size="sm" onClick={handleBack} disabled={!canSave}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold">{t('field.type')}</p>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={proxyConfig?.type === 'http' ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", proxyConfig?.type === 'http' && "bg-primary/15")}
|
||||
onClick={() => onUpdateProxy('type', 'http')}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'http' && "opacity-0")} />
|
||||
HTTP
|
||||
</Button>
|
||||
<Button
|
||||
variant={proxyConfig?.type === 'socks5' ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", proxyConfig?.type === 'socks5' && "bg-primary/15")}
|
||||
onClick={() => onUpdateProxy('type', 'socks5')}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'socks5' && "opacity-0")} />
|
||||
SOCKS5
|
||||
</Button>
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile) && 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" />
|
||||
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.savedProxy')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('hostDetails.proxyPanel.savedProxy')}
|
||||
className="h-10"
|
||||
>
|
||||
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
|
||||
{proxyProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
{profile.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{hasMissingProfile && (
|
||||
<div className="min-w-0 rounded-md border border-destructive/30 bg-destructive/10 p-2 text-sm text-destructive">
|
||||
{t('hostDetails.proxyPanel.missingSaved')}
|
||||
</div>
|
||||
)}
|
||||
{selectedProfile && (
|
||||
<div className="min-w-0 rounded-md bg-secondary/50 p-2 text-sm">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{selectedProfile.config.type.toUpperCase()}
|
||||
</Badge>
|
||||
<span className="truncate">
|
||||
{selectedProfile.config.host}:{selectedProfile.config.port}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
|
||||
value={proxyConfig?.host || ""}
|
||||
onChange={(e) => onUpdateProxy('host', e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
|
||||
{!isUsingProfile && (
|
||||
<>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t('field.type')}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={proxyConfig?.type === 'http' ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", proxyConfig?.type === 'http' && "bg-primary/15")}
|
||||
onClick={() => onUpdateProxy('type', 'http')}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'http' && "opacity-0")} />
|
||||
HTTP
|
||||
</Button>
|
||||
<Button
|
||||
variant={proxyConfig?.type === 'socks5' ? "secondary" : "ghost"}
|
||||
size="sm"
|
||||
className={cn("h-8", proxyConfig?.type === 'socks5' && "bg-primary/15")}
|
||||
onClick={() => onUpdateProxy('type', 'socks5')}
|
||||
>
|
||||
<Check size={14} className={cn("mr-1", proxyConfig?.type !== 'socks5' && "opacity-0")} />
|
||||
SOCKS5
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.hostPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.hostPlaceholder')}
|
||||
value={proxyConfig?.host || ""}
|
||||
onChange={(e) => onUpdateProxy('host', e.target.value)}
|
||||
className="h-10 flex-1"
|
||||
/>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-muted-foreground">{t('hostDetails.port')}</span>
|
||||
<Input
|
||||
aria-label={t('hostDetails.port')}
|
||||
type="number"
|
||||
placeholder="3128"
|
||||
min={1}
|
||||
max={65535}
|
||||
step={1}
|
||||
value={proxyConfig?.port || ""}
|
||||
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
|
||||
className="h-10 w-20 text-center"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{hasInvalidManualProxyPort && (
|
||||
<p className="text-xs text-destructive">
|
||||
{t('proxyProfiles.error.port')}
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-muted-foreground" />
|
||||
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.credentials')}</p>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
|
||||
</div>
|
||||
<Input
|
||||
type="number"
|
||||
placeholder="3128"
|
||||
value={proxyConfig?.port || ""}
|
||||
onChange={(e) => onUpdateProxy('port', parseInt(e.target.value) || 0)}
|
||||
className="h-10 w-20 text-center"
|
||||
aria-label={t('hostDetails.proxyPanel.usernamePlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
|
||||
value={proxyConfig?.username || ""}
|
||||
onChange={(e) => onUpdateProxy('username', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Input
|
||||
aria-label={t('hostDetails.proxyPanel.passwordPlaceholder')}
|
||||
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
|
||||
type="password"
|
||||
value={proxyConfig?.password || ""}
|
||||
onChange={(e) => onUpdateProxy('password', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-semibold">{t('hostDetails.proxyPanel.credentials')}</p>
|
||||
<Badge variant="secondary" className="text-xs">{t('common.optional')}</Badge>
|
||||
</div>
|
||||
<Input
|
||||
placeholder={t('hostDetails.proxyPanel.usernamePlaceholder')}
|
||||
value={proxyConfig?.username || ""}
|
||||
onChange={(e) => onUpdateProxy('username', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('hostDetails.proxyPanel.passwordPlaceholder')}
|
||||
type="password"
|
||||
value={proxyConfig?.password || ""}
|
||||
onChange={(e) => onUpdateProxy('password', e.target.value)}
|
||||
className="h-10"
|
||||
/>
|
||||
<Button variant="ghost" size="sm" className="text-primary" onClick={() => { }}>
|
||||
{t('hostDetails.proxyPanel.identities')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
{proxyConfig?.host && (
|
||||
{(proxyConfig?.host || selectedProxyProfileId) && (
|
||||
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
|
||||
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
|
||||
</Button>
|
||||
|
||||
@@ -19,7 +19,7 @@ import { SettingsTabContent } from "../settings-ui";
|
||||
export default function SettingsSyncTab(props: {
|
||||
vault: SyncableVaultData;
|
||||
portForwardingRules: PortForwardingRule[];
|
||||
importDataFromString: (data: string) => void;
|
||||
importDataFromString: (data: string) => void | Promise<void>;
|
||||
importPortForwardingRules: (rules: PortForwardingRule[]) => void;
|
||||
clearVaultData: () => void;
|
||||
onSettingsApplied?: () => void;
|
||||
|
||||
@@ -108,6 +108,8 @@ export const useIsPaneActive = (side: "left" | "right", paneId: string): boolean
|
||||
export interface SftpContextValue {
|
||||
// Hosts list for connection picker
|
||||
hosts: Host[];
|
||||
// Raw hosts list for bookmark persistence and other host writes.
|
||||
writableHosts: Host[];
|
||||
// Host updater for bookmark persistence
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
|
||||
@@ -159,6 +161,12 @@ export const useSftpHosts = () => {
|
||||
return context.hosts;
|
||||
};
|
||||
|
||||
// Hook to get raw hosts for writeback
|
||||
export const useSftpWritableHosts = () => {
|
||||
const context = useSftpContext();
|
||||
return context.writableHosts;
|
||||
};
|
||||
|
||||
// Hook to get host updater
|
||||
export const useSftpUpdateHosts = () => {
|
||||
const context = useSftpContext();
|
||||
@@ -167,6 +175,7 @@ export const useSftpUpdateHosts = () => {
|
||||
|
||||
interface SftpContextProviderProps {
|
||||
hosts: Host[];
|
||||
writableHosts?: Host[];
|
||||
updateHosts: (hosts: Host[]) => void;
|
||||
draggedFiles: (SftpTransferSource & { side: "left" | "right" })[] | null;
|
||||
dragCallbacks: SftpDragCallbacks;
|
||||
@@ -177,6 +186,7 @@ interface SftpContextProviderProps {
|
||||
|
||||
export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
hosts,
|
||||
writableHosts,
|
||||
updateHosts,
|
||||
draggedFiles,
|
||||
dragCallbacks,
|
||||
@@ -188,11 +198,12 @@ export const SftpContextProvider: React.FC<SftpContextProviderProps> = ({
|
||||
const value = useMemo<SftpContextValue>(
|
||||
() => ({
|
||||
hosts,
|
||||
writableHosts: writableHosts ?? hosts,
|
||||
updateHosts,
|
||||
leftCallbacks,
|
||||
rightCallbacks,
|
||||
}),
|
||||
[hosts, updateHosts, leftCallbacks, rightCallbacks],
|
||||
[hosts, writableHosts, updateHosts, leftCallbacks, rightCallbacks],
|
||||
);
|
||||
|
||||
// Memoize drag context separately so only drag consumers re-render on drag state changes
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useSftpHosts,
|
||||
useSftpPaneCallbacks,
|
||||
useSftpUpdateHosts,
|
||||
useSftpWritableHosts,
|
||||
} from "./index";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import { joinPath } from "../../application/state/sftp/utils";
|
||||
@@ -96,6 +97,7 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
const callbacks = useSftpPaneCallbacks(side);
|
||||
const { draggedFiles, onDragStart, onDragEnd } = useSftpDrag();
|
||||
const hosts = useSftpHosts();
|
||||
const writableHosts = useSftpWritableHosts();
|
||||
|
||||
const { t } = useI18n();
|
||||
const hostId = pane.connection?.hostId;
|
||||
@@ -141,12 +143,12 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
// Bookmark support
|
||||
const updateHosts = useSftpUpdateHosts();
|
||||
const currentHost = useMemo(
|
||||
() => hosts.find((h) => h.id === pane.connection?.hostId),
|
||||
[hosts, pane.connection?.hostId],
|
||||
() => writableHosts.find((h) => h.id === pane.connection?.hostId),
|
||||
[writableHosts, pane.connection?.hostId],
|
||||
);
|
||||
const onUpdateHost = useCallback(
|
||||
(updated: Host) => updateHosts(hosts.map((h) => (h.id === updated.id ? updated : h))),
|
||||
[hosts, updateHosts],
|
||||
(updated: Host) => updateHosts(writableHosts.map((h) => (h.id === updated.id ? updated : h))),
|
||||
[updateHosts, writableHosts],
|
||||
);
|
||||
const remoteBookmarks = useSftpBookmarks({
|
||||
host: currentHost,
|
||||
|
||||
@@ -18,6 +18,7 @@ export {
|
||||
useSftpPaneCallbacks,
|
||||
useSftpDrag,
|
||||
useSftpHosts,
|
||||
useSftpWritableHosts,
|
||||
useSftpUpdateHosts,
|
||||
useActiveTabId,
|
||||
useIsPaneActive,
|
||||
|
||||
@@ -11,7 +11,7 @@ export const useTerminalAuthState = ({
|
||||
pendingAuthRef,
|
||||
termRef,
|
||||
onUpdateHost,
|
||||
onStartSsh,
|
||||
onStartSession,
|
||||
setStatus,
|
||||
setProgressLogs,
|
||||
}: {
|
||||
@@ -19,7 +19,7 @@ export const useTerminalAuthState = ({
|
||||
pendingAuthRef: RefObject<PendingAuth>;
|
||||
termRef: RefObject<XTerm | null>;
|
||||
onUpdateHost?: (host: Host) => void;
|
||||
onStartSsh: (term: XTerm) => void;
|
||||
onStartSession: (term: XTerm) => void;
|
||||
setStatus: (status: TerminalSession["status"]) => void;
|
||||
setProgressLogs: (next: string[] | ((prev: string[]) => string[])) => void;
|
||||
}) => {
|
||||
@@ -106,7 +106,7 @@ export const useTerminalAuthState = ({
|
||||
logger.warn("Failed to clear terminal", err);
|
||||
}
|
||||
|
||||
onStartSsh(term);
|
||||
onStartSession(term);
|
||||
},
|
||||
[
|
||||
authKeyId,
|
||||
@@ -116,7 +116,7 @@ export const useTerminalAuthState = ({
|
||||
authUsername,
|
||||
host,
|
||||
isValid,
|
||||
onStartSsh,
|
||||
onStartSession,
|
||||
onUpdateHost,
|
||||
pendingAuthRef,
|
||||
saveCredentials,
|
||||
|
||||
@@ -1,10 +1,26 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createTerminalSessionStarters } from "./createTerminalSessionStarters";
|
||||
import { createTerminalSessionStarters, getMissingChainHostIds } from "./createTerminalSessionStarters";
|
||||
|
||||
const noop = () => undefined;
|
||||
|
||||
test("getMissingChainHostIds reports unresolved jump hosts", () => {
|
||||
assert.deepEqual(
|
||||
getMissingChainHostIds(
|
||||
{
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
hostChain: { hostIds: ["jump-1", "jump-2"] },
|
||||
} as never,
|
||||
[{ id: "jump-1" }] as never,
|
||||
),
|
||||
["jump-2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("startMosh does not pass legacy configured mosh client paths to the backend", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
|
||||
@@ -155,3 +171,617 @@ test("startMosh passes the saved password to the mosh backend", async () => {
|
||||
assert.equal(capturedOptions.username, "alice");
|
||||
assert.equal(capturedOptions.password, "saved-secret");
|
||||
});
|
||||
|
||||
test("startMosh passes configured key material to the mosh backend", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async (options: Record<string, unknown>) => {
|
||||
capturedOptions = options;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
password: "wrong-password",
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
identityFilePaths: ["/should/not/be/used"],
|
||||
port: 2200,
|
||||
},
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
label: "Deploy key",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
passphrase: "key-passphrase",
|
||||
}],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.ok(capturedOptions);
|
||||
assert.equal(capturedOptions.password, "wrong-password");
|
||||
assert.equal(capturedOptions.privateKey, "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----");
|
||||
assert.equal(capturedOptions.keyId, "key-1");
|
||||
assert.equal(capturedOptions.passphrase, "key-passphrase");
|
||||
assert.equal(capturedOptions.identityFilePaths, undefined);
|
||||
});
|
||||
|
||||
test("startMosh asks for credential re-entry when saved key material cannot be decrypted", async () => {
|
||||
let started = false;
|
||||
let needsAuth = false;
|
||||
let retryMessage: string | null = null;
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => {
|
||||
started = true;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
authMethod: "key",
|
||||
identityFileId: "key-1",
|
||||
port: 2200,
|
||||
},
|
||||
keys: [{
|
||||
id: "key-1",
|
||||
label: "Deploy key",
|
||||
privateKey: "enc:v1:djEwAAAA",
|
||||
}],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: (value: boolean) => { needsAuth = value; },
|
||||
setAuthRetryMessage: (message: string | null) => { retryMessage = message; },
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.equal(needsAuth, true);
|
||||
assert.match(retryMessage || "", /Saved credentials cannot be decrypted/);
|
||||
});
|
||||
|
||||
test("startMosh omits identity file paths when password auth is explicit", async () => {
|
||||
let capturedOptions: Record<string, unknown> | null = null;
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async (options: Record<string, unknown>) => {
|
||||
capturedOptions = options;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
authMethod: "password",
|
||||
password: "saved-secret",
|
||||
identityFilePaths: ["/should/not/be/used"],
|
||||
port: 2200,
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: noop,
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.ok(capturedOptions);
|
||||
assert.equal(capturedOptions.password, "saved-secret");
|
||||
assert.equal(capturedOptions.identityFilePaths, undefined);
|
||||
});
|
||||
|
||||
test("startMosh rejects missing saved proxy profiles", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => {
|
||||
started = true;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
port: 2200,
|
||||
proxyProfileId: "missing-proxy",
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: (message: string) => { error = message; },
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Saved proxy/);
|
||||
});
|
||||
|
||||
test("startMosh rejects configured proxies instead of connecting directly", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => {
|
||||
started = true;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
port: 2200,
|
||||
proxyProfileId: "proxy-1",
|
||||
proxyConfig: { type: "http", host: "proxy.example.com", port: 3128 },
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: (message: string) => { error = message; },
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Mosh does not support proxy/);
|
||||
});
|
||||
|
||||
test("startMosh rejects jump host chains instead of connecting directly", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => "telnet-session",
|
||||
startMoshSession: async () => {
|
||||
started = true;
|
||||
return "mosh-session";
|
||||
},
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
hostChain: { hostIds: ["jump-1"] },
|
||||
port: 2200,
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [{ id: "jump-1", hostname: "jump.example.test" }],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: (message: string) => { error = message; },
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startMosh(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Mosh does not support jump host chains/);
|
||||
});
|
||||
|
||||
test("startTelnet rejects missing saved proxy profiles", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => {
|
||||
started = true;
|
||||
return "telnet-session";
|
||||
},
|
||||
startMoshSession: async () => "mosh-session",
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
telnetPort: 2323,
|
||||
proxyProfileId: "missing-proxy",
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: (message: string) => { error = message; },
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startTelnet(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Saved proxy/);
|
||||
});
|
||||
|
||||
test("startTelnet rejects configured proxies instead of connecting directly", async () => {
|
||||
let started = false;
|
||||
let error = "";
|
||||
|
||||
const terminalBackend = {
|
||||
backendAvailable: () => true,
|
||||
telnetAvailable: () => true,
|
||||
moshAvailable: () => true,
|
||||
localAvailable: () => true,
|
||||
serialAvailable: () => true,
|
||||
execAvailable: () => true,
|
||||
startSSHSession: async () => "ssh-session",
|
||||
startTelnetSession: async () => {
|
||||
started = true;
|
||||
return "telnet-session";
|
||||
},
|
||||
startMoshSession: async () => "mosh-session",
|
||||
startLocalSession: async () => "local-session",
|
||||
startSerialSession: async () => "serial-session",
|
||||
execCommand: async () => ({}),
|
||||
onSessionData: () => noop,
|
||||
onSessionExit: () => noop,
|
||||
onChainProgress: () => noop,
|
||||
writeToSession: noop,
|
||||
resizeSession: noop,
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
host: {
|
||||
id: "host-1",
|
||||
label: "Example",
|
||||
hostname: "example.test",
|
||||
username: "alice",
|
||||
telnetPort: 2323,
|
||||
proxyProfileId: "proxy-1",
|
||||
proxyConfig: { type: "http", host: "proxy.example.com", port: 3128 },
|
||||
},
|
||||
keys: [],
|
||||
resolvedChainHosts: [],
|
||||
sessionId: "session-1",
|
||||
terminalSettings: {},
|
||||
terminalBackend,
|
||||
sessionRef: { current: null },
|
||||
hasConnectedRef: { current: false },
|
||||
hasRunStartupCommandRef: { current: false },
|
||||
disposeDataRef: { current: null },
|
||||
disposeExitRef: { current: null },
|
||||
fitAddonRef: { current: null },
|
||||
serializeAddonRef: { current: null },
|
||||
pendingAuthRef: { current: null },
|
||||
updateStatus: noop,
|
||||
setStatus: noop,
|
||||
setError: (message: string) => { error = message; },
|
||||
setNeedsAuth: noop,
|
||||
setAuthRetryMessage: noop,
|
||||
setAuthPassword: noop,
|
||||
setProgressLogs: noop,
|
||||
setProgressValue: noop,
|
||||
setChainProgress: noop,
|
||||
};
|
||||
|
||||
const term = {
|
||||
cols: 120,
|
||||
rows: 32,
|
||||
write: noop,
|
||||
writeln: noop,
|
||||
scrollToBottom: noop,
|
||||
};
|
||||
|
||||
await createTerminalSessionStarters(ctx as never).startTelnet(term as never);
|
||||
|
||||
assert.equal(started, false);
|
||||
assert.match(error, /Telnet does not support proxy/);
|
||||
});
|
||||
|
||||
@@ -143,6 +143,16 @@ export type TerminalSessionStartersContext = {
|
||||
) => void;
|
||||
};
|
||||
|
||||
export const getMissingChainHostIds = (
|
||||
host: Host,
|
||||
resolvedChainHosts: Host[],
|
||||
): string[] => {
|
||||
const requestedIds = host.hostChain?.hostIds ?? [];
|
||||
if (requestedIds.length === 0) return [];
|
||||
const resolvedIds = new Set(resolvedChainHosts.map((chainHost) => chainHost.id));
|
||||
return requestedIds.filter((hostId) => !resolvedIds.has(hostId));
|
||||
};
|
||||
|
||||
const buildTermEnv = (host: Host, terminalSettings?: TerminalSettings) => {
|
||||
const env: Record<string, string> = {
|
||||
TERM: terminalSettings?.terminalEmulationType ?? "xterm-256color",
|
||||
@@ -337,6 +347,24 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
return;
|
||||
}
|
||||
|
||||
const missingChainHostIds = getMissingChainHostIds(ctx.host, ctx.resolvedChainHosts);
|
||||
if (missingChainHostIds.length > 0) {
|
||||
const base = tr(
|
||||
"terminal.auth.jumpHostMissing",
|
||||
"A configured jump host is missing. Open host settings and repair the jump host chain.",
|
||||
);
|
||||
const suffix = missingChainHostIds.length > 2
|
||||
? ` +${missingChainHostIds.length - 2}`
|
||||
: "";
|
||||
const message = `${base} (${missingChainHostIds.slice(0, 2).join(", ")}${suffix})`;
|
||||
ctx.setNeedsAuth(false);
|
||||
ctx.setAuthRetryMessage(null);
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingAuth = ctx.pendingAuthRef.current;
|
||||
const resolvedAuth = resolveHostAuth({
|
||||
host: ctx.host,
|
||||
@@ -372,6 +400,13 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
};
|
||||
|
||||
const rawProxyPassword = ctx.host.proxyConfig?.password;
|
||||
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
|
||||
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
const hasEncryptedProxyPassword = isEncryptedCredentialPlaceholder(rawProxyPassword);
|
||||
const proxyConfig = ctx.host.proxyConfig
|
||||
? {
|
||||
@@ -384,6 +419,14 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
: undefined;
|
||||
|
||||
const jumpHostsWithUnavailableCredentials: string[] = [];
|
||||
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
|
||||
if (unresolvedJumpProxyHost) {
|
||||
const message = `Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`;
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost, index) => {
|
||||
const jumpAuth = resolveHostAuth({
|
||||
host: jumpHost,
|
||||
@@ -720,6 +763,22 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
|
||||
const message = `Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`;
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
if (ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) {
|
||||
const message = "Telnet does not support proxy connections. Use SSH for this host or remove the proxy from this connection.";
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const telnetEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
|
||||
const id = await ctx.terminalBackend.startTelnetSession({
|
||||
@@ -754,6 +813,39 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
}
|
||||
|
||||
try {
|
||||
const stopMosh = (message: string) => {
|
||||
ctx.setError(message);
|
||||
term.writeln(`\r\n[${message}]`);
|
||||
ctx.updateStatus("disconnected");
|
||||
};
|
||||
|
||||
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
|
||||
stopMosh(`Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasConfiguredJumpHostChain =
|
||||
(ctx.host.hostChain?.hostIds?.length || 0) > 0 ||
|
||||
ctx.resolvedChainHosts.length > 0;
|
||||
if (hasConfiguredJumpHostChain) {
|
||||
stopMosh("Mosh does not support jump host chains. Use SSH for this host or remove the jump hosts from this connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
const unresolvedJumpProxyHost = ctx.resolvedChainHosts.find((jumpHost) => jumpHost.proxyProfileId && !jumpHost.proxyConfig);
|
||||
if (unresolvedJumpProxyHost) {
|
||||
stopMosh(`Saved proxy for jump host "${unresolvedJumpProxyHost.label || unresolvedJumpProxyHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const hasConfiguredProxy =
|
||||
Boolean(ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) ||
|
||||
ctx.resolvedChainHosts.some((jumpHost) => Boolean(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port));
|
||||
if (hasConfiguredProxy) {
|
||||
stopMosh("Mosh does not support proxy connections. Use SSH for this host or remove the proxy from this connection.");
|
||||
return;
|
||||
}
|
||||
|
||||
const pendingAuth = ctx.pendingAuthRef.current;
|
||||
const resolvedAuth = resolveHostAuth({
|
||||
host: ctx.host,
|
||||
@@ -770,12 +862,44 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
: null,
|
||||
});
|
||||
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
|
||||
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
|
||||
const authMethod = resolvedAuth.authMethod;
|
||||
const key = authMethod === "password" ? undefined : resolvedAuth.key;
|
||||
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
|
||||
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(resolvedAuth.key?.privateKey);
|
||||
const hasKeyMaterial = !!sanitizeCredentialValue(key?.privateKey) && authMethod !== "password";
|
||||
const hasPassword = !!effectivePassword;
|
||||
const needsCredentialReentry =
|
||||
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
|
||||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
|
||||
|
||||
if (needsCredentialReentry) {
|
||||
ctx.setError(null);
|
||||
ctx.setNeedsAuth(true);
|
||||
ctx.setAuthRetryMessage(
|
||||
tr(
|
||||
"terminal.auth.credentialsUnavailable",
|
||||
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
|
||||
),
|
||||
);
|
||||
ctx.setAuthPassword("");
|
||||
ctx.setStatus("connecting");
|
||||
return;
|
||||
}
|
||||
|
||||
const moshEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
|
||||
const id = await ctx.terminalBackend.startMoshSession({
|
||||
sessionId: ctx.sessionId,
|
||||
hostname: ctx.host.hostname,
|
||||
username: resolvedAuth.username || "root",
|
||||
password: effectivePassword,
|
||||
privateKey: sanitizeCredentialValue(key?.privateKey),
|
||||
certificate: key?.certificate,
|
||||
keyId: key?.id,
|
||||
passphrase: key
|
||||
? (effectivePassphrase || sanitizeCredentialValue(key.passphrase))
|
||||
: undefined,
|
||||
identityFilePaths: authMethod !== "password" && !key ? ctx.host.identityFilePaths : undefined,
|
||||
port: ctx.host.port || 22,
|
||||
moshServerPath: ctx.host.moshServerPath,
|
||||
agentForwarding: ctx.host.agentForwarding,
|
||||
|
||||
36
components/terminalLayerMemo.ts
Normal file
36
components/terminalLayerMemo.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
export const terminalLayerAreEqual = (
|
||||
prev: Record<string, unknown>,
|
||||
next: Record<string, unknown>,
|
||||
): boolean => (
|
||||
prev.hosts === next.hosts &&
|
||||
prev.groupConfigs === next.groupConfigs &&
|
||||
prev.proxyProfiles === next.proxyProfiles &&
|
||||
prev.keys === next.keys &&
|
||||
prev.snippets === next.snippets &&
|
||||
prev.snippetPackages === next.snippetPackages &&
|
||||
prev.sessions === next.sessions &&
|
||||
prev.workspaces === next.workspaces &&
|
||||
prev.draggingSessionId === next.draggingSessionId &&
|
||||
prev.terminalTheme === next.terminalTheme &&
|
||||
prev.accentMode === next.accentMode &&
|
||||
prev.customAccent === next.customAccent &&
|
||||
prev.terminalSettings === next.terminalSettings &&
|
||||
prev.fontSize === next.fontSize &&
|
||||
prev.hotkeyScheme === next.hotkeyScheme &&
|
||||
prev.keyBindings === next.keyBindings &&
|
||||
prev.sftpDefaultViewMode === next.sftpDefaultViewMode &&
|
||||
prev.sftpDoubleClickBehavior === next.sftpDoubleClickBehavior &&
|
||||
prev.sftpAutoSync === next.sftpAutoSync &&
|
||||
prev.sftpShowHiddenFiles === next.sftpShowHiddenFiles &&
|
||||
prev.sftpUseCompressedUpload === next.sftpUseCompressedUpload &&
|
||||
prev.sftpAutoOpenSidebar === next.sftpAutoOpenSidebar &&
|
||||
prev.editorWordWrap === next.editorWordWrap &&
|
||||
prev.setEditorWordWrap === next.setEditorWordWrap &&
|
||||
prev.onHotkeyAction === next.onHotkeyAction &&
|
||||
prev.onUpdateHost === next.onUpdateHost &&
|
||||
prev.onToggleWorkspaceViewMode === next.onToggleWorkspaceViewMode &&
|
||||
prev.onSetWorkspaceFocusedSession === next.onSetWorkspaceFocusedSession &&
|
||||
prev.onSplitSession === next.onSplitSession &&
|
||||
prev.toggleScriptsSidePanelRef === next.toggleScriptsSidePanelRef &&
|
||||
prev.identities === next.identities
|
||||
);
|
||||
@@ -88,6 +88,12 @@ export const findSyncPayloadEncryptedCredentialPaths = (
|
||||
}
|
||||
});
|
||||
|
||||
payload.proxyProfiles?.forEach((profile, index) => {
|
||||
if (isEncryptedCredentialPlaceholder(profile.config.password)) {
|
||||
issues.push(`proxyProfiles[${index}].config.password`);
|
||||
}
|
||||
});
|
||||
|
||||
payload.groupConfigs?.forEach((config, index) => {
|
||||
if (isEncryptedCredentialPlaceholder(config.password)) {
|
||||
issues.push(`groupConfigs[${index}].password`);
|
||||
|
||||
132
domain/groupConfig.test.ts
Normal file
132
domain/groupConfig.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyGroupDefaults, resolveGroupDefaults } from "./groupConfig.ts";
|
||||
import type { GroupConfig, Host } from "./models.ts";
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("applyGroupDefaults lets a host proxy profile override a group custom proxy", () => {
|
||||
const groupDefaults: Partial<GroupConfig> = {
|
||||
proxyConfig: { type: "http", host: "group-proxy.example.com", port: 3128 },
|
||||
};
|
||||
|
||||
const result = applyGroupDefaults(host({ proxyProfileId: "proxy-1" }), groupDefaults);
|
||||
|
||||
assert.equal(result.proxyProfileId, "proxy-1");
|
||||
assert.equal(result.proxyConfig, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults lets a host custom proxy override a group proxy profile", () => {
|
||||
const groupDefaults: Partial<GroupConfig> = {
|
||||
proxyProfileId: "group-proxy",
|
||||
};
|
||||
const customProxy = { type: "socks5" as const, host: "host-proxy.example.com", port: 1080 };
|
||||
|
||||
const result = applyGroupDefaults(host({ proxyConfig: customProxy }), groupDefaults);
|
||||
|
||||
assert.equal(result.proxyProfileId, undefined);
|
||||
assert.deepEqual(result.proxyConfig, customProxy);
|
||||
});
|
||||
|
||||
test("resolveGroupDefaults treats saved and custom proxies as one inherited setting", () => {
|
||||
const resolved = resolveGroupDefaults("prod/api", [
|
||||
{
|
||||
path: "prod",
|
||||
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
|
||||
},
|
||||
{
|
||||
path: "prod/api",
|
||||
proxyProfileId: "child-proxy",
|
||||
},
|
||||
]);
|
||||
|
||||
assert.equal(resolved.proxyProfileId, "child-proxy");
|
||||
assert.equal(resolved.proxyConfig, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults keeps a missing host proxy profile instead of using group proxy", () => {
|
||||
const groupDefaults: Partial<GroupConfig> = {
|
||||
proxyProfileId: "group-proxy",
|
||||
};
|
||||
|
||||
const result = applyGroupDefaults(
|
||||
host({ proxyProfileId: "missing-proxy" }),
|
||||
groupDefaults,
|
||||
{ validProxyProfileIds: new Set(["group-proxy"]) },
|
||||
);
|
||||
|
||||
assert.equal(result.proxyProfileId, "missing-proxy");
|
||||
assert.equal(result.proxyConfig, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults keeps a missing host proxy profile when no group fallback exists", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host({ proxyProfileId: "missing-proxy" }),
|
||||
{},
|
||||
{ validProxyProfileIds: new Set(["group-proxy"]) },
|
||||
);
|
||||
|
||||
assert.equal(result.proxyProfileId, "missing-proxy");
|
||||
assert.equal(result.proxyConfig, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults keeps a missing host proxy profile instead of using group custom proxy", () => {
|
||||
const groupProxy = { type: "http" as const, host: "group-proxy.example.com", port: 3128 };
|
||||
const result = applyGroupDefaults(
|
||||
host({ proxyProfileId: "missing-proxy" }),
|
||||
{ proxyConfig: groupProxy },
|
||||
{ validProxyProfileIds: new Set(["group-proxy"]) },
|
||||
);
|
||||
|
||||
assert.equal(result.proxyProfileId, "missing-proxy");
|
||||
assert.equal(result.proxyConfig, undefined);
|
||||
});
|
||||
|
||||
test("resolveGroupDefaults keeps a missing group proxy marker when there is no fallback", () => {
|
||||
const resolved = resolveGroupDefaults(
|
||||
"prod",
|
||||
[{ path: "prod", proxyProfileId: "missing-proxy" }],
|
||||
{ validProxyProfileIds: new Set(["group-proxy"]) },
|
||||
);
|
||||
|
||||
assert.equal(resolved.proxyProfileId, "missing-proxy");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults inherits a missing group proxy marker so connect paths can fail", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host({ group: "prod" }),
|
||||
{ proxyProfileId: "missing-proxy" },
|
||||
{ validProxyProfileIds: new Set(["group-proxy"]) },
|
||||
);
|
||||
|
||||
assert.equal(result.proxyProfileId, "missing-proxy");
|
||||
assert.equal(result.proxyConfig, undefined);
|
||||
});
|
||||
|
||||
test("resolveGroupDefaults keeps missing child proxy profiles instead of using parent proxy", () => {
|
||||
const resolved = resolveGroupDefaults(
|
||||
"prod/api",
|
||||
[
|
||||
{
|
||||
path: "prod",
|
||||
proxyConfig: { type: "http", host: "parent-proxy.example.com", port: 3128 },
|
||||
},
|
||||
{
|
||||
path: "prod/api",
|
||||
proxyProfileId: "missing-proxy",
|
||||
},
|
||||
],
|
||||
{ validProxyProfileIds: new Set(["group-proxy"]) },
|
||||
);
|
||||
|
||||
assert.equal(resolved.proxyProfileId, "missing-proxy");
|
||||
assert.equal(resolved.proxyConfig, undefined);
|
||||
});
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { GroupConfig, Host } from './models';
|
||||
|
||||
export interface ApplyGroupDefaultsOptions {
|
||||
validProxyProfileIds?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
const hasUsableProxyProfileId = (
|
||||
proxyProfileId: string | undefined,
|
||||
options?: ApplyGroupDefaultsOptions,
|
||||
): boolean => {
|
||||
if (!proxyProfileId) return false;
|
||||
return !options?.validProxyProfileIds || options.validProxyProfileIds.has(proxyProfileId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve merged group defaults by walking the ancestor chain.
|
||||
* For group "A/B/C", merges configs from A, A/B, A/B/C (child overrides parent).
|
||||
@@ -7,6 +19,7 @@ import type { GroupConfig, Host } from './models';
|
||||
export function resolveGroupDefaults(
|
||||
groupPath: string,
|
||||
groupConfigs: GroupConfig[],
|
||||
options?: ApplyGroupDefaultsOptions,
|
||||
): Partial<GroupConfig> {
|
||||
const configMap = new Map(groupConfigs.map((c) => [c.path, c]));
|
||||
const parts = groupPath.split('/').filter(Boolean);
|
||||
@@ -17,6 +30,14 @@ export function resolveGroupDefaults(
|
||||
const config = configMap.get(ancestorPath);
|
||||
if (config) {
|
||||
for (const [key, value] of Object.entries(config)) {
|
||||
if (
|
||||
key === 'proxyProfileId' &&
|
||||
typeof value === 'string' &&
|
||||
options?.validProxyProfileIds &&
|
||||
!options.validProxyProfileIds.has(value)
|
||||
) {
|
||||
delete merged.proxyConfig;
|
||||
}
|
||||
if (
|
||||
(key === 'theme' && config.themeOverride === false) ||
|
||||
(key === 'fontFamily' && config.fontFamilyOverride === false) ||
|
||||
@@ -26,6 +47,12 @@ export function resolveGroupDefaults(
|
||||
continue;
|
||||
}
|
||||
if (key !== 'path' && value !== undefined) {
|
||||
if (key === 'proxyProfileId') {
|
||||
delete merged.proxyConfig;
|
||||
}
|
||||
if (key === 'proxyConfig') {
|
||||
delete merged.proxyProfileId;
|
||||
}
|
||||
merged[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -48,7 +75,7 @@ export function resolveGroupDefaults(
|
||||
|
||||
const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
|
||||
'username', 'password', 'savePassword', 'authMethod', 'identityId', 'identityFileId', 'identityFilePaths',
|
||||
'port', 'protocol', 'agentForwarding', 'proxyConfig', 'hostChain', 'startupCommand',
|
||||
'port', 'protocol', 'agentForwarding', 'proxyProfileId', 'proxyConfig', 'hostChain', 'startupCommand',
|
||||
'legacyAlgorithms', 'environmentVariables', 'charset', 'moshEnabled', 'moshServerPath',
|
||||
'telnetEnabled', 'telnetPort', 'telnetUsername', 'telnetPassword',
|
||||
'theme', 'themeOverride', 'fontFamily', 'fontFamilyOverride', 'fontSize', 'fontSizeOverride', 'fontWeight', 'fontWeightOverride',
|
||||
@@ -59,10 +86,20 @@ const INHERITABLE_KEYS: (keyof GroupConfig)[] = [
|
||||
* Apply group defaults to a host. Only fills in fields the host doesn't already have.
|
||||
* Returns a new host object — does NOT mutate the original.
|
||||
*/
|
||||
export function applyGroupDefaults(host: Host, groupDefaults: Partial<GroupConfig>): Host {
|
||||
export function applyGroupDefaults(
|
||||
host: Host,
|
||||
groupDefaults: Partial<GroupConfig>,
|
||||
options?: ApplyGroupDefaultsOptions,
|
||||
): Host {
|
||||
const effective = { ...host };
|
||||
const hostHasUsableProxyProfile = hasUsableProxyProfileId(host.proxyProfileId, options);
|
||||
|
||||
for (const key of INHERITABLE_KEYS) {
|
||||
const hostValue = (host as unknown as Record<string, unknown>)[key];
|
||||
if (key === 'proxyProfileId') {
|
||||
if (host.proxyConfig !== undefined || !groupDefaults.proxyProfileId) continue;
|
||||
}
|
||||
if (key === 'proxyConfig' && (host.proxyProfileId !== undefined || hostHasUsableProxyProfile)) continue;
|
||||
const hostValue = (effective as unknown as Record<string, unknown>)[key];
|
||||
const groupValue = (groupDefaults as unknown as Record<string, unknown>)[key];
|
||||
if ((hostValue === undefined || hostValue === '' || hostValue === null) && groupValue !== undefined) {
|
||||
(effective as unknown as Record<string, unknown>)[key] = groupValue;
|
||||
|
||||
@@ -11,6 +11,14 @@ export interface ProxyConfig {
|
||||
password?: string;
|
||||
}
|
||||
|
||||
export interface ProxyProfile {
|
||||
id: string;
|
||||
label: string;
|
||||
config: ProxyConfig;
|
||||
createdAt: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
// Host chain configuration for jump host / bastion connections
|
||||
export interface HostChainConfig {
|
||||
hostIds: string[]; // Array of host IDs in order (first = closest to client)
|
||||
@@ -83,6 +91,7 @@ export interface Host {
|
||||
startupCommand?: string;
|
||||
hostChaining?: string; // Deprecated: use hostChain instead
|
||||
proxy?: string; // Deprecated: use proxyConfig instead
|
||||
proxyProfileId?: string; // Reference to reusable proxy profile
|
||||
proxyConfig?: ProxyConfig; // New structured proxy configuration
|
||||
hostChain?: HostChainConfig; // New structured host chain configuration
|
||||
envVars?: string; // Deprecated: use environmentVariables instead
|
||||
@@ -205,6 +214,7 @@ export interface GroupConfig {
|
||||
port?: number;
|
||||
protocol?: 'ssh' | 'telnet';
|
||||
agentForwarding?: boolean;
|
||||
proxyProfileId?: string;
|
||||
proxyConfig?: ProxyConfig;
|
||||
hostChain?: HostChainConfig;
|
||||
startupCommand?: string;
|
||||
|
||||
91
domain/proxyProfiles.test.ts
Normal file
91
domain/proxyProfiles.test.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import type { Host, ProxyProfile } from "./models.ts";
|
||||
import {
|
||||
isCompleteProxyConfig,
|
||||
normalizeManualProxyConfig,
|
||||
materializeHostProxyProfile,
|
||||
removeProxyProfileReferences,
|
||||
} from "./proxyProfiles.ts";
|
||||
|
||||
const profile = (overrides: Partial<ProxyProfile> = {}): ProxyProfile => ({
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: {
|
||||
type: "socks5",
|
||||
host: "proxy.example.com",
|
||||
port: 1080,
|
||||
username: "alice",
|
||||
password: "secret",
|
||||
},
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const host = (overrides: Partial<Host> = {}): Host => ({
|
||||
id: "host-1",
|
||||
label: "Server",
|
||||
hostname: "server.example.com",
|
||||
username: "root",
|
||||
os: "linux",
|
||||
tags: [],
|
||||
protocol: "ssh",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
test("materializeHostProxyProfile resolves a selected proxy profile", () => {
|
||||
const resolved = materializeHostProxyProfile(
|
||||
host({ proxyProfileId: "proxy-1" }),
|
||||
[profile()],
|
||||
);
|
||||
|
||||
assert.deepEqual(resolved.proxyConfig, profile().config);
|
||||
});
|
||||
|
||||
test("materializeHostProxyProfile keeps explicit custom proxy ahead of profile reference", () => {
|
||||
const customProxy = {
|
||||
type: "http" as const,
|
||||
host: "custom.example.com",
|
||||
port: 3128,
|
||||
};
|
||||
|
||||
const resolved = materializeHostProxyProfile(
|
||||
host({ proxyProfileId: "proxy-1", proxyConfig: customProxy }),
|
||||
[profile()],
|
||||
);
|
||||
|
||||
assert.deepEqual(resolved.proxyConfig, customProxy);
|
||||
});
|
||||
|
||||
test("removeProxyProfileReferences clears hosts and group configs that use a deleted profile", () => {
|
||||
const result = removeProxyProfileReferences("proxy-1", {
|
||||
hosts: [
|
||||
host({ id: "host-1", proxyProfileId: "proxy-1" }),
|
||||
host({ id: "host-2", proxyProfileId: "proxy-2" }),
|
||||
],
|
||||
groupConfigs: [
|
||||
{ path: "prod", proxyProfileId: "proxy-1" },
|
||||
{ path: "dev", proxyProfileId: "proxy-2" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(result.hosts[0].proxyProfileId, undefined);
|
||||
assert.equal(result.hosts[1].proxyProfileId, "proxy-2");
|
||||
assert.equal(result.groupConfigs[0].proxyProfileId, undefined);
|
||||
assert.equal(result.groupConfigs[1].proxyProfileId, "proxy-2");
|
||||
});
|
||||
|
||||
test("normalizeManualProxyConfig clears empty proxy drafts", () => {
|
||||
assert.equal(
|
||||
normalizeManualProxyConfig({ type: "http", host: "", port: 8080 }),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("isCompleteProxyConfig requires host and a valid port", () => {
|
||||
assert.equal(isCompleteProxyConfig({ type: "http", host: "", port: 8080 }), false);
|
||||
assert.equal(isCompleteProxyConfig({ type: "http", host: "proxy.example.com", port: 0 }), false);
|
||||
assert.equal(isCompleteProxyConfig({ type: "http", host: "proxy.example.com", port: 3128 }), true);
|
||||
});
|
||||
77
domain/proxyProfiles.ts
Normal file
77
domain/proxyProfiles.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import type { GroupConfig, Host, ProxyConfig, ProxyProfile } from "./models";
|
||||
|
||||
const cloneProxyConfig = (config: ProxyConfig): ProxyConfig => ({
|
||||
...config,
|
||||
});
|
||||
|
||||
export const isValidProxyPort = (port: unknown): boolean => {
|
||||
const value = Number(port);
|
||||
return Number.isInteger(value) && value >= 1 && value <= 65535;
|
||||
};
|
||||
|
||||
export const isEmptyProxyConfigDraft = (config: ProxyConfig | undefined): boolean => {
|
||||
if (!config) return true;
|
||||
return !config.host.trim() && !config.username?.trim() && !config.password?.trim();
|
||||
};
|
||||
|
||||
export const isCompleteProxyConfig = (config: ProxyConfig | undefined): boolean => {
|
||||
return Boolean(config?.host.trim()) && isValidProxyPort(config?.port);
|
||||
};
|
||||
|
||||
export const normalizeManualProxyConfig = (
|
||||
config: ProxyConfig | undefined,
|
||||
): ProxyConfig | undefined => {
|
||||
if (!config || isEmptyProxyConfigDraft(config)) return undefined;
|
||||
return {
|
||||
...config,
|
||||
host: config.host.trim(),
|
||||
username: config.username?.trim() || undefined,
|
||||
password: config.password || undefined,
|
||||
};
|
||||
};
|
||||
|
||||
export function findProxyProfile(
|
||||
proxyProfileId: string | undefined,
|
||||
proxyProfiles: ProxyProfile[],
|
||||
): ProxyProfile | undefined {
|
||||
if (!proxyProfileId) return undefined;
|
||||
return proxyProfiles.find((profile) => profile.id === proxyProfileId);
|
||||
}
|
||||
|
||||
export function materializeHostProxyProfile<T extends Host>(
|
||||
host: T,
|
||||
proxyProfiles: ProxyProfile[],
|
||||
): T {
|
||||
if (host.proxyConfig || !host.proxyProfileId) return host;
|
||||
const profile = findProxyProfile(host.proxyProfileId, proxyProfiles);
|
||||
if (!profile) return host;
|
||||
return {
|
||||
...host,
|
||||
proxyConfig: cloneProxyConfig(profile.config),
|
||||
};
|
||||
}
|
||||
|
||||
const clearProxyProfileId = <T extends { proxyProfileId?: string }>(
|
||||
item: T,
|
||||
proxyProfileId: string,
|
||||
): T => {
|
||||
if (item.proxyProfileId !== proxyProfileId) return item;
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = item;
|
||||
return rest as T;
|
||||
};
|
||||
|
||||
export function removeProxyProfileReferences(
|
||||
proxyProfileId: string,
|
||||
data: {
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
},
|
||||
): {
|
||||
hosts: Host[];
|
||||
groupConfigs: GroupConfig[];
|
||||
} {
|
||||
return {
|
||||
hosts: data.hosts.map((host) => clearProxyProfileId(host, proxyProfileId)),
|
||||
groupConfigs: data.groupConfigs.map((config) => clearProxyProfileId(config, proxyProfileId)),
|
||||
};
|
||||
}
|
||||
@@ -164,6 +164,7 @@ export interface SyncPayload {
|
||||
hosts: import('./models').Host[];
|
||||
keys: import('./models').SSHKey[];
|
||||
identities?: import('./models').Identity[];
|
||||
proxyProfiles?: import('./models').ProxyProfile[];
|
||||
snippets: import('./models').Snippet[];
|
||||
customGroups: string[];
|
||||
snippetPackages?: string[];
|
||||
|
||||
@@ -156,6 +156,26 @@ test("only non-hosts entity shrinks → reports that entity", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("proxy profile shrink is protected like other synced vault entities", () => {
|
||||
const proxyProfiles = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({
|
||||
id: `proxy-${i}`,
|
||||
label: `Proxy ${i}`,
|
||||
config: { type: "http", host: `proxy-${i}.example.com`, port: 3128 },
|
||||
createdAt: i,
|
||||
}));
|
||||
|
||||
const base = payload({ proxyProfiles: proxyProfiles(10) } as Partial<SyncPayload>);
|
||||
const out = payload({ proxyProfiles: [] } as Partial<SyncPayload>);
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) {
|
||||
assert.equal(result.entityType, "proxyProfiles");
|
||||
assert.equal(result.reason, "large-shrink");
|
||||
}
|
||||
});
|
||||
|
||||
test("knownHosts shrink is ignored because known hosts are local-only", () => {
|
||||
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
|
||||
const base = payload({ knownHosts: kh(12) });
|
||||
|
||||
@@ -9,6 +9,7 @@ export type ShrinkFinding =
|
||||
| 'hosts'
|
||||
| 'keys'
|
||||
| 'identities'
|
||||
| 'proxyProfiles'
|
||||
| 'snippets'
|
||||
| 'customGroups'
|
||||
| 'snippetPackages'
|
||||
@@ -28,6 +29,7 @@ const CHECKED_ENTITIES = [
|
||||
'hosts',
|
||||
'keys',
|
||||
'identities',
|
||||
'proxyProfiles',
|
||||
'snippets',
|
||||
'customGroups',
|
||||
'snippetPackages',
|
||||
|
||||
@@ -26,8 +26,9 @@ const knownHosts = (n: number): SyncPayload["knownHosts"] =>
|
||||
hostname: `host-${i}.example.com`,
|
||||
port: 22,
|
||||
keyType: "ssh-ed25519",
|
||||
fingerprint: `SHA256:${i}`,
|
||||
})) as SyncPayload["knownHosts"];
|
||||
publicKey: `SHA256:${i}`,
|
||||
discoveredAt: 1,
|
||||
}));
|
||||
|
||||
test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
|
||||
const result = mergeSyncPayloads(
|
||||
@@ -38,3 +39,100 @@ test("mergeSyncPayloads does not carry legacy known hosts forward", () => {
|
||||
|
||||
assert.equal("knownHosts" in result.payload, false);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads merges reusable proxy profiles by id", () => {
|
||||
const localProfile = {
|
||||
id: "proxy-local",
|
||||
label: "Local Proxy",
|
||||
config: { type: "http", host: "local.example.com", port: 3128 },
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
};
|
||||
const remoteProfile = {
|
||||
id: "proxy-remote",
|
||||
label: "Remote Proxy",
|
||||
config: { type: "socks5", host: "remote.example.com", port: 1080 },
|
||||
createdAt: 2,
|
||||
updatedAt: 2,
|
||||
};
|
||||
|
||||
const result = mergeSyncPayloads(
|
||||
payload(),
|
||||
payload({ proxyProfiles: [localProfile] } as Partial<SyncPayload>),
|
||||
payload({ proxyProfiles: [remoteProfile] } as Partial<SyncPayload>),
|
||||
);
|
||||
|
||||
assert.deepEqual(result.payload.proxyProfiles?.map((item) => item.id).sort(), [
|
||||
"proxy-local",
|
||||
"proxy-remote",
|
||||
]);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads preserves proxy profiles when remote payload predates them", () => {
|
||||
const proxy = {
|
||||
id: "proxy-1",
|
||||
label: "Office Proxy",
|
||||
config: { type: "http", host: "proxy.example.com", port: 3128 },
|
||||
createdAt: 1,
|
||||
};
|
||||
|
||||
const result = mergeSyncPayloads(
|
||||
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
|
||||
payload({ proxyProfiles: [proxy] } as Partial<SyncPayload>),
|
||||
payload(),
|
||||
);
|
||||
|
||||
assert.deepEqual(result.payload.proxyProfiles, [proxy]);
|
||||
});
|
||||
|
||||
test("mergeSyncPayloads keeps missing proxy references visible to connection guards", () => {
|
||||
const result = mergeSyncPayloads(
|
||||
payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
proxyProfileId: "proxy-1",
|
||||
}],
|
||||
proxyProfiles: [{
|
||||
id: "proxy-1",
|
||||
label: "Old Proxy",
|
||||
config: { type: "http", host: "old.example.com", port: 3128 },
|
||||
createdAt: 1,
|
||||
}],
|
||||
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
|
||||
}),
|
||||
payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
proxyProfileId: "proxy-1",
|
||||
}],
|
||||
proxyProfiles: [],
|
||||
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
|
||||
}),
|
||||
payload({
|
||||
hosts: [{
|
||||
id: "host-1",
|
||||
label: "Host",
|
||||
hostname: "example.com",
|
||||
username: "root",
|
||||
tags: [],
|
||||
os: "linux",
|
||||
proxyProfileId: "proxy-1",
|
||||
}],
|
||||
proxyProfiles: [],
|
||||
groupConfigs: [{ path: "prod", proxyProfileId: "proxy-1" }],
|
||||
}),
|
||||
);
|
||||
|
||||
assert.equal(result.payload.hosts[0]?.proxyProfileId, "proxy-1");
|
||||
assert.equal(result.payload.groupConfigs?.[0]?.proxyProfileId, "proxy-1");
|
||||
});
|
||||
|
||||
@@ -344,6 +344,7 @@ export function mergeSyncPayloads(
|
||||
hosts: [],
|
||||
keys: [],
|
||||
identities: [],
|
||||
proxyProfiles: [],
|
||||
snippets: [],
|
||||
customGroups: [],
|
||||
snippetPackages: [],
|
||||
@@ -363,6 +364,12 @@ export function mergeSyncPayloads(
|
||||
const hosts = mergeEntityArrays(b.hosts ?? [], local.hosts ?? [], remote.hosts ?? []);
|
||||
const keys = mergeEntityArrays(b.keys ?? [], local.keys ?? [], remote.keys ?? []);
|
||||
const identities = mergeEntityArrays(b.identities ?? [], local.identities ?? [], remote.identities ?? []);
|
||||
const baseProxyProfiles = b.proxyProfiles ?? [];
|
||||
const proxyProfiles = mergeEntityArrays(
|
||||
baseProxyProfiles,
|
||||
local.proxyProfiles ?? baseProxyProfiles,
|
||||
remote.proxyProfiles ?? baseProxyProfiles,
|
||||
);
|
||||
const snippets = mergeEntityArrays(b.snippets ?? [], local.snippets ?? [], remote.snippets ?? []);
|
||||
const portForwardingRules = mergeEntityArrays(
|
||||
b.portForwardingRules ?? [],
|
||||
@@ -376,11 +383,16 @@ export function mergeSyncPayloads(
|
||||
(arr ?? []).map(gc => ({ ...gc, id: gc.path }));
|
||||
const unwrapGC = (arr: GCWithId[]): import('./models').GroupConfig[] =>
|
||||
arr.map(({ id: _id, ...rest }) => rest as import('./models').GroupConfig);
|
||||
const groupConfigsResult = mergeEntityArrays(wrapGC(b.groupConfigs), wrapGC(local.groupConfigs), wrapGC(remote.groupConfigs));
|
||||
const baseGroupConfigs = b.groupConfigs ?? [];
|
||||
const groupConfigsResult = mergeEntityArrays(
|
||||
wrapGC(baseGroupConfigs),
|
||||
wrapGC(local.groupConfigs ?? baseGroupConfigs),
|
||||
wrapGC(remote.groupConfigs ?? baseGroupConfigs),
|
||||
);
|
||||
|
||||
// Aggregate stats
|
||||
const entityResults: Pick<EntityMergeResult<unknown>, 'added' | 'deleted' | 'modified' | 'conflicts'>[] =
|
||||
[hosts, keys, identities, snippets, portForwardingRules, groupConfigsResult];
|
||||
[hosts, keys, identities, proxyProfiles, snippets, portForwardingRules, groupConfigsResult];
|
||||
for (const r of entityResults) {
|
||||
summary.added.local += r.added.local;
|
||||
summary.added.remote += r.added.remote;
|
||||
@@ -416,15 +428,18 @@ export function mergeSyncPayloads(
|
||||
});
|
||||
}
|
||||
|
||||
const groupConfigs = unwrapGC(groupConfigsResult.merged);
|
||||
|
||||
const payload: SyncPayload = {
|
||||
hosts: hosts.merged,
|
||||
keys: keys.merged,
|
||||
identities: identities.merged,
|
||||
proxyProfiles: proxyProfiles.merged,
|
||||
snippets: snippets.merged,
|
||||
customGroups,
|
||||
snippetPackages,
|
||||
portForwardingRules: portForwardingRules.merged,
|
||||
groupConfigs: unwrapGC(groupConfigsResult.merged),
|
||||
groupConfigs,
|
||||
settings,
|
||||
syncedAt: Date.now(),
|
||||
};
|
||||
|
||||
@@ -168,6 +168,29 @@ function shouldUseShellForCommand(command) {
|
||||
return normalized.endsWith(".cmd") || normalized.endsWith(".bat");
|
||||
}
|
||||
|
||||
function quoteWindowsShellArg(value) {
|
||||
const arg = String(value ?? "");
|
||||
if (!arg) return "\"\"";
|
||||
return `"${arg.replace(/"/g, '\\"')}"`;
|
||||
}
|
||||
|
||||
function buildWindowsShellCommandLine(command, args) {
|
||||
return [command, ...(args || [])].map(quoteWindowsShellArg).join(" ");
|
||||
}
|
||||
|
||||
function prepareCommandForSpawn(command, args) {
|
||||
const spawnArgs = Array.isArray(args) ? args : [];
|
||||
if (!shouldUseShellForCommand(command)) {
|
||||
return { command, args: spawnArgs, shell: false };
|
||||
}
|
||||
|
||||
return {
|
||||
command: buildWindowsShellCommandLine(command, spawnArgs),
|
||||
args: [],
|
||||
shell: true,
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCliFromPath(command, shellEnv) {
|
||||
// Validate command: only allow valid binary names (alphanumeric, hyphens, underscores, dots)
|
||||
if (!command || !/^[a-zA-Z0-9._-]+$/.test(command)) {
|
||||
@@ -380,6 +403,9 @@ module.exports = {
|
||||
extractFirstNonLocalhostUrl,
|
||||
normalizeCliPathForPlatform,
|
||||
shouldUseShellForCommand,
|
||||
quoteWindowsShellArg,
|
||||
buildWindowsShellCommandLine,
|
||||
prepareCommandForSpawn,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
toUnpackedAsarPath,
|
||||
|
||||
@@ -2,10 +2,12 @@ const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
buildWindowsShellCommandLine,
|
||||
extractTrailingIdlePrompt,
|
||||
getFreshIdlePrompt,
|
||||
isDefaultPowerShellPromptLine,
|
||||
isPlausibleCliVersionOutput,
|
||||
prepareCommandForSpawn,
|
||||
trackSessionIdlePrompt,
|
||||
} = require("./shellUtils.cjs");
|
||||
|
||||
@@ -76,6 +78,30 @@ test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
|
||||
assert.equal(isPlausibleCliVersionOutput("Usage: claude [options]"), false);
|
||||
});
|
||||
|
||||
test("buildWindowsShellCommandLine quotes command paths and args with spaces", () => {
|
||||
assert.equal(
|
||||
buildWindowsShellCommandLine("C:\\Program Files\\Codex\\codex.cmd", ["login", "status"]),
|
||||
"\"C:\\Program Files\\Codex\\codex.cmd\" \"login\" \"status\"",
|
||||
);
|
||||
});
|
||||
|
||||
test("prepareCommandForSpawn wraps Windows cmd shims as a single shell command", () => {
|
||||
const result = prepareCommandForSpawn("C:\\Program Files\\Codex\\codex.cmd", ["--version"]);
|
||||
if (process.platform === "win32") {
|
||||
assert.deepEqual(result, {
|
||||
command: "\"C:\\Program Files\\Codex\\codex.cmd\" \"--version\"",
|
||||
args: [],
|
||||
shell: true,
|
||||
});
|
||||
} else {
|
||||
assert.deepEqual(result, {
|
||||
command: "C:\\Program Files\\Codex\\codex.cmd",
|
||||
args: ["--version"],
|
||||
shell: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("tracks PowerShell idle prompt after SSH output", () => {
|
||||
const session = {};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ const {
|
||||
const {
|
||||
stripAnsi,
|
||||
normalizeCliPathForPlatform,
|
||||
shouldUseShellForCommand,
|
||||
prepareCommandForSpawn,
|
||||
resolveCliFromPath,
|
||||
resolveClaudeAcpBinaryPath,
|
||||
isPlausibleCliVersionOutput,
|
||||
@@ -1392,11 +1392,12 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
async function runCommand(command, args, options) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args || [], {
|
||||
const spawnSpec = prepareCommandForSpawn(command, args || []);
|
||||
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: options?.cwd || undefined,
|
||||
env: options?.env || process.env,
|
||||
shell: shouldUseShellForCommand(command),
|
||||
shell: spawnSpec.shell,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
@@ -2006,10 +2007,11 @@ function registerHandlers(ipcMain) {
|
||||
const shellEnv = await getShellEnv();
|
||||
const codexCliPath = resolveCliFromPath("codex", shellEnv) || "codex";
|
||||
const sessionId = `codex_login_${randomUUID()}`;
|
||||
const child = spawn(codexCliPath, ["login"], {
|
||||
const spawnSpec = prepareCommandForSpawn(codexCliPath, ["login"]);
|
||||
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
env: shellEnv,
|
||||
shell: shouldUseShellForCommand(codexCliPath),
|
||||
shell: spawnSpec.shell,
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require("node:fs");
|
||||
const Module = require("node:module");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { prepareCommandForSpawn } = require("./ai/shellUtils.cjs");
|
||||
|
||||
function createIpcMainStub() {
|
||||
const handlers = new Map();
|
||||
@@ -116,6 +117,10 @@ function loadBridgeWithMocks(options = {}) {
|
||||
? options.normalizeCliPathForPlatform(...args)
|
||||
: args[0],
|
||||
shouldUseShellForCommand: () => false,
|
||||
prepareCommandForSpawn: (...args) =>
|
||||
typeof options.prepareCommandForSpawn === "function"
|
||||
? options.prepareCommandForSpawn(...args)
|
||||
: prepareCommandForSpawn(...args),
|
||||
isPlausibleCliVersionOutput: (value) =>
|
||||
typeof options.isPlausibleCliVersionOutput === "function"
|
||||
? options.isPlausibleCliVersionOutput(value)
|
||||
@@ -532,6 +537,128 @@ test("resolve-cli accepts stored bundled Codex ACP path", async (t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli probes Windows cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty codex resolve "));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const codexPath = path.join(tempDir, "codex.cmd");
|
||||
fs.writeFileSync(
|
||||
codexPath,
|
||||
"@echo off\r\necho codex-cli 1.2.3\r\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
prepareCommandForSpawn,
|
||||
resolveCliFromPath: (command) => (command === "codex" ? codexPath : null),
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "codex", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: codexPath,
|
||||
version: "codex-cli 1.2.3",
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli probes Windows Claude cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude resolve "));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const claudePath = path.join(tempDir, "claude.cmd");
|
||||
fs.writeFileSync(
|
||||
claudePath,
|
||||
"@echo off\r\necho 2.1.123 (Claude Code)\r\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
prepareCommandForSpawn,
|
||||
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: claudePath,
|
||||
version: "2.1.123 (Claude Code)",
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli probes Windows Claude exe paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty claude exe resolve "));
|
||||
t.after(() => {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const claudePath = path.join(tempDir, "claude.exe");
|
||||
fs.copyFileSync(process.execPath, claudePath);
|
||||
|
||||
const { bridge, restore } = loadBridgeWithMocks({
|
||||
prepareCommandForSpawn,
|
||||
resolveCliFromPath: (command) => (command === "claude" ? claudePath : null),
|
||||
});
|
||||
const ipcMain = createIpcMainStub();
|
||||
|
||||
bridge.init({
|
||||
sessions: new Map(),
|
||||
sftpClients: new Map(),
|
||||
electronModule: { app: { getPath: () => process.cwd() } },
|
||||
});
|
||||
bridge.registerHandlers(ipcMain);
|
||||
|
||||
try {
|
||||
const resolveHandler = ipcMain.handlers.get("netcatty:ai:resolve-cli");
|
||||
assert.equal(typeof resolveHandler, "function");
|
||||
|
||||
const result = await resolveHandler({ sender: { id: 1 } }, { command: "claude", customPath: "" });
|
||||
|
||||
assert.deepEqual(result, {
|
||||
path: claudePath,
|
||||
version: process.version,
|
||||
available: true,
|
||||
});
|
||||
} finally {
|
||||
restore();
|
||||
}
|
||||
});
|
||||
|
||||
test("resolve-cli falls back to bundled Codex ACP when a stored path is stale", async (t) => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-acp-stale-"));
|
||||
t.after(() => {
|
||||
|
||||
@@ -124,10 +124,12 @@ function flushBuffer(entry) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderSnapshotContent(entry) {
|
||||
function renderSnapshotContent(entry, { finalize = false } = {}) {
|
||||
if (finalize) entry.renderer.finish();
|
||||
const renderOptions = finalize ? undefined : { includePendingClearedScreen: true };
|
||||
return entry.isHtml
|
||||
? wrapTerminalHtmlContent(entry.renderer.toHtmlContent(), entry.hostLabel, entry.startTime)
|
||||
: entry.renderer.toString();
|
||||
? wrapTerminalHtmlContent(entry.renderer.toHtmlContent(renderOptions), entry.hostLabel, entry.startTime)
|
||||
: entry.renderer.toString(renderOptions);
|
||||
}
|
||||
|
||||
function scheduleSnapshot(entry) {
|
||||
@@ -206,9 +208,9 @@ async function stopStream(sessionId) {
|
||||
await new Promise((resolve) => {
|
||||
entry.writeStream.end(resolve);
|
||||
});
|
||||
} else if (!entry.disabled && entry.snapshotDirty) {
|
||||
} else if (!entry.disabled) {
|
||||
try {
|
||||
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry), "utf8");
|
||||
await fs.promises.writeFile(entry.filePath, renderSnapshotContent(entry, { finalize: true }), "utf8");
|
||||
entry.snapshotDirty = false;
|
||||
} catch (err) {
|
||||
console.error(`[SessionLogStream] Final snapshot write failed for ${sessionId}:`, err.message);
|
||||
|
||||
83
electron/bridges/sessionLogStreamManager.test.cjs
Normal file
83
electron/bridges/sessionLogStreamManager.test.cjs
Normal file
@@ -0,0 +1,83 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const {
|
||||
appendData,
|
||||
startStream,
|
||||
stopStream,
|
||||
} = require("./sessionLogStreamManager.cjs");
|
||||
|
||||
const TEMP_ROOT = path.join(__dirname, ".tmp-session-log-stream-tests");
|
||||
|
||||
test("txt stream live snapshots include pending ED2 cleared screens", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
try {
|
||||
startStream(sessionId, {
|
||||
hostLabel: "host",
|
||||
hostname: "host.example",
|
||||
directory,
|
||||
format: "txt",
|
||||
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
|
||||
});
|
||||
appendData(sessionId, "before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n");
|
||||
|
||||
const filePath = await waitForFileContent(directory, "before tui\n\nframe one\n\nframe two");
|
||||
assert.equal(fs.readFileSync(filePath, "utf8"), "before tui\n\nframe one\n\nframe two");
|
||||
} finally {
|
||||
await stopStream(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("txt stream finalization commits pending ED2 cleared screens", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `stream-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const sessionId = `session-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
|
||||
try {
|
||||
startStream(sessionId, {
|
||||
hostLabel: "host",
|
||||
hostname: "host.example",
|
||||
directory,
|
||||
format: "txt",
|
||||
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
|
||||
});
|
||||
appendData(sessionId, "before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n");
|
||||
|
||||
const filePath = await stopStream(sessionId);
|
||||
|
||||
assert.equal(fs.readFileSync(filePath, "utf8"), "before tui\n\nframe one\n\nframe two");
|
||||
} finally {
|
||||
await stopStream(sessionId);
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function waitForFileContent(directory, expectedContent) {
|
||||
const deadline = Date.now() + 3000;
|
||||
let lastContent = "";
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const filePath = findFirstTxtFile(directory);
|
||||
if (filePath && fs.existsSync(filePath)) {
|
||||
lastContent = fs.readFileSync(filePath, "utf8");
|
||||
if (lastContent === expectedContent) return filePath;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
assert.fail(`Timed out waiting for live snapshot content. Last content: ${JSON.stringify(lastContent)}`);
|
||||
}
|
||||
|
||||
function findFirstTxtFile(directory) {
|
||||
if (!fs.existsSync(directory)) return null;
|
||||
for (const hostDirName of fs.readdirSync(directory)) {
|
||||
const hostDir = path.join(directory, hostDirName);
|
||||
if (!fs.statSync(hostDir).isDirectory()) continue;
|
||||
const fileName = fs.readdirSync(hostDir).find((name) => name.endsWith(".txt"));
|
||||
if (fileName) return path.join(hostDir, fileName);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -4,7 +4,12 @@ const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
const { addBundledMoshDllPath, resolveBareMoshClient } = require("./terminalBridge.cjs");
|
||||
const {
|
||||
addBundledMoshDllPath,
|
||||
addBundledMoshRuntimeEnv,
|
||||
addBundledMoshTerminfoEnv,
|
||||
resolveBareMoshClient,
|
||||
} = require("./terminalBridge.cjs");
|
||||
|
||||
function makeTmp() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resolve-"));
|
||||
@@ -120,6 +125,102 @@ test("Windows dev mosh-client updates the PATH key used by child process env", (
|
||||
assert.equal(Object.prototype.hasOwnProperty.call(env, "Path"), false);
|
||||
});
|
||||
|
||||
test("Linux mosh-client prefers a sibling bundled terminfo dir", () => {
|
||||
const tmp = makeTmp();
|
||||
const client = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
const terminfo = path.join(tmp, "resources", "mosh", "linux-x64", "terminfo");
|
||||
writeExecutable(client);
|
||||
fs.mkdirSync(path.join(terminfo, "x"), { recursive: true });
|
||||
fs.writeFileSync(path.join(terminfo, "x", "xterm-256color"), "terminfo");
|
||||
|
||||
const env = {};
|
||||
addBundledMoshTerminfoEnv(env, client, { platform: "linux" });
|
||||
|
||||
assert.equal(env.TERMINFO, terminfo);
|
||||
const dirs = env.TERMINFO_DIRS.split(":");
|
||||
assert.equal(dirs[0], terminfo);
|
||||
assert.ok(dirs.includes("/usr/share/terminfo"));
|
||||
});
|
||||
|
||||
test("Linux mosh-client falls back to distro paths when no bundle present", () => {
|
||||
const tmp = makeTmp();
|
||||
const client = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
writeExecutable(client);
|
||||
|
||||
const env = {};
|
||||
addBundledMoshTerminfoEnv(env, client, { platform: "linux" });
|
||||
|
||||
assert.equal(env.TERMINFO, undefined);
|
||||
const dirs = env.TERMINFO_DIRS.split(":");
|
||||
assert.ok(dirs.includes("/etc/terminfo"));
|
||||
assert.ok(dirs.includes("/lib/terminfo"));
|
||||
assert.ok(dirs.includes("/usr/share/terminfo"));
|
||||
});
|
||||
|
||||
test("Linux mosh-client merges caller-supplied TERMINFO_DIRS between bundle and system defaults", () => {
|
||||
const tmp = makeTmp();
|
||||
const client = path.join(tmp, "resources", "mosh", "linux-x64", "mosh-client");
|
||||
const terminfo = path.join(tmp, "resources", "mosh", "linux-x64", "terminfo");
|
||||
writeExecutable(client);
|
||||
fs.mkdirSync(path.join(terminfo, "x"), { recursive: true });
|
||||
fs.writeFileSync(path.join(terminfo, "x", "xterm-256color"), "terminfo");
|
||||
|
||||
const env = { TERMINFO_DIRS: "/home/user/.terminfo" };
|
||||
addBundledMoshTerminfoEnv(env, client, { platform: "linux" });
|
||||
|
||||
const dirs = env.TERMINFO_DIRS.split(":");
|
||||
assert.equal(dirs[0], terminfo);
|
||||
assert.equal(dirs[1], "/home/user/.terminfo");
|
||||
assert.ok(dirs.includes("/usr/share/terminfo"));
|
||||
});
|
||||
|
||||
test("Darwin mosh-client uses macOS-aware terminfo search paths", () => {
|
||||
const tmp = makeTmp();
|
||||
const client = path.join(tmp, "resources", "mosh", "darwin-universal", "mosh-client");
|
||||
writeExecutable(client);
|
||||
|
||||
const env = {};
|
||||
addBundledMoshTerminfoEnv(env, client, { platform: "darwin" });
|
||||
|
||||
const dirs = env.TERMINFO_DIRS.split(":");
|
||||
assert.ok(dirs.includes("/usr/share/terminfo"));
|
||||
assert.ok(dirs.includes("/opt/homebrew/share/terminfo"));
|
||||
});
|
||||
|
||||
test("Windows mosh-client points ncurses at bundled terminfo", () => {
|
||||
const tmp = makeTmp();
|
||||
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
|
||||
const terminfo = path.join(tmp, "resources", "mosh", "win32-x64", "terminfo");
|
||||
writeExecutable(client);
|
||||
fs.mkdirSync(path.join(terminfo, "x"), { recursive: true });
|
||||
fs.writeFileSync(path.join(terminfo, "x", "xterm-256color"), "terminfo");
|
||||
|
||||
const env = {};
|
||||
addBundledMoshTerminfoEnv(env, client, { platform: "win32" });
|
||||
|
||||
assert.equal(env.TERMINFO, terminfo);
|
||||
assert.equal(env.TERMINFO_DIRS, terminfo);
|
||||
});
|
||||
|
||||
test("Windows mosh runtime env includes DLL path and terminfo", () => {
|
||||
const tmp = makeTmp();
|
||||
const client = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client.exe");
|
||||
const dllDir = path.join(tmp, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls");
|
||||
const terminfo = path.join(tmp, "resources", "mosh", "win32-x64", "terminfo");
|
||||
writeExecutable(client);
|
||||
fs.mkdirSync(dllDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dllDir, "cygwin1.dll"), "dll");
|
||||
fs.mkdirSync(path.join(terminfo, "78"), { recursive: true });
|
||||
fs.writeFileSync(path.join(terminfo, "78", "xterm-256color"), "terminfo");
|
||||
|
||||
const env = { Path: "C:\\Windows\\System32" };
|
||||
addBundledMoshRuntimeEnv(env, client, { platform: "win32", arch: "x64" });
|
||||
|
||||
assert.equal(env.Path.split(";")[0], dllDir);
|
||||
assert.equal(env.TERMINFO, terminfo);
|
||||
assert.equal(env.TERMINFO_DIRS, terminfo);
|
||||
});
|
||||
|
||||
test("removed Mosh client detection APIs are not exposed to the renderer", () => {
|
||||
const bridgeSource = fs.readFileSync(path.join(__dirname, "terminalBridge.cjs"), "utf8");
|
||||
const preloadSource = fs.readFileSync(path.join(__dirname, "..", "preload.cjs"), "utf8");
|
||||
|
||||
@@ -7,7 +7,9 @@ const os = require("node:os");
|
||||
const fs = require("node:fs");
|
||||
const net = require("node:net");
|
||||
const { randomUUID } = require("node:crypto");
|
||||
const { execFile } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const { promisify } = require("node:util");
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
const pty = require("node-pty");
|
||||
const { SerialPort } = require("serialport");
|
||||
@@ -20,6 +22,9 @@ const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
|
||||
const { createZmodemSentry } = require("./zmodemHelper.cjs");
|
||||
const { discoverShells } = require("./shellDiscovery.cjs");
|
||||
const moshHandshake = require("./moshHandshake.cjs");
|
||||
const tempDirBridge = require("./tempDirBridge.cjs");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
// Shared references
|
||||
let sessions = null;
|
||||
@@ -856,27 +861,234 @@ function addBundledMoshDllPath(env, bareClient, opts = {}) {
|
||||
return dllDir ? prependEnvPath(env, dllDir, opts) : env;
|
||||
}
|
||||
|
||||
function createMoshSshPasswordResponder(sshPty, password) {
|
||||
if (typeof password !== "string" || password.length === 0) {
|
||||
function findBundledMoshTerminfoDir(bareClient, _opts = {}) {
|
||||
if (!bareClient) return null;
|
||||
const terminfoDir = path.join(path.dirname(bareClient), "terminfo");
|
||||
const hasXterm256 =
|
||||
fs.existsSync(path.join(terminfoDir, "x", "xterm-256color")) ||
|
||||
fs.existsSync(path.join(terminfoDir, "78", "xterm-256color"));
|
||||
return hasXterm256 ? terminfoDir : null;
|
||||
}
|
||||
|
||||
// Standard locations where distros / package managers install the compiled
|
||||
// terminfo database. Used as a fallback only — the bundled directory ships
|
||||
// with the mosh release and is preferred. See issue #890 for context.
|
||||
const LINUX_SYSTEM_TERMINFO_DIRS = [
|
||||
"/etc/terminfo",
|
||||
"/lib/terminfo",
|
||||
"/usr/share/terminfo",
|
||||
"/usr/lib/terminfo",
|
||||
];
|
||||
|
||||
const DARWIN_SYSTEM_TERMINFO_DIRS = [
|
||||
"/usr/share/terminfo",
|
||||
"/opt/homebrew/share/terminfo",
|
||||
"/usr/local/share/terminfo",
|
||||
"/opt/local/share/terminfo",
|
||||
];
|
||||
|
||||
function addBundledMoshTerminfoEnv(env, bareClient, opts = {}) {
|
||||
const platform = opts.platform || process.platform;
|
||||
const terminfoDir = findBundledMoshTerminfoDir(bareClient, opts);
|
||||
|
||||
if (platform === "win32") {
|
||||
if (!terminfoDir) return env;
|
||||
env.TERMINFO = terminfoDir;
|
||||
env.TERMINFO_DIRS = terminfoDir;
|
||||
return env;
|
||||
}
|
||||
|
||||
// POSIX. The bundled terminfo is the source of truth — our static
|
||||
// ncurses' compiled-in default points at a build-time temp dir that no
|
||||
// longer exists on the user's machine. Fall back to standard distro
|
||||
// paths when the bundle is absent (e.g. running against an older mosh
|
||||
// binary release that pre-dates the bundle). A caller-supplied
|
||||
// TERMINFO_DIRS is preserved between the bundle and the system defaults.
|
||||
const existing = (typeof env.TERMINFO_DIRS === "string" && env.TERMINFO_DIRS.length > 0)
|
||||
? env.TERMINFO_DIRS.split(":").filter(Boolean)
|
||||
: [];
|
||||
const systemDirs = platform === "darwin" ? DARWIN_SYSTEM_TERMINFO_DIRS : LINUX_SYSTEM_TERMINFO_DIRS;
|
||||
const dirs = [];
|
||||
if (terminfoDir) dirs.push(terminfoDir);
|
||||
for (const dir of existing) {
|
||||
if (!dirs.includes(dir)) dirs.push(dir);
|
||||
}
|
||||
for (const dir of systemDirs) {
|
||||
if (!dirs.includes(dir)) dirs.push(dir);
|
||||
}
|
||||
if (terminfoDir) {
|
||||
env.TERMINFO = terminfoDir;
|
||||
}
|
||||
env.TERMINFO_DIRS = dirs.join(":");
|
||||
return env;
|
||||
}
|
||||
|
||||
function addBundledMoshRuntimeEnv(env, bareClient, opts = {}) {
|
||||
addBundledMoshDllPath(env, bareClient, opts);
|
||||
addBundledMoshTerminfoEnv(env, bareClient, opts);
|
||||
return env;
|
||||
}
|
||||
|
||||
function createMoshSshPasswordResponder(sshPty, password, passphrase) {
|
||||
if (
|
||||
(typeof password !== "string" || password.length === 0) &&
|
||||
(typeof passphrase !== "string" || passphrase.length === 0)
|
||||
) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
let answered = false;
|
||||
let answeredPassword = false;
|
||||
let answeredPassphrase = false;
|
||||
let tail = "";
|
||||
|
||||
return (chunk) => {
|
||||
if (answered) return;
|
||||
if (answeredPassword && answeredPassphrase) return;
|
||||
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk || "");
|
||||
if (!text) return;
|
||||
|
||||
tail = (tail + text).slice(-512);
|
||||
if (typeof passphrase === "string" && passphrase.length > 0 && !answeredPassphrase && /(^|[\r\n]).*passphrase.*:\s*$/i.test(tail)) {
|
||||
answeredPassphrase = true;
|
||||
sshPty.write(`${passphrase}\r`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof password !== "string" || password.length === 0 || answeredPassword) return;
|
||||
if (!/(^|[\r\n]).*password:\s*$/i.test(tail)) return;
|
||||
|
||||
answered = true;
|
||||
answeredPassword = true;
|
||||
sshPty.write(`${password}\r`);
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeMoshIdentityPath(keyPath) {
|
||||
if (typeof keyPath !== "string") return null;
|
||||
const trimmed = keyPath.trim();
|
||||
if (!trimmed) return null;
|
||||
if (trimmed === "~") return os.homedir();
|
||||
if (trimmed.startsWith("~/")) return path.join(os.homedir(), trimmed.slice(2));
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function safeMoshAuthFileName(sessionId, keyId, suffix) {
|
||||
const safeId = String(keyId || sessionId || randomUUID())
|
||||
.replace(/[^a-zA-Z0-9_-]/g, "_")
|
||||
.slice(0, 80);
|
||||
return `mosh-auth-${safeId}-${randomUUID()}${suffix}`;
|
||||
}
|
||||
|
||||
async function writeMoshAuthTempFile(fileName, content) {
|
||||
const target = tempDirBridge.getTempFilePath(fileName);
|
||||
const normalized = content.endsWith("\n") ? content : `${content}\n`;
|
||||
let created = false;
|
||||
try {
|
||||
const handle = await fs.promises.open(target, "wx", 0o600);
|
||||
created = true;
|
||||
await handle.close();
|
||||
await restrictMoshAuthFilePermissions(target, { failClosed: true });
|
||||
await fs.promises.writeFile(target, normalized, { flag: "w", mode: 0o600 });
|
||||
try {
|
||||
await fs.promises.chmod(target, 0o600);
|
||||
} catch {
|
||||
// Best effort on Windows; ACL hardening above is the security boundary.
|
||||
}
|
||||
} catch (err) {
|
||||
if (created) cleanupMoshAuthTempFiles([target]);
|
||||
throw err;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
async function restrictMoshAuthFilePermissions(target, opts = {}) {
|
||||
if (process.platform !== "win32") return true;
|
||||
|
||||
let username = process.env.USERNAME;
|
||||
if (!username) {
|
||||
try {
|
||||
username = os.userInfo().username;
|
||||
} catch {
|
||||
username = "";
|
||||
}
|
||||
}
|
||||
if (!username) {
|
||||
if (opts.failClosed) {
|
||||
throw new Error("Failed to restrict private key ACLs: unable to resolve current Windows user");
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const identities = [];
|
||||
if (process.env.USERDOMAIN) identities.push(`${process.env.USERDOMAIN}\\${username}`);
|
||||
identities.push(username);
|
||||
|
||||
let lastError = null;
|
||||
for (const identity of identities) {
|
||||
try {
|
||||
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], { windowsHide: true });
|
||||
await execFileAsync("icacls.exe", [target, "/inheritance:r"], { windowsHide: true });
|
||||
await execFileAsync("icacls.exe", [target, "/grant:r", `${identity}:F`], { windowsHide: true });
|
||||
return true;
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
|
||||
const message = lastError?.message || String(lastError || "unknown error");
|
||||
if (opts.failClosed) {
|
||||
throw new Error(`Failed to restrict private key ACLs: ${message}`);
|
||||
}
|
||||
console.warn("[Mosh] Failed to restrict private key ACLs:", message);
|
||||
return false;
|
||||
}
|
||||
|
||||
function cleanupMoshAuthTempFiles(files) {
|
||||
for (const file of files || []) {
|
||||
try {
|
||||
fs.unlinkSync(file);
|
||||
} catch {
|
||||
// Best effort cleanup; Settings > System can clear Netcatty temp files.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function buildMoshSshAuthArgs(options, sessionId) {
|
||||
const sshArgs = [];
|
||||
const tempFiles = [];
|
||||
|
||||
try {
|
||||
if (typeof options.privateKey === "string" && options.privateKey.trim().length > 0) {
|
||||
const keyPath = await writeMoshAuthTempFile(
|
||||
safeMoshAuthFileName(sessionId, options.keyId, ".pem"),
|
||||
options.privateKey,
|
||||
);
|
||||
tempFiles.push(keyPath);
|
||||
sshArgs.push("-i", keyPath, "-o", "IdentitiesOnly=yes");
|
||||
|
||||
if (typeof options.certificate === "string" && options.certificate.trim().length > 0) {
|
||||
const certPath = await writeMoshAuthTempFile(
|
||||
safeMoshAuthFileName(sessionId, options.keyId, "-cert.pub"),
|
||||
options.certificate,
|
||||
);
|
||||
tempFiles.push(certPath);
|
||||
sshArgs.push("-o", `CertificateFile=${certPath}`);
|
||||
}
|
||||
} else if (Array.isArray(options.identityFilePaths) && options.identityFilePaths.length > 0) {
|
||||
for (const keyPath of options.identityFilePaths) {
|
||||
const normalized = normalizeMoshIdentityPath(keyPath);
|
||||
if (normalized) sshArgs.push("-i", normalized);
|
||||
}
|
||||
if (sshArgs.length > 0) {
|
||||
sshArgs.push("-o", "IdentitiesOnly=yes");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
cleanupMoshAuthTempFiles(tempFiles);
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { sshArgs, tempFiles };
|
||||
}
|
||||
|
||||
/**
|
||||
* Phase-2 / Phase-3b path: run the SSH bootstrap ourselves *inside the
|
||||
* user's terminal PTY* so password / 2FA / known-hosts prompts render
|
||||
@@ -906,6 +1118,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
const rows = options.rows || 24;
|
||||
const optionsEnv = options.env || {};
|
||||
const lang = optionsEnv.LANG || resolveLangFromCharsetForMosh(options.charset);
|
||||
const moshAuth = await buildMoshSshAuthArgs(options, sessionId);
|
||||
|
||||
const { args: sshArgs } = moshHandshake.buildSshHandshakeCommand({
|
||||
host: options.hostname,
|
||||
@@ -913,20 +1126,34 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
username: options.username,
|
||||
lang,
|
||||
moshServer: moshHandshake.buildMoshServerCommand(options.moshServerPath),
|
||||
sshArgs: moshAuth.sshArgs,
|
||||
});
|
||||
|
||||
const sshEnv = { ...process.env, ...optionsEnv, TERM: "xterm-256color" };
|
||||
// macOS Terminal/iTerm export LC_CTYPE=UTF-8 (a bare value, not a real
|
||||
// locale name). System ssh_config has `SendEnv LC_*`, so without scrubbing
|
||||
// these the remote shell tries to setlocale("UTF-8") and prints a warning
|
||||
// on every connection. mosh-server sets the locale it needs separately.
|
||||
for (const key of Object.keys(sshEnv)) {
|
||||
if (key.startsWith("LC_")) delete sshEnv[key];
|
||||
}
|
||||
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
|
||||
sshEnv.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
|
||||
const sshPty = pty.spawn(sshExe, sshArgs, {
|
||||
cols,
|
||||
rows,
|
||||
env: sshEnv,
|
||||
cwd: os.homedir(),
|
||||
encoding: null,
|
||||
});
|
||||
let sshPty;
|
||||
try {
|
||||
sshPty = pty.spawn(sshExe, sshArgs, {
|
||||
cols,
|
||||
rows,
|
||||
env: sshEnv,
|
||||
cwd: os.homedir(),
|
||||
encoding: null,
|
||||
});
|
||||
} catch (err) {
|
||||
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||||
throw err;
|
||||
}
|
||||
|
||||
const session = {
|
||||
proc: sshPty,
|
||||
@@ -947,6 +1174,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
rows,
|
||||
moshHandshakePhase: "ssh",
|
||||
moshHandshakeResult: null,
|
||||
moshAuthTempFiles: moshAuth.tempFiles,
|
||||
};
|
||||
sessions.set(sessionId, session);
|
||||
|
||||
@@ -967,7 +1195,7 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
session.flushPendingData = flush;
|
||||
|
||||
const sniffer = moshHandshake.createMoshConnectSniffer();
|
||||
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password);
|
||||
const respondToPasswordPrompt = createMoshSshPasswordResponder(sshPty, options.password, options.passphrase);
|
||||
|
||||
// Forward bytes from the ssh PTY to the renderer, redacting the
|
||||
// MOSH CONNECT magic line. ZMODEM is intentionally not enabled
|
||||
@@ -991,8 +1219,10 @@ async function startMoshSessionViaHandshake(event, options, { bareClient, sshExe
|
||||
|
||||
sshPty.onExit(({ exitCode, signal }) => {
|
||||
if (sessions.get(sessionId) !== session || session.closed) {
|
||||
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||||
return;
|
||||
}
|
||||
cleanupMoshAuthTempFiles(moshAuth.tempFiles);
|
||||
|
||||
if (session.moshHandshakePhase === "parsed" && session.moshHandshakeResult) {
|
||||
try {
|
||||
@@ -1052,7 +1282,7 @@ function swapToMoshClient(session, options, ctx) {
|
||||
key: parsed.key,
|
||||
lang,
|
||||
});
|
||||
addBundledMoshDllPath(env, bareClient);
|
||||
addBundledMoshRuntimeEnv(env, bareClient);
|
||||
if (options.agentForwarding && process.env.SSH_AUTH_SOCK) {
|
||||
env.SSH_AUTH_SOCK = process.env.SSH_AUTH_SOCK;
|
||||
}
|
||||
@@ -1402,6 +1632,8 @@ function closeSession(event, payload) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("Close failed", err);
|
||||
} finally {
|
||||
cleanupMoshAuthTempFiles(session.moshAuthTempFiles);
|
||||
}
|
||||
ptyProcessTree.unregisterPid(payload.sessionId);
|
||||
sessions.delete(payload.sessionId);
|
||||
@@ -1627,6 +1859,8 @@ module.exports = {
|
||||
bundledMoshClient,
|
||||
resolveBareMoshClient,
|
||||
addBundledMoshDllPath,
|
||||
addBundledMoshTerminfoEnv,
|
||||
addBundledMoshRuntimeEnv,
|
||||
startSerialSession,
|
||||
listSerialPorts,
|
||||
writeToSession,
|
||||
|
||||
@@ -219,6 +219,98 @@ test("startMoshSession writes the saved password when ssh prompts for one", asyn
|
||||
assert.deepEqual(h.spawns[0].writes, ["saved-secret\r"]);
|
||||
});
|
||||
|
||||
test("startMoshSession passes vault private keys to ssh via a temp identity file", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{
|
||||
...h.options,
|
||||
keyId: "key-1",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
password: "wrong-password",
|
||||
},
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
const keyFlagIndex = h.spawns[0].args.indexOf("-i");
|
||||
assert.notEqual(keyFlagIndex, -1);
|
||||
const keyPath = h.spawns[0].args[keyFlagIndex + 1];
|
||||
assert.equal(fs.existsSync(keyPath), true);
|
||||
assert.equal(h.spawns[0].args.includes("IdentitiesOnly=yes"), true);
|
||||
assert.equal(h.spawns[0].args.includes("alice@example.com"), true);
|
||||
|
||||
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
|
||||
assert.equal(fs.existsSync(keyPath), false);
|
||||
});
|
||||
|
||||
test("startMoshSession uses unique temp identity files for concurrent sessions with the same key", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
const authOptions = {
|
||||
keyId: "key-1",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
};
|
||||
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{ ...h.options, ...authOptions },
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{ ...h.options, ...authOptions, sessionId: "mosh-test-session-2" },
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
const firstKeyPath = h.spawns[0].args[h.spawns[0].args.indexOf("-i") + 1];
|
||||
const secondKeyPath = h.spawns[1].args[h.spawns[1].args.indexOf("-i") + 1];
|
||||
assert.notEqual(firstKeyPath, secondKeyPath);
|
||||
assert.equal(fs.existsSync(firstKeyPath), true);
|
||||
assert.equal(fs.existsSync(secondKeyPath), true);
|
||||
|
||||
h.spawns[0].emitExit({ exitCode: 255, signal: 0 });
|
||||
assert.equal(fs.existsSync(firstKeyPath), false);
|
||||
assert.equal(fs.existsSync(secondKeyPath), true);
|
||||
|
||||
h.spawns[1].emitExit({ exitCode: 255, signal: 0 });
|
||||
assert.equal(fs.existsSync(secondKeyPath), false);
|
||||
});
|
||||
|
||||
test("closeSession removes Mosh temp identity files even before ssh exits", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{
|
||||
...h.options,
|
||||
keyId: "key-1",
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
},
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
const keyPath = h.spawns[0].args[h.spawns[0].args.indexOf("-i") + 1];
|
||||
assert.equal(fs.existsSync(keyPath), true);
|
||||
|
||||
h.bridge.closeSession(h.event, { sessionId: "mosh-test-session" });
|
||||
assert.equal(fs.existsSync(keyPath), false);
|
||||
});
|
||||
|
||||
test("startMoshSession writes the saved passphrase when ssh prompts for the temp key", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(
|
||||
h.event,
|
||||
{
|
||||
...h.options,
|
||||
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nkey\n-----END OPENSSH PRIVATE KEY-----",
|
||||
passphrase: "key-passphrase",
|
||||
},
|
||||
{ moshClientLookup: h.lookupOpts },
|
||||
);
|
||||
|
||||
h.spawns[0].emitData("Enter passphrase for key 'mosh-auth-key-1.pem':");
|
||||
|
||||
assert.deepEqual(h.spawns[0].writes, ["key-passphrase\r"]);
|
||||
});
|
||||
|
||||
test("startMoshSession handshake path sends the existing exit event after mosh-client exits", async (t) => {
|
||||
const h = makeHarness(t);
|
||||
await h.bridge.startMoshSession(h.event, h.options, { moshClientLookup: h.lookupOpts });
|
||||
|
||||
@@ -36,9 +36,14 @@ class TerminalTextRenderer {
|
||||
this.lines = [[]];
|
||||
this.row = 0;
|
||||
this.col = 0;
|
||||
this.screenBaseRow = 0;
|
||||
this.state = "normal";
|
||||
this.escapeBuffer = "";
|
||||
this.style = createDefaultStyle();
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.justStartedLogScreen = false;
|
||||
this.hasPreservedScreenHistory = false;
|
||||
this.pendingClearedScreen = null;
|
||||
}
|
||||
|
||||
feed(input) {
|
||||
@@ -52,18 +57,21 @@ class TerminalTextRenderer {
|
||||
finish() {
|
||||
this.state = "normal";
|
||||
this.escapeBuffer = "";
|
||||
this.#commitPendingClearedScreen();
|
||||
return this.toString();
|
||||
}
|
||||
|
||||
toString() {
|
||||
return this.lines
|
||||
toString({ includePendingClearedScreen = false } = {}) {
|
||||
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
|
||||
return lines
|
||||
.map((line) => line.map((cell) => cell?.ch || " ").join("").replace(/[ \t]+$/g, ""))
|
||||
.join("\n")
|
||||
.replace(/\n+$/g, "");
|
||||
}
|
||||
|
||||
toHtmlContent() {
|
||||
return this.lines
|
||||
toHtmlContent({ includePendingClearedScreen = false } = {}) {
|
||||
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
|
||||
return lines
|
||||
.map((line) => renderLineHtml(line))
|
||||
.join("\n")
|
||||
.replace(/\n+$/g, "");
|
||||
@@ -109,10 +117,12 @@ class TerminalTextRenderer {
|
||||
break;
|
||||
case "\r":
|
||||
this.col = 0;
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
break;
|
||||
case "\n":
|
||||
this.row += 1;
|
||||
this.col = 0;
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "\t":
|
||||
@@ -156,33 +166,40 @@ class TerminalTextRenderer {
|
||||
|
||||
switch (final) {
|
||||
case "A":
|
||||
this.row = Math.max(0, this.row - n);
|
||||
this.row = Math.max(this.screenBaseRow, this.row - n);
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "B":
|
||||
case "E":
|
||||
this.row += n;
|
||||
if (final === "E") this.col = 0;
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "C":
|
||||
this.col += n;
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
break;
|
||||
case "D":
|
||||
this.col = Math.max(0, this.col - n);
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
break;
|
||||
case "F":
|
||||
this.row = Math.max(0, this.row - n);
|
||||
this.row = Math.max(this.screenBaseRow, this.row - n);
|
||||
this.col = 0;
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "G":
|
||||
this.col = Math.max(0, n - 1);
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
break;
|
||||
case "H":
|
||||
case "f":
|
||||
this.row = Math.max(0, (values[0] || 1) - 1);
|
||||
this.row = this.screenBaseRow + Math.max(0, (values[0] || 1) - 1);
|
||||
this.col = Math.max(0, (values[1] || 1) - 1);
|
||||
this.cursorMovedHomeByCsi = this.row === this.screenBaseRow && this.col === 0;
|
||||
this.#ensureLine();
|
||||
break;
|
||||
case "J":
|
||||
@@ -262,6 +279,8 @@ class TerminalTextRenderer {
|
||||
line[this.col] = createCell(ch, this.style);
|
||||
this.col += 1;
|
||||
}
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.justStartedLogScreen = false;
|
||||
}
|
||||
|
||||
#eraseLine(mode) {
|
||||
@@ -282,22 +301,119 @@ class TerminalTextRenderer {
|
||||
|
||||
#eraseDisplay(mode) {
|
||||
this.#ensureLine();
|
||||
if (mode === 2 || mode === 3) {
|
||||
this.lines = [[]];
|
||||
this.row = 0;
|
||||
this.col = 0;
|
||||
if (mode === 3 && this.pendingClearedScreen) {
|
||||
this.#commitPendingClearedScreen();
|
||||
return;
|
||||
}
|
||||
if (mode === 3) {
|
||||
this.pendingClearedScreen = null;
|
||||
return;
|
||||
}
|
||||
if (mode === 2) {
|
||||
if (this.hasPreservedScreenHistory) {
|
||||
this.#clearCurrentLogScreen({ keepPending: true });
|
||||
return;
|
||||
}
|
||||
this.#startNewLogScreen();
|
||||
return;
|
||||
}
|
||||
if (mode === 1) {
|
||||
this.lines = this.lines.slice(this.row);
|
||||
this.row = 0;
|
||||
for (let i = this.screenBaseRow; i < this.row; i += 1) {
|
||||
this.lines[i] = [];
|
||||
}
|
||||
this.#eraseLine(1);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
this.row === this.screenBaseRow &&
|
||||
this.col === 0 &&
|
||||
this.cursorMovedHomeByCsi &&
|
||||
!this.hasPreservedScreenHistory
|
||||
) {
|
||||
this.#startNewLogScreen();
|
||||
return;
|
||||
}
|
||||
this.#eraseLine(0);
|
||||
this.lines.length = this.row + 1;
|
||||
}
|
||||
|
||||
#clearCurrentLogScreen({ keepPending = false } = {}) {
|
||||
const targetRow = this.row;
|
||||
if (keepPending && this.#currentLogScreenHasContent()) {
|
||||
this.pendingClearedScreen = {
|
||||
lines: cloneLines(this.lines.slice(this.screenBaseRow)),
|
||||
baseRow: this.screenBaseRow,
|
||||
};
|
||||
} else if (!keepPending) {
|
||||
this.pendingClearedScreen = null;
|
||||
}
|
||||
for (let i = this.screenBaseRow; i < this.lines.length; i += 1) {
|
||||
this.lines[i] = [];
|
||||
}
|
||||
this.row = Math.max(this.screenBaseRow, targetRow);
|
||||
this.#ensureLine();
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.justStartedLogScreen = true;
|
||||
}
|
||||
|
||||
#commitPendingClearedScreen() {
|
||||
const pending = this.pendingClearedScreen;
|
||||
if (!pending) return;
|
||||
const relativeRow = Math.max(0, this.row - pending.baseRow);
|
||||
const col = this.col;
|
||||
const { lines, screenBaseRow } = this.#buildLinesWithPendingClearedScreen(pending);
|
||||
this.lines = lines;
|
||||
this.screenBaseRow = screenBaseRow;
|
||||
this.row = this.screenBaseRow + relativeRow;
|
||||
this.col = col;
|
||||
this.#ensureLine();
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.justStartedLogScreen = true;
|
||||
this.hasPreservedScreenHistory = true;
|
||||
this.pendingClearedScreen = null;
|
||||
}
|
||||
|
||||
#linesWithPendingClearedScreen() {
|
||||
const pending = this.pendingClearedScreen;
|
||||
if (!pending) return this.lines;
|
||||
return this.#buildLinesWithPendingClearedScreen(pending).lines;
|
||||
}
|
||||
|
||||
#buildLinesWithPendingClearedScreen(pending) {
|
||||
const prefix = this.lines.slice(0, pending.baseRow);
|
||||
const activeLines = this.lines.slice(pending.baseRow);
|
||||
const pendingLines = trimTrailingBlankLines(pending.lines);
|
||||
return {
|
||||
lines: prefix.concat(pendingLines, [[]], activeLines.length > 0 ? activeLines : [[]]),
|
||||
screenBaseRow: prefix.length + pendingLines.length + 1,
|
||||
};
|
||||
}
|
||||
|
||||
#startNewLogScreen() {
|
||||
if (this.justStartedLogScreen) return;
|
||||
const hasContent = this.lines.some((line) => getTrimmedLineLength(line) > 0);
|
||||
if (hasContent) {
|
||||
this.lines.push([]);
|
||||
this.row = this.lines.length - 1;
|
||||
} else {
|
||||
this.lines = [[]];
|
||||
this.row = 0;
|
||||
}
|
||||
this.screenBaseRow = this.row;
|
||||
this.col = 0;
|
||||
this.cursorMovedHomeByCsi = false;
|
||||
this.justStartedLogScreen = true;
|
||||
this.hasPreservedScreenHistory = hasContent;
|
||||
this.pendingClearedScreen = null;
|
||||
}
|
||||
|
||||
#currentLogScreenHasContent() {
|
||||
for (let i = this.screenBaseRow; i < this.lines.length; i += 1) {
|
||||
if (getTrimmedLineLength(this.lines[i]) > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
#ensureLine() {
|
||||
while (this.lines.length <= this.row) this.lines.push([]);
|
||||
}
|
||||
@@ -351,6 +467,18 @@ function createCell(ch, style) {
|
||||
};
|
||||
}
|
||||
|
||||
function cloneLines(lines) {
|
||||
return lines.map((line) => line.map((cell) => (cell ? createCell(cell.ch, cell.style) : cell)));
|
||||
}
|
||||
|
||||
function trimTrailingBlankLines(lines) {
|
||||
let length = lines.length;
|
||||
while (length > 0 && getTrimmedLineLength(lines[length - 1]) === 0) {
|
||||
length -= 1;
|
||||
}
|
||||
return lines.slice(0, length);
|
||||
}
|
||||
|
||||
function renderLineHtml(line) {
|
||||
let html = "";
|
||||
let runText = "";
|
||||
|
||||
153
electron/bridges/terminalLogSanitizer.test.cjs
Normal file
153
electron/bridges/terminalLogSanitizer.test.cjs
Normal file
@@ -0,0 +1,153 @@
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const {
|
||||
createTerminalTextRenderer,
|
||||
terminalDataToPlainText,
|
||||
} = require("./terminalLogSanitizer.cjs");
|
||||
const { terminalDataToHtml } = require("./sessionLogsBridge.cjs");
|
||||
|
||||
test("plain text rendering applies backspace edits", () => {
|
||||
assert.equal(terminalDataToPlainText("hellp\bo\n"), "hello");
|
||||
});
|
||||
|
||||
test("plain text rendering applies carriage-return overwrites", () => {
|
||||
assert.equal(terminalDataToPlainText("progress 10%\rprogress 100%\n"), "progress 100%");
|
||||
});
|
||||
|
||||
test("plain text rendering applies erase-line controls", () => {
|
||||
assert.equal(terminalDataToPlainText("loading...\r\x1b[Kdone\n"), "done");
|
||||
});
|
||||
|
||||
test("erase display from carriage return preserves overwrite semantics", () => {
|
||||
assert.equal(terminalDataToPlainText("progress 10%\r\x1b[Jprogress 20%\n"), "progress 20%");
|
||||
});
|
||||
|
||||
test("stateful renderer handles CSI sequences split across chunks", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("red \x1b[");
|
||||
renderer.feed("31mtext\x1b[0m\n");
|
||||
assert.equal(renderer.finish(), "red text");
|
||||
});
|
||||
|
||||
test("plain text rendering removes OSC payloads", () => {
|
||||
assert.equal(terminalDataToPlainText("before\x1b]0;secret title\x07after\n"), "beforeafter");
|
||||
});
|
||||
|
||||
test("HTML rendering escapes content and strips terminal controls", () => {
|
||||
const html = terminalDataToHtml("a < b\x1b[31m & c\x1b[0m\r\x1b[Kdone\n", "host<1>", 0);
|
||||
assert.equal(html.includes("\x1b"), false);
|
||||
assert.equal(html.includes("[31m"), false);
|
||||
assert.equal(html.includes("done"), true);
|
||||
assert.equal(html.includes("a < b"), false);
|
||||
assert.equal(html.includes("host<1>"), true);
|
||||
});
|
||||
|
||||
test("display clear preserves prior log history", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("login banner\n$ tmux\n\x1b[H\x1b[2Jtmux pane\n"),
|
||||
"login banner\n$ tmux\n\ntmux pane",
|
||||
);
|
||||
});
|
||||
|
||||
test("ED3 after ED2 does not add a duplicate log separator", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("login banner\n$ clear\n\x1b[H\x1b[2J\x1b[3Jafter clear\n"),
|
||||
"login banner\n$ clear\n\nafter clear",
|
||||
);
|
||||
});
|
||||
|
||||
test("cursor home after display clear stays within the new log screen", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("old1\nold2\n\x1b[2J\x1b[Hnew\n"),
|
||||
"old1\nold2\n\nnew",
|
||||
);
|
||||
});
|
||||
|
||||
test("erase display backward after full clear preserves prior log history", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("old\n\x1b[2Jnew\x1b[1Jafter\n"),
|
||||
"old\n\n after",
|
||||
);
|
||||
});
|
||||
|
||||
test("clear from home preserves prior log history", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before zellij\n$ zellij\n\x1b[H\x1b[Jzellij pane\n"),
|
||||
"before zellij\n$ zellij\n\nzellij pane",
|
||||
);
|
||||
});
|
||||
|
||||
test("home clear repaint updates current preserved screen instead of appending frames", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before tui\n\x1b[H\x1b[Jframe one\n\x1b[H\x1b[Jframe two\n"),
|
||||
"before tui\n\nframe two",
|
||||
);
|
||||
});
|
||||
|
||||
test("home ED2 preserves cleared screens even without ED3", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n"),
|
||||
"before tui\n\nframe one\n\nframe two",
|
||||
);
|
||||
});
|
||||
|
||||
test("repeated ED2 preserves cleared screens even without ED3", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before tui\n\x1b[2Jframe one\r\x1b[2Jframe two\n"),
|
||||
"before tui\n\nframe one\n\nframe two",
|
||||
);
|
||||
});
|
||||
|
||||
test("redundant ED2 keeps pending cleared screen when current screen is empty", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before\n\x1b[2Jfirst\x1b[2J\x1b[2Jsecond\n"),
|
||||
"before\n\nfirst\n\n second",
|
||||
);
|
||||
});
|
||||
|
||||
test("home ED2 preserves each cleared shell frame without ED3", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before\n\x1b[H\x1b[2Jone\n\x1b[H\x1b[2Jtwo\n"),
|
||||
"before\n\none\n\ntwo",
|
||||
);
|
||||
});
|
||||
|
||||
test("home ED2 repaint does not accumulate every intermediate frame", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n\x1b[H\x1b[2Jframe three\n"),
|
||||
"before tui\n\nframe two\n\nframe three",
|
||||
);
|
||||
});
|
||||
|
||||
test("committing pending ED2 preserves cursor movement before printable output", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before\n\x1b[H\x1b[2Jone\n\x1b[2J\x1b[10;5Htext\n"),
|
||||
"before\n\none\n\n\n\n\n\n\n\n\n\n\n text",
|
||||
);
|
||||
});
|
||||
|
||||
test("pending ED2 snapshot rendering does not mutate repaint state", () => {
|
||||
const renderer = createTerminalTextRenderer();
|
||||
renderer.feed("before tui\n\x1b[H\x1b[2Jframe one\n\x1b[H\x1b[2Jframe two\n");
|
||||
assert.equal(
|
||||
renderer.toString({ includePendingClearedScreen: true }),
|
||||
"before tui\n\nframe one\n\nframe two",
|
||||
);
|
||||
|
||||
renderer.feed("\x1b[H\x1b[2Jframe three\n");
|
||||
assert.equal(renderer.finish(), "before tui\n\nframe two\n\nframe three");
|
||||
});
|
||||
|
||||
test("later shell clear preserves intervening screen output", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before\n\x1b[H\x1b[2Jfirst screen\n\x1b[H\x1b[2J\x1b[3Jsecond screen\n"),
|
||||
"before\n\nfirst screen\n\nsecond screen",
|
||||
);
|
||||
});
|
||||
|
||||
test("standalone ED3 preserves current visible screen", () => {
|
||||
assert.equal(
|
||||
terminalDataToPlainText("before\n\x1b[H\x1b[2Jscreen\n\x1b[3Jafter\n"),
|
||||
"before\n\nscreen\nafter",
|
||||
);
|
||||
});
|
||||
5
global.d.ts
vendored
5
global.d.ts
vendored
@@ -178,6 +178,11 @@ declare global {
|
||||
hostname: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
certificate?: string;
|
||||
keyId?: string;
|
||||
passphrase?: string;
|
||||
identityFilePaths?: string[];
|
||||
port?: number;
|
||||
moshServerPath?: string;
|
||||
moshClientPath?: string;
|
||||
|
||||
@@ -28,10 +28,12 @@ export const STORAGE_KEY_KNOWN_HOSTS = 'netcatty_known_hosts_v1';
|
||||
export const STORAGE_KEY_SHELL_HISTORY = 'netcatty_shell_history_v1';
|
||||
export const STORAGE_KEY_CONNECTION_LOGS = 'netcatty_connection_logs_v1';
|
||||
export const STORAGE_KEY_IDENTITIES = 'netcatty_identities_v1';
|
||||
export const STORAGE_KEY_PROXY_PROFILES = 'netcatty_proxy_profiles_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_VIEW_MODE = 'netcatty_vault_hosts_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_HOSTS_TREE_EXPANDED = 'netcatty_vault_hosts_tree_expanded_v1';
|
||||
export const STORAGE_KEY_VAULT_SIDEBAR_COLLAPSED = 'netcatty_vault_sidebar_collapsed_v1';
|
||||
export const STORAGE_KEY_VAULT_KEYS_VIEW_MODE = 'netcatty_vault_keys_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE = 'netcatty_vault_proxy_profiles_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_SNIPPETS_VIEW_MODE = 'netcatty_vault_snippets_view_mode_v1';
|
||||
export const STORAGE_KEY_VAULT_KNOWN_HOSTS_VIEW_MODE = 'netcatty_vault_known_hosts_view_mode_v1';
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
* function degrades to a no-op — values pass through unmodified.
|
||||
*/
|
||||
|
||||
import type { GroupConfig, Host, Identity, SSHKey } from "../../domain/models";
|
||||
import type { GroupConfig, Host, Identity, ProxyProfile, SSHKey } from "../../domain/models";
|
||||
import type { ProviderConnection, S3Config, WebDAVConfig } from "../../domain/sync";
|
||||
import { netcattyBridge } from "../services/netcattyBridge";
|
||||
|
||||
@@ -123,6 +123,30 @@ export function decryptGroupConfigs(configs: GroupConfig[]): Promise<GroupConfig
|
||||
return Promise.all(configs.map(decryptGroupConfigSecrets));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ProxyProfile
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function encryptProxyProfileSecrets(profile: ProxyProfile): Promise<ProxyProfile> {
|
||||
const out = { ...profile, config: { ...profile.config } };
|
||||
out.config.password = await encryptField(out.config.password);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function decryptProxyProfileSecrets(profile: ProxyProfile): Promise<ProxyProfile> {
|
||||
const out = { ...profile, config: { ...profile.config } };
|
||||
out.config.password = await decryptField(out.config.password);
|
||||
return out;
|
||||
}
|
||||
|
||||
export function encryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyProfile[]> {
|
||||
return Promise.all(profiles.map(encryptProxyProfileSecrets));
|
||||
}
|
||||
|
||||
export function decryptProxyProfiles(profiles: ProxyProfile[]): Promise<ProxyProfile[]> {
|
||||
return Promise.all(profiles.map(decryptProxyProfileSecrets));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider Connection (Cloud Sync)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1421,6 +1421,7 @@ export class CloudSyncManager {
|
||||
buildPayload(data: {
|
||||
hosts: SyncPayload['hosts'];
|
||||
keys: SyncPayload['keys'];
|
||||
proxyProfiles?: SyncPayload['proxyProfiles'];
|
||||
snippets: SyncPayload['snippets'];
|
||||
customGroups: SyncPayload['customGroups'];
|
||||
snippetPackages?: SyncPayload['snippetPackages'];
|
||||
|
||||
@@ -380,6 +380,10 @@ export const startPortForward = async (
|
||||
// Generate a unique tunnel ID
|
||||
const tunnelId = `pf-${rule.id}-${Date.now()}`;
|
||||
|
||||
if (host.proxyProfileId && !host.proxyConfig) {
|
||||
throw new Error(`Saved proxy for host "${host.label || host.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
|
||||
const resolved = resolveHostAuth({ host, keys, identities });
|
||||
const key = resolved.key;
|
||||
const proxy = host.proxyConfig
|
||||
@@ -403,6 +407,9 @@ export const startPortForward = async (
|
||||
jumpHosts = resolvedJumpHosts
|
||||
.filter((jumpHost): jumpHost is Host => Boolean(jumpHost))
|
||||
.map((jumpHost, index) => {
|
||||
if (jumpHost.proxyProfileId && !jumpHost.proxyConfig) {
|
||||
throw new Error(`Saved proxy for jump host "${jumpHost.label || jumpHost.hostname}" is missing. Open host settings and select a valid proxy.`);
|
||||
}
|
||||
const hasConfiguredJumpProxyEndpoint =
|
||||
index === 0 &&
|
||||
!!(jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port);
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"tool:cli": "node electron/cli/netcatty-tool-cli.cjs",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/state/*.test.ts components/*.test.tsx components/editor/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
"test": "node --test --import tsx electron/bridges/*.test.cjs electron/bridges/*/*.test.cjs scripts/*.test.cjs application/state/*.test.ts application/state/*/*.test.ts components/*.test.tsx components/editor/*.test.tsx components/ai/*.test.ts components/terminal/*.test.ts components/terminal/runtime/*.test.ts domain/*.test.ts infrastructure/ai/*.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.58",
|
||||
|
||||
@@ -8,23 +8,23 @@ directly (see `electron/bridges/moshHandshake.cjs` and
|
||||
|
||||
## How binaries land here
|
||||
|
||||
1. `.github/workflows/build-mosh-binaries.yml` builds `mosh-client` on
|
||||
relevant pushes/PRs, or on a manual `workflow_dispatch`. It uses
|
||||
`scripts/build-mosh/{build-linux,build-macos,build-windows}.sh` to
|
||||
produce one binary per target from upstream `mobile-shell/mosh`
|
||||
source:
|
||||
1. `.github/workflows/build-mosh-binaries.yml` builds or fetches
|
||||
`mosh-client` on relevant pushes/PRs, or on a manual
|
||||
`workflow_dispatch`. It uses `scripts/build-mosh/build-linux.sh` and
|
||||
`scripts/build-mosh/build-macos.sh` for Linux/macOS, and
|
||||
`scripts/build-mosh/fetch-windows.sh` for the pinned Windows binary:
|
||||
|
||||
| target | provenance |
|
||||
|-------------------|-----------------------------------------------------------------|
|
||||
| `linux-x64` | upstream source, manylinux2014, static third-party deps + glibc |
|
||||
| `linux-arm64` | upstream source, manylinux2014, static third-party deps + glibc |
|
||||
| `darwin-universal`| upstream source, lipo arm64 + x86_64, macOS system dylibs only |
|
||||
| `win32-x64` | upstream source, Cygwin GCC, ships with bundled Cygwin DLLs |
|
||||
| `win32-x64` | FluentTerminal-pinned standalone fallback, SHA256 pinned |
|
||||
| `win32-arm64` | (not built — Cygwin arm64 port not yet stable) |
|
||||
|
||||
`fetch-windows.sh` is preserved as an emergency fallback that pulls
|
||||
the FluentTerminal-pinned binary; it's no longer wired into the
|
||||
default workflow.
|
||||
The upstream Cygwin Windows build path was removed from the default
|
||||
workflow because the tested build clears the terminal but never
|
||||
renders remote output on Windows.
|
||||
|
||||
2. When manually dispatched with `release_tag`, that workflow publishes
|
||||
the binaries to the dedicated `binaricat/Netcatty-mosh-bin`
|
||||
@@ -50,8 +50,8 @@ directly (see `electron/bridges/moshHandshake.cjs` and
|
||||
whatever happens to be installed on the developer machine.
|
||||
|
||||
Official Windows package builds currently ship x64 only for bundled
|
||||
Mosh coverage. Windows arm64 packaging should be re-enabled there
|
||||
after the `build-mosh-binaries` workflow can produce `win32-arm64`.
|
||||
Mosh coverage. Windows arm64 packaging should be added only after we
|
||||
have a tested standalone arm64 client.
|
||||
|
||||
The directory is otherwise empty (binaries are gitignored).
|
||||
|
||||
@@ -61,12 +61,12 @@ The directory is otherwise empty (binaries are gitignored).
|
||||
(https://github.com/mobile-shell/mosh).
|
||||
- Netcatty is **GPL-3.0**, so redistribution as part of the installer
|
||||
is permitted.
|
||||
- The Windows binary is built in CI from upstream
|
||||
https://github.com/mobile-shell/mosh @ tag `MOSH_REF` (default
|
||||
`mosh-1.4.0`) using the Cygwin GCC toolchain. The bundled DLLs are
|
||||
redistributable Cygwin runtime libraries — see
|
||||
`mosh-client-win32-x64-dlls/README.txt` (generated by the build) for
|
||||
the per-DLL license listing.
|
||||
- The default Windows x64 binary is the FluentTerminal-pinned
|
||||
standalone `mosh-client.exe` from
|
||||
https://github.com/felixse/FluentTerminal @ commit `bad0f85`, pinned
|
||||
by SHA256 in `scripts/fetch-mosh-binaries.cjs`. The old Cygwin build
|
||||
path is intentionally not used for Windows releases while it
|
||||
reproduces the blank-screen runtime issue.
|
||||
- Bundled/static deps (OpenSSL Apache-2.0, protobuf BSD-3-Clause,
|
||||
ncurses MIT) are compatible with GPL-3.0.
|
||||
|
||||
@@ -98,12 +98,12 @@ For macOS the build needs an Xcode toolchain; see
|
||||
- Mosh startup requires Netcatty's bundled `mosh-client` and a usable
|
||||
`ssh` client for the remote bootstrap. System-installed `mosh` /
|
||||
`mosh-client` binaries are intentionally ignored.
|
||||
- Windows binary built in-CI from upstream source via Cygwin GCC; ships
|
||||
alongside `cygwin1.dll` + transitive deps so it runs on a stock
|
||||
Windows machine without a Cygwin install.
|
||||
- Windows x64 currently ships the FluentTerminal-pinned standalone
|
||||
client because the upstream Cygwin bundle can blank after terminal
|
||||
initialization on Windows.
|
||||
|
||||
## Roadmap
|
||||
|
||||
- Cygwin arm64 port stabilizes → add a `build-windows-arm64` matrix
|
||||
leg using the same `build-windows.sh` script.
|
||||
- Add Windows arm64 only after a tested standalone arm64 client is
|
||||
available.
|
||||
- Make `MOSH_REF` track upstream release tags automatically.
|
||||
|
||||
@@ -4,11 +4,17 @@
|
||||
# Inputs (env):
|
||||
# MOSH_REF — git ref of mobile-shell/mosh to build (e.g. mosh-1.4.0)
|
||||
# ARCH — x64 | arm64 (for output naming only; container is already that arch)
|
||||
# OUT_DIR — directory to write mosh-client-linux-<arch> + sha256
|
||||
# OUT_DIR — directory to write mosh-client-linux-<arch>.tar.gz + sha256
|
||||
#
|
||||
# Output:
|
||||
# $OUT_DIR/mosh-client-linux-<arch>
|
||||
# $OUT_DIR/mosh-client-linux-<arch>.sha256
|
||||
# $OUT_DIR/mosh-client-linux-<arch>.tar.gz (binary + terminfo bundle)
|
||||
# $OUT_DIR/mosh-client-linux-<arch>.tar.gz.sha256
|
||||
#
|
||||
# The bundle ships a private terminfo database next to the binary because
|
||||
# our statically-linked ncurses has its compiled-in TERMINFO path pointing
|
||||
# at the build-time prefix (a temp dir). Without bundling, mosh-client on
|
||||
# distros lacking /usr/share/terminfo (or stripped containers) fails with
|
||||
# "Terminfo database could not be found." See issue #890.
|
||||
#
|
||||
# Strategy: build OpenSSL, protobuf, ncurses as static archives in a
|
||||
# scratch prefix, then build mosh against those and link libstdc++/libgcc
|
||||
@@ -87,7 +93,9 @@ git -C mosh checkout --detach FETCH_HEAD
|
||||
LIBS="-ldl -lpthread"
|
||||
make -j"$(nproc)" )
|
||||
|
||||
OUT_BIN="$OUT_DIR/mosh-client-linux-$ARCH"
|
||||
BUNDLE_DIR="$WORK/linux-$ARCH-bundle"
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
OUT_BIN="$BUNDLE_DIR/mosh-client"
|
||||
cp mosh/src/frontend/mosh-client "$OUT_BIN"
|
||||
strip "$OUT_BIN"
|
||||
|
||||
@@ -110,5 +118,38 @@ if grep -Ev '^(linux-vdso\.so\.1|lib(c|m|pthread|rt|dl|resolv|util|z)\.so\.[0-9]
|
||||
exit 1
|
||||
fi
|
||||
|
||||
( cd "$OUT_DIR" && sha256sum "mosh-client-linux-$ARCH" > "mosh-client-linux-$ARCH.sha256" )
|
||||
cat "$OUT_DIR/mosh-client-linux-$ARCH.sha256"
|
||||
# Bundle the terminfo entries our statically-linked ncurses needs. The
|
||||
# ncurses `make install` above populated $PREFIX/share/terminfo/ with the
|
||||
# full upstream terminfo.src. Ship a curated subset so users hit a
|
||||
# working entry regardless of TERM.
|
||||
TERMINFO_SRC="$PREFIX/share/terminfo"
|
||||
TERMINFO_OUT="$BUNDLE_DIR/terminfo"
|
||||
mkdir -p "$TERMINFO_OUT"
|
||||
copy_terminfo_entry() {
|
||||
local name="$1"
|
||||
for src in "$TERMINFO_SRC"/?/"$name" "$TERMINFO_SRC"/??/"$name"; do
|
||||
[ -f "$src" ] || continue
|
||||
local rel
|
||||
rel=$(basename "$(dirname "$src")")
|
||||
mkdir -p "$TERMINFO_OUT/$rel"
|
||||
cp "$src" "$TERMINFO_OUT/$rel/$name"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
for entry in xterm-256color xterm xterm-color vt100 vt220 ansi screen screen-256color tmux tmux-256color dumb linux; do
|
||||
copy_terminfo_entry "$entry" || echo "WARN: terminfo entry $entry not found in $TERMINFO_SRC" >&2
|
||||
done
|
||||
if [ ! -f "$TERMINFO_OUT/x/xterm-256color" ] && [ ! -f "$TERMINFO_OUT/78/xterm-256color" ]; then
|
||||
echo "ERROR: failed to bundle xterm-256color terminfo for mosh-client (linux-$ARCH)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- bundled terminfo ---"
|
||||
find "$TERMINFO_OUT" -type f -print
|
||||
|
||||
BUNDLE_TGZ="$OUT_DIR/mosh-client-linux-$ARCH.tar.gz"
|
||||
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" "mosh-client" "terminfo" )
|
||||
|
||||
( cd "$OUT_DIR" && sha256sum "mosh-client-linux-$ARCH.tar.gz" > "mosh-client-linux-$ARCH.tar.gz.sha256" )
|
||||
cat "$OUT_DIR/mosh-client-linux-$ARCH.tar.gz.sha256"
|
||||
|
||||
@@ -7,8 +7,13 @@
|
||||
# MACOSX_DEPLOYMENT_TARGET — minimum macOS version (default 11.0)
|
||||
#
|
||||
# Output:
|
||||
# $OUT_DIR/mosh-client-darwin-universal
|
||||
# $OUT_DIR/mosh-client-darwin-universal.sha256
|
||||
# $OUT_DIR/mosh-client-darwin-universal.tar.gz (binary + terminfo bundle)
|
||||
# $OUT_DIR/mosh-client-darwin-universal.tar.gz.sha256
|
||||
#
|
||||
# The bundle ships a private terminfo database next to the binary because
|
||||
# our statically-linked ncurses has its compiled-in TERMINFO path pointing
|
||||
# at the build-time prefix (a temp dir). Without bundling, mosh-client
|
||||
# fails with "Terminfo database could not be found." See issue #890.
|
||||
#
|
||||
# Strategy: build OpenSSL/protobuf/ncurses for arm64 and x86_64
|
||||
# (cross-compile via Apple clang's -arch flag), link mosh-client per arch,
|
||||
@@ -110,7 +115,16 @@ build_arch() {
|
||||
CFLAGS="$CFLAGS_COMMON" CXXFLAGS="$CFLAGS_COMMON" LDFLAGS="$LDFLAGS_COMMON"
|
||||
make -j"$(sysctl -n hw.ncpu)"
|
||||
make -C include install
|
||||
make -C ncurses install )
|
||||
make -C ncurses install
|
||||
# Compile + install the terminfo database for the native arch only.
|
||||
# `tic` runs on the build host, so a cross-target build can't compile
|
||||
# terminfo entries — but the .src database is arch-independent, so a
|
||||
# single native-arch install populates $PREFIX/share/terminfo/ with
|
||||
# the data we bundle below.
|
||||
if [ "$ARCH" = "$NATIVE_ARCH" ]; then
|
||||
make -C progs install
|
||||
make -C misc install
|
||||
fi )
|
||||
|
||||
# mosh per-arch build
|
||||
( cd mosh-src
|
||||
@@ -138,7 +152,9 @@ else
|
||||
build_arch arm64
|
||||
fi
|
||||
|
||||
OUT_BIN="$OUT_DIR/mosh-client-darwin-universal"
|
||||
BUNDLE_DIR="$WORK/darwin-universal-bundle"
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
OUT_BIN="$BUNDLE_DIR/mosh-client"
|
||||
lipo -create "$WORK/mosh-client-arm64" "$WORK/mosh-client-x86_64" -output "$OUT_BIN"
|
||||
strip -x "$OUT_BIN" || true
|
||||
|
||||
@@ -157,5 +173,36 @@ if otool -L "$OUT_BIN" | tail -n +2 | awk '{print $1}' | grep -Ev "^(/usr/lib/|/
|
||||
exit 1
|
||||
fi
|
||||
|
||||
( cd "$OUT_DIR" && shasum -a 256 "mosh-client-darwin-universal" > "mosh-client-darwin-universal.sha256" )
|
||||
cat "$OUT_DIR/mosh-client-darwin-universal.sha256"
|
||||
# Bundle the terminfo entries our statically-linked ncurses needs. Pull
|
||||
# from the native-arch prefix where `make -C misc install` ran tic above.
|
||||
TERMINFO_SRC="$WORK/prefix-$NATIVE_ARCH/share/terminfo"
|
||||
TERMINFO_OUT="$BUNDLE_DIR/terminfo"
|
||||
mkdir -p "$TERMINFO_OUT"
|
||||
copy_terminfo_entry() {
|
||||
local name="$1"
|
||||
for src in "$TERMINFO_SRC"/?/"$name" "$TERMINFO_SRC"/??/"$name"; do
|
||||
[ -f "$src" ] || continue
|
||||
local rel
|
||||
rel=$(basename "$(dirname "$src")")
|
||||
mkdir -p "$TERMINFO_OUT/$rel"
|
||||
cp "$src" "$TERMINFO_OUT/$rel/$name"
|
||||
return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
for entry in xterm-256color xterm xterm-color vt100 vt220 ansi screen screen-256color tmux tmux-256color dumb; do
|
||||
copy_terminfo_entry "$entry" || echo "WARN: terminfo entry $entry not found in $TERMINFO_SRC" >&2
|
||||
done
|
||||
if [ ! -f "$TERMINFO_OUT/x/xterm-256color" ] && [ ! -f "$TERMINFO_OUT/78/xterm-256color" ]; then
|
||||
echo "ERROR: failed to bundle xterm-256color terminfo for mosh-client (darwin-universal)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- bundled terminfo ---"
|
||||
find "$TERMINFO_OUT" -type f -print
|
||||
|
||||
BUNDLE_TGZ="$OUT_DIR/mosh-client-darwin-universal.tar.gz"
|
||||
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" "mosh-client" "terminfo" )
|
||||
|
||||
( cd "$OUT_DIR" && shasum -a 256 "mosh-client-darwin-universal.tar.gz" > "mosh-client-darwin-universal.tar.gz.sha256" )
|
||||
cat "$OUT_DIR/mosh-client-darwin-universal.tar.gz.sha256"
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Build mosh-client.exe from upstream mobile-shell/mosh source inside a
|
||||
# Cygwin environment. Phase 1 pinned a third-party prebuilt
|
||||
# (FluentTerminal); this rebuilds it in CI so we own the provenance
|
||||
# end-to-end and ship the same upstream version everywhere.
|
||||
#
|
||||
# Cygwin doesn't make full static linking practical (cygwin1.dll
|
||||
# implements the POSIX runtime; it must be present at runtime), so we
|
||||
# bundle every required Cygwin DLL alongside `mosh-client.exe`. This
|
||||
# keeps the binary reproducible and self-contained — the only
|
||||
# environmental requirement is the Cygwin Project's GPL-3.0 DLLs, all
|
||||
# of which we redistribute under their respective licenses.
|
||||
#
|
||||
# Inputs (env):
|
||||
# MOSH_REF — git ref of mobile-shell/mosh (e.g. mosh-1.4.0)
|
||||
# ARCH — x64 (only — Cygwin's arm64 port isn't release-ready)
|
||||
# OUT_DIR — directory to write mosh-client-win32-<arch>.exe + DLL bundle
|
||||
#
|
||||
# Output:
|
||||
# $OUT_DIR/mosh-client-win32-<arch>.exe
|
||||
# $OUT_DIR/mosh-client-win32-<arch>-dlls/*.dll
|
||||
# $OUT_DIR/mosh-client-win32-<arch>.sha256
|
||||
#
|
||||
# Expected to run inside a Cygwin bash login shell (set up by the CI's
|
||||
# cygwin-install-action with development packages already installed).
|
||||
set -euo pipefail
|
||||
|
||||
: "${MOSH_REF:?missing MOSH_REF}"
|
||||
: "${ARCH:?missing ARCH}"
|
||||
: "${OUT_DIR:?missing OUT_DIR}"
|
||||
|
||||
validate_mosh_ref() {
|
||||
if [[ ! "$MOSH_REF" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]*$ ]] \
|
||||
|| [[ "$MOSH_REF" == *..* ]] \
|
||||
|| [[ "$MOSH_REF" == *@\{* ]] \
|
||||
|| [[ "$MOSH_REF" == */ ]] \
|
||||
|| [[ "$MOSH_REF" == *.lock ]]; then
|
||||
echo "ERROR: invalid MOSH_REF: $MOSH_REF" >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
validate_mosh_ref
|
||||
|
||||
if [ "$ARCH" != "x64" ]; then
|
||||
echo "ERROR: only ARCH=x64 supported by the Cygwin Windows build (got: $ARCH)." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Sanity: must run under Cygwin so we have access to cygcheck and the
|
||||
# Cygwin gcc toolchain.
|
||||
if ! uname -a | grep -qi CYGWIN; then
|
||||
echo "ERROR: build-windows.sh must run inside a Cygwin shell." >&2
|
||||
uname -a >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "$WORK"' EXIT
|
||||
mkdir -p "$OUT_DIR"
|
||||
|
||||
cd "$WORK"
|
||||
|
||||
# Build mosh against the Cygwin-supplied OpenSSL, protobuf, ncurses.
|
||||
# Static linking against those is not supported by the upstream
|
||||
# build for Cygwin, so we accept the dynamic deps and bundle the DLLs.
|
||||
git init mosh
|
||||
git -C mosh remote add origin https://github.com/mobile-shell/mosh.git
|
||||
git -C mosh fetch --depth 1 origin "$MOSH_REF"
|
||||
git -C mosh checkout --detach FETCH_HEAD
|
||||
cd mosh
|
||||
./autogen.sh
|
||||
./configure --enable-completion=no --disable-server \
|
||||
CXXFLAGS="-O2 -static-libgcc -static-libstdc++" \
|
||||
LDFLAGS="-static-libgcc -static-libstdc++"
|
||||
make -j"$(nproc)"
|
||||
|
||||
OUT_EXE="$OUT_DIR/mosh-client-win32-x64.exe"
|
||||
DLL_DIR="$OUT_DIR/mosh-client-win32-x64-dlls"
|
||||
mkdir -p "$DLL_DIR"
|
||||
cp src/frontend/mosh-client.exe "$OUT_EXE"
|
||||
strip "$OUT_EXE"
|
||||
|
||||
echo "--- file ---"
|
||||
file "$OUT_EXE"
|
||||
echo "--- size ---"
|
||||
ls -lh "$OUT_EXE"
|
||||
|
||||
# Walk the import graph via cygcheck and copy every Cygwin-shipped DLL
|
||||
# (paths that normalize to /usr/bin/) so the binary runs anywhere without
|
||||
# an external Cygwin install.
|
||||
echo "--- cygcheck ---"
|
||||
CYGCHECK_OUT="$WORK/cygcheck.txt"
|
||||
cygcheck "$OUT_EXE" | tee "$CYGCHECK_OUT"
|
||||
bundled_count=0
|
||||
while IFS= read -r line; do
|
||||
candidate=$(printf '%s' "$line" | tr -d '\r' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
case "$candidate" in
|
||||
*.dll|*.DLL)
|
||||
# Convert Windows-style paths to Cygwin paths if present.
|
||||
cyg_candidate=$(cygpath -u "$candidate" 2>/dev/null || echo "$candidate")
|
||||
case "$cyg_candidate" in
|
||||
/usr/bin/*.dll|/usr/bin/*.DLL)
|
||||
if [ -f "$cyg_candidate" ]; then
|
||||
base=$(basename "$cyg_candidate")
|
||||
if [ ! -f "$DLL_DIR/$base" ]; then
|
||||
cp "$cyg_candidate" "$DLL_DIR/$base"
|
||||
echo "bundled DLL: $base"
|
||||
bundled_count=$((bundled_count + 1))
|
||||
fi
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
;;
|
||||
esac
|
||||
done < "$CYGCHECK_OUT"
|
||||
|
||||
if [ "$bundled_count" -eq 0 ] || [ ! -f "$DLL_DIR/cygwin1.dll" ]; then
|
||||
echo "ERROR: failed to bundle required Cygwin DLLs for mosh-client.exe." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "--- bundled DLLs ---"
|
||||
ls -lh "$DLL_DIR"
|
||||
|
||||
# License: the Cygwin DLLs ship under various GPL-compatible licenses.
|
||||
# Ship a top-level NOTICE so end users can see what we redistributed.
|
||||
cat > "$DLL_DIR/README.txt" <<'EOF'
|
||||
This directory bundles the Cygwin runtime DLLs required by
|
||||
mosh-client.exe (built from https://github.com/mobile-shell/mosh ).
|
||||
|
||||
cygwin1.dll : LGPL-3.0 (Cygwin Project, https://cygwin.com/)
|
||||
cygcrypto-*.dll : Apache-2.0 (OpenSSL Project, https://www.openssl.org/)
|
||||
cygprotobuf-*.dll : BSD-3-Clause (Google, https://github.com/protocolbuffers/protobuf)
|
||||
cygncursesw-*.dll : MIT-style (Free Software Foundation)
|
||||
cygintl-*.dll : LGPL-2.1 (GNU gettext)
|
||||
cyggcc_s-*.dll, cygstdc++ : GPL-3.0 with GCC Runtime Library Exception
|
||||
|
||||
The full text of each license is reproduced in the upstream source
|
||||
tree of the respective project.
|
||||
EOF
|
||||
|
||||
# Bundle exe + DLLs into a single tar.gz artifact for distribution.
|
||||
# fetch-mosh-binaries.cjs unpacks the tarball into the local
|
||||
# resources/mosh/win32-x64/ directory.
|
||||
BUNDLE_TGZ="$OUT_DIR/mosh-client-win32-x64.tar.gz"
|
||||
BUNDLE_DIR="$WORK/win32-x64-bundle"
|
||||
mkdir -p "$BUNDLE_DIR"
|
||||
cp "$OUT_EXE" "$BUNDLE_DIR/mosh-client.exe"
|
||||
cp -R "$DLL_DIR" "$BUNDLE_DIR/mosh-client-win32-x64-dlls"
|
||||
( cd "$BUNDLE_DIR" && tar -czf "$BUNDLE_TGZ" \
|
||||
"mosh-client.exe" \
|
||||
"mosh-client-win32-x64-dlls" )
|
||||
|
||||
( cd "$OUT_DIR" && sha256sum "mosh-client-win32-x64.exe" > "mosh-client-win32-x64.sha256" )
|
||||
( cd "$OUT_DIR" && sha256sum "mosh-client-win32-x64.tar.gz" > "mosh-client-win32-x64.tar.gz.sha256" )
|
||||
cat "$OUT_DIR/mosh-client-win32-x64.sha256"
|
||||
cat "$OUT_DIR/mosh-client-win32-x64.tar.gz.sha256"
|
||||
@@ -1,12 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase-1 source: pin to the FluentTerminal-shipped mosh-cygwin standalone
|
||||
# Source: pin to the FluentTerminal-shipped mosh-cygwin standalone
|
||||
# build (PE32+ x86-64, statically linked Cygwin runtime, no cygwin1.dll
|
||||
# dependency). FluentTerminal is GPL-3.0 — same license as netcatty —
|
||||
# and the binary itself is GPL-3.0 from upstream mobile-shell/mosh.
|
||||
#
|
||||
# Phase-2 replaced this fetch with an in-CI Cygwin build from upstream
|
||||
# source so we own the provenance end-to-end.
|
||||
#
|
||||
# dependency). FluentTerminal is GPL-3.0, same license as Netcatty, and
|
||||
# the binary itself is GPL-3.0 from upstream mobile-shell/mosh.
|
||||
# The pinned commit is FluentTerminal master @ bad0f85 (2019-09-12), which
|
||||
# is the commit where the prebuilt mosh-client.exe was added to the repo.
|
||||
# Verifying SHA256 against a frozen value protects against silent updates.
|
||||
|
||||
@@ -23,6 +23,13 @@
|
||||
// MOSH_BIN_RES_DIR — override output dir for tests.
|
||||
// MOSH_BIN_ALLOW_UNVERIFIED=true — explicit local escape hatch for mirrors
|
||||
// without SHA256SUMS. Never use for release builds.
|
||||
// MOSH_BIN_FORCE_WINDOWS_CYGWIN=true — debug escape hatch for the upstream
|
||||
// Cygwin Windows bundle. The default Windows x64 asset
|
||||
// is the FluentTerminal-pinned standalone client because
|
||||
// the current Cygwin build clears the terminal and never
|
||||
// renders remote output on Windows.
|
||||
// MOSH_BIN_WINDOWS_LEGACY_URL / MOSH_BIN_WINDOWS_LEGACY_SHA256 — test/mirror
|
||||
// overrides for that pinned Windows fallback.
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
@@ -35,22 +42,84 @@ const { main: resolveMoshBinRelease } = require("./resolve-mosh-bin-release.cjs"
|
||||
|
||||
const ROOT = path.resolve(__dirname, "..");
|
||||
const DEFAULT_RES_DIR = path.join(ROOT, "resources", "mosh");
|
||||
const WINDOWS_LEGACY_FLUENT_MOSH_CLIENT = {
|
||||
id: "windows-fluentterminal-standalone",
|
||||
file: "mosh-client-win32-x64.exe",
|
||||
local: "win32-x64/mosh-client.exe",
|
||||
url: "https://raw.githubusercontent.com/felixse/FluentTerminal/bad0f85/Dependencies/MoshExecutables/x64/mosh-client.exe",
|
||||
sha256: "5a8d84ff205c6a0711e53b961f909484a892f42648807e52d46d4fa93c05e286",
|
||||
};
|
||||
|
||||
// (file basename in the release -> relative subpath under resources/mosh/)
|
||||
// Using flat names in the release for SHA256SUMS readability, then
|
||||
// fanning out into platform-arch subdirs locally.
|
||||
//
|
||||
// `extract` indicates a tar.gz archive containing the binary + helper
|
||||
// DLLs (Windows). The tarball is unpacked into the platform-arch
|
||||
// directory so resources/mosh/win32-x64/ ends up with mosh-client.exe
|
||||
// alongside cygwin1.dll, cygcrypto-*.dll, etc.
|
||||
// Linux/macOS targets are tar.gz bundles containing the binary plus the
|
||||
// runtime helpers each platform needs. Windows x64 defaults to the
|
||||
// SHA256-pinned FluentTerminal standalone exe because the tested Cygwin
|
||||
// bundle clears the terminal and never renders remote output on Windows.
|
||||
// Bundling terminfo lets bundled Posix mosh-client builds work on
|
||||
// minimal hosts that don't have a
|
||||
// system ncurses-base — see issue #890.
|
||||
//
|
||||
// `legacy` describes the pre-bundle artifact name some published mosh
|
||||
// binary releases still ship (Linux/Darwin used flat files before the
|
||||
// bundle layout). When SHA256SUMS lists only the legacy name we fall
|
||||
// back to it so existing releases keep working until a new tag is
|
||||
// republished with the bundle layout.
|
||||
const TARGETS = [
|
||||
{ platform: "linux", arch: "x64", file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
|
||||
{ platform: "linux", arch: "arm64", file: "mosh-client-linux-arm64", local: "linux-arm64/mosh-client" },
|
||||
{ platform: "darwin", arch: "universal", file: "mosh-client-darwin-universal", local: "darwin-universal/mosh-client" },
|
||||
{ platform: "win32", arch: "x64", file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz" },
|
||||
{
|
||||
platform: "linux", arch: "x64",
|
||||
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
|
||||
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
|
||||
},
|
||||
{
|
||||
platform: "linux", arch: "arm64",
|
||||
file: "mosh-client-linux-arm64.tar.gz", localDir: "linux-arm64", extract: "tar.gz",
|
||||
legacy: { file: "mosh-client-linux-arm64", local: "linux-arm64/mosh-client" },
|
||||
},
|
||||
{
|
||||
platform: "darwin", arch: "universal",
|
||||
file: "mosh-client-darwin-universal.tar.gz", localDir: "darwin-universal", extract: "tar.gz",
|
||||
legacy: { file: "mosh-client-darwin-universal", local: "darwin-universal/mosh-client" },
|
||||
},
|
||||
{
|
||||
platform: "win32", arch: "x64",
|
||||
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
|
||||
legacy: WINDOWS_LEGACY_FLUENT_MOSH_CLIENT,
|
||||
preferLegacy: true,
|
||||
},
|
||||
];
|
||||
|
||||
function applyReleaseAssetOverrides(asset, opts = {}) {
|
||||
if (asset.id !== WINDOWS_LEGACY_FLUENT_MOSH_CLIENT.id) return asset;
|
||||
return {
|
||||
...asset,
|
||||
url: opts.windowsLegacyUrl || asset.url,
|
||||
sha256: opts.windowsLegacySha256 || asset.sha256,
|
||||
};
|
||||
}
|
||||
|
||||
function selectReleaseAsset(target, sums, opts = {}) {
|
||||
const primary = { file: target.file, extract: target.extract, local: target.local, localDir: target.localDir };
|
||||
if (!target.legacy) return primary;
|
||||
if (target.preferLegacy && !opts.forceWindowsCygwin) {
|
||||
const legacy = applyReleaseAssetOverrides(target.legacy, opts);
|
||||
if (sums.get(target.legacy.file) === legacy.sha256) {
|
||||
return { file: target.legacy.file, local: target.legacy.local, sha256: legacy.sha256 };
|
||||
}
|
||||
return legacy;
|
||||
}
|
||||
// SHA256SUMS unavailable (allowUnverified mirror) — keep the primary
|
||||
// and let download / extraction errors surface naturally.
|
||||
if (sums.size === 0) return primary;
|
||||
if (sums.has(target.file)) return primary;
|
||||
if (sums.has(target.legacy.file)) {
|
||||
return applyReleaseAssetOverrides({ file: target.legacy.file, local: target.legacy.local }, opts);
|
||||
}
|
||||
return primary;
|
||||
}
|
||||
|
||||
function log(msg) { console.log(`[fetch-mosh-binaries] ${msg}`); }
|
||||
function warn(msg) { console.warn(`[fetch-mosh-binaries] WARN ${msg}`); }
|
||||
|
||||
@@ -183,6 +252,20 @@ function assertExtractedTreeSafe(root) {
|
||||
}
|
||||
}
|
||||
|
||||
function assertBundledTerminfo(extractDir, target) {
|
||||
const terminfoDir = path.join(extractDir, "terminfo");
|
||||
const terminfoEntry = [
|
||||
path.join(terminfoDir, "x", "xterm-256color"),
|
||||
path.join(terminfoDir, "78", "xterm-256color"),
|
||||
].find((entry) => fs.existsSync(entry));
|
||||
if (terminfoEntry && !fs.lstatSync(terminfoEntry).isFile()) {
|
||||
throw new Error(`${target.file} contained invalid terminfo for xterm-256color`);
|
||||
}
|
||||
if (!terminfoEntry) {
|
||||
warn(`${target.file} did not contain terminfo for xterm-256color; ${target.platform}-${target.arch} mosh packaging will fall back to host system terminfo (issue #890).`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeWindowsBundle(extractDir, target) {
|
||||
const genericExe = path.join(extractDir, "mosh-client.exe");
|
||||
const legacyExe = path.join(extractDir, `mosh-client-${target.platform}-${target.arch}.exe`);
|
||||
@@ -196,9 +279,28 @@ function normalizeWindowsBundle(extractDir, target) {
|
||||
if (!fs.existsSync(dllDir) || !fs.statSync(dllDir).isDirectory()) {
|
||||
throw new Error(`${target.file} did not contain ${path.basename(dllDir)}/`);
|
||||
}
|
||||
assertBundledTerminfo(extractDir, target);
|
||||
chmodExecutable(genericExe);
|
||||
}
|
||||
|
||||
function normalizePosixBundle(extractDir, target) {
|
||||
const binary = path.join(extractDir, "mosh-client");
|
||||
const legacyBinary = path.join(extractDir, `mosh-client-${target.platform}-${target.arch}`);
|
||||
if (!fs.existsSync(binary) && fs.existsSync(legacyBinary)) {
|
||||
fs.renameSync(legacyBinary, binary);
|
||||
}
|
||||
if (!fs.existsSync(binary) || !fs.lstatSync(binary).isFile()) {
|
||||
throw new Error(`${target.file} did not contain mosh-client`);
|
||||
}
|
||||
assertBundledTerminfo(extractDir, target);
|
||||
chmodExecutable(binary);
|
||||
}
|
||||
|
||||
function normalizeBundle(extractDir, target) {
|
||||
if (target.platform === "win32") return normalizeWindowsBundle(extractDir, target);
|
||||
return normalizePosixBundle(extractDir, target);
|
||||
}
|
||||
|
||||
function replaceDir(srcDir, destDir) {
|
||||
fs.rmSync(destDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||
@@ -226,9 +328,7 @@ function unpackTarGz(buf, target, { resDir }) {
|
||||
stdio: "inherit",
|
||||
});
|
||||
assertExtractedTreeSafe(extractDir);
|
||||
if (target.platform === "win32") {
|
||||
normalizeWindowsBundle(extractDir, target);
|
||||
}
|
||||
normalizeBundle(extractDir, target);
|
||||
replaceDir(extractDir, destDir);
|
||||
} finally {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
@@ -236,38 +336,54 @@ function unpackTarGz(buf, target, { resDir }) {
|
||||
return destDir;
|
||||
}
|
||||
|
||||
function writeFlatAsset(buf, target, asset, { resDir }) {
|
||||
const dest = path.join(resDir, asset.local);
|
||||
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-flat-"));
|
||||
const tmpDest = path.join(tmpRoot, path.basename(dest));
|
||||
try {
|
||||
fs.writeFileSync(tmpDest, buf);
|
||||
if (target.platform !== "win32") fs.chmodSync(tmpDest, 0o755);
|
||||
replaceDir(tmpRoot, path.dirname(dest));
|
||||
} catch (err) {
|
||||
fs.rmSync(tmpRoot, { recursive: true, force: true });
|
||||
throw err;
|
||||
}
|
||||
return dest;
|
||||
}
|
||||
|
||||
async function fetchOne(target, sums, opts) {
|
||||
const { baseUrl, resDir, allowUnverified = false } = opts;
|
||||
const url = `${baseUrl}/${target.file}`;
|
||||
const asset = selectReleaseAsset(target, sums, opts);
|
||||
if (asset.file !== target.file) {
|
||||
log(`using legacy asset ${asset.file} for ${target.platform}-${target.arch}`);
|
||||
}
|
||||
const url = asset.url || `${baseUrl}/${asset.file}`;
|
||||
let buf;
|
||||
try {
|
||||
buf = await follow(url);
|
||||
} catch (err) {
|
||||
throw new Error(`download failed for ${target.file}: ${err.message}`);
|
||||
throw new Error(`download failed for ${asset.file}: ${err.message}`);
|
||||
}
|
||||
|
||||
const expected = sums.get(target.file);
|
||||
const expected = asset.sha256 || sums.get(asset.file);
|
||||
const actual = crypto.createHash("sha256").update(buf).digest("hex");
|
||||
if (expected && expected !== actual) {
|
||||
throw new Error(`SHA256 mismatch for ${target.file}: expected ${expected}, got ${actual}`);
|
||||
throw new Error(`SHA256 mismatch for ${asset.file}: expected ${expected}, got ${actual}`);
|
||||
}
|
||||
if (!expected) {
|
||||
if (!allowUnverified) {
|
||||
throw new Error(`no SHA256 entry for ${target.file}`);
|
||||
throw new Error(`no SHA256 entry for ${asset.file}`);
|
||||
}
|
||||
warn(`no SHA256 entry for ${target.file} - accepting actual ${actual}`);
|
||||
warn(`no SHA256 entry for ${asset.file} - accepting actual ${actual}`);
|
||||
}
|
||||
|
||||
if (target.extract === "tar.gz") {
|
||||
if (asset.extract === "tar.gz") {
|
||||
const destDir = unpackTarGz(buf, target, { resDir });
|
||||
log(`unpacked ${target.file} into ${path.relative(ROOT, destDir)}/ (sha256=${actual})`);
|
||||
log(`unpacked ${asset.file} into ${path.relative(ROOT, destDir)}/ (sha256=${actual})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
const dest = path.join(resDir, target.local);
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.writeFileSync(dest, buf);
|
||||
if (target.platform !== "win32") fs.chmodSync(dest, 0o755);
|
||||
const dest = writeFlatAsset(buf, target, asset, { resDir });
|
||||
log(`wrote ${path.relative(ROOT, dest)} (${buf.length} bytes, sha256=${actual})`);
|
||||
return true;
|
||||
}
|
||||
@@ -299,6 +415,7 @@ async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
`https://github.com/${owner}/${repo}/releases/download/${encodeURIComponent(release)}`;
|
||||
const resDir = path.resolve(env.MOSH_BIN_RES_DIR || DEFAULT_RES_DIR);
|
||||
const allowUnverified = env.MOSH_BIN_ALLOW_UNVERIFIED === "true";
|
||||
const forceWindowsCygwin = env.MOSH_BIN_FORCE_WINDOWS_CYGWIN === "true";
|
||||
const platformFilter = hostTarget?.platform || platformArg;
|
||||
const archFilter = hostTarget?.arch || archArg;
|
||||
|
||||
@@ -310,7 +427,14 @@ async function main(argv = process.argv.slice(2), env = process.env) {
|
||||
if (platformFilter && target.platform !== platformFilter) continue;
|
||||
if (archFilter && target.arch !== archFilter) continue;
|
||||
total += 1;
|
||||
if (await fetchOne(target, sums, { baseUrl, resDir, allowUnverified })) ok += 1;
|
||||
if (await fetchOne(target, sums, {
|
||||
baseUrl,
|
||||
resDir,
|
||||
allowUnverified,
|
||||
forceWindowsCygwin,
|
||||
windowsLegacyUrl: env.MOSH_BIN_WINDOWS_LEGACY_URL,
|
||||
windowsLegacySha256: env.MOSH_BIN_WINDOWS_LEGACY_SHA256,
|
||||
})) ok += 1;
|
||||
}
|
||||
log(`done - ${ok}/${total} binaries written`);
|
||||
if (ok < total) throw new Error(`only wrote ${ok}/${total} requested binaries`);
|
||||
@@ -331,8 +455,10 @@ module.exports = {
|
||||
resolveHostTarget,
|
||||
resolveTarArchiveInvocation,
|
||||
parseSums,
|
||||
selectReleaseAsset,
|
||||
validateTarEntries,
|
||||
assertExtractedTreeSafe,
|
||||
unpackTarGz,
|
||||
writeFlatAsset,
|
||||
main,
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
replaceDir,
|
||||
resolveHostTarget,
|
||||
resolveTarArchiveInvocation,
|
||||
selectReleaseAsset,
|
||||
} = require("./fetch-mosh-binaries.cjs");
|
||||
|
||||
function makeTmp(t) {
|
||||
@@ -183,6 +184,7 @@ test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", as
|
||||
const tar = makeTarGz(t, {
|
||||
"mosh-client-win32-x64.exe": "exe",
|
||||
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
|
||||
"terminfo/x/xterm-256color": "terminfo",
|
||||
});
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-win32-x64.tar.gz": tar,
|
||||
@@ -195,6 +197,7 @@ test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", as
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
@@ -202,6 +205,65 @@ test("fetch-mosh-binaries normalizes the Windows tarball to mosh-client.exe", as
|
||||
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client.exe")), true);
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll")), true);
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "terminfo", "x", "xterm-256color")), true);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries accepts legacy Windows bundles without terminfo", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const tar = makeTarGz(t, {
|
||||
"mosh-client.exe": "exe",
|
||||
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
|
||||
});
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-win32-x64.tar.gz": tar,
|
||||
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
|
||||
});
|
||||
|
||||
const { stderr } = await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
assert.match(stderr, /did not contain terminfo for xterm-256color/);
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client.exe")), true);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries rejects invalid Windows terminfo entries", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const srcDir = makeTmp(t);
|
||||
fs.writeFileSync(path.join(srcDir, "mosh-client.exe"), "exe");
|
||||
fs.mkdirSync(path.join(srcDir, "mosh-client-win32-x64-dlls"), { recursive: true });
|
||||
fs.writeFileSync(path.join(srcDir, "mosh-client-win32-x64-dlls", "cygwin1.dll"), "dll");
|
||||
fs.mkdirSync(path.join(srcDir, "terminfo", "x", "xterm-256color"), { recursive: true });
|
||||
const tarPath = path.join(makeTmp(t), "invalid-terminfo.tar.gz");
|
||||
execFileSync("tar", ["-czf", tarPath, "-C", srcDir, "mosh-client.exe", "mosh-client-win32-x64-dlls", "terminfo"], { stdio: "pipe" });
|
||||
const tar = fs.readFileSync(tarPath);
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-win32-x64.tar.gz": tar,
|
||||
SHA256SUMS: `${sha256(tar)} mosh-client-win32-x64.tar.gz\n`,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
}),
|
||||
/invalid terminfo for xterm-256color/,
|
||||
);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", async (t) => {
|
||||
@@ -209,6 +271,7 @@ test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", asyn
|
||||
const tar = makeTarGz(t, {
|
||||
"mosh-client.exe": "exe",
|
||||
"mosh-client-win32-x64-dlls/cygwin1.dll": "dll",
|
||||
"terminfo/x/xterm-256color": "terminfo",
|
||||
});
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-win32-x64.tar.gz": tar,
|
||||
@@ -222,6 +285,7 @@ test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", asyn
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
@@ -229,6 +293,264 @@ test("fetch-mosh-binaries fails when SHA256SUMS lacks the requested asset", asyn
|
||||
);
|
||||
});
|
||||
|
||||
test("selectReleaseAsset prefers the bundled tarball when listed in SHA256SUMS", () => {
|
||||
const target = {
|
||||
platform: "linux", arch: "x64",
|
||||
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
|
||||
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
|
||||
};
|
||||
const sums = new Map([
|
||||
["mosh-client-linux-x64.tar.gz", "abc"],
|
||||
["mosh-client-linux-x64", "def"],
|
||||
]);
|
||||
assert.equal(selectReleaseAsset(target, sums).file, "mosh-client-linux-x64.tar.gz");
|
||||
});
|
||||
|
||||
test("selectReleaseAsset falls back to the legacy flat asset when only it is published", () => {
|
||||
const target = {
|
||||
platform: "linux", arch: "x64",
|
||||
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
|
||||
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
|
||||
};
|
||||
const sums = new Map([["mosh-client-linux-x64", "def"]]);
|
||||
const asset = selectReleaseAsset(target, sums);
|
||||
assert.equal(asset.file, "mosh-client-linux-x64");
|
||||
assert.equal(asset.local, "linux-x64/mosh-client");
|
||||
assert.equal(asset.extract, undefined);
|
||||
});
|
||||
|
||||
test("selectReleaseAsset stays on the primary when SHA256SUMS is empty (unverified mirror)", () => {
|
||||
const target = {
|
||||
platform: "linux", arch: "x64",
|
||||
file: "mosh-client-linux-x64.tar.gz", localDir: "linux-x64", extract: "tar.gz",
|
||||
legacy: { file: "mosh-client-linux-x64", local: "linux-x64/mosh-client" },
|
||||
};
|
||||
assert.equal(selectReleaseAsset(target, new Map()).file, "mosh-client-linux-x64.tar.gz");
|
||||
});
|
||||
|
||||
test("selectReleaseAsset prefers the pinned Windows standalone client by default", () => {
|
||||
const target = {
|
||||
platform: "win32", arch: "x64",
|
||||
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
|
||||
legacy: {
|
||||
id: "windows-fluentterminal-standalone",
|
||||
file: "mosh-client-win32-x64.exe",
|
||||
local: "win32-x64/mosh-client.exe",
|
||||
url: "https://example.test/mosh-client.exe",
|
||||
sha256: "abc",
|
||||
},
|
||||
preferLegacy: true,
|
||||
};
|
||||
const sums = new Map([["mosh-client-win32-x64.tar.gz", "def"]]);
|
||||
|
||||
assert.equal(selectReleaseAsset(target, sums).file, "mosh-client-win32-x64.exe");
|
||||
assert.equal(selectReleaseAsset(target, sums, { forceWindowsCygwin: true }).file, "mosh-client-win32-x64.tar.gz");
|
||||
});
|
||||
|
||||
test("selectReleaseAsset uses the released Windows standalone asset when published", () => {
|
||||
const target = {
|
||||
platform: "win32", arch: "x64",
|
||||
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
|
||||
legacy: {
|
||||
id: "windows-fluentterminal-standalone",
|
||||
file: "mosh-client-win32-x64.exe",
|
||||
local: "win32-x64/mosh-client.exe",
|
||||
url: "https://example.test/mosh-client.exe",
|
||||
sha256: "abc",
|
||||
},
|
||||
preferLegacy: true,
|
||||
};
|
||||
const asset = selectReleaseAsset(target, new Map([["mosh-client-win32-x64.exe", "abc"]]));
|
||||
|
||||
assert.equal(asset.file, "mosh-client-win32-x64.exe");
|
||||
assert.equal(asset.local, "win32-x64/mosh-client.exe");
|
||||
assert.equal(asset.url, undefined);
|
||||
assert.equal(asset.sha256, "abc");
|
||||
});
|
||||
|
||||
test("selectReleaseAsset ignores a released Windows asset when its checksum is not the pinned standalone", () => {
|
||||
const target = {
|
||||
platform: "win32", arch: "x64",
|
||||
file: "mosh-client-win32-x64.tar.gz", localDir: "win32-x64", extract: "tar.gz",
|
||||
legacy: {
|
||||
id: "windows-fluentterminal-standalone",
|
||||
file: "mosh-client-win32-x64.exe",
|
||||
local: "win32-x64/mosh-client.exe",
|
||||
url: "https://example.test/mosh-client.exe",
|
||||
sha256: "abc",
|
||||
},
|
||||
preferLegacy: true,
|
||||
};
|
||||
const asset = selectReleaseAsset(target, new Map([["mosh-client-win32-x64.exe", "def"]]));
|
||||
|
||||
assert.equal(asset.file, "mosh-client-win32-x64.exe");
|
||||
assert.equal(asset.local, "win32-x64/mosh-client.exe");
|
||||
assert.equal(asset.url, "https://example.test/mosh-client.exe");
|
||||
assert.equal(asset.sha256, "abc");
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries downloads the pinned Windows standalone client", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const flat = Buffer.from("working-windows-standalone");
|
||||
fs.mkdirSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls"), { recursive: true });
|
||||
fs.mkdirSync(path.join(resDir, "win32-x64", "terminfo", "78"), { recursive: true });
|
||||
fs.writeFileSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"), "stale");
|
||||
fs.writeFileSync(path.join(resDir, "win32-x64", "terminfo", "78", "xterm-256color"), "stale");
|
||||
const baseUrl = await serveAssets(t, {
|
||||
SHA256SUMS: "",
|
||||
});
|
||||
const legacyBaseUrl = await serveAssets(t, {
|
||||
"mosh-client-win32-x64.exe": flat,
|
||||
});
|
||||
|
||||
await execFileAsync(process.execPath, [script, "--platform=win32", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
MOSH_BIN_WINDOWS_LEGACY_URL: `${legacyBaseUrl}/mosh-client-win32-x64.exe`,
|
||||
MOSH_BIN_WINDOWS_LEGACY_SHA256: sha256(flat),
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
const dest = path.join(resDir, "win32-x64", "mosh-client.exe");
|
||||
assert.equal(fs.existsSync(dest), true);
|
||||
assert.equal(fs.readFileSync(dest, "utf8"), "working-windows-standalone");
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "mosh-client-win32-x64-dlls")), false);
|
||||
assert.equal(fs.existsSync(path.join(resDir, "win32-x64", "terminfo")), false);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries falls back to the legacy flat asset for older releases", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const flat = Buffer.from("legacy-binary");
|
||||
fs.mkdirSync(path.join(resDir, "linux-x64", "terminfo", "78"), { recursive: true });
|
||||
fs.writeFileSync(path.join(resDir, "linux-x64", "terminfo", "78", "xterm-256color"), "stale");
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-linux-x64": flat,
|
||||
SHA256SUMS: `${sha256(flat)} mosh-client-linux-x64\n`,
|
||||
});
|
||||
|
||||
await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "mosh-client")), true);
|
||||
assert.equal(fs.readFileSync(path.join(resDir, "linux-x64", "mosh-client"), "utf8"), "legacy-binary");
|
||||
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "terminfo")), false);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries unpacks the Linux tarball with bundled terminfo", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const tar = makeTarGz(t, {
|
||||
"mosh-client": "binary",
|
||||
"terminfo/x/xterm-256color": "terminfo",
|
||||
});
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-linux-x64.tar.gz": tar,
|
||||
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
|
||||
});
|
||||
|
||||
await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "mosh-client")), true);
|
||||
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "terminfo", "x", "xterm-256color")), true);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries warns when the Linux tarball lacks terminfo", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const tar = makeTarGz(t, {
|
||||
"mosh-client": "binary",
|
||||
});
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-linux-x64.tar.gz": tar,
|
||||
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
|
||||
});
|
||||
|
||||
const { stderr } = await execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
assert.match(stderr, /did not contain terminfo for xterm-256color/);
|
||||
assert.equal(fs.existsSync(path.join(resDir, "linux-x64", "mosh-client")), true);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries unpacks the Darwin tarball with bundled terminfo", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const tar = makeTarGz(t, {
|
||||
"mosh-client": "binary",
|
||||
"terminfo/x/xterm-256color": "terminfo",
|
||||
});
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-darwin-universal.tar.gz": tar,
|
||||
SHA256SUMS: `${sha256(tar)} mosh-client-darwin-universal.tar.gz\n`,
|
||||
});
|
||||
|
||||
await execFileAsync(process.execPath, [script, "--platform=darwin", "--arch=universal"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
});
|
||||
|
||||
assert.equal(fs.existsSync(path.join(resDir, "darwin-universal", "mosh-client")), true);
|
||||
assert.equal(fs.existsSync(path.join(resDir, "darwin-universal", "terminfo", "x", "xterm-256color")), true);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries rejects a Linux tarball without mosh-client", async (t) => {
|
||||
const resDir = path.join(makeTmp(t), "resources", "mosh");
|
||||
const tar = makeTarGz(t, {
|
||||
"terminfo/x/xterm-256color": "terminfo",
|
||||
});
|
||||
const baseUrl = await serveAssets(t, {
|
||||
"mosh-client-linux-x64.tar.gz": tar,
|
||||
SHA256SUMS: `${sha256(tar)} mosh-client-linux-x64.tar.gz\n`,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
execFileAsync(process.execPath, [script, "--platform=linux", "--arch=x64"], {
|
||||
env: {
|
||||
...process.env,
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: resDir,
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
}),
|
||||
/did not contain mosh-client/,
|
||||
);
|
||||
});
|
||||
|
||||
test("fetch-mosh-binaries rejects symlinks inside Windows tarballs", { skip: process.platform === "win32" }, async (t) => {
|
||||
const srcDir = makeTmp(t);
|
||||
fs.writeFileSync(path.join(srcDir, "outside.exe"), "outside");
|
||||
@@ -250,6 +572,7 @@ test("fetch-mosh-binaries rejects symlinks inside Windows tarballs", { skip: pro
|
||||
MOSH_BIN_RELEASE: "test",
|
||||
MOSH_BIN_BASE_URL: baseUrl,
|
||||
MOSH_BIN_RES_DIR: path.join(makeTmp(t), "resources", "mosh"),
|
||||
MOSH_BIN_FORCE_WINDOWS_CYGWIN: "true",
|
||||
CI: "true",
|
||||
},
|
||||
stdio: "pipe",
|
||||
|
||||
@@ -32,30 +32,52 @@ function moshExtraResources(platform) {
|
||||
if (platform === "darwin") {
|
||||
const file = path.join(moshRoot, "darwin-universal", "mosh-client");
|
||||
if (!hasFile(file)) return [];
|
||||
return [
|
||||
const resources = [
|
||||
{ from: "resources/mosh/darwin-universal/", to: "mosh/", filter: ["mosh-client"] },
|
||||
];
|
||||
const terminfoDir = path.join(moshRoot, "darwin-universal", "terminfo");
|
||||
if (hasDir(terminfoDir)) {
|
||||
resources.push({ from: "resources/mosh/darwin-universal/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] });
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
if (platform === "linux") {
|
||||
const arch = requestedArch();
|
||||
const file = path.join(moshRoot, `linux-${arch}`, "mosh-client");
|
||||
if (!hasFile(file)) return [];
|
||||
return [{ from: `resources/mosh/linux-${arch}/`, to: "mosh/", filter: ["mosh-client"] }];
|
||||
const resources = [
|
||||
{ from: `resources/mosh/linux-${arch}/`, to: "mosh/", filter: ["mosh-client"] },
|
||||
];
|
||||
const terminfoDir = path.join(moshRoot, `linux-${arch}`, "terminfo");
|
||||
if (hasDir(terminfoDir)) {
|
||||
resources.push({ from: `resources/mosh/linux-${arch}/terminfo/`, to: "mosh/terminfo/", filter: ["**/*"] });
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
if (platform === "win32") {
|
||||
// Windows ships mosh-client.exe + Cygwin DLL bundle (cygwin1.dll,
|
||||
// cygcrypto-*.dll, etc.) — copy the entire arch directory so the
|
||||
// exe finds its DLLs at runtime via Windows' default search order.
|
||||
// Windows normally ships the pinned standalone mosh-client.exe. Keep
|
||||
// optional DLL/terminfo packaging so older Cygwin bundles remain usable.
|
||||
const arch = requestedArch();
|
||||
const exe = path.join(moshRoot, `win32-${arch}`, "mosh-client.exe");
|
||||
const dllDir = path.join(moshRoot, `win32-${arch}`, `mosh-client-win32-${arch}-dlls`);
|
||||
if (!hasFile(exe) || !hasDir(dllDir)) return [];
|
||||
return [
|
||||
if (!hasFile(exe)) return [];
|
||||
const resources = [
|
||||
{ from: `resources/mosh/win32-${arch}/`, to: "mosh/", filter: ["mosh-client.exe"] },
|
||||
{ from: `resources/mosh/win32-${arch}/mosh-client-win32-${arch}-dlls/`, to: "mosh/", filter: ["**/*"] },
|
||||
];
|
||||
if (hasDir(dllDir)) {
|
||||
resources.push({
|
||||
from: `resources/mosh/win32-${arch}/mosh-client-win32-${arch}-dlls/`,
|
||||
to: `mosh/mosh-client-win32-${arch}-dlls/`,
|
||||
filter: ["**/*"],
|
||||
});
|
||||
}
|
||||
const terminfoDir = path.join(moshRoot, `win32-${arch}`, "terminfo");
|
||||
if (hasDir(terminfoDir)) {
|
||||
resources.push({ from: `resources/mosh/win32-${arch}/terminfo/`, to: "mosh/terminfo/", filter: ["**/*"] });
|
||||
}
|
||||
return resources;
|
||||
}
|
||||
|
||||
return [];
|
||||
|
||||
@@ -8,7 +8,10 @@ const { moshExtraResources } = require("./mosh-extra-resources.cjs");
|
||||
|
||||
function makeTmp(t) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-mosh-resources-"));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
t.after(() => {
|
||||
if (process.cwd().startsWith(dir)) process.chdir(os.tmpdir());
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
return dir;
|
||||
}
|
||||
|
||||
@@ -29,7 +32,7 @@ function writeFile(filePath) {
|
||||
fs.writeFileSync(filePath, "x");
|
||||
}
|
||||
|
||||
test("moshExtraResources returns concrete Linux arch paths", (t) => {
|
||||
test("moshExtraResources returns concrete Linux arch paths (legacy bundle without terminfo)", (t) => {
|
||||
const root = makeTmp(t);
|
||||
withCwdAndArch(t, root, "x64");
|
||||
writeFile(path.join(root, "resources", "mosh", "linux-x64", "mosh-client"));
|
||||
@@ -40,18 +43,78 @@ test("moshExtraResources returns concrete Linux arch paths", (t) => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("moshExtraResources packages bundled terminfo on Linux when present", (t) => {
|
||||
const root = makeTmp(t);
|
||||
withCwdAndArch(t, root, "arm64");
|
||||
writeFile(path.join(root, "resources", "mosh", "linux-arm64", "mosh-client"));
|
||||
writeFile(path.join(root, "resources", "mosh", "linux-arm64", "terminfo", "x", "xterm-256color"));
|
||||
|
||||
const got = moshExtraResources("linux");
|
||||
assert.deepEqual(got, [
|
||||
{ from: "resources/mosh/linux-arm64/", to: "mosh/", filter: ["mosh-client"] },
|
||||
{ from: "resources/mosh/linux-arm64/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("moshExtraResources packages bundled terminfo on Darwin when present", (t) => {
|
||||
const root = makeTmp(t);
|
||||
withCwdAndArch(t, root, "x64");
|
||||
writeFile(path.join(root, "resources", "mosh", "darwin-universal", "mosh-client"));
|
||||
writeFile(path.join(root, "resources", "mosh", "darwin-universal", "terminfo", "x", "xterm-256color"));
|
||||
|
||||
const got = moshExtraResources("darwin");
|
||||
assert.deepEqual(got, [
|
||||
{ from: "resources/mosh/darwin-universal/", to: "mosh/", filter: ["mosh-client"] },
|
||||
{ from: "resources/mosh/darwin-universal/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("moshExtraResources returns concrete Windows arch paths only when that arch exists", (t) => {
|
||||
const root = makeTmp(t);
|
||||
withCwdAndArch(t, root, "x64");
|
||||
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
|
||||
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"));
|
||||
writeFile(path.join(root, "resources", "mosh", "win32-x64", "terminfo", "x", "xterm-256color"));
|
||||
|
||||
const got = moshExtraResources("win32");
|
||||
assert.deepEqual(got, [
|
||||
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
|
||||
{
|
||||
from: "resources/mosh/win32-x64/mosh-client-win32-x64-dlls/",
|
||||
to: "mosh/mosh-client-win32-x64-dlls/",
|
||||
filter: ["**/*"],
|
||||
},
|
||||
{ from: "resources/mosh/win32-x64/terminfo/", to: "mosh/terminfo/", filter: ["**/*"] },
|
||||
]);
|
||||
|
||||
process.env.npm_config_arch = "arm64";
|
||||
assert.deepEqual(moshExtraResources("win32"), []);
|
||||
});
|
||||
|
||||
test("moshExtraResources keeps legacy Windows bundles packageable", (t) => {
|
||||
const root = makeTmp(t);
|
||||
withCwdAndArch(t, root, "x64");
|
||||
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
|
||||
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client-win32-x64-dlls", "cygwin1.dll"));
|
||||
|
||||
const got = moshExtraResources("win32");
|
||||
assert.deepEqual(got, [
|
||||
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
|
||||
{
|
||||
from: "resources/mosh/win32-x64/mosh-client-win32-x64-dlls/",
|
||||
to: "mosh/mosh-client-win32-x64-dlls/",
|
||||
filter: ["**/*"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("moshExtraResources packages standalone Windows mosh-client.exe", (t) => {
|
||||
const root = makeTmp(t);
|
||||
withCwdAndArch(t, root, "x64");
|
||||
writeFile(path.join(root, "resources", "mosh", "win32-x64", "mosh-client.exe"));
|
||||
|
||||
const got = moshExtraResources("win32");
|
||||
assert.deepEqual(got, [
|
||||
{ from: "resources/mosh/win32-x64/", to: "mosh/", filter: ["mosh-client.exe"] },
|
||||
{ from: "resources/mosh/win32-x64/mosh-client-win32-x64-dlls/", to: "mosh/", filter: ["**/*"] },
|
||||
]);
|
||||
|
||||
process.env.npm_config_arch = "arm64";
|
||||
assert.deepEqual(moshExtraResources("win32"), []);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user