Bump Harbor versions and surface gateway mode in overview
This commit is contained in:
@@ -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">
|
||||
<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" />
|
||||
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 className="harbor-brand-name"><strong>Harbor</strong><em>{product}</em></span>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user