import { normalizeRouteRules } from '../routingRules.js'; const MODES = new Set(['client', 'gateway']); const CONNECTION_STATES = new Set(['running', 'stopped']); const OPERATION_STATES = new Set(['idle', 'running', 'failed']); const text = (value) => String(value || '').trim(); const nullableText = (value) => value == null ? null : String(value); const dateOrNull = (value) => ( typeof value === 'string' && Number.isFinite(Date.parse(value)) ? value : null ); export function normalizeStoredState(value) { const state = value && typeof value === 'object' && !Array.isArray(value) ? value : {}; const selectedTag = text(state.selectedTag); 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 : [], routeRules: normalizeRouteRules(state.routeRules), appliedRouteRules: normalizeRouteRules(state.appliedRouteRules), routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0 ? state.routeRulesRevision : 0, }; } export function createStateSnapshot({ storedState, runtime, gatewayAuto, appMode, configExists, subscriptionHost, operation = { kind: null, status: 'idle', startedAt: null, error: null }, now = new Date(), }) { const stored = normalizeStoredState(storedState); const mode = MODES.has(appMode) ? appMode : 'gateway'; const hasSubscription = Boolean(stored.subscriptionUrl); 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 routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent'; const activeLocalRules = runtime?.running ? stored.appliedRouteRules : []; return assertStateSnapshot({ apiVersion: 1, revision: stored.revision, generatedAt: now.toISOString(), mode, subscription: { status: hasSubscription ? 'ready' : 'missing', host: hasSubscription ? subscriptionHost : '', fetchedAt: dateOrNull(stored.fetchedAt), userInfo: stored.userInfo && typeof stored.userInfo === 'object' ? stored.userInfo : {}, }, selection: { desiredServerId: stored.selectedTag, appliedServerId: stored.appliedTag, }, connection: { desired, process: runtime?.running ? 'running' : 'stopped', startedAt: dateOrNull(runtime?.startedAt), lastError: null, }, route: { mode: routeMode, gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null, lastVerifiedAt: null, reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual', localRules: stored.routeRules, activeLocalRules, localRulesRevision: stored.routeRulesRevision, localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules), }, operation: { kind: nullableText(operation.kind), status: operation.status, startedAt: nullableText(operation.startedAt), error: nullableText(operation.error), }, servers, }); } export function withStateV0Compatibility(snapshot, { storedState, gatewayAuto, port, proxyPort, configExists, }) { const stored = normalizeStoredState(storedState); return { ...snapshot, port, proxyPort, configExists, singboxRunning: snapshot.connection.process === 'running', singboxStartedAt: snapshot.connection.startedAt, subscriptionHost: snapshot.subscription.host, hasSubscription: snapshot.subscription.status === 'ready', selectedTag: snapshot.selection.desiredServerId, userInfo: snapshot.subscription.userInfo, fetchedAt: snapshot.subscription.fetchedAt, gatewayAuto: snapshot.mode === 'client' ? { mode: gatewayAuto?.mode || 'local-vpn', enabled: stored.gatewayAutoEnabled !== false, available: Boolean(gatewayAuto?.gatewayId), address: gatewayAuto?.gateway?.gateway || '', interface: gatewayAuto?.gateway?.interface || '', failures: Number(gatewayAuto?.failures) || 0, lastError: gatewayAuto?.lastError || '', } : null, }; } export function assertStateSnapshot(snapshot) { const validDate = (value) => typeof value === 'string' && Number.isFinite(Date.parse(value)); const nullableDate = (value) => value === null || validDate(value); const nullableString = (value) => value === null || typeof value === 'string'; const validServer = (server) => ( server && typeof server.id === 'string' && typeof server.label === 'string' && typeof server.host === 'string' && Number.isInteger(server.port) && server.port >= 0 && typeof server.protocol === 'string' ); const validRouteRule = (rule) => ( rule && ['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) && typeof rule.value === 'string' && Boolean(rule.value) && typeof rule.enabled === 'boolean' ); if ( !snapshot || snapshot.apiVersion !== 1 || !Number.isSafeInteger(snapshot.revision) || snapshot.revision < 0 || !validDate(snapshot.generatedAt) || !MODES.has(snapshot.mode) || !snapshot.subscription || !['missing', 'ready'].includes(snapshot.subscription.status) || typeof snapshot.subscription.host !== 'string' || Object.hasOwn(snapshot.subscription, 'url') || !nullableDate(snapshot.subscription.fetchedAt) || !snapshot.subscription.userInfo || typeof snapshot.subscription.userInfo !== 'object' || !snapshot.selection || typeof snapshot.selection.desiredServerId !== 'string' || typeof snapshot.selection.appliedServerId !== 'string' || !snapshot.connection || !CONNECTION_STATES.has(snapshot.connection.desired) || !CONNECTION_STATES.has(snapshot.connection.process) || !nullableDate(snapshot.connection.startedAt) || !nullableString(snapshot.connection.lastError) || !snapshot.route || typeof snapshot.route.mode !== 'string' || !nullableString(snapshot.route.gatewayAddress) || !nullableDate(snapshot.route.lastVerifiedAt) || typeof snapshot.route.reason !== 'string' || !Array.isArray(snapshot.route.localRules) || !snapshot.route.localRules.every(validRouteRule) || !Array.isArray(snapshot.route.activeLocalRules) || !snapshot.route.activeLocalRules.every(validRouteRule) || !Number.isSafeInteger(snapshot.route.localRulesRevision) || snapshot.route.localRulesRevision < 0 || typeof snapshot.route.localRulesPendingRestart !== 'boolean' || !snapshot.operation || !nullableString(snapshot.operation.kind) || !OPERATION_STATES.has(snapshot.operation.status) || !nullableDate(snapshot.operation.startedAt) || !nullableString(snapshot.operation.error) || !Array.isArray(snapshot.servers) || !snapshot.servers.every(validServer) ) { throw new TypeError('Invalid Harbor state snapshot v1'); } return snapshot; } function isSameRules(left, right) { return JSON.stringify(left) === JSON.stringify(right); }