mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-08 09:01:40 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b63489e0fa | ||
|
|
ccc1fe3e15 | ||
|
|
63e7fd988d | ||
|
|
5b20f1de22 | ||
|
|
d6cf695ad2 | ||
|
|
4f7292b6da | ||
|
|
9410805d75 |
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Новая папка',
|
||||
|
||||
@@ -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': '本地',
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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." },
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user