Bump Harbor versions and surface gateway mode in overview
This commit is contained in:
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
|
||||
<div className="harbor-brand-content">
|
||||
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><em>{product}</em></span>
|
||||
</div>
|
||||
<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 (
|
||||
<div className={`harbor-brand is-${product.toLowerCase()}${switchable ? ` is-switchable${gatewayDirect ? ' is-gateway-active' : ''}` : ''}`}>
|
||||
{switchable ? <button
|
||||
className={`harbor-brand-control${modeAnimating ? ' is-mode-animating' : ''}`}
|
||||
type="button"
|
||||
aria-label={label}
|
||||
aria-describedby="harbor-mode-tooltip"
|
||||
aria-pressed={gatewayDirect}
|
||||
disabled={blocked}
|
||||
onPointerEnter={startModeAnimation}
|
||||
onPointerLeave={finishModeAnimation}
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
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({
|
||||
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 (
|
||||
<div
|
||||
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}${gatewayDirect ? ' is-gateway-route' : ''}`}
|
||||
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}
|
||||
>
|
||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||
</div>
|
||||
<HarborBrand isGateway={isGateway} />
|
||||
<HarborBrand
|
||||
isGateway={isGateway}
|
||||
gatewayAvailable={gatewayAvailable}
|
||||
gatewayDirect={gatewayDirect}
|
||||
blocked={gatewayAutoBlocked}
|
||||
onSetGatewayAuto={onSetGatewayAuto}
|
||||
/>
|
||||
{hasSubscription && subscriptionContentReady && <button
|
||||
ref={instructionsToggleRef}
|
||||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||||
@@ -1093,8 +1136,7 @@ export function ClientOverviewPage({
|
||||
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
|
||||
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
|
||||
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
|
||||
<span className={connected && gatewayDirect && !gatewayStale ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
|
||||
<span className={connected && gatewayStale ? 'is-active' : ''} aria-hidden="true">Gateway проверяется</span>
|
||||
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
|
||||
</h2>
|
||||
<div className="client-state-detail">
|
||||
{connected ? (
|
||||
@@ -1145,12 +1187,6 @@ export function ClientOverviewPage({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isGateway && <RouteStatus
|
||||
route={route}
|
||||
blocked={gatewayAutoBlocked}
|
||||
onSetGatewayAuto={onSetGatewayAuto}
|
||||
/>}
|
||||
|
||||
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
||||
{isGateway && (
|
||||
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
|
||||
@@ -1199,6 +1235,14 @@ export function ClientOverviewPage({
|
||||
aria-labelledby={isGateway ? 'client-access-tab-proxy' : undefined}
|
||||
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={state.gatewayAuto.uiOrigin || `http://${state.gatewayAuto.address}:3456`}>Harbor Gateway</a> · {state.gatewayAuto.address}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<strong className="client-proxy-address">
|
||||
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||
</strong>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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\)/);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user