1519 lines
62 KiB
JavaScript
1519 lines
62 KiB
JavaScript
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||
import { flushSync } from 'react-dom';
|
||
import { api } from '../api.js';
|
||
import {
|
||
accessTabForKey,
|
||
connectionAction,
|
||
connectionDurationParts,
|
||
copyText,
|
||
isSubscriptionUrlValid,
|
||
localProxyUrls,
|
||
subscriptionDomain,
|
||
subscriptionDaysLeft,
|
||
subscriptionUsage,
|
||
} from '../utils/clientControls.js';
|
||
import { formatBytes } from '../utils/format.js';
|
||
import { instructionBlocks } from '../instructions.js';
|
||
import { operationBlocked } from '../state/operations.js';
|
||
import { ConfirmationPopup } from './ConfirmationPopup.jsx';
|
||
import { ServerPicker } from './ServerPicker.jsx';
|
||
import { ERROR_DEFINITIONS } from '../../shared/errors.js';
|
||
import { canAppendRouteRule } from '../../shared/routingRules.js';
|
||
import {
|
||
HARBOR_VERSIONS,
|
||
parseVersion,
|
||
versionCompatibility,
|
||
} from '../../shared/versions.js';
|
||
|
||
const SUBSCRIPTION_REVEAL_DELAY_MS = 1350;
|
||
const DURATION_MODE_STORAGE_KEY = 'harbor-duration-mode';
|
||
const ACCESS_TABS = [
|
||
['gateway', 'Gateway'],
|
||
['proxy', 'Gateway Proxy'],
|
||
];
|
||
|
||
function CloudTooltip({ children }) {
|
||
return <span className="client-tooltip" role="tooltip">{children}</span>;
|
||
}
|
||
|
||
const VERSION_PARTS = [
|
||
['major', 'Major'],
|
||
['minor', 'Minor'],
|
||
['hotfix', 'Hotfix'],
|
||
];
|
||
|
||
function VersionBadge({ code, component, componentKey, version, runtime, incompatible = false }) {
|
||
const parsed = parseVersion(version);
|
||
const values = parsed ? VERSION_PARTS.map(([key]) => parsed[key]) : ['–', '–', '–'];
|
||
|
||
function description(key) {
|
||
if (key === 'major') {
|
||
return 'Уровень совместимости всей экосистемы. Все совместно работающие компоненты и клиенты должны иметь одинаковый major.';
|
||
}
|
||
if (key === 'minor') {
|
||
return 'Уровень конкретного компонента и тесно связанной с ним части системы. Остальные клиенты с тем же major могут обновляться отдельно.';
|
||
}
|
||
return 'Локальное совместимое исправление этой версии. Не требует обновлять связанные компоненты или другие клиенты.';
|
||
}
|
||
|
||
return (
|
||
<div className={`harbor-version${incompatible ? ' is-incompatible' : ''}`}>
|
||
<span className="harbor-version-code" aria-hidden="true">{code}</span>
|
||
<span className="harbor-version-number" aria-label={`${component} ${version || 'не определена'}`}>
|
||
{VERSION_PARTS.map(([key, label], index) => {
|
||
const tooltipId = `harbor-version-${componentKey}-${key}`;
|
||
return <React.Fragment key={key}>
|
||
{index > 0 && <span className="harbor-version-dot" aria-hidden="true">.</span>}
|
||
<span
|
||
className="harbor-version-part"
|
||
tabIndex="0"
|
||
aria-describedby={tooltipId}
|
||
>
|
||
{values[index]}
|
||
<span className="harbor-version-tooltip" id={tooltipId} role="tooltip">
|
||
<strong>{component} · {label} {values[index]}</strong>
|
||
<span>{description(key)}</span>
|
||
{runtime && <small>{runtime}</small>}
|
||
{incompatible && <small className="is-warning">Версии Gateway несовместимы.</small>}
|
||
</span>
|
||
</span>
|
||
</React.Fragment>;
|
||
})}
|
||
</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function VersionDisplay({ isGateway, versionInfo }) {
|
||
const runtimeSingBox = versionInfo?.runtime?.singBox;
|
||
if (!isGateway) {
|
||
return <aside className="harbor-versions" aria-label="Версия Harbor">
|
||
<VersionBadge
|
||
code="M"
|
||
component="Mac client"
|
||
componentKey="macClient"
|
||
version={versionInfo?.components?.macClient || HARBOR_VERSIONS.macClient}
|
||
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
|
||
/>
|
||
</aside>;
|
||
}
|
||
|
||
const backendVersion = versionInfo?.components?.gatewayBackend;
|
||
const dataplaneVersion = versionInfo?.runtime?.dataplaneVersion;
|
||
const compatibility = backendVersion && versionCompatibility({
|
||
...HARBOR_VERSIONS,
|
||
gatewayBackend: backendVersion,
|
||
});
|
||
const incompatible = compatibility && !compatibility.compatible;
|
||
return <aside className="harbor-versions" aria-label="Версии Harbor Gateway">
|
||
<VersionBadge
|
||
code="C"
|
||
component="Gateway client UI"
|
||
componentKey="gatewayClient"
|
||
version={HARBOR_VERSIONS.gatewayClient}
|
||
incompatible={incompatible}
|
||
/>
|
||
<VersionBadge
|
||
code="B"
|
||
component="Gateway control backend"
|
||
componentKey="gatewayBackend"
|
||
version={backendVersion}
|
||
incompatible={incompatible}
|
||
/>
|
||
<VersionBadge
|
||
code="D"
|
||
component="Gateway dataplane"
|
||
componentKey="gatewayDataplane"
|
||
version={dataplaneVersion}
|
||
runtime={runtimeSingBox ? `Runtime: sing-box ${runtimeSingBox}` : null}
|
||
/>
|
||
</aside>;
|
||
}
|
||
|
||
function InlineError({ error, context }) {
|
||
if (!error || error.context !== context) return null;
|
||
return (
|
||
<div className={`client-inline-error is-${context}`} role="alert">
|
||
<span>{error.message}</span>
|
||
{error.retry && <button type="button" onClick={error.retry}>Повторить</button>}
|
||
{error.correlationId && (
|
||
<small title={error.correlationId}>Код: {error.correlationId.slice(0, 8)}</small>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const operationProgress = {
|
||
connection: ['connection', 'Меняем состояние подключения…'],
|
||
serverApply: ['connection', 'Применяем сервер…'],
|
||
subscriptionImport: ['subscription', 'Загружаем подписку…'],
|
||
subscriptionDelete: ['subscription', 'Удаляем подписку…'],
|
||
routeRules: ['routing', 'Применяем локальные правила…'],
|
||
};
|
||
|
||
function InlineProgress({ operations, context }) {
|
||
const active = Object.entries(operationProgress).find(([key, [operationContext]]) => (
|
||
operationContext === context && operations[key]?.status === 'running'
|
||
));
|
||
if (!active) return null;
|
||
return (
|
||
<div className={`client-inline-error client-operation-progress is-${context}`} role="status">
|
||
<span>{active[1][1]}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function InstructionStep({ step }) {
|
||
if (typeof step === 'string') return step;
|
||
return (
|
||
<>
|
||
{step.before}
|
||
<a href={step.link[1]} target="_blank" rel="noreferrer">{step.link[0]}</a>
|
||
{step.after}
|
||
</>
|
||
);
|
||
}
|
||
|
||
function InstructionBlock({ block, open, onToggle }) {
|
||
return (
|
||
<section
|
||
className={`client-instruction-block${open ? ' is-open' : ''}`}
|
||
style={{ viewTransitionName: `instruction-${block.id}` }}
|
||
>
|
||
<button
|
||
className="client-instruction-summary"
|
||
type="button"
|
||
aria-expanded={open}
|
||
onClick={onToggle}
|
||
>
|
||
<span>{block.label}</span>
|
||
<strong>{block.title}</strong>
|
||
<small>{block.summary}</small>
|
||
<i aria-hidden="true" />
|
||
</button>
|
||
<div className="client-instruction-reveal" aria-hidden={!open} inert={!open ? true : undefined}>
|
||
<div className="client-instruction-body">
|
||
{block.paragraphs?.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||
{block.steps && (
|
||
<ol>
|
||
{block.steps.map((step) => (
|
||
<li key={typeof step === 'string' ? step : step.link[1]}>
|
||
<InstructionStep step={step} />
|
||
</li>
|
||
))}
|
||
</ol>
|
||
)}
|
||
{block.code && <code>{block.code}</code>}
|
||
{block.note && <p className="client-instruction-note">{block.note}</p>}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
const ROUTE_RULE_OPTIONS = [
|
||
['domain', 'Точный домен'],
|
||
['domain_suffix', 'Суффикс'],
|
||
['domain_keyword', 'Содержит'],
|
||
];
|
||
|
||
const ROUTE_RULE_PLACEHOLDERS = {
|
||
domain: 'example.com или полный URL',
|
||
domain_suffix: 'example.org',
|
||
domain_keyword: 'cdn',
|
||
};
|
||
|
||
let localRuleDraftId = 0;
|
||
const createLocalRuleDraft = (rule) => ({
|
||
...rule,
|
||
enabled: rule?.enabled !== false,
|
||
_key: `route-rule-${localRuleDraftId += 1}`,
|
||
});
|
||
const localRuleValues = (rules) => rules
|
||
.filter((rule) => !rule.removing)
|
||
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
||
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
|
||
const localRuleKey = ({ type, value, enabled }) => `${type}:${String(value || '').trim().toLowerCase()}:${enabled}`;
|
||
|
||
function localRuleStatus(rule, savedRules, activeRules, runtimeActive) {
|
||
const key = localRuleKey(rule);
|
||
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
|
||
if (!rule.enabled) return ['disabled', 'Выключено'];
|
||
if (!runtimeActive) return ['saved', 'Сохранено'];
|
||
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
|
||
return ['pending', 'Ждёт перезапуска'];
|
||
}
|
||
|
||
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
|
||
const [open, setOpen] = useState(false);
|
||
const rootRef = useRef(null);
|
||
const triggerRef = useRef(null);
|
||
const optionRefs = useRef([]);
|
||
const listId = `${ruleKey}-types`;
|
||
const selectedIndex = Math.max(0, ROUTE_RULE_OPTIONS.findIndex(([type]) => type === value));
|
||
|
||
useEffect(() => {
|
||
if (!open) return undefined;
|
||
optionRefs.current[selectedIndex]?.focus();
|
||
const close = (event) => {
|
||
if (event.type === 'keydown' && event.key !== 'Escape') return;
|
||
if (rootRef.current?.contains(event.target)) return;
|
||
setOpen(false);
|
||
};
|
||
document.addEventListener('pointerdown', close);
|
||
document.addEventListener('keydown', close);
|
||
return () => {
|
||
document.removeEventListener('pointerdown', close);
|
||
document.removeEventListener('keydown', close);
|
||
};
|
||
}, [open, selectedIndex]);
|
||
|
||
function choose(type) {
|
||
onChange(type);
|
||
setOpen(false);
|
||
triggerRef.current?.focus();
|
||
}
|
||
|
||
function moveOption(event, offset) {
|
||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End', 'Escape'].includes(event.key)) return;
|
||
event.preventDefault();
|
||
if (event.key === 'Escape') {
|
||
setOpen(false);
|
||
triggerRef.current?.focus();
|
||
return;
|
||
}
|
||
const current = optionRefs.current.indexOf(document.activeElement);
|
||
const next = event.key === 'Home'
|
||
? 0
|
||
: event.key === 'End'
|
||
? ROUTE_RULE_OPTIONS.length - 1
|
||
: (current + offset + ROUTE_RULE_OPTIONS.length) % ROUTE_RULE_OPTIONS.length;
|
||
optionRefs.current[next]?.focus();
|
||
}
|
||
|
||
return (
|
||
<div className={`client-rule-type${open ? ' is-open' : ''}`} ref={rootRef}>
|
||
<button
|
||
ref={triggerRef}
|
||
className="client-rule-type-trigger"
|
||
type="button"
|
||
aria-label={`Тип правила ${index + 1}`}
|
||
aria-haspopup="listbox"
|
||
aria-expanded={open}
|
||
aria-controls={listId}
|
||
disabled={disabled}
|
||
onClick={() => setOpen((current) => !current)}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape' && open) {
|
||
event.preventDefault();
|
||
setOpen(false);
|
||
return;
|
||
}
|
||
if (!['ArrowDown', 'ArrowUp'].includes(event.key)) return;
|
||
event.preventDefault();
|
||
setOpen(true);
|
||
}}
|
||
>
|
||
<span>{ROUTE_RULE_OPTIONS[selectedIndex][1]}</span>
|
||
<svg viewBox="0 0 12 8" aria-hidden="true"><path d="m1 1 5 5 5-5" /></svg>
|
||
</button>
|
||
<div className="client-rule-type-list" id={listId} role="listbox" aria-hidden={!open}>
|
||
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => (
|
||
<button
|
||
ref={(node) => { optionRefs.current[optionIndex] = node; }}
|
||
type="button"
|
||
role="option"
|
||
aria-selected={type === value}
|
||
tabIndex={open ? 0 : -1}
|
||
key={type}
|
||
onClick={() => choose(type)}
|
||
onKeyDown={(event) => moveOption(event, event.key === 'ArrowUp' ? -1 : 1)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function LocalRulesPanel({
|
||
open,
|
||
rules,
|
||
savedRules,
|
||
activeRules,
|
||
runtimeActive,
|
||
blocked,
|
||
dirty,
|
||
restartPending,
|
||
error,
|
||
operations,
|
||
panelRef,
|
||
closeRef,
|
||
onAdd,
|
||
onChange,
|
||
onRemove,
|
||
onRemoveComplete,
|
||
onClose,
|
||
onSave,
|
||
}) {
|
||
const draftRules = rules.filter((rule) => !rule.removing);
|
||
const canAdd = canAppendRouteRule(draftRules) && !blocked;
|
||
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
|
||
|
||
return (
|
||
<aside
|
||
ref={panelRef}
|
||
id="client-local-rules"
|
||
className={`client-local-rules${open ? ' is-open' : ''}`}
|
||
aria-labelledby="local-rules-title"
|
||
aria-hidden={!open}
|
||
inert={!open ? true : undefined}
|
||
>
|
||
<div className="client-local-rules-sheet">
|
||
<button
|
||
ref={closeRef}
|
||
className="client-drawer-close"
|
||
type="button"
|
||
aria-label="Закрыть локальные правила"
|
||
onClick={onClose}
|
||
>×</button>
|
||
<header className="client-local-rules-header">
|
||
<span>Маршрутизация</span>
|
||
<button
|
||
className="client-local-rules-save"
|
||
type="submit"
|
||
form="client-local-rules-form"
|
||
disabled={blocked || !dirty}
|
||
>
|
||
Сохранить
|
||
</button>
|
||
<h2 id="local-rules-title">Локальные правила</h2>
|
||
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
||
{restartPending && (
|
||
<p className="client-local-rules-runtime" role="status">
|
||
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
|
||
</p>
|
||
)}
|
||
</header>
|
||
|
||
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={onSave}>
|
||
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
||
<span id="local-rules-list-title">Правила</span>
|
||
<div className="client-local-rules-list">
|
||
{rules.map((rule, index) => {
|
||
const [status, statusLabel] = localRuleStatus(
|
||
rule,
|
||
savedRules,
|
||
activeRules,
|
||
runtimeActive,
|
||
);
|
||
return (
|
||
<div
|
||
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
|
||
key={rule._key}
|
||
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
|
||
inert={rule.removing ? true : undefined}
|
||
>
|
||
<button
|
||
className="client-local-rule-enabled"
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={rule.enabled}
|
||
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
|
||
onClick={() => onChange(index, 'enabled', !rule.enabled)}
|
||
>
|
||
<svg viewBox="0 0 20 20" aria-hidden="true">
|
||
<circle cx="10" cy="10" r="6" />
|
||
<path className="client-rule-check" d="m6.8 10.1 2.1 2.2 4.5-5" />
|
||
</svg>
|
||
</button>
|
||
<RuleTypePicker
|
||
value={rule.type}
|
||
ruleKey={rule._key}
|
||
index={index}
|
||
disabled={rule.removing}
|
||
onChange={(type) => onChange(index, 'type', type)}
|
||
/>
|
||
<input
|
||
type="text"
|
||
inputMode="url"
|
||
autoComplete="off"
|
||
spellCheck={false}
|
||
required
|
||
aria-label={`Значение правила ${index + 1}`}
|
||
placeholder={ROUTE_RULE_PLACEHOLDERS[rule.type]}
|
||
value={rule.value}
|
||
onChange={(event) => onChange(index, 'value', event.target.value)}
|
||
/>
|
||
<span className="client-local-rule-status" role="status">{statusLabel}</span>
|
||
<button
|
||
className="client-local-rule-delete"
|
||
type="button"
|
||
aria-label={`Удалить правило ${index + 1}`}
|
||
onClick={() => onRemove(index)}
|
||
>
|
||
×
|
||
</button>
|
||
<span
|
||
className="client-delete-strike"
|
||
aria-hidden="true"
|
||
onAnimationEnd={() => onRemoveComplete(rule._key)}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
{!rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
|
||
</div>
|
||
<div className="client-local-rule-add-slot">
|
||
<button className="client-local-rule-add" type="button" disabled={!canAdd} onClick={onAdd}>
|
||
+ Добавить правило
|
||
</button>
|
||
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
|
||
</div>
|
||
</section>
|
||
|
||
<p className="client-local-rules-note">
|
||
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
|
||
</p>
|
||
<InlineError error={error} context="routing" />
|
||
<InlineProgress operations={operations} context="routing" />
|
||
<div className="client-local-rules-actions">
|
||
<button type="button" onClick={onClose}>Отмена</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</aside>
|
||
);
|
||
}
|
||
|
||
function DurationPart({ name, children }) {
|
||
return (
|
||
<span
|
||
className={`client-duration-part client-duration-${name}${name.endsWith('-value') ? ' is-value' : ' is-label'}`}
|
||
>
|
||
{children}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function AnimatedSeconds({ value, padded = true }) {
|
||
return String(value).padStart(padded ? 2 : 1, '0').split('').map((digit, index) => (
|
||
<span className="client-duration-second-digit" key={`${index}-${digit}`}>{digit}</span>
|
||
));
|
||
}
|
||
|
||
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';
|
||
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 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>
|
||
);
|
||
}
|
||
|
||
export function ClientOverviewPage({
|
||
state,
|
||
versionInfo,
|
||
operations = {},
|
||
error,
|
||
subscriptionUrl,
|
||
setSubscriptionUrl,
|
||
servers,
|
||
pendingServerId,
|
||
setPendingServerId,
|
||
onFetchSubscription,
|
||
onRefreshSubscription,
|
||
onForgetSubscription,
|
||
onApply,
|
||
onRestart,
|
||
onStop,
|
||
onSetGatewayAuto,
|
||
onSaveRouteRules,
|
||
onDismissError,
|
||
}) {
|
||
const isGateway = state?.mode === 'gateway';
|
||
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 || '';
|
||
const showPower = hasSubscription && Boolean(selectedServerId);
|
||
const canStart = Boolean(selectedServerId || state?.configExists);
|
||
const [now, setNow] = useState(Date.now());
|
||
const [durationMode, setDurationMode] = useState(() => {
|
||
try {
|
||
return localStorage.getItem(DURATION_MODE_STORAGE_KEY) === 'words' ? 'words' : 'digital';
|
||
} catch {
|
||
return 'digital';
|
||
}
|
||
});
|
||
const [editingSubscription, setEditingSubscription] = useState(!state?.hasSubscription);
|
||
const [showIntro, setShowIntro] = useState(!hasSubscription);
|
||
const [subscriptionContentReady, setSubscriptionContentReady] = useState(hasSubscription);
|
||
const [accessTab, setAccessTab] = useState('gateway');
|
||
const [copyFeedback, setCopyFeedback] = useState(null);
|
||
const [subscriptionValidation, setSubscriptionValidation] = useState({
|
||
url: '',
|
||
status: 'idle',
|
||
error: null,
|
||
});
|
||
const [subscriptionValidationAttempt, setSubscriptionValidationAttempt] = useState(0);
|
||
const [refreshingInfo, setRefreshingInfo] = useState(false);
|
||
const [usageUpdated, setUsageUpdated] = useState(false);
|
||
const [serverRevealVersion, setServerRevealVersion] = useState(0);
|
||
const [serversLeaving, setServersLeaving] = useState(false);
|
||
const [instructionsOpen, setInstructionsOpen] = useState(false);
|
||
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
||
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
||
const [localRulesRevision, setLocalRulesRevision] = useState(state?.route?.localRulesRevision || 0);
|
||
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
|
||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||
const [openInstructionId, setOpenInstructionId] = useState('');
|
||
const subscriptionInputRef = useRef(null);
|
||
const subscriptionRef = useRef(null);
|
||
const copyTimerRef = useRef(null);
|
||
const instructionsPanelRef = useRef(null);
|
||
const instructionsToggleRef = useRef(null);
|
||
const instructionsCloseRef = useRef(null);
|
||
const localRulesPanelRef = useRef(null);
|
||
const localRulesToggleRef = useRef(null);
|
||
const localRulesCloseRef = useRef(null);
|
||
const localRulesBaselineRef = useRef('[]');
|
||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||
const gatewayAddress = isGateway ? window.location.hostname : '127.0.0.1';
|
||
const proxyUrls = localProxyUrls(state?.proxyPort, gatewayAddress);
|
||
const usage = subscriptionUsage(state?.userInfo);
|
||
const duration = connectionDurationParts(state?.singboxStartedAt, now);
|
||
const connectionTitle = connected
|
||
? gatewayDirect ? 'Gateway подключён' : 'VPN включён'
|
||
: 'Подключение выключено';
|
||
const [displayedUsed, setDisplayedUsed] = useState(usage.used);
|
||
const hasUsage = Boolean(
|
||
state?.userInfo && ['upload', 'download', 'total', 'expire'].some((key) => key in state.userInfo),
|
||
);
|
||
const instructions = instructionBlocks({
|
||
isGateway,
|
||
host: gatewayAddress,
|
||
port: state?.proxyPort || (isGateway ? 8080 : 8082),
|
||
});
|
||
const [instructionsIntro, ...instructionGuides] = instructions;
|
||
const openInstruction = instructionGuides.find((block) => block.id === openInstructionId);
|
||
const orderedInstructionGuides = openInstruction
|
||
? [openInstruction, ...instructionGuides.filter((block) => block.id !== openInstructionId)]
|
||
: instructionGuides;
|
||
const normalizedSubscriptionUrl = subscriptionUrl.trim();
|
||
const currentSubscriptionValidation = subscriptionValidation.url === normalizedSubscriptionUrl
|
||
? subscriptionValidation
|
||
: null;
|
||
const localSubscriptionError = normalizedSubscriptionUrl
|
||
&& !isSubscriptionUrlValid(normalizedSubscriptionUrl)
|
||
? { context: 'subscription', message: ERROR_DEFINITIONS.SUBSCRIPTION_INVALID.message }
|
||
: null;
|
||
const subscriptionError = currentSubscriptionValidation?.error
|
||
|| localSubscriptionError
|
||
|| (error?.context === 'subscription' ? error : null);
|
||
const subscriptionValidationStatus = !normalizedSubscriptionUrl
|
||
? 'idle'
|
||
: subscriptionError || !isSubscriptionUrlValid(normalizedSubscriptionUrl)
|
||
? 'invalid'
|
||
: currentSubscriptionValidation?.status || 'checking';
|
||
const subscriptionWaiting = hasSubscription && !subscriptionContentReady;
|
||
const connectionBlocked = operationBlocked(operations, 'connection');
|
||
const serverApplyBlocked = operationBlocked(operations, 'serverApply');
|
||
const subscriptionImportBlocked = operationBlocked(operations, 'subscriptionImport');
|
||
const subscriptionRefreshBlocked = operationBlocked(operations, 'subscriptionRefresh');
|
||
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
|
||
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
||
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
|
||
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
|
||
const localRulesPendingRestart = connected && state?.route?.localRulesPendingRestart === true;
|
||
const pendingLocalRulesCount = localRulesPendingRestart
|
||
? (state?.route?.localRules || []).filter((rule) => (
|
||
rule.enabled && !(state?.route?.activeLocalRules || []).some((active) => localRuleKey(active) === localRuleKey(rule))
|
||
)).length
|
||
: 0;
|
||
|
||
useEffect(() => {
|
||
setNow(Date.now());
|
||
if (!connected || !state?.singboxStartedAt) return undefined;
|
||
|
||
const timer = setInterval(() => setNow(Date.now()), 1000);
|
||
return () => clearInterval(timer);
|
||
}, [connected, state?.singboxStartedAt]);
|
||
|
||
useEffect(() => {
|
||
if (editingSubscription) subscriptionInputRef.current?.focus();
|
||
}, [editingSubscription]);
|
||
|
||
useEffect(() => {
|
||
if (!normalizedSubscriptionUrl || !isSubscriptionUrlValid(normalizedSubscriptionUrl)) return undefined;
|
||
const controller = new AbortController();
|
||
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'checking', error: null });
|
||
const timer = setTimeout(async () => {
|
||
try {
|
||
await api.subscription.validate(normalizedSubscriptionUrl, { signal: controller.signal });
|
||
setSubscriptionValidation({ url: normalizedSubscriptionUrl, status: 'valid', error: null });
|
||
} catch (requestError) {
|
||
if (requestError?.name === 'AbortError') return;
|
||
setSubscriptionValidation({
|
||
url: normalizedSubscriptionUrl,
|
||
status: 'invalid',
|
||
error: {
|
||
context: 'subscription',
|
||
message: requestError.message,
|
||
correlationId: requestError.correlationId,
|
||
retry: requestError.retryable
|
||
? () => setSubscriptionValidationAttempt((attempt) => attempt + 1)
|
||
: null,
|
||
},
|
||
});
|
||
}
|
||
}, 300);
|
||
return () => {
|
||
clearTimeout(timer);
|
||
controller.abort();
|
||
};
|
||
}, [normalizedSubscriptionUrl, subscriptionValidationAttempt]);
|
||
|
||
useEffect(() => {
|
||
if (!showIntro) return undefined;
|
||
const timer = setTimeout(() => setShowIntro(false), 1200);
|
||
return () => clearTimeout(timer);
|
||
}, [showIntro]);
|
||
|
||
useEffect(() => {
|
||
const previouslyHadSubscription = previousHasSubscriptionRef.current;
|
||
previousHasSubscriptionRef.current = hasSubscription;
|
||
|
||
if (!hasSubscription) {
|
||
setSubscriptionContentReady(false);
|
||
return undefined;
|
||
}
|
||
if (previouslyHadSubscription) {
|
||
setSubscriptionContentReady(true);
|
||
return undefined;
|
||
}
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
setSubscriptionContentReady(true);
|
||
return undefined;
|
||
}
|
||
|
||
const timer = setTimeout(() => setSubscriptionContentReady(true), SUBSCRIPTION_REVEAL_DELAY_MS);
|
||
return () => clearTimeout(timer);
|
||
}, [hasSubscription]);
|
||
|
||
useEffect(() => {
|
||
if (!hasSubscription) {
|
||
setEditingSubscription(true);
|
||
setInstructionsOpen(false);
|
||
setLocalRulesOpen(false);
|
||
}
|
||
}, [hasSubscription]);
|
||
|
||
useEffect(() => {
|
||
if (!editingSubscription || !state?.hasSubscription || subscriptionUrl) return undefined;
|
||
const timer = setTimeout(() => setEditingSubscription(false), 5000);
|
||
return () => clearTimeout(timer);
|
||
}, [editingSubscription, state?.hasSubscription, subscriptionUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!editingSubscription || !hasSubscription) return undefined;
|
||
const closeOnOutsideClick = (event) => {
|
||
if (subscriptionRef.current?.contains(event.target)) return;
|
||
setSubscriptionUrl('');
|
||
setEditingSubscription(false);
|
||
};
|
||
document.addEventListener('pointerdown', closeOnOutsideClick);
|
||
return () => document.removeEventListener('pointerdown', closeOnOutsideClick);
|
||
}, [editingSubscription, hasSubscription, setSubscriptionUrl]);
|
||
|
||
useEffect(() => {
|
||
if (!state?.hasSubscription) return undefined;
|
||
onRefreshSubscription();
|
||
return undefined;
|
||
}, [state?.hasSubscription]);
|
||
|
||
useEffect(() => {
|
||
const from = displayedUsed;
|
||
const to = usage.used;
|
||
if (from === to) return undefined;
|
||
const startedAt = performance.now();
|
||
let frame;
|
||
const tick = (now) => {
|
||
const progress = Math.min(1, (now - startedAt) / 900);
|
||
const eased = 1 - Math.pow(1 - progress, 4);
|
||
setDisplayedUsed(from + (to - from) * eased);
|
||
if (progress < 1) frame = requestAnimationFrame(tick);
|
||
};
|
||
frame = requestAnimationFrame(tick);
|
||
return () => cancelAnimationFrame(frame);
|
||
}, [usage.used]);
|
||
|
||
useEffect(() => () => clearTimeout(copyTimerRef.current), []);
|
||
|
||
useEffect(() => {
|
||
if (!instructionsOpen) return undefined;
|
||
const frame = requestAnimationFrame(() => instructionsCloseRef.current?.focus());
|
||
const closeOnEscape = (event) => {
|
||
if (event.key === 'Escape') setInstructionsOpen(false);
|
||
};
|
||
document.addEventListener('keydown', closeOnEscape);
|
||
return () => {
|
||
cancelAnimationFrame(frame);
|
||
document.removeEventListener('keydown', closeOnEscape);
|
||
requestAnimationFrame(() => {
|
||
if (instructionsPanelRef.current?.contains(document.activeElement)) instructionsToggleRef.current?.focus();
|
||
});
|
||
};
|
||
}, [instructionsOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!localRulesOpen) return undefined;
|
||
const frame = requestAnimationFrame(() => localRulesCloseRef.current?.focus());
|
||
return () => {
|
||
cancelAnimationFrame(frame);
|
||
requestAnimationFrame(() => {
|
||
if (localRulesPanelRef.current?.contains(document.activeElement)) localRulesToggleRef.current?.focus();
|
||
});
|
||
};
|
||
}, [localRulesOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!localRulesOpen) return undefined;
|
||
const closeLocalRules = (event) => {
|
||
if (event.type === 'keydown') {
|
||
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||
} else {
|
||
if (localRulesPanelRef.current?.contains(event.target)) return;
|
||
if (localRulesToggleRef.current?.contains(event.target)) return;
|
||
}
|
||
requestCloseLocalRules();
|
||
};
|
||
document.addEventListener('pointerdown', closeLocalRules);
|
||
document.addEventListener('keydown', closeLocalRules);
|
||
return () => {
|
||
document.removeEventListener('pointerdown', closeLocalRules);
|
||
document.removeEventListener('keydown', closeLocalRules);
|
||
};
|
||
}, [localRulesOpen, localRulesDirty]);
|
||
|
||
useEffect(() => {
|
||
if (!localRulesOpen || !localRulesDirty) return undefined;
|
||
const warnBeforeUnload = (event) => {
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
};
|
||
window.addEventListener('beforeunload', warnBeforeUnload);
|
||
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
||
}, [localRulesOpen, localRulesDirty]);
|
||
|
||
useEffect(() => {
|
||
if (!instructionsOpen) return undefined;
|
||
const closeInstructionsOutside = (event) => {
|
||
if (instructionsPanelRef.current?.contains(event.target)) return;
|
||
if (instructionsToggleRef.current?.contains(event.target)) return;
|
||
setInstructionsOpen(false);
|
||
};
|
||
document.addEventListener('pointerdown', closeInstructionsOutside);
|
||
return () => document.removeEventListener('pointerdown', closeInstructionsOutside);
|
||
}, [instructionsOpen]);
|
||
|
||
async function toggleConnection() {
|
||
const action = connectionAction({ connected, selectedServerId, configExists: state?.configExists });
|
||
if (action?.type === 'stop') return onStop();
|
||
if (action?.type === 'apply') return onApply(action.serverId);
|
||
if (action?.type === 'restart') return onRestart();
|
||
}
|
||
|
||
function selectServer(serverId) {
|
||
setPendingServerId(serverId);
|
||
if (connected && serverId) onApply(serverId);
|
||
}
|
||
|
||
async function submitSubscription(event) {
|
||
event.preventDefault();
|
||
if (subscriptionValidationStatus !== 'valid') return;
|
||
if (!await onFetchSubscription()) return;
|
||
setSubscriptionUrl('');
|
||
setEditingSubscription(false);
|
||
}
|
||
|
||
async function copyProxy(kind) {
|
||
const value = kind === 'gateway' ? gatewayAddress : proxyUrls[kind];
|
||
clearTimeout(copyTimerRef.current);
|
||
try {
|
||
await copyText(value);
|
||
setCopyFeedback({ kind, failed: false });
|
||
} catch {
|
||
setCopyFeedback({ kind, failed: true });
|
||
}
|
||
copyTimerRef.current = setTimeout(() => setCopyFeedback(null), 800);
|
||
}
|
||
|
||
function navigateAccessTabs(event, current) {
|
||
if (!['ArrowLeft', 'ArrowRight', 'Home', 'End'].includes(event.key)) return;
|
||
event.preventDefault();
|
||
const next = accessTabForKey(ACCESS_TABS.map(([tab]) => tab), current, event.key);
|
||
setAccessTab(next);
|
||
requestAnimationFrame(() => document.getElementById(`client-access-tab-${next}`)?.focus());
|
||
}
|
||
|
||
async function refreshSubscription() {
|
||
const startedAt = performance.now();
|
||
setRefreshingInfo(true);
|
||
try {
|
||
if (!await onRefreshSubscription()) return;
|
||
setUsageUpdated(false);
|
||
requestAnimationFrame(() => setUsageUpdated(true));
|
||
setTimeout(() => setUsageUpdated(false), 900);
|
||
setServersLeaving(true);
|
||
await new Promise((resolve) => setTimeout(resolve, 420 + Math.min(7, Math.max(0, servers.length - 1)) * 90));
|
||
setServerRevealVersion((version) => version + 1);
|
||
setServersLeaving(false);
|
||
} finally {
|
||
const elapsed = performance.now() - startedAt;
|
||
const completeCyclesAt = Math.max(900, Math.ceil(elapsed / 900) * 900);
|
||
await new Promise((resolve) => setTimeout(resolve, completeCyclesAt - elapsed));
|
||
setRefreshingInfo(false);
|
||
}
|
||
}
|
||
|
||
async function forgetSubscription() {
|
||
if (!await onForgetSubscription()) return;
|
||
setConfirmingDelete(false);
|
||
}
|
||
|
||
function toggleInstruction(id) {
|
||
const update = () => flushSync(() => {
|
||
setOpenInstructionId((current) => current === id ? '' : id);
|
||
});
|
||
|
||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
update();
|
||
return;
|
||
}
|
||
|
||
document.startViewTransition(update);
|
||
}
|
||
|
||
function toggleDurationMode() {
|
||
setDurationMode((mode) => {
|
||
const nextMode = mode === 'digital' ? 'words' : 'digital';
|
||
try {
|
||
localStorage.setItem(DURATION_MODE_STORAGE_KEY, nextMode);
|
||
} catch {
|
||
// The visual preference still works for this session.
|
||
}
|
||
return nextMode;
|
||
});
|
||
}
|
||
|
||
function openLocalRules() {
|
||
const rules = state?.route?.localRules || [];
|
||
setInstructionsOpen(false);
|
||
localRulesBaselineRef.current = localRulesSignature(rules);
|
||
setLocalRulesDraft(rules.map(createLocalRuleDraft));
|
||
setLocalRulesRevision(state?.route?.localRulesRevision || 0);
|
||
setConfirmingLocalRulesClose(false);
|
||
onDismissError();
|
||
setLocalRulesOpen(true);
|
||
}
|
||
|
||
function changeLocalRule(index, field, value) {
|
||
setLocalRulesDraft((rules) => rules.map((rule, ruleIndex) => (
|
||
ruleIndex === index ? { ...rule, [field]: value } : rule
|
||
)));
|
||
}
|
||
|
||
async function saveLocalRules(event) {
|
||
event.preventDefault();
|
||
const rules = localRuleValues(localRulesDraft);
|
||
const result = await onSaveRouteRules(rules, localRulesRevision);
|
||
if (!result) return;
|
||
localRulesBaselineRef.current = JSON.stringify(rules);
|
||
setLocalRulesRevision(result.state.route.localRulesRevision);
|
||
setConfirmingLocalRulesClose(false);
|
||
if (!connected || !result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
|
||
}
|
||
|
||
function requestCloseLocalRules() {
|
||
if (localRulesDirty) {
|
||
setConfirmingLocalRulesClose(true);
|
||
return false;
|
||
}
|
||
setLocalRulesOpen(false);
|
||
return true;
|
||
}
|
||
|
||
function discardLocalRules() {
|
||
setConfirmingLocalRulesClose(false);
|
||
setLocalRulesOpen(false);
|
||
}
|
||
|
||
function removeLocalRule(ruleKey) {
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
|
||
return;
|
||
}
|
||
setLocalRulesDraft((rules) => rules.map((rule) => (
|
||
rule._key === ruleKey ? { ...rule, removing: true } : rule
|
||
)));
|
||
}
|
||
|
||
function finishRemoveLocalRule(ruleKey) {
|
||
const update = () => flushSync(() => {
|
||
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
|
||
});
|
||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
update();
|
||
return;
|
||
}
|
||
document.startViewTransition(update);
|
||
}
|
||
|
||
return (
|
||
<div
|
||
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}
|
||
gatewayAvailable={gatewayAvailable}
|
||
gatewayDirect={gatewayDirect}
|
||
blocked={gatewayAutoBlocked}
|
||
onSetGatewayAuto={onSetGatewayAuto}
|
||
/>
|
||
{hasSubscription && subscriptionContentReady && <nav className="client-secondary-menu" aria-label="Дополнительные меню">
|
||
<button
|
||
ref={instructionsToggleRef}
|
||
className={`client-instructions-toggle${instructionsOpen ? ' is-open' : ''}`}
|
||
type="button"
|
||
aria-expanded={instructionsOpen}
|
||
aria-controls="client-instructions"
|
||
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||
onClick={() => {
|
||
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||
setInstructionsOpen((open) => !open);
|
||
}}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<circle cx="12" cy="12" r="8.5" />
|
||
<path d="M12 11v5M12 8h.01" />
|
||
</svg>
|
||
<span>Как использовать</span>
|
||
</button>
|
||
<button
|
||
ref={localRulesToggleRef}
|
||
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${localRulesPendingRestart ? ' has-pending' : ''}`}
|
||
type="button"
|
||
disabled={gatewayDirect}
|
||
aria-expanded={localRulesOpen}
|
||
aria-controls="client-local-rules"
|
||
aria-label={gatewayDirect
|
||
? 'Сейчас работают правила Harbor Gateway'
|
||
: localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||
onClick={() => localRulesOpen ? requestCloseLocalRules() : openLocalRules()}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
|
||
<circle cx="15" cy="7" r="2" />
|
||
<circle cx="9" cy="17" r="2" />
|
||
</svg>
|
||
<span>{gatewayDirect
|
||
? 'Сейчас работают правила Gateway'
|
||
: localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||
</button>
|
||
</nav>}
|
||
<main className={`client-panel${showPower ? '' : ' is-setup'}${hasSubscription ? ' has-subscription' : ''}`}>
|
||
{showPower && (
|
||
<section className="client-power-section" aria-labelledby="connection-title">
|
||
<button
|
||
className="client-power"
|
||
type="button"
|
||
role="switch"
|
||
aria-checked={connected}
|
||
aria-label={connected ? 'Остановить Harbor Connect' : 'Запустить Harbor Connect'}
|
||
disabled={connectionBlocked || (!connected && !canStart)}
|
||
onClick={toggleConnection}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M12 2v10M5.6 5.6a9 9 0 1 0 12.8 0" />
|
||
</svg>
|
||
</button>
|
||
<div className={`client-route-rules-pending${pendingLocalRulesCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||
{pendingLocalRulesCount > 0 && (
|
||
<>
|
||
<span>{pendingLocalRulesCount} {pendingLocalRulesCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
|
||
<button type="button" disabled={connectionBlocked} onClick={onRestart}>Перезапустить VPN</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
<div className="client-state-copy" aria-live="polite">
|
||
<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 ? 'is-active' : ''} aria-hidden="true">Gateway подключён</span>
|
||
</h2>
|
||
<div className="client-state-detail">
|
||
{connected ? (
|
||
<button
|
||
className={`client-duration-toggle client-tooltip-anchor${durationMode === 'words' ? ' is-words' : ''}`}
|
||
type="button"
|
||
key="duration"
|
||
aria-label={durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}
|
||
onClick={toggleDurationMode}
|
||
>
|
||
<span className="client-duration-stack">
|
||
<time
|
||
className={`client-duration${durationMode === 'digital' ? ' is-active' : ''}`}
|
||
aria-hidden={durationMode !== 'digital'}
|
||
>
|
||
<DurationPart name="hours-value">{String(duration.totalHours).padStart(2, '0')}</DurationPart>
|
||
:<DurationPart name="minutes-value">{String(duration.minutes.value).padStart(2, '0')}</DurationPart>
|
||
:<DurationPart name="seconds-value"><AnimatedSeconds value={duration.seconds.value} /></DurationPart>
|
||
</time>
|
||
<time
|
||
className={`client-duration${durationMode === 'words' ? ' is-active' : ''}`}
|
||
aria-hidden={durationMode !== 'words'}
|
||
>
|
||
{[
|
||
['days', duration.days],
|
||
['hours', duration.hours],
|
||
['minutes', duration.minutes],
|
||
['seconds', duration.seconds],
|
||
]
|
||
.filter(([name, part]) => part.value || name === 'seconds')
|
||
.map(([name, part]) => (
|
||
<span className="client-duration-unit" data-unit={name} key={name}>
|
||
<DurationPart name={`${name}-value`}>{name === 'seconds'
|
||
? <AnimatedSeconds value={part.value} padded={false} />
|
||
: part.value}</DurationPart>{' '}
|
||
<DurationPart name={`${name}-label`}>{part.label}</DurationPart>
|
||
</span>
|
||
))}
|
||
</time>
|
||
</span>
|
||
<CloudTooltip>{durationMode === 'digital' ? 'Показать время словами' : 'Показать цифровой таймер'}</CloudTooltip>
|
||
</button>
|
||
) : (
|
||
<p key="hint">
|
||
{canStart ? 'Нажмите, чтобы включить' : 'Добавьте ссылку и выберите сервер'}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<section className={`client-proxies${isGateway ? ' has-tabs' : ''}`} aria-label={isGateway ? 'Gateway и Gateway Proxy' : 'Локальный прокси'}>
|
||
{isGateway && (
|
||
<div className="client-access-tabs" role="tablist" aria-label="Способ подключения">
|
||
{ACCESS_TABS.map(([tab, label]) => (
|
||
<button
|
||
id={`client-access-tab-${tab}`}
|
||
className={`client-access-tab${accessTab === tab ? ' is-active' : ''}`}
|
||
type="button"
|
||
role="tab"
|
||
key={tab}
|
||
aria-selected={accessTab === tab}
|
||
aria-controls={`client-access-panel-${tab}`}
|
||
tabIndex={accessTab === tab ? 0 : -1}
|
||
onClick={() => setAccessTab(tab)}
|
||
onKeyDown={(event) => navigateAccessTabs(event, tab)}
|
||
>
|
||
{label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
{isGateway && (
|
||
<div
|
||
id="client-access-panel-gateway"
|
||
className="client-access-point"
|
||
role="tabpanel"
|
||
aria-labelledby="client-access-tab-gateway"
|
||
hidden={accessTab !== 'gateway'}
|
||
>
|
||
<strong className="client-proxy-address">{gatewayAddress}</strong>
|
||
<button
|
||
className={`client-copy-button${copyFeedback?.kind === 'gateway' ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||
type="button"
|
||
aria-label={`Скопировать Gateway: ${gatewayAddress}`}
|
||
onClick={() => copyProxy('gateway')}
|
||
>
|
||
<span className="client-copy-label">КОПИРОВАТЬ</span>
|
||
{copyFeedback?.kind === 'gateway' && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
|
||
</button>
|
||
</div>
|
||
)}
|
||
<div
|
||
id={isGateway ? 'client-access-panel-proxy' : undefined}
|
||
className="client-access-point"
|
||
role={isGateway ? 'tabpanel' : undefined}
|
||
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>
|
||
<div className="client-proxy-actions">
|
||
{[
|
||
['socks5', 'SOCKS5'],
|
||
['http', 'HTTP'],
|
||
].map(([kind, label]) => (
|
||
<button
|
||
className={`client-copy-button${copyFeedback?.kind === kind ? copyFeedback.failed ? ' is-copy-error' : ' is-copied' : ''}`}
|
||
type="button"
|
||
key={kind}
|
||
aria-label={`Скопировать ${label}: ${proxyUrls[kind]}`}
|
||
onClick={() => copyProxy(kind)}
|
||
>
|
||
<span className="client-copy-label">{label}</span>
|
||
{copyFeedback?.kind === kind && <span className="client-copy-feedback" aria-hidden="true">{copyFeedback.failed ? 'ОШИБКА' : 'ГОТОВО'}</span>}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
<InlineError error={error} context="connection" />
|
||
<InlineProgress operations={operations} context="connection" />
|
||
</section>
|
||
)}
|
||
|
||
<div
|
||
className={`client-form${subscriptionWaiting ? ' is-waiting' : ''}`}
|
||
aria-hidden={subscriptionWaiting}
|
||
aria-disabled={gatewayDirect}
|
||
inert={subscriptionWaiting || gatewayDirect ? true : undefined}
|
||
>
|
||
<div className="client-form-content">
|
||
<div
|
||
ref={subscriptionRef}
|
||
className={`client-subscription ${editingSubscription ? 'is-editing' : ''}${editingSubscription && state?.hasSubscription && !subscriptionUrl ? ' is-timing-out' : ''}`}
|
||
>
|
||
<div
|
||
className="client-subscription-summary"
|
||
aria-hidden={editingSubscription}
|
||
inert={editingSubscription ? true : undefined}
|
||
>
|
||
<div className="client-subscription-heading">
|
||
<span className="client-subscription-label">Ваша подписка</span>
|
||
<span className="client-icon-tooltip client-tooltip-anchor">
|
||
<button
|
||
className="client-subscription-refresh"
|
||
type="button"
|
||
aria-label="Обновить подписку"
|
||
disabled={refreshingInfo || subscriptionRefreshBlocked}
|
||
onClick={refreshSubscription}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path d="M21 12a9 9 0 0 0-15.2-6.5L3 8m0-5v5h5M3 12a9 9 0 0 0 15.2 6.5L21 16m0 5v-5h-5" />
|
||
</svg>
|
||
</button>
|
||
<CloudTooltip>Обновить подписку</CloudTooltip>
|
||
</span>
|
||
<span className="client-icon-tooltip client-tooltip-anchor">
|
||
<button
|
||
className="client-subscription-delete"
|
||
type="button"
|
||
aria-label="Удалить подписку"
|
||
disabled={subscriptionDeleteBlocked}
|
||
onClick={() => setConfirmingDelete(true)}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path className="client-trash-lid" d="M4 7h16M9 7V4h6v3" />
|
||
<path d="m6 7 1 13h10l1-13M10 11v5M14 11v5" />
|
||
</svg>
|
||
</button>
|
||
<CloudTooltip>Удалить подписку</CloudTooltip>
|
||
</span>
|
||
</div>
|
||
<button
|
||
className="client-subscription-domain-button"
|
||
type="button"
|
||
tabIndex={editingSubscription ? -1 : 0}
|
||
onClick={() => setEditingSubscription(true)}
|
||
>
|
||
<strong>{subscriptionDomain(state?.subscriptionHost)}</strong>
|
||
</button>
|
||
</div>
|
||
|
||
<form
|
||
className={`client-subscription-edit is-${subscriptionValidationStatus}`}
|
||
autoComplete="off"
|
||
aria-hidden={!editingSubscription}
|
||
inert={!editingSubscription ? true : undefined}
|
||
onSubmit={submitSubscription}
|
||
>
|
||
<input
|
||
ref={subscriptionInputRef}
|
||
id="subscription-url"
|
||
type="url"
|
||
inputMode="url"
|
||
autoComplete="off"
|
||
tabIndex={editingSubscription ? 0 : -1}
|
||
aria-label="Ссылка подписки"
|
||
placeholder="Вставьте ссылку подписки"
|
||
className={subscriptionUrl ? 'has-value' : ''}
|
||
value={subscriptionUrl}
|
||
onChange={(event) => {
|
||
if (error?.context === 'subscription') onDismissError();
|
||
setSubscriptionValidation({ url: '', status: 'idle', error: null });
|
||
setSubscriptionUrl(event.target.value);
|
||
}}
|
||
onKeyDown={(event) => {
|
||
if (event.key === 'Escape' && state?.hasSubscription) {
|
||
setSubscriptionUrl('');
|
||
setEditingSubscription(false);
|
||
}
|
||
}}
|
||
/>
|
||
{subscriptionUrl && (
|
||
<span className="client-subscription-domain">
|
||
{subscriptionDomain(subscriptionUrl)}
|
||
</span>
|
||
)}
|
||
{normalizedSubscriptionUrl && (
|
||
<button
|
||
className="client-subscription-submit"
|
||
type="submit"
|
||
aria-live="polite"
|
||
aria-label={subscriptionValidationStatus === 'valid'
|
||
? 'Сохранить подписку'
|
||
: subscriptionValidationStatus === 'checking'
|
||
? 'Проверяем подписку'
|
||
: subscriptionError?.message || 'Ссылка подписки не распознана'}
|
||
disabled={subscriptionImportBlocked || subscriptionValidationStatus !== 'valid'}
|
||
>
|
||
{subscriptionValidationStatus === 'valid'
|
||
? '✓'
|
||
: subscriptionValidationStatus === 'checking' ? '…' : '×'}
|
||
</button>
|
||
)}
|
||
</form>
|
||
<InlineError error={subscriptionError || error} context="subscription" />
|
||
<InlineProgress operations={operations} context="subscription" />
|
||
</div>
|
||
|
||
{hasSubscription && subscriptionContentReady && hasUsage && (
|
||
<section className={`client-usage${usageUpdated ? ' is-updated' : ''}`} aria-label="Статистика подписки">
|
||
<span>Использовано</span>
|
||
<strong>
|
||
{formatBytes(displayedUsed)}
|
||
<small> / {usage.total ? formatBytes(usage.total) : 'без лимита'}</small>
|
||
</strong>
|
||
{usage.percent !== null && (
|
||
<div
|
||
className="client-usage-bar"
|
||
role="progressbar"
|
||
aria-label="Использованный трафик"
|
||
aria-valuemin="0"
|
||
aria-valuemax="100"
|
||
aria-valuenow={Math.round(usage.percent)}
|
||
>
|
||
<i style={{ width: `${usage.percent}%` }} />
|
||
</div>
|
||
)}
|
||
<div className="client-usage-details">
|
||
{usage.expiresAt && !Number.isNaN(usage.expiresAt.getTime()) && (
|
||
<span>
|
||
до {usage.expiresAt.toLocaleDateString('ru-RU', { day: 'numeric', month: 'long' })}
|
||
{' · '}{subscriptionDaysLeft(usage.expiresAt)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
{hasSubscription && subscriptionContentReady && (
|
||
<ServerPicker
|
||
servers={servers}
|
||
selectedServerId={selectedServerId}
|
||
disabled={serverApplyBlocked}
|
||
prompt={!showPower}
|
||
leaving={serversLeaving}
|
||
revealVersion={serverRevealVersion}
|
||
onSelect={selectServer}
|
||
/>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</main>
|
||
|
||
{hasSubscription && subscriptionContentReady && <aside
|
||
ref={instructionsPanelRef}
|
||
id="client-instructions"
|
||
className={`client-instructions${instructionsOpen ? ' is-open' : ''}`}
|
||
aria-labelledby="instructions-title"
|
||
aria-hidden={!instructionsOpen}
|
||
inert={!instructionsOpen ? true : undefined}
|
||
>
|
||
<div className="client-instructions-sheet">
|
||
<button
|
||
ref={instructionsCloseRef}
|
||
className="client-drawer-close"
|
||
type="button"
|
||
aria-label="Закрыть инструкции"
|
||
onClick={() => setInstructionsOpen(false)}
|
||
>×</button>
|
||
<header className="client-instructions-header">
|
||
<span>Подключение</span>
|
||
<h2 id="instructions-title">Как использовать {isGateway ? 'Gateway' : 'прокси'}</h2>
|
||
<div className="client-instructions-intro">
|
||
{instructionsIntro.paragraphs.map((paragraph) => <p key={paragraph}>{paragraph}</p>)}
|
||
</div>
|
||
</header>
|
||
|
||
<div className="client-instruction-list">
|
||
{orderedInstructionGuides.map((block) => (
|
||
<InstructionBlock
|
||
block={block}
|
||
key={block.id}
|
||
open={block.id === openInstructionId}
|
||
onToggle={() => toggleInstruction(block.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</aside>}
|
||
|
||
{hasSubscription && subscriptionContentReady && <LocalRulesPanel
|
||
open={localRulesOpen}
|
||
rules={localRulesDraft}
|
||
savedRules={state?.route?.localRules || []}
|
||
activeRules={state?.route?.activeLocalRules || []}
|
||
runtimeActive={connected}
|
||
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
|
||
dirty={localRulesDirty}
|
||
restartPending={localRulesPendingRestart}
|
||
error={error}
|
||
operations={operations}
|
||
panelRef={localRulesPanelRef}
|
||
closeRef={localRulesCloseRef}
|
||
onAdd={() => setLocalRulesDraft((rules) => [
|
||
...rules,
|
||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
|
||
])}
|
||
onChange={changeLocalRule}
|
||
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
|
||
onRemoveComplete={finishRemoveLocalRule}
|
||
onClose={requestCloseLocalRules}
|
||
onSave={saveLocalRules}
|
||
/>}
|
||
<ConfirmationPopup
|
||
open={confirmingLocalRulesClose}
|
||
id="discard-local-rules"
|
||
title="Есть несохранённые настройки"
|
||
description="Закрыть редактор и потерять изменения?"
|
||
cancelLabel="Остаться"
|
||
confirmLabel="Закрыть без сохранения"
|
||
onCancel={() => setConfirmingLocalRulesClose(false)}
|
||
onConfirm={discardLocalRules}
|
||
/>
|
||
<ConfirmationPopup
|
||
open={confirmingDelete}
|
||
id="delete-subscription"
|
||
kicker="Необратимое действие"
|
||
title="Удалить подписку?"
|
||
description="Harbor остановит VPN и удалит сохранённую подписку. Приложения с локальным прокси потеряют соединение до добавления новой подписки."
|
||
cancelLabel="Отмена"
|
||
confirmLabel="Удалить"
|
||
busy={subscriptionDeleteBlocked}
|
||
onCancel={() => setConfirmingDelete(false)}
|
||
onConfirm={forgetSubscription}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|