Refine VPN client instructions and subscription refresh flow
This commit is contained in:
@@ -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.
|
- 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.
|
- 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.
|
- 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
|
## Acceptance pass
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- Place subscription identity, usage, expiry, and servers in a compact column to the right.
|
||||||
- Collapse to one centered column on narrow screens.
|
- 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.
|
- Keep server rows vertical and narrow. Underlines should be only slightly wider than the server label and ping.
|
||||||
|
|
||||||
## Geometry and alignment
|
## Geometry and alignment
|
||||||
|
|||||||
@@ -11,14 +11,17 @@ import {
|
|||||||
removeSingboxConfig,
|
removeSingboxConfig,
|
||||||
writeSingboxConfig,
|
writeSingboxConfig,
|
||||||
} from './singbox.js';
|
} from './singbox.js';
|
||||||
import { fetchSubscription, fetchSubscriptionInfo } from './subscription.js';
|
import { fetchSubscription, selectRefreshedServer } from './subscription.js';
|
||||||
|
|
||||||
const MAX_BODY_BYTES = 1_000_000;
|
const MAX_BODY_BYTES = 1_000_000;
|
||||||
|
const SUBSCRIPTION_REFRESH_INTERVAL_MS = 15 * 60 * 1000;
|
||||||
|
|
||||||
fs.mkdirSync(settings.dataDir, { recursive: true });
|
fs.mkdirSync(settings.dataDir, { recursive: true });
|
||||||
|
|
||||||
let singboxProcess = null;
|
let singboxProcess = null;
|
||||||
let singboxStartedAt = null;
|
let singboxStartedAt = null;
|
||||||
|
let subscriptionRefreshPromise = null;
|
||||||
|
let subscriptionRefreshTimer = null;
|
||||||
|
|
||||||
function readJson(filePath, fallback) {
|
function readJson(filePath, fallback) {
|
||||||
try {
|
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) {
|
async function handleApi(req, res) {
|
||||||
if (req.method === 'GET' && req.url === '/api/state') {
|
if (req.method === 'GET' && req.url === '/api/state') {
|
||||||
return sendJson(res, 200, publicState());
|
return sendJson(res, 200, publicState());
|
||||||
@@ -236,16 +302,8 @@ async function handleApi(req, res) {
|
|||||||
return sendJson(res, 200, { success: true, ...parsed });
|
return sendJson(res, 200, { success: true, ...parsed });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && req.url === '/api/subscription/refresh-info') {
|
if (req.method === 'POST' && req.url === '/api/subscription/refresh') {
|
||||||
const state = readJson(settings.statePath, {});
|
return sendJson(res, 200, await refreshSavedSubscription());
|
||||||
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 === 'DELETE' && req.url === '/api/subscription') {
|
if (req.method === 'DELETE' && req.url === '/api/subscription') {
|
||||||
@@ -319,6 +377,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
async function shutdown() {
|
async function shutdown() {
|
||||||
|
clearInterval(subscriptionRefreshTimer);
|
||||||
await stopSingbox();
|
await stopSingbox();
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -336,3 +395,10 @@ await startSingbox().catch((error) => console.warn(`[control] sing-box не за
|
|||||||
server.listen(settings.port, '0.0.0.0', () => {
|
server.listen(settings.port, '0.0.0.0', () => {
|
||||||
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
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();
|
||||||
|
|||||||
@@ -160,13 +160,13 @@ async function requestSubscription(url) {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubscriptionInfo(url) {
|
export function selectRefreshedServer(currentTag, servers) {
|
||||||
const response = await requestSubscription(url);
|
const selectedTag = String(currentTag || '').trim();
|
||||||
await response.body?.cancel();
|
if (!selectedTag) return '';
|
||||||
return {
|
|
||||||
userInfo: parseUserInfo(response.headers.get('subscription-userinfo')),
|
return servers.find((server) => String(server.tag || '').trim() === selectedTag)?.tag
|
||||||
fetchedAt: new Date().toISOString(),
|
|| servers[0]?.tag
|
||||||
};
|
|| '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubscription(url) {
|
export async function fetchSubscription(url) {
|
||||||
|
|||||||
@@ -16,7 +16,11 @@ function App() {
|
|||||||
const data = await api.state();
|
const data = await api.state();
|
||||||
setState(data);
|
setState(data);
|
||||||
setServers(data.servers || []);
|
setServers(data.servers || []);
|
||||||
setPendingTag((current) => current || data.selectedTag || '');
|
setPendingTag((current) => (
|
||||||
|
(data.servers || []).some((server) => server.tag === current)
|
||||||
|
? current
|
||||||
|
: data.selectedTag || ''
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -51,13 +55,17 @@ function App() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshSubscriptionInfo() {
|
async function refreshSubscription() {
|
||||||
const data = await api.subscription.refreshInfo();
|
const data = await api.subscription.refresh();
|
||||||
setState((current) => current ? {
|
setState((current) => current ? {
|
||||||
...current,
|
...current,
|
||||||
userInfo: data.userInfo,
|
userInfo: data.userInfo,
|
||||||
fetchedAt: data.fetchedAt,
|
fetchedAt: data.fetchedAt,
|
||||||
|
servers: data.servers,
|
||||||
|
selectedTag: data.selectedTag,
|
||||||
} : current);
|
} : current);
|
||||||
|
setServers(data.servers || []);
|
||||||
|
setPendingTag(data.selectedTag || '');
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +95,7 @@ function App() {
|
|||||||
pendingTag={pendingTag}
|
pendingTag={pendingTag}
|
||||||
setPendingTag={setPendingTag}
|
setPendingTag={setPendingTag}
|
||||||
onFetchSubscription={fetchSubscription}
|
onFetchSubscription={fetchSubscription}
|
||||||
onRefreshSubscriptionInfo={refreshSubscriptionInfo}
|
onRefreshSubscription={refreshSubscription}
|
||||||
onForgetSubscription={forgetSubscription}
|
onForgetSubscription={forgetSubscription}
|
||||||
onApply={(tag) => run(() => api.apply(tag))}
|
onApply={(tag) => run(() => api.apply(tag))}
|
||||||
onRestart={() => run(api.singbox.restart)}
|
onRestart={() => run(api.singbox.restart)}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ export const api = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({ url }),
|
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' }),
|
forget: () => request('/api/subscription', { method: 'DELETE' }),
|
||||||
},
|
},
|
||||||
apply: (selectedTag) => request('/api/apply', {
|
apply: (selectedTag) => request('/api/apply', {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { flushSync } from 'react-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import {
|
import {
|
||||||
connectionAction,
|
connectionAction,
|
||||||
@@ -10,6 +11,55 @@ import {
|
|||||||
subscriptionUsage,
|
subscriptionUsage,
|
||||||
} from '../utils/clientControls.js';
|
} from '../utils/clientControls.js';
|
||||||
import { formatBytes } from '../utils/format.js';
|
import { formatBytes } from '../utils/format.js';
|
||||||
|
import { instructionBlocks } from '../instructions.js';
|
||||||
|
|
||||||
|
function InstructionStep({ step }) {
|
||||||
|
if (typeof step === 'string') return step;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{step.before}
|
||||||
|
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
|
||||||
|
{step.after}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InstructionBlock({ block, open, onToggle }) {
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`client-instruction-block${open ? ' is-open' : ''}`}
|
||||||
|
style={{ viewTransitionName: `instruction-${block.id}` }}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="client-instruction-summary"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={open}
|
||||||
|
onClick={onToggle}
|
||||||
|
>
|
||||||
|
<span>{block.label}</span>
|
||||||
|
<strong>{block.title}</strong>
|
||||||
|
<small>{block.summary}</small>
|
||||||
|
<i aria-hidden="true" />
|
||||||
|
</button>
|
||||||
|
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
|
||||||
|
<div className="client-instruction-body">
|
||||||
|
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||||
|
{block.steps && (
|
||||||
|
<ol>
|
||||||
|
{block.steps.map((step) => (
|
||||||
|
<li key={typeof step === 'string' ? step : step.link[1]}>
|
||||||
|
<InstructionStep step={step} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
{block.code && <code>{block.code}</code>}
|
||||||
|
{block.note && <p className="client-instruction-note">{block.note}</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientOverviewPage({
|
export function ClientOverviewPage({
|
||||||
state,
|
state,
|
||||||
@@ -21,7 +71,7 @@ export function ClientOverviewPage({
|
|||||||
pendingTag,
|
pendingTag,
|
||||||
setPendingTag,
|
setPendingTag,
|
||||||
onFetchSubscription,
|
onFetchSubscription,
|
||||||
onRefreshSubscriptionInfo,
|
onRefreshSubscription,
|
||||||
onForgetSubscription,
|
onForgetSubscription,
|
||||||
onApply,
|
onApply,
|
||||||
onRestart,
|
onRestart,
|
||||||
@@ -42,6 +92,8 @@ export function ClientOverviewPage({
|
|||||||
const [usageUpdated, setUsageUpdated] = useState(false);
|
const [usageUpdated, setUsageUpdated] = useState(false);
|
||||||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||||||
const [serversLeaving, setServersLeaving] = useState(false);
|
const [serversLeaving, setServersLeaving] = useState(false);
|
||||||
|
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||||||
|
const [openInstructionId, setOpenInstructionId] = useState('');
|
||||||
const subscriptionInputRef = useRef(null);
|
const subscriptionInputRef = useRef(null);
|
||||||
const subscriptionRef = useRef(null);
|
const subscriptionRef = useRef(null);
|
||||||
const copyTimerRef = useRef(null);
|
const copyTimerRef = useRef(null);
|
||||||
@@ -53,6 +105,16 @@ export function ClientOverviewPage({
|
|||||||
const hasUsage = Boolean(
|
const hasUsage = Boolean(
|
||||||
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||||||
);
|
);
|
||||||
|
const instructions = instructionBlocks({
|
||||||
|
isGateway,
|
||||||
|
host: gatewayAddress,
|
||||||
|
port: state?.proxyPort || (isGateway ? 8080 : 8082),
|
||||||
|
});
|
||||||
|
const [instructionsIntro, ...instructionGuides] = instructions;
|
||||||
|
const openInstruction = instructionGuides.find((block) => block.id === openInstructionId);
|
||||||
|
const orderedInstructionGuides = openInstruction
|
||||||
|
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
|
||||||
|
: instructionGuides;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
@@ -92,7 +154,10 @@ export function ClientOverviewPage({
|
|||||||
}, [editingSubscription]);
|
}, [editingSubscription]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!hasSubscription) setEditingSubscription(true);
|
if (!hasSubscription) {
|
||||||
|
setEditingSubscription(true);
|
||||||
|
setInstructionsOpen(false);
|
||||||
|
}
|
||||||
}, [hasSubscription]);
|
}, [hasSubscription]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -114,10 +179,8 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state?.hasSubscription) return undefined;
|
if (!state?.hasSubscription) return undefined;
|
||||||
const refresh = () => onRefreshSubscriptionInfo().catch(() => {});
|
onRefreshSubscription().catch(() => {});
|
||||||
refresh();
|
return undefined;
|
||||||
const timer = setInterval(refresh, 60_000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [state?.hasSubscription]);
|
}, [state?.hasSubscription]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -138,6 +201,15 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!instructionsOpen) return undefined;
|
||||||
|
const closeOnEscape = (event) => {
|
||||||
|
if (event.key === 'Escape') setInstructionsOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', closeOnEscape);
|
||||||
|
return () => document.removeEventListener('keydown', closeOnEscape);
|
||||||
|
}, [instructionsOpen]);
|
||||||
|
|
||||||
async function toggleConnection() {
|
async function toggleConnection() {
|
||||||
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
const action = connectionAction({ connected, selectedTag, configExists: state?.configExists });
|
||||||
if (action?.type === 'stop') return onStop();
|
if (action?.type === 'stop') return onStop();
|
||||||
@@ -170,11 +242,11 @@ export function ClientOverviewPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refreshInfo() {
|
async function refreshSubscription() {
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
setRefreshingInfo(true);
|
setRefreshingInfo(true);
|
||||||
try {
|
try {
|
||||||
await onRefreshSubscriptionInfo();
|
await onRefreshSubscription();
|
||||||
setUsageUpdated(false);
|
setUsageUpdated(false);
|
||||||
requestAnimationFrame(() => setUsageUpdated(true));
|
requestAnimationFrame(() => setUsageUpdated(true));
|
||||||
setTimeout(() => setUsageUpdated(false), 900);
|
setTimeout(() => setUsageUpdated(false), 900);
|
||||||
@@ -190,8 +262,36 @@ export function ClientOverviewPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toggleInstruction(id) {
|
||||||
|
const update = () => flushSync(() => {
|
||||||
|
setOpenInstructionId((current) => current === id ? '' : id);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.startViewTransition(update);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="client-shell">
|
<div className="client-shell">
|
||||||
|
{hasSubscription && <button
|
||||||
|
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||||||
|
type="button"
|
||||||
|
aria-expanded={instructionsOpen}
|
||||||
|
aria-controls="client-instructions"
|
||||||
|
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||||
|
onClick={() => setInstructionsOpen((open) => !open)}
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<path d="m3 5 7 7-7 7" />
|
||||||
|
<path d="m7 5 7 7-7 7" />
|
||||||
|
<path d="m11 5 7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
<span>Как использовать</span>
|
||||||
|
</button>}
|
||||||
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
||||||
{showPower && (
|
{showPower && (
|
||||||
<section className="client-power-section" aria-labelledby="connection-title">
|
<section className="client-power-section" aria-labelledby="connection-title">
|
||||||
@@ -304,10 +404,10 @@ export function ClientOverviewPage({
|
|||||||
<button
|
<button
|
||||||
className="client-subscription-refresh"
|
className="client-subscription-refresh"
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Обновить статистику подписки"
|
aria-label="Обновить подписку"
|
||||||
title="Обновить статистику"
|
title="Обновить подписку"
|
||||||
disabled={refreshingInfo}
|
disabled={refreshingInfo}
|
||||||
onClick={refreshInfo}
|
onClick={refreshSubscription}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||||||
@@ -441,6 +541,35 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{hasSubscription && <aside
|
||||||
|
id="client-instructions"
|
||||||
|
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||||||
|
aria-labelledby="instructions-title"
|
||||||
|
aria-hidden={!instructionsOpen}
|
||||||
|
inert={!instructionsOpen ? true : undefined}
|
||||||
|
>
|
||||||
|
<div className="client-instructions-sheet">
|
||||||
|
<header className="client-instructions-header">
|
||||||
|
<span>Подключение</span>
|
||||||
|
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||||||
|
<div className="client-instructions-intro">
|
||||||
|
{instructionsIntro.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="client-instruction-list">
|
||||||
|
{orderedInstructionGuides.map((block) => (
|
||||||
|
<InstructionBlock
|
||||||
|
block={block}
|
||||||
|
key={block.id}
|
||||||
|
open={block.id === openInstructionId}
|
||||||
|
onToggle={() => toggleInstruction(block.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
87
src/web/instructions.js
Normal file
87
src/web/instructions.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
export function instructionBlocks({ isGateway, host, port }) {
|
||||||
|
const httpProxy = `http://${host}:${port}`;
|
||||||
|
const socksProxy = `socks5://${host}:${port}`;
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
label: 'Основы',
|
||||||
|
title: isGateway ? 'Gateway и прокси' : 'Что такое прокси',
|
||||||
|
summary: isGateway
|
||||||
|
? 'Два способа направить трафик через это устройство.'
|
||||||
|
: 'Способ направить трафик выбранного приложения через VPN.',
|
||||||
|
paragraphs: isGateway
|
||||||
|
? [
|
||||||
|
`Gateway (${host}) заменяет основной шлюз устройства и проводит через VPN весь его интернет-трафик.`,
|
||||||
|
`Gateway Proxy (${host}:${port}) работает точечно: его указывают в браузере, редакторе или другом приложении. Если приложение не умеет работать с прокси, можно использовать ProxyBridge.`,
|
||||||
|
]
|
||||||
|
: [
|
||||||
|
`Локальный прокси (${host}:${port}) не перенаправляет приложения автоматически. Каждое приложение должно использовать этот адрес само — напрямую или через ProxyBridge.`,
|
||||||
|
'HTTP обычно проще для браузеров и редакторов. SOCKS5 подходит приложениям и инструментам, которым нужен более универсальный транспорт.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'proxybridge',
|
||||||
|
label: 'Приложения',
|
||||||
|
title: 'ProxyBridge',
|
||||||
|
summary: 'Направляет через прокси отдельные приложения, даже если у них нет своей настройки.',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
link: ['Установите ProxyBridge', 'https://interceptsuite.com/download/proxybridge'],
|
||||||
|
after: ' с официальной страницы проекта.',
|
||||||
|
},
|
||||||
|
`Добавьте прокси типа SOCKS5: сервер ${host}, порт ${port}.`,
|
||||||
|
'Создайте правило, выберите нужное приложение и действие Proxy.',
|
||||||
|
'Включите ProxyBridge и запустите приложение заново.',
|
||||||
|
],
|
||||||
|
note: 'Не добавляйте в правило сам ProxyBridge и VPN-клиент: это может создать прокси-цикл.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'switchyomega',
|
||||||
|
label: 'Браузер',
|
||||||
|
title: 'SwitchyOmega',
|
||||||
|
summary: 'Переключает прокси-профили только для браузера.',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
link: ['Установите расширение', 'https://chromewebstore.google.com/detail/proxy-switchyomega/padekgcemlokbadohgkifijomclgjgif'],
|
||||||
|
after: ' и откройте его настройки.',
|
||||||
|
},
|
||||||
|
'Создайте профиль Proxy Profile.',
|
||||||
|
`Выберите HTTP, укажите сервер ${host} и порт ${port}.`,
|
||||||
|
'Создайте профиль Auto Switch, выберите созданный прокси для нужных сайтов, а для остальных оставьте Direct.',
|
||||||
|
'Если сайт не загрузился, откройте SwitchyOmega: расширение покажет проблемные ресурсы. Добавьте домен текущего сайта в Auto Switch и назначьте ему прокси-профиль.',
|
||||||
|
],
|
||||||
|
note: 'Проект больше не поддерживается. Используйте его только если расширение уже подходит вашему браузеру.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vscode',
|
||||||
|
label: 'Редактор',
|
||||||
|
title: 'Visual Studio Code',
|
||||||
|
summary: 'VS Code использует системный прокси или адрес, переданный при запуске.',
|
||||||
|
steps: [
|
||||||
|
'Если прокси уже настроен в системе, полностью перезапустите VS Code — обычно он подхватит настройку автоматически.',
|
||||||
|
'Для отдельного запуска через SOCKS5 используйте команду ниже.',
|
||||||
|
{
|
||||||
|
link: ['Документация VS Code', 'https://code.visualstudio.com/docs/setup/network'],
|
||||||
|
after: ' описывает также системный прокси, HTTP и параметры исключений.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
code: `code --proxy-server="${socksProxy}"`,
|
||||||
|
note: `VS Code не поддерживает логин и пароль для SOCKS5. Здесь прокси ${host}:${port} локальный и без авторизации, поэтому этот вариант подходит. HTTP-адрес ${httpProxy} остаётся альтернативой.`,
|
||||||
|
},
|
||||||
|
...(isGateway ? [{
|
||||||
|
id: 'router',
|
||||||
|
label: 'Вся сеть',
|
||||||
|
title: 'Заменить Gateway в роутере',
|
||||||
|
summary: 'Роутер будет выдавать этот Gateway устройствам как основной шлюз.',
|
||||||
|
steps: [
|
||||||
|
`Закрепите за Gateway постоянный адрес ${host} в настройках DHCP роутера.`,
|
||||||
|
'Откройте настройки локальной сети или DHCP. Не меняйте шлюз WAN/интернет-подключения.',
|
||||||
|
`В поле Default Gateway, Router или Основной шлюз укажите ${host}.`,
|
||||||
|
'Сохраните настройки и переподключите устройства к сети, чтобы они получили новый маршрут.',
|
||||||
|
`Для отката верните в это поле локальный адрес самого роутера вместо ${host}.`,
|
||||||
|
],
|
||||||
|
note: 'Gateway и устройства должны находиться в одной локальной сети. Сначала проверьте настройку на одном устройстве вручную.',
|
||||||
|
}] : []),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -79,6 +79,7 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-shell {
|
.client-shell {
|
||||||
|
position: relative;
|
||||||
width: min(100%, 1100px);
|
width: min(100%, 1100px);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -86,6 +87,352 @@ p {
|
|||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle {
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 14px;
|
||||||
|
z-index: 30;
|
||||||
|
width: 42px;
|
||||||
|
height: 54px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-muted);
|
||||||
|
cursor: pointer;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
transition: transform 440ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle svg {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
width: 23px;
|
||||||
|
height: 18px;
|
||||||
|
fill: none;
|
||||||
|
stroke: currentColor;
|
||||||
|
stroke-width: 1.7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
overflow: visible;
|
||||||
|
transition: transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle path {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateX(-5px);
|
||||||
|
transition: opacity 260ms ease, transform 480ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle path:last-child {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(-4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle span {
|
||||||
|
position: absolute;
|
||||||
|
left: 48px;
|
||||||
|
width: max-content;
|
||||||
|
color: var(--client-text);
|
||||||
|
font: 700 10px/1.2 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(5px);
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateX(-8px);
|
||||||
|
transition: opacity 350ms ease, filter 350ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle:hover,
|
||||||
|
.client-instructions-toggle:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
transform: translateY(-50%) translateX(3px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle:hover path,
|
||||||
|
.client-instructions-toggle:focus-visible path {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle:hover span,
|
||||||
|
.client-instructions-toggle:focus-visible span {
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle.is-open svg {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle.is-open path {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle.is-open path:first-child {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateX(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle.is-open span {
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
z-index: 20;
|
||||||
|
width: min(470px, 100vw);
|
||||||
|
overflow-y: auto;
|
||||||
|
background: color-mix(in oklch, var(--client-bg) 99%, var(--client-panel));
|
||||||
|
color: var(--client-text);
|
||||||
|
box-shadow: 26px 0 72px oklch(0.09 0.015 145 / 0.12);
|
||||||
|
opacity: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
transform: translateX(-104%);
|
||||||
|
transition: transform 760ms cubic-bezier(0.16, 1, 0.3, 1), opacity 500ms ease, visibility 0s 760ms;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions.is-open {
|
||||||
|
opacity: 1;
|
||||||
|
visibility: visible;
|
||||||
|
transform: translateX(0);
|
||||||
|
transition-delay: 0s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-sheet {
|
||||||
|
min-height: 100%;
|
||||||
|
padding: 54px 34px 72px 72px;
|
||||||
|
font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-header {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
margin: 0 8px 38px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-header span,
|
||||||
|
.client-instruction-summary > span {
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-intro {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 15px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-intro p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block {
|
||||||
|
border: 0;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: color-mix(in oklch, var(--client-panel) 56%, var(--client-bg));
|
||||||
|
box-shadow: 0 14px 36px oklch(0.1 0.015 145 / 0.065);
|
||||||
|
transition: background 300ms ease, box-shadow 500ms ease, transform 500ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block:nth-child(even) {
|
||||||
|
margin-left: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block:nth-child(3n) {
|
||||||
|
margin-right: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block:hover,
|
||||||
|
.client-instruction-block.is-open {
|
||||||
|
background: color-mix(in oklch, var(--client-panel) 68%, var(--client-bg));
|
||||||
|
box-shadow: 0 20px 48px oklch(0.1 0.015 145 / 0.095);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block.is-open {
|
||||||
|
animation: client-instruction-promote 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes client-instruction-promote {
|
||||||
|
from { opacity: 0.72; transform: translateY(10px); }
|
||||||
|
to { opacity: 1; transform: translateY(-2px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-old(root),
|
||||||
|
::view-transition-new(root) {
|
||||||
|
animation: none;
|
||||||
|
mix-blend-mode: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
::view-transition-group(instruction-proxybridge),
|
||||||
|
::view-transition-group(instruction-switchyomega),
|
||||||
|
::view-transition-group(instruction-vscode),
|
||||||
|
::view-transition-group(instruction-router) {
|
||||||
|
animation-duration: 560ms;
|
||||||
|
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-summary {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
display: grid;
|
||||||
|
gap: 7px;
|
||||||
|
padding: 21px 46px 21px 22px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-summary:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block:has(.client-instruction-summary:focus-visible) {
|
||||||
|
background: color-mix(in oklch, var(--client-panel) 72%, var(--client-bg));
|
||||||
|
box-shadow: 0 20px 48px oklch(0.1 0.015 145 / 0.11);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-summary > i {
|
||||||
|
position: absolute;
|
||||||
|
top: 27px;
|
||||||
|
right: 20px;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-summary > i::before,
|
||||||
|
.client-instruction-summary > i::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 5px;
|
||||||
|
left: 0;
|
||||||
|
width: 12px;
|
||||||
|
height: 1px;
|
||||||
|
background: var(--client-muted);
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 520ms cubic-bezier(0.16, 1, 0.3, 1), opacity 360ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-summary > i::after {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block.is-open .client-instruction-summary > i::after {
|
||||||
|
opacity: 0;
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block.is-open .client-instruction-summary > i::before {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-summary strong {
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-summary small {
|
||||||
|
max-width: 54ch;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-reveal {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
opacity: 0;
|
||||||
|
filter: blur(4px);
|
||||||
|
transition: grid-template-rows 600ms cubic-bezier(0.16, 1, 0.3, 1), opacity 420ms ease, filter 500ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block.is-open .client-instruction-reveal {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
opacity: 1;
|
||||||
|
filter: blur(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-body {
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0 22px 24px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-body p,
|
||||||
|
.client-instruction-body ol {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-body ol {
|
||||||
|
display: grid;
|
||||||
|
gap: 9px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-body li::marker {
|
||||||
|
color: var(--client-accent);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-body code {
|
||||||
|
display: block;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in oklch, var(--client-control) 84%, transparent);
|
||||||
|
color: var(--client-text);
|
||||||
|
font: 500 11px/1.5 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-body .client-instruction-note {
|
||||||
|
padding: 11px 13px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: color-mix(in oklch, var(--client-control) 48%, transparent);
|
||||||
|
color: var(--client-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-body a {
|
||||||
|
width: fit-content;
|
||||||
|
color: var(--client-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-body a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-panel {
|
.client-panel {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: grid;
|
display: grid;
|
||||||
@@ -899,6 +1246,22 @@ p {
|
|||||||
padding: 20px 6px;
|
padding: 20px 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-instructions-toggle {
|
||||||
|
left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-sheet {
|
||||||
|
padding: 40px 18px 60px 58px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instructions-header h2 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-instruction-block:nth-child(n) {
|
||||||
|
margin-inline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.client-form {
|
.client-form {
|
||||||
position: static;
|
position: static;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -927,6 +1290,15 @@ p {
|
|||||||
.client-access-tab,
|
.client-access-tab,
|
||||||
.client-access-point,
|
.client-access-point,
|
||||||
.client-copy-feedback,
|
.client-copy-feedback,
|
||||||
|
.client-instructions,
|
||||||
|
.client-instructions-toggle,
|
||||||
|
.client-instructions-toggle svg,
|
||||||
|
.client-instructions-toggle path,
|
||||||
|
.client-instructions-toggle span,
|
||||||
|
.client-instruction-block,
|
||||||
|
.client-instruction-reveal,
|
||||||
|
.client-instruction-summary > i::before,
|
||||||
|
.client-instruction-summary > i::after,
|
||||||
.client-subscription-edit,
|
.client-subscription-edit,
|
||||||
.client-subscription-edit::after,
|
.client-subscription-edit::after,
|
||||||
.client-subscription-summary,
|
.client-subscription-summary,
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
|
|
||||||
import { parseSubscriptionBody } from '../../src/server/subscription.js';
|
import {
|
||||||
|
parseSubscriptionBody,
|
||||||
|
selectRefreshedServer,
|
||||||
|
} from '../../src/server/subscription.js';
|
||||||
|
|
||||||
test('subscription server tags are trimmed for selection', () => {
|
test('subscription server tags are trimmed for selection', () => {
|
||||||
const { servers } = parseSubscriptionBody(JSON.stringify({
|
const { servers } = parseSubscriptionBody(JSON.stringify({
|
||||||
@@ -10,3 +13,19 @@ test('subscription server tags are trimmed for selection', () => {
|
|||||||
|
|
||||||
assert.equal(servers[0].tag, 'de-frankfurt');
|
assert.equal(servers[0].tag, 'de-frankfurt');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('refreshed subscription keeps the selected server when its tag still exists', () => {
|
||||||
|
const servers = [{ tag: 'de' }, { tag: 'nl' }];
|
||||||
|
|
||||||
|
assert.equal(selectRefreshedServer('nl', servers), 'nl');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshed subscription selects the first server when the old tag disappeared', () => {
|
||||||
|
const servers = [{ tag: 'de' }, { tag: 'nl' }];
|
||||||
|
|
||||||
|
assert.equal(selectRefreshedServer('old-name', servers), 'de');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('refreshed subscription does not select a server before the first user choice', () => {
|
||||||
|
assert.equal(selectRefreshedServer('', [{ tag: 'de' }]), '');
|
||||||
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
subscriptionDaysLeft,
|
subscriptionDaysLeft,
|
||||||
subscriptionUsage,
|
subscriptionUsage,
|
||||||
} from '../../src/web/utils/clientControls.js';
|
} from '../../src/web/utils/clientControls.js';
|
||||||
|
import { instructionBlocks } from '../../src/web/instructions.js';
|
||||||
|
|
||||||
test('connection button chooses the only valid client action', () => {
|
test('connection button chooses the only valid client action', () => {
|
||||||
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
|
assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' });
|
||||||
@@ -92,3 +93,12 @@ test('subscription expiry uses Russian day forms', () => {
|
|||||||
assert.equal(subscriptionDaysLeft(new Date('2026-07-13T00:00:00Z'), now), 'осталось 2 дня');
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-13T00:00:00Z'), now), 'осталось 2 дня');
|
||||||
assert.equal(subscriptionDaysLeft(new Date('2026-07-16T00:00:00Z'), now), 'осталось 5 дней');
|
assert.equal(subscriptionDaysLeft(new Date('2026-07-16T00:00:00Z'), now), 'осталось 5 дней');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('gateway receives router instructions while local client does not', () => {
|
||||||
|
const gateway = instructionBlocks({ isGateway: true, host: '192.168.1.20', port: 8080 });
|
||||||
|
const client = instructionBlocks({ isGateway: false, host: '127.0.0.1', port: 8082 });
|
||||||
|
|
||||||
|
assert.equal(gateway.at(-1).id, 'router');
|
||||||
|
assert.equal(client.some((block) => block.id === 'router'), false);
|
||||||
|
assert.match(client.find((block) => block.id === 'vscode').code, /127\.0\.0\.1:8082/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user