diff --git a/src/server/gatewayPresence.js b/src/server/gatewayPresence.js index 7c6fbb5..4429380 100644 --- a/src/server/gatewayPresence.js +++ b/src/server/gatewayPresence.js @@ -108,8 +108,9 @@ export async function probeGatewayPresence({ }) { if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway'); + const presenceUrl = `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`; const response = await fetchImpl( - `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`, + presenceUrl, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) }, ); const payload = await response.json().catch(() => ({})); @@ -117,7 +118,11 @@ export async function probeGatewayPresence({ 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, { @@ -169,6 +174,8 @@ export function createGatewayAutoState() { failures: 0, gateway: null, gatewayId: '', + uiOrigin: '', + lastVerifiedAt: null, lastError: '', }; } @@ -199,6 +206,8 @@ export function nextGatewayAutoState(current, { mode: 'gateway-direct', failures: 0, gatewayId: verifiedGateway.gatewayId, + uiOrigin: verifiedGateway.uiOrigin || '', + lastVerifiedAt: verifiedGateway.verifiedAt || new Date().toISOString(), lastError: '', }; } diff --git a/src/shared/contracts/state.js b/src/shared/contracts/state.js index a49da82..d098b0b 100644 --- a/src/shared/contracts/state.js +++ b/src/shared/contracts/state.js @@ -54,6 +54,14 @@ export function createStateSnapshot({ : configExists ? 'running' : 'stopped'; const servers = stored.servers; 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 : []; return assertStateSnapshot({ @@ -80,8 +88,11 @@ export function createStateSnapshot({ route: { mode: routeMode, gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null, - lastVerifiedAt: null, - reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual', + gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null, + lastVerifiedAt: mode === 'client' ? dateOrNull(gatewayAuto?.lastVerifiedAt) : null, + autoEnabled: mode === 'client' && gatewayAutoEnabled, + fallbackPreference: mode === 'client' ? 'local-vpn' : 'none', + reason: routeReason, localRules: stored.routeRules, activeLocalRules, localRulesRevision: stored.routeRulesRevision, @@ -122,6 +133,7 @@ export function withStateV0Compatibility(snapshot, { enabled: stored.gatewayAutoEnabled !== false, available: Boolean(gatewayAuto?.gatewayId), address: gatewayAuto?.gateway?.gateway || '', + uiOrigin: gatewayAuto?.uiOrigin || '', interface: gatewayAuto?.gateway?.interface || '', failures: Number(gatewayAuto?.failures) || 0, lastError: gatewayAuto?.lastError || '', @@ -175,7 +187,10 @@ export function assertStateSnapshot(snapshot) { !snapshot.route || typeof snapshot.route.mode !== 'string' || !nullableString(snapshot.route.gatewayAddress) || + !nullableString(snapshot.route.gatewayUiOrigin) || !nullableDate(snapshot.route.lastVerifiedAt) || + typeof snapshot.route.autoEnabled !== 'boolean' || + typeof snapshot.route.fallbackPreference !== 'string' || typeof snapshot.route.reason !== 'string' || !Array.isArray(snapshot.route.localRules) || !snapshot.route.localRules.every(validRouteRule) || diff --git a/src/shared/versions.js b/src/shared/versions.js index b57480a..143fa82 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.7.36', - gatewayClient: '0.7.34', - gatewayBackend: '0.7.5', + macClient: '0.8.0', + gatewayClient: '0.8.0', + gatewayBackend: '0.8.0', }); export function parseVersion(value) { diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 2e5d98e..9b65d0e 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -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 { api } from '../api.js'; import { @@ -8,6 +8,7 @@ import { copyText, isSubscriptionUrlValid, localProxyUrls, + routePresentation, subscriptionDomain, subscriptionDaysLeft, subscriptionUsage, @@ -503,96 +504,53 @@ function AnimatedSeconds({ value, padded = true }) { )); } -function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }) { - const [modeAnimating, setModeAnimating] = useState(false); - const [arrowTurns, setArrowTurns] = useState(gatewayDirect ? 0.5 : 0); - const stopModeAnimationRef = useRef(false); - const previousGatewayDirectRef = useRef(gatewayDirect); +function HarborBrand({ isGateway }) { 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 =
- - - Harbor - {switchable ? - - - - {gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'} - {gatewayDirect - ? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.' - : 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'} - - : {product}} - -
; - return ( -
- {switchable ? :
{content}
} +
+
+ + Harbor{product} +
); } +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
+ Маршрут + {presentation.title} + {presentation.detail} + {route?.gatewayAddress &&
+ {route.gatewayUiOrigin + ? Harbor Gateway · {route.gatewayAddress} + : Harbor Gateway · {route.gatewayAddress}} + {verifiedAt && } +
} + +
; +} + export function ClientOverviewPage({ state, versionInfo, @@ -614,8 +572,13 @@ export function ClientOverviewPage({ onDismissError, }) { const isGateway = state?.mode === 'gateway'; - const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct'; - const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available); + const route = { + ...(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 hasSubscription = Boolean(state?.hasSubscription); const selectedServerId = pendingServerId || state?.selection?.desiredServerId || ''; @@ -667,7 +630,7 @@ export function ClientOverviewPage({ const usage = subscriptionUsage(state?.userInfo); const duration = connectionDurationParts(state?.singboxStartedAt, now); const connectionTitle = connected - ? gatewayDirect ? 'Gateway подключён' : 'VPN включён' + ? gatewayStale ? 'Gateway проверяется' : gatewayDirect ? 'Gateway подключён' : 'VPN включён' : 'Подключение выключено'; const [displayedUsed, setDisplayedUsed] = useState(usage.used); const hasUsage = Boolean( @@ -1055,19 +1018,13 @@ export function ClientOverviewPage({ return (
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
- + {hasSubscription && subscriptionContentReady &&
+ {!isGateway && } +
{isGateway && (
@@ -1235,14 +1199,6 @@ export function ClientOverviewPage({ aria-labelledby={isGateway ? 'client-access-tab-proxy' : undefined} hidden={isGateway && accessTab !== 'proxy'} > - {!isGateway && ( - - Локальный VPN - - Через Harbor Gateway · {state.gatewayAuto.address} - - - )} {proxyUrls.http.replace(/^https?:\/\//, '')} diff --git a/src/web/styles.css b/src/web/styles.css index aeff5f6..9fcd0c6 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -188,7 +188,7 @@ p { color: var(--client-text); } -.client-shell:has(.harbor-brand.is-gateway-active) { +.client-shell.is-gateway-route { --client-accent: var(--harbor-gateway); } @@ -327,32 +327,6 @@ p { 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 { transform: translate(-50%, calc(-50% - 72px)) scale(1.35); } @@ -422,246 +396,6 @@ p { 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 { position: fixed; top: 50%; @@ -2235,7 +1969,7 @@ p { 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; filter: grayscale(1) saturate(0); } @@ -3130,6 +2864,104 @@ p { 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 { display: grid; justify-items: center; @@ -3142,53 +2974,6 @@ p { 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 { color: var(--client-text); font-size: 12px; @@ -3546,6 +3331,11 @@ p { font-size: 10px; } + .client-route-auto { + min-height: 44px; + font-size: 10px; + } + .client-server-filters button, .client-server-mode-toggle, .client-server-group-toggle, @@ -3654,6 +3444,7 @@ p { .client-server-mode-panel .client-server-pinned, .client-server-mode-panel .client-server-scroll, .client-server-favorite, + .client-route-status > strong, .client-server-group-toggle, .client-server-more, .client-copy-button, @@ -3677,8 +3468,7 @@ p { .client-subscription-edit::after, .client-subscription-submit, .client-subscription-summary, - .client-subscription-summary strong, - .client-proxy-label > span { + .client-subscription-summary strong { transition: none; animation: none; } @@ -3715,16 +3505,6 @@ p { 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-confirmation-popup, .client-confirmation-dialog, diff --git a/src/web/utils/clientControls.js b/src/web/utils/clientControls.js index 4a62e65..63a53c1 100644 --- a/src/web/utils/clientControls.js +++ b/src/web/utils/clientControls.js @@ -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 = {}) { const clipboard = options.clipboard ?? globalThis.navigator?.clipboard; const documentRef = options.documentRef ?? globalThis.document; diff --git a/test/server/gateway-presence.test.js b/test/server/gateway-presence.test.js index e688cfd..1675bda 100644 --- a/test/server/gateway-presence.test.js +++ b/test/server/gateway-presence.test.js @@ -13,6 +13,7 @@ import { readHostNetworkState, verifyGatewayPresence, } from '../../src/server/gatewayPresence.js'; +import { createStateSnapshot } from '../../src/shared/contracts/state.js'; const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef'; const nonce = '0123456789abcdef0123456789abcdef'; @@ -34,16 +35,19 @@ test('Gateway presence is authenticated by the shared subscription secret', asyn const result = await probeGatewayPresence({ gateway: '192.168.50.111', subscriptionUrl, + port: 4567, nonce, fetchImpl: async (url) => { assert.equal( 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 }; }, }); - 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({ appMode: 'gateway', @@ -88,9 +92,15 @@ test('host route freshness and Gateway failures drive a safe automatic fallback' let state = nextGatewayAutoState(createGatewayAutoState(), { 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.uiOrigin, 'http://192.168.50.111:4567'); + assert.equal(state.lastVerifiedAt, new Date(now).toISOString()); 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); }); +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', () => { const detected = { ...createGatewayAutoState(), diff --git a/test/web/client-controls.test.js b/test/web/client-controls.test.js index 68fa14c..b90711a 100644 --- a/test/web/client-controls.test.js +++ b/test/web/client-controls.test.js @@ -9,6 +9,7 @@ import { formatConnectionDurationWords, isSubscriptionUrlValid, localProxyUrls, + routePresentation, subscriptionDomain, subscriptionDaysLeft, 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 () => { const textarea = { style: {}, diff --git a/test/web/responsive-layout-contract.test.js b/test/web/responsive-layout-contract.test.js index 8e1bc86..526fd7d 100644 --- a/test/web/responsive-layout-contract.test.js +++ b/test/web/responsive-layout-contract.test.js @@ -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.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-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', () => { diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js index e5d51b3..89b2f0e 100644 --- a/test/web/rule-editor-contract.test.js +++ b/test/web/rule-editor-contract.test.js @@ -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-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\)/); +});