583 lines
21 KiB
TypeScript
583 lines
21 KiB
TypeScript
import {
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
type FormEvent,
|
||
type ReactNode,
|
||
} from 'react';
|
||
import { flushSync } from 'react-dom';
|
||
import { canAppendRouteRule } from '../../../shared/routingRules.js';
|
||
import type { RouteRule } from '../../../shared/contracts/state.js';
|
||
import { operationBlocked } from '../../state/operations.js';
|
||
import { ConfirmationDialog } from '../../ui/ConfirmationDialog.js';
|
||
import { Drawer } from '../../ui/Drawer.js';
|
||
import { RailAction } from '../../ui/RailAction.js';
|
||
|
||
const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
|
||
['domain', 'Точный домен'],
|
||
['domain_suffix', 'Суффикс'],
|
||
['domain_keyword', 'Содержит'],
|
||
];
|
||
|
||
const ROUTE_RULE_PLACEHOLDERS: Record<RouteRule['type'], string> = {
|
||
domain: 'example.com или полный URL',
|
||
domain_suffix: 'example.org',
|
||
domain_keyword: 'cdn',
|
||
};
|
||
|
||
interface DraftRule extends RouteRule {
|
||
_key: string;
|
||
removing?: boolean;
|
||
}
|
||
|
||
interface RoutingState {
|
||
localRules?: RouteRule[];
|
||
activeLocalRules?: RouteRule[];
|
||
localRulesRevision?: number;
|
||
localRulesPendingRestart?: boolean;
|
||
}
|
||
|
||
interface RoutingFeatureOptions {
|
||
route?: RoutingState | null;
|
||
connected: boolean;
|
||
operations: Record<string, { status?: string } | undefined>;
|
||
onSave: (rules: RouteRule[], expectedRevision: number) => Promise<unknown>;
|
||
onDismissError: () => void;
|
||
}
|
||
|
||
interface RoutingSaveState {
|
||
localRulesRevision: number;
|
||
localRulesPendingRestart: boolean;
|
||
}
|
||
|
||
let localRuleDraftId = 0;
|
||
|
||
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
|
||
...rule,
|
||
enabled: rule?.enabled !== false,
|
||
_key: `route-rule-${localRuleDraftId += 1}`,
|
||
});
|
||
|
||
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
|
||
.filter((rule) => !rule.removing)
|
||
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
||
|
||
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
|
||
rules
|
||
.filter((rule) => !rule.removing)
|
||
.map(({ type, value, enabled }) => ({ type, value, enabled })),
|
||
);
|
||
|
||
const localRuleKey = ({ type, value, enabled }: RouteRule) => (
|
||
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}`
|
||
);
|
||
|
||
function localRuleStatus(
|
||
rule: DraftRule,
|
||
savedRules: RouteRule[],
|
||
activeRules: RouteRule[],
|
||
runtimeActive: boolean,
|
||
) {
|
||
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 routingSaveState(result: unknown): RoutingSaveState | null {
|
||
if (!result) return null;
|
||
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
||
throw new TypeError('Harbor route mutation returned invalid state');
|
||
}
|
||
const state = (result as Record<string, unknown>).state;
|
||
if (!state || typeof state !== 'object' || Array.isArray(state)) {
|
||
throw new TypeError('Harbor route mutation returned invalid state');
|
||
}
|
||
const route = (state as Record<string, unknown>).route;
|
||
if (!route || typeof route !== 'object' || Array.isArray(route)) {
|
||
throw new TypeError('Harbor route mutation returned invalid state');
|
||
}
|
||
const { localRulesRevision, localRulesPendingRestart } = route as Record<string, unknown>;
|
||
if (
|
||
!Number.isSafeInteger(localRulesRevision)
|
||
|| (localRulesRevision as number) < 0
|
||
|| typeof localRulesPendingRestart !== 'boolean'
|
||
) {
|
||
throw new TypeError('Harbor route mutation returned invalid state');
|
||
}
|
||
return { localRulesRevision: localRulesRevision as number, localRulesPendingRestart };
|
||
}
|
||
|
||
export function useRoutingFeature({
|
||
route,
|
||
connected,
|
||
operations,
|
||
onSave,
|
||
onDismissError,
|
||
}: RoutingFeatureOptions) {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [rules, setRules] = useState<DraftRule[]>([]);
|
||
const [revision, setRevision] = useState(route?.localRulesRevision || 0);
|
||
const [confirmingClose, setConfirmingClose] = useState(false);
|
||
const panelRef = useRef<HTMLElement>(null);
|
||
const toggleRef = useRef<HTMLButtonElement>(null);
|
||
const closeRef = useRef<HTMLButtonElement>(null);
|
||
const baselineRef = useRef('[]');
|
||
const savedRules = route?.localRules || [];
|
||
const activeRules = route?.activeLocalRules || [];
|
||
const dirty = localRulesSignature(rules) !== baselineRef.current;
|
||
const pendingRestart = connected && route?.localRulesPendingRestart === true;
|
||
const pendingCount = pendingRestart
|
||
? savedRules.filter((rule) => (
|
||
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
|
||
)).length
|
||
: 0;
|
||
const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return undefined;
|
||
const frame = requestAnimationFrame(() => closeRef.current?.focus());
|
||
return () => {
|
||
cancelAnimationFrame(frame);
|
||
requestAnimationFrame(() => {
|
||
if (panelRef.current?.contains(document.activeElement)) toggleRef.current?.focus();
|
||
});
|
||
};
|
||
}, [isOpen]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return undefined;
|
||
const closeRouting = (event: PointerEvent | KeyboardEvent) => {
|
||
if (event.type === 'keydown') {
|
||
const keyboardEvent = event as KeyboardEvent;
|
||
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
|
||
} else {
|
||
if (panelRef.current?.contains(event.target as Node)) return;
|
||
if (toggleRef.current?.contains(event.target as Node)) return;
|
||
}
|
||
requestClose();
|
||
};
|
||
document.addEventListener('pointerdown', closeRouting);
|
||
document.addEventListener('keydown', closeRouting);
|
||
return () => {
|
||
document.removeEventListener('pointerdown', closeRouting);
|
||
document.removeEventListener('keydown', closeRouting);
|
||
};
|
||
}, [isOpen, dirty]);
|
||
|
||
useEffect(() => {
|
||
if (!isOpen || !dirty) return undefined;
|
||
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
|
||
event.preventDefault();
|
||
event.returnValue = '';
|
||
};
|
||
window.addEventListener('beforeunload', warnBeforeUnload);
|
||
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
||
}, [isOpen, dirty]);
|
||
|
||
function open() {
|
||
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled })));
|
||
setRules(savedRules.map(createLocalRuleDraft));
|
||
setRevision(route?.localRulesRevision || 0);
|
||
setConfirmingClose(false);
|
||
onDismissError();
|
||
setIsOpen(true);
|
||
}
|
||
|
||
function forceClose() {
|
||
setIsOpen(false);
|
||
}
|
||
|
||
function requestClose() {
|
||
if (dirty) {
|
||
setConfirmingClose(true);
|
||
return false;
|
||
}
|
||
setIsOpen(false);
|
||
return true;
|
||
}
|
||
|
||
function discard() {
|
||
setConfirmingClose(false);
|
||
setIsOpen(false);
|
||
}
|
||
|
||
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) {
|
||
setRules((current) => current.map((rule, ruleIndex) => (
|
||
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
|
||
)));
|
||
}
|
||
|
||
function add() {
|
||
setRules((current) => [
|
||
...current,
|
||
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
|
||
]);
|
||
}
|
||
|
||
function remove(ruleKey: string) {
|
||
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||
return;
|
||
}
|
||
setRules((current) => current.map((rule) => (
|
||
rule._key === ruleKey ? { ...rule, removing: true } : rule
|
||
)));
|
||
}
|
||
|
||
function finishRemove(ruleKey: string) {
|
||
const update = () => flushSync(() => {
|
||
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
|
||
});
|
||
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||
update();
|
||
return;
|
||
}
|
||
document.startViewTransition(update);
|
||
}
|
||
|
||
async function save(event: FormEvent<HTMLFormElement>) {
|
||
event.preventDefault();
|
||
const values = localRuleValues(rules);
|
||
const result = routingSaveState(await onSave(values, revision));
|
||
if (!result) return;
|
||
baselineRef.current = JSON.stringify(values);
|
||
setRevision(result.localRulesRevision);
|
||
setConfirmingClose(false);
|
||
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
|
||
}
|
||
|
||
return {
|
||
isOpen,
|
||
rules,
|
||
savedRules,
|
||
activeRules,
|
||
connected,
|
||
dirty,
|
||
pendingRestart,
|
||
pendingCount,
|
||
blocked,
|
||
confirmingClose,
|
||
panelRef,
|
||
toggleRef,
|
||
closeRef,
|
||
open,
|
||
forceClose,
|
||
requestClose,
|
||
setConfirmingClose,
|
||
discard,
|
||
change,
|
||
add,
|
||
remove,
|
||
finishRemove,
|
||
save,
|
||
};
|
||
}
|
||
|
||
type RoutingFeature = ReturnType<typeof useRoutingFeature>;
|
||
|
||
interface RuleTypePickerProps {
|
||
value: RouteRule['type'];
|
||
ruleKey: string;
|
||
index: number;
|
||
disabled?: boolean;
|
||
onChange: (value: RouteRule['type']) => void;
|
||
}
|
||
|
||
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypePickerProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const rootRef = useRef<HTMLDivElement>(null);
|
||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||
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: PointerEvent | KeyboardEvent) => {
|
||
if (event.type === 'keydown' && (event as KeyboardEvent).key !== 'Escape') return;
|
||
if (rootRef.current?.contains(event.target as Node)) 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: RouteRule['type']) {
|
||
onChange(type);
|
||
setOpen(false);
|
||
triggerRef.current?.focus();
|
||
}
|
||
|
||
function moveOption(event: React.KeyboardEvent<HTMLButtonElement>, offset: number) {
|
||
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 as HTMLButtonElement);
|
||
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>
|
||
);
|
||
}
|
||
|
||
export function RoutingToggle({
|
||
feature,
|
||
open,
|
||
gatewayDirect,
|
||
isGateway,
|
||
hasSubscription,
|
||
onOpen,
|
||
}: {
|
||
feature: RoutingFeature;
|
||
open: boolean;
|
||
gatewayDirect: boolean;
|
||
isGateway: boolean;
|
||
hasSubscription: boolean;
|
||
onOpen: () => void;
|
||
}) {
|
||
const disabled = gatewayDirect || (isGateway && !hasSubscription);
|
||
return (
|
||
<RailAction
|
||
buttonRef={feature.toggleRef}
|
||
className={`client-local-rules-toggle${feature.pendingRestart ? ' has-pending' : ''}`}
|
||
open={open}
|
||
controls="client-local-rules"
|
||
disabled={disabled}
|
||
ariaLabel={disabled
|
||
? hasSubscription
|
||
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
|
||
: 'Локальные правила недоступны: сначала добавьте подписку'
|
||
: open ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||
label={disabled
|
||
? hasSubscription
|
||
? 'Локальные правила недоступны: сейчас работают правила Gateway'
|
||
: 'Сначала добавьте подписку'
|
||
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}
|
||
onClick={() => open ? feature.requestClose() : onOpen()}
|
||
>
|
||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||
<path className="client-rail-rule-track" d="M4 7h16M4 17h16" />
|
||
<path className="client-rail-rule-scan is-top" pathLength="1" d="M4 7h16" />
|
||
<path className="client-rail-rule-scan is-bottom" pathLength="1" d="M4 17h16" />
|
||
<circle className="client-rail-rule-knob is-top" cx="15" cy="7" r="2" />
|
||
<circle className="client-rail-rule-knob is-bottom" cx="9" cy="17" r="2" />
|
||
</svg>
|
||
</RailAction>
|
||
);
|
||
}
|
||
|
||
export function RoutingPendingStatus({
|
||
feature,
|
||
blocked,
|
||
onRestart,
|
||
}: {
|
||
feature: RoutingFeature;
|
||
blocked: boolean;
|
||
onRestart: () => unknown;
|
||
}) {
|
||
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
|
||
{feature.pendingCount > 0 && (
|
||
<>
|
||
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
|
||
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
|
||
</>
|
||
)}
|
||
</div>;
|
||
}
|
||
|
||
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
|
||
const draftRules = feature.rules.filter((rule) => !rule.removing);
|
||
const canAdd = canAppendRouteRule(draftRules) && !feature.blocked;
|
||
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
|
||
|
||
return (
|
||
<Drawer
|
||
panelRef={feature.panelRef}
|
||
closeRef={feature.closeRef}
|
||
id="client-local-rules"
|
||
className="client-local-rules"
|
||
sheetClassName="client-local-rules-sheet"
|
||
open={feature.isOpen}
|
||
labelledBy="local-rules-title"
|
||
closeLabel="Закрыть локальные правила"
|
||
onClose={feature.requestClose}
|
||
>
|
||
<header className="client-local-rules-header">
|
||
<span>Маршрутизация</span>
|
||
<button
|
||
className="client-local-rules-save"
|
||
type="submit"
|
||
form="client-local-rules-form"
|
||
disabled={feature.blocked || !feature.dirty}
|
||
>
|
||
Сохранить
|
||
</button>
|
||
<h2 id="local-rules-title">Локальные правила</h2>
|
||
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
||
{feature.pendingRestart && (
|
||
<p className="client-local-rules-runtime" role="status">
|
||
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
|
||
</p>
|
||
)}
|
||
</header>
|
||
|
||
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
|
||
<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">
|
||
{feature.rules.map((rule, index) => {
|
||
const [status, statusLabel] = localRuleStatus(
|
||
rule,
|
||
feature.savedRules,
|
||
feature.activeRules,
|
||
feature.connected,
|
||
);
|
||
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={() => feature.change(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) => feature.change(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) => feature.change(index, 'value', event.target.value)}
|
||
/>
|
||
<span className="client-local-rule-status" role="status">{statusLabel}</span>
|
||
<button
|
||
className="client-row-delete"
|
||
type="button"
|
||
aria-label={`Удалить правило ${index + 1}`}
|
||
onClick={() => feature.remove(rule._key)}
|
||
>
|
||
×
|
||
</button>
|
||
<span
|
||
className="client-delete-strike"
|
||
aria-hidden="true"
|
||
onAnimationEnd={() => feature.finishRemove(rule._key)}
|
||
/>
|
||
</div>
|
||
);
|
||
})}
|
||
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
|
||
</div>
|
||
<div className="client-row-add-slot">
|
||
<button className="client-row-add" type="button" disabled={!canAdd} onClick={feature.add}>
|
||
+ Добавить правило
|
||
</button>
|
||
<span className={incomplete ? 'is-visible' : ''}>Сначала заполните текущее правило</span>
|
||
</div>
|
||
</section>
|
||
|
||
<p className="client-local-rules-note">
|
||
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
|
||
</p>
|
||
{statusSlot}
|
||
<div className="client-local-rules-actions">
|
||
<button type="button" onClick={feature.requestClose}>Отмена</button>
|
||
</div>
|
||
</form>
|
||
</Drawer>
|
||
);
|
||
}
|
||
|
||
export function RoutingDiscardDialog({ feature }: { feature: RoutingFeature }) {
|
||
return <ConfirmationDialog
|
||
open={feature.confirmingClose}
|
||
id="discard-local-rules"
|
||
title="Есть несохранённые настройки"
|
||
description="Закрыть редактор и потерять изменения?"
|
||
cancelLabel="Остаться"
|
||
confirmLabel="Закрыть без сохранения"
|
||
onCancel={() => feature.setConfirmingClose(false)}
|
||
onConfirm={feature.discard}
|
||
/>;
|
||
}
|