Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 310ab602c4 test: share the global default proxy settings harness
The peer-window case and the multi-instance cleanup case were mounting
the same JSDOM fixture twice.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:27:31 +00:00
Cursor Agentand陈大猫 277ac31f3f refactor: clear a deleted global default proxy at the call site
Drop the pass-through field on removeProxyProfileReferences. The profile
manager already knows the active id and clears it directly.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:26:45 +00:00
Cursor Agentand陈大猫 44bcc92c89 fix: follow global default proxy changes in peer session windows
Session windows disable settings IPC sync, which also skipped the
storage listener and left the resolver singleton at its mount-time id.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:26:45 +00:00
Cursor Agentand陈大猫 e30055d70e fix: keep CSV proxy export from embedding the global default
Grouped hosts ran through applyGroupDefaults and picked up the ambient
global proxy, while ungrouped hosts exported an empty inheriting cell.
Both paths now opt out of that default so inheritance stays inheritance.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 04:26:45 +00:00
Cursor Agentand陈大猫 3546b2baf4 fix: round-trip explicit direct mode in vault CSV
Write the Proxy cell as direct when a host sets directProxy, and import that literal back as an explicit direct connection. An empty Proxy cell still inherits.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:50:22 +00:00
Cursor Agentand陈大猫 d6a13e3a8b fix: keep global default proxy persistence across settings instances
Unregister only the setter owned by the unmounting useSettingsState instance so closing Settings still writes the cleared default through the remaining AppLockGate setter.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:50:22 +00:00
Cursor Agentand陈大猫 735368e697 feat: add a global default proxy with an explicit direct override
Resolve the proxy as the host setting, then the nearest group default,
then one optional saved profile from synced settings, then a direct
connection. A host can choose direct and skip both inherited defaults.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-10-20 03:10:10 +00:00
36 changed files with 876 additions and 32 deletions

No files matched your search

+3
View File
@@ -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.',
+2
View File
@@ -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',
+3
View File
@@ -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.',
+2
View File
@@ -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',
+3
View File
@@ -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': 'Показывать адрес хоста и сведения о сервере над терминалом. Кнопки действий остаются доступными после скрытия.',
+2
View File
@@ -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': '会话保持间隔',
+2
View File
@@ -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': '工作階段保持間隔',
+2
View File
@@ -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': '環境變數',
+13
View File
@@ -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), []);
});
});
+53 -1
View File
@@ -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,
]),
};
+24
View File
@@ -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");
+13
View File
@@ -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) {
+26 -1
View File
@@ -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"
+12 -3
View File
@@ -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}
+7
View File
@@ -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,
+8
View File
@@ -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>
)}
+8 -4
View File
@@ -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 -9
View File
@@ -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")}
+53
View File
@@ -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);
}
});
+62
View File
@@ -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);
};
+115
View File
@@ -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);
});
+40
View File
@@ -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;
}
+6
View File
@@ -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
+8
View File
@@ -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",
+5
View File
@@ -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>>;
+5 -3
View File
@@ -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;
+102
View File
@@ -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 },
+29 -11
View File
@@ -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([
+7
View File
@@ -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;
+2
View File
@@ -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';