Introduce stable server IDs for subscription state
This commit is contained in:
@@ -15,8 +15,8 @@
|
||||
"userInfo": {}
|
||||
},
|
||||
"selection": {
|
||||
"desiredServerId": "Amsterdam",
|
||||
"appliedServerId": "Amsterdam"
|
||||
"desiredServerId": "srv_4d7c5d1bcd60d665",
|
||||
"appliedServerId": "srv_4d7c5d1bcd60d665"
|
||||
},
|
||||
"connection": {
|
||||
"desired": "running",
|
||||
@@ -36,7 +36,15 @@
|
||||
"startedAt": null,
|
||||
"error": null
|
||||
},
|
||||
"servers": []
|
||||
"servers": [
|
||||
{
|
||||
"id": "srv_4d7c5d1bcd60d665",
|
||||
"label": "Amsterdam",
|
||||
"host": "nl.example.net",
|
||||
"port": 443,
|
||||
"protocol": "vless"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@@ -56,7 +64,7 @@ Browser transport state lives beside, not inside, the domain snapshot. It record
|
||||
|
||||
`selection.desiredServerId` records the user's requested server. `selection.appliedServerId` changes only after its sing-box configuration has been applied. Likewise, `connection.desired` records intent while `connection.process` reports the observed runtime. A failed operation can therefore leave desired and applied values different without pretending that the request succeeded.
|
||||
|
||||
Server IDs are currently derived from the existing trimmed subscription tag. Stable IDs across cosmetic renames are deferred to TASK-008.
|
||||
Server IDs are deterministic from normalized protocol, host and port, while provider order and the human-readable `label` are separate. Duplicate labels remain separate servers; reorder and cosmetic rename keep the same ID. Ping results, React keys, persisted selection and apply commands use the ID. If the selected endpoint disappears, Harbor stops the active process, clears selection and requires an explicit new choice instead of silently switching traffic.
|
||||
|
||||
## Subscription import and refresh
|
||||
|
||||
@@ -68,8 +76,8 @@ The existing background refresh remains every 15 minutes. Provider requests time
|
||||
|
||||
## Compatibility and migration
|
||||
|
||||
No path, volume or file is renamed. A legacy `state.json` without `revision`, `appliedTag` or `connectionDesired` is normalized on read: revision starts at `0`, the legacy `selectedTag` is treated as both desired and applied, and connection intent is inferred from the existing config. The next domain write stores the added fields. Existing unknown fields remain untouched.
|
||||
No path, volume or file is renamed. A legacy `state.json` without stable IDs is migrated to schema v4. A unique `selectedTag` is matched to its normalized endpoint and stored as `selectedServerId`/`appliedServerId`; an ambiguous or missing tag explicitly clears selection. The raw provider config remains unchanged in subscription cache and is normalized only in memory, so an older Harbor build can still use its original tags after rollback. Existing unknown fields remain untouched.
|
||||
|
||||
During the v0 compatibility window, the snapshot also exposes `selectedTag`, `singboxRunning`, `servers[].tag`, `gatewayAuto` and the other previous GET fields. Mutation responses retain their previous result fields and add `state`. The canonical `subscription` object never contains the full subscription URL.
|
||||
|
||||
Rollback is code-only: deploy the previous build. The added persisted fields are ignored by the previous implementation, so no data rewrite is needed. Revisions created by the newer build remain harmless integers in `state.json`.
|
||||
Rollback is code-only: deploy the previous build. The v4 state keeps `selectedTag`, `appliedTag` and server aliases for older builds, while subscription cache keeps raw provider tags. The added ID fields are ignored by the previous implementation.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Harbor state recovery
|
||||
|
||||
Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 3`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 introduced locally managed domain routing rules. Schema v3 adds the `enabled` state and migrates the former code-owned `.ru` exception into the first ordinary enabled rule.
|
||||
Harbor keeps the existing data paths and volumes. `state.json` now uses `schemaVersion: 4`; subscription cache, generated sing-box config and HWID keep their existing filenames. Schema v2 introduced locally managed domain routing rules. Schema v3 added rule `enabled` state. Schema v4 adds stable server IDs and migrates an unambiguous legacy `selectedTag` to `selectedServerId`.
|
||||
|
||||
## Atomic writes
|
||||
|
||||
@@ -8,13 +8,13 @@ Persistent files are written to a unique temporary file in the same directory, f
|
||||
|
||||
## Migration
|
||||
|
||||
On startup, a legacy `state.json` without `schemaVersion`, or any v1/v2 state, is normalized and migrated to the current schema. Existing custom rules are preserved, default to `enabled: true`, and follow the new ordinary `.ru` rule. A v3 state may keep, disable, or delete that rule without Harbor recreating it. Before replacement Harbor saves the original beside it:
|
||||
On startup, a legacy `state.json` without `schemaVersion`, or any v1-v3 state, is normalized and migrated to the current schema. Existing custom rules are preserved. Server identity is derived from protocol, host and port; a unique legacy tag keeps selection, while duplicate or missing matches require a new explicit choice. Before replacement Harbor saves the original beside it:
|
||||
|
||||
```text
|
||||
state.json.backup-v0-2026-07-11T12-00-00-000Z
|
||||
```
|
||||
|
||||
The migration preserves existing fields, adds normalized revision, selection and server fields, and does not rename the volume. The backup is the safest rollback source because builds that only understand schema v2 do not know the per-rule `enabled` state.
|
||||
The migration preserves compatibility aliases, adds normalized revision, selection and server fields, and does not rename the volume. Subscription cache keeps the raw provider config so older builds can still use its original outbound tags. The backup remains the safest manual recovery source.
|
||||
|
||||
## Corrupt JSON
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
createStateSnapshot,
|
||||
normalizeStoredState,
|
||||
} from '../../src/shared/contracts/state.js';
|
||||
import { createServerId } from '../../src/shared/serverIdentity.js';
|
||||
import { HARBOR_VERSIONS } from '../../src/shared/versions.js';
|
||||
|
||||
const root = path.resolve(import.meta.dirname, '../..');
|
||||
@@ -59,10 +60,12 @@ async function waitForState(port, child, stderr) {
|
||||
}
|
||||
|
||||
test('state v1 normalizes legacy storage and validates the canonical snapshot', () => {
|
||||
const legacyServer = { tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 };
|
||||
const legacyServerId = createServerId(legacyServer);
|
||||
const stored = normalizeStoredState({
|
||||
subscriptionUrl: 'https://provider.example/subscription/test',
|
||||
selectedTag: ' legacy ',
|
||||
servers: [{ tag: ' legacy ', type: 'vless', server: 'vpn.example', server_port: 443 }],
|
||||
servers: [legacyServer],
|
||||
});
|
||||
const snapshot = createStateSnapshot({
|
||||
storedState: stored,
|
||||
@@ -76,8 +79,8 @@ test('state v1 normalizes legacy storage and validates the canonical snapshot',
|
||||
|
||||
assert.equal(snapshot.apiVersion, 1);
|
||||
assert.deepEqual(snapshot.selection, {
|
||||
desiredServerId: 'legacy',
|
||||
appliedServerId: 'legacy',
|
||||
desiredServerId: legacyServerId,
|
||||
appliedServerId: legacyServerId,
|
||||
});
|
||||
assert.equal(snapshot.connection.process, 'running');
|
||||
assert.equal(JSON.stringify(snapshot).includes(stored.subscriptionUrl), false);
|
||||
@@ -100,6 +103,7 @@ test('GET and domain mutations return one state shape with monotonic revisions',
|
||||
tls: { enabled: true },
|
||||
}],
|
||||
};
|
||||
const testServerId = createServerId(config.outbounds[0]);
|
||||
fs.mkdirSync(binDir);
|
||||
const singboxPath = path.join(binDir, 'sing-box');
|
||||
const workingSingbox = `#!/usr/bin/env node
|
||||
@@ -190,7 +194,7 @@ setInterval(() => {}, 60_000);
|
||||
runtime: { singBox: '1.12.13' },
|
||||
});
|
||||
assertStateSnapshot(initial);
|
||||
assert.equal(initial.selection.appliedServerId, 'test-vpn');
|
||||
assert.equal(initial.selection.appliedServerId, testServerId);
|
||||
assert.deepEqual(initial.route.localRules, [
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||
]);
|
||||
@@ -266,7 +270,7 @@ setInterval(() => {}, 60_000);
|
||||
);
|
||||
assert.equal(missingServer.response.status, 404);
|
||||
assert.equal(missingServer.payload.error.code, 'SERVER_NOT_FOUND');
|
||||
assert.equal((await request(port, '/api/state')).selection.desiredServerId, 'test-vpn');
|
||||
assert.equal((await request(port, '/api/state')).selection.desiredServerId, testServerId);
|
||||
|
||||
async function stateResponse(pathname, method = 'POST', body) {
|
||||
const result = await request(port, pathname, method, body);
|
||||
@@ -285,10 +289,14 @@ setInterval(() => {}, 60_000);
|
||||
const fetchesBeforeImport = providerFetchCount;
|
||||
await mutation('/api/subscription/fetch', 'POST', { url: subscriptionUrl });
|
||||
assert.equal(providerFetchCount, fetchesBeforeImport + 1);
|
||||
const applied = await mutation('/api/apply', 'POST', { selectedTag: 'test-vpn' });
|
||||
assert.equal(
|
||||
JSON.parse(fs.readFileSync(path.join(dir, 'subscription-cache.json'))).config.outbounds[0].tag,
|
||||
'test-vpn',
|
||||
);
|
||||
const applied = await mutation('/api/apply', 'POST', { serverId: testServerId });
|
||||
assert.deepEqual(applied.state.selection, {
|
||||
desiredServerId: 'test-vpn',
|
||||
appliedServerId: 'test-vpn',
|
||||
desiredServerId: testServerId,
|
||||
appliedServerId: testServerId,
|
||||
});
|
||||
assert.equal(applied.state.connection.process, 'running');
|
||||
const cacheBeforeFailedRefresh = fs.readFileSync(path.join(dir, 'subscription-cache.json'), 'utf8');
|
||||
@@ -323,7 +331,7 @@ setInterval(() => {}, 60_000);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
|
||||
{ domain: ['example.com'], outbound: 'direct' },
|
||||
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
||||
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||
{ inbound: ['mixed-in'], outbound: testServerId },
|
||||
]);
|
||||
|
||||
await mutation('/api/singbox/stop');
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
createStateStore,
|
||||
STATE_SCHEMA_VERSION,
|
||||
} from '../../src/server/services/stateStore.js';
|
||||
import { createServerId } from '../../src/shared/serverIdentity.js';
|
||||
|
||||
const fixture = (t) => {
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'harbor-state-store-'));
|
||||
@@ -39,7 +40,7 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
|
||||
schemaVersion: 2,
|
||||
revision: 7,
|
||||
selectedTag: 'nl',
|
||||
servers: [{ tag: 'nl' }],
|
||||
servers: [{ tag: 'nl', type: 'vless', server: 'nl.example', server_port: 443 }],
|
||||
};
|
||||
fs.writeFileSync(filePath, JSON.stringify(legacy));
|
||||
|
||||
@@ -53,11 +54,31 @@ test('schema v2 state migrates built-in .ru into a normal enabled rule', (t) =>
|
||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||
]);
|
||||
assert.equal(migrated.appliedTag, 'nl');
|
||||
assert.equal(migrated.selectedServerId, createServerId(legacy.servers[0]));
|
||||
assert.equal(migrated.appliedServerId, migrated.selectedServerId);
|
||||
assert.equal(store.migration.fromVersion, 2);
|
||||
assert.deepEqual(JSON.parse(fs.readFileSync(store.migration.backupPath, 'utf8')), legacy);
|
||||
assert.equal(JSON.parse(fs.readFileSync(filePath, 'utf8')).schemaVersion, STATE_SCHEMA_VERSION);
|
||||
});
|
||||
|
||||
test('ambiguous legacy selectedTag explicitly requires a new choice', (t) => {
|
||||
const filePath = fixture(t);
|
||||
fs.writeFileSync(filePath, JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
selectedTag: 'Amsterdam',
|
||||
servers: [
|
||||
{ tag: 'Amsterdam', type: 'vless', server: 'nl-1.example', server_port: 443 },
|
||||
{ tag: 'Amsterdam', type: 'vless', server: 'nl-2.example', server_port: 443 },
|
||||
],
|
||||
}));
|
||||
|
||||
const migrated = createStateStore(filePath).read();
|
||||
|
||||
assert.equal(migrated.selectedServerId, '');
|
||||
assert.equal(migrated.appliedServerId, '');
|
||||
assert.equal(migrated.servers.length, 2);
|
||||
});
|
||||
|
||||
test('corrupt JSON is preserved and replaced with an explicit recovery state', (t) => {
|
||||
const filePath = fixture(t);
|
||||
fs.writeFileSync(filePath, '{broken');
|
||||
|
||||
@@ -6,26 +6,51 @@ import {
|
||||
selectRefreshedServer,
|
||||
} from '../../src/server/subscription.js';
|
||||
|
||||
test('subscription server tags are trimmed for selection', () => {
|
||||
const { servers } = parseSubscriptionBody(JSON.stringify({
|
||||
outbounds: [{ type: 'vless', tag: 'de-frankfurt ', server: 'de.example', server_port: 443 }],
|
||||
}));
|
||||
|
||||
assert.equal(servers[0].tag, 'de-frankfurt');
|
||||
const parse = (outbounds) => parseSubscriptionBody(JSON.stringify({ outbounds }));
|
||||
const outbound = (tag, server, server_port = 443) => ({
|
||||
type: 'vless',
|
||||
tag,
|
||||
server,
|
||||
server_port,
|
||||
});
|
||||
|
||||
test('refreshed subscription keeps the selected server when its tag still exists', () => {
|
||||
const servers = [{ tag: 'de' }, { tag: 'nl' }];
|
||||
test('duplicate labels remain independently addressable by stable ID', () => {
|
||||
const { config, servers } = parse([
|
||||
outbound('Amsterdam', 'nl-1.example'),
|
||||
outbound('Amsterdam', 'nl-2.example'),
|
||||
]);
|
||||
|
||||
assert.equal(selectRefreshedServer('nl', servers), 'nl');
|
||||
assert.equal(servers.length, 2);
|
||||
assert.equal(servers[0].label, 'Amsterdam');
|
||||
assert.equal(servers[1].label, 'Amsterdam');
|
||||
assert.notEqual(servers[0].id, servers[1].id);
|
||||
assert.deepEqual(config.outbounds.map((item) => item.tag), servers.map((server) => server.id));
|
||||
});
|
||||
|
||||
test('refreshed subscription selects the first server when the old tag disappeared', () => {
|
||||
const servers = [{ tag: 'de' }, { tag: 'nl' }];
|
||||
test('provider reorder does not change server IDs or selection', () => {
|
||||
const before = parse([outbound('DE', 'de.example'), outbound('NL', 'nl.example')]).servers;
|
||||
const after = parse([outbound('NL', 'nl.example'), outbound('DE', 'de.example')]).servers;
|
||||
const selectedId = before[1].id;
|
||||
|
||||
assert.equal(selectRefreshedServer('old-name', servers), 'de');
|
||||
assert.deepEqual(
|
||||
new Map(after.map((server) => [server.host, server.id])),
|
||||
new Map(before.map((server) => [server.host, server.id])),
|
||||
);
|
||||
assert.equal(selectRefreshedServer(selectedId, before, after), selectedId);
|
||||
});
|
||||
|
||||
test('refreshed subscription does not select a server before the first user choice', () => {
|
||||
assert.equal(selectRefreshedServer('', [{ tag: 'de' }]), '');
|
||||
test('cosmetic rename keeps selection for the same endpoint', () => {
|
||||
const before = parse([outbound('Old name', 'nl.example')]).servers;
|
||||
const after = parse([outbound('New name', 'nl.example')]).servers;
|
||||
|
||||
assert.equal(after[0].id, before[0].id);
|
||||
assert.equal(selectRefreshedServer(before[0].id, before, after), before[0].id);
|
||||
});
|
||||
|
||||
test('removed selected server requires an explicit new choice', () => {
|
||||
const before = parse([outbound('DE', 'de.example'), outbound('NL', 'nl.example')]).servers;
|
||||
const after = parse([outbound('DE', 'de.example')]).servers;
|
||||
|
||||
assert.equal(selectRefreshedServer(before[1].id, before, after), '');
|
||||
assert.equal(selectRefreshedServer('', before, after), '');
|
||||
});
|
||||
|
||||
@@ -16,9 +16,9 @@ import { instructionBlocks } from '../../src/web/instructions.js';
|
||||
|
||||
test('connection button chooses the only valid client action', () => {
|
||||
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
|
||||
assert.deepEqual(connectionAction({ selectedTag: 'nl-amsterdam' }), {
|
||||
assert.deepEqual(connectionAction({ selectedServerId: 'srv_nl' }), {
|
||||
type: 'apply',
|
||||
selectedTag: 'nl-amsterdam',
|
||||
serverId: 'srv_nl',
|
||||
});
|
||||
assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' });
|
||||
assert.equal(connectionAction({}), null);
|
||||
|
||||
Reference in New Issue
Block a user