Add gateway auto toggle for client mode
This commit is contained in:
@@ -174,6 +174,13 @@ export function createGatewayAutoState() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyGatewayPreference(state, enabled) {
|
||||||
|
return {
|
||||||
|
...state,
|
||||||
|
mode: enabled && state.gatewayId ? 'gateway-direct' : 'local-vpn',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function nextGatewayAutoState(current, {
|
export function nextGatewayAutoState(current, {
|
||||||
network,
|
network,
|
||||||
verifiedGateway = null,
|
verifiedGateway = null,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { spawn, spawnSync } from 'node:child_process';
|
|||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
import { settings } from './config.js';
|
import { settings } from './config.js';
|
||||||
import {
|
import {
|
||||||
|
applyGatewayPreference,
|
||||||
buildGatewayPresence,
|
buildGatewayPresence,
|
||||||
createGatewayAutoState,
|
createGatewayAutoState,
|
||||||
nextGatewayAutoState,
|
nextGatewayAutoState,
|
||||||
@@ -183,6 +184,7 @@ async function startSingbox() {
|
|||||||
|
|
||||||
function publicState() {
|
function publicState() {
|
||||||
const state = readJson(settings.statePath, {});
|
const state = readJson(settings.statePath, {});
|
||||||
|
const gatewayAutoEnabled = state.gatewayAutoEnabled !== false;
|
||||||
return {
|
return {
|
||||||
mode: settings.appMode,
|
mode: settings.appMode,
|
||||||
port: settings.port,
|
port: settings.port,
|
||||||
@@ -197,6 +199,8 @@ function publicState() {
|
|||||||
fetchedAt: state.fetchedAt || null,
|
fetchedAt: state.fetchedAt || null,
|
||||||
gatewayAuto: settings.appMode === 'client' ? {
|
gatewayAuto: settings.appMode === 'client' ? {
|
||||||
mode: gatewayAutoState.mode,
|
mode: gatewayAutoState.mode,
|
||||||
|
enabled: gatewayAutoEnabled,
|
||||||
|
available: Boolean(gatewayAutoState.gatewayId),
|
||||||
address: gatewayAutoState.gateway?.gateway || '',
|
address: gatewayAutoState.gateway?.gateway || '',
|
||||||
interface: gatewayAutoState.gateway?.interface || '',
|
interface: gatewayAutoState.gateway?.interface || '',
|
||||||
failures: gatewayAutoState.failures,
|
failures: gatewayAutoState.failures,
|
||||||
@@ -291,7 +295,10 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
|||||||
return gatewayAutoState;
|
return gatewayAutoState;
|
||||||
}
|
}
|
||||||
await applyGatewayAutoState(
|
await applyGatewayAutoState(
|
||||||
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }),
|
applyGatewayPreference(
|
||||||
|
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, verifiedGateway }),
|
||||||
|
latestState.gatewayAutoEnabled !== false,
|
||||||
|
),
|
||||||
{ reconfigure },
|
{ reconfigure },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -311,7 +318,10 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
|||||||
console.warn(`[control] Gateway не используется: ${reason}`);
|
console.warn(`[control] Gateway не используется: ${reason}`);
|
||||||
}
|
}
|
||||||
await applyGatewayAutoState(
|
await applyGatewayAutoState(
|
||||||
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }),
|
applyGatewayPreference(
|
||||||
|
nextGatewayAutoState(gatewayAutoState, { network: latestNetwork, error: reason }),
|
||||||
|
latestState.gatewayAutoEnabled !== false,
|
||||||
|
),
|
||||||
{ reconfigure },
|
{ reconfigure },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -458,8 +468,10 @@ async function handleApi(req, res) {
|
|||||||
const parsed = await fetchSubscription(normalizedUrl);
|
const parsed = await fetchSubscription(normalizedUrl);
|
||||||
await serializeControl(async () => {
|
await serializeControl(async () => {
|
||||||
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
|
writeJson(settings.subscriptionCachePath, { url: normalizedUrl, ...parsed });
|
||||||
|
const previousState = readJson(settings.statePath, {});
|
||||||
writeJson(settings.statePath, {
|
writeJson(settings.statePath, {
|
||||||
subscriptionUrl: normalizedUrl,
|
subscriptionUrl: normalizedUrl,
|
||||||
|
gatewayAutoEnabled: previousState.gatewayAutoEnabled !== false,
|
||||||
servers: parsed.servers,
|
servers: parsed.servers,
|
||||||
userInfo: parsed.userInfo,
|
userInfo: parsed.userInfo,
|
||||||
fetchedAt: parsed.fetchedAt,
|
fetchedAt: parsed.fetchedAt,
|
||||||
@@ -481,6 +493,24 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, await refreshSavedSubscription());
|
return sendJson(res, 200, await refreshSavedSubscription());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (req.method === 'POST' && req.url === '/api/gateway-auto') {
|
||||||
|
if (settings.appMode !== 'client') {
|
||||||
|
return sendJson(res, 400, { success: false, error: 'Режим доступен только в Harbor Connect' });
|
||||||
|
}
|
||||||
|
const { enabled } = await readBody(req);
|
||||||
|
if (typeof enabled !== 'boolean') {
|
||||||
|
return sendJson(res, 400, { success: false, error: 'Укажите enabled: true или false' });
|
||||||
|
}
|
||||||
|
await serializeControl(async () => {
|
||||||
|
writeJson(settings.statePath, {
|
||||||
|
...readJson(settings.statePath, {}),
|
||||||
|
gatewayAutoEnabled: enabled,
|
||||||
|
});
|
||||||
|
await applyGatewayAutoState(applyGatewayPreference(gatewayAutoState, enabled));
|
||||||
|
});
|
||||||
|
return sendJson(res, 200, { success: true, gatewayAuto: publicState().gatewayAuto });
|
||||||
|
}
|
||||||
|
|
||||||
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||||
await serializeControl(async () => {
|
await serializeControl(async () => {
|
||||||
await stopSingbox();
|
await stopSingbox();
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ function App() {
|
|||||||
onApply={(tag) => run(() => api.apply(tag))}
|
onApply={(tag) => run(() => api.apply(tag))}
|
||||||
onRestart={() => run(api.singbox.restart)}
|
onRestart={() => run(api.singbox.restart)}
|
||||||
onStop={() => run(api.singbox.stop)}
|
onStop={() => run(api.singbox.stop)}
|
||||||
|
onSetGatewayAuto={(enabled) => run(() => api.gatewayAuto.setEnabled(enabled))}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -32,6 +32,12 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ selectedTag }),
|
body: JSON.stringify({ selectedTag }),
|
||||||
}),
|
}),
|
||||||
|
gatewayAuto: {
|
||||||
|
setEnabled: (enabled) => request('/api/gateway-auto', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ enabled }),
|
||||||
|
}),
|
||||||
|
},
|
||||||
singbox: {
|
singbox: {
|
||||||
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
stop: () => request('/api/singbox/stop', { method: 'POST' }),
|
||||||
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
restart: () => request('/api/singbox/restart', { method: 'POST' }),
|
||||||
|
|||||||
@@ -73,20 +73,45 @@ function DurationPart({ name, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function HarborBrand({ isGateway }) {
|
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, busy, onSetGatewayAuto }) {
|
||||||
const product = isGateway ? 'Gateway' : 'Connect';
|
const product = isGateway ? 'Gateway' : 'Connect';
|
||||||
|
const switchable = !isGateway && gatewayAvailable;
|
||||||
|
const label = gatewayDirect
|
||||||
|
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
|
||||||
|
: 'Использовать обнаруженный Harbor Gateway';
|
||||||
|
|
||||||
|
const content = <div className="harbor-brand-content">
|
||||||
|
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||||||
|
<circle cx="16" cy="6" r="3" />
|
||||||
|
<path d="M16 9v15M10 14h12" />
|
||||||
|
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
|
||||||
|
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
|
||||||
|
</svg>
|
||||||
|
<span className="harbor-brand-name">
|
||||||
|
<strong>Harbor</strong>
|
||||||
|
{switchable ? <span className="harbor-mode-stack" aria-hidden="true">
|
||||||
|
<em className="harbor-mode-connect">Connect</em>
|
||||||
|
<em className="harbor-mode-gateway">Gateway</em>
|
||||||
|
</span> : <em>{product}</em>}
|
||||||
|
</span>
|
||||||
|
{switchable && <svg className="harbor-mode-swap" viewBox="0 0 18 18" aria-hidden="true">
|
||||||
|
<path d="M3 6h10m-3-3 3 3-3 3M15 12H5m3 3-3-3 3-3" />
|
||||||
|
</svg>}
|
||||||
|
</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
|
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
|
||||||
<div className="harbor-brand-content">
|
{switchable ? <button
|
||||||
<svg viewBox="0 0 32 32" aria-hidden="true">
|
className="harbor-brand-control"
|
||||||
<circle cx="16" cy="6" r="3" />
|
type="button"
|
||||||
<path d="M16 9v15M10 14h12" />
|
aria-label={label}
|
||||||
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
|
aria-pressed={gatewayDirect}
|
||||||
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
|
title={label}
|
||||||
</svg>
|
disabled={busy}
|
||||||
<span><strong>Harbor</strong> <em>{product}</em></span>
|
onClick={() => onSetGatewayAuto(!gatewayDirect)}
|
||||||
</div>
|
>
|
||||||
|
{content}
|
||||||
|
</button> : <div aria-label={`Harbor ${product}`}>{content}</div>}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -106,9 +131,11 @@ export function ClientOverviewPage({
|
|||||||
onApply,
|
onApply,
|
||||||
onRestart,
|
onRestart,
|
||||||
onStop,
|
onStop,
|
||||||
|
onSetGatewayAuto,
|
||||||
}) {
|
}) {
|
||||||
const isGateway = state?.mode === 'gateway';
|
const isGateway = state?.mode === 'gateway';
|
||||||
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
||||||
|
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available);
|
||||||
const connected = Boolean(state?.singboxRunning);
|
const connected = Boolean(state?.singboxRunning);
|
||||||
const hasSubscription = Boolean(state?.hasSubscription);
|
const hasSubscription = Boolean(state?.hasSubscription);
|
||||||
const selectedTag = pendingTag || state?.selectedTag || '';
|
const selectedTag = pendingTag || state?.selectedTag || '';
|
||||||
@@ -387,7 +414,13 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
||||||
<HarborBrand isGateway={isGateway} />
|
<HarborBrand
|
||||||
|
isGateway={isGateway}
|
||||||
|
gatewayAvailable={gatewayAvailable}
|
||||||
|
gatewayDirect={gatewayDirect}
|
||||||
|
busy={busy}
|
||||||
|
onSetGatewayAuto={onSetGatewayAuto}
|
||||||
|
/>
|
||||||
{hasSubscription && subscriptionContentReady && <button
|
{hasSubscription && subscriptionContentReady && <button
|
||||||
ref={instructionsToggleRef}
|
ref={instructionsToggleRef}
|
||||||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||||||
|
|||||||
@@ -105,6 +105,31 @@ p {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-brand.is-switchable {
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand-control {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 7px;
|
||||||
|
padding: 3px 5px;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand-control:focus-visible {
|
||||||
|
outline: 1px solid color-mix(in srgb, var(--harbor-gateway) 55%, transparent);
|
||||||
|
outline-offset: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand-control:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
}
|
||||||
|
|
||||||
.client-shell.is-first-run .harbor-brand {
|
.client-shell.is-first-run .harbor-brand {
|
||||||
transform: translate(-50%, calc(-50% - 72px)) scale(1.35);
|
transform: translate(-50%, calc(-50% - 72px)) scale(1.35);
|
||||||
}
|
}
|
||||||
@@ -153,6 +178,12 @@ p {
|
|||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-brand-name {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45em;
|
||||||
|
}
|
||||||
|
|
||||||
.harbor-brand em {
|
.harbor-brand em {
|
||||||
color: var(--harbor-connect);
|
color: var(--harbor-connect);
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
@@ -163,6 +194,61 @@ p {
|
|||||||
color: var(--harbor-gateway);
|
color: var(--harbor-gateway);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-mode-stack {
|
||||||
|
position: relative;
|
||||||
|
display: inline-block;
|
||||||
|
width: 7.2em;
|
||||||
|
height: 1.25em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-mode-stack em {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto auto 0;
|
||||||
|
transition: color 420ms ease, opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-mode-connect {
|
||||||
|
z-index: 2;
|
||||||
|
color: var(--harbor-connect);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-mode-gateway {
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--harbor-gateway);
|
||||||
|
opacity: 0.2;
|
||||||
|
filter: blur(0.4px);
|
||||||
|
transform: translate(0.42em, 0.34em) scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand.is-gateway-active .harbor-mode-connect {
|
||||||
|
z-index: 1;
|
||||||
|
opacity: 0.18;
|
||||||
|
filter: blur(0.4px);
|
||||||
|
transform: translate(0.42em, 0.34em) scale(0.96);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand.is-gateway-active .harbor-mode-gateway {
|
||||||
|
z-index: 2;
|
||||||
|
opacity: 1;
|
||||||
|
filter: none;
|
||||||
|
transform: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand .harbor-mode-swap {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
opacity: 0.42;
|
||||||
|
stroke-width: 1.35;
|
||||||
|
transition: color 320ms ease, opacity 320ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.harbor-brand-control:hover .harbor-mode-swap,
|
||||||
|
.harbor-brand-control:focus-visible .harbor-mode-swap {
|
||||||
|
color: var(--harbor-gateway);
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(1px);
|
||||||
|
}
|
||||||
|
|
||||||
.client-instructions-toggle {
|
.client-instructions-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -1529,6 +1615,11 @@ p {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.harbor-mode-stack em,
|
||||||
|
.harbor-brand .harbor-mode-swap {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.client-state-copy h2,
|
.client-state-copy h2,
|
||||||
.client-state-detail > * {
|
.client-state-detail > * {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import path from 'node:path';
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
applyGatewayPreference,
|
||||||
buildGatewayPresence,
|
buildGatewayPresence,
|
||||||
createGatewayAutoState,
|
createGatewayAutoState,
|
||||||
nextGatewayAutoState,
|
nextGatewayAutoState,
|
||||||
@@ -107,3 +108,17 @@ test('host route freshness and Gateway failures drive a safe automatic fallback'
|
|||||||
}));
|
}));
|
||||||
assert.equal(readHostNetworkState(statePath, { now }), null);
|
assert.equal(readHostNetworkState(statePath, { now }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('client can ignore and restore a verified Gateway without losing discovery', () => {
|
||||||
|
const detected = {
|
||||||
|
...createGatewayAutoState(),
|
||||||
|
mode: 'gateway-direct',
|
||||||
|
gatewayId: 'gateway-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ignored = applyGatewayPreference(detected, false);
|
||||||
|
assert.equal(ignored.mode, 'local-vpn');
|
||||||
|
assert.equal(ignored.gatewayId, 'gateway-1');
|
||||||
|
assert.equal(applyGatewayPreference(ignored, true).mode, 'gateway-direct');
|
||||||
|
assert.equal(applyGatewayPreference(createGatewayAutoState(), true).mode, 'local-vpn');
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user