From f629309f324049ccc2eb9d649e8d888ac1c52a37 Mon Sep 17 00:00:00 2001 From: Dmitriy Petrov Date: Mon, 13 Jul 2026 20:32:59 +0300 Subject: [PATCH] Bump Harbor versions and surface gateway mode in overview --- src/shared/versions.js | 4 +- src/web/components/ClientOverviewPage.jsx | 166 +++++--- src/web/styles.css | 434 +++++++++++++++----- src/web/utils/clientControls.js | 36 -- test/web/client-controls.test.js | 9 - test/web/responsive-layout-contract.test.js | 2 +- test/web/rule-editor-contract.test.js | 10 - 7 files changed, 435 insertions(+), 226 deletions(-) diff --git a/src/shared/versions.js b/src/shared/versions.js index 143fa82..82987ae 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,6 +1,6 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.8.0', - gatewayClient: '0.8.0', + macClient: '0.8.1', + gatewayClient: '0.8.1', gatewayBackend: '0.8.0', }); diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 9b65d0e..50f7a2e 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { flushSync } from 'react-dom'; import { api } from '../api.js'; import { @@ -8,7 +8,6 @@ import { copyText, isSubscriptionUrlValid, localProxyUrls, - routePresentation, subscriptionDomain, subscriptionDaysLeft, subscriptionUsage, @@ -504,53 +503,96 @@ function AnimatedSeconds({ value, padded = true }) { )); } -function HarborBrand({ isGateway }) { +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); const product = isGateway ? 'Gateway' : 'Connect'; - return ( -
-
-
+ + + Harbor + {switchable ? + + - Harbor{product} -
+ + {gatewayDirect ? 'Harbor Gateway активен' : 'Harbor Gateway доступен'} + {gatewayDirect + ? 'Трафик идёт через Gateway в этой сети. Нажмите, чтобы использовать локальный VPN.' + : 'Сейчас используется локальный VPN. Нажмите, чтобы направить трафик через Gateway.'} + + : {product}} + +
; + + return ( +
+ {switchable ? :
{content}
}
); } -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, @@ -572,13 +614,8 @@ export function ClientOverviewPage({ onDismissError, }) { const isGateway = state?.mode === 'gateway'; - 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 gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct'; + const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available); const connected = Boolean(state?.singboxRunning); const hasSubscription = Boolean(state?.hasSubscription); const selectedServerId = pendingServerId || state?.selection?.desiredServerId || ''; @@ -630,7 +667,7 @@ export function ClientOverviewPage({ const usage = subscriptionUsage(state?.userInfo); const duration = connectionDurationParts(state?.singboxStartedAt, now); const connectionTitle = connected - ? gatewayStale ? 'Gateway проверяется' : gatewayDirect ? 'Gateway подключён' : 'VPN включён' + ? gatewayDirect ? 'Gateway подключён' : 'VPN включён' : 'Подключение выключено'; const [displayedUsed, setDisplayedUsed] = useState(usage.used); const hasUsage = Boolean( @@ -1018,13 +1055,19 @@ export function ClientOverviewPage({ return (
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
- + {hasSubscription && subscriptionContentReady &&
- {!isGateway && } -
{isGateway && (
@@ -1199,6 +1235,14 @@ 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 9fcd0c6..aeff5f6 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -188,7 +188,7 @@ p { color: var(--client-text); } -.client-shell.is-gateway-route { +.client-shell:has(.harbor-brand.is-gateway-active) { --client-accent: var(--harbor-gateway); } @@ -327,6 +327,32 @@ 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); } @@ -396,6 +422,246 @@ 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%; @@ -1969,7 +2235,7 @@ p { pointer-events: none; } -.client-shell.is-gateway-route .client-form:not(.is-waiting) { +.client-shell:has(.harbor-brand.is-gateway-active) .client-form:not(.is-waiting) { opacity: 0.34; filter: grayscale(1) saturate(0); } @@ -2864,104 +3130,6 @@ 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; @@ -2974,6 +3142,53 @@ 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; @@ -3331,11 +3546,6 @@ 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, @@ -3444,7 +3654,6 @@ 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, @@ -3468,7 +3677,8 @@ p { .client-subscription-edit::after, .client-subscription-submit, .client-subscription-summary, - .client-subscription-summary strong { + .client-subscription-summary strong, + .client-proxy-label > span { transition: none; animation: none; } @@ -3505,6 +3715,16 @@ 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 63a53c1..4a62e65 100644 --- a/src/web/utils/clientControls.js +++ b/src/web/utils/clientControls.js @@ -77,42 +77,6 @@ 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/web/client-controls.test.js b/test/web/client-controls.test.js index b90711a..68fa14c 100644 --- a/test/web/client-controls.test.js +++ b/test/web/client-controls.test.js @@ -9,7 +9,6 @@ import { formatConnectionDurationWords, isSubscriptionUrlValid, localProxyUrls, - routePresentation, subscriptionDomain, subscriptionDaysLeft, subscriptionUsage, @@ -75,14 +74,6 @@ 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 526fd7d..8e1bc86 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.doesNotMatch(component, /harbor-mode-tooltip|harbor-brand-control/); + assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/); }); 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 89b2f0e..e5d51b3 100644 --- a/test/web/rule-editor-contract.test.js +++ b/test/web/rule-editor-contract.test.js @@ -69,13 +69,3 @@ 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\)/); -});