Handle rejected subscriptions and add local client compose
This commit is contained in:
@@ -108,8 +108,9 @@ export async function probeGatewayPresence({
|
|||||||
}) {
|
}) {
|
||||||
if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway');
|
if (!isIpv4(gateway)) throw new Error('Некорректный адрес default gateway');
|
||||||
|
|
||||||
|
const presenceUrl = `http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`;
|
||||||
const response = await fetchImpl(
|
const response = await fetchImpl(
|
||||||
`http://${gateway}:${port}/api/gateway-presence?nonce=${nonce}`,
|
presenceUrl,
|
||||||
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) },
|
{ headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) },
|
||||||
);
|
);
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
@@ -117,7 +118,11 @@ export async function probeGatewayPresence({
|
|||||||
throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
|
throw new Error('Текущий default gateway не является доверенным Harbor Gateway');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { gatewayId: payload.gatewayId };
|
return {
|
||||||
|
gatewayId: payload.gatewayId,
|
||||||
|
uiOrigin: new URL(presenceUrl).origin,
|
||||||
|
verifiedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeHostNetworkState(value, {
|
export function normalizeHostNetworkState(value, {
|
||||||
@@ -169,6 +174,8 @@ export function createGatewayAutoState() {
|
|||||||
failures: 0,
|
failures: 0,
|
||||||
gateway: null,
|
gateway: null,
|
||||||
gatewayId: '',
|
gatewayId: '',
|
||||||
|
uiOrigin: '',
|
||||||
|
lastVerifiedAt: null,
|
||||||
lastError: '',
|
lastError: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -199,6 +206,8 @@ export function nextGatewayAutoState(current, {
|
|||||||
mode: 'gateway-direct',
|
mode: 'gateway-direct',
|
||||||
failures: 0,
|
failures: 0,
|
||||||
gatewayId: verifiedGateway.gatewayId,
|
gatewayId: verifiedGateway.gatewayId,
|
||||||
|
uiOrigin: verifiedGateway.uiOrigin || '',
|
||||||
|
lastVerifiedAt: verifiedGateway.verifiedAt || new Date().toISOString(),
|
||||||
lastError: '',
|
lastError: '',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,14 @@ export function createStateSnapshot({
|
|||||||
: configExists ? 'running' : 'stopped';
|
: configExists ? 'running' : 'stopped';
|
||||||
const servers = stored.servers;
|
const servers = stored.servers;
|
||||||
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent';
|
||||||
|
const gatewayAutoEnabled = stored.gatewayAutoEnabled !== false;
|
||||||
|
const routeReason = mode !== 'client'
|
||||||
|
? 'gateway-host'
|
||||||
|
: !gatewayAutoEnabled
|
||||||
|
? 'disabled'
|
||||||
|
: routeMode === 'gateway-direct'
|
||||||
|
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
|
||||||
|
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
|
||||||
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
const activeLocalRules = runtime?.running ? stored.appliedRouteRules : [];
|
||||||
|
|
||||||
return assertStateSnapshot({
|
return assertStateSnapshot({
|
||||||
@@ -80,8 +88,11 @@ export function createStateSnapshot({
|
|||||||
route: {
|
route: {
|
||||||
mode: routeMode,
|
mode: routeMode,
|
||||||
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
|
||||||
lastVerifiedAt: null,
|
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
|
||||||
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
|
lastVerifiedAt: mode === 'client' ? dateOrNull(gatewayAuto?.lastVerifiedAt) : null,
|
||||||
|
autoEnabled: mode === 'client' && gatewayAutoEnabled,
|
||||||
|
fallbackPreference: mode === 'client' ? 'local-vpn' : 'none',
|
||||||
|
reason: routeReason,
|
||||||
localRules: stored.routeRules,
|
localRules: stored.routeRules,
|
||||||
activeLocalRules,
|
activeLocalRules,
|
||||||
localRulesRevision: stored.routeRulesRevision,
|
localRulesRevision: stored.routeRulesRevision,
|
||||||
@@ -122,6 +133,7 @@ export function withStateV0Compatibility(snapshot, {
|
|||||||
enabled: stored.gatewayAutoEnabled !== false,
|
enabled: stored.gatewayAutoEnabled !== false,
|
||||||
available: Boolean(gatewayAuto?.gatewayId),
|
available: Boolean(gatewayAuto?.gatewayId),
|
||||||
address: gatewayAuto?.gateway?.gateway || '',
|
address: gatewayAuto?.gateway?.gateway || '',
|
||||||
|
uiOrigin: gatewayAuto?.uiOrigin || '',
|
||||||
interface: gatewayAuto?.gateway?.interface || '',
|
interface: gatewayAuto?.gateway?.interface || '',
|
||||||
failures: Number(gatewayAuto?.failures) || 0,
|
failures: Number(gatewayAuto?.failures) || 0,
|
||||||
lastError: gatewayAuto?.lastError || '',
|
lastError: gatewayAuto?.lastError || '',
|
||||||
@@ -175,7 +187,10 @@ export function assertStateSnapshot(snapshot) {
|
|||||||
!snapshot.route ||
|
!snapshot.route ||
|
||||||
typeof snapshot.route.mode !== 'string' ||
|
typeof snapshot.route.mode !== 'string' ||
|
||||||
!nullableString(snapshot.route.gatewayAddress) ||
|
!nullableString(snapshot.route.gatewayAddress) ||
|
||||||
|
!nullableString(snapshot.route.gatewayUiOrigin) ||
|
||||||
!nullableDate(snapshot.route.lastVerifiedAt) ||
|
!nullableDate(snapshot.route.lastVerifiedAt) ||
|
||||||
|
typeof snapshot.route.autoEnabled !== 'boolean' ||
|
||||||
|
typeof snapshot.route.fallbackPreference !== 'string' ||
|
||||||
typeof snapshot.route.reason !== 'string' ||
|
typeof snapshot.route.reason !== 'string' ||
|
||||||
!Array.isArray(snapshot.route.localRules) ||
|
!Array.isArray(snapshot.route.localRules) ||
|
||||||
!snapshot.route.localRules.every(validRouteRule) ||
|
!snapshot.route.localRules.every(validRouteRule) ||
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.7.36',
|
macClient: '0.8.0',
|
||||||
gatewayClient: '0.7.34',
|
gatewayClient: '0.8.0',
|
||||||
gatewayBackend: '0.7.5',
|
gatewayBackend: '0.8.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
import { flushSync } from 'react-dom';
|
import { flushSync } from 'react-dom';
|
||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import {
|
import {
|
||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
copyText,
|
copyText,
|
||||||
isSubscriptionUrlValid,
|
isSubscriptionUrlValid,
|
||||||
localProxyUrls,
|
localProxyUrls,
|
||||||
|
routePresentation,
|
||||||
subscriptionDomain,
|
subscriptionDomain,
|
||||||
subscriptionDaysLeft,
|
subscriptionDaysLeft,
|
||||||
subscriptionUsage,
|
subscriptionUsage,
|
||||||
@@ -503,96 +504,53 @@ function AnimatedSeconds({ value, padded = true }) {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
function HarborBrand({ isGateway, gatewayAvailable, gatewayDirect, blocked, onSetGatewayAuto }) {
|
function HarborBrand({ isGateway }) {
|
||||||
const [modeAnimating, setModeAnimating] = useState(false);
|
|
||||||
const [arrowTurns, setArrowTurns] = useState(gatewayDirect ? 0.5 : 0);
|
|
||||||
const stopModeAnimationRef = useRef(false);
|
|
||||||
const previousGatewayDirectRef = useRef(gatewayDirect);
|
|
||||||
const product = isGateway ? 'Gateway' : 'Connect';
|
const product = isGateway ? 'Gateway' : 'Connect';
|
||||||
const switchable = !isGateway && gatewayAvailable;
|
return (
|
||||||
const label = gatewayDirect
|
<div className={`harbor-brand is-${product.toLowerCase()}`} aria-label={`Harbor ${product}`}>
|
||||||
? 'Игнорировать Harbor Gateway и использовать локальный VPN'
|
<div className="harbor-brand-content">
|
||||||
: 'Использовать обнаруженный 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">
|
<svg viewBox="0 0 32 32" aria-hidden="true">
|
||||||
<circle cx="16" cy="6" r="3" />
|
<circle cx="16" cy="6" r="3" />
|
||||||
<path d="M16 9v15M10 14h12" />
|
<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-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" />
|
<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>
|
</svg>
|
||||||
<span className="harbor-brand-name">
|
<span className="harbor-brand-name"><strong>Harbor</strong><em>{product}</em></span>
|
||||||
<strong>Harbor</strong>
|
</div>
|
||||||
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function RouteStatus({ route, blocked, onSetGatewayAuto }) {
|
||||||
|
const presentation = routePresentation(route);
|
||||||
|
const verifiedAt = route?.lastVerifiedAt && Number.isFinite(Date.parse(route.lastVerifiedAt))
|
||||||
|
? new Date(route.lastVerifiedAt)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return <section className={`client-route-status is-${presentation.tone}`} aria-label="Маршрут Connect">
|
||||||
|
<span>Маршрут</span>
|
||||||
|
<strong aria-live="polite">{presentation.title}</strong>
|
||||||
|
<small>{presentation.detail}</small>
|
||||||
|
{route?.gatewayAddress && <div className="client-route-gateway">
|
||||||
|
{route.gatewayUiOrigin
|
||||||
|
? <a href={route.gatewayUiOrigin}>Harbor Gateway · {route.gatewayAddress}</a>
|
||||||
|
: <span>Harbor Gateway · {route.gatewayAddress}</span>}
|
||||||
|
{verifiedAt && <time dateTime={route.lastVerifiedAt}>
|
||||||
|
Проверен {verifiedAt.toLocaleString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
|
||||||
|
</time>}
|
||||||
|
</div>}
|
||||||
|
<label className="client-route-auto">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={route?.autoEnabled !== false}
|
||||||
|
disabled={blocked}
|
||||||
|
onChange={(event) => onSetGatewayAuto(event.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>Использовать домашний Gateway автоматически</span>
|
||||||
|
</label>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientOverviewPage({
|
export function ClientOverviewPage({
|
||||||
state,
|
state,
|
||||||
versionInfo,
|
versionInfo,
|
||||||
@@ -614,8 +572,13 @@ export function ClientOverviewPage({
|
|||||||
onDismissError,
|
onDismissError,
|
||||||
}) {
|
}) {
|
||||||
const isGateway = state?.mode === 'gateway';
|
const isGateway = state?.mode === 'gateway';
|
||||||
const gatewayDirect = !isGateway && state?.gatewayAuto?.mode === 'gateway-direct';
|
const route = {
|
||||||
const gatewayAvailable = !isGateway && Boolean(state?.gatewayAuto?.available);
|
...(state?.route || {}),
|
||||||
|
autoEnabled: state?.route?.autoEnabled ?? state?.gatewayAuto?.enabled ?? true,
|
||||||
|
gatewayUiOrigin: state?.route?.gatewayUiOrigin || state?.gatewayAuto?.uiOrigin || null,
|
||||||
|
};
|
||||||
|
const gatewayDirect = !isGateway && route.mode === 'gateway-direct';
|
||||||
|
const gatewayStale = gatewayDirect && route.reason === 'gateway-stale';
|
||||||
const connected = Boolean(state?.singboxRunning);
|
const connected = Boolean(state?.singboxRunning);
|
||||||
const hasSubscription = Boolean(state?.hasSubscription);
|
const hasSubscription = Boolean(state?.hasSubscription);
|
||||||
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
|
const selectedServerId = pendingServerId || state?.selection?.desiredServerId || '';
|
||||||
@@ -667,7 +630,7 @@ export function ClientOverviewPage({
|
|||||||
const usage = subscriptionUsage(state?.userInfo);
|
const usage = subscriptionUsage(state?.userInfo);
|
||||||
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
||||||
const connectionTitle = connected
|
const connectionTitle = connected
|
||||||
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
? gatewayStale ? 'Gateway проверяется' : gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
||||||
: 'Подключение выключено';
|
: 'Подключение выключено';
|
||||||
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||||||
const hasUsage = Boolean(
|
const hasUsage = Boolean(
|
||||||
@@ -1055,19 +1018,13 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}
|
className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}${gatewayDirect ? ' is-gateway-route' : ''}`}
|
||||||
>
|
>
|
||||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||||
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
<div className="client-live-region" role="status" aria-live="polite" aria-atomic="true">
|
||||||
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
{copyFeedback ? copyFeedback.failed ? 'Не удалось скопировать' : 'Скопировано' : ''}
|
||||||
</div>
|
</div>
|
||||||
<HarborBrand
|
<HarborBrand isGateway={isGateway} />
|
||||||
isGateway={isGateway}
|
|
||||||
gatewayAvailable={gatewayAvailable}
|
|
||||||
gatewayDirect={gatewayDirect}
|
|
||||||
blocked={gatewayAutoBlocked}
|
|
||||||
onSetGatewayAuto={onSetGatewayAuto}
|
|
||||||
/>
|
|
||||||
{hasSubscription && subscriptionContentReady && <button
|
{hasSubscription && subscriptionContentReady && <button
|
||||||
ref={instructionsToggleRef}
|
ref={instructionsToggleRef}
|
||||||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||||||
@@ -1136,7 +1093,8 @@ export function ClientOverviewPage({
|
|||||||
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
|
<h2 id="connection-title" className="client-connection-title" aria-label={connectionTitle}>
|
||||||
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
|
<span className={!connected ? 'is-active' : ''} aria-hidden="true">Подключение выключено</span>
|
||||||
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
|
<span className={connected && !gatewayDirect ? 'is-active' : ''} aria-hidden="true">VPN включён</span>
|
||||||
<span className={connected && gatewayDirect ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
|
<span className={connected && gatewayDirect && !gatewayStale ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
|
||||||
|
<span className={connected && gatewayStale ? 'is-active' : ''} aria-hidden="true">Gateway проверяется</span>
|
||||||
</h2>
|
</h2>
|
||||||
<div className="client-state-detail">
|
<div className="client-state-detail">
|
||||||
{connected ? (
|
{connected ? (
|
||||||
@@ -1187,6 +1145,12 @@ export function ClientOverviewPage({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{!isGateway && <RouteStatus
|
||||||
|
route={route}
|
||||||
|
blocked={gatewayAutoBlocked}
|
||||||
|
onSetGatewayAuto={onSetGatewayAuto}
|
||||||
|
/>}
|
||||||
|
|
||||||
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
||||||
{isGateway && (
|
{isGateway && (
|
||||||
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
|
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
|
||||||
@@ -1235,14 +1199,6 @@ export function ClientOverviewPage({
|
|||||||
aria-labelledby={isGateway ? 'client-access-tab-proxy' : undefined}
|
aria-labelledby={isGateway ? 'client-access-tab-proxy' : undefined}
|
||||||
hidden={isGateway && accessTab !== 'proxy'}
|
hidden={isGateway && accessTab !== 'proxy'}
|
||||||
>
|
>
|
||||||
{!isGateway && (
|
|
||||||
<span className={`client-proxy-label${gatewayDirect ? ' is-gateway' : ''}`}>
|
|
||||||
<span className={!gatewayDirect ? 'is-active' : ''}>Локальный VPN</span>
|
|
||||||
<span className={gatewayDirect ? 'is-active' : ''}>
|
|
||||||
Через <a href={`http://${state.gatewayAuto.address}:3456`}>Harbor Gateway</a> · {state.gatewayAuto.address}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<strong className="client-proxy-address">
|
<strong className="client-proxy-address">
|
||||||
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
{proxyUrls.http.replace(/^https?:\/\//, '')}
|
||||||
</strong>
|
</strong>
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ p {
|
|||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-shell:has(.harbor-brand.is-gateway-active) {
|
.client-shell.is-gateway-route {
|
||||||
--client-accent: var(--harbor-gateway);
|
--client-accent: var(--harbor-gateway);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -327,32 +327,6 @@ p {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.harbor-brand.is-switchable {
|
|
||||||
pointer-events: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand-control {
|
|
||||||
position: relative;
|
|
||||||
appearance: none;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 7px;
|
|
||||||
padding: 3px 5px;
|
|
||||||
background: transparent;
|
|
||||||
color: inherit;
|
|
||||||
font: inherit;
|
|
||||||
letter-spacing: inherit;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand-control:focus-visible {
|
|
||||||
outline: 1px solid color-mix(in srgb, var(--harbor-gateway) 55%, transparent);
|
|
||||||
outline-offset: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand-control:disabled {
|
|
||||||
cursor: wait;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-shell.is-first-run .harbor-brand {
|
.client-shell.is-first-run .harbor-brand {
|
||||||
transform: translate(-50%, calc(-50% - 72px)) scale(1.35);
|
transform: translate(-50%, calc(-50% - 72px)) scale(1.35);
|
||||||
}
|
}
|
||||||
@@ -422,246 +396,6 @@ p {
|
|||||||
color: var(--harbor-gateway);
|
color: var(--harbor-gateway);
|
||||||
}
|
}
|
||||||
|
|
||||||
.harbor-mode-control,
|
|
||||||
.harbor-mode-stack {
|
|
||||||
position: relative;
|
|
||||||
display: inline-block;
|
|
||||||
width: 4.5em;
|
|
||||||
height: 1.25em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-stack {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-stack em {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 auto auto 0;
|
|
||||||
transition: color 420ms ease, opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand .harbor-mode-connect {
|
|
||||||
z-index: 2;
|
|
||||||
color: var(--harbor-connect);
|
|
||||||
animation: harbor-mode-ambient-front 10s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand .harbor-mode-gateway {
|
|
||||||
z-index: 1;
|
|
||||||
color: var(--harbor-gateway);
|
|
||||||
opacity: 0.2;
|
|
||||||
filter: blur(0.4px);
|
|
||||||
transform: translate(0.42em, 0.34em) scale(0.96);
|
|
||||||
animation: harbor-mode-ambient-back 10s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-gateway > span {
|
|
||||||
display: block;
|
|
||||||
animation: harbor-mode-discovered 520ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand.is-gateway-active .harbor-mode-connect {
|
|
||||||
z-index: 1;
|
|
||||||
opacity: 0.18;
|
|
||||||
filter: blur(0.4px);
|
|
||||||
transform: translate(0.42em, 0.34em) scale(0.96);
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand.is-gateway-active .harbor-mode-gateway {
|
|
||||||
z-index: 2;
|
|
||||||
opacity: 1;
|
|
||||||
filter: none;
|
|
||||||
transform: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-mode-discovered {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(3px);
|
|
||||||
transform: translateX(4.8em);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand.is-gateway-active .harbor-mode-connect {
|
|
||||||
animation-name: harbor-mode-ambient-back;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand.is-gateway-active .harbor-mode-gateway {
|
|
||||||
animation-name: harbor-mode-ambient-front;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand-control.is-mode-animating .harbor-mode-connect {
|
|
||||||
animation: harbor-mode-float-front 1500ms ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand-control.is-mode-animating .harbor-mode-gateway {
|
|
||||||
animation: harbor-mode-float-back 1500ms ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand.is-gateway-active .harbor-brand-control.is-mode-animating .harbor-mode-connect {
|
|
||||||
animation-name: harbor-mode-float-back;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand.is-gateway-active .harbor-brand-control.is-mode-animating .harbor-mode-gateway {
|
|
||||||
animation-name: harbor-mode-float-front;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-mode-float-front {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(0.12em); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-mode-float-back {
|
|
||||||
0%, 100% { transform: translate(0.42em, 0.34em) scale(0.96); }
|
|
||||||
50% { transform: translate(0.42em, 0.22em) scale(0.96); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-mode-ambient-front {
|
|
||||||
0%, 88%, 96%, 100% { transform: translateY(0); }
|
|
||||||
92% { transform: translateY(0.08em); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-mode-ambient-back {
|
|
||||||
0%, 88%, 96%, 100% { transform: translate(0.42em, 0.34em) scale(0.96); }
|
|
||||||
92% { transform: translate(0.42em, 0.26em) scale(0.96); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand .harbor-mode-swap {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
left: calc(100% + 4px);
|
|
||||||
width: 14px;
|
|
||||||
height: 14px;
|
|
||||||
opacity: 0.82;
|
|
||||||
stroke-width: 1.35;
|
|
||||||
transform: translateY(-50%);
|
|
||||||
transition: color 320ms ease, opacity 320ms ease, transform 420ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
animation: harbor-mode-arrows-discovered 260ms 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-mode-arrows-discovered {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(2px);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-swap g {
|
|
||||||
transform-box: view-box;
|
|
||||||
transform-origin: 9px 9px;
|
|
||||||
transform: rotate(var(--harbor-arrow-turn, 0turn));
|
|
||||||
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-swap path {
|
|
||||||
transform-box: fill-box;
|
|
||||||
transform-origin: center;
|
|
||||||
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-swap .is-connect {
|
|
||||||
opacity: 1;
|
|
||||||
stroke: var(--harbor-connect);
|
|
||||||
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-swap .is-connect path {
|
|
||||||
animation: harbor-arrow-ambient-right 10s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-swap .is-gateway {
|
|
||||||
opacity: 0.28;
|
|
||||||
filter: blur(0.45px);
|
|
||||||
stroke: var(--harbor-gateway);
|
|
||||||
transition: opacity 420ms ease, filter 420ms ease, transform 560ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-swap .is-gateway path {
|
|
||||||
animation: harbor-arrow-ambient-left 10s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand.is-gateway-active .harbor-mode-swap .is-connect {
|
|
||||||
opacity: 0.28;
|
|
||||||
filter: blur(0.45px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand.is-gateway-active .harbor-mode-swap .is-gateway {
|
|
||||||
opacity: 1;
|
|
||||||
filter: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-tooltip {
|
|
||||||
position: absolute;
|
|
||||||
bottom: calc(100% + 9px);
|
|
||||||
left: 0;
|
|
||||||
width: calc(100% + 22px);
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
padding: 5px 6px;
|
|
||||||
border-radius: 5px;
|
|
||||||
background: oklch(0.14 0.012 145);
|
|
||||||
box-shadow: 0 5px 18px oklch(0.08 0.015 145 / 0.1);
|
|
||||||
color: oklch(0.88 0.012 145);
|
|
||||||
font-size: 4px;
|
|
||||||
line-height: 1.5;
|
|
||||||
letter-spacing: 0;
|
|
||||||
text-align: left;
|
|
||||||
opacity: 0;
|
|
||||||
visibility: hidden;
|
|
||||||
filter: blur(2px);
|
|
||||||
pointer-events: none;
|
|
||||||
transform: translateY(3px);
|
|
||||||
transition: opacity 90ms ease, filter 120ms ease, transform 140ms cubic-bezier(0.16, 1, 0.3, 1), visibility 0s 140ms;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-mode-tooltip strong {
|
|
||||||
color: oklch(0.96 0.008 145);
|
|
||||||
font-size: 4.2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand-control:hover .harbor-mode-tooltip,
|
|
||||||
.harbor-brand-control:focus-visible .harbor-mode-tooltip {
|
|
||||||
opacity: 1;
|
|
||||||
visibility: visible;
|
|
||||||
filter: blur(0);
|
|
||||||
transform: translateY(0);
|
|
||||||
transition-delay: 20ms, 20ms, 20ms, 0s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand-control:hover .harbor-mode-swap,
|
|
||||||
.harbor-brand-control:focus-visible .harbor-mode-swap {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand-control.is-mode-animating .harbor-mode-swap .is-connect path {
|
|
||||||
animation: harbor-arrow-float-right 1500ms ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
.harbor-brand-control.is-mode-animating .harbor-mode-swap .is-gateway path {
|
|
||||||
animation: harbor-arrow-float-left 1500ms ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-arrow-float-right {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
50% { transform: translateX(1.25px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-arrow-float-left {
|
|
||||||
0%, 100% { transform: translateX(0); }
|
|
||||||
50% { transform: translateX(-1.25px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-arrow-ambient-right {
|
|
||||||
0%, 88%, 96%, 100% { transform: translateX(0); }
|
|
||||||
92% { transform: translateX(0.75px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes harbor-arrow-ambient-left {
|
|
||||||
0%, 88%, 96%, 100% { transform: translateX(0); }
|
|
||||||
92% { transform: translateX(-0.75px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-instructions-toggle {
|
.client-instructions-toggle {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 50%;
|
top: 50%;
|
||||||
@@ -2235,7 +1969,7 @@ p {
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-shell:has(.harbor-brand.is-gateway-active) .client-form:not(.is-waiting) {
|
.client-shell.is-gateway-route .client-form:not(.is-waiting) {
|
||||||
opacity: 0.34;
|
opacity: 0.34;
|
||||||
filter: grayscale(1) saturate(0);
|
filter: grayscale(1) saturate(0);
|
||||||
}
|
}
|
||||||
@@ -3130,6 +2864,104 @@ p {
|
|||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-route-status {
|
||||||
|
width: min(280px, 100%);
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-status > span {
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-status > strong {
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 12px;
|
||||||
|
transition: color 420ms ease, text-shadow 520ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-status.is-gateway > strong {
|
||||||
|
color: var(--harbor-gateway);
|
||||||
|
text-shadow: 0 0 10px color-mix(in oklch, var(--harbor-gateway) 35%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-status.is-stale > strong,
|
||||||
|
.client-route-status.is-lost > strong {
|
||||||
|
color: var(--client-warning, oklch(0.72 0.12 72));
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-status > small {
|
||||||
|
max-width: 38ch;
|
||||||
|
min-height: 30px;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-gateway {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 9px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-gateway a {
|
||||||
|
color: var(--client-accent);
|
||||||
|
font-weight: 700;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-gateway a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-gateway a:focus-visible {
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-gateway time {
|
||||||
|
white-space: nowrap;
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-auto {
|
||||||
|
min-height: 44px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-auto input {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--client-accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-auto:has(input:focus-visible) {
|
||||||
|
color: var(--client-text);
|
||||||
|
outline: 2px solid var(--client-accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-route-auto:has(input:disabled) {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
.client-proxies {
|
.client-proxies {
|
||||||
display: grid;
|
display: grid;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
@@ -3142,53 +2974,6 @@ p {
|
|||||||
min-height: 91px;
|
min-height: 91px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-proxy-label {
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
color: var(--client-muted);
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 700;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-proxy-label > span {
|
|
||||||
grid-area: 1 / 1;
|
|
||||||
opacity: 0;
|
|
||||||
filter: blur(3px);
|
|
||||||
pointer-events: none;
|
|
||||||
transform: translateX(-12px);
|
|
||||||
transition: color 700ms ease, opacity 520ms ease, filter 620ms ease, transform 700ms cubic-bezier(0.16, 1, 0.3, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-proxy-label > span:last-child {
|
|
||||||
color: var(--client-accent);
|
|
||||||
transform: translateX(12px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-proxy-label > span.is-active {
|
|
||||||
opacity: 1;
|
|
||||||
filter: blur(0);
|
|
||||||
pointer-events: auto;
|
|
||||||
transform: translateX(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-proxy-label a {
|
|
||||||
color: var(--client-accent);
|
|
||||||
text-decoration: none;
|
|
||||||
text-shadow: 0 0 10px color-mix(in oklch, var(--client-accent) 42%, transparent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-proxy-label a:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-proxy-label a:focus-visible {
|
|
||||||
outline: 2px solid var(--client-accent);
|
|
||||||
outline-offset: 2px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-proxy-address {
|
.client-proxy-address {
|
||||||
color: var(--client-text);
|
color: var(--client-text);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -3546,6 +3331,11 @@ p {
|
|||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-route-auto {
|
||||||
|
min-height: 44px;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.client-server-filters button,
|
.client-server-filters button,
|
||||||
.client-server-mode-toggle,
|
.client-server-mode-toggle,
|
||||||
.client-server-group-toggle,
|
.client-server-group-toggle,
|
||||||
@@ -3654,6 +3444,7 @@ p {
|
|||||||
.client-server-mode-panel .client-server-pinned,
|
.client-server-mode-panel .client-server-pinned,
|
||||||
.client-server-mode-panel .client-server-scroll,
|
.client-server-mode-panel .client-server-scroll,
|
||||||
.client-server-favorite,
|
.client-server-favorite,
|
||||||
|
.client-route-status > strong,
|
||||||
.client-server-group-toggle,
|
.client-server-group-toggle,
|
||||||
.client-server-more,
|
.client-server-more,
|
||||||
.client-copy-button,
|
.client-copy-button,
|
||||||
@@ -3677,8 +3468,7 @@ p {
|
|||||||
.client-subscription-edit::after,
|
.client-subscription-edit::after,
|
||||||
.client-subscription-submit,
|
.client-subscription-submit,
|
||||||
.client-subscription-summary,
|
.client-subscription-summary,
|
||||||
.client-subscription-summary strong,
|
.client-subscription-summary strong {
|
||||||
.client-proxy-label > span {
|
|
||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
@@ -3715,16 +3505,6 @@ p {
|
|||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.harbor-mode-stack em,
|
|
||||||
.harbor-mode-gateway > span,
|
|
||||||
.harbor-mode-swap g,
|
|
||||||
.harbor-mode-swap path,
|
|
||||||
.harbor-brand .harbor-mode-swap,
|
|
||||||
.harbor-mode-tooltip {
|
|
||||||
transition: none;
|
|
||||||
animation: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-form-content,
|
.client-form-content,
|
||||||
.client-confirmation-popup,
|
.client-confirmation-popup,
|
||||||
.client-confirmation-dialog,
|
.client-confirmation-dialog,
|
||||||
|
|||||||
@@ -77,6 +77,42 @@ export function localProxyUrls(port = 8082, host = '127.0.0.1') {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function routePresentation(route = {}) {
|
||||||
|
if (route.mode === 'gateway-direct') {
|
||||||
|
return route.reason === 'gateway-stale'
|
||||||
|
? {
|
||||||
|
title: 'Harbor Gateway проверяется',
|
||||||
|
detail: 'Последняя проверка успешна, но свежего ответа пока нет.',
|
||||||
|
tone: 'stale',
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
title: 'Через Harbor Gateway',
|
||||||
|
detail: 'Подпиской и сервером сейчас управляет Gateway.',
|
||||||
|
tone: 'gateway',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (route.mode === 'local-vpn') {
|
||||||
|
if (route.reason === 'gateway-lost') {
|
||||||
|
return {
|
||||||
|
title: 'Локальный VPN · Gateway недоступен',
|
||||||
|
detail: 'Harbor вернулся к локальному VPN. При восстановлении Gateway авторежим переключится обратно.',
|
||||||
|
tone: 'lost',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
title: 'Локальный VPN',
|
||||||
|
detail: route.reason === 'disabled'
|
||||||
|
? 'Автопереход на домашний Gateway выключен.'
|
||||||
|
: 'Connect использует выбранный здесь VPN-сервер.',
|
||||||
|
tone: 'local',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (route.mode === 'direct') {
|
||||||
|
return { title: 'Прямое подключение', detail: 'Трафик не идёт через VPN.', tone: 'direct' };
|
||||||
|
}
|
||||||
|
return { title: 'Маршрут неизвестен', detail: 'Harbor ещё не подтвердил текущий маршрут.', tone: 'unknown' };
|
||||||
|
}
|
||||||
|
|
||||||
export async function copyText(text, options = {}) {
|
export async function copyText(text, options = {}) {
|
||||||
const clipboard = options.clipboard ?? globalThis.navigator?.clipboard;
|
const clipboard = options.clipboard ?? globalThis.navigator?.clipboard;
|
||||||
const documentRef = options.documentRef ?? globalThis.document;
|
const documentRef = options.documentRef ?? globalThis.document;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
readHostNetworkState,
|
readHostNetworkState,
|
||||||
verifyGatewayPresence,
|
verifyGatewayPresence,
|
||||||
} from '../../src/server/gatewayPresence.js';
|
} from '../../src/server/gatewayPresence.js';
|
||||||
|
import { createStateSnapshot } from '../../src/shared/contracts/state.js';
|
||||||
|
|
||||||
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
|
const subscriptionUrl = 'https://subscription.example/0123456789abcdef0123456789abcdef';
|
||||||
const nonce = '0123456789abcdef0123456789abcdef';
|
const nonce = '0123456789abcdef0123456789abcdef';
|
||||||
@@ -34,16 +35,19 @@ test('Gateway presence is authenticated by the shared subscription secret', asyn
|
|||||||
const result = await probeGatewayPresence({
|
const result = await probeGatewayPresence({
|
||||||
gateway: '192.168.50.111',
|
gateway: '192.168.50.111',
|
||||||
subscriptionUrl,
|
subscriptionUrl,
|
||||||
|
port: 4567,
|
||||||
nonce,
|
nonce,
|
||||||
fetchImpl: async (url) => {
|
fetchImpl: async (url) => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
url,
|
url,
|
||||||
`http://192.168.50.111:3456/api/gateway-presence?nonce=${nonce}`,
|
`http://192.168.50.111:4567/api/gateway-presence?nonce=${nonce}`,
|
||||||
);
|
);
|
||||||
return { ok: true, json: async () => payload };
|
return { ok: true, json: async () => payload };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.deepEqual(result, { gatewayId: 'gateway-1' });
|
assert.equal(result.gatewayId, 'gateway-1');
|
||||||
|
assert.equal(result.uiOrigin, 'http://192.168.50.111:4567');
|
||||||
|
assert.equal(Number.isFinite(Date.parse(result.verifiedAt)), true);
|
||||||
|
|
||||||
assert.equal(buildGatewayPresence({
|
assert.equal(buildGatewayPresence({
|
||||||
appMode: 'gateway',
|
appMode: 'gateway',
|
||||||
@@ -88,9 +92,15 @@ test('host route freshness and Gateway failures drive a safe automatic fallback'
|
|||||||
|
|
||||||
let state = nextGatewayAutoState(createGatewayAutoState(), {
|
let state = nextGatewayAutoState(createGatewayAutoState(), {
|
||||||
network,
|
network,
|
||||||
verifiedGateway: { gatewayId: 'gateway-1' },
|
verifiedGateway: {
|
||||||
|
gatewayId: 'gateway-1',
|
||||||
|
uiOrigin: 'http://192.168.50.111:4567',
|
||||||
|
verifiedAt: new Date(now).toISOString(),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
assert.equal(state.mode, 'gateway-direct');
|
assert.equal(state.mode, 'gateway-direct');
|
||||||
|
assert.equal(state.uiOrigin, 'http://192.168.50.111:4567');
|
||||||
|
assert.equal(state.lastVerifiedAt, new Date(now).toISOString());
|
||||||
|
|
||||||
state = nextGatewayAutoState(state, { network });
|
state = nextGatewayAutoState(state, { network });
|
||||||
state = nextGatewayAutoState(state, { network });
|
state = nextGatewayAutoState(state, { network });
|
||||||
@@ -116,6 +126,44 @@ test('host route freshness and Gateway failures drive a safe automatic fallback'
|
|||||||
assert.equal(readHostNetworkState(statePath, { now }), null);
|
assert.equal(readHostNetworkState(statePath, { now }), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('canonical route distinguishes fresh, stale, lost, disabled and local states', () => {
|
||||||
|
const storedState = {
|
||||||
|
revision: 1,
|
||||||
|
subscriptionUrl,
|
||||||
|
gatewayAutoEnabled: true,
|
||||||
|
};
|
||||||
|
const snapshot = (gatewayAuto, stored = storedState) => createStateSnapshot({
|
||||||
|
storedState: stored,
|
||||||
|
runtime: { running: true },
|
||||||
|
gatewayAuto,
|
||||||
|
appMode: 'client',
|
||||||
|
configExists: true,
|
||||||
|
subscriptionHost: 'subscription.example/…',
|
||||||
|
now: new Date('2026-07-13T12:00:00.000Z'),
|
||||||
|
}).route;
|
||||||
|
const fresh = {
|
||||||
|
...createGatewayAutoState(),
|
||||||
|
mode: 'gateway-direct',
|
||||||
|
gateway: { gateway: '192.168.50.111' },
|
||||||
|
gatewayId: 'gateway-1',
|
||||||
|
uiOrigin: 'http://192.168.50.111:4567',
|
||||||
|
lastVerifiedAt: '2026-07-13T11:59:59.000Z',
|
||||||
|
};
|
||||||
|
|
||||||
|
const found = snapshot(fresh);
|
||||||
|
assert.equal(found.mode, 'gateway-direct');
|
||||||
|
assert.equal(found.reason, 'gateway-found');
|
||||||
|
assert.equal(found.gatewayAddress, '192.168.50.111');
|
||||||
|
assert.equal(found.gatewayUiOrigin, 'http://192.168.50.111:4567');
|
||||||
|
assert.equal(found.lastVerifiedAt, '2026-07-13T11:59:59.000Z');
|
||||||
|
assert.equal(found.autoEnabled, true);
|
||||||
|
assert.equal(found.fallbackPreference, 'local-vpn');
|
||||||
|
assert.equal(snapshot({ ...fresh, failures: 1 }).reason, 'gateway-stale');
|
||||||
|
assert.equal(snapshot({ ...fresh, mode: 'local-vpn', gatewayId: '', lastError: 'lost' }).reason, 'gateway-lost');
|
||||||
|
assert.equal(snapshot(fresh, { ...storedState, gatewayAutoEnabled: false }).reason, 'disabled');
|
||||||
|
assert.equal(snapshot(createGatewayAutoState()).reason, 'local');
|
||||||
|
});
|
||||||
|
|
||||||
test('client can ignore and restore a verified Gateway without losing discovery', () => {
|
test('client can ignore and restore a verified Gateway without losing discovery', () => {
|
||||||
const detected = {
|
const detected = {
|
||||||
...createGatewayAutoState(),
|
...createGatewayAutoState(),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
formatConnectionDurationWords,
|
formatConnectionDurationWords,
|
||||||
isSubscriptionUrlValid,
|
isSubscriptionUrlValid,
|
||||||
localProxyUrls,
|
localProxyUrls,
|
||||||
|
routePresentation,
|
||||||
subscriptionDomain,
|
subscriptionDomain,
|
||||||
subscriptionDaysLeft,
|
subscriptionDaysLeft,
|
||||||
subscriptionUsage,
|
subscriptionUsage,
|
||||||
@@ -74,6 +75,14 @@ test('gateway proxy URLs use its network address', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('route copy distinguishes found, stale, lost, disabled and local states', () => {
|
||||||
|
assert.equal(routePresentation({ mode: 'gateway-direct', reason: 'gateway-found' }).title, 'Через Harbor Gateway');
|
||||||
|
assert.equal(routePresentation({ mode: 'gateway-direct', reason: 'gateway-stale' }).tone, 'stale');
|
||||||
|
assert.match(routePresentation({ mode: 'local-vpn', reason: 'gateway-lost' }).title, /Gateway недоступен/);
|
||||||
|
assert.match(routePresentation({ mode: 'local-vpn', reason: 'disabled' }).detail, /выключен/);
|
||||||
|
assert.equal(routePresentation({ mode: 'local-vpn', reason: 'local' }).title, 'Локальный VPN');
|
||||||
|
});
|
||||||
|
|
||||||
test('copy uses the synchronous native path available on gateway HTTP', async () => {
|
test('copy uses the synchronous native path available on gateway HTTP', async () => {
|
||||||
const textarea = {
|
const textarea = {
|
||||||
style: {},
|
style: {},
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ test('tooltips stay opaque, above adjacent content, and do not stick after point
|
|||||||
assert.match(styles, /\.client-tooltip-anchor:has\(> :focus-visible\) > \.client-tooltip/);
|
assert.match(styles, /\.client-tooltip-anchor:has\(> :focus-visible\) > \.client-tooltip/);
|
||||||
assert.doesNotMatch(styles, /\.client-tooltip-anchor:focus-within > \.client-tooltip/);
|
assert.doesNotMatch(styles, /\.client-tooltip-anchor:focus-within > \.client-tooltip/);
|
||||||
assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
|
assert.match(rule('.harbor-version-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
|
||||||
assert.match(rule('.harbor-mode-tooltip'), /background:\s*oklch\(0\.14 0\.012 145\)/);
|
assert.doesNotMatch(component, /harbor-mode-tooltip|harbor-brand-control/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('subscription validation waits for the provider and keeps diagnostics below errors', () => {
|
test('subscription validation waits for the provider and keeps diagnostics below errors', () => {
|
||||||
|
|||||||
@@ -69,3 +69,13 @@ test('copy feedback, drawers and Gateway tabs expose complete keyboard semantics
|
|||||||
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/);
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-copy-button \{[\s\S]*min-height: 44px/);
|
||||||
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-local-rule-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
|
assert.match(styles, /@media \(max-width: 560px\)[\s\S]*\.client-local-rule-enabled,[\s\S]*\.client-local-rule-delete \{[\s\S]*width: 44px;[\s\S]*height: 44px/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('Connect route uses a labelled native Auto control instead of the logo', () => {
|
||||||
|
assert.match(component, /aria-label="Маршрут Connect"/);
|
||||||
|
assert.match(component, /type="checkbox"[\s\S]*checked={route\?\.autoEnabled !== false}/);
|
||||||
|
assert.match(component, /onChange={\(event\) => onSetGatewayAuto\(event\.target\.checked\)}/);
|
||||||
|
assert.match(component, /Использовать домашний Gateway автоматически/);
|
||||||
|
assert.doesNotMatch(component, /harbor-brand-control|:3456/);
|
||||||
|
assert.match(styles, /\.client-route-auto \{[\s\S]*min-height: 44px/);
|
||||||
|
assert.match(styles, /\.client-route-auto:has\(input:focus-visible\)/);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user