Skip to content

Commit e19b68d

Browse files
authored
Merge pull request #52 from binaricat:copilot/add-auto-start-reconnect-port-forwarding
feat: add auto-start and auto-reconnect for port forwarding rules
2 parents dff869a + f6e67b6 commit e19b68d

10 files changed

Lines changed: 311 additions & 13 deletions

File tree

App.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, { Suspense, lazy, useCallback, useEffect, useMemo, useRef, useState } from 'react';
22
import { activeTabStore, useActiveTabId, useIsSftpActive, useIsTerminalLayerVisible, useIsVaultActive } from './application/state/activeTabStore';
33
import { useAutoSync } from './application/state/useAutoSync';
4+
import { usePortForwardingAutoStart } from './application/state/usePortForwardingAutoStart';
45
import { useSessionState } from './application/state/useSessionState';
56
import { useSettingsState } from './application/state/useSettingsState';
67
import { useUpdateCheck } from './application/state/useUpdateCheck';
@@ -284,6 +285,12 @@ function App({ settings }: { settings: SettingsState }) {
284285
}
285286
}, [updateState.hasUpdate, updateState.latestRelease, t, openReleasePage, dismissUpdate]);
286287

288+
// Auto-start port forwarding rules on app launch
289+
usePortForwardingAutoStart({
290+
hosts,
291+
keys: keys.map((k) => ({ id: k.id, privateKey: k.privateKey })),
292+
});
293+
287294
// Debounce ref for moveFocus to prevent double-triggering when focus switches
288295
const lastMoveFocusTimeRef = useRef<number>(0);
289296
const MOVE_FOCUS_DEBOUNCE_MS = 200;

application/i18n/locales/en.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,6 +375,8 @@ const en: Messages = {
375375
'pf.deleteActive.title': 'Delete Active Port Forwarding?',
376376
'pf.deleteActive.desc': 'This port forwarding rule "{label}" is currently active. Deleting it will stop the tunnel first.',
377377
'pf.deleteActive.confirm': 'Stop and Delete',
378+
'pf.form.autoStart': 'Auto Start',
379+
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
378380

379381
// SFTP
380382
'sftp.newFolder': 'New Folder',

application/i18n/locales/zh-CN.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,8 @@ const zhCN: Messages = {
674674
'pf.deleteActive.title': '删除正在运行的端口转发?',
675675
'pf.deleteActive.desc': '端口转发规则 "{label}" 当前正在运行。删除前将先关闭转发连接。',
676676
'pf.deleteActive.confirm': '关闭并删除',
677+
'pf.form.autoStart': '自动启动',
678+
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
677679

678680
// SFTP (pane + conflict)
679681
'sftp.pane.local': '本地',
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
/**
2+
* Hook for auto-starting port forwarding rules on app launch.
3+
* This should be used at the App level to ensure auto-start happens
4+
* when the application starts, not when the user navigates to the port forwarding page.
5+
*/
6+
import { useEffect, useRef } from "react";
7+
import { Host, PortForwardingRule } from "../../domain/models";
8+
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
9+
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
10+
import {
11+
getActiveConnection,
12+
setReconnectCallback,
13+
startPortForward,
14+
syncWithBackend,
15+
} from "../../infrastructure/services/portForwardingService";
16+
import { logger } from "../../lib/logger";
17+
18+
export interface UsePortForwardingAutoStartOptions {
19+
hosts: Host[];
20+
keys: { id: string; privateKey: string }[];
21+
}
22+
23+
/**
24+
* Auto-starts port forwarding rules that have autoStart enabled.
25+
* This hook should be called at the App level to run on app launch.
26+
*/
27+
export const usePortForwardingAutoStart = ({
28+
hosts,
29+
keys,
30+
}: UsePortForwardingAutoStartOptions): void => {
31+
const autoStartExecutedRef = useRef(false);
32+
const hostsRef = useRef<Host[]>(hosts);
33+
const keysRef = useRef<{ id: string; privateKey: string }[]>(keys);
34+
35+
// Keep refs in sync
36+
useEffect(() => {
37+
hostsRef.current = hosts;
38+
}, [hosts]);
39+
40+
useEffect(() => {
41+
keysRef.current = keys;
42+
}, [keys]);
43+
44+
// Set up the reconnect callback
45+
useEffect(() => {
46+
const handleReconnect = async (
47+
ruleId: string,
48+
onStatusChange: (status: PortForwardingRule["status"], error?: string) => void,
49+
) => {
50+
// Load the current rules from storage
51+
const rules = localStorageAdapter.read<PortForwardingRule[]>(
52+
STORAGE_KEY_PORT_FORWARDING,
53+
) ?? [];
54+
55+
const rule = rules.find((r) => r.id === ruleId);
56+
if (!rule || !rule.hostId) {
57+
return { success: false, error: "Rule or host not found" };
58+
}
59+
60+
const host = hostsRef.current.find((h) => h.id === rule.hostId);
61+
if (!host) {
62+
return { success: false, error: "Host not found" };
63+
}
64+
65+
return startPortForward(rule, host, keysRef.current, onStatusChange, true);
66+
};
67+
68+
setReconnectCallback(handleReconnect);
69+
return () => {
70+
setReconnectCallback(null);
71+
};
72+
}, []);
73+
74+
// Auto-start rules on app launch
75+
useEffect(() => {
76+
if (autoStartExecutedRef.current) return;
77+
if (hosts.length === 0) return;
78+
79+
const runAutoStart = async () => {
80+
// First sync with backend to get any active tunnels
81+
await syncWithBackend();
82+
83+
// Load rules from storage
84+
const rules = localStorageAdapter.read<PortForwardingRule[]>(
85+
STORAGE_KEY_PORT_FORWARDING,
86+
) ?? [];
87+
88+
// Only start rules that are not already active
89+
const autoStartRules = rules.filter((r) => {
90+
if (!r.autoStart || !r.hostId) return false;
91+
// Check if there's an active connection for this rule
92+
const conn = getActiveConnection(r.id);
93+
// Only start if not already connecting or active
94+
return !conn || conn.status === 'inactive' || conn.status === 'error';
95+
});
96+
97+
if (autoStartRules.length === 0) return;
98+
99+
autoStartExecutedRef.current = true;
100+
logger.info(`[PortForwardingAutoStart] Starting ${autoStartRules.length} auto-start rules`);
101+
102+
// Start each auto-start rule
103+
for (const rule of autoStartRules) {
104+
const host = hosts.find((h) => h.id === rule.hostId);
105+
if (host) {
106+
void startPortForward(
107+
rule,
108+
host,
109+
keys,
110+
(status, error) => {
111+
// Update the rule status in storage
112+
const currentRules = localStorageAdapter.read<PortForwardingRule[]>(
113+
STORAGE_KEY_PORT_FORWARDING,
114+
) ?? [];
115+
116+
const updatedRules = currentRules.map((r) =>
117+
r.id === rule.id
118+
? {
119+
...r,
120+
status,
121+
error,
122+
lastUsedAt: status === "active" ? Date.now() : r.lastUsedAt,
123+
}
124+
: r,
125+
);
126+
127+
localStorageAdapter.write(STORAGE_KEY_PORT_FORWARDING, updatedRules);
128+
},
129+
true, // Enable reconnect for auto-start rules
130+
);
131+
}
132+
}
133+
};
134+
135+
void runAutoStart();
136+
}, [hosts, keys]);
137+
};

application/state/usePortForwardingState.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
} from "../../infrastructure/config/storageKeys";
88
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
99
import {
10+
clearReconnectTimer,
1011
getActiveConnection,
1112
getActiveRuleIds,
1213
startPortForward,
@@ -51,6 +52,7 @@ export interface UsePortForwardingStateResult {
5152
host: Host,
5253
keys: { id: string; privateKey: string }[],
5354
onStatusChange?: (status: PortForwardingRule["status"], error?: string) => void,
55+
enableReconnect?: boolean,
5456
) => Promise<{ success: boolean; error?: string }>;
5557
stopTunnel: (
5658
ruleId: string,
@@ -212,11 +214,12 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
212214
status: PortForwardingRule["status"],
213215
error?: string,
214216
) => void,
217+
enableReconnect = false,
215218
) => {
216219
return startPortForward(rule, host, keys, (status, error) => {
217220
setRuleStatus(rule.id, status, error);
218221
onStatusChange?.(status, error ?? undefined);
219-
});
222+
}, enableReconnect);
220223
},
221224
[setRuleStatus],
222225
);
@@ -226,6 +229,8 @@ export const usePortForwardingState = (): UsePortForwardingStateResult => {
226229
ruleId: string,
227230
onStatusChange?: (status: PortForwardingRule["status"]) => void,
228231
) => {
232+
// Clear any pending reconnect timer when manually stopping
233+
clearReconnectTimer(ruleId);
229234
return stopPortForward(ruleId, (status) => {
230235
setRuleStatus(ruleId, status);
231236
onStatusChange?.(status);

components/PortForwardingNew.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
138138
);
139139
}
140140
},
141+
rule.autoStart, // Enable reconnect for auto-start rules
141142
);
142143
// Show error from result only if not already shown
143144
if (!result.success && result.error && !errorShown) {

components/port-forwarding/EditPanel.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { AsideActionMenu,AsideActionMenuItem,AsidePanel,AsidePanelContent,AsideP
1212
import { Button } from '../ui/button';
1313
import { Input } from '../ui/input';
1414
import { Label } from '../ui/label';
15+
import { Switch } from '../ui/switch';
1516

1617
export interface EditPanelProps {
1718
rule: PortForwardingRule;
@@ -152,6 +153,18 @@ export const EditPanel: React.FC<EditPanelProps> = ({
152153
</div>
153154
</>
154155
)}
156+
157+
{/* Auto Start Toggle */}
158+
<div className="flex items-center justify-between py-2">
159+
<div className="space-y-0.5">
160+
<Label className="text-sm font-medium">{t('pf.form.autoStart')}</Label>
161+
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoStartDesc')}</p>
162+
</div>
163+
<Switch
164+
checked={draft.autoStart ?? false}
165+
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
166+
/>
167+
</div>
155168
</AsidePanelContent>
156169
<AsidePanelFooter className="space-y-2">
157170
<Button

components/port-forwarding/NewFormPanel.tsx

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import { AsidePanel,AsidePanelContent,AsidePanelFooter } from '../ui/aside-panel
1313
import { Button } from '../ui/button';
1414
import { Input } from '../ui/input';
1515
import { Label } from '../ui/label';
16+
import { Switch } from '../ui/switch';
1617
import { getTypeLabel } from './utils';
1718

1819
export interface NewFormPanelProps {
@@ -153,6 +154,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
153154
</div>
154155
</>
155156
)}
157+
158+
{/* Auto Start Toggle */}
159+
<div className="flex items-center justify-between py-2">
160+
<div className="space-y-0.5">
161+
<Label className="text-sm font-medium">{t('pf.form.autoStart')}</Label>
162+
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoStartDesc')}</p>
163+
</div>
164+
<Switch
165+
checked={draft.autoStart ?? false}
166+
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
167+
/>
168+
</div>
156169
</AsidePanelContent>
157170
<AsidePanelFooter className="space-y-2">
158171
<Button

domain/models.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -576,6 +576,8 @@ export interface PortForwardingRule {
576576
remotePort?: number;
577577
// Host to tunnel through
578578
hostId?: string;
579+
// Auto-start: if true, this rule will automatically start when the app launches
580+
autoStart?: boolean;
579581
// Runtime state
580582
status: PortForwardingStatus;
581583
error?: string;

0 commit comments

Comments
 (0)