Handle rejected subscriptions and add local client compose
All checks were successful
Build and Deploy Gateway / build-and-push (push) Successful in 14s
Build and Deploy Gateway / deploy (push) Successful in 6s

This commit is contained in:
2026-07-13 20:23:37 +03:00
parent 8c19f2cba9
commit 56f5e408e3
10 changed files with 306 additions and 443 deletions

View File

@@ -108,8 +108,9 @@ export async function probeGatewayPresence({
}) { }) {
if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway'); if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway');
const presenceUrl = `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`;
const response = await fetchImpl( const response = await fetchImpl(
`http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`, presenceUrl,
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) }, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) },
); );
const payload = await response.json().catch(() => ({})); const payload = await response.json().catch(() => ({}));
@@ -117,7 +118,11 @@ export async function probeGatewayPresence({
throw new Error('Текущий default gateway не является доверенным Harbor Gateway'); throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
} }
return { gatewayId: payload.gatewayId }; return {
gatewayId: payload.gatewayId,
uiOrigin: new URL(presenceUrl).origin,
verifiedAt: new Date().toISOString(),
};
} }
export function normalizeHostNetworkState(value, { export function normalizeHostNetworkState(value, {
@@ -169,6 +174,8 @@ export function createGatewayAutoState() {
failures: 0, failures: 0,
gateway: null, gateway: null,
gatewayId: '', gatewayId: '',
uiOrigin: '',
lastVerifiedAt: null,
lastError: '', lastError: '',
}; };
} }
@@ -199,6 +206,8 @@ export function nextGatewayAutoState(current, {
mode: 'gateway-direct', mode: 'gateway-direct',
failures: 0, failures: 0,
gatewayId: verifiedGateway.gatewayId, gatewayId: verifiedGateway.gatewayId,
uiOrigin: verifiedGateway.uiOrigin || '',
lastVerifiedAt: verifiedGateway.verifiedAt || new Date().toISOString(),
lastError: '', lastError: '',
}; };
} }

View File

@@ -54,6 +54,14 @@ export function createStateSnapshot({
: configExists ? 'running' : 'stopped'; : configExists ? 'running' : 'stopped';
const servers = stored.servers; const servers = stored.servers;
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent'; const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
const routeReason = mode !== 'client'
? 'gateway-host'
: !gatewayAutoEnabled
? 'disabled'
: routeMode === 'gateway-direct'
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : []; const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
return assertStateSnapshot({ return assertStateSnapshot({
@@ -80,8 +88,11 @@ export function createStateSnapshot({
route: { route: {
mode: routeMode, mode: routeMode,
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null, gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
lastVerifiedAt: null, gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual', lastVerifiedAt: mode === 'client' ? dateOrNull(gatewayAuto?.lastVerifiedAt) : null,
autoEnabled: mode === 'client' && gatewayAutoEnabled,
fallbackPreference: mode === 'client' ? 'local-vpn' : 'none',
reason: routeReason,
localRules: stored.routeRules, localRules: stored.routeRules,
activeLocalRules, activeLocalRules,
localRulesRevision: stored.routeRulesRevision, localRulesRevision: stored.routeRulesRevision,
@@ -122,6 +133,7 @@ export function withStateV0Compatibility(snapshot, {
enabled: stored.gatewayAutoEnabled !== false, enabled: stored.gatewayAutoEnabled !== false,
available: Boolean(gatewayAuto?.gatewayId), available: Boolean(gatewayAuto?.gatewayId),
address: gatewayAuto?.gateway?.gateway || '', address: gatewayAuto?.gateway?.gateway || '',
uiOrigin: gatewayAuto?.uiOrigin || '',
interface: gatewayAuto?.gateway?.interface || '', interface: gatewayAuto?.gateway?.interface || '',
failures: Number(gatewayAuto?.failures) || 0, failures: Number(gatewayAuto?.failures) || 0,
lastError: gatewayAuto?.lastError || '', lastError: gatewayAuto?.lastError || '',
@@ -175,7 +187,10 @@ export function assertStateSnapshot(snapshot) {
!snapshot.route || !snapshot.route ||
typeof snapshot.route.mode !== 'string' || typeof snapshot.route.mode !== 'string' ||
!nullableString(snapshot.route.gatewayAddress) || !nullableString(snapshot.route.gatewayAddress) ||
!nullableString(snapshot.route.gatewayUiOrigin) ||
!nullableDate(snapshot.route.lastVerifiedAt) || !nullableDate(snapshot.route.lastVerifiedAt) ||
typeof snapshot.route.autoEnabled !== 'boolean' ||
typeof snapshot.route.fallbackPreference !== 'string' ||
typeof snapshot.route.reason !== 'string' || typeof snapshot.route.reason !== 'string' ||
!Array.isArray(snapshot.route.localRules) || !Array.isArray(snapshot.route.localRules) ||
!snapshot.route.localRules.every(validRouteRule) || !snapshot.route.localRules.every(validRouteRule) ||

View File

@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({ export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.7.36', macClient: '0.8.0',
gatewayClient: '0.7.34', gatewayClient: '0.8.0',
gatewayBackend: '0.7.5', gatewayBackend: '0.8.0',
}); });
export function parseVersion(value) { export function parseVersion(value) {

View File

@@ -1,4 +1,4 @@
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { flushSync } from 'react-dom'; import { flushSync } from 'react-dom';
import { api } from '../api.js'; import { api } from '../api.js';
import { import {
@@ -8,6 +8,7 @@ import {
copyText, copyText,
isSubscriptionUrlValid, isSubscriptionUrlValid,
localProxyUrls, localProxyUrls,
routePresentation,
subscriptionDomain, subscriptionDomain,
subscriptionDaysLeft, subscriptionDaysLeft,
subscriptionUsage, subscriptionUsage,
@@ -503,96 +504,53 @@ function AnimatedSeconds({ value, padded = true }) {
)); ));
} }
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }) { function HarborBrand({ isGateway }) {
const [modeAnimating, setModeAnimating] = useState(false);
const [arrowTurns, setArrowTurns] = useState(gatewayDirect ? 0.5 : 0);
const stopModeAnimationRef = useRef(false);
const previousGatewayDirectRef = useRef(gatewayDirect);
const product = isGateway ? 'Gateway' : 'Connect'; const product = isGateway ? 'Gateway' : 'Connect';
const switchable = !isGateway && gatewayAvailable;
const label = gatewayDirect
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
: 'Использовать обнаруженный Harbor Gateway';
useLayoutEffect(() => {
if (previousGatewayDirectRef.current === gatewayDirect) return;
previousGatewayDirectRef.current = gatewayDirect;
setArrowTurns((turns) => turns + 0.5);
}, [gatewayDirect]);
function startModeAnimation() {
stopModeAnimationRef.current = false;
setModeAnimating(true);
}
function finishModeAnimation() {
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setModeAnimating(false);
return;
}
stopModeAnimationRef.current = true;
}
const content = <div className="harbor-brand-content">
<svg viewBox="0 0 32 32" aria-hidden="true">
<circle cx="16" cy="6" r="3" />
<path d="M16 9v15M10 14h12" />
<path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
<path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
</svg>
<span className="harbor-brand-name">
<strong>Harbor</strong>
{switchable ? <span className="harbor-mode-control">
<span className="harbor-mode-stack" aria-hidden="true">
<em className="harbor-mode-connect">Connect</em>
<em className="harbor-mode-gateway"><span>Gateway</span></em>
</span>
<svg
className="harbor-mode-swap"
viewBox="0 0 18 18"
aria-hidden="true"
style={{ '--harbor-arrow-turn': `${arrowTurns}turn` }}
>
<g className="is-connect"><path d="M3 6h10m-3-3 3 3-3 3" /></g>
<g className="is-gateway"><path d="M15 12H5m3 3-3-3 3-3" /></g>
</svg>
<span id="harbor-mode-tooltip" className="harbor-mode-tooltip" role="tooltip">
<strong>{gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'}</strong>
<span>{gatewayDirect
? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.'
: 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'}</span>
</span>
</span> : <em>{product}</em>}
</span>
</div>;
return ( return (
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}> <div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
{switchable ? <button <div className="harbor-brand-content">
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`} <svg viewBox="0 0 32 32" aria-hidden="true">
type="button" <circle cx="16" cy="6" r="3" />
aria-label={label} <path d="M16 9v15M10 14h12" />
aria-describedby="harbor-mode-tooltip" <path className="harbor-anchor-left" d="M16 28C11 28 8.4 25.2 6.3 22v-3.3M3.8 21.4l2.5-2.7 2.5 2.7" />
aria-pressed={gatewayDirect} <path className="harbor-anchor-right" d="M16 28C21 28 23.6 25.2 25.7 22v-3.3M23.2 21.4l2.5-2.7 2.5 2.7" />
disabled={blocked} </svg>
onPointerEnter={startModeAnimation} <span className="harbor-brand-name"><strong>Harbor</strong><em>{product}</em></span>
onPointerLeave={finishModeAnimation} </div>
onFocus={startModeAnimation}
onBlur={finishModeAnimation}
onAnimationIteration={(event) => {
if (event.animationName === 'harbor-mode-float-front' && stopModeAnimationRef.current) {
stopModeAnimationRef.current = false;
setModeAnimating(false);
}
}}
onClick={() => onSetGatewayAuto(!gatewayDirect)}
>
{content}
</button> : <div aria-label={`Harbor ${product}`}>{content}</div>}
</div> </div>
); );
} }
function RouteStatus({ route, blocked, onSetGatewayAuto }) {
const presentation = routePresentation(route);
const verifiedAt = route?.lastVerifiedAt && Number.isFinite(Date.parse(route.lastVerifiedAt))
? new Date(route.lastVerifiedAt)
: null;
return <section className={`client-route-status is-${presentation.tone}`} aria-label="Маршрут Connect">
<span>Маршрут</span>
<strong aria-live="polite">{presentation.title}</strong>
<small>{presentation.detail}</small>
{route?.gatewayAddress && <div className="client-route-gateway">
{route.gatewayUiOrigin
? <a href={route.gatewayUiOrigin}>Harbor Gateway · {route.gatewayAddress}</a>
: <span>Harbor Gateway · {route.gatewayAddress}</span>}
{verifiedAt && <time dateTime={route.lastVerifiedAt}>
Проверен {verifiedAt.toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</time>}
</div>}
<label className="client-route-auto">
<input
type="checkbox"
checked={route?.autoEnabled !== false}
disabled={blocked}
onChange={(event) => onSetGatewayAuto(event.target.checked)}
/>
<span>Использовать домашний Gateway автоматически</span>
</label>
</section>;
}
export function ClientOverviewPage({ export function ClientOverviewPage({
state, state,
versionInfo, versionInfo,
@@ -614,8 +572,13 @@ export function ClientOverviewPage({
onDismissError, onDismissError,
}) { }) {
const isGateway = state?.mode === 'gateway'; const isGateway = state?.mode === 'gateway';
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct'; const route = {
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available); ...(state?.route || {}),
autoEnabled: state?.route?.autoEnabled ?? state?.gatewayAuto?.enabled ?? true,
gatewayUiOrigin: state?.route?.gatewayUiOrigin || state?.gatewayAuto?.uiOrigin || null,
};
const gatewayDirect = !isGateway && route.mode === 'gateway-direct';
const gatewayStale = gatewayDirect && route.reason === 'gateway-stale';
const connected = Boolean(state?.singboxRunning); const connected = Boolean(state?.singboxRunning);
const hasSubscription = Boolean(state?.hasSubscription); const hasSubscription = Boolean(state?.hasSubscription);
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || ''; const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
@@ -667,7 +630,7 @@ export function ClientOverviewPage({
const usage = subscriptionUsage(state?.userInfo); const usage = subscriptionUsage(state?.userInfo);
const duration = connectionDurationParts(state?.singboxStartedAt, now); const duration = connectionDurationParts(state?.singboxStartedAt, now);
const connectionTitle = connected const connectionTitle = connected
? gatewayDirect ? 'Gateway подключён' : 'VPN включён' ? gatewayStale ? 'Gateway проверяется' : gatewayDirect ? 'Gateway подключён' : 'VPN включён'
: 'Подключение выключено'; : 'Подключение выключено';
const [displayedUsed, setDisplayedUsed] = useState(usage.used); const [displayedUsed, setDisplayedUsed] = useState(usage.used);
const hasUsage = Boolean( const hasUsage = Boolean(
@@ -1055,19 +1018,13 @@ export function ClientOverviewPage({
return ( return (
<div <div
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`} className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}${gatewayDirect ? ' is-gateway-route' : ''}`}
> >
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} /> <VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true"> <div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''} {copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
</div> </div>
<HarborBrand <HarborBrand isGateway={isGateway} />
isGateway={isGateway}
gatewayAvailable={gatewayAvailable}
gatewayDirect={gatewayDirect}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>
{hasSubscription && subscriptionContentReady && <button {hasSubscription && subscriptionContentReady && <button
ref={instructionsToggleRef} ref={instructionsToggleRef}
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`} className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
@@ -1136,7 +1093,8 @@ export function ClientOverviewPage({
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}> <h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span> <span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span> <span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span> <span className={connected && gatewayDirect && !gatewayStale ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
<span className={connected && gatewayStale ? 'is-active' : ''} aria-hidden="true">Gateway проверяется</span>
</h2> </h2>
<div className="client-state-detail"> <div className="client-state-detail">
{connected ? ( {connected ? (
@@ -1187,6 +1145,12 @@ export function ClientOverviewPage({
</div> </div>
</div> </div>
{!isGateway && <RouteStatus
route={route}
blocked={gatewayAutoBlocked}
onSetGatewayAuto={onSetGatewayAuto}
/>}
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}> <section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
{isGateway && ( {isGateway && (
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения"> <div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
@@ -1235,14 +1199,6 @@ export function ClientOverviewPage({
aria-labelledby={isGateway ? 'client-access-tab-proxy' : undefined} aria-labelledby={isGateway ? 'client-access-tab-proxy' : undefined}
hidden={isGateway && accessTab !== 'proxy'} hidden={isGateway && accessTab !== 'proxy'}
> >
{!isGateway && (
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
<span className={gatewayDirect ? 'is-active' : ''}>
Через <a href={`http://${state.gatewayAuto.address}:3456`}>Harbor Gateway</a> · {state.gatewayAuto.address}
</span>
</span>
)}
<strong className="client-proxy-address"> <strong className="client-proxy-address">
{proxyUrls.http.replace(/^https?:\/\//, '')} {proxyUrls.http.replace(/^https?:\/\//, '')}
</strong> </strong>

View File

@@ -188,7 +188,7 @@ p {
color: var(--client-text); color: var(--client-text);
} }
.client-shell:has(.harbor-brand.is-gateway-active) { .client-shell.is-gateway-route {
--client-accent: var(--harbor-gateway); --client-accent: var(--harbor-gateway);
} }
@@ -327,32 +327,6 @@ p {
pointer-events: none; pointer-events: none;
} }
.harbor-brand.is-switchable {
pointer-events: auto;
}
.harbor-brand-control {
position: relative;
appearance: none;
border: 0;
border-radius: 7px;
padding: 3px 5px;
background: transparent;
color: inherit;
font: inherit;
letter-spacing: inherit;
cursor: pointer;
}
.harbor-brand-control:focus-visible {
outline: 1px solid color-mix(in srgb, var(--harbor-gateway) 55%, transparent);
outline-offset: 4px;
}
.harbor-brand-control:disabled {
cursor: wait;
}
.client-shell.is-first-run .harbor-brand { .client-shell.is-first-run .harbor-brand {
transform: translate(-50%, calc(-50% - 72px)) scale(1.35); transform: translate(-50%, calc(-50% - 72px)) scale(1.35);
} }
@@ -422,246 +396,6 @@ p {
color: var(--harbor-gateway); color: var(--harbor-gateway);
} }
.harbor-mode-control,
.harbor-mode-stack {
position: relative;
display: inline-block;
width: 4.5em;
height: 1.25em;
}
.harbor-mode-stack {
width: 100%;
height: 100%;
}
.harbor-mode-stack em {
position: absolute;
inset: 0 auto auto 0;
transition: color 420ms ease, opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-brand .harbor-mode-connect {
z-index: 2;
color: var(--harbor-connect);
animation: harbor-mode-ambient-front 10s ease-in-out infinite;
}
.harbor-brand .harbor-mode-gateway {
z-index: 1;
color: var(--harbor-gateway);
opacity: 0.2;
filter: blur(0.4px);
transform: translate(0.42em, 0.34em) scale(0.96);
animation: harbor-mode-ambient-back 10s ease-in-out infinite;
}
.harbor-mode-gateway > span {
display: block;
animation: harbor-mode-discovered 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.harbor-brand.is-gateway-active .harbor-mode-connect {
z-index: 1;
opacity: 0.18;
filter: blur(0.4px);
transform: translate(0.42em, 0.34em) scale(0.96);
}
.harbor-brand.is-gateway-active .harbor-mode-gateway {
z-index: 2;
opacity: 1;
filter: none;
transform: none;
}
@keyframes harbor-mode-discovered {
from {
opacity: 0;
filter: blur(3px);
transform: translateX(4.8em);
}
}
.harbor-brand.is-gateway-active .harbor-mode-connect {
animation-name: harbor-mode-ambient-back;
}
.harbor-brand.is-gateway-active .harbor-mode-gateway {
animation-name: harbor-mode-ambient-front;
}
.harbor-brand-control.is-mode-animating .harbor-mode-connect {
animation: harbor-mode-float-front 1500ms ease-in-out infinite;
}
.harbor-brand-control.is-mode-animating .harbor-mode-gateway {
animation: harbor-mode-float-back 1500ms ease-in-out infinite;
}
.harbor-brand.is-gateway-active .harbor-brand-control.is-mode-animating .harbor-mode-connect {
animation-name: harbor-mode-float-back;
}
.harbor-brand.is-gateway-active .harbor-brand-control.is-mode-animating .harbor-mode-gateway {
animation-name: harbor-mode-float-front;
}
@keyframes harbor-mode-float-front {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(0.12em); }
}
@keyframes harbor-mode-float-back {
0%, 100% { transform: translate(0.42em, 0.34em) scale(0.96); }
50% { transform: translate(0.42em, 0.22em) scale(0.96); }
}
@keyframes harbor-mode-ambient-front {
0%, 88%, 96%, 100% { transform: translateY(0); }
92% { transform: translateY(0.08em); }
}
@keyframes harbor-mode-ambient-back {
0%, 88%, 96%, 100% { transform: translate(0.42em, 0.34em) scale(0.96); }
92% { transform: translate(0.42em, 0.26em) scale(0.96); }
}
.harbor-brand .harbor-mode-swap {
position: absolute;
top: 50%;
left: calc(100% + 4px);
width: 14px;
height: 14px;
opacity: 0.82;
stroke-width: 1.35;
transform: translateY(-50%);
transition: color 320ms ease, opacity 320ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
animation: harbor-mode-arrows-discovered 260ms 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes harbor-mode-arrows-discovered {
from {
opacity: 0;
filter: blur(2px);
}
}
.harbor-mode-swap g {
transform-box: view-box;
transform-origin: 9px 9px;
transform: rotate(var(--harbor-arrow-turn, 0turn));
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-mode-swap path {
transform-box: fill-box;
transform-origin: center;
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-mode-swap .is-connect {
opacity: 1;
stroke: var(--harbor-connect);
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-mode-swap .is-connect path {
animation: harbor-arrow-ambient-right 10s ease-in-out infinite;
}
.harbor-mode-swap .is-gateway {
opacity: 0.28;
filter: blur(0.45px);
stroke: var(--harbor-gateway);
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
}
.harbor-mode-swap .is-gateway path {
animation: harbor-arrow-ambient-left 10s ease-in-out infinite;
}
.harbor-brand.is-gateway-active .harbor-mode-swap .is-connect {
opacity: 0.28;
filter: blur(0.45px);
}
.harbor-brand.is-gateway-active .harbor-mode-swap .is-gateway {
opacity: 1;
filter: none;
}
.harbor-mode-tooltip {
position: absolute;
bottom: calc(100% + 9px);
left: 0;
width: calc(100% + 22px);
display: grid;
gap: 2px;
padding: 5px 6px;
border-radius: 5px;
background: oklch(0.14 0.012 145);
box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1);
color: oklch(0.88 0.012 145);
font-size: 4px;
line-height: 1.5;
letter-spacing: 0;
text-align: left;
opacity: 0;
visibility: hidden;
filter: blur(2px);
pointer-events: none;
transform: translateY(3px);
transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
}
.harbor-mode-tooltip strong {
color: oklch(0.96 0.008 145);
font-size: 4.2px;
}
.harbor-brand-control:hover .harbor-mode-tooltip,
.harbor-brand-control:focus-visible .harbor-mode-tooltip {
opacity: 1;
visibility: visible;
filter: blur(0);
transform: translateY(0);
transition-delay: 20ms, 20ms, 20ms, 0s;
}
.harbor-brand-control:hover .harbor-mode-swap,
.harbor-brand-control:focus-visible .harbor-mode-swap {
opacity: 1;
}
.harbor-brand-control.is-mode-animating .harbor-mode-swap .is-connect path {
animation: harbor-arrow-float-right 1500ms ease-in-out infinite;
}
.harbor-brand-control.is-mode-animating .harbor-mode-swap .is-gateway path {
animation: harbor-arrow-float-left 1500ms ease-in-out infinite;
}
@keyframes harbor-arrow-float-right {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(1.25px); }
}
@keyframes harbor-arrow-float-left {
0%, 100% { transform: translateX(0); }
50% { transform: translateX(-1.25px); }
}
@keyframes harbor-arrow-ambient-right {
0%, 88%, 96%, 100% { transform: translateX(0); }
92% { transform: translateX(0.75px); }
}
@keyframes harbor-arrow-ambient-left {
0%, 88%, 96%, 100% { transform: translateX(0); }
92% { transform: translateX(-0.75px); }
}
.client-instructions-toggle { .client-instructions-toggle {
position: fixed; position: fixed;
top: 50%; top: 50%;
@@ -2235,7 +1969,7 @@ p {
pointer-events: none; pointer-events: none;
} }
.client-shell:has(.harbor-brand.is-gateway-active) .client-form:not(.is-waiting) { .client-shell.is-gateway-route .client-form:not(.is-waiting) {
opacity: 0.34; opacity: 0.34;
filter: grayscale(1) saturate(0); filter: grayscale(1) saturate(0);
} }
@@ -3130,6 +2864,104 @@ p {
outline-offset: 3px; outline-offset: 3px;
} }
.client-route-status {
width: min(280px, 100%);
display: grid;
justify-items: center;
gap: 5px;
color: var(--client-muted);
text-align: center;
}
.client-route-status > span {
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-route-status > strong {
color: var(--client-text);
font-size: 12px;
transition: color 420ms ease, text-shadow 520ms ease;
}
.client-route-status.is-gateway > strong {
color: var(--harbor-gateway);
text-shadow: 0 0 10px color-mix(in oklch, var(--harbor-gateway) 35%, transparent);
}
.client-route-status.is-stale > strong,
.client-route-status.is-lost > strong {
color: var(--client-warning, oklch(0.72 0.12 72));
}
.client-route-status > small {
max-width: 38ch;
min-height: 30px;
font-size: 9px;
line-height: 1.55;
}
.client-route-gateway {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 9px;
}
.client-route-gateway a {
color: var(--client-accent);
font-weight: 700;
text-decoration: none;
}
.client-route-gateway a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.client-route-gateway a:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-route-gateway time {
white-space: nowrap;
opacity: 0.72;
}
.client-route-auto {
min-height: 44px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 9px;
line-height: 1.35;
text-align: left;
}
.client-route-auto input {
width: 16px;
height: 16px;
flex: 0 0 auto;
margin: 0;
accent-color: var(--client-accent);
}
.client-route-auto:has(input:focus-visible) {
color: var(--client-text);
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-route-auto:has(input:disabled) {
cursor: wait;
opacity: 0.5;
}
.client-proxies { .client-proxies {
display: grid; display: grid;
justify-items: center; justify-items: center;
@@ -3142,53 +2974,6 @@ p {
min-height: 91px; min-height: 91px;
} }
.client-proxy-label {
display: grid;
place-items: center;
color: var(--client-muted);
font-size: 9px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.client-proxy-label > span {
grid-area: 1 / 1;
opacity: 0;
filter: blur(3px);
pointer-events: none;
transform: translateX(-12px);
transition: color 700ms ease, opacity 520ms ease, filter 620ms ease, transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
}
.client-proxy-label > span:last-child {
color: var(--client-accent);
transform: translateX(12px);
}
.client-proxy-label > span.is-active {
opacity: 1;
filter: blur(0);
pointer-events: auto;
transform: translateX(0);
}
.client-proxy-label a {
color: var(--client-accent);
text-decoration: none;
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 42%, transparent);
}
.client-proxy-label a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.client-proxy-label a:focus-visible {
outline: 2px solid var(--client-accent);
outline-offset: 2px;
}
.client-proxy-address { .client-proxy-address {
color: var(--client-text); color: var(--client-text);
font-size: 12px; font-size: 12px;
@@ -3546,6 +3331,11 @@ p {
font-size: 10px; font-size: 10px;
} }
.client-route-auto {
min-height: 44px;
font-size: 10px;
}
.client-server-filters button, .client-server-filters button,
.client-server-mode-toggle, .client-server-mode-toggle,
.client-server-group-toggle, .client-server-group-toggle,
@@ -3654,6 +3444,7 @@ p {
.client-server-mode-panel .client-server-pinned, .client-server-mode-panel .client-server-pinned,
.client-server-mode-panel .client-server-scroll, .client-server-mode-panel .client-server-scroll,
.client-server-favorite, .client-server-favorite,
.client-route-status > strong,
.client-server-group-toggle, .client-server-group-toggle,
.client-server-more, .client-server-more,
.client-copy-button, .client-copy-button,
@@ -3677,8 +3468,7 @@ p {
.client-subscription-edit::after, .client-subscription-edit::after,
.client-subscription-submit, .client-subscription-submit,
.client-subscription-summary, .client-subscription-summary,
.client-subscription-summary strong, .client-subscription-summary strong {
.client-proxy-label > span {
transition: none; transition: none;
animation: none; animation: none;
} }
@@ -3715,16 +3505,6 @@ p {
animation: none; animation: none;
} }
.harbor-mode-stack em,
.harbor-mode-gateway > span,
.harbor-mode-swap g,
.harbor-mode-swap path,
.harbor-brand .harbor-mode-swap,
.harbor-mode-tooltip {
transition: none;
animation: none;
}
.client-form-content, .client-form-content,
.client-confirmation-popup, .client-confirmation-popup,
.client-confirmation-dialog, .client-confirmation-dialog,

View File

@@ -77,6 +77,42 @@ export function localProxyUrls(port = 8082, host = '127.0.0.1') {
}; };
} }
export function routePresentation(route = {}) {
if (route.mode === 'gateway-direct') {
return route.reason === 'gateway-stale'
? {
title: 'Harbor Gateway проверяется',
detail: 'Последняя проверка успешна, но свежего ответа пока нет.',
tone: 'stale',
}
: {
title: 'Через Harbor Gateway',
detail: 'Подпиской и сервером сейчас управляет Gateway.',
tone: 'gateway',
};
}
if (route.mode === 'local-vpn') {
if (route.reason === 'gateway-lost') {
return {
title: 'Локальный VPN · Gateway недоступен',
detail: 'Harbor вернулся к локальному VPN. При восстановлении Gateway авторежим переключится обратно.',
tone: 'lost',
};
}
return {
title: 'Локальный VPN',
detail: route.reason === 'disabled'
? 'Автопереход на домашний Gateway выключен.'
: 'Connect использует выбранный здесь VPN-сервер.',
tone: 'local',
};
}
if (route.mode === 'direct') {
return { title: 'Прямое подключение', detail: 'Трафик не идёт через VPN.', tone: 'direct' };
}
return { title: 'Маршрут неизвестен', detail: 'Harbor ещё не подтвердил текущий маршрут.', tone: 'unknown' };
}
export async function copyText(text, options = {}) { export async function copyText(text, options = {}) {
const clipboard = options.clipboard ?? globalThis.navigator?.clipboard; const clipboard = options.clipboard ?? globalThis.navigator?.clipboard;
const documentRef = options.documentRef ?? globalThis.document; const documentRef = options.documentRef ?? globalThis.document;

View File

@@ -13,6 +13,7 @@ import {
readHostNetworkState, readHostNetworkState,
verifyGatewayPresence, verifyGatewayPresence,
} from '../../src/server/gatewayPresence.js'; } from '../../src/server/gatewayPresence.js';
import { createStateSnapshot } from '../../src/shared/contracts/state.js';
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef'; const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
const nonce = '0123456789abcdef0123456789abcdef'; const nonce = '0123456789abcdef0123456789abcdef';
@@ -34,16 +35,19 @@ test('Gateway presence is authenticated by the shared subscription secret', asyn
const result = await probeGatewayPresence({ const result = await probeGatewayPresence({
gateway: '192.168.50.111', gateway: '192.168.50.111',
subscriptionUrl, subscriptionUrl,
port: 4567,
nonce, nonce,
fetchImpl: async (url) => { fetchImpl: async (url) => {
assert.equal( assert.equal(
url, url,
`http://192.168.50.111:3456/api/gateway-presence?nonce=${nonce}`, `http://192.168.50.111:4567/api/gateway-presence?nonce=${nonce}`,
); );
return { ok: true, json: async () => payload }; return { ok: true, json: async () => payload };
}, },
}); });
assert.deepEqual(result, { gatewayId: 'gateway-1' }); assert.equal(result.gatewayId, 'gateway-1');
assert.equal(result.uiOrigin, 'http://192.168.50.111:4567');
assert.equal(Number.isFinite(Date.parse(result.verifiedAt)), true);
assert.equal(buildGatewayPresence({ assert.equal(buildGatewayPresence({
appMode: 'gateway', appMode: 'gateway',
@@ -88,9 +92,15 @@ test('host route freshness and Gateway failures drive a safe automatic fallback'
let state = nextGatewayAutoState(createGatewayAutoState(), { let state = nextGatewayAutoState(createGatewayAutoState(), {
network, network,
verifiedGateway: { gatewayId: 'gateway-1' }, verifiedGateway: {
gatewayId: 'gateway-1',
uiOrigin: 'http://192.168.50.111:4567',
verifiedAt: new Date(now).toISOString(),
},
}); });
assert.equal(state.mode, 'gateway-direct'); assert.equal(state.mode, 'gateway-direct');
assert.equal(state.uiOrigin, 'http://192.168.50.111:4567');
assert.equal(state.lastVerifiedAt, new Date(now).toISOString());
state = nextGatewayAutoState(state, { network }); state = nextGatewayAutoState(state, { network });
state = nextGatewayAutoState(state, { network }); state = nextGatewayAutoState(state, { network });
@@ -116,6 +126,44 @@ test('host route freshness and Gateway failures drive a safe automatic fallback'
assert.equal(readHostNetworkState(statePath, { now }), null); assert.equal(readHostNetworkState(statePath, { now }), null);
}); });
test('canonical route distinguishes fresh, stale, lost, disabled and local states', () => {
const storedState = {
revision: 1,
subscriptionUrl,
gatewayAutoEnabled: true,
};
const snapshot = (gatewayAuto, stored = storedState) => createStateSnapshot({
storedState: stored,
runtime: { running: true },
gatewayAuto,
appMode: 'client',
configExists: true,
subscriptionHost: 'subscription.example/…',
now: new Date('2026-07-13T12:00:00.000Z'),
}).route;
const fresh = {
...createGatewayAutoState(),
mode: 'gateway-direct',
gateway: { gateway: '192.168.50.111' },
gatewayId: 'gateway-1',
uiOrigin: 'http://192.168.50.111:4567',
lastVerifiedAt: '2026-07-13T11:59:59.000Z',
};
const found = snapshot(fresh);
assert.equal(found.mode, 'gateway-direct');
assert.equal(found.reason, 'gateway-found');
assert.equal(found.gatewayAddress, '192.168.50.111');
assert.equal(found.gatewayUiOrigin, 'http://192.168.50.111:4567');
assert.equal(found.lastVerifiedAt, '2026-07-13T11:59:59.000Z');
assert.equal(found.autoEnabled, true);
assert.equal(found.fallbackPreference, 'local-vpn');
assert.equal(snapshot({ ...fresh, failures: 1 }).reason, 'gateway-stale');
assert.equal(snapshot({ ...fresh, mode: 'local-vpn', gatewayId: '', lastError: 'lost' }).reason, 'gateway-lost');
assert.equal(snapshot(fresh, { ...storedState, gatewayAutoEnabled: false }).reason, 'disabled');
assert.equal(snapshot(createGatewayAutoState()).reason, 'local');
});
test('client can ignore and restore a verified Gateway without losing discovery', () => { test('client can ignore and restore a verified Gateway without losing discovery', () => {
const detected = { const detected = {
...createGatewayAutoState(), ...createGatewayAutoState(),

View File

@@ -9,6 +9,7 @@ import {
formatConnectionDurationWords, formatConnectionDurationWords,
isSubscriptionUrlValid, isSubscriptionUrlValid,
localProxyUrls, localProxyUrls,
routePresentation,
subscriptionDomain, subscriptionDomain,
subscriptionDaysLeft, subscriptionDaysLeft,
subscriptionUsage, subscriptionUsage,
@@ -74,6 +75,14 @@ test('gateway proxy URLs use its network address', () => {
}); });
}); });
test('route copy distinguishes found, stale, lost, disabled and local states', () => {
assert.equal(routePresentation({ mode: 'gateway-direct', reason: 'gateway-found' }).title, 'Через Harbor Gateway');
assert.equal(routePresentation({ mode: 'gateway-direct', reason: 'gateway-stale' }).tone, 'stale');
assert.match(routePresentation({ mode: 'local-vpn', reason: 'gateway-lost' }).title, /Gateway недоступен/);
assert.match(routePresentation({ mode: 'local-vpn', reason: 'disabled' }).detail, /выключен/);
assert.equal(routePresentation({ mode: 'local-vpn', reason: 'local' }).title, 'Локальный VPN');
});
test('copy uses the synchronous native path available on gateway HTTP', async () => { test('copy uses the synchronous native path available on gateway HTTP', async () => {
const textarea = { const textarea = {
style: {}, style: {},

View File

@@ -94,7 +94,7 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
assert.match(styles, /\.client-tooltip-anchor:has\(> :focus-visible\) > \.client-tooltip/); assert.match(styles, /\.client-tooltip-anchor:has\(> :focus-visible\) > \.client-tooltip/);
assert.doesNotMatch(styles, /\.client-tooltip-anchor:focus-within > \.client-tooltip/); assert.doesNotMatch(styles, /\.client-tooltip-anchor:focus-within > \.client-tooltip/);
assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/); assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/); assert.doesNotMatch(component, /harbor-mode-tooltip|harbor-brand-control/);
}); });
test('subscription validation waits for the provider and keeps diagnostics below errors', () => { test('subscription validation waits for the provider and keeps diagnostics below errors', () => {

View File

@@ -69,3 +69,13 @@ test('copy feedback, drawers and Gateway tabs expose complete keyboard semantics
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/); assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/);
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-local-rule-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/); assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-local-rule-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
}); });
test('Connect route uses a labelled native Auto control instead of the logo', () => {
assert.match(component, /aria-label="Маршрут Connect"/);
assert.match(component, /type="checkbox"[\s\S]*checked={route\?\.autoEnabled !== false}/);
assert.match(component, /onChange={\(event\) => onSetGatewayAuto\(event\.target\.checked\)}/);
assert.match(component, /Использовать домашний Gateway автоматически/);
assert.doesNotMatch(component, /harbor-brand-control|:3456/);
assert.match(styles, /\.client-route-auto \{[\s\S]*min-height: 44px/);
assert.match(styles, /\.client-route-auto:has\(input:focus-visible\)/);
});