Refine subscription import and refresh flow
This commit is contained in:
@@ -29,6 +29,7 @@ export const settings = {
|
||||
hostNetworkStatePath:
|
||||
process.env.HARBOR_HOST_NETWORK_STATE || "/run/harbor-host/network.json",
|
||||
gatewayPresencePort: parsePort(process.env.HARBOR_GATEWAY_CONTROL_PORT, 3456),
|
||||
subscriptionTimeoutMs: parsePort(process.env.SUBSCRIPTION_TIMEOUT_MS, 15_000),
|
||||
hwidPath: path.join(dataDir, "hwid"),
|
||||
logLevel: process.env.LOG_LEVEL || "info",
|
||||
appName: "VPN Proxy Gateway",
|
||||
|
||||
@@ -431,55 +431,39 @@ async function applyRouteRules(routeRules) {
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSavedSubscription() {
|
||||
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
||||
|
||||
subscriptionRefreshPromise = (async () => {
|
||||
const initialState = stateStore.read();
|
||||
if (!initialState.subscriptionUrl) {
|
||||
throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
async function commitSubscription(subscriptionUrl, parsed, { resetSelection = false } = {}) {
|
||||
return serializeControl(async () => {
|
||||
const previousState = normalizeStoredState(stateStore.read());
|
||||
if (!resetSelection && previousState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
|
||||
const subscriptionUrl = initialState.subscriptionUrl;
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
return serializeControl(async () => {
|
||||
const currentState = stateStore.read();
|
||||
if (currentState.subscriptionUrl !== subscriptionUrl) {
|
||||
throw new HarborError('STATE_CONFLICT');
|
||||
}
|
||||
const selectedTag = resetSelection
|
||||
? ''
|
||||
: selectRefreshedServer(previousState.selectedTag, parsed.servers);
|
||||
const candidateConfig = selectedTag
|
||||
? buildActiveConfig(parsed.config, selectedTag, previousState.routeRules)
|
||||
: null;
|
||||
const previousCache = readSubscriptionCache();
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
const previousGatewayAutoState = gatewayAutoState;
|
||||
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
||||
|
||||
const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers);
|
||||
const previousCache = readSubscriptionCache();
|
||||
const previousConfig = fs.existsSync(settings.configPath)
|
||||
? fs.readFileSync(settings.configPath, 'utf8')
|
||||
: null;
|
||||
const activeConfigChanged = Boolean(currentState.selectedTag && selectedTag) && (
|
||||
!previousCache?.config || !isDeepStrictEqual(
|
||||
buildActiveConfig(previousCache.config, currentState.selectedTag),
|
||||
buildActiveConfig(parsed.config, selectedTag),
|
||||
)
|
||||
);
|
||||
try {
|
||||
if (resetSelection && wasRunning) await stopSingbox();
|
||||
if (candidateConfig) writeSingboxConfig(candidateConfig);
|
||||
else removeSingboxConfig();
|
||||
subscriptionCacheStore.write({ url: subscriptionUrl, ...parsed });
|
||||
|
||||
try {
|
||||
if (singboxRuntime.running && activeConfigChanged) {
|
||||
await applySelectedServer(selectedTag, { persist: false });
|
||||
}
|
||||
else if (selectedTag) {
|
||||
if (!singboxRuntime.running) writeSingboxConfig(buildActiveConfig(parsed.config, selectedTag));
|
||||
} else {
|
||||
removeSingboxConfig();
|
||||
}
|
||||
} catch (error) {
|
||||
if (previousCache) subscriptionCacheStore.write(previousCache);
|
||||
else subscriptionCacheStore.remove();
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
throw error;
|
||||
}
|
||||
if (!resetSelection && wasRunning && candidateConfig) await startSingbox();
|
||||
|
||||
updateStoredState((state) => ({
|
||||
...state,
|
||||
...(resetSelection ? {
|
||||
routeRules: state.routeRules,
|
||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||
connectionDesired: 'stopped',
|
||||
} : state),
|
||||
subscriptionUrl,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
@@ -487,15 +471,48 @@ function refreshSavedSubscription() {
|
||||
selectedTag,
|
||||
appliedTag: selectedTag,
|
||||
}));
|
||||
if (resetSelection) gatewayAutoState = createGatewayAutoState();
|
||||
} catch (error) {
|
||||
gatewayAutoState = previousGatewayAutoState;
|
||||
if (previousCache) subscriptionCacheStore.write(previousCache);
|
||||
else subscriptionCacheStore.remove();
|
||||
if (previousConfig === null) removeSingboxConfig();
|
||||
else restoreSingboxConfig(previousConfig);
|
||||
if (wasRunning) {
|
||||
try {
|
||||
await startSingbox();
|
||||
} catch (rollbackError) {
|
||||
throw new HarborError('PROCESS_START_FAILED', {
|
||||
cause: new AggregateError([error, rollbackError], 'Subscription rollback failed'),
|
||||
});
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
};
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
servers: parsed.servers,
|
||||
userInfo: parsed.userInfo,
|
||||
fetchedAt: parsed.fetchedAt,
|
||||
selectedTag,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function importSubscription(subscriptionUrl) {
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
return commitSubscription(subscriptionUrl, parsed, { resetSelection: true });
|
||||
}
|
||||
|
||||
function refreshSavedSubscription() {
|
||||
if (subscriptionRefreshPromise) return subscriptionRefreshPromise;
|
||||
|
||||
subscriptionRefreshPromise = (async () => {
|
||||
const subscriptionUrl = stateStore.read().subscriptionUrl;
|
||||
if (!subscriptionUrl) throw new HarborError('SUBSCRIPTION_INVALID');
|
||||
const parsed = await fetchSubscription(subscriptionUrl);
|
||||
return commitSubscription(subscriptionUrl, parsed);
|
||||
})().finally(() => {
|
||||
subscriptionRefreshPromise = null;
|
||||
});
|
||||
@@ -553,25 +570,7 @@ async function handleApi(req, res) {
|
||||
const { url = '' } = await readBody(req);
|
||||
const normalizedUrl = String(url).trim();
|
||||
const parsed = await withOperation('subscription-import', async () => {
|
||||
const result = await fetchSubscription(normalizedUrl);
|
||||
await serializeControl(async () => {
|
||||
await stopSingbox();
|
||||
removeSingboxConfig();
|
||||
subscriptionCacheStore.write({ url: normalizedUrl, ...result });
|
||||
updateStoredState((state) => ({
|
||||
routeRules: state.routeRules,
|
||||
subscriptionUrl: normalizedUrl,
|
||||
gatewayAutoEnabled: state.gatewayAutoEnabled !== false,
|
||||
servers: result.servers,
|
||||
userInfo: result.userInfo,
|
||||
fetchedAt: result.fetchedAt,
|
||||
selectedTag: '',
|
||||
appliedTag: '',
|
||||
connectionDesired: 'stopped',
|
||||
}));
|
||||
gatewayAutoState = createGatewayAutoState();
|
||||
});
|
||||
return result;
|
||||
return importSubscription(normalizedUrl);
|
||||
});
|
||||
return sendState(res, parsed);
|
||||
}
|
||||
|
||||
@@ -143,7 +143,7 @@ export function parseSubscriptionBody(body) {
|
||||
return { config: parsedConfig, servers };
|
||||
}
|
||||
|
||||
async function requestSubscription(url) {
|
||||
async function requestSubscription(url, { fetchImpl = fetch, timeoutMs = settings.subscriptionTimeoutMs } = {}) {
|
||||
let parsedUrl;
|
||||
try {
|
||||
parsedUrl = new URL(url);
|
||||
@@ -157,9 +157,10 @@ async function requestSubscription(url) {
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(parsedUrl, {
|
||||
response = await fetchImpl(parsedUrl, {
|
||||
headers: subscriptionHeaders(),
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (cause) {
|
||||
throw new HarborError('PROVIDER_UNAVAILABLE', { cause });
|
||||
@@ -181,8 +182,8 @@ export function selectRefreshedServer(currentTag, servers) {
|
||||
|| '';
|
||||
}
|
||||
|
||||
export async function fetchSubscription(url) {
|
||||
const response = await requestSubscription(url);
|
||||
export async function fetchSubscription(url, options) {
|
||||
const response = await requestSubscription(url, options);
|
||||
|
||||
const body = await response.text();
|
||||
const userInfo = parseUserInfo(response.headers.get('subscription-userinfo'));
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export const HARBOR_VERSIONS = Object.freeze({
|
||||
macClient: '0.6.16',
|
||||
gatewayClient: '0.6.15',
|
||||
gatewayBackend: '0.6.0',
|
||||
macClient: '0.6.17',
|
||||
gatewayClient: '0.6.16',
|
||||
gatewayBackend: '0.6.1',
|
||||
});
|
||||
|
||||
export function parseVersion(value) {
|
||||
|
||||
@@ -16,10 +16,6 @@ export class HarborApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function validationStatusForError(error) {
|
||||
return error?.code === 'SUBSCRIPTION_INVALID' ? 'invalid' : 'unavailable';
|
||||
}
|
||||
|
||||
export async function request(url, options = {}, fetchImpl = fetch) {
|
||||
let response;
|
||||
try {
|
||||
@@ -54,11 +50,6 @@ export const api = {
|
||||
state: () => request('/api/state'),
|
||||
version: () => request('/api/version'),
|
||||
subscription: {
|
||||
validate: (url, signal) => request('/api/subscription/validate', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
signal,
|
||||
}),
|
||||
fetch: (url) => request('/api/subscription/fetch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ url }),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { flushSync } from 'react-dom';
|
||||
import { api, validationStatusForError } from '../api.js';
|
||||
import { api } from '../api.js';
|
||||
import {
|
||||
connectionAction,
|
||||
connectionDurationParts,
|
||||
copyText,
|
||||
isSubscriptionUrlValid,
|
||||
localProxyUrls,
|
||||
subscriptionDomain,
|
||||
subscriptionDaysLeft,
|
||||
@@ -12,7 +13,7 @@ import {
|
||||
} from '../utils/clientControls.js';
|
||||
import { formatBytes } from '../utils/format.js';
|
||||
import { instructionBlocks } from '../instructions.js';
|
||||
import { createLatestRequest, operationBlocked } from '../state/operations.js';
|
||||
import { operationBlocked } from '../state/operations.js';
|
||||
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
|
||||
import { canAppendRouteRule } from '../../shared/routingRules.js';
|
||||
import {
|
||||
@@ -594,8 +595,6 @@ export function ClientOverviewPage({
|
||||
}
|
||||
});
|
||||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||||
const [subscriptionValidation, setSubscriptionValidation] = useState({ url: '', status: 'idle' });
|
||||
const [validationAttempt, setValidationAttempt] = useState(0);
|
||||
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
||||
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
|
||||
const [pings, setPings] = useState({});
|
||||
@@ -621,8 +620,6 @@ export function ClientOverviewPage({
|
||||
const localRulesToggleRef = useRef(null);
|
||||
const localRulesBaselineRef = useRef('[]');
|
||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||
const validationRequests = useRef(null);
|
||||
if (!validationRequests.current) validationRequests.current = createLatestRequest();
|
||||
const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|');
|
||||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||||
@@ -646,12 +643,9 @@ export function ClientOverviewPage({
|
||||
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
|
||||
: instructionGuides;
|
||||
const normalizedSubscriptionUrl = subscriptionUrl.trim();
|
||||
const subscriptionValidationStatus = subscriptionValidation.url === normalizedSubscriptionUrl
|
||||
? subscriptionValidation.status
|
||||
: normalizedSubscriptionUrl ? 'checking' : 'idle';
|
||||
const subscriptionError = subscriptionValidation.url === normalizedSubscriptionUrl
|
||||
? subscriptionValidation.error
|
||||
: null;
|
||||
const subscriptionValidationStatus = !normalizedSubscriptionUrl
|
||||
? 'idle'
|
||||
: isSubscriptionUrlValid(normalizedSubscriptionUrl) ? 'valid' : 'invalid';
|
||||
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
|
||||
const connectionBlocked = operationBlocked(operations, 'connection');
|
||||
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
|
||||
@@ -729,54 +723,6 @@ export function ClientOverviewPage({
|
||||
return () => clearTimeout(timer);
|
||||
}, [hasSubscription]);
|
||||
|
||||
useEffect(() => {
|
||||
validationRequests.current.cancel();
|
||||
if (!normalizedSubscriptionUrl) {
|
||||
setSubscriptionValidation({ url: '', status: 'idle' });
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking' });
|
||||
const timer = setTimeout(() => {
|
||||
validationRequests.current
|
||||
.run((signal) => api.subscription.validate(normalizedSubscriptionUrl, signal))
|
||||
.then((result) => {
|
||||
if (!result) return;
|
||||
setSubscriptionValidation({
|
||||
url: normalizedSubscriptionUrl,
|
||||
status: 'valid',
|
||||
error: null,
|
||||
});
|
||||
})
|
||||
.catch((validationError) => {
|
||||
setSubscriptionValidation({
|
||||
url: normalizedSubscriptionUrl,
|
||||
status: validationStatusForError(validationError),
|
||||
error: {
|
||||
context: 'subscription',
|
||||
message: validationError.message,
|
||||
correlationId: validationError.correlationId,
|
||||
retry: validationError.retryable
|
||||
? () => {
|
||||
setSubscriptionValidation({
|
||||
url: normalizedSubscriptionUrl,
|
||||
status: 'checking',
|
||||
error: null,
|
||||
});
|
||||
setValidationAttempt((attempt) => attempt + 1);
|
||||
}
|
||||
: null,
|
||||
},
|
||||
});
|
||||
});
|
||||
}, 350);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
validationRequests.current.cancel();
|
||||
};
|
||||
}, [normalizedSubscriptionUrl, validationAttempt]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasSubscription) {
|
||||
setEditingSubscription(true);
|
||||
@@ -1317,22 +1263,16 @@ export function ClientOverviewPage({
|
||||
type="submit"
|
||||
aria-label={subscriptionValidationStatus === 'valid'
|
||||
? 'Сохранить подписку'
|
||||
: subscriptionValidationStatus === 'invalid'
|
||||
? 'Ссылка подписки не распознана'
|
||||
: subscriptionValidationStatus === 'unavailable'
|
||||
? 'Проверка подписки временно недоступна'
|
||||
: 'Проверяем ссылку подписки'}
|
||||
: 'Ссылка подписки не распознана'}
|
||||
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
|
||||
>
|
||||
{subscriptionValidationStatus === 'valid'
|
||||
? '✓'
|
||||
: subscriptionValidationStatus === 'invalid'
|
||||
? '×'
|
||||
: subscriptionValidationStatus === 'unavailable' ? '!' : '…'}
|
||||
: '×'}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
<InlineError error={subscriptionError || error} context="subscription" />
|
||||
<InlineError error={error} context="subscription" />
|
||||
<InlineProgress operations={operations} context="subscription" />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -40,27 +40,3 @@ export function createOperationRegistry(onChange = () => {}, now = () => new Dat
|
||||
|
||||
return { run, getSnapshot: () => operations };
|
||||
}
|
||||
|
||||
export function createLatestRequest() {
|
||||
let controller = null;
|
||||
|
||||
return {
|
||||
run(action) {
|
||||
controller?.abort();
|
||||
controller = new AbortController();
|
||||
const current = controller;
|
||||
return Promise.resolve()
|
||||
.then(() => action(current.signal))
|
||||
.then(
|
||||
(value) => current.signal.aborted ? undefined : value,
|
||||
(error) => {
|
||||
if (current.signal.aborted) return undefined;
|
||||
throw error;
|
||||
},
|
||||
);
|
||||
},
|
||||
cancel() {
|
||||
controller?.abort();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2361,20 +2361,11 @@ p {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.client-subscription-edit.is-checking .client-subscription-submit {
|
||||
color: var(--client-muted);
|
||||
animation: client-validation-pulse 900ms ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
.client-subscription-edit.is-invalid .client-subscription-submit {
|
||||
color: oklch(0.68 0.15 28);
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes client-validation-pulse {
|
||||
to { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.client-subscription-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -61,6 +61,14 @@ export function subscriptionDomain(subscriptionHost) {
|
||||
}
|
||||
}
|
||||
|
||||
export function isSubscriptionUrlValid(value) {
|
||||
try {
|
||||
return ['http:', 'https:'].includes(new URL(String(value).trim()).protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function localProxyUrls(port = 8082, host = '127.0.0.1') {
|
||||
const urlHost = host.includes(':') && !host.startsWith('[') ? `[${host}]` : host;
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user