Compare commits

...
27 changed files with 306 additions and 48 deletions

No files matched your search

+2 -1
View File
@@ -9,6 +9,7 @@ import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
@@ -153,7 +154,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings, knownHosts);
}, isPortForwardingAutoReconnectEnabled(rule), terminalSettings, knownHosts);
}
return;
}
+2
View File
@@ -266,6 +266,8 @@ export const enVaultMessages: Messages = {
'pf.deleteActive.confirm': 'Stop and Delete',
'pf.form.autoStart': 'Auto Start',
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
'pf.form.autoReconnect': 'Auto Reconnect',
'pf.form.autoReconnectDesc': 'Automatically reconnect when the connection drops',
// SFTP
'sftp.newFolder': 'New Folder',
+2
View File
@@ -266,6 +266,8 @@ export const esVaultMessages: Messages = {
'pf.deleteActive.confirm': 'Detener y eliminar',
'pf.form.autoStart': 'Inicio automático',
'pf.form.autoStartDesc': 'Inicia automáticamente esta regla cuando se abra la aplicación',
'pf.form.autoReconnect': 'Reconexión automática',
'pf.form.autoReconnectDesc': 'Vuelve a conectar automáticamente si se pierde la conexión',
// SFTP
'sftp.newFolder': 'Nueva carpeta',
+2
View File
@@ -304,6 +304,8 @@ export const ruVaultMessages: Messages = {
'pf.deleteActive.confirm': 'Остановить и удалить',
'pf.form.autoStart': 'Автозапуск',
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
'pf.form.autoReconnect': 'Автопереподключение',
'pf.form.autoReconnectDesc': 'Автоматически восстанавливать соединение при обрыве',
// SFTP
'sftp.newFolder': 'Новая папка',
+2
View File
@@ -980,6 +980,8 @@ export const zhCNVaultMessages: Messages = {
'pf.deleteActive.confirm': '关闭并删除',
'pf.form.autoStart': '自动启动',
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
'pf.form.autoReconnect': '自动重连',
'pf.form.autoReconnectDesc': '连接断开后自动尝试重新连接',
// SFTP (pane + conflict)
'sftp.pane.local': '本地',
+2
View File
@@ -980,6 +980,8 @@ export const zhTWVaultMessages: Messages = {
'pf.deleteActive.confirm': '關閉並刪除',
'pf.form.autoStart': '自動啟動',
'pf.form.autoStartDesc': '應用程式啟動時自動開啟此規則',
'pf.form.autoReconnect': '自動重連',
'pf.form.autoReconnectDesc': '連線中斷後自動嘗試重新連線',
// SFTP (pane + conflict)
'sftp.pane.local': '本機',
@@ -7,6 +7,7 @@ import {
getAutoStartRuleBlockReason,
isAutoStartProxyReady,
isPortForwardingAutoStartEnabled,
isPortForwardingReconnectRequested,
} from "./usePortForwardingAutoStart.ts";
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
@@ -177,6 +178,15 @@ test("reconnect eligibility follows the current auto-start setting", () => {
assert.equal(isPortForwardingAutoStartEnabled([], "rule-1"), false);
});
test("reconnect is requested for auto-start and auto-reconnect rules only", () => {
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true })], "rule-1"), true);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true, autoReconnect: false })], "rule-1"), false);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: true })], "rule-1"), true);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: false })], "rule-1"), false);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false })], "rule-1"), false);
assert.equal(isPortForwardingReconnectRequested([], "rule-1"), false);
});
test("network recovery starts an exhausted auto-start rule through the real service path", async (t) => {
const env = installStorage();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
@@ -8,6 +8,7 @@ import { GroupConfig, Host, Identity, KnownHost, PortForwardingRule, ProxyProfil
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
getActiveConnection,
@@ -103,6 +104,11 @@ export const isPortForwardingAutoStartEnabled = (
ruleId: string,
): boolean => rules.some((rule) => rule.id === ruleId && rule.autoStart === true);
export const isPortForwardingReconnectRequested = (
rules: PortForwardingRule[],
ruleId: string,
): boolean => rules.some((rule) => rule.id === ruleId && isPortForwardingAutoReconnectEnabled(rule));
export const shouldStartPortForwardingAutoStartRule = (
rule: PortForwardingRule,
connection?: { status: PortForwardingRule["status"] },
@@ -110,6 +116,18 @@ export const shouldStartPortForwardingAutoStartRule = (
!connection || connection.status === "inactive" || connection.status === "error"
);
/**
* Network-recovery counterpart of `shouldStartPortForwardingAutoStartRule`:
* unlike launch-time auto-start, rules that only opt in via `autoReconnect`
* are also restarted after network restoration.
*/
export const shouldRestartPortForwardingReconnectRule = (
rule: PortForwardingRule,
connection?: { status: PortForwardingRule["status"] },
): boolean => isPortForwardingAutoReconnectEnabled(rule) && (
!connection || connection.status === "inactive" || connection.status === "error"
);
export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
restartAutoStartRules: (recoverableRuleIds: ReadonlySet<string>) => Promise<void>,
): Promise<void> => {
@@ -118,7 +136,7 @@ export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
) ?? [];
const recoverableRuleIds = new Set<string>();
for (const rule of rules) {
if (rule.autoStart && resetReconnectAttempts(rule.id)) {
if (isPortForwardingAutoReconnectEnabled(rule) && resetReconnectAttempts(rule.id)) {
recoverableRuleIds.add(rule.id);
}
}
@@ -184,7 +202,7 @@ export const runPortForwardingAutoStart = async ({
recoveryRuleIds,
}: RunPortForwardingAutoStartOptions): Promise<void> => {
await syncWithBackend({
shouldReconnect: (ruleId) => isPortForwardingAutoStartEnabled(
shouldReconnect: (ruleId) => isPortForwardingReconnectRequested(
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
ruleId,
),
@@ -197,9 +215,11 @@ export const runPortForwardingAutoStart = async ({
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const autoStartRules = rules.filter((rule) =>
(!recoveryRuleIds || recoveryRuleIds.has(rule.id)) &&
(!recoveryRuleIds || isReconnectRecoveryEligible(rule.id)) &&
shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
recoveryRuleIds
? recoveryRuleIds.has(rule.id) &&
isReconnectRecoveryEligible(rule.id) &&
shouldRestartPortForwardingReconnectRule(rule, getActiveConnection(rule.id))
: shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
);
if (autoStartRules.length === 0) return;
@@ -385,7 +405,7 @@ export const usePortForwardingAutoStart = ({
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
};
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingReconnectRequested(
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
ruleId,
));
+6
View File
@@ -32,6 +32,11 @@ export const useTrayPanelBackend = () => {
await bridge?.connectToHostFromTrayPanel?.(hostId);
}, []);
const startPortForwardFromTrayPanel = useCallback(async (ruleId: string) => {
const bridge = netcattyBridge.get();
return bridge?.startPortForwardFromTrayPanel?.(ruleId);
}, []);
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelCloseRequest?.(callback);
@@ -71,6 +76,7 @@ export const useTrayPanelBackend = () => {
quitApp,
jumpToSession,
closeSessionFromTrayPanel,
startPortForwardFromTrayPanel,
connectToHostFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
+6 -1
View File
@@ -30,6 +30,7 @@ import {
isPortForwardingRuleStartable,
isPortForwardingRuleStoppable,
} from "../domain/portForwardingBulkActions";
import { isPortForwardingAutoReconnectEnabled } from "../domain/portForwardingReconnect";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel";
@@ -207,7 +208,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
);
}
},
rule.autoStart, // Enable reconnect for auto-start rules
isPortForwardingAutoReconnectEnabled(rule), // Enable reconnect for auto-reconnect rules (auto-start implies reconnect)
terminalSettings,
knownHosts,
);
@@ -516,6 +517,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
remoteHost: newFormDraft.remoteHost,
remotePort: newFormDraft.remotePort,
hostId: newFormDraft.hostId,
autoStart: newFormDraft.autoStart ?? false,
autoReconnect: isPortForwardingAutoReconnectEnabled(newFormDraft),
});
setShowNewForm(false);
@@ -714,6 +717,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
remoteHost: draftRule.remoteHost,
remotePort: draftRule.remotePort,
hostId: draftRule.hostId,
autoStart: draftRule.autoStart ?? false,
autoReconnect: isPortForwardingAutoReconnectEnabled(draftRule),
});
}
+8 -25
View File
@@ -10,10 +10,8 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { upsertKnownHost } from "../domain/knownHosts";
import type { Host, KnownHost } from "../domain/models";
import type { KnownHost } from "../domain/models";
import { getEffectiveKnownHosts } from "../infrastructure/syncHelpers";
import { PortForwardHostKeyTrayPrompt } from "./port-forwarding";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
@@ -136,11 +134,7 @@ const WorkspaceGroup: React.FC<{
);
};
interface TrayPanelContentProps {
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
}
const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings }) => {
const TrayPanelContent: React.FC = () => {
const { t } = useI18n();
const {
hideTrayPanel,
@@ -148,27 +142,23 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
quitApp,
jumpToSession,
closeSessionFromTrayPanel,
startPortForwardFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys, identities, proxyProfiles, groupConfigs, knownHosts, updateKnownHosts } = useVaultState();
const { hosts, knownHosts, updateKnownHosts } = useVaultState();
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
// independent from (and typically empty compared to) the main App's — it's
// used here only for its storage-sync side effects, never for closeSession.
useSessionState({ persistSessionRestore: false });
const {
rules: portForwardingRules,
startTunnel,
stopTunnel,
hasRuntimeTunnel,
} = usePortForwardingState();
const activeTabId = useActiveTabId();
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
@@ -444,16 +434,9 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
if (!result.success && result.error) toast.error(result.error);
});
} else {
const resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
};
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings, effectiveKnownHosts);
void startPortForwardFromTrayPanel(rule.id).then((result) => {
if (result && !result.success && result.error) toast.error(result.error);
});
}
}}
className={cn(
@@ -519,7 +502,7 @@ type SettingsState = AppLockGateRenderContext["settings"];
const TrayPanel: React.FC<{ settings: SettingsState }> = ({ settings }) => {
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent terminalSettings={settings.terminalSettings} />
<TrayPanelContent />
</I18nProvider>
);
};
+13
View File
@@ -4,6 +4,7 @@
*/
import { ChevronDown,Copy,Trash2 } from 'lucide-react';
import React from 'react';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host,PortForwardingRule } from '../../domain/models';
import { DistroAvatar } from '../DistroAvatar';
@@ -182,6 +183,18 @@ export const EditPanel: React.FC<EditPanelProps> = ({
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
/>
</div>
{/* Auto Reconnect Toggle */}
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
</div>
<Switch
checked={isPortForwardingAutoReconnectEnabled(draft)}
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
/>
</div>
</AsidePanelContent>
<AsidePanelFooter className="space-y-2">
<Button
@@ -4,6 +4,7 @@
*/
import { ChevronDown,Zap } from 'lucide-react';
import React from 'react';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host,PortForwardingRule,PortForwardingType } from '../../domain/models';
import { cn } from '../../lib/utils';
@@ -182,6 +183,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
/>
</div>
{/* Auto Reconnect Toggle */}
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
</div>
<Switch
checked={isPortForwardingAutoReconnectEnabled(draft)}
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
/>
</div>
</AsidePanelContent>
<AsidePanelFooter className="space-y-2">
<Button
+3
View File
@@ -27,6 +27,9 @@ export interface PortForwardingRule {
hostId?: string;
// Auto-start: if true, this rule will automatically start when the app launches
autoStart?: boolean;
// Auto-reconnect: if true, the tunnel automatically reconnects (with bounded
// retries) after an unexpected disconnect, regardless of auto-start
autoReconnect?: boolean;
/**
* Runtime projection for the UI. Authoritative phase lives in the Electron
* main-process registry; this field is rebuilt from snapshots / events and
+16 -10
View File
@@ -23,6 +23,15 @@ const parsePort = (value: unknown, name: string): Result<number> => {
: { ok: false, error: `${name} must be an integer between 1 and 65535.` };
};
const parseToggle = (value: unknown, name: string): Result<boolean | undefined> => {
if (value === undefined) return { ok: true, value: undefined };
if (typeof value === 'boolean') return { ok: true, value };
const normalized = String(value).trim().toLowerCase();
if (['true', '1', 'yes'].includes(normalized)) return { ok: true, value: true };
if (['false', '0', 'no'].includes(normalized)) return { ok: true, value: false };
return { ok: false, error: `${name} must be true or false.` };
};
export const validatePortForwardingHost = (
hosts: Host[],
hostId: string | undefined,
@@ -61,16 +70,12 @@ function buildRule(
remotePort = parsedRemotePort.value;
}
if (!existing && !newRule) return { ok: false, error: 'New rule id and timestamp are required.' };
let autoStart = existing?.autoStart ?? false;
if (source.autoStart !== undefined) {
if (typeof source.autoStart === 'boolean') autoStart = source.autoStart;
else {
const normalized = String(source.autoStart).trim().toLowerCase();
if (['true', '1', 'yes'].includes(normalized)) autoStart = true;
else if (['false', '0', 'no'].includes(normalized)) autoStart = false;
else return { ok: false, error: 'autoStart must be true or false.' };
}
}
const autoStartToggle = parseToggle(source.autoStart, 'autoStart');
if ('error' in autoStartToggle) return autoStartToggle;
const autoStart = autoStartToggle.value ?? existing?.autoStart ?? false;
const autoReconnectToggle = parseToggle(source.autoReconnect, 'autoReconnect');
if ('error' in autoReconnectToggle) return autoReconnectToggle;
const autoReconnect = autoReconnectToggle.value ?? existing?.autoReconnect;
return {
ok: true,
value: {
@@ -82,6 +87,7 @@ function buildRule(
...(type === 'dynamic' ? {} : { remoteHost, remotePort }),
hostId,
autoStart,
...(autoReconnect === undefined ? {} : { autoReconnect }),
status: existing?.status ?? 'inactive',
error: existing?.error,
lastUsedAt: existing?.lastUsedAt,
+23
View File
@@ -0,0 +1,23 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { isPortForwardingAutoReconnectEnabled } from "./portForwardingReconnect";
describe("isPortForwardingAutoReconnectEnabled", () => {
it("returns true when autoReconnect is enabled", () => {
assert.equal(isPortForwardingAutoReconnectEnabled({ autoReconnect: true }), true);
});
it("returns true for auto-start rules (pre-existing behavior)", () => {
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: true }), true);
});
it("honors explicit disable even for auto-start rules", () => {
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: true, autoReconnect: false }), false);
});
it("returns false when neither flag opts in", () => {
assert.equal(isPortForwardingAutoReconnectEnabled({}), false);
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: false }), false);
assert.equal(isPortForwardingAutoReconnectEnabled({ autoReconnect: false }), false);
});
});
+12
View File
@@ -0,0 +1,12 @@
import type { PortForwardingRule } from "./models";
/**
* Whether a port-forwarding rule should automatically reconnect (with bounded
* retries) after an unexpected disconnect.
*
* Legacy rules without `autoReconnect` retain their auto-start reconnect
* behavior. An explicit choice always takes precedence.
*/
export const isPortForwardingAutoReconnectEnabled = (
rule: Pick<PortForwardingRule, "autoStart" | "autoReconnect">,
): boolean => rule.autoReconnect ?? rule.autoStart === true;
+15
View File
@@ -1271,6 +1271,21 @@ function registerHandlers(ipcMain) {
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:startPortForward", async (_event, ruleId) => {
const win = await openMainWindowReady();
if (!win) return { success: false, error: "Main window is not available" };
if (isAppRuntimeLocked()) {
sendOrQueuePortForwardToggle(win, ruleId, true);
return { success: true };
}
const delivered = await sendToMainWindow("netcatty:trayPanel:startPortForward", ruleId, {
focus: false,
});
return delivered
? { success: true }
: { success: false, error: "Main window is not ready" };
});
ipcMain.handle("netcatty:trayPanel:closeSession", async (_event, sessionId) => {
const delivered = await sendToMainWindow("netcatty:trayPanel:closeSession", sessionId, {
focus: false,
@@ -1770,3 +1770,55 @@ test("setCloseToTray(false) still destroys an unpinned tray as before", async ()
bridge.cleanup();
}
});
test("tray panel forwarding start reaches the main renderer coordinator", async () => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const sentMessages = [];
const mainWin = new FakeWindow();
mainWin.webContents = { send: (...args) => sentMessages.push(args) };
electronModule.BrowserWindow.getAllWindows = () => [mainWin];
bridge.init({ electronModule, getMainWindow: () => mainWin });
const ipcMain = createIpcMainStub();
bridge.registerHandlers(ipcMain);
const result = await ipcMain.handlers.get("netcatty:trayPanel:startPortForward")(null, "reconnect-rule");
assert.deepEqual(result, { success: true });
assert.ok(sentMessages.some((args) =>
args[0] === "netcatty:trayPanel:startPortForward" && args[1] === "reconnect-rule"));
});
});
test("tray panel forwarding waits for a newly created main renderer", async () => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const mainWin = new FakeWindow();
let main = null;
let releaseReady;
let delivered = false;
const ready = new Promise((resolve) => { releaseReady = resolve; });
electronModule.BrowserWindow.getAllWindows = () => main ? [main] : [];
bridge.init({
electronModule,
getMainWindow: () => main,
ensureMainWindow: async () => { main = mainWin; return mainWin; },
sendWhenRendererReady: async (win, channel, ruleId) => {
assert.equal(win, mainWin);
assert.equal(channel, "netcatty:trayPanel:startPortForward");
assert.equal(ruleId, "new-main-rule");
await ready;
delivered = true;
return { success: true };
},
});
const ipcMain = createIpcMainStub();
bridge.registerHandlers(ipcMain);
const pending = ipcMain.handlers.get("netcatty:trayPanel:startPortForward")(null, "new-main-rule");
assert.equal(delivered, false);
releaseReady();
assert.deepEqual(await pending, { success: true });
assert.equal(delivered, true);
});
});
@@ -327,6 +327,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
remotePort: { type: "number", optional: true, description: "Required except for dynamic forwarding." },
hostId: { type: "string", description: "Vault host ID used for the tunnel." },
autoStart: { type: "string", optional: true, description: "true or false." },
autoReconnect: { type: "string", optional: true, description: "true or false. Reconnect automatically after unexpected disconnects." },
},
"portforward.rules.update": {
ruleId: { type: "string", description: "Port forwarding rule ID." },
@@ -338,6 +339,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
remotePort: { type: "number", optional: true, description: "Remote port." },
hostId: { type: "string", optional: true, description: "Vault host ID used for the tunnel." },
autoStart: { type: "string", optional: true, description: "true or false." },
autoReconnect: { type: "string", optional: true, description: "true or false. Reconnect automatically after unexpected disconnects." },
},
"portforward.rules.duplicate": {
ruleId: { type: "string", description: "Port forwarding rule ID to copy." },
+8 -1
View File
@@ -1571,8 +1571,13 @@ function createPreloadApi(ctx) {
},
onTrayTogglePortForward: (callback) => {
const handler = (_event, ruleId, start) => callback(ruleId, start);
const startHandler = (_event, ruleId) => callback(ruleId, true);
ipcRenderer.on("netcatty:tray:togglePortForward", handler);
return () => ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
ipcRenderer.on("netcatty:trayPanel:startPortForward", startHandler);
return () => {
ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
ipcRenderer.removeListener("netcatty:trayPanel:startPortForward", startHandler);
};
},
// Tray panel actions forwarded to main window
@@ -1600,6 +1605,8 @@ function createPreloadApi(ctx) {
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
connectToHostFromTrayPanel: (hostId) =>
ipcRenderer.invoke("netcatty:trayPanel:connectToHost", hostId),
startPortForwardFromTrayPanel: (ruleId) =>
ipcRenderer.invoke("netcatty:trayPanel:startPortForward", ruleId),
closeSessionFromTrayPanel: (sessionId) =>
ipcRenderer.invoke("netcatty:trayPanel:closeSession", sessionId),
onTrayPanelCloseRequest: (callback) => {
@@ -1993,6 +1993,11 @@
"type": "string",
"optional": true,
"description": "true or false."
},
"autoReconnect": {
"type": "string",
"optional": true,
"description": "true or false. Reconnect automatically after unexpected disconnects."
}
},
"policy": {
@@ -2056,6 +2061,11 @@
"type": "string",
"optional": true,
"description": "true or false."
},
"autoReconnect": {
"type": "string",
"optional": true,
"description": "true or false. Reconnect automatically after unexpected disconnects."
}
},
"policy": {
@@ -2077,6 +2077,11 @@
"type": "string",
"optional": true,
"description": "true or false."
},
"autoReconnect": {
"type": "string",
"optional": true,
"description": "true or false. Reconnect automatically after unexpected disconnects."
}
},
"policy": {
@@ -2141,6 +2146,11 @@
"type": "string",
"optional": true,
"description": "true or false."
},
"autoReconnect": {
"type": "string",
"optional": true,
"description": "true or false. Reconnect automatically after unexpected disconnects."
}
},
"policy": {
+3 -1
View File
@@ -59,6 +59,7 @@ import {
updatePortForwardingRule,
validatePortForwardingHost,
} from '../../domain/portForwardingAgentOps';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { deleteGroup, upsertGroup } from '../../domain/vaultGroupAgentOps';
import {
remapSnippetTargetGroupPaths,
@@ -182,6 +183,7 @@ export function sanitizePortForwardRuleForAgent(rule: PortForwardingRule): Recor
remotePort: rule.remotePort,
hostId: rule.hostId,
autoStart: rule.autoStart,
autoReconnect: rule.autoReconnect,
status: rule.status,
error: rule.error,
createdAt: rule.createdAt,
@@ -1393,7 +1395,7 @@ export async function handleVaultAgentOp(
deps.keys,
deps.identities,
undefined,
false,
isPortForwardingAutoReconnectEnabled(rule),
deps.terminalSettings,
deps.knownHosts,
);
@@ -1,4 +1,5 @@
import test from "node:test";
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect.ts";
import assert from "node:assert/strict";
import type { Host, PortForwardingRule, SSHKey } from "../../domain/models.ts";
@@ -2317,3 +2318,45 @@ test("startAllPortForwards skips tracked error runtimes and stopAll stops them",
stopAndCleanupRule("bulk-idle-after-error");
await new Promise<void>((resolve) => setImmediate(resolve));
});
for (const type of ["local", "remote", "dynamic"] as const) {
test(`${type} reconnect follows live rule choices and respects manual stop`, async (t) => {
const liveRule = rule({ id: `reconnect-choice-${type}`, type, autoStart: true });
let listener: ((status: PortForwardingRule["status"], error?: string) => void) | undefined;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
startPortForward: async () => ({ success: true }),
onPortForwardStatus: (_id: string, callback: typeof listener) => {
listener = callback;
return () => undefined;
},
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
} },
});
setReconnectCallback(async () => ({ success: true }), () => isPortForwardingAutoReconnectEnabled(liveRule));
t.after(async () => {
setReconnectCallback(null);
await stopAndCleanupRuleAndWait(liveRule.id);
});
const start = async () => {
await startPortForward(liveRule, host(), [], [], [], () => undefined,
isPortForwardingAutoReconnectEnabled(liveRule));
listener?.("active");
};
// Legacy auto-start still reconnects, until explicitly turned off while active.
await start();
liveRule.autoReconnect = false;
listener?.("error", "network dropped");
assert.equal(getActiveConnection(liveRule.id)?.reconnectTimerCallback, undefined);
await stopAndCleanupRuleAndWait(liveRule.id);
// Enabling reconnect while active takes effect without restarting the tunnel.
liveRule.autoStart = false;
await start();
liveRule.autoReconnect = true;
listener?.("error", "network dropped");
assert.ok(getActiveConnection(liveRule.id)?.reconnectTimerCallback);
await stopPortForward(liveRule.id, () => undefined);
assert.equal(getActiveConnection(liveRule.id)?.reconnectTimerCallback, undefined);
});
}
@@ -12,6 +12,7 @@ import {
selectStoppablePortForwardingRules,
} from '../../domain/portForwardingBulkActions';
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from '../../domain/sshAuth';
import { resolveHostKeepalive } from '../../domain/host';
import { resolveHostSshConnectionTimeouts } from '../../domain/sshConnectionTimeouts';
@@ -215,11 +216,21 @@ const scheduleReconnectIfNeeded = (
enableReconnect: boolean,
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
): boolean => {
if (!enableReconnect || !reconnectCallback || shouldReconnectRule?.(ruleId) === false) {
// The rule's autoReconnect setting may change while the tunnel is running
// (e.g. toggled in the edit panel). The start-time flag alone would keep
// rejecting reconnects after such a change, so also consult the dynamic
// rule gate, which reads fresh rule state.
const effectiveEnableReconnect =
enableReconnect || shouldReconnectRule?.(ruleId) === true;
if (
!effectiveEnableReconnect ||
!reconnectCallback ||
shouldReconnectRule?.(ruleId) === false
) {
return false;
}
if (rulesPendingCleanup.has(ruleId)) {
deferredReconnects.set(ruleId, { enableReconnect, onStatusChange });
deferredReconnects.set(ruleId, { enableReconnect: effectiveEnableReconnect, onStatusChange });
return true;
}
@@ -1392,7 +1403,7 @@ export const startAllPortForwards = async (
keys,
identities,
(status, error) => onStatusChange(rule.id, status, error),
Boolean(rule.autoStart),
isPortForwardingAutoReconnectEnabled(rule),
terminalSettings,
knownHosts,
);
+1
View File
@@ -108,6 +108,7 @@ declare global {
quitApp?(): Promise<{ success: boolean }>;
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
startPortForwardFromTrayPanel?(ruleId: string): Promise<{ success: boolean; error?: string }>;
closeSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
onTrayPanelCloseRequest?(callback: () => void): () => void;
onTrayPanelRefresh?(callback: () => void): () => void;