Introduce stable server IDs for subscription state
This commit is contained in:
@@ -23,7 +23,12 @@ import {
|
||||
restoreSingboxConfig,
|
||||
writeSingboxConfig,
|
||||
} from './singbox.js';
|
||||
import { fetchSubscription, getHwid, selectRefreshedServer } from './subscription.js';
|
||||
import {
|
||||
fetchSubscription,
|
||||
getHwid,
|
||||
normalizeSubscriptionConfig,
|
||||
selectRefreshedServer,
|
||||
} from './subscription.js';
|
||||
import {
|
||||
createStateSnapshot,
|
||||
normalizeStoredState,
|
||||
@@ -53,7 +58,9 @@ function readSubscriptionCache() {
|
||||
cacheRecoveryLogged = true;
|
||||
console.warn(`[storage] corrupt subscription cache recovered; backup: ${subscriptionCacheStore.recovery.backupPath}`);
|
||||
}
|
||||
return cached;
|
||||
return cached?.config
|
||||
? { ...cached, ...normalizeSubscriptionConfig(cached.config), _persisted: cached }
|
||||
: cached;
|
||||
}
|
||||
|
||||
const initialStoredState = stateStore.read();
|
||||
@@ -195,10 +202,10 @@ function subscriptionHost(url) {
|
||||
|
||||
function buildActiveConfig(
|
||||
subscriptionConfig,
|
||||
selectedTag,
|
||||
selectedServerId,
|
||||
routeRules = stateStore.read().routeRules,
|
||||
) {
|
||||
return buildGatewayConfig(subscriptionConfig, selectedTag, {
|
||||
return buildGatewayConfig(subscriptionConfig, selectedServerId, {
|
||||
clientDirect: settings.appMode === 'client' && gatewayAutoState.mode === 'gateway-direct',
|
||||
routeRules,
|
||||
});
|
||||
@@ -233,8 +240,8 @@ async function publicState() {
|
||||
function writeCurrentConfig() {
|
||||
const state = stateStore.read();
|
||||
const cached = readSubscriptionCache();
|
||||
if (!state.selectedTag || !cached?.config) return false;
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag));
|
||||
if (!state.selectedServerId || !cached?.config) return false;
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId));
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -356,15 +363,15 @@ function refreshGatewayAutoMode({ reconfigure = true } = {}) {
|
||||
return gatewayDiscoveryPromise;
|
||||
}
|
||||
|
||||
async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
async function applySelectedServer(selectedServerId, { persist = true } = {}) {
|
||||
const cached = readSubscriptionCache();
|
||||
if (!cached?.config) throw new HarborError('CONFIG_INVALID');
|
||||
const nextConfig = buildActiveConfig(cached.config, selectedTag);
|
||||
const nextConfig = buildActiveConfig(cached.config, selectedServerId);
|
||||
|
||||
if (persist) {
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
selectedTag,
|
||||
selectedServerId,
|
||||
connectionDesired: 'running',
|
||||
}));
|
||||
}
|
||||
@@ -383,7 +390,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
...(persist ? {
|
||||
appliedTag: selectedTag,
|
||||
appliedServerId: selectedServerId,
|
||||
appliedAt: new Date().toISOString(),
|
||||
} : {}),
|
||||
appliedRouteRules: state.routeRules,
|
||||
@@ -393,7 +400,7 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
||||
async function applyRouteRules(routeRules) {
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
const cached = readSubscriptionCache();
|
||||
if (!state.selectedTag || !cached?.config) {
|
||||
if (!state.selectedServerId || !cached?.config) {
|
||||
updateStoredState((current) => ({
|
||||
...current,
|
||||
routeRules,
|
||||
@@ -407,7 +414,7 @@ async function applyRouteRules(routeRules) {
|
||||
: null;
|
||||
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
||||
try {
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag, routeRules));
|
||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedServerId, routeRules));
|
||||
if (wasRunning) await startSingbox();
|
||||
updateStoredState((current) => ({
|
||||
...current,
|
||||
@@ -438,11 +445,15 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
|
||||
const selectedTag = resetSelection
|
||||
const selectedServerId = resetSelection
|
||||
? ''
|
||||
: selectRefreshedServer(previousState.selectedTag, parsed.servers);
|
||||
const candidateConfig = selectedTag
|
||||
? buildActiveConfig(parsed.config, selectedTag, previousState.routeRules)
|
||||
: selectRefreshedServer(
|
||||
previousState.selectedServerId,
|
||||
previousState.servers,
|
||||
parsed.servers,
|
||||
);
|
||||
const candidateConfig = selectedServerId
|
||||
? buildActiveConfig(parsed.config, selectedServerId, previousState.routeRules)
|
||||
: null;
|
||||
const previousCache = readSubscriptionCache();
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
@@ -452,10 +463,16 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
|
||||
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
||||
|
||||
try {
|
||||
if (resetSelection && wasRunning) await stopSingbox();
|
||||
if ((resetSelection || !candidateConfig) && wasRunning) await stopSingbox();
|
||||
if (candidateConfig) writeSingboxConfig(candidateConfig);
|
||||
else removeSingboxConfig();
|
||||
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
|
||||
subscriptionCacheStore.write({
|
||||
url: subscriptionUrl,
|
||||
config: parsed.sourceConfig || parsed.config,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
});
|
||||
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
|
||||
|
||||
updateStoredState((state) => ({
|
||||
@@ -468,13 +485,14 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
appliedTag: selectedTag,
|
||||
selectedServerId,
|
||||
appliedServerId: selectedServerId,
|
||||
...(!selectedServerId ? { connectionDesired: 'stopped' } : {}),
|
||||
}));
|
||||
if (resetSelection) gatewayAutoState = createGatewayAutoState();
|
||||
} catch (error) {
|
||||
gatewayAutoState = previousGatewayAutoState;
|
||||
if (previousCache) subscriptionCacheStore.write(previousCache);
|
||||
if (previousCache) subscriptionCacheStore.write(previousCache._persisted || previousCache);
|
||||
else subscriptionCacheStore.remove();
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
@@ -495,7 +513,8 @@ async function commitSubscription(subscriptionUrl, parsed, { resetSelection = fa
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
selectedServerId,
|
||||
selectedTag: parsed.servers.find((server) => server.id === selectedServerId)?.label || '',
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -559,8 +578,9 @@ async function handleApi(req, res) {
|
||||
if (req.method === 'POST' && req.url === '/api/servers/ping-all') {
|
||||
const state = stateStore.read();
|
||||
const results = await Promise.all((state.servers || []).map(async (server) => ({
|
||||
tag: String(server.tag || '').trim(),
|
||||
...await tcpPing(server.server, server.server_port),
|
||||
id: server.id,
|
||||
tag: server.label,
|
||||
...await tcpPing(server.host, server.port),
|
||||
checkedAt: new Date().toISOString(),
|
||||
})));
|
||||
return sendState(res, { results });
|
||||
@@ -644,11 +664,20 @@ async function handleApi(req, res) {
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/apply') {
|
||||
const { selectedTag = '' } = await readBody(req);
|
||||
const tag = String(selectedTag).trim();
|
||||
if (!tag) throw new HarborError('REQUEST_INVALID');
|
||||
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(tag)));
|
||||
return sendState(res, { selectedTag: tag });
|
||||
const { serverId = '', selectedTag = '' } = await readBody(req);
|
||||
const state = normalizeStoredState(stateStore.read());
|
||||
const id = String(serverId).trim() || (() => {
|
||||
const matches = state.servers.filter((server) => server.label === String(selectedTag).trim());
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
})();
|
||||
if (!id || !state.servers.some((server) => server.id === id)) {
|
||||
throw new HarborError('SERVER_NOT_FOUND');
|
||||
}
|
||||
await withOperation('apply-server', () => serializeControl(() => applySelectedServer(id)));
|
||||
return sendState(res, {
|
||||
serverId: id,
|
||||
selectedTag: state.servers.find((server) => server.id === id)?.label || '',
|
||||
});
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url === '/api/singbox/stop') {
|
||||
@@ -667,7 +696,7 @@ async function handleApi(req, res) {
|
||||
await singboxRuntime.restart();
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
appliedTag: state.selectedTag,
|
||||
appliedServerId: state.selectedServerId,
|
||||
connectionDesired: 'running',
|
||||
appliedRouteRules: state.routeRules,
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,7 @@ import path from 'node:path';
|
||||
import { normalizeStoredState } from '../../shared/contracts/state.js';
|
||||
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
|
||||
|
||||
export const STATE_SCHEMA_VERSION = 3;
|
||||
export const STATE_SCHEMA_VERSION = 4;
|
||||
|
||||
const clone = (value) => structuredClone(value);
|
||||
const stamp = (value) => value.toISOString().replace(/[:.]/g, '-');
|
||||
|
||||
@@ -2,6 +2,11 @@ import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import { settings } from './config.js';
|
||||
import { HarborError } from '../shared/errors.js';
|
||||
import {
|
||||
createServerId,
|
||||
normalizeServer,
|
||||
serverIdentityKey,
|
||||
} from '../shared/serverIdentity.js';
|
||||
import { atomicWriteFile } from './services/stateStore.js';
|
||||
|
||||
const PROXY_TYPES = new Set(['vless', 'vmess', 'trojan', 'shadowsocks', 'hysteria2']);
|
||||
@@ -105,8 +110,27 @@ function maybeDecodeBase64(content) {
|
||||
return content;
|
||||
}
|
||||
|
||||
export function normalizeSubscriptionConfig(value) {
|
||||
const parsedConfig = value && typeof value === 'object' ? value : {};
|
||||
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
|
||||
const servers = [];
|
||||
const seen = new Set();
|
||||
const normalizedOutbounds = outbounds.flatMap((outbound) => {
|
||||
if (!PROXY_TYPES.has(outbound.type)) return [outbound];
|
||||
const id = createServerId(outbound);
|
||||
// ponytail: endpoint identity deduplicates indistinguishable entries; include provider IDs if real feeds need same-endpoint variants.
|
||||
if (seen.has(id)) return [];
|
||||
seen.add(id);
|
||||
servers.push(normalizeServer({ ...outbound, id }));
|
||||
return [{ ...outbound, tag: id }];
|
||||
});
|
||||
|
||||
if (!servers.length) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
return { config: { ...parsedConfig, outbounds: normalizedOutbounds }, servers };
|
||||
}
|
||||
|
||||
export function parseSubscriptionBody(body) {
|
||||
let parsedConfig = null;
|
||||
let parsedConfig;
|
||||
|
||||
try {
|
||||
parsedConfig = JSON.parse(body);
|
||||
@@ -126,21 +150,7 @@ export function parseSubscriptionBody(body) {
|
||||
};
|
||||
}
|
||||
|
||||
const outbounds = Array.isArray(parsedConfig.outbounds) ? parsedConfig.outbounds : [];
|
||||
const servers = outbounds
|
||||
.filter((outbound) => PROXY_TYPES.has(outbound.type))
|
||||
.map((outbound) => ({
|
||||
tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(),
|
||||
type: outbound.type,
|
||||
server: outbound.server || 'unknown',
|
||||
server_port: outbound.server_port || 443,
|
||||
}));
|
||||
|
||||
if (!servers.length) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
}
|
||||
|
||||
return { config: parsedConfig, servers };
|
||||
return { ...normalizeSubscriptionConfig(parsedConfig), sourceConfig: parsedConfig };
|
||||
}
|
||||
|
||||
async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
|
||||
@@ -173,13 +183,14 @@ async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = setting
|
||||
return response;
|
||||
}
|
||||
|
||||
export function selectRefreshedServer(currentTag, servers) {
|
||||
const selectedTag = String(currentTag || '').trim();
|
||||
if (!selectedTag) return '';
|
||||
|
||||
return servers.find((server) => String(server.tag || '').trim() === selectedTag)?.tag
|
||||
|| servers[0]?.tag
|
||||
|| '';
|
||||
export function selectRefreshedServer(currentServerId, currentServers, nextServers) {
|
||||
if (!currentServerId) return '';
|
||||
if (nextServers.some((server) => server.id === currentServerId)) return currentServerId;
|
||||
const previous = currentServers.find((server) => server.id === currentServerId);
|
||||
if (!previous) return '';
|
||||
const identity = serverIdentityKey(previous);
|
||||
const matches = nextServers.filter((server) => serverIdentityKey(server) === identity);
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
}
|
||||
|
||||
export async function fetchSubscription(url, options) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizeRouteRules } from '../routingRules.js';
|
||||
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
|
||||
|
||||
const MODES = new Set(['client', 'gateway']);
|
||||
const CONNECTION_STATES = new Set(['running', 'stopped']);
|
||||
@@ -12,13 +13,21 @@ const dateOrNull = (value) => (
|
||||
|
||||
export function normalizeStoredState(value) {
|
||||
const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
|
||||
const selectedTag = text(state.selectedTag);
|
||||
const servers = normalizeServers(state.servers);
|
||||
const selectedServerId = resolveServerId(servers, state.selectedServerId, state.selectedTag);
|
||||
const appliedServerId = Object.hasOwn(state, 'appliedServerId')
|
||||
? resolveServerId(servers, state.appliedServerId)
|
||||
: resolveServerId(servers, '', state.appliedTag || state.selectedTag);
|
||||
const selectedServer = servers.find((server) => server.id === selectedServerId);
|
||||
const appliedServer = servers.find((server) => server.id === appliedServerId);
|
||||
return {
|
||||
...state,
|
||||
revision: Number.isSafeInteger(state.revision) && state.revision >= 0 ? state.revision : 0,
|
||||
selectedTag,
|
||||
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
|
||||
servers: Array.isArray(state.servers) ? state.servers : [],
|
||||
selectedServerId,
|
||||
appliedServerId,
|
||||
selectedTag: selectedServer?.label || '',
|
||||
appliedTag: appliedServer?.label || '',
|
||||
servers,
|
||||
routeRules: normalizeRouteRules(state.routeRules),
|
||||
appliedRouteRules: normalizeRouteRules(state.appliedRouteRules),
|
||||
routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0
|
||||
@@ -43,22 +52,7 @@ export function createStateSnapshot({
|
||||
const desired = CONNECTION_STATES.has(stored.connectionDesired)
|
||||
? stored.connectionDesired
|
||||
: configExists ? 'running' : 'stopped';
|
||||
const servers = stored.servers.map((server) => {
|
||||
const tag = text(server.tag);
|
||||
return {
|
||||
...server,
|
||||
id: tag,
|
||||
label: tag,
|
||||
host: text(server.server),
|
||||
port: Number(server.server_port) || 0,
|
||||
protocol: text(server.type),
|
||||
// v0 compatibility: the current UI and integrations still read these aliases.
|
||||
tag,
|
||||
server: text(server.server),
|
||||
server_port: Number(server.server_port) || 0,
|
||||
type: text(server.type),
|
||||
};
|
||||
});
|
||||
const servers = stored.servers;
|
||||
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
||||
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
||||
|
||||
@@ -74,8 +68,8 @@ export function createStateSnapshot({
|
||||
userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {},
|
||||
},
|
||||
selection: {
|
||||
desiredServerId: stored.selectedTag,
|
||||
appliedServerId: stored.appliedTag,
|
||||
desiredServerId: stored.selectedServerId,
|
||||
appliedServerId: stored.appliedServerId,
|
||||
},
|
||||
connection: {
|
||||
desired,
|
||||
@@ -120,7 +114,7 @@ export function withStateV0Compatibility(snapshot, {
|
||||
singboxStartedAt: snapshot.connection.startedAt,
|
||||
subscriptionHost: snapshot.subscription.host,
|
||||
hasSubscription: snapshot.subscription.status === 'ready',
|
||||
selectedTag: snapshot.selection.desiredServerId,
|
||||
selectedTag: stored.selectedTag,
|
||||
userInfo: snapshot.subscription.userInfo,
|
||||
fetchedAt: snapshot.subscription.fetchedAt,
|
||||
gatewayAuto: snapshot.mode === 'client' ? {
|
||||
|
||||
67
src/shared/serverIdentity.js
Normal file
67
src/shared/serverIdentity.js
Normal file
@@ -0,0 +1,67 @@
|
||||
const text = (value) => String(value || '').trim();
|
||||
|
||||
function hash64(value) {
|
||||
let hash = 0xcbf29ce484222325n;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
hash ^= BigInt(value.charCodeAt(index));
|
||||
hash = BigInt.asUintN(64, hash * 0x100000001b3n);
|
||||
}
|
||||
return hash.toString(16).padStart(16, '0');
|
||||
}
|
||||
|
||||
export function serverIdentityKey(server) {
|
||||
const protocol = text(server?.protocol || server?.type).toLowerCase();
|
||||
const host = text(server?.host || server?.server).toLowerCase();
|
||||
const port = Number(server?.port || server?.server_port) || 0;
|
||||
return `${protocol}\u0000${host}\u0000${port}`;
|
||||
}
|
||||
|
||||
export function createServerId(server) {
|
||||
return `srv_${hash64(`endpoint\u0000${serverIdentityKey(server)}`)}`;
|
||||
}
|
||||
|
||||
export function normalizeServer(server) {
|
||||
const source = server && typeof server === 'object' ? server : {};
|
||||
const protocol = text(source.protocol || source.type).toLowerCase();
|
||||
const host = text(source.host || source.server);
|
||||
const port = Number(source.port || source.server_port) || 0;
|
||||
const id = text(source.id) || createServerId(source);
|
||||
const label = text(source.label || source.tag) || host;
|
||||
const metadata = Object.fromEntries([
|
||||
['country', text(source.country)],
|
||||
['city', text(source.city)],
|
||||
['provider', text(source.provider)],
|
||||
].filter(([, value]) => value));
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
host,
|
||||
port,
|
||||
protocol,
|
||||
...metadata,
|
||||
// v0 aliases remain until old clients no longer consume this API.
|
||||
tag: label,
|
||||
server: host,
|
||||
server_port: port,
|
||||
type: protocol,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeServers(servers) {
|
||||
const seen = new Set();
|
||||
return (Array.isArray(servers) ? servers : []).flatMap((server) => {
|
||||
const normalized = normalizeServer(server);
|
||||
if (!normalized.id || seen.has(normalized.id)) return [];
|
||||
seen.add(normalized.id);
|
||||
return [normalized];
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveServerId(servers, serverId, legacyTag = '') {
|
||||
const id = text(serverId);
|
||||
if (id) return servers.some((server) => server.id === id) ? id : '';
|
||||
const tag = text(legacyTag);
|
||||
const matches = tag ? servers.filter((server) => server.label === tag) : [];
|
||||
return matches.length === 1 ? matches[0].id : '';
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.6.17',
|
||||
gatewayClient: '0.6.16',
|
||||
gatewayBackend: '0.6.1',
|
||||
macClient: '0.7.0',
|
||||
gatewayClient: '0.7.0',
|
||||
gatewayBackend: '0.7.0',
|
||||
});
|
||||
|
||||
export function parseVersion(value) {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { createOperationRegistry } from './state/operations.js';
|
||||
|
||||
function App() {
|
||||
const previewReady = new URLSearchParams(window.location.search).has('preview-ready');
|
||||
const [{ snapshot: state, pendingServerId: pendingTag, transport }, dispatch] = useReducer(
|
||||
const [{ snapshot: state, pendingServerId, transport }, dispatch] = useReducer(
|
||||
harborReducer,
|
||||
initialHarborState,
|
||||
);
|
||||
@@ -27,7 +27,7 @@ function App() {
|
||||
operationRegistry.current = createOperationRegistry(setOperations);
|
||||
}
|
||||
|
||||
function setPendingTag(serverId) {
|
||||
function setPendingServerId(serverId) {
|
||||
dispatch({ type: 'select-server', serverId });
|
||||
}
|
||||
|
||||
@@ -144,19 +144,32 @@ function App() {
|
||||
<div className="app-body client-mode">
|
||||
<main className="app-main">
|
||||
<ClientOverviewPage
|
||||
state={previewReady ? { ...state, mode: 'client', hasSubscription: true, subscriptionHost: 'harbor.example', selectedTag: 'Amsterdam', proxyPort: 8082 } : state}
|
||||
state={previewReady ? {
|
||||
...state,
|
||||
mode: 'client',
|
||||
hasSubscription: true,
|
||||
subscriptionHost: 'harbor.example',
|
||||
selection: { desiredServerId: 'preview-amsterdam', appliedServerId: 'preview-amsterdam' },
|
||||
proxyPort: 8082,
|
||||
} : state}
|
||||
versionInfo={versionInfo}
|
||||
operations={operations}
|
||||
error={error}
|
||||
subscriptionUrl={subscriptionUrl}
|
||||
setSubscriptionUrl={setSubscriptionUrl}
|
||||
servers={previewReady ? [{ tag: 'Amsterdam', server: '127.0.0.1', server_port: 443 }] : state.servers || []}
|
||||
pendingTag={previewReady ? 'Amsterdam' : pendingTag}
|
||||
setPendingTag={setPendingTag}
|
||||
servers={previewReady ? [{
|
||||
id: 'preview-amsterdam',
|
||||
label: 'Amsterdam',
|
||||
host: '127.0.0.1',
|
||||
port: 443,
|
||||
protocol: 'vless',
|
||||
}] : state.servers || []}
|
||||
pendingServerId={previewReady ? 'preview-amsterdam' : pendingServerId}
|
||||
setPendingServerId={setPendingServerId}
|
||||
onFetchSubscription={fetchSubscription}
|
||||
onRefreshSubscription={refreshSubscription}
|
||||
onForgetSubscription={forgetSubscription}
|
||||
onApply={(tag) => run('serverApply', () => api.apply(tag), 'connection')}
|
||||
onApply={(serverId) => run('serverApply', () => api.apply(serverId), 'connection')}
|
||||
onRestart={() => run('connection', api.singbox.restart, 'connection')}
|
||||
onStop={() => run('connection', api.singbox.stop, 'connection')}
|
||||
onSetGatewayAuto={(enabled) => run('gatewayAuto', () => api.gatewayAuto.setEnabled(enabled), 'connection')}
|
||||
|
||||
@@ -57,9 +57,10 @@ export const api = {
|
||||
refresh: () => request('/api/subscription/refresh', { method: 'POST' }),
|
||||
forget: () => request('/api/subscription', { method: 'DELETE' }),
|
||||
},
|
||||
apply: (selectedTag) => request('/api/apply', {
|
||||
apply: (serverId) => request('/api/apply', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ selectedTag }),
|
||||
// selectedTag keeps this client compatible with pre-ID Harbor backends.
|
||||
body: JSON.stringify({ serverId, selectedTag: serverId }),
|
||||
}),
|
||||
gatewayAuto: {
|
||||
setEnabled: (enabled) => request('/api/gateway-auto', {
|
||||
|
||||
@@ -566,8 +566,8 @@ export function ClientOverviewPage({
|
||||
subscriptionUrl,
|
||||
setSubscriptionUrl,
|
||||
servers,
|
||||
pendingTag,
|
||||
setPendingTag,
|
||||
pendingServerId,
|
||||
setPendingServerId,
|
||||
onFetchSubscription,
|
||||
onRefreshSubscription,
|
||||
onForgetSubscription,
|
||||
@@ -583,9 +583,9 @@ export function ClientOverviewPage({
|
||||
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available);
|
||||
const connected = Boolean(state?.singboxRunning);
|
||||
const hasSubscription = Boolean(state?.hasSubscription);
|
||||
const selectedTag = pendingTag || state?.selectedTag || '';
|
||||
const showPower = hasSubscription && Boolean(selectedTag);
|
||||
const canStart = Boolean(selectedTag || state?.configExists);
|
||||
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
|
||||
const showPower = hasSubscription && Boolean(selectedServerId);
|
||||
const canStart = Boolean(selectedServerId || state?.configExists);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const [durationMode, setDurationMode] = useState(() => {
|
||||
try {
|
||||
@@ -620,7 +620,7 @@ export function ClientOverviewPage({
|
||||
const localRulesToggleRef = useRef(null);
|
||||
const localRulesBaselineRef = useRef('[]');
|
||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||
const serverKey = servers.map((server) => server.id).join('|');
|
||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||
const usage = subscriptionUsage(state?.userInfo);
|
||||
@@ -674,18 +674,18 @@ export function ClientOverviewPage({
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }])));
|
||||
setPings(Object.fromEntries(servers.map((server) => [server.id, { checking: true }])));
|
||||
api.servers.pingAll()
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
setPings(Object.fromEntries((data.results || []).map((ping) => [
|
||||
String(ping.tag || '').trim(),
|
||||
ping.id || ping.tag,
|
||||
ping,
|
||||
])));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }])));
|
||||
setPings(Object.fromEntries(servers.map((server) => [server.id, { ok: false }])));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -822,15 +822,15 @@ export function ClientOverviewPage({
|
||||
}, [instructionsOpen]);
|
||||
|
||||
async function toggleConnection() {
|
||||
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||
const action = connectionAction({ connected, selectedServerId, configExists: state?.configExists });
|
||||
if (action?.type === 'stop') return onStop();
|
||||
if (action?.type === 'apply') return onApply(action.selectedTag);
|
||||
if (action?.type === 'apply') return onApply(action.serverId);
|
||||
if (action?.type === 'restart') return onRestart();
|
||||
}
|
||||
|
||||
function selectServer(tag) {
|
||||
setPendingTag(tag);
|
||||
if (connected && tag) onApply(tag);
|
||||
function selectServer(serverId) {
|
||||
setPendingServerId(serverId);
|
||||
if (connected && serverId) onApply(serverId);
|
||||
}
|
||||
|
||||
async function submitSubscription(event) {
|
||||
@@ -1314,8 +1314,8 @@ export function ClientOverviewPage({
|
||||
key={`${serverKey}:${serverRevealVersion}`}
|
||||
>
|
||||
{servers.map((server, index) => {
|
||||
const ping = pings[server.tag];
|
||||
const selected = server.tag === selectedTag;
|
||||
const ping = pings[server.id];
|
||||
const selected = server.id === selectedServerId;
|
||||
const pingText = ping?.checking
|
||||
? 'Проверка…'
|
||||
: ping?.ok ? `${ping.latency} ms` : 'Недоступен';
|
||||
@@ -1327,13 +1327,14 @@ export function ClientOverviewPage({
|
||||
<button
|
||||
className={`client-server ${selected ? 'is-selected' : ''}`}
|
||||
type="button"
|
||||
key={server.tag}
|
||||
key={server.id}
|
||||
disabled={serverApplyBlocked}
|
||||
aria-pressed={selected}
|
||||
aria-label={`${server.label}, ${server.host}:${server.port}, ${pingText}`}
|
||||
style={{ '--server-index': index }}
|
||||
onClick={() => selectServer(server.tag)}
|
||||
onClick={() => selectServer(server.id)}
|
||||
>
|
||||
<strong>{server.tag}</strong>
|
||||
<strong>{server.label}</strong>
|
||||
<small className={pingClass}>{pingText}</small>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export function connectionAction({ connected, selectedTag, configExists }) {
|
||||
export function connectionAction({ connected, selectedServerId, configExists }) {
|
||||
if (connected) return { type: 'stop' };
|
||||
if (selectedTag) return { type: 'apply', selectedTag };
|
||||
if (selectedServerId) return { type: 'apply', serverId: selectedServerId };
|
||||
if (configExists) return { type: 'restart' };
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user