mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-29 18:11:40 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
310ab602c4 | ||
|
|
277ac31f3f | ||
|
|
44bcc92c89 | ||
|
|
e30055d70e | ||
|
|
3546b2baf4 | ||
|
|
d6a13e3a8b | ||
|
|
735368e697 |
No files matched your search
@@ -829,6 +829,9 @@ Highlight the focused split pane:
|
||||
'settings.terminal.connection.x11Display': 'X11 display',
|
||||
'settings.terminal.connection.x11Display.desc': 'Optional local display address for X11 forwarding. Leave empty to use the system default.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 or DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Default proxy',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Saved proxy used when a host has no proxy of its own and does not inherit one from a group. None connects directly.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'None',
|
||||
'settings.terminal.section.serverStats': 'Server Stats (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Show host information bar',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Show the host address and server information above the terminal. Action buttons remain available when hidden.',
|
||||
|
||||
@@ -949,6 +949,8 @@ export const enVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Configure Proxy Hosts',
|
||||
'hostDetails.proxy': 'Proxy via HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'None',
|
||||
'hostDetails.proxy.direct': 'Direct (no proxy)',
|
||||
'hostDetails.proxy.directHint': 'Does not use a group or global default proxy',
|
||||
'hostDetails.proxy.edit': 'Edit Proxy',
|
||||
'hostDetails.proxy.configure': 'Configure Proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy via HTTP/SOCKS5/Command',
|
||||
|
||||
@@ -744,6 +744,9 @@ Resalta el panel dividido enfocado:
|
||||
'settings.terminal.connection.x11Display': 'Pantalla X11',
|
||||
'settings.terminal.connection.x11Display.desc': 'Dirección de pantalla local opcional para el reenvío X11. Déjala vacía para usar el valor predeterminado del sistema.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Auto (:0 o DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Proxy predeterminado',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Proxy guardado que se usa cuando el host no tiene uno propio y no hereda ninguno de un grupo. Ninguno conecta directamente.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'Ninguno',
|
||||
'settings.terminal.section.serverStats': 'Estadísticas del servidor (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Mostrar barra de información del host',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Muestra la dirección del host y la información del servidor sobre la terminal. Los botones de acción siguen disponibles cuando está oculta.',
|
||||
|
||||
@@ -939,6 +939,8 @@ export const esVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Configurar hosts proxy',
|
||||
'hostDetails.proxy': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
'hostDetails.proxy.none': 'Ninguno',
|
||||
'hostDetails.proxy.direct': 'Directo (sin proxy)',
|
||||
'hostDetails.proxy.directHint': 'No usa el proxy del grupo ni el predeterminado global',
|
||||
'hostDetails.proxy.edit': 'Editar proxy',
|
||||
'hostDetails.proxy.configure': 'Configurar proxy',
|
||||
'hostDetails.proxyPanel.title': 'Proxy mediante HTTP/SOCKS5/Comando',
|
||||
|
||||
@@ -782,6 +782,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.terminal.connection.x11Display': 'Дисплей X11',
|
||||
'settings.terminal.connection.x11Display.desc': 'Необязательный адрес локального дисплея для перенаправления X11. Оставьте пустым, чтобы использовать системное значение по умолчанию.',
|
||||
'settings.terminal.connection.x11Display.placeholder': 'Авто (:0 или DISPLAY)',
|
||||
'settings.terminal.connection.defaultProxy': 'Прокси по умолчанию',
|
||||
'settings.terminal.connection.defaultProxy.desc': 'Сохраненный прокси, если у хоста нет своего и он не наследует прокси группы. «Нет» — прямое подключение.',
|
||||
'settings.terminal.connection.defaultProxy.none': 'Нет',
|
||||
'settings.terminal.section.serverStats': 'Статистика сервера (Linux)',
|
||||
'settings.terminal.hostInfoBar.show': 'Показывать сведения о хосте',
|
||||
'settings.terminal.hostInfoBar.show.desc': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
|
||||
|
||||
@@ -987,6 +987,8 @@ export const ruVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': 'Настроить прокси-хосты',
|
||||
'hostDetails.proxy': 'Прокси через HTTP/SOCKS5/Command',
|
||||
'hostDetails.proxy.none': 'Нет',
|
||||
'hostDetails.proxy.direct': 'Напрямую (без прокси)',
|
||||
'hostDetails.proxy.directHint': 'Не использует прокси группы или глобальный прокси по умолчанию',
|
||||
'hostDetails.proxy.edit': 'Редактировать прокси',
|
||||
'hostDetails.proxy.configure': 'Настроить прокси',
|
||||
'hostDetails.proxyPanel.title': 'Прокси через HTTP/SOCKS5/Command',
|
||||
|
||||
@@ -47,6 +47,22 @@ test("localized settings include workspace focus indicator labels", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include the global default proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.terminal.connection.defaultProxy",
|
||||
"settings.terminal.connection.defaultProxy.desc",
|
||||
"settings.terminal.connection.defaultProxy.none",
|
||||
"hostDetails.proxy.direct",
|
||||
"hostDetails.proxy.directHint",
|
||||
];
|
||||
const locales = [{ name: "en", messages: en }, ...LOCALIZED_SETTINGS_LOCALES];
|
||||
|
||||
for (const locale of locales) {
|
||||
const missing = keys.filter((key) => !locale.messages[key]);
|
||||
assert.deepEqual(missing, [], `${locale.name} is missing global default proxy labels`);
|
||||
}
|
||||
});
|
||||
|
||||
test("localized settings include network proxy labels", () => {
|
||||
const keys = [
|
||||
"settings.system.networkProxy.title",
|
||||
|
||||
@@ -454,6 +454,9 @@ export const zhCNTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目录不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路径是文件,不是目录',
|
||||
'settings.terminal.section.connection': '连接',
|
||||
'settings.terminal.connection.defaultProxy': '默认代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主机没有单独设置、也没有从分组继承代理时使用的已保存代理。选择“无”则直接连接。',
|
||||
'settings.terminal.connection.defaultProxy.none': '无',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 断线后自动重连',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '打开后,已连上的 SSH 会话意外断开时会每 5 秒尝试重新连接,直到关闭标签页或连接恢复。',
|
||||
'settings.terminal.connection.keepaliveInterval': '会话保持间隔',
|
||||
|
||||
@@ -363,6 +363,8 @@ export const zhCNVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': '配置代理主机',
|
||||
'hostDetails.proxy': '通过 HTTP/SOCKS5/命令代理',
|
||||
'hostDetails.proxy.none': '无',
|
||||
'hostDetails.proxy.direct': '直连(不使用代理)',
|
||||
'hostDetails.proxy.directHint': '不使用分组或全局默认代理',
|
||||
'hostDetails.proxy.edit': '编辑代理',
|
||||
'hostDetails.proxy.configure': '配置代理',
|
||||
'hostDetails.envVars': '环境变量',
|
||||
|
||||
@@ -454,6 +454,9 @@ export const zhTWTerminalMessages: Messages = {
|
||||
'settings.terminal.localShell.startDir.notFound': '目錄不存在',
|
||||
'settings.terminal.localShell.startDir.isFile': '路徑是檔案,不是目錄',
|
||||
'settings.terminal.section.connection': '連線',
|
||||
'settings.terminal.connection.defaultProxy': '預設代理',
|
||||
'settings.terminal.connection.defaultProxy.desc': '主機沒有單獨設定、也沒有從群組繼承代理時使用的已儲存代理。選擇「無」則直接連線。',
|
||||
'settings.terminal.connection.defaultProxy.none': '無',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled': 'SSH 斷線後自動重連',
|
||||
'settings.terminal.connection.sshAutoReconnectEnabled.desc': '開啟後,已連上的 SSH 工作階段意外斷開時會每 5 秒嘗試重新連線,直到關閉分頁或連線恢復。',
|
||||
'settings.terminal.connection.keepaliveInterval': '工作階段保持間隔',
|
||||
|
||||
@@ -363,6 +363,8 @@ export const zhTWVaultMessages: Messages = {
|
||||
'hostDetails.jumpHosts.configure': '設定代理主機',
|
||||
'hostDetails.proxy': '透過 HTTP/SOCKS5/指令代理',
|
||||
'hostDetails.proxy.none': '無',
|
||||
'hostDetails.proxy.direct': '直連(不使用代理)',
|
||||
'hostDetails.proxy.directHint': '不使用群組或全域預設代理',
|
||||
'hostDetails.proxy.edit': '編輯代理',
|
||||
'hostDetails.proxy.configure': '設定代理',
|
||||
'hostDetails.envVars': '環境變數',
|
||||
|
||||
@@ -51,6 +51,7 @@ import {
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
} from '../../infrastructure/config/storageKeys';
|
||||
import { resolveAppIconVariant, type AppIconVariant } from '../../domain/appIconVariant';
|
||||
import {
|
||||
@@ -58,6 +59,10 @@ import {
|
||||
normalizeHttpNetworkProxySettings,
|
||||
type HttpNetworkProxySettings,
|
||||
} from '../../domain/httpNetworkProxy';
|
||||
import {
|
||||
normalizeGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
isValidUiFontId,
|
||||
@@ -98,6 +103,7 @@ interface UseSettingsIpcSyncParams {
|
||||
setAutoUpdateEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setAutoLaunchEnabled: Dispatch<SetStateAction<boolean>>;
|
||||
setHttpNetworkProxy: Dispatch<SetStateAction<HttpNetworkProxySettings>>;
|
||||
setGlobalDefaultProxyProfileIdState: Dispatch<SetStateAction<string | undefined>>;
|
||||
setSftpAutoOpenSidebar: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpFollowTerminalCwd: Dispatch<SetStateAction<boolean>>;
|
||||
setSftpDefaultViewMode: Dispatch<SetStateAction<'list' | 'tree'>>;
|
||||
@@ -146,6 +152,7 @@ export function useSettingsIpcSync({
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -292,6 +299,11 @@ export function useSettingsIpcSync({
|
||||
areHttpNetworkProxySettingsEqual(prev, next) ? prev : next
|
||||
));
|
||||
}
|
||||
if (key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(value);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
|
||||
}
|
||||
if (key === STORAGE_KEY_SFTP_AUTO_OPEN_SIDEBAR && typeof value === 'boolean') {
|
||||
setSftpAutoOpenSidebar((prev) => (prev === value ? prev : value));
|
||||
}
|
||||
@@ -354,6 +366,7 @@ export function useSettingsIpcSync({
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setEditorWordWrapState,
|
||||
setFollowAppTerminalThemeState,
|
||||
setGlobalHotkeyEnabled,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
|
||||
import {
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
subscribeActiveGlobalDefaultProxyProfileId,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
|
||||
export const useActiveGlobalDefaultProxyProfileId = (): string | undefined => {
|
||||
return useSyncExternalStore(
|
||||
subscribeActiveGlobalDefaultProxyProfileId,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,140 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
clearActiveGlobalDefaultProxyProfile,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
} from "../../domain/globalDefaultProxy.ts";
|
||||
import { STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE } from "../../infrastructure/config/storageKeys.ts";
|
||||
import { useSettingsState } from "./useSettingsState.ts";
|
||||
|
||||
type Notification = { key: string; value: unknown };
|
||||
|
||||
const withSettingsDom = async (
|
||||
run: (env: { dom: JSDOM; root: Root; notifications: Notification[] }) => Promise<void>,
|
||||
) => {
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", { url: "http://localhost" });
|
||||
const globalAny = globalThis as Record<string, unknown>;
|
||||
const saved = {
|
||||
window: globalAny.window,
|
||||
document: globalAny.document,
|
||||
localStorage: globalAny.localStorage,
|
||||
actEnv: globalAny.IS_REACT_ACT_ENVIRONMENT,
|
||||
};
|
||||
Object.defineProperty(globalThis, "window", { configurable: true, value: dom.window });
|
||||
Object.defineProperty(globalThis, "document", { configurable: true, value: dom.window.document });
|
||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, value: dom.window.localStorage });
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", { configurable: true, value: true });
|
||||
|
||||
const notifications: Notification[] = [];
|
||||
dom.window.netcatty = {
|
||||
notifySettingsChanged: (payload: Notification) => {
|
||||
notifications.push(payload);
|
||||
},
|
||||
} as Window["netcatty"];
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-1");
|
||||
|
||||
const container = dom.window.document.createElement("div");
|
||||
dom.window.document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
try {
|
||||
await run({ dom, root, notifications });
|
||||
} finally {
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
publishActiveGlobalDefaultProxyProfileId(undefined);
|
||||
container.remove();
|
||||
dom.window.close();
|
||||
for (const key of ["window", "document", "localStorage"] as const) {
|
||||
if (saved[key] === undefined) delete globalAny[key];
|
||||
else {
|
||||
Object.defineProperty(globalThis, key, {
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
writable: true,
|
||||
value: saved[key],
|
||||
});
|
||||
}
|
||||
}
|
||||
Object.defineProperty(globalAny, "IS_REACT_ACT_ENVIRONMENT", {
|
||||
configurable: true,
|
||||
value: saved.actEnv,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const proxyNotifications = (notifications: Notification[]) => (
|
||||
notifications.filter((entry) => entry.key === STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE)
|
||||
);
|
||||
|
||||
test("closing a second settings instance still persists a cleared global default proxy", async () => {
|
||||
await withSettingsDom(async ({ dom, root, notifications }) => {
|
||||
let showSecond = true;
|
||||
function SettingsProbe() {
|
||||
useSettingsState({ enableSystemEffects: false });
|
||||
return null;
|
||||
}
|
||||
function Harness() {
|
||||
return React.createElement(
|
||||
React.Fragment,
|
||||
null,
|
||||
React.createElement(SettingsProbe),
|
||||
showSecond ? React.createElement(SettingsProbe) : null,
|
||||
);
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-1");
|
||||
|
||||
showSecond = false;
|
||||
await act(async () => {
|
||||
root.render(React.createElement(Harness));
|
||||
});
|
||||
notifications.length = 0;
|
||||
await act(async () => {
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
});
|
||||
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
|
||||
assert.deepEqual(proxyNotifications(notifications), [
|
||||
{ key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, value: "" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
test("a peer session window follows global default proxy storage without settings sync", async () => {
|
||||
await withSettingsDom(async ({ dom, root, notifications }) => {
|
||||
function PeerSettings() {
|
||||
useSettingsState({ enableSettingsSync: false, enableSystemEffects: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root.render(React.createElement(PeerSettings));
|
||||
});
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-1");
|
||||
|
||||
notifications.length = 0;
|
||||
dom.window.localStorage.setItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "profile-2");
|
||||
await act(async () => {
|
||||
dom.window.dispatchEvent(new dom.window.StorageEvent("storage", {
|
||||
key: STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
newValue: "profile-2",
|
||||
storageArea: dom.window.localStorage,
|
||||
}));
|
||||
});
|
||||
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), "profile-2");
|
||||
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "profile-2");
|
||||
assert.deepEqual(proxyNotifications(notifications), []);
|
||||
});
|
||||
});
|
||||
@@ -70,6 +70,7 @@ import {
|
||||
STORAGE_KEY_SHOW_TRAY_ICON,
|
||||
STORAGE_KEY_AUTO_LAUNCH_ENABLED,
|
||||
STORAGE_KEY_HTTP_NETWORK_PROXY,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_GLOBAL_HOTKEY_ENABLED,
|
||||
STORAGE_KEY_WINDOW_OPACITY,
|
||||
STORAGE_KEY_APP_ICON_VARIANT,
|
||||
@@ -104,6 +105,11 @@ import { getUiThemeById } from '../../infrastructure/config/uiThemes';
|
||||
import { DEFAULT_UI_FONT_ID, withWindowsEmojiFallback } from '../../infrastructure/config/uiFonts';
|
||||
import { uiFontStore, useUIFontsLoaded } from './uiFontStore';
|
||||
import { localStorageAdapter } from '../../infrastructure/persistence/localStorageAdapter';
|
||||
import {
|
||||
normalizeGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
registerGlobalDefaultProxyProfileSetter,
|
||||
} from '../../domain/globalDefaultProxy';
|
||||
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
|
||||
import {
|
||||
resolveSftpTransferConcurrency,
|
||||
@@ -574,6 +580,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const stored = localStorageAdapter.read<unknown>(STORAGE_KEY_HTTP_NETWORK_PROXY);
|
||||
return normalizeHttpNetworkProxySettings(stored ?? DEFAULT_HTTP_NETWORK_PROXY);
|
||||
});
|
||||
const [globalDefaultProxyProfileId, setGlobalDefaultProxyProfileIdState] = useState<string | undefined>(() => {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(
|
||||
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
|
||||
);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
return next;
|
||||
});
|
||||
const setHttpNetworkProxy = useCallback((nextValue: SetStateAction<HttpNetworkProxySettings>) => {
|
||||
setHttpNetworkProxyState((prev) => {
|
||||
const candidate = typeof nextValue === 'function'
|
||||
@@ -796,6 +809,37 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
}
|
||||
}, [enableSettingsSync]);
|
||||
|
||||
const setGlobalDefaultProxyProfileId = useCallback((value: string | undefined) => {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(value);
|
||||
setGlobalDefaultProxyProfileIdState(next);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
if (!persistMountedRef.current) return;
|
||||
notifySettingsChanged(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, next ?? '');
|
||||
}, [notifySettingsChanged]);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
publishActiveGlobalDefaultProxyProfileId(globalDefaultProxyProfileId);
|
||||
}, [globalDefaultProxyProfileId]);
|
||||
|
||||
useEffect(
|
||||
() => registerGlobalDefaultProxyProfileSetter(setGlobalDefaultProxyProfileId),
|
||||
[setGlobalDefaultProxyProfileId],
|
||||
);
|
||||
|
||||
// Peer session windows set enableSettingsSync false, which skips IPC sync.
|
||||
// This setting still has to follow localStorage or an already-open session
|
||||
// window keeps the proxy id it read at mount.
|
||||
useEffect(() => {
|
||||
const onStorage = (event: StorageEvent) => {
|
||||
if (event.key !== STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE) return;
|
||||
const next = normalizeGlobalDefaultProxyProfileId(event.newValue);
|
||||
publishActiveGlobalDefaultProxyProfileId(next);
|
||||
setGlobalDefaultProxyProfileIdState((prev) => (prev === next ? prev : next));
|
||||
};
|
||||
window.addEventListener('storage', onStorage);
|
||||
return () => window.removeEventListener('storage', onStorage);
|
||||
}, []);
|
||||
|
||||
const setSftpTransferConcurrency = useCallback((value: number) => {
|
||||
const clamped = Math.max(1, Math.min(16, Math.round(value)));
|
||||
@@ -1079,6 +1123,11 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
localStorageAdapter.read(STORAGE_KEY_HTTP_NETWORK_PROXY) ?? DEFAULT_HTTP_NETWORK_PROXY,
|
||||
),
|
||||
);
|
||||
const nextGlobalDefaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(
|
||||
localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE),
|
||||
);
|
||||
publishActiveGlobalDefaultProxyProfileId(nextGlobalDefaultProxyProfileId);
|
||||
setGlobalDefaultProxyProfileIdState(nextGlobalDefaultProxyProfileId);
|
||||
|
||||
// Custom terminal themes
|
||||
customThemeStore.loadFromStorage();
|
||||
@@ -1227,6 +1276,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
setAutoUpdateEnabled,
|
||||
setAutoLaunchEnabled,
|
||||
setHttpNetworkProxy,
|
||||
setGlobalDefaultProxyProfileIdState,
|
||||
setSftpAutoOpenSidebar,
|
||||
setSftpFollowTerminalCwd,
|
||||
setSftpDefaultViewMode,
|
||||
@@ -2211,6 +2261,8 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
autoLaunchSupported,
|
||||
httpNetworkProxy,
|
||||
setHttpNetworkProxy,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
autoUpdateEnabled,
|
||||
setAutoUpdateEnabled,
|
||||
hotkeyRegistrationError,
|
||||
@@ -2233,7 +2285,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
terminalThemeId, terminalFontFamilyId, terminalFontSize, terminalSettings,
|
||||
customKeyBindings, editorWordWrap,
|
||||
sftpDoubleClickBehavior, sftpAutoSync, sftpShowHiddenFiles, sftpUseCompressedUpload, sftpSkipUnchanged, sftpAutoOpenSidebar, sftpFollowTerminalCwd, sftpDefaultViewMode,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom,
|
||||
showRecentHosts, hostClickBehavior, showOnlyUngroupedHostsInRoot, showSftpTab, showHostTreeSidebar, terminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, localShellSidePanelAutoOpen, localShellSidePanelAutoOpenTab, shellOnlyTabNumberShortcuts, showTabNumberBadges, disableTerminalFontZoom, globalDefaultProxyProfileId,
|
||||
customThemes, workspaceFocusStyle, sessionLogsTimestampsEnabled, sshDebugLogsEnabled, sshDeepLinkEnabled, jmsDeepLinkEnabled, explorerContextMenuEnabled,
|
||||
]),
|
||||
};
|
||||
|
||||
@@ -454,6 +454,30 @@ test("note appearance settings survive sync and trigger auto-sync", async () =>
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_VAULT_NOTES_CODE_FONT_SIZE), "14");
|
||||
});
|
||||
|
||||
test("buildSyncPayload round-trips the global default proxy profile", async () => {
|
||||
localStorage.clear();
|
||||
const absent = buildSyncPayload(vault([]));
|
||||
assert.equal(absent.settings?.defaultProxyProfileId, undefined);
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "proxy-1");
|
||||
const selected = buildSyncPayload(vault([]));
|
||||
assert.equal(selected.settings?.defaultProxyProfileId, "proxy-1");
|
||||
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE, "");
|
||||
const cleared = buildSyncPayload(vault([]));
|
||||
assert.equal(cleared.settings?.defaultProxyProfileId, "");
|
||||
|
||||
localStorage.clear();
|
||||
await applySyncPayload(selected, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "proxy-1");
|
||||
|
||||
await applySyncPayload(cleared, { importVaultData: () => {} });
|
||||
assert.equal(localStorage.getItem(storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE), "");
|
||||
assert.ok((SYNCABLE_SETTING_STORAGE_KEYS as readonly string[]).includes(
|
||||
storageKeys.STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
));
|
||||
});
|
||||
|
||||
test("buildSyncPayload includes host tree sidebar visibility setting", () => {
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR, "false");
|
||||
localStorage.setItem(storageKeys.STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN, "true");
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type SyncPayload,
|
||||
} from '../domain/sync';
|
||||
import { migrateLegacyCommandBlocklist } from '../domain/commandBlocklist';
|
||||
import { normalizeGlobalDefaultProxyProfileId } from '../domain/globalDefaultProxy';
|
||||
import { migrateHostsFromLegacyLineTimestamps } from '../domain/host';
|
||||
import { toPersistedPortForwardingRules } from '../domain/portForwardingPersistence';
|
||||
import {
|
||||
@@ -112,6 +113,7 @@ import {
|
||||
STORAGE_KEY_SHOW_ONLY_UNGROUPED_HOSTS_IN_ROOT,
|
||||
STORAGE_KEY_SHOW_SFTP_TAB,
|
||||
STORAGE_KEY_SHOW_HOST_TREE_SIDEBAR,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_DISABLE_TERMINAL_FONT_ZOOM,
|
||||
@@ -345,6 +347,7 @@ export const SYNCABLE_SETTING_STORAGE_KEYS = [
|
||||
STORAGE_KEY_SHELL_ONLY_TAB_NUMBER_SHORTCUTS,
|
||||
STORAGE_KEY_SHOW_TAB_NUMBER_BADGES,
|
||||
STORAGE_KEY_WORKSPACE_FOCUS_STYLE,
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
STORAGE_KEY_AI_PROVIDERS,
|
||||
STORAGE_KEY_AI_ACTIVE_PROVIDER,
|
||||
STORAGE_KEY_AI_ACTIVE_MODEL,
|
||||
@@ -592,6 +595,10 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
if (workspaceFocusStyle === 'dim' || workspaceFocusStyle === 'border') {
|
||||
settings.workspaceFocusStyle = workspaceFocusStyle;
|
||||
}
|
||||
const storedDefaultProxyProfileId = localStorageAdapter.readString(STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE);
|
||||
if (storedDefaultProxyProfileId !== null) {
|
||||
settings.defaultProxyProfileId = normalizeGlobalDefaultProxyProfileId(storedDefaultProxyProfileId) ?? '';
|
||||
}
|
||||
|
||||
const ai: NonNullable<SyncPayload['settings']>['ai'] = {};
|
||||
const providers = readArraySetting(STORAGE_KEY_AI_PROVIDERS);
|
||||
@@ -906,6 +913,12 @@ async function applySyncableSettings(
|
||||
if (settings.workspaceFocusStyle != null) {
|
||||
localStorageAdapter.writeString(STORAGE_KEY_WORKSPACE_FOCUS_STYLE, settings.workspaceFocusStyle);
|
||||
}
|
||||
if (typeof settings.defaultProxyProfileId === 'string') {
|
||||
localStorageAdapter.writeString(
|
||||
STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE,
|
||||
normalizeGlobalDefaultProxyProfileId(settings.defaultProxyProfileId) ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
const ai = settings.ai;
|
||||
if (ai) {
|
||||
|
||||
@@ -687,7 +687,32 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
|
||||
title={t("hostDetails.proxy")}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
{form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
{form.directProxy ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="min-w-0 grid grid-cols-[auto_minmax(0,1fr)] items-center gap-2 p-2 rounded-md bg-secondary/50 hover:bg-secondary transition-colors cursor-pointer overflow-hidden"
|
||||
onClick={() => setActiveSubPanel("proxy")}
|
||||
>
|
||||
<Badge variant="secondary" className="text-xs shrink-0">
|
||||
{t("hostDetails.proxy.direct")}
|
||||
</Badge>
|
||||
<span className="block min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-sm">
|
||||
{t("hostDetails.proxy.directHint")}
|
||||
</span>
|
||||
</button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9 text-muted-foreground hover:text-destructive shrink-0"
|
||||
aria-label={t("hostDetails.proxyPanel.remove")}
|
||||
onClick={clearProxyConfig}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : form.proxyConfig?.host || form.proxyConfig?.command || form.proxyProfileId ? (
|
||||
<div className="w-full min-w-0 grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -453,7 +453,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
const updateProxyConfig = useCallback(
|
||||
(field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => {
|
||||
setForm((prev) => {
|
||||
const { proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
const { proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
return {
|
||||
...rest,
|
||||
proxyConfig: updateProxyConfigField(prev.proxyConfig, field, value),
|
||||
@@ -465,19 +465,26 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const clearProxyConfig = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
return rest as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectProxyProfile = useCallback((profileId: string | undefined) => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, directProxy: _directProxy, ...rest } = prev;
|
||||
if (!profileId) return rest as Host;
|
||||
return { ...rest, proxyProfileId: profileId } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const selectDirectProxy = useCallback(() => {
|
||||
setForm((prev) => {
|
||||
const { proxyConfig: _proxyConfig, proxyProfileId: _proxyProfileId, ...rest } = prev;
|
||||
return { ...rest, directProxy: true } as Host;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const addHostToChain = (hostId: string) => {
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
@@ -886,8 +893,10 @@ const HostDetailsPanel: React.FC<HostDetailsPanelPropsWithResize> = ({
|
||||
proxyProfiles={proxyProfiles}
|
||||
identities={identities}
|
||||
selectedProxyProfileId={form.proxyProfileId}
|
||||
directProxy={form.directProxy}
|
||||
onUpdateProxy={updateProxyConfig}
|
||||
onSelectProxyProfile={selectProxyProfile}
|
||||
onSelectDirectProxy={selectDirectProxy}
|
||||
onClearProxy={clearProxyConfig}
|
||||
onBack={() => setActiveSubPanel("none")}
|
||||
onCancel={onCancel}
|
||||
|
||||
@@ -27,6 +27,10 @@ import {
|
||||
removeProxyProfileReferences,
|
||||
updateProxyConfigField,
|
||||
} from "../domain/proxyProfiles";
|
||||
import {
|
||||
clearActiveGlobalDefaultProxyProfile,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
} from "../domain/globalDefaultProxy";
|
||||
import { reorderVaultItems, sortByVaultOrder } from "../domain/vaultOrder";
|
||||
import {
|
||||
STORAGE_KEY_VAULT_PROXY_PROFILES_VIEW_MODE,
|
||||
@@ -391,6 +395,9 @@ export const ProxyProfilesManager: React.FC<ProxyProfilesManagerProps> = ({
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (!deleteTarget) return;
|
||||
if (getActiveGlobalDefaultProxyProfileId() === deleteTarget.id) {
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
}
|
||||
const cleaned = removeProxyProfileReferences(deleteTarget.id, {
|
||||
hosts,
|
||||
groupConfigs,
|
||||
|
||||
@@ -141,6 +141,8 @@ type TerminalTabSettingsProps = Pick<
|
||||
| 'setLocalShellSidePanelAutoOpenTab'
|
||||
| 'workspaceFocusStyle'
|
||||
| 'setWorkspaceFocusStyle'
|
||||
| 'globalDefaultProxyProfileId'
|
||||
| 'setGlobalDefaultProxyProfileId'
|
||||
>;
|
||||
|
||||
const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(function SettingsTerminalTabContainer({
|
||||
@@ -171,6 +173,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
setLocalShellSidePanelAutoOpenTab,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
}) {
|
||||
const availableFonts = useAvailableFonts();
|
||||
|
||||
@@ -204,6 +208,8 @@ const SettingsTerminalTabContainer = React.memo<TerminalTabSettingsProps>(functi
|
||||
availableFonts={availableFonts}
|
||||
workspaceFocusStyle={workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={setWorkspaceFocusStyle}
|
||||
globalDefaultProxyProfileId={globalDefaultProxyProfileId}
|
||||
setGlobalDefaultProxyProfileId={setGlobalDefaultProxyProfileId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
@@ -599,6 +605,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
|
||||
setLocalShellSidePanelAutoOpenTab={settings.setLocalShellSidePanelAutoOpenTab}
|
||||
workspaceFocusStyle={settings.workspaceFocusStyle}
|
||||
setWorkspaceFocusStyle={settings.setWorkspaceFocusStyle}
|
||||
globalDefaultProxyProfileId={settings.globalDefaultProxyProfileId}
|
||||
setGlobalDefaultProxyProfileId={settings.setGlobalDefaultProxyProfileId}
|
||||
/>
|
||||
</SettingsLazyTab>
|
||||
)}
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useRenderTracker } from "../lib/useRenderTracker";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Host, Identity, KnownHost, ProxyProfile, SSHKey, TransferTask } from "../types";
|
||||
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
|
||||
import { useActiveGlobalDefaultProxyProfileId } from "../application/state/useGlobalDefaultProxyProfileId";
|
||||
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
|
||||
import { useSftpFileAssociations } from "../application/state/useSftpFileAssociations";
|
||||
import { registerEditorSftpWriterScoped } from "../application/state/editorSftpBridge";
|
||||
@@ -161,16 +162,19 @@ const SftpViewInner: React.FC<SftpViewProps> = ({
|
||||
resolveTransferSourceSessionId,
|
||||
]);
|
||||
|
||||
// Pre-resolve group defaults so SFTP connections inherit group config
|
||||
// Memoized on vault props, so this subscription is what refreshes the list
|
||||
// after the global default changes.
|
||||
const globalDefaultProxyProfileId = useActiveGlobalDefaultProxyProfileId();
|
||||
const effectiveHosts = useMemo(() => {
|
||||
const validProxyProfileIds = new Set(proxyProfiles.map((profile) => profile.id));
|
||||
const inheritanceOptions = { validProxyProfileIds, globalProxyProfileId: globalDefaultProxyProfileId };
|
||||
return hosts.map(h => {
|
||||
const withGroupDefaults = h.group
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, { validProxyProfileIds }), { validProxyProfileIds })
|
||||
: applyGroupDefaults(h, {}, { validProxyProfileIds });
|
||||
? applyGroupDefaults(h, resolveGroupDefaults(h.group, groupConfigs, inheritanceOptions), inheritanceOptions)
|
||||
: applyGroupDefaults(h, {}, inheritanceOptions);
|
||||
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
|
||||
});
|
||||
}, [hosts, groupConfigs, proxyProfiles]);
|
||||
}, [hosts, groupConfigs, proxyProfiles, globalDefaultProxyProfileId]);
|
||||
|
||||
const hostWriteSource = writableHosts ?? hosts;
|
||||
|
||||
|
||||
@@ -27,8 +27,10 @@ export interface ProxyPanelProps {
|
||||
proxyProfiles?: ProxyProfile[];
|
||||
identities?: Identity[];
|
||||
selectedProxyProfileId?: string;
|
||||
directProxy?: boolean;
|
||||
onUpdateProxy: (field: keyof ProxyConfig, value: ProxyConfig[keyof ProxyConfig]) => void;
|
||||
onSelectProxyProfile?: (profileId: string | undefined) => void;
|
||||
onSelectDirectProxy?: () => void;
|
||||
onClearProxy: () => void;
|
||||
onBack: () => void;
|
||||
onCancel: () => void;
|
||||
@@ -42,8 +44,10 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
proxyProfiles = [],
|
||||
identities = [],
|
||||
selectedProxyProfileId,
|
||||
directProxy = false,
|
||||
onUpdateProxy,
|
||||
onSelectProxyProfile,
|
||||
onSelectDirectProxy,
|
||||
onClearProxy,
|
||||
onBack,
|
||||
onCancel,
|
||||
@@ -54,13 +58,18 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const customValue = '__custom__';
|
||||
const directValue = '__direct__';
|
||||
const selectedProfile = useMemo(
|
||||
() => proxyProfiles.find((profile) => profile.id === selectedProxyProfileId),
|
||||
[proxyProfiles, selectedProxyProfileId],
|
||||
);
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile);
|
||||
const selectedValue = selectedProfile ? selectedProfile.id : customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile);
|
||||
const hasMissingProfile = Boolean(selectedProxyProfileId && !selectedProfile && !directProxy);
|
||||
const selectedValue = directProxy
|
||||
? directValue
|
||||
: selectedProfile
|
||||
? selectedProfile.id
|
||||
: customValue;
|
||||
const isUsingProfile = Boolean(selectedProfile) && !directProxy;
|
||||
const isCommandProxy = isProxyCommandConfig(proxyConfig);
|
||||
const hasManualProxyHost = Boolean(proxyConfig?.host?.trim());
|
||||
const hasManualProxyCommand = Boolean(proxyConfig?.command?.trim());
|
||||
@@ -71,8 +80,8 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
const hasIncompleteIdentity = hasIncompleteProxyIdentity(effectiveProxyConfig, identities);
|
||||
const hasUnreadableIdentity = hasUnreadableProxyCredential(effectiveProxyConfig, identities);
|
||||
const hasInvalidIdentity = hasMissingIdentity || hasIncompleteIdentity || hasUnreadableIdentity;
|
||||
const canSave = (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const canSave = directProxy || (isUsingProfile && !hasInvalidIdentity) ||
|
||||
(!isUsingProfile && !directProxy && hasManualProxyValue && !hasInvalidManualProxyPort && !hasInvalidIdentity);
|
||||
const manualCredentialsValue = '__manual_credentials__';
|
||||
const missingIdentityValue = '__missing_identity__';
|
||||
const selectedIdentity = useMemo(
|
||||
@@ -103,7 +112,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
}
|
||||
>
|
||||
<AsidePanelContent>
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile) && onSelectProxyProfile && (
|
||||
{(proxyProfiles.length > 0 || hasMissingProfile || onSelectDirectProxy) && onSelectProxyProfile && (
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={14} className="text-muted-foreground" />
|
||||
@@ -111,7 +120,13 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</div>
|
||||
<Select
|
||||
value={selectedValue}
|
||||
onValueChange={(value) => onSelectProxyProfile(value === customValue ? undefined : value)}
|
||||
onValueChange={(value) => {
|
||||
if (value === directValue) {
|
||||
onSelectDirectProxy?.();
|
||||
return;
|
||||
}
|
||||
onSelectProxyProfile(value === customValue ? undefined : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
aria-label={t('hostDetails.proxyPanel.savedProxy')}
|
||||
@@ -120,6 +135,9 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
<SelectValue placeholder={t('hostDetails.proxyPanel.selectSaved')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{onSelectDirectProxy && (
|
||||
<SelectItem value={directValue}>{t('hostDetails.proxy.direct')}</SelectItem>
|
||||
)}
|
||||
<SelectItem value={customValue}>{t('hostDetails.proxyPanel.customProxy')}</SelectItem>
|
||||
{proxyProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
@@ -163,7 +181,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!isUsingProfile && (
|
||||
{!isUsingProfile && !directProxy && (
|
||||
<>
|
||||
<Card className="p-3 space-y-3 bg-card border-border/80">
|
||||
<div className="space-y-2">
|
||||
@@ -328,7 +346,7 @@ export const ProxyPanel: React.FC<ProxyPanelPropsWithResize> = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId) && (
|
||||
{(proxyConfig?.host || proxyConfig?.command || selectedProxyProfileId || directProxy) && (
|
||||
<Button variant="ghost" className="w-full h-10 text-destructive" onClick={onClearProxy}>
|
||||
<Trash2 size={14} className="mr-2" /> {t('hostDetails.proxyPanel.remove')}
|
||||
</Button>
|
||||
|
||||
@@ -5,10 +5,13 @@ import type {
|
||||
CursorShape,
|
||||
HostInfoBarTitleMode,
|
||||
PasswordPromptAssistMode,
|
||||
ProxyProfile,
|
||||
TerminalEmulationType,
|
||||
TerminalSettings,
|
||||
} from "../../../domain/models";
|
||||
import { useI18n } from "../../../application/i18n/I18nProvider";
|
||||
import { localStorageAdapter, LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from "../../../infrastructure/persistence/localStorageAdapter";
|
||||
import { STORAGE_KEY_PROXY_PROFILES } from "../../../infrastructure/config/storageKeys";
|
||||
import { MAX_FONT_SIZE, MIN_FONT_SIZE, resolveTerminalFontFamilyId, type TerminalFont } from "../../../infrastructure/config/fonts";
|
||||
import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes";
|
||||
import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore";
|
||||
@@ -43,6 +46,34 @@ import {
|
||||
TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MIN,
|
||||
} from "../../../domain/terminalInlineImages";
|
||||
|
||||
const GLOBAL_DEFAULT_PROXY_NONE = "__none__";
|
||||
|
||||
const readSavedProxyProfiles = (): ProxyProfile[] => {
|
||||
const stored = localStorageAdapter.read<ProxyProfile[]>(STORAGE_KEY_PROXY_PROFILES);
|
||||
return Array.isArray(stored) ? stored : [];
|
||||
};
|
||||
|
||||
const useSavedProxyProfiles = (): ProxyProfile[] => {
|
||||
const [profiles, setProfiles] = useState<ProxyProfile[]>(readSavedProxyProfiles);
|
||||
useEffect(() => {
|
||||
const refresh = (key?: string | null) => {
|
||||
if (key && key !== STORAGE_KEY_PROXY_PROFILES) return;
|
||||
setProfiles(readSavedProxyProfiles());
|
||||
};
|
||||
const onAdapterChange = (event: Event) => {
|
||||
refresh((event as CustomEvent<{ key?: string }>).detail?.key);
|
||||
};
|
||||
const onStorage = (event: StorageEvent) => refresh(event.key);
|
||||
window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => {
|
||||
window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, onAdapterChange);
|
||||
window.removeEventListener("storage", onStorage);
|
||||
};
|
||||
}, []);
|
||||
return profiles;
|
||||
};
|
||||
|
||||
const FONT_WEIGHT_OPTIONS = [
|
||||
{ value: "100", labelKey: "settings.terminal.font.weight.thin" },
|
||||
{ value: "200", labelKey: "settings.terminal.font.weight.extraLight" },
|
||||
@@ -87,6 +118,8 @@ function SettingsTerminalTab(props: {
|
||||
availableFonts: TerminalFont[];
|
||||
workspaceFocusStyle: 'dim' | 'border';
|
||||
setWorkspaceFocusStyle: (style: 'dim' | 'border') => void;
|
||||
globalDefaultProxyProfileId?: string;
|
||||
setGlobalDefaultProxyProfileId: (id: string | undefined) => void;
|
||||
}) {
|
||||
const {
|
||||
terminalThemeId,
|
||||
@@ -117,8 +150,14 @@ function SettingsTerminalTab(props: {
|
||||
availableFonts,
|
||||
workspaceFocusStyle,
|
||||
setWorkspaceFocusStyle,
|
||||
globalDefaultProxyProfileId,
|
||||
setGlobalDefaultProxyProfileId,
|
||||
} = props;
|
||||
const { t } = useI18n();
|
||||
const proxyProfiles = useSavedProxyProfiles();
|
||||
const defaultProxyValue = proxyProfiles.some((profile) => profile.id === globalDefaultProxyProfileId)
|
||||
? globalDefaultProxyProfileId ?? GLOBAL_DEFAULT_PROXY_NONE
|
||||
: GLOBAL_DEFAULT_PROXY_NONE;
|
||||
|
||||
// Local shell settings state
|
||||
const [defaultShell, setDefaultShell] = useState<string>("");
|
||||
@@ -932,6 +971,23 @@ function SettingsTerminalTab(props: {
|
||||
|
||||
<SectionHeader title={t("settings.terminal.section.connection")} />
|
||||
<div className="space-y-0 divide-y divide-border rounded-lg border bg-card px-4">
|
||||
<SettingRow
|
||||
anchorId="terminal-default-proxy"
|
||||
label={t("settings.terminal.connection.defaultProxy")}
|
||||
description={t("settings.terminal.connection.defaultProxy.desc")}
|
||||
>
|
||||
<Select
|
||||
value={defaultProxyValue}
|
||||
onChange={(value) => {
|
||||
setGlobalDefaultProxyProfileId(value === GLOBAL_DEFAULT_PROXY_NONE ? undefined : value);
|
||||
}}
|
||||
options={[
|
||||
{ value: GLOBAL_DEFAULT_PROXY_NONE, label: t("settings.terminal.connection.defaultProxy.none") },
|
||||
...proxyProfiles.map((profile) => ({ value: profile.id, label: profile.label })),
|
||||
]}
|
||||
className="w-56"
|
||||
/>
|
||||
</SettingRow>
|
||||
<SettingRow
|
||||
anchorId="terminal-verify-host-keys"
|
||||
label={t("settings.terminal.connection.verifyHostKeys")}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
clearActiveGlobalDefaultProxyProfile,
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
registerGlobalDefaultProxyProfileSetter,
|
||||
} from "./globalDefaultProxy.ts";
|
||||
|
||||
test("clearing the global default uses the settings setter that is still mounted", () => {
|
||||
const writes: string[] = [];
|
||||
publishActiveGlobalDefaultProxyProfileId("profile-1");
|
||||
const unregisterGate = registerGlobalDefaultProxyProfileSetter((id) => {
|
||||
writes.push(`gate:${id ?? ""}`);
|
||||
publishActiveGlobalDefaultProxyProfileId(id);
|
||||
});
|
||||
const unregisterTab = registerGlobalDefaultProxyProfileSetter((id) => {
|
||||
writes.push(`tab:${id ?? ""}`);
|
||||
publishActiveGlobalDefaultProxyProfileId(id);
|
||||
});
|
||||
|
||||
try {
|
||||
unregisterGate();
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
assert.deepEqual(writes, ["tab:"]);
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
|
||||
|
||||
writes.length = 0;
|
||||
publishActiveGlobalDefaultProxyProfileId("profile-1");
|
||||
unregisterTab();
|
||||
const unregisterGateAgain = registerGlobalDefaultProxyProfileSetter((id) => {
|
||||
writes.push(`gate:${id ?? ""}`);
|
||||
publishActiveGlobalDefaultProxyProfileId(id);
|
||||
});
|
||||
try {
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
assert.deepEqual(writes, ["gate:"]);
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
|
||||
} finally {
|
||||
unregisterGateAgain();
|
||||
}
|
||||
|
||||
publishActiveGlobalDefaultProxyProfileId("profile-1");
|
||||
clearActiveGlobalDefaultProxyProfile();
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
|
||||
assert.deepEqual(writes, ["gate:"]);
|
||||
} finally {
|
||||
unregisterGate();
|
||||
unregisterTab();
|
||||
publishActiveGlobalDefaultProxyProfileId(undefined);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* App-wide default proxy profile. Settings publish the saved id here so
|
||||
* `applyGroupDefaults` can apply it for every connection path, including
|
||||
* callers that pass empty group defaults.
|
||||
*/
|
||||
let activeId: string | undefined;
|
||||
const listeners = new Set<() => void>();
|
||||
let registrationSeq = 0;
|
||||
// Several settings hooks can be mounted at once (AppLockGate plus a settings
|
||||
// tab). Each registration owns its cleanup so unmounting one instance falls
|
||||
// back to the setter that is still mounted.
|
||||
const persistSetters: Array<{
|
||||
id: number;
|
||||
setter: (id: string | undefined) => void;
|
||||
}> = [];
|
||||
|
||||
export const normalizeGlobalDefaultProxyProfileId = (
|
||||
value: unknown,
|
||||
): string | undefined => {
|
||||
if (typeof value !== 'string') return undefined;
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
};
|
||||
|
||||
export const getActiveGlobalDefaultProxyProfileId = (): string | undefined => activeId;
|
||||
|
||||
export const publishActiveGlobalDefaultProxyProfileId = (value: unknown): void => {
|
||||
const next = normalizeGlobalDefaultProxyProfileId(value);
|
||||
if (next === activeId) return;
|
||||
activeId = next;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
export const subscribeActiveGlobalDefaultProxyProfileId = (
|
||||
listener: () => void,
|
||||
): (() => void) => {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
};
|
||||
|
||||
export const registerGlobalDefaultProxyProfileSetter = (
|
||||
setter: (id: string | undefined) => void,
|
||||
): (() => void) => {
|
||||
const id = ++registrationSeq;
|
||||
persistSetters.push({ id, setter });
|
||||
return () => {
|
||||
const index = persistSetters.findIndex((entry) => entry.id === id);
|
||||
if (index >= 0) persistSetters.splice(index, 1);
|
||||
};
|
||||
};
|
||||
|
||||
/** Clear the saved global default and persist that choice when settings are mounted. */
|
||||
export const clearActiveGlobalDefaultProxyProfile = (): void => {
|
||||
const persistSetter = persistSetters[persistSetters.length - 1]?.setter;
|
||||
if (persistSetter) {
|
||||
persistSetter(undefined);
|
||||
return;
|
||||
}
|
||||
publishActiveGlobalDefaultProxyProfileId(undefined);
|
||||
};
|
||||
@@ -1,6 +1,10 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyGroupDefaults, resolveGroupDefaults, sanitizeGroupConfig } from "./groupConfig.ts";
|
||||
import {
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
publishActiveGlobalDefaultProxyProfileId,
|
||||
} from "./globalDefaultProxy.ts";
|
||||
import { resolveTelnetPassword, resolveTelnetUsername } from "./host.ts";
|
||||
import type { GroupConfig, Host } from "./models.ts";
|
||||
|
||||
@@ -728,3 +732,114 @@ test("applyGroupDefaults does not inherit the group startup command into local s
|
||||
assert.equal(result.startupCommand, undefined);
|
||||
assert.equal(result.startupCommandRunMode, "paste");
|
||||
});
|
||||
|
||||
const globalProxyOptions = {
|
||||
globalProxyProfileId: "global-proxy",
|
||||
validProxyProfileIds: new Set(["global-proxy", "group-proxy", "host-proxy"]),
|
||||
};
|
||||
|
||||
test("applyGroupDefaults prefers the host proxy over the group and global defaults", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host({ proxyProfileId: "host-proxy" }),
|
||||
{ proxyProfileId: "group-proxy" },
|
||||
globalProxyOptions,
|
||||
);
|
||||
|
||||
assert.equal(result.proxyProfileId, "host-proxy");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults prefers the nearest group proxy over the global default", () => {
|
||||
const groupDefaults = resolveGroupDefaults("prod/api", [
|
||||
{ path: "prod", proxyProfileId: "group-proxy" },
|
||||
{ path: "prod/api", proxyProfileId: "child-proxy" },
|
||||
]);
|
||||
const result = applyGroupDefaults(host({ group: "prod/api" }), groupDefaults, {
|
||||
...globalProxyOptions,
|
||||
validProxyProfileIds: new Set(["global-proxy", "group-proxy", "child-proxy"]),
|
||||
});
|
||||
|
||||
assert.equal(result.proxyProfileId, "child-proxy");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults uses the global default when the host and group have no proxy", () => {
|
||||
const grouped = applyGroupDefaults(host({ group: "prod" }), {}, globalProxyOptions);
|
||||
const ungrouped = applyGroupDefaults(host(), {}, globalProxyOptions);
|
||||
|
||||
assert.equal(grouped.proxyProfileId, "global-proxy");
|
||||
assert.equal(ungrouped.proxyProfileId, "global-proxy");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults stays direct when the global default is off", () => {
|
||||
const result = applyGroupDefaults(host(), {}, { globalProxyProfileId: "" });
|
||||
|
||||
assert.equal(result.proxyProfileId, undefined);
|
||||
assert.equal(result.proxyConfig, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults lets an explicit host direct choice override group and global proxies", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host({
|
||||
directProxy: true,
|
||||
proxyProfileId: "host-proxy",
|
||||
proxyConfig: { type: "http", host: "leftover.example.com", port: 8080 },
|
||||
}),
|
||||
{ proxyProfileId: "group-proxy" },
|
||||
globalProxyOptions,
|
||||
);
|
||||
|
||||
assert.equal(result.directProxy, true);
|
||||
assert.equal(result.proxyProfileId, undefined);
|
||||
assert.equal(result.proxyConfig, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults still inherits after the host proxy setting is cleared", () => {
|
||||
const fromGroup = applyGroupDefaults(
|
||||
host({ proxyProfileId: "" }),
|
||||
{ proxyProfileId: "group-proxy" },
|
||||
globalProxyOptions,
|
||||
);
|
||||
const fromGlobal = applyGroupDefaults(
|
||||
host({ proxyProfileId: "" }),
|
||||
{},
|
||||
globalProxyOptions,
|
||||
);
|
||||
|
||||
assert.equal(fromGroup.proxyProfileId, "group-proxy");
|
||||
assert.equal(fromGlobal.proxyProfileId, "global-proxy");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults ignores a deleted global proxy profile", () => {
|
||||
const result = applyGroupDefaults(host(), {}, {
|
||||
globalProxyProfileId: "deleted-proxy",
|
||||
validProxyProfileIds: new Set(["group-proxy"]),
|
||||
});
|
||||
|
||||
assert.equal(result.proxyProfileId, undefined);
|
||||
});
|
||||
|
||||
test("applyGroupDefaults keeps a missing group proxy instead of using the global default", () => {
|
||||
const result = applyGroupDefaults(
|
||||
host(),
|
||||
{ proxyProfileId: "missing-proxy" },
|
||||
{
|
||||
globalProxyProfileId: "global-proxy",
|
||||
validProxyProfileIds: new Set(["global-proxy"]),
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(result.proxyProfileId, "missing-proxy");
|
||||
});
|
||||
|
||||
test("applyGroupDefaults reads the active global default when callers omit it", () => {
|
||||
publishActiveGlobalDefaultProxyProfileId("global-proxy");
|
||||
try {
|
||||
const inherited = applyGroupDefaults(host(), {});
|
||||
const forcedOff = applyGroupDefaults(host(), {}, { globalProxyProfileId: "" });
|
||||
assert.equal(inherited.proxyProfileId, "global-proxy");
|
||||
assert.equal(forcedOff.proxyProfileId, undefined);
|
||||
} finally {
|
||||
publishActiveGlobalDefaultProxyProfileId(undefined);
|
||||
}
|
||||
|
||||
assert.equal(getActiveGlobalDefaultProxyProfileId(), undefined);
|
||||
});
|
||||
@@ -1,5 +1,9 @@
|
||||
import type { GroupConfig, Host } from './models';
|
||||
import { migrateDeprecatedFontOverride } from '../infrastructure/config/fonts';
|
||||
import {
|
||||
getActiveGlobalDefaultProxyProfileId,
|
||||
normalizeGlobalDefaultProxyProfileId,
|
||||
} from './globalDefaultProxy';
|
||||
|
||||
/**
|
||||
* Migrate deprecated primary-font ids out of a GroupConfig's
|
||||
@@ -26,6 +30,12 @@ export function sanitizeGroupConfig(config: GroupConfig): GroupConfig {
|
||||
|
||||
export interface ApplyGroupDefaultsOptions {
|
||||
validProxyProfileIds?: ReadonlySet<string>;
|
||||
/**
|
||||
* Saved proxy used when the host and its groups do not set one.
|
||||
* An empty string forces no global default. When the property is omitted,
|
||||
* the active app setting is used.
|
||||
*/
|
||||
globalProxyProfileId?: string;
|
||||
}
|
||||
|
||||
export const hasManualGroupSshCredentials = (config: Partial<GroupConfig>): boolean => [
|
||||
@@ -50,6 +60,28 @@ const hasUsableProxyProfileId = (
|
||||
return !options?.validProxyProfileIds || options.validProxyProfileIds.has(proxyProfileId);
|
||||
};
|
||||
|
||||
const resolveGlobalProxyProfileId = (
|
||||
options?: ApplyGroupDefaultsOptions,
|
||||
): string | undefined => {
|
||||
const hasExplicit = Boolean(options)
|
||||
&& Object.prototype.hasOwnProperty.call(options, 'globalProxyProfileId');
|
||||
const raw = hasExplicit
|
||||
? options?.globalProxyProfileId
|
||||
: getActiveGlobalDefaultProxyProfileId();
|
||||
const id = normalizeGlobalDefaultProxyProfileId(raw);
|
||||
if (!id) return undefined;
|
||||
if (options?.validProxyProfileIds && !options.validProxyProfileIds.has(id)) return undefined;
|
||||
return id;
|
||||
};
|
||||
|
||||
const withoutInheritedProxy = (host: Host): Host => {
|
||||
if (host.proxyProfileId === undefined && host.proxyConfig === undefined) return host;
|
||||
const next = { ...host };
|
||||
delete next.proxyProfileId;
|
||||
delete next.proxyConfig;
|
||||
return next;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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).
|
||||
@@ -268,6 +300,14 @@ export function applyGroupDefaults(
|
||||
(effective as unknown as Record<string, unknown>)[key] = groupValue;
|
||||
}
|
||||
}
|
||||
// Explicit direct beats a host proxy, the nearest group proxy, and the
|
||||
// global default. Clearing the host setting leaves directProxy unset and
|
||||
// still inherits.
|
||||
if (host.directProxy === true) return withoutInheritedProxy(effective);
|
||||
if (!effective.proxyConfig && !effective.proxyProfileId) {
|
||||
const globalProxyProfileId = resolveGlobalProxyProfileId(options);
|
||||
if (globalProxyProfileId) effective.proxyProfileId = globalProxyProfileId;
|
||||
}
|
||||
return effective;
|
||||
}
|
||||
|
||||
|
||||
@@ -217,6 +217,12 @@ export interface Host {
|
||||
proxy?: string; // Deprecated: use proxyConfig instead
|
||||
proxyProfileId?: string; // Reference to reusable proxy profile
|
||||
proxyConfig?: ProxyConfig; // New structured proxy configuration
|
||||
/**
|
||||
* Explicit direct connection for this host. Overrides group and global
|
||||
* proxy defaults. Absent means inherit. This is not the jump-host
|
||||
* "Direct" choice (`hostChain`).
|
||||
*/
|
||||
directProxy?: boolean;
|
||||
hostChain?: HostChainConfig; // New structured host chain configuration
|
||||
envVars?: string; // Deprecated: use environmentVariables instead
|
||||
environmentVariables?: EnvVar[]; // Structured environment variables
|
||||
|
||||
@@ -465,6 +465,14 @@ export const SETTINGS_SEARCH_CATALOG: readonly SettingsSearchEntry[] = [
|
||||
descriptionKey: "settings.terminal.localShell.shell.desc",
|
||||
sectionKey: "settings.terminal.section.localShell",
|
||||
},
|
||||
{
|
||||
id: "terminal-default-proxy",
|
||||
tab: "terminal",
|
||||
labelKey: "settings.terminal.connection.defaultProxy",
|
||||
descriptionKey: "settings.terminal.connection.defaultProxy.desc",
|
||||
sectionKey: "settings.terminal.section.connection",
|
||||
keywords: ["proxy", "socks", "默认代理", "全域"],
|
||||
},
|
||||
{
|
||||
id: "terminal-verify-host-keys",
|
||||
tab: "terminal",
|
||||
|
||||
@@ -368,6 +368,11 @@ export interface SyncPayload {
|
||||
showHostTreeSidebar?: boolean;
|
||||
// Workspace focus indicator style
|
||||
workspaceFocusStyle?: 'dim' | 'border';
|
||||
/**
|
||||
* Saved proxy profile for hosts that do not set a proxy and do not inherit
|
||||
* one from a group. An empty string is an explicit direct default.
|
||||
*/
|
||||
defaultProxyProfileId?: string;
|
||||
// AI configuration
|
||||
ai?: {
|
||||
providers?: Array<Record<string, unknown>>;
|
||||
|
||||
@@ -16,7 +16,7 @@ export {
|
||||
import { parseQuickConnectInput } from "./quickConnect";
|
||||
import { findExactHeaderIndex, findHeaderIndex, parseCsv } from "./vaultImport/csvUtils";
|
||||
import { decodeCsvKeyPath, decodeCsvLocalShell, decodeCsvPassphrase } from "./vaultImport/csvCredentialFields";
|
||||
import { parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { isCsvDirectProxyValue, parseCsvProxy } from "./vaultImport/csvProxy";
|
||||
import { attachMobaXtermPasswords } from "./vaultImport/mobaXtermPasswords";
|
||||
import { decodeFinalShellPassword } from "./finalShellPassword";
|
||||
export {
|
||||
@@ -532,8 +532,9 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
? decodedPassphrase
|
||||
: undefined;
|
||||
const proxyRaw = (proxyIdx >= 0 ? row[proxyIdx] : undefined)?.trim();
|
||||
const proxyConfig = proxyRaw ? parseCsvProxy(proxyRaw) : undefined;
|
||||
if (proxyRaw && !proxyConfig) {
|
||||
const directProxy = Boolean(proxyRaw && isCsvDirectProxyValue(proxyRaw));
|
||||
const proxyConfig = proxyRaw && !directProxy ? parseCsvProxy(proxyRaw) : undefined;
|
||||
if (proxyRaw && !directProxy && !proxyConfig) {
|
||||
issues.push({
|
||||
level: "warning",
|
||||
message: `CSV row ${i + 2}: Proxy was ignored because it is not a valid http://, socks5://, or command:// value.`,
|
||||
@@ -576,6 +577,7 @@ const importFromCsv = (text: string): VaultImportResult => {
|
||||
notes,
|
||||
});
|
||||
if (proxyConfig) host.proxyConfig = proxyConfig;
|
||||
if (directProxy) host.directProxy = true;
|
||||
if (localShellSpec && protocol === "local") {
|
||||
if (localShellSpec.shell) host.localShell = localShellSpec.shell;
|
||||
if (localShellSpec.shellArgs) host.localShellArgs = localShellSpec.shellArgs;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { publishActiveGlobalDefaultProxyProfileId } from "../globalDefaultProxy.ts";
|
||||
import { applyGroupDefaults } from "../groupConfig.ts";
|
||||
import { importVaultHostsFromText } from "../vaultImport.ts";
|
||||
import { exportHostsToCsvWithStats, getVaultCsvTemplate } from "./csvExport.ts";
|
||||
import type { Host } from "../models.ts";
|
||||
@@ -392,6 +394,106 @@ test("CSV preserves inherited proxies and gives host settings priority", () => {
|
||||
assert.deepEqual(hosts, original);
|
||||
});
|
||||
|
||||
test("CSV round-trips explicit direct proxy mode and still treats an empty Proxy as inherit", () => {
|
||||
const staleProxy = { type: "http" as const, host: "stale.example.com", port: 8080 };
|
||||
const groupProxy = { type: "socks5" as const, host: "group.example.com", port: 1080 };
|
||||
const hosts: Host[] = [
|
||||
{
|
||||
...hostDefaults,
|
||||
id: "direct",
|
||||
label: "Direct",
|
||||
hostname: "direct.example.com",
|
||||
directProxy: true,
|
||||
proxyConfig: staleProxy,
|
||||
},
|
||||
{
|
||||
...hostDefaults,
|
||||
id: "grouped-direct",
|
||||
label: "Grouped direct",
|
||||
hostname: "grouped.example.com",
|
||||
group: "Corp",
|
||||
directProxy: true,
|
||||
},
|
||||
{ ...hostDefaults, id: "inherit", label: "Inherit", hostname: "inherit.example.com" },
|
||||
];
|
||||
const { csv } = exportHostsToCsvWithStats(hosts, {
|
||||
groupConfigs: [{ path: "Corp", proxyConfig: groupProxy }],
|
||||
});
|
||||
const lines = csv.replace(/^\uFEFF/, "").trimEnd().split("\r\n");
|
||||
const proxyIndex = lines[0].split(",").indexOf("Proxy");
|
||||
const proxyCell = (line: string) => line.split(",")[proxyIndex];
|
||||
assert.equal(proxyCell(lines[1]), "direct");
|
||||
assert.equal(proxyCell(lines[2]), "direct");
|
||||
assert.equal(proxyCell(lines[3]), "");
|
||||
assert.equal(csv.includes("stale.example.com"), false);
|
||||
assert.equal(csv.includes("group.example.com"), false);
|
||||
|
||||
const imported = importVaultHostsFromText("csv", csv);
|
||||
assert.deepEqual(imported.issues, []);
|
||||
assert.equal(imported.hosts[0]?.directProxy, true);
|
||||
assert.equal(imported.hosts[0]?.proxyConfig, undefined);
|
||||
assert.equal(imported.hosts[1]?.directProxy, true);
|
||||
assert.equal(imported.hosts[1]?.proxyConfig, undefined);
|
||||
assert.equal(imported.hosts[2]?.directProxy, undefined);
|
||||
assert.equal(imported.hosts[2]?.proxyConfig, undefined);
|
||||
|
||||
const options = {
|
||||
globalProxyProfileId: "global-proxy",
|
||||
validProxyProfileIds: new Set(["global-proxy"]),
|
||||
};
|
||||
const directEffective = applyGroupDefaults(imported.hosts[0], {}, options);
|
||||
assert.equal(directEffective.directProxy, true);
|
||||
assert.equal(directEffective.proxyProfileId, undefined);
|
||||
const inheritEffective = applyGroupDefaults(imported.hosts[2], {}, options);
|
||||
assert.equal(inheritEffective.directProxy, undefined);
|
||||
assert.equal(inheritEffective.proxyProfileId, "global-proxy");
|
||||
|
||||
const literal = importVaultHostsFromText("csv", "Hostname,Proxy\nmanual.example.com,direct\nempty.example.com,");
|
||||
assert.deepEqual(literal.issues, []);
|
||||
assert.equal(literal.hosts[0]?.directProxy, true);
|
||||
assert.equal(literal.hosts[0]?.proxyConfig, undefined);
|
||||
assert.equal(literal.hosts[1]?.directProxy, undefined);
|
||||
});
|
||||
|
||||
test("CSV export keeps an empty Proxy cell when only the global default is set", () => {
|
||||
publishActiveGlobalDefaultProxyProfileId("global-proxy");
|
||||
try {
|
||||
const groupProxy = { type: "socks5" as const, host: "group.example.com", port: 1080 };
|
||||
const hosts: Host[] = [
|
||||
{ ...hostDefaults, id: "grouped", label: "Grouped", hostname: "grouped.example.com", group: "Corp" },
|
||||
{ ...hostDefaults, id: "plain", label: "Plain", hostname: "plain.example.com" },
|
||||
{ ...hostDefaults, id: "edge", label: "Edge", hostname: "edge.example.com", group: "Corp/Edge" },
|
||||
];
|
||||
const { csv } = exportHostsToCsvWithStats(hosts, {
|
||||
groupConfigs: [{ path: "Corp/Edge", proxyConfig: groupProxy }],
|
||||
proxyProfiles: [{
|
||||
id: "global-proxy",
|
||||
label: "Global",
|
||||
createdAt: 0,
|
||||
config: {
|
||||
type: "http",
|
||||
host: "secret-proxy.example.com",
|
||||
port: 8080,
|
||||
username: "alice",
|
||||
password: "s3cret",
|
||||
},
|
||||
}],
|
||||
});
|
||||
const lines = csv.replace(/^\uFEFF/, "").trimEnd().split("\r\n");
|
||||
const proxyIndex = lines[0].split(",").indexOf("Proxy");
|
||||
const proxyCell = (line: string) => line.split(",")[proxyIndex];
|
||||
assert.equal(proxyCell(lines[1]), "");
|
||||
assert.equal(proxyCell(lines[2]), "");
|
||||
assert.equal(proxyCell(lines[3]), "socks5://group.example.com:1080");
|
||||
assert.equal(csv.includes("secret-proxy.example.com"), false);
|
||||
assert.equal(csv.includes("s3cret"), false);
|
||||
assert.equal(importVaultHostsFromText("csv", csv).hosts[0]?.proxyConfig, undefined);
|
||||
assert.equal(importVaultHostsFromText("csv", csv).hosts[1]?.proxyConfig, undefined);
|
||||
} finally {
|
||||
publishActiveGlobalDefaultProxyProfileId(undefined);
|
||||
}
|
||||
});
|
||||
|
||||
test("CSV preserves IPv6 proxies and multiline commands with CSV punctuation", () => {
|
||||
const proxies = [
|
||||
{ type: "socks5" as const, host: "::1", port: 1080 },
|
||||
|
||||
@@ -3,7 +3,7 @@ import { applyGroupDefaults, resolveGroupDefaults } from '../groupConfig';
|
||||
import { isPluginHostProtocol } from '../pluginConnection';
|
||||
import { hasUnreadableProxyCredential, materializeHostProxyProfile, resolveProxyConfigAuth } from '../proxyProfiles';
|
||||
import { CsvLocalShellSpec, encodeCsvKeyPath, encodeCsvLocalShell, encodeCsvPassphrase, encodeCsvProxy } from './csvCredentialFields';
|
||||
import { formatCsvProxy } from './csvProxy';
|
||||
import { CSV_DIRECT_PROXY_VALUE, formatCsvProxy } from './csvProxy';
|
||||
|
||||
const UTF8_BOM = "\uFEFF";
|
||||
|
||||
@@ -112,7 +112,14 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
};
|
||||
|
||||
const proxyProfiles = options.proxyProfiles ?? [];
|
||||
const inheritanceOptions = { validProxyProfileIds: new Set(proxyProfiles.map((profile) => profile.id)) };
|
||||
// Materialize group proxies so the file stays self-contained, but never the
|
||||
// ambient global default. An empty Proxy cell means inherit for grouped and
|
||||
// ungrouped hosts alike; injecting the global profile would embed its
|
||||
// credentials and change routing based only on whether `group` is set.
|
||||
const inheritanceOptions = {
|
||||
validProxyProfileIds: new Set(proxyProfiles.map((profile) => profile.id)),
|
||||
globalProxyProfileId: "",
|
||||
};
|
||||
for (const host of exportableHosts) {
|
||||
// For telnet hosts, use telnet-specific port and username
|
||||
const isTelnet = host.protocol === "telnet";
|
||||
@@ -132,16 +139,27 @@ const exportHostsToCsv = (hosts: Host[], options: VaultCsvExportOptions) => {
|
||||
: "";
|
||||
// Proxy profiles are materialized inline so the CSV stays self-contained;
|
||||
// encrypted credential placeholders are never written to the file.
|
||||
const effectiveHost = host.group
|
||||
? applyGroupDefaults(host, resolveGroupDefaults(host.group, options.groupConfigs ?? [], inheritanceOptions), inheritanceOptions)
|
||||
: host;
|
||||
const proxyConfig = materializeHostProxyProfile(effectiveHost, proxyProfiles).proxyConfig;
|
||||
if (hasUnreadableProxyCredential(proxyConfig, options.identities)) {
|
||||
unreadableProxyCredentialCount += 1;
|
||||
const effectiveHost = applyGroupDefaults(
|
||||
host,
|
||||
host.group
|
||||
? resolveGroupDefaults(host.group, options.groupConfigs ?? [], inheritanceOptions)
|
||||
: {},
|
||||
inheritanceOptions,
|
||||
);
|
||||
// `direct` is the reversible CSV form of directProxy. An empty Proxy cell
|
||||
// still means inherit, including a configured global default.
|
||||
let proxyValue = "";
|
||||
if (effectiveHost.directProxy === true) {
|
||||
proxyValue = CSV_DIRECT_PROXY_VALUE;
|
||||
} else {
|
||||
const proxyConfig = materializeHostProxyProfile(effectiveHost, proxyProfiles).proxyConfig;
|
||||
if (hasUnreadableProxyCredential(proxyConfig, options.identities)) {
|
||||
unreadableProxyCredentialCount += 1;
|
||||
}
|
||||
if (proxyConfig) {
|
||||
proxyValue = formatCsvProxy(resolveProxyConfigAuth(proxyConfig, options.identities));
|
||||
}
|
||||
}
|
||||
const proxyValue = proxyConfig
|
||||
? formatCsvProxy(resolveProxyConfigAuth(proxyConfig, options.identities))
|
||||
: "";
|
||||
const localShellSpec = getLocalShellSpec(host);
|
||||
|
||||
rows.push([
|
||||
|
||||
@@ -2,9 +2,16 @@ import type { ProxyConfig } from "../models";
|
||||
import { decodeCsvProxy } from "./csvCredentialFields";
|
||||
|
||||
// CSV representation of a per-host proxy configuration:
|
||||
// direct
|
||||
// http://[user[:pass]@]host:port
|
||||
// socks5://[user[:pass]@]host:port
|
||||
// command://<command text, verbatim after the scheme>
|
||||
// `direct` is an explicit per-host direct connection. An empty cell still means inherit.
|
||||
export const CSV_DIRECT_PROXY_VALUE = "direct";
|
||||
|
||||
export const isCsvDirectProxyValue = (raw: string): boolean =>
|
||||
decodeCsvProxy(raw.trim()) === CSV_DIRECT_PROXY_VALUE;
|
||||
|
||||
const PROXY_SCHEME_PATTERN = /^(http|socks5|command):\/\/(.*)$/is;
|
||||
const PROXY_AUTHORITY_PATTERN = /^(?:([^@:/?#\s]+)(?::([^@/?#]*))?@)?(\[[^\[\]@/?#\s]+\]|[^\[\]@:/?#\s]+):(\d+)$/u;
|
||||
|
||||
|
||||
@@ -183,6 +183,8 @@ export const STORAGE_KEY_SHOW_TRAY_ICON = 'netcatty_show_tray_icon_v1';
|
||||
export const STORAGE_KEY_AUTO_LAUNCH_ENABLED = 'netcatty_auto_launch_enabled_v1';
|
||||
/** App-level HTTP(S) proxy for cloud sync / AI (not SSH ProxyJump). */
|
||||
export const STORAGE_KEY_HTTP_NETWORK_PROXY = 'netcatty_http_network_proxy_v1';
|
||||
/** Saved proxy profile used when a host and its groups do not set one. Empty string means explicit none. */
|
||||
export const STORAGE_KEY_GLOBAL_DEFAULT_PROXY_PROFILE = 'netcatty_global_default_proxy_profile_v1';
|
||||
export const STORAGE_KEY_GLOBAL_HOTKEY_ENABLED = 'netcatty_global_hotkey_enabled_v1';
|
||||
export const STORAGE_KEY_WINDOW_OPACITY = 'netcatty_window_opacity_v1';
|
||||
export const STORAGE_KEY_APP_ICON_VARIANT = 'netcatty_app_icon_variant_v1';
|
||||
|
||||
Reference in new issue
Block a user