From fa3b455fab83d0e917ded581c3f6f340d913fed2 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Sat, 11 Jul 2026 12:34:14 +0300 Subject: [PATCH] Refine VPN client instructions and subscription refresh flow --- .codex/skills/design-vpn-client-ui/SKILL.md | 2 + .../references/visual-language.md | 3 +- src/server/index.js | 88 ++++- src/server/subscription.js | 14 +- src/web/App.jsx | 16 +- src/web/api.js | 2 +- src/web/components/ClientOverviewPage.jsx | 151 ++++++- src/web/instructions.js | 87 ++++ src/web/styles.css | 372 ++++++++++++++++++ test/server/subscription.test.js | 21 +- test/web/client-controls.test.js | 10 + 11 files changed, 730 insertions(+), 36 deletions(-) create mode 100644 src/web/instructions.js diff --git a/.codex/skills/design-vpn-client-ui/SKILL.md b/.codex/skills/design-vpn-client-ui/SKILL.md index 441bc79..a6c792d 100644 --- a/.codex/skills/design-vpn-client-ui/SKILL.md +++ b/.codex/skills/design-vpn-client-ui/SKILL.md @@ -28,6 +28,8 @@ Preserve the repo's focused one-screen VPN client language: a centered primary a - Let visible cycles finish. Never stop a spinner mid-turn or remount a list before its exit animation completes. - Prefer one clear value over unsupported detail. Hide subscription fields the provider does not supply. - Keep client UI compact and calm. Do not introduce dashboard cards, decorative chrome, or admin-console density. +- Do not use a modal, popup, or blocking backdrop unless the user explicitly asks for one. Prefer inline disclosure or a non-modal layer that preserves the main screen. +- Avoid borders, divider lines, and framed regions by default. Build hierarchy with spacing, typography, subtle surface changes, light, and depth; use a line only when it communicates an essential state. ## Acceptance pass diff --git a/.codex/skills/design-vpn-client-ui/references/visual-language.md b/.codex/skills/design-vpn-client-ui/references/visual-language.md index f8c3c99..4861a5a 100644 --- a/.codex/skills/design-vpn-client-ui/references/visual-language.md +++ b/.codex/skills/design-vpn-client-ui/references/visual-language.md @@ -11,7 +11,8 @@ Design for a macOS user glancing at a small VPN control surface in a quiet deskt - Build the left flow vertically: power icon, stable connection copy, proxy address, copy actions. - Place subscription identity, usage, expiry, and servers in a compact column to the right. - Collapse to one centered column on narrow screens. -- Avoid cards and enclosing frames. Use spacing, type, thin rules, and state color for hierarchy. +- Avoid enclosing frames, borders, and divider lines. Use spacing, type, subtle surface changes, light, depth, and state color for hierarchy. +- Do not introduce popups or modals without an explicit user request. Prefer inline disclosure or a non-modal side layer when supporting content must coexist with the main control surface. - Keep server rows vertical and narrow. Underlines should be only slightly wider than the server label and ping. ## Geometry and alignment diff --git a/src/server/index.js b/src/server/index.js index deb7cf3..0893797 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -11,14 +11,17 @@ import { removeSingboxConfig, writeSingboxConfig, } from './singbox.js'; -import { fetchSubscription, fetchSubscriptionInfo } from './subscription.js'; +import { fetchSubscription, selectRefreshedServer } from './subscription.js'; const MAX_BODY_BYTES = 1_000_000; +const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000; fs.mkdirSync(settings.dataDir, { recursive: true }); let singboxProcess = null; let singboxStartedAt = null; +let subscriptionRefreshPromise = null; +let subscriptionRefreshTimer = null; function readJson(filePath, fallback) { try { @@ -195,6 +198,69 @@ async function applySelectedServer(selectedTag) { }); } +function refreshSavedSubscription() { + if (subscriptionRefreshPromise) return subscriptionRefreshPromise; + + subscriptionRefreshPromise = (async () => { + const initialState = readJson(settings.statePath, {}); + if (!initialState.subscriptionUrl) { + const error = new Error('Подписка не настроена'); + error.statusCode = 400; + throw error; + } + + const subscriptionUrl = initialState.subscriptionUrl; + const parsed = await fetchSubscription(subscriptionUrl); + const currentState = readJson(settings.statePath, {}); + if (currentState.subscriptionUrl !== subscriptionUrl) { + throw new Error('Подписка была изменена во время обновления'); + } + + const selectedTag = selectRefreshedServer(currentState.selectedTag, parsed.servers); + const previousCache = readJson(settings.subscriptionCachePath, null); + const previousConfig = fs.existsSync(settings.configPath) + ? fs.readFileSync(settings.configPath, 'utf8') + : null; + writeJson(settings.subscriptionCachePath, { url: subscriptionUrl, ...parsed }); + + try { + if (singboxProcess && selectedTag) await applySelectedServer(selectedTag); + else if (selectedTag) { + writeSingboxConfig(buildGatewayConfig(parsed.config, selectedTag)); + } else { + removeSingboxConfig(); + } + } catch (error) { + if (previousCache) writeJson(settings.subscriptionCachePath, previousCache); + else fs.rmSync(settings.subscriptionCachePath, { force: true }); + if (previousConfig === null) removeSingboxConfig(); + else fs.writeFileSync(settings.configPath, previousConfig, 'utf8'); + throw error; + } + + writeJson(settings.statePath, { + ...readJson(settings.statePath, {}), + subscriptionUrl, + servers: parsed.servers, + userInfo: parsed.userInfo, + fetchedAt: parsed.fetchedAt, + selectedTag, + }); + + return { + success: true, + servers: parsed.servers, + userInfo: parsed.userInfo, + fetchedAt: parsed.fetchedAt, + selectedTag, + }; + })().finally(() => { + subscriptionRefreshPromise = null; + }); + + return subscriptionRefreshPromise; +} + async function handleApi(req, res) { if (req.method === 'GET' && req.url === '/api/state') { return sendJson(res, 200, publicState()); @@ -236,16 +302,8 @@ async function handleApi(req, res) { return sendJson(res, 200, { success: true, ...parsed }); } - if (req.method === 'POST' && req.url === '/api/subscription/refresh-info') { - const state = readJson(settings.statePath, {}); - if (!state.subscriptionUrl) { - return sendJson(res, 400, { success: false, error: 'Подписка не настроена' }); - } - const info = await fetchSubscriptionInfo(state.subscriptionUrl); - writeJson(settings.statePath, { ...state, ...info }); - const cached = readJson(settings.subscriptionCachePath, null); - if (cached) writeJson(settings.subscriptionCachePath, { ...cached, ...info }); - return sendJson(res, 200, { success: true, ...info }); + if (req.method === 'POST' && req.url === '/api/subscription/refresh') { + return sendJson(res, 200, await refreshSavedSubscription()); } if (req.method === 'DELETE' && req.url === '/api/subscription') { @@ -319,6 +377,7 @@ const server = http.createServer(async (req, res) => { }); async function shutdown() { + clearInterval(subscriptionRefreshTimer); await stopSingbox(); process.exit(0); } @@ -336,3 +395,10 @@ await startSingbox().catch((error) => console.warn(`[control] sing-box не за server.listen(settings.port, '0.0.0.0', () => { console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`); }); + +subscriptionRefreshTimer = setInterval(() => { + if (!readJson(settings.statePath, {}).subscriptionUrl) return; + refreshSavedSubscription() + .catch((error) => console.warn(`[control] подписка не обновлена: ${error.message}`)); +}, SUBSCRIPTION_REFRESH_INTERVAL_MS); +subscriptionRefreshTimer.unref(); diff --git a/src/server/subscription.js b/src/server/subscription.js index a3a7529..33be146 100644 --- a/src/server/subscription.js +++ b/src/server/subscription.js @@ -160,13 +160,13 @@ async function requestSubscription(url) { return response; } -export async function fetchSubscriptionInfo(url) { - const response = await requestSubscription(url); - await response.body?.cancel(); - return { - userInfo: parseUserInfo(response.headers.get('subscription-userinfo')), - fetchedAt: new Date().toISOString(), - }; +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 async function fetchSubscription(url) { diff --git a/src/web/App.jsx b/src/web/App.jsx index 242598a..df0f05a 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -16,7 +16,11 @@ function App() { const data = await api.state(); setState(data); setServers(data.servers || []); - setPendingTag((current) => current || data.selectedTag || ''); + setPendingTag((current) => ( + (data.servers || []).some((server) => server.tag === current) + ? current + : data.selectedTag || '' + )); } useEffect(() => { @@ -51,13 +55,17 @@ function App() { }); } - async function refreshSubscriptionInfo() { - const data = await api.subscription.refreshInfo(); + async function refreshSubscription() { + const data = await api.subscription.refresh(); setState((current) => current ? { ...current, userInfo: data.userInfo, fetchedAt: data.fetchedAt, + servers: data.servers, + selectedTag: data.selectedTag, } : current); + setServers(data.servers || []); + setPendingTag(data.selectedTag || ''); return data; } @@ -87,7 +95,7 @@ function App() { pendingTag={pendingTag} setPendingTag={setPendingTag} onFetchSubscription={fetchSubscription} - onRefreshSubscriptionInfo={refreshSubscriptionInfo} + onRefreshSubscription={refreshSubscription} onForgetSubscription={forgetSubscription} onApply={(tag) => run(() => api.apply(tag))} onRestart={() => run(api.singbox.restart)} diff --git a/src/web/api.js b/src/web/api.js index bc6071e..460dca8 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -20,7 +20,7 @@ export const api = { method: 'POST', body: JSON.stringify({ url }), }), - refreshInfo: () => request('/api/subscription/refresh-info', { method: 'POST' }), + refresh: () => request('/api/subscription/refresh', { method: 'POST' }), forget: () => request('/api/subscription', { method: 'DELETE' }), }, apply: (selectedTag) => request('/api/apply', { diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 8d3d64d..5eafff8 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -1,4 +1,5 @@ import React, { useEffect, useRef, useState } from 'react'; +import { flushSync } from 'react-dom'; import { api } from '../api.js'; import { connectionAction, @@ -10,6 +11,55 @@ import { subscriptionUsage, } from '../utils/clientControls.js'; import { formatBytes } from '../utils/format.js'; +import { instructionBlocks } from '../instructions.js'; + +function InstructionStep({ step }) { + if (typeof step === 'string') return step; + return ( + <> + {step.before} + {step.link[0]} + {step.after} + + ); +} + +function InstructionBlock({ block, open, onToggle }) { + return ( +
+