diff --git a/.env.example b/.env.example index 9edc26c..7beed9e 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,8 @@ PORT=3456 APP_MODE=gateway CLIENT_UI_PORT=3456 -CLIENT_PROXY_PORT_START=8080 -CLIENT_PROXY_PORT_END=8090 +CLIENT_PROXY_PORT_START=8082 +CLIENT_PROXY_PORT_END=8082 BASE_IMAGE=debian:bookworm-slim SINGBOX_VERSION=1.12.13 INSTALL_RUNTIME_DEPS=true diff --git a/Dockerfile.client b/Dockerfile.client index fc78c8e..e0525ec 100644 --- a/Dockerfile.client +++ b/Dockerfile.client @@ -40,7 +40,7 @@ RUN chmod +x /entrypoint.client.sh \ ENV APP_MODE=client \ PORT=3456 \ - PROXY_PORT=8080 \ + PROXY_PORT=8082 \ PROXY_BIND_IP=0.0.0.0 \ DATA_DIR=/var/lib/vpn-proxy \ SING_BOX_CONFIG=/etc/sing-box/config.json \ @@ -49,6 +49,6 @@ ENV APP_MODE=client \ ROUTING_RU_DIRECT=true \ LOG_LEVEL=info -EXPOSE 3456 8080 +EXPOSE 3456 8082 ENTRYPOINT ["dumb-init", "/entrypoint.client.sh"] diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 0000000..1c594f2 --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,35 @@ +# Product + +## Register + +product + +## Users + +macOS users without networking or Docker expertise. They open the client only to add a VPN subscription, choose a server, and turn the connection on or off. + +## Product Purpose + +Provide a small, dependable control surface for the Docker-based macOS VPN client. Success means the current connection state is obvious and the full everyday workflow fits on one screen. + +## Brand Personality + +Soft, calm, precise. Familiar to macOS users, with sharper geometry and a quiet monospace character. + +## Anti-references + +Not an admin dashboard, network console, settings maze, or enclosing card. Avoid sidebars, technical route diagrams, framed content areas, decorative effects, and controls for gateway or server administration. + +## Design Principles + +- One screen, one primary action. +- Use plain language and hide implementation details. +- Make connection state unmistakable without relying on color alone. +- Prefer native controls and predictable macOS behavior. +- Show saved subscriptions as a domain, not as a credential-like URL. +- Make servers directly selectable instead of hiding them in a dropdown. +- Keep advanced and server-only features out of the client path. + +## Accessibility & Inclusion + +Support keyboard navigation, visible focus, sufficient contrast, system light and dark themes, and reduced motion preferences. diff --git a/README.md b/README.md index 167f451..9d9e42f 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ curl -fsSL https://git.dokops.ru/dokril/vpn-proxy/raw/branch/master/scripts/inst После запуска по умолчанию: - UI: `http://127.0.0.1:3456` -- HTTP/SOCKS proxy: `127.0.0.1:8080` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его +- HTTP/SOCKS proxy: `127.0.0.1:8082` по умолчанию; установщик интерактивно спросит proxy-порт и опубликует только его Установщик интерактивно спросит proxy-порт. Если стандартный UI-порт `3456` занят другим контейнером, установщик попросит выбрать свободный UI-порт. Для неинтерактивного запуска можно задать порты заранее; тогда вопросы не появятся: @@ -393,8 +393,8 @@ UI доступен на `http://:3456`. | `CLIENT_UI_PORT` | `3456` | Host-порт UI для `docker-compose.client.yml` | | `VPN_PROXY_CLIENT_UI_PORT` | unset | UI-порт для macOS installer; записывается в `CLIENT_UI_PORT` | | `VPN_PROXY_CLIENT_PORT` | unset | Proxy-порт для macOS installer; записывает `CLIENT_PROXY_PORT` и single-port `CLIENT_PROXY_PORT_START/END` | -| `CLIENT_PROXY_PORT` | `8080` | Единственный host/container proxy-порт для `docker-compose.client.yml` | -| `CLIENT_PROXY_PORT_START` | `8080` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом | +| `CLIENT_PROXY_PORT` | `8082` | Единственный host/container proxy-порт для `docker-compose.client.yml` | +| `CLIENT_PROXY_PORT_START` | `8082` | Совместимость со старым env; в client compose считается тем же одиночным proxy-портом | | `CLIENT_PROXY_PORT_END` | same as start | Совместимость со старым env; по умолчанию не расширяет Docker-публикацию в диапазон | | `SHARED_PROXY_HOST` | unset | Явный host/IP, который gateway отдаёт в `/api/shared-proxy`; если не задан, берётся Host заголовок запроса | | `PORT` | `3456` | Порт веб-интерфейса | diff --git a/docker-compose.client.yml b/docker-compose.client.yml index 55a07e9..6aceb20 100644 --- a/docker-compose.client.yml +++ b/docker-compose.client.yml @@ -9,9 +9,9 @@ services: environment: APP_MODE: client PORT: ${PORT:-3456} - PROXY_PORT: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}} - CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}} - CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}} + PROXY_PORT: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}} + CLIENT_PROXY_PORT_START: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}} + CLIENT_PROXY_PORT_END: ${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}} PROXY_BIND_IP: 0.0.0.0 DATA_DIR: /var/lib/vpn-proxy SING_BOX_CONFIG: /etc/sing-box/config.json @@ -29,7 +29,7 @@ services: no_proxy: "localhost,127.0.0.1,host.docker.internal" ports: - "127.0.0.1:${CLIENT_UI_PORT:-3456}:${PORT:-3456}" - - "127.0.0.1:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8080}}" + - "127.0.0.1:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}:${CLIENT_PROXY_PORT:-${CLIENT_PROXY_PORT_START:-8082}}" volumes: - vpn-proxy-client-data:/var/lib/vpn-proxy - sing-box-client-cache:/var/lib/sing-box diff --git a/entrypoint.client.sh b/entrypoint.client.sh index bde2b06..fbccb43 100755 --- a/entrypoint.client.sh +++ b/entrypoint.client.sh @@ -2,7 +2,7 @@ set -euo pipefail PORT="${PORT:-3456}" -PROXY_PORT="${PROXY_PORT:-8080}" +PROXY_PORT="${PROXY_PORT:-8082}" DATA_DIR="${DATA_DIR:-/var/lib/vpn-proxy}" SING_BOX_CONFIG="${SING_BOX_CONFIG:-/etc/sing-box/config.json}" SING_BOX_CACHE="${SING_BOX_CACHE:-/var/lib/sing-box/cache.db}" diff --git a/index.html b/index.html index c596d96..7de9e6e 100644 --- a/index.html +++ b/index.html @@ -3,7 +3,7 @@ - VPN Proxy Gateway + VPN
diff --git a/scripts/install-macos-client.sh b/scripts/install-macos-client.sh index 0e73a56..fd42fdf 100755 --- a/scripts/install-macos-client.sh +++ b/scripts/install-macos-client.sh @@ -5,7 +5,7 @@ INSTALL_DIR="${VPN_PROXY_INSTALL_DIR:-$HOME/.vpn-proxy-client}" REPO_URL="${VPN_PROXY_REPO_URL:-https://git.dokops.ru/dokril/vpn-proxy.git}" BRANCH="${VPN_PROXY_BRANCH:-master}" COMPOSE_FILE="docker-compose.client.yml" -DEFAULT_PROXY_PORT="8080" +DEFAULT_PROXY_PORT="8082" REQUESTED_PROXY_PORT="${VPN_PROXY_CLIENT_PORT:-}" REQUESTED_UI_PORT="${VPN_PROXY_CLIENT_UI_PORT:-${CLIENT_UI_PORT:-}}" CLIENT_CONTAINER_NAME="vpn-proxy-client" diff --git a/src/server/config.js b/src/server/config.js index c9fcf96..b4cb88b 100644 --- a/src/server/config.js +++ b/src/server/config.js @@ -5,7 +5,10 @@ const parsePort = (value, fallback) => { const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) ? parsed : fallback; }; -const proxyPort = parsePort(process.env.PROXY_PORT, 8080); +const proxyPort = parsePort( + process.env.PROXY_PORT, + process.env.APP_MODE === "client" ? 8082 : 8080, +); const clientProxyPortStart = parsePort( process.env.CLIENT_PROXY_PORT_START, proxyPort, diff --git a/src/server/index.js b/src/server/index.js index 5ecdc95..c378869 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -607,7 +607,7 @@ function publicState() { const customRules = readJson(settings.customRulesPath, []); const deviceProfiles = readDeviceProfiles(); const clientSettings = readClientSettings(); - const { subscriptionUrl, ...rest } = state; + const { subscriptionUrl, servers = [], ...rest } = state; return { mode: settings.appMode, port: settings.port, @@ -640,6 +640,10 @@ function publicState() { directBypassAvailable: IPSET_AVAILABLE, sourceBypassCidrs: sourceBypassCidrs(deviceProfiles), ...rest, + servers: servers.map((server) => ({ + ...server, + tag: String(server.tag || '').trim(), + })), }; } @@ -1324,7 +1328,7 @@ async function handleApi(req, res) { servers.map(async (server) => { const ping = await tcpPing(server.server, server.server_port, 3000); return { - tag: server.tag, + tag: String(server.tag || '').trim(), ...ping, checkedAt: new Date().toISOString(), }; diff --git a/src/server/subscription.js b/src/server/subscription.js index 9d27053..39621df 100644 --- a/src/server/subscription.js +++ b/src/server/subscription.js @@ -123,7 +123,7 @@ export function parseSubscriptionBody(body) { const servers = outbounds .filter((outbound) => PROXY_TYPES.has(outbound.type)) .map((outbound) => ({ - tag: outbound.tag || `${outbound.type}-${outbound.server || 'server'}`, + tag: String(outbound.tag || `${outbound.type}-${outbound.server || 'server'}`).trim(), type: outbound.type, server: outbound.server || 'unknown', server_port: outbound.server_port || 443, diff --git a/src/web/App.jsx b/src/web/App.jsx index 16369ea..9b5670c 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -33,10 +33,6 @@ function App() { proxyDefaultMode: 'vpn', devices: [], }); - const [clientSettings, setClientSettings] = useState({ - homeBypassEnabled: false, - sharedProxyEnabled: false, - }); const [selectedTag, setSelectedTag] = useState(''); const [pendingTag, setPendingTag] = useState(''); const [busy, setBusy] = useState(false); @@ -82,7 +78,6 @@ function App() { proxyDefaultMode: 'vpn', devices: data.devices || [], }); - setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false }); setSelectedTag((prev) => prev || data.selectedTag || ''); setPendingTag((prev) => prev || data.selectedTag || ''); } @@ -109,7 +104,9 @@ function App() { setError(''); try { const result = await fn(); - if (!quiet && label) pushToast({ kind: 'success', title: label }); + if (!quiet && label && state?.mode !== 'client') { + pushToast({ kind: 'success', title: label }); + } return result; } catch (err) { setError(err.message); @@ -125,9 +122,12 @@ function App() { return withBusy('Подписка обновлена', async () => { const data = await api.subscription.fetch(subscriptionUrl || state?.subscriptionHost || ''); setServers(data.servers || []); - if (!selectedTag && data.servers?.length) { - setSelectedTag(data.servers[0].tag); - setPendingTag(data.servers[0].tag); + if (data.servers?.length) { + const nextTag = data.servers.some((server) => server.tag === selectedTag) + ? selectedTag + : data.servers[0].tag; + setSelectedTag(nextTag); + setPendingTag(nextTag); } await loadState(); }); @@ -158,7 +158,7 @@ function App() { }); setApplyStatus('idle'); - if (previous && previous !== target) { + if (state?.mode !== 'client' && previous && previous !== target) { setRollbackOffer({ from: target, to: previous, expiresAt: Date.now() + ROLLBACK_WINDOW_MS }); if (rollbackTimerRef.current) clearTimeout(rollbackTimerRef.current); rollbackTimerRef.current = setTimeout(() => setRollbackOffer(null), ROLLBACK_WINDOW_MS); @@ -180,8 +180,8 @@ function App() { } // === sing-box control === - async function stopSingbox() { - if (!confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return; + async function stopSingbox(confirmFirst = true) { + if (confirmFirst && !confirm('Остановить sing-box? Трафик через шлюз перестанет ходить.')) return; return withBusy('Остановлено', async () => { await api.singbox.stop(); await loadState(); }); } async function restartSingbox() { @@ -278,22 +278,6 @@ function App() { saveDevicesConfig(nextConfig); } - async function saveClientSettings(nextSettings) { - return withBusy(null, async () => { - const data = await api.clientSettings.save(nextSettings); - setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false }); - await loadState(); - }, { quiet: true }); - } - - async function checkSharedProxy(url) { - return withBusy('Общий proxy подключён', async () => { - const data = await api.clientSettings.checkSharedProxy(url); - setClientSettings(data.clientSettings || { homeBypassEnabled: false, sharedProxyEnabled: false }); - await loadState(); - }); - } - // === Rules CRUD === function emptyRule() { return { @@ -410,16 +394,20 @@ function App() { }; // === Render === + if (!state) return
VPN
; + return ( -
- +
+ {!isClientMode && ( + + )}
{!isClientMode && } @@ -437,13 +425,10 @@ function App() { servers={servers} pendingTag={pendingTag} setPendingTag={setPendingTag} - clientSettings={clientSettings} - onSaveClientSettings={saveClientSettings} - onCheckSharedProxy={checkSharedProxy} onFetchSubscription={fetchSubscription} onApply={applyServer} onRestart={restartSingbox} - onStop={stopSingbox} + onStop={() => stopSingbox(false)} /> ) : ( request("/api/client-settings"), - save: (clientSettings) => - request("/api/client-settings", { - method: "PUT", - body: JSON.stringify({ clientSettings }), - }), - checkSharedProxy: (url) => - request("/api/client-settings/shared-proxy/check", { - method: "POST", - body: JSON.stringify({ url }), - }), - }, - ruleSets: { get: () => request("/api/rule-sets"), save: (ruleSets) => diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 440daed..b55ba72 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -1,368 +1,235 @@ -import React, { useEffect, useMemo, useState } from 'react'; -import { flagFor } from '../utils/country.js'; -import { formatRelative } from '../utils/format.js'; -import { resolveClientRoute } from '../utils/clientRoute.js'; - -function CopyValue({ value }) { - const [copied, setCopied] = useState(false); - - async function copy() { - await navigator.clipboard.writeText(value); - setCopied(true); - setTimeout(() => setCopied(false), 1200); - } - - return ( - - ); -} - -function StatusPanel({ route, state }) { - const statusLabel = { - connected: 'Работает', - stopped: 'Остановлен', - empty: 'Не настроен', - }[route.status]; - - return ( -
-
- -
-
Текущий маршрут
-

{route.title}

-

{route.description}

-
-
-
-
- Куда - {route.target} - {route.targetDetail} -
-
- Локальный proxy - {route.localProxy} - HTTP и SOCKS5 -
-
- Сервис - {statusLabel} - {state?.appliedAt ? `применено ${formatRelative(state.appliedAt)}` : 'нет примененного config'} -
-
-
- ); -} - -function RouteLine({ route }) { - return ( -
- {route.path.map((item, index) => ( - - {item} - {index < route.path.length - 1 && } - - ))} -
- ); -} - -function ModeButton({ active, selected, title, subtitle, onClick, disabled }) { - return ( - - ); -} - -function GatewaySettings({ settings, busy, onCheck }) { - const [draftUrl, setDraftUrl] = useState(settings?.sharedProxyControlUrl || ''); - const sharedProxy = settings?.sharedProxy; - - useEffect(() => { - setDraftUrl(settings?.sharedProxyControlUrl || ''); - }, [settings?.sharedProxyControlUrl]); - - return ( -
-
- -
- setDraftUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)} - /> - -
-
- {sharedProxy && ( -
- Найден общий proxy - {sharedProxy.host}:{sharedProxy.port} -
- )} -
- ); -} - -function VpnSettings({ - state, - servers, - subscriptionUrl, - setSubscriptionUrl, - pendingTag, - setPendingTag, - busy, - onFetchSubscription, - onApply, -}) { - const selected = pendingTag || state?.selectedTag || ''; - const activeServer = servers.find((server) => server.tag === selected); - - return ( -
-
- -
- setSubscriptionUrl(e.target.value)} - onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()} - /> - -
-
-
- -
- - -
- {activeServer && Выбран {flagFor(activeServer)} {activeServer.tag}} -
-
- ); -} - -function DirectSettings({ busy, onEnable }) { - return ( -
-
- Прямой режим -

Приложения продолжают использовать локальный proxy, но трафик идет без VPN и без gateway.

-
- -
- ); -} - -function ProxySettings({ state, settings, busy, onSave }) { - const range = state?.clientProxyPortRange || { start: 8080, end: 8090 }; - const port = settings?.proxyPort || state?.proxyPort || 8080; - const [draftPort, setDraftPort] = useState(String(port)); - - useEffect(() => { - setDraftPort(String(port)); - }, [port]); - - const parsed = Number.parseInt(draftPort, 10); - const invalid = !Number.isInteger(parsed) || parsed < range.start || parsed > range.end; - const dirty = !invalid && parsed !== port; - const singlePublishedPort = range.start === range.end; - - return ( - - ); -} +import React, { useEffect, useRef, useState } from 'react'; +import { api } from '../api.js'; +import { + connectionAction, + formatConnectionDuration, + localProxyUrls, + subscriptionDomain, +} from '../utils/clientControls.js'; export function ClientOverviewPage({ state, - activeServer, busy, subscriptionUrl, setSubscriptionUrl, servers, pendingTag, setPendingTag, - clientSettings, - onSaveClientSettings, - onCheckSharedProxy, onFetchSubscription, onApply, + onRestart, + onStop, }) { - const route = useMemo( - () => resolveClientRoute({ state, activeServer }), - [state, activeServer], - ); - const [setupMode, setSetupMode] = useState(route.mode === 'none' ? 'gateway' : route.mode); + const connected = Boolean(state?.singboxRunning); + const selectedTag = pendingTag || state?.selectedTag || ''; + const canStart = Boolean(selectedTag || state?.configExists); + const [now, setNow] = useState(Date.now()); + const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription); + const [pings, setPings] = useState({}); + const [copiedProxy, setCopiedProxy] = useState(''); + const subscriptionInputRef = useRef(null); + const serverKey = servers.map((server) => `${server.tag}:${server.server}:${server.server_port}`).join('|'); + const proxyUrls = localProxyUrls(state?.proxyPort); useEffect(() => { - if (route.mode !== 'none') setSetupMode(route.mode); - }, [route.mode]); + setNow(Date.now()); + if (!connected || !state?.singboxStartedAt) return undefined; - function enableDirect() { - return onSaveClientSettings({ - ...clientSettings, - homeBypassEnabled: true, - sharedProxyEnabled: false, - }); + const timer = setInterval(() => setNow(Date.now()), 1000); + return () => clearInterval(timer); + }, [connected, state?.singboxStartedAt]); + + useEffect(() => { + if (!servers.length) { + setPings({}); + return undefined; + } + + let cancelled = false; + setPings(Object.fromEntries(servers.map((server) => [server.tag, { checking: true }]))); + api.servers.pingAll() + .then((data) => { + if (cancelled) return; + setPings(Object.fromEntries((data.results || []).map((ping) => [ + String(ping.tag || '').trim(), + ping, + ]))); + }) + .catch(() => { + if (!cancelled) { + setPings(Object.fromEntries(servers.map((server) => [server.tag, { ok: false }]))); + } + }); + + return () => { cancelled = true; }; + }, [serverKey]); + + useEffect(() => { + if (editingSubscription) subscriptionInputRef.current?.focus(); + }, [editingSubscription]); + + useEffect(() => { + if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined; + const timer = setTimeout(() => setEditingSubscription(false), 5000); + return () => clearTimeout(timer); + }, [editingSubscription, state?.hasSubscription, subscriptionUrl]); + + async function toggleConnection() { + const action = connectionAction({ connected, selectedTag, configExists: state?.configExists }); + if (action?.type === 'stop') return onStop(); + if (action?.type === 'apply') return onApply(action.selectedTag); + if (action?.type === 'restart') return onRestart(); } - function selectGateway() { - setSetupMode('gateway'); - if (clientSettings?.sharedProxyControlUrl) { - return onCheckSharedProxy(clientSettings.sharedProxyControlUrl); - } - return null; + function selectServer(tag) { + setPendingTag(tag); + if (connected && tag) onApply(tag); } - function selectVpn() { - setSetupMode('vpn'); - if (state?.selectedTag) { - return onApply(state.selectedTag); + async function submitSubscription(event) { + event.preventDefault(); + if (!subscriptionUrl.trim()) return; + await onFetchSubscription(); + setSubscriptionUrl(''); + setEditingSubscription(false); + } + + async function copyProxy(kind) { + try { + await navigator.clipboard.writeText(proxyUrls[kind]); + setCopiedProxy(kind); + setTimeout(() => setCopiedProxy(''), 1400); + } catch { + setCopiedProxy('error'); + setTimeout(() => setCopiedProxy(''), 1400); } - return onSaveClientSettings({ - ...clientSettings, - homeBypassEnabled: false, - sharedProxyEnabled: false, - }); } return ( -
- - +
+
+
+ +
+

{connected ? 'VPN включён' : 'VPN выключен'}

+ {connected && ( + + )} + {!connected && ( +

{canStart ? 'Нажмите, чтобы подключиться' : 'Добавьте ссылку и выберите сервер'}

+ )} +
+
-
-
-
- - - { - setSetupMode('direct'); - enableDirect(); - }} - /> +
+
+ + +
+ setSubscriptionUrl(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape' && state?.hasSubscription) { + setSubscriptionUrl(''); + setEditingSubscription(false); + } + }} + /> + {subscriptionUrl.trim() && ( + + )} +
- {setupMode === 'gateway' && ( - - )} - {setupMode === 'vpn' && ( - - )} - {setupMode === 'direct' && ( - - )} -
+
+
+ {servers.map((server) => { + const ping = pings[server.tag]; + const selected = server.tag === selectedTag; + const pingText = ping?.checking + ? 'Проверка…' + : ping?.ok ? `${ping.latency} ms` : 'Недоступен'; + const pingClass = ping?.ok + ? ping.latency < 100 ? 'good' : ping.latency < 250 ? 'medium' : 'slow' + : ''; - + return ( + + ); + })} +
+
+
+
+ +
+ {[ + ['socks5', 'SOCKS5'], + ['http', 'HTTP'], + ].map(([kind, label]) => ( + + ))}
); diff --git a/src/web/styles.css b/src/web/styles.css index 0e88c78..69921b4 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -63,6 +63,16 @@ html, body, #root { height: 100%; } +.app-loading { + min-height: 100%; + display: grid; + place-items: center; + font: 700 16px/1 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; + color: light-dark(oklch(0.42 0.01 145), oklch(0.76 0.01 145)); + background: light-dark(oklch(0.965 0.006 145), oklch(0.18 0.012 145)); + color-scheme: light dark; +} + body { margin: 0; font-family: var(--font-ui); @@ -830,269 +840,418 @@ code, .mono { /* ============ Client overview ============ */ +.app.client-app { + --client-bg: oklch(0.965 0.006 145); + --client-panel: oklch(0.995 0.003 145); + --client-control: oklch(0.955 0.006 145); + --client-border: oklch(0.86 0.012 145); + --client-text: oklch(0.24 0.014 145); + --client-muted: oklch(0.53 0.014 145); + --client-accent: oklch(0.62 0.14 151); + --client-accent-soft: oklch(0.92 0.045 151); + grid-template-rows: 1fr; + color-scheme: light; + background: var(--client-bg); +} + +.app-body.client-mode { + background: var(--client-bg); +} + .client-mode .app-main { - max-width: 1180px; - width: 100%; - margin: 0 auto; - padding-top: 18px; + display: grid; + place-items: center; + padding: 48px 24px 32px; + overflow-y: auto; } -.client-dashboard { +.client-shell { + width: min(100%, 440px); display: grid; gap: 12px; + font-family: 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; + color: var(--client-text); } -.client-status-panel { +.client-panel { display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(420px, 0.8fr); - gap: 16px; - padding: 18px; - background: #101820; - border: 1px solid #263442; - border-radius: 8px; + gap: 30px; + padding: 24px 0; } -.client-status-panel.connected { border-color: rgba(109, 255, 157, 0.46); } -.client-status-panel.stopped { border-color: rgba(255, 209, 102, 0.42); } -.client-status-panel.empty { border-color: rgba(142, 212, 255, 0.32); } -.client-status-main { - min-width: 0; - display: flex; - align-items: flex-start; - gap: 14px; +.client-power-section p { + color: var(--client-muted); + font-size: 11px; } -.client-status-dot { - width: 12px; - height: 12px; - margin-top: 9px; + +.client-power-section { + display: grid; + justify-items: center; + gap: 18px; + text-align: center; +} + +.client-power { + width: 96px; + height: 96px; + display: grid; + place-items: center; + border: 1px solid var(--client-border); border-radius: 50%; - background: var(--subtle); - box-shadow: 0 0 0 6px rgba(111, 140, 124, 0.12); - flex: 0 0 12px; -} -.client-status-dot.connected { - background: var(--success); - box-shadow: 0 0 0 6px rgba(109, 255, 157, 0.12); -} -.client-status-dot.stopped { - background: var(--warning); - box-shadow: 0 0 0 6px rgba(255, 209, 102, 0.12); -} -.client-status-dot.empty { - background: var(--info); - box-shadow: 0 0 0 6px rgba(142, 212, 255, 0.12); -} -.client-eyebrow { - color: var(--subtle); - font-size: 11px; - font-weight: 700; - text-transform: uppercase; -} -.client-status-main h1 { - margin: 2px 0 4px; - font-size: 30px; - line-height: 1.08; - letter-spacing: 0; -} -.client-status-main p { - margin: 0; - color: var(--muted); + background: var(--client-control); + color: var(--client-muted); + cursor: pointer; + transition: color 180ms ease, background 180ms ease, border-color 180ms ease, transform 180ms ease; } -.client-status-facts { +.client-power:hover:not(:disabled) { + transform: translateY(-2px); + color: var(--client-text); + border-color: var(--client-muted); +} + +.client-power[aria-checked='true'] { + color: var(--client-accent); + background: var(--client-accent-soft); + border-color: var(--client-accent); +} + +.client-power:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +.client-power svg { + width: 40px; + height: 40px; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; +} + +.client-power-section h2 { + font: 700 18px/1.3 'JetBrains Mono', 'SF Mono', ui-monospace, Menlo, monospace; + letter-spacing: -0.04em; +} + +.client-duration { + display: block; + margin-top: 8px; + color: var(--client-text); + font-size: 14px; + font-variant-numeric: tabular-nums; + letter-spacing: 0.04em; +} + +.client-power-section p { + min-height: 17px; + margin-top: 5px; +} + +.client-form { display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 10px; -} -.client-status-facts > div { - min-width: 0; - padding: 12px; - background: #0b1219; - border: 1px solid #253341; - border-radius: 8px; -} -.client-status-facts small, -.client-current-target small, -.client-panel-title { - display: block; - color: var(--subtle); - font-size: 11px; - font-weight: 700; - text-transform: uppercase; -} -.client-status-facts strong, -.client-current-target strong { - display: block; - margin: 3px 0; - overflow-wrap: anywhere; -} -.client-status-facts span { - color: var(--muted); - font-size: 12px; + gap: 36px; } -.client-route-line { - display: flex; +.client-subscription { + min-height: 88px; + display: grid; align-items: center; - gap: 8px; - padding: 10px 14px; - background: #0b1219; - border: 1px solid #253341; - border-radius: 8px; - color: var(--muted); - overflow-x: auto; - white-space: nowrap; -} -.client-route-line span { - color: var(--text); - font-family: var(--font-mono); - font-size: 12px; -} -.client-route-line b { - color: var(--subtle); - font-weight: 600; + justify-items: center; } -.client-workspace { - display: grid; - grid-template-columns: minmax(0, 1fr) 320px; - gap: 12px; - align-items: start; +.client-subscription-edit, +.client-subscription-summary { + grid-area: 1 / 1; + width: 100%; + border: 0; + background: transparent; + transition: opacity 650ms cubic-bezier(0.16, 1, 0.3, 1), filter 650ms cubic-bezier(0.16, 1, 0.3, 1), transform 650ms cubic-bezier(0.16, 1, 0.3, 1); } -.client-main-panel, -.client-side-panel { - background: #101820; - border: 1px solid #263442; - border-radius: 8px; - padding: 14px; + +.client-subscription-edit { + width: min(100%, 380px); + min-height: 44px; + display: block; + position: relative; + opacity: 0; + filter: blur(16px); + transform: translateY(6px) scale(0.96); + pointer-events: none; } -.client-main-panel { + +.client-subscription-edit::before, +.client-subscription-edit::after { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + height: 1px; + pointer-events: none; +} + +.client-subscription-edit::before { + background: var(--client-border); +} + +.client-subscription-edit::after { + background: var(--client-accent); + opacity: 0.72; + filter: blur(0.5px); + box-shadow: 0 0 8px var(--client-accent), 0 0 18px var(--client-accent-soft); +} + +.client-subscription.is-editing .client-subscription-edit { + opacity: 1; + filter: blur(0); + transform: translateY(0); + pointer-events: auto; +} + +.client-subscription-edit input { + width: 100%; + min-width: 0; + height: 44px; + padding: 0 42px; + border: 0; + outline: 0; + background: transparent; + color: var(--client-text); + caret-color: var(--client-accent); + font-size: 12px; + text-align: center; +} + +.client-subscription-edit input::placeholder { + color: var(--client-muted); +} + +.client-subscription-edit button { + position: absolute; + top: 4px; + right: 0; + width: 36px; + height: 36px; + padding: 0; + border: 0; + background: transparent; + color: var(--client-accent); + font-size: 19px; + font-weight: 700; + cursor: pointer; +} + +.client-subscription-summary { + min-height: 88px; display: flex; flex-direction: column; - gap: 14px; + align-items: center; + justify-content: center; + gap: 8px; + padding: 0 2px; + color: var(--client-muted); + text-align: center; + cursor: pointer; } -.client-mode-grid { + +.client-subscription.is-editing .client-subscription-summary { + opacity: 0; + filter: blur(18px); + transform: translateY(-4px) scale(1.06); + pointer-events: none; +} + +.client-subscription-summary span { + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.06em; +} + +.client-subscription-summary strong { + color: var(--client-text); + font-size: 28px; + font-weight: 600; + letter-spacing: -0.055em; + opacity: 0.88; + text-shadow: 0 0 20px oklch(0.62 0.05 151 / 0.24); + transition: opacity 300ms cubic-bezier(0.16, 1, 0.3, 1), text-shadow 300ms cubic-bezier(0.16, 1, 0.3, 1); +} + +.client-subscription-summary:hover strong { + opacity: 1; + text-shadow: 0 0 28px oklch(0.7 0.08 151 / 0.42); +} + +.client-servers { + display: block; +} + +.client-server-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } -.client-mode-button { + +.client-server { min-width: 0; - text-align: left; - padding: 12px; - border: 1px solid #2a3948; - border-radius: 8px; - background: #0b1219; - cursor: pointer; - transition: border-color 0.16s ease, background 0.16s ease; -} -.client-mode-button:hover:not(:disabled) { - border-color: #4c6d88; - background: #101c27; -} -.client-mode-button.selected { - border-color: var(--info); - background: rgba(142, 212, 255, 0.08); -} -.client-mode-button.active { - border-color: var(--success); - background: rgba(109, 255, 157, 0.11); -} -.client-mode-button strong, -.client-mode-button span { + min-height: 56px; display: flex; - min-width: 0; + flex-direction: column; + justify-content: center; + gap: 3px; + padding: 7px 4px 8px; + border: 0; + border-bottom: 1px solid var(--client-border); + border-radius: 0; + background: transparent; + color: var(--client-text); + text-align: center; + cursor: pointer; + transition: border-color 160ms ease, background 160ms ease, transform 160ms ease; +} + +.client-server:hover:not(:disabled) { + transform: none; + background: transparent; + opacity: 0.82; +} + +.client-server.is-selected { + border-bottom: 2px solid var(--client-accent); + background: transparent; +} + +.client-server strong { + display: block; overflow: hidden; text-overflow: ellipsis; - white-space: nowrap; -} -.client-mode-button strong { - font-size: 14px; -} -.client-mode-button span { - margin-top: 3px; - color: var(--muted); - font-size: 12px; -} - -.client-mode-settings { - display: grid; - gap: 12px; -} -.client-mode-settings.direct { - grid-template-columns: minmax(0, 1fr) auto; - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} -.client-mode-settings.direct p { - margin: 4px 0 0; -} -.client-inline-form { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; -} - -.client-current-target { - padding: 10px 12px; - background: #0b1219; - border: 1px solid #253341; - border-radius: 8px; -} - -.client-side-panel { - display: grid; - gap: 16px; -} -.client-copy-stack { - display: grid; - gap: 8px; - margin-top: 8px; -} -.client-copy { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - gap: 10px; - padding: 10px; - background: #0b1219; - border: 1px solid #253341; - border-radius: 8px; - cursor: pointer; -} -.client-copy span { - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono); - font-size: 12px; -} -.client-copy strong { - color: var(--accent); + overflow-wrap: anywhere; font-size: 11px; -} -.client-port-row { - display: grid; - grid-template-columns: minmax(0, 1fr) auto; - gap: 8px; + line-height: 1.4; + text-align: center; } -@media (max-width: 980px) { - .client-status-panel, - .client-workspace { +.client-server small { + color: var(--client-muted); + font-size: 11px; + text-align: center; +} + +.client-server small.good { color: var(--client-accent); } +.client-server small.medium { color: oklch(0.72 0.12 80); } +.client-server small.slow { color: oklch(0.65 0.16 30); } + +.client-server:disabled { + cursor: wait; +} + +.client-subscription-edit button:focus-visible, +.client-subscription-summary:focus-visible, +.client-server:focus-visible, +.client-power:focus-visible { + outline: 2px solid var(--client-accent); + outline-offset: 3px; +} + +.client-proxies { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 24px; + margin-top: 12px; +} + +.client-proxies button { + min-width: 0; + display: grid; + gap: 4px; + padding: 6px 2px; + border: 0; + background: transparent; + color: var(--client-text); + text-align: left; + cursor: pointer; + opacity: 0.76; + transition: opacity 180ms ease, transform 180ms ease; +} + +.client-proxies button:hover { + transform: translateY(-1px); + opacity: 1; +} + +.client-proxies button.is-copied { + opacity: 1; +} + +.client-proxies span { + color: var(--client-muted); + font-size: 9px; + font-weight: 700; + letter-spacing: 0.08em; +} + +.client-proxies strong { + overflow: hidden; + color: var(--client-text); + font-size: 12px; + font-weight: 500; + text-overflow: ellipsis; + text-shadow: 0 0 12px oklch(0.62 0.05 151 / 0.12); + white-space: nowrap; +} + +.client-proxies button.is-copied span { + color: var(--client-accent); +} + +.client-proxies button:focus-visible { + outline: 2px solid var(--client-accent); + outline-offset: 3px; +} + +@media (prefers-color-scheme: dark) { + .app.client-app { + --client-bg: oklch(0.18 0.012 145); + --client-panel: oklch(0.22 0.012 145); + --client-control: oklch(0.26 0.012 145); + --client-border: oklch(0.34 0.015 145); + --client-text: oklch(0.93 0.008 145); + --client-muted: oklch(0.68 0.012 145); + --client-accent: oklch(0.76 0.13 151); + --client-accent-soft: oklch(0.32 0.055 151); + color-scheme: dark; + } +} + +@media (max-width: 560px) { + .client-mode .app-main { + padding: 20px 14px; + } + + .client-panel { + gap: 26px; + padding: 20px 6px; + } + + .client-server-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .client-proxies { grid-template-columns: 1fr; } - .client-status-facts, - .client-mode-grid { - grid-template-columns: 1fr; - } - .client-mode-settings.direct, - .client-inline-form { - grid-template-columns: 1fr; +} + +@media (prefers-reduced-motion: reduce) { + .client-power, + .client-server, + .client-proxies button, + .client-subscription-edit, + .client-subscription-summary, + .client-subscription-summary strong { + transition: none; } } diff --git a/src/web/utils/clientControls.js b/src/web/utils/clientControls.js new file mode 100644 index 0000000..662e1ef --- /dev/null +++ b/src/web/utils/clientControls.js @@ -0,0 +1,31 @@ +export function connectionAction({ connected, selectedTag, configExists }) { + if (connected) return { type: 'stop' }; + if (selectedTag) return { type: 'apply', selectedTag }; + if (configExists) return { type: 'restart' }; + return null; +} + +export function formatConnectionDuration(startedAt, now = Date.now()) { + const started = Date.parse(startedAt); + const totalSeconds = Number.isFinite(started) + ? Math.max(0, Math.floor((now - started) / 1000)) + : 0; + const hours = Math.floor(totalSeconds / 3600); + const minutes = Math.floor((totalSeconds % 3600) / 60); + const seconds = totalSeconds % 60; + + return [hours, minutes, seconds] + .map((part) => String(part).padStart(2, '0')) + .join(':'); +} + +export function subscriptionDomain(subscriptionHost) { + return String(subscriptionHost || '').split('/')[0]; +} + +export function localProxyUrls(port = 8082) { + return { + socks5: `socks5://127.0.0.1:${port}`, + http: `http://127.0.0.1:${port}`, + }; +} diff --git a/src/web/utils/clientRoute.js b/src/web/utils/clientRoute.js deleted file mode 100644 index ca4eb5d..0000000 --- a/src/web/utils/clientRoute.js +++ /dev/null @@ -1,57 +0,0 @@ -export function resolveClientRoute({ state, activeServer } = {}) { - const settings = state?.clientSettings || {}; - const localProxy = `127.0.0.1:${state?.proxyPort || settings.proxyPort || 8080}`; - const running = Boolean(state?.singboxRunning); - const hasConfig = Boolean(state?.configExists); - - let mode = "none"; - let target = "выберите режим"; - let targetDetail = "Gateway, локальный VPN или напрямую"; - let title = "Не подключено"; - let description = "Выберите режим подключения и примените его."; - let pathTarget = "не выбран"; - - if (settings.sharedProxyEnabled && settings.sharedProxy) { - mode = "gateway"; - target = `${settings.sharedProxy.host}:${settings.sharedProxy.port}`; - targetDetail = "общий gateway proxy"; - title = running ? "Подключено к gateway" : "Gateway настроен, но остановлен"; - description = "Локальный proxy на Mac отправляет трафик на серверный gateway."; - pathTarget = `Gateway ${target}`; - } else if (settings.homeBypassEnabled) { - mode = "direct"; - target = "без VPN"; - targetDetail = "прямое подключение"; - title = running ? "Подключено напрямую" : "Direct настроен, но остановлен"; - description = "Приложения используют локальный proxy, но трафик идет напрямую."; - pathTarget = "Direct"; - } else if (state?.selectedTag) { - mode = "vpn"; - target = activeServer?.tag || state.selectedTag; - targetDetail = "локальный VPN"; - title = running ? "Подключено через VPN" : "VPN настроен, но остановлен"; - description = "Локальный proxy на Mac отправляет трафик через выбранный VPN-сервер."; - pathTarget = `VPN ${target}`; - } - - const status = running - ? "connected" - : hasConfig && mode !== "none" - ? "stopped" - : "empty"; - - if (status === "empty") { - title = "Не подключено"; - } - - return { - mode, - status, - localProxy, - title, - target, - targetDetail, - description, - path: ["Mac apps", localProxy, pathTarget, "Internet"], - }; -} diff --git a/test/server/client-config.test.js b/test/server/client-config.test.js index 49aa422..cd747ef 100644 --- a/test/server/client-config.test.js +++ b/test/server/client-config.test.js @@ -43,3 +43,23 @@ test("client proxy range defaults to the single configured proxy port", async () }, ); }); + +test("client proxy defaults to 8082 when no port is configured", async () => { + await withEnv( + { + APP_MODE: "client", + PROXY_PORT: undefined, + CLIENT_PROXY_PORT_START: undefined, + CLIENT_PROXY_PORT_END: undefined, + }, + async () => { + const { settings } = await import( + `../../src/server/config.js?client-default-port=${Date.now()}` + ); + + assert.equal(settings.proxyPort, 8082); + assert.equal(settings.clientProxyPortStart, 8082); + assert.equal(settings.clientProxyPortEnd, 8082); + }, + ); +}); diff --git a/test/server/singbox-client-mode.test.js b/test/server/singbox-client-mode.test.js index 1a1ff9d..8a012ec 100644 --- a/test/server/singbox-client-mode.test.js +++ b/test/server/singbox-client-mode.test.js @@ -36,7 +36,7 @@ test("client mode exposes only the local mixed proxy inbound", () => { ["mixed-in"], ); assert.equal(config.inbounds[0].type, "mixed"); - assert.equal(config.inbounds[0].listen_port, 8080); + assert.equal(config.inbounds[0].listen_port, 8082); }); test("client mode routes mixed proxy fallback to the selected VPN", () => { @@ -92,7 +92,7 @@ test("client mode ignores saved proxy port outside the published single port", ( const config = buildGatewayConfig(subscriptionConfig, "test-vpn"); - assert.equal(config.inbounds[0].listen_port, 8080); + assert.equal(config.inbounds[0].listen_port, 8082); assert.deepEqual(config.route.rules, [ { inbound: ["mixed-in"], outbound: "test-vpn" }, ]); diff --git a/test/server/subscription.test.js b/test/server/subscription.test.js new file mode 100644 index 0000000..9b6452e --- /dev/null +++ b/test/server/subscription.test.js @@ -0,0 +1,12 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { parseSubscriptionBody } 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'); +}); diff --git a/test/web/client-controls.test.js b/test/web/client-controls.test.js new file mode 100644 index 0000000..1ab43cd --- /dev/null +++ b/test/web/client-controls.test.js @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + connectionAction, + formatConnectionDuration, + localProxyUrls, + subscriptionDomain, +} from '../../src/web/utils/clientControls.js'; + +test('connection button chooses the only valid client action', () => { + assert.deepEqual(connectionAction({ connected: true }), { type: 'stop' }); + assert.deepEqual(connectionAction({ selectedTag: 'nl-amsterdam' }), { + type: 'apply', + selectedTag: 'nl-amsterdam', + }); + assert.deepEqual(connectionAction({ configExists: true }), { type: 'restart' }); + assert.equal(connectionAction({}), null); +}); + +test('connection duration is derived from the backend start time', () => { + const startedAt = '2026-07-10T10:00:00.000Z'; + const now = Date.parse('2026-07-11T12:03:04.900Z'); + + assert.equal(formatConnectionDuration(startedAt, now), '26:03:04'); + assert.equal(formatConnectionDuration(null, now), '00:00:00'); +}); + +test('saved subscription is reduced to its public domain', () => { + assert.equal(subscriptionDomain('sub.example.com/…'), 'sub.example.com'); + assert.equal(subscriptionDomain(''), ''); +}); + +test('local proxy exposes both supported URLs', () => { + assert.deepEqual(localProxyUrls(18080), { + socks5: 'socks5://127.0.0.1:18080', + http: 'http://127.0.0.1:18080', + }); +}); + +test('local proxy defaults to the macOS client port', () => { + assert.deepEqual(localProxyUrls(), { + socks5: 'socks5://127.0.0.1:8082', + http: 'http://127.0.0.1:8082', + }); +}); diff --git a/test/web/client-route.test.js b/test/web/client-route.test.js deleted file mode 100644 index 55c7205..0000000 --- a/test/web/client-route.test.js +++ /dev/null @@ -1,92 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { resolveClientRoute } from "../../src/web/utils/clientRoute.js"; - -test("shows gateway route as the active Mac connection", () => { - const route = resolveClientRoute({ - state: { - singboxRunning: true, - proxyPort: 18080, - clientSettings: { - sharedProxyEnabled: true, - sharedProxy: { host: "192.168.50.111", port: 8080, protocol: "socks5" }, - }, - }, - }); - - assert.equal(route.mode, "gateway"); - assert.equal(route.status, "connected"); - assert.equal(route.title, "Подключено к gateway"); - assert.equal(route.target, "192.168.50.111:8080"); - assert.deepEqual(route.path, [ - "Mac apps", - "127.0.0.1:18080", - "Gateway 192.168.50.111:8080", - "Internet", - ]); -}); - -test("shows local VPN route with selected server", () => { - const route = resolveClientRoute({ - state: { - singboxRunning: true, - proxyPort: 8082, - selectedTag: "nl-amsterdam", - clientSettings: {}, - }, - activeServer: { tag: "nl-amsterdam", country: "NL" }, - }); - - assert.equal(route.mode, "vpn"); - assert.equal(route.status, "connected"); - assert.equal(route.title, "Подключено через VPN"); - assert.equal(route.target, "nl-amsterdam"); -}); - -test("shows direct route when home mode is enabled", () => { - const route = resolveClientRoute({ - state: { - singboxRunning: true, - proxyPort: 8082, - clientSettings: { homeBypassEnabled: true }, - }, - }); - - assert.equal(route.mode, "direct"); - assert.equal(route.status, "connected"); - assert.equal(route.title, "Подключено напрямую"); - assert.equal(route.target, "без VPN"); -}); - -test("shows configured but stopped route clearly", () => { - const route = resolveClientRoute({ - state: { - singboxRunning: false, - configExists: true, - proxyPort: 8082, - selectedTag: "nl-amsterdam", - clientSettings: {}, - }, - }); - - assert.equal(route.mode, "vpn"); - assert.equal(route.status, "stopped"); - assert.equal(route.title, "VPN настроен, но остановлен"); -}); - -test("shows missing setup when nothing is configured", () => { - const route = resolveClientRoute({ - state: { - singboxRunning: false, - configExists: false, - proxyPort: 8082, - clientSettings: {}, - }, - }); - - assert.equal(route.mode, "none"); - assert.equal(route.status, "empty"); - assert.equal(route.title, "Не подключено"); - assert.equal(route.target, "выберите режим"); -});