Simplify proxy routing and configuration

This commit is contained in:
2026-07-11 04:42:16 +03:00
parent efa46d1ee5
commit d3b7f0d613
22 changed files with 786 additions and 787 deletions

View File

@@ -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

View File

@@ -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"]

35
PRODUCT.md Normal file
View File

@@ -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.

View File

@@ -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://<gateway-ip>: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` | Порт веб-интерфейса |

View File

@@ -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

View File

@@ -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}"

View File

@@ -3,7 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>VPN Proxy Gateway</title>
<title>VPN</title>
</head>
<body>
<div id="root"></div>

View File

@@ -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"

View File

@@ -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,

View File

@@ -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(),
};

View File

@@ -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,

View File

@@ -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 <div className="app-loading">VPN</div>;
return (
<div className="app">
<Topbar
state={state}
status={status}
activeServer={activeServer}
dirty={dirty}
onRestart={restartSingbox}
onTryApply={rollback}
/>
<div className={`app${isClientMode ? ' client-app' : ''}`}>
{!isClientMode && (
<Topbar
state={state}
status={status}
activeServer={activeServer}
dirty={dirty}
onRestart={restartSingbox}
onTryApply={rollback}
/>
)}
<div className={`app-body${isClientMode ? ' client-mode' : ''}`}>
{!isClientMode && <Sidebar active={page} onChange={navigate} badges={sidebarBadges} mode={state?.mode} />}
@@ -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)}
/>
) : (
<OverviewPage

View File

@@ -44,20 +44,6 @@ export const api = {
}),
},
clientSettings: {
get: () => 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) =>

View File

@@ -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 (
<button className="client-copy" type="button" onClick={copy}>
<span>{value}</span>
<strong>{copied ? 'OK' : 'Copy'}</strong>
</button>
);
}
function StatusPanel({ route, state }) {
const statusLabel = {
connected: 'Работает',
stopped: 'Остановлен',
empty: 'Не настроен',
}[route.status];
return (
<section className={`client-status-panel ${route.status}`}>
<div className="client-status-main">
<span className={`client-status-dot ${route.status}`} />
<div>
<div className="client-eyebrow">Текущий маршрут</div>
<h1>{route.title}</h1>
<p>{route.description}</p>
</div>
</div>
<div className="client-status-facts">
<div>
<small>Куда</small>
<strong>{route.target}</strong>
<span>{route.targetDetail}</span>
</div>
<div>
<small>Локальный proxy</small>
<strong>{route.localProxy}</strong>
<span>HTTP и SOCKS5</span>
</div>
<div>
<small>Сервис</small>
<strong>{statusLabel}</strong>
<span>{state?.appliedAt ? `применено ${formatRelative(state.appliedAt)}` : 'нет примененного config'}</span>
</div>
</div>
</section>
);
}
function RouteLine({ route }) {
return (
<div className="client-route-line">
{route.path.map((item, index) => (
<React.Fragment key={`${item}-${index}`}>
<span>{item}</span>
{index < route.path.length - 1 && <b></b>}
</React.Fragment>
))}
</div>
);
}
function ModeButton({ active, selected, title, subtitle, onClick, disabled }) {
return (
<button
type="button"
className={`client-mode-button ${selected ? 'selected' : ''} ${active ? 'active' : ''}`}
disabled={disabled}
onClick={onClick}
>
<strong>{title}</strong>
<span>{subtitle}</span>
</button>
);
}
function GatewaySettings({ settings, busy, onCheck }) {
const [draftUrl, setDraftUrl] = useState(settings?.sharedProxyControlUrl || '');
const sharedProxy = settings?.sharedProxy;
useEffect(() => {
setDraftUrl(settings?.sharedProxyControlUrl || '');
}, [settings?.sharedProxyControlUrl]);
return (
<div className="client-mode-settings">
<div className="field">
<label className="field-label">Адрес gateway UI</label>
<div className="client-inline-form">
<input
className="input"
placeholder="http://192.168.50.111:3456"
value={draftUrl}
onChange={(e) => setDraftUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && draftUrl && onCheck(draftUrl)}
/>
<button className="btn btn-primary" disabled={busy || !draftUrl} onClick={() => onCheck(draftUrl)}>
Подключить
</button>
</div>
</div>
{sharedProxy && (
<div className="client-current-target">
<small>Найден общий proxy</small>
<strong>{sharedProxy.host}:{sharedProxy.port}</strong>
</div>
)}
</div>
);
}
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 (
<div className="client-mode-settings">
<div className="field">
<label className="field-label">Подписка или VLESS</label>
<div className="client-inline-form">
<input
className="input"
placeholder="https://… или vless://…"
value={subscriptionUrl}
onChange={(e) => setSubscriptionUrl(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && subscriptionUrl && onFetchSubscription()}
/>
<button className="btn btn-secondary" disabled={busy || !subscriptionUrl} onClick={onFetchSubscription}>
Загрузить
</button>
</div>
</div>
<div className="field">
<label className="field-label">VPN-сервер</label>
<div className="client-inline-form">
<select
className="select"
value={selected}
disabled={!servers.length}
onChange={(e) => setPendingTag(e.target.value)}
>
<option value="">Выберите сервер</option>
{servers.map((server) => (
<option key={server.tag} value={server.tag}>
{flagFor(server)} {server.tag}
</option>
))}
</select>
<button className="btn btn-primary" disabled={busy || !selected} onClick={() => onApply(selected)}>
Подключить
</button>
</div>
{activeServer && <small className="field-hint">Выбран {flagFor(activeServer)} {activeServer.tag}</small>}
</div>
</div>
);
}
function DirectSettings({ busy, onEnable }) {
return (
<div className="client-mode-settings direct">
<div>
<strong>Прямой режим</strong>
<p className="muted">Приложения продолжают использовать локальный proxy, но трафик идет без VPN и без gateway.</p>
</div>
<button className="btn btn-primary" disabled={busy} onClick={onEnable}>
Включить напрямую
</button>
</div>
);
}
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 (
<aside className="client-side-panel">
<div>
<div className="client-panel-title">Адрес для приложений</div>
<div className="client-copy-stack">
<CopyValue value={`http://127.0.0.1:${port}`} />
<CopyValue value={`socks5://127.0.0.1:${port}`} />
</div>
</div>
<div className="field">
<label className="field-label">Порт proxy</label>
<div className="client-port-row">
<input
className="input"
type="number"
min={range.start}
max={range.end}
value={draftPort}
disabled={singlePublishedPort}
onChange={(e) => setDraftPort(e.target.value)}
/>
<button
className="btn btn-secondary"
disabled={busy || singlePublishedPort || !dirty}
onClick={() => onSave({ ...settings, proxyPort: parsed })}
>
Save
</button>
</div>
<small className={invalid ? 'field-error' : 'field-hint'}>
{singlePublishedPort ? 'Порт задаётся установщиком' : `${range.start}${range.end}`}
</small>
</div>
</aside>
);
}
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 (
<div className="client-dashboard">
<StatusPanel route={route} state={state} />
<RouteLine route={route} />
<div className="client-shell">
<main className="client-panel">
<section className="client-power-section" aria-labelledby="connection-title">
<button
className="client-power"
type="button"
role="switch"
aria-checked={connected}
aria-label={connected ? 'Выключить VPN' : 'Включить VPN'}
disabled={busy || (!connected && !canStart)}
onClick={toggleConnection}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
</svg>
</button>
<div>
<h2 id="connection-title">{connected ? 'VPN включён' : 'VPN выключен'}</h2>
{connected && (
<time className="client-duration">
{formatConnectionDuration(state?.singboxStartedAt, now)}
</time>
)}
{!connected && (
<p>{canStart ? 'Нажмите, чтобы подключиться' : 'Добавьте ссылку и выберите сервер'}</p>
)}
</div>
</section>
<section className="client-workspace">
<div className="client-main-panel">
<div className="client-mode-grid">
<ModeButton
active={route.mode === 'gateway'}
selected={setupMode === 'gateway'}
title="Общий gateway"
subtitle={clientSettings?.sharedProxy ? `${clientSettings.sharedProxy.host}:${clientSettings.sharedProxy.port}` : 'серверная proxy'}
disabled={busy}
onClick={selectGateway}
/>
<ModeButton
active={route.mode === 'vpn'}
selected={setupMode === 'vpn'}
title="Локальный VPN"
subtitle={state?.selectedTag || 'выбрать сервер'}
disabled={busy}
onClick={selectVpn}
/>
<ModeButton
active={route.mode === 'direct'}
selected={setupMode === 'direct'}
title="Напрямую"
subtitle="без VPN"
disabled={busy}
onClick={() => {
setSetupMode('direct');
enableDirect();
}}
/>
<div className="client-form">
<div className={`client-subscription ${editingSubscription ? 'is-editing' : ''}`}>
<button
className="client-subscription-summary"
type="button"
aria-hidden={editingSubscription}
inert={editingSubscription ? true : undefined}
tabIndex={editingSubscription ? -1 : 0}
onClick={() => setEditingSubscription(true)}
>
<span>Ваша подписка</span>
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
</button>
<form
className="client-subscription-edit"
aria-hidden={!editingSubscription}
inert={!editingSubscription ? true : undefined}
onSubmit={submitSubscription}
>
<input
ref={subscriptionInputRef}
id="subscription-url"
type="url"
inputMode="url"
autoComplete="url"
tabIndex={editingSubscription ? 0 : -1}
aria-label="Ссылка подписки"
placeholder="Вставьте ссылку подписки"
value={subscriptionUrl}
onChange={(event) => setSubscriptionUrl(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Escape' && state?.hasSubscription) {
setSubscriptionUrl('');
setEditingSubscription(false);
}
}}
/>
{subscriptionUrl.trim() && (
<button type="submit" aria-label="Сохранить подписку" disabled={busy}></button>
)}
</form>
</div>
{setupMode === 'gateway' && (
<GatewaySettings
settings={clientSettings}
busy={busy}
onCheck={onCheckSharedProxy}
/>
)}
{setupMode === 'vpn' && (
<VpnSettings
state={state}
servers={servers}
subscriptionUrl={subscriptionUrl}
setSubscriptionUrl={setSubscriptionUrl}
pendingTag={pendingTag}
setPendingTag={setPendingTag}
busy={busy}
onFetchSubscription={onFetchSubscription}
onApply={onApply}
/>
)}
{setupMode === 'direct' && (
<DirectSettings busy={busy} onEnable={enableDirect} />
)}
</div>
<section className="client-servers" aria-label="Серверы">
<div className="client-server-grid">
{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'
: '';
<ProxySettings
state={state}
settings={clientSettings}
busy={busy}
onSave={onSaveClientSettings}
/>
return (
<button
className={`client-server ${selected ? 'is-selected' : ''}`}
type="button"
key={server.tag}
disabled={busy}
aria-pressed={selected}
onClick={() => selectServer(server.tag)}
>
<strong>{server.tag}</strong>
<small className={pingClass}>{pingText}</small>
</button>
);
})}
</div>
</section>
</div>
</main>
<section className="client-proxies" aria-label="Локальный прокси">
{[
['socks5', 'SOCKS5'],
['http', 'HTTP'],
].map(([kind, label]) => (
<button
className={copiedProxy === kind ? 'is-copied' : ''}
type="button"
key={kind}
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
onClick={() => copyProxy(kind)}
>
<span>
{copiedProxy === kind
? 'Скопировано ✓'
: copiedProxy === 'error' ? 'Ошибка копирования' : label}
</span>
<strong>{proxyUrls[kind]}</strong>
</button>
))}
</section>
</div>
);

View File

@@ -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;
}
}

View File

@@ -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}`,
};
}

View File

@@ -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"],
};
}

View File

@@ -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);
},
);
});

View File

@@ -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" },
]);

View File

@@ -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');
});

View File

@@ -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',
});
});

View File

@@ -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, "выберите режим");
});