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 = { 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; onSave: (rules: RouteRule[], expectedRevision: number) => Promise; 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) => 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).state; if (!state || typeof state !== 'object' || Array.isArray(state)) { throw new TypeError('Harbor route mutation returned invalid state'); } const route = (state as Record).route; if (!route || typeof route !== 'object' || Array.isArray(route)) { throw new TypeError('Harbor route mutation returned invalid state'); } const { localRulesRevision, localRulesPendingRestart } = route as Record; 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([]); const [revision, setRevision] = useState(route?.localRulesRevision || 0); const [confirmingClose, setConfirmingClose] = useState(false); const panelRef = useRef(null); const toggleRef = useRef(null); const closeRef = useRef(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, 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) { 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; 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(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: 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, 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 (
{ROUTE_RULE_OPTIONS.map(([type, label], optionIndex) => ( ))}
); } export function RoutingToggle({ feature, gatewayDirect, isGateway, hasSubscription, onOpen, }: { feature: RoutingFeature; gatewayDirect: boolean; isGateway: boolean; hasSubscription: boolean; onOpen: () => void; }) { const disabled = gatewayDirect || (isGateway && !hasSubscription); return ( feature.isOpen ? feature.requestClose() : onOpen()} > ); } export function RoutingPendingStatus({ feature, blocked, onRestart, }: { feature: RoutingFeature; blocked: boolean; onRestart: () => unknown; }) { return
{feature.pendingCount > 0 && ( <> {feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'} )}
; } 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 (
Маршрутизация

Локальные правила

Эти домены идут напрямую. Остальной трафик — через выбранный VPN.

{feature.pendingRestart && (

Правила сохранены, но начнут работать после запуска или перезапуска sing-box.

)}
Правила
{feature.rules.map((rule, index) => { const [status, statusLabel] = localRuleStatus( rule, feature.savedRules, feature.activeRules, feature.connected, ); return (
feature.change(index, 'type', type)} /> feature.change(index, 'value', event.target.value)} /> {statusLabel}
); })} {!feature.rules.length &&

Правил пока нет. Весь трафик идёт через VPN.

}
Сначала заполните текущее правило

Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.

{statusSlot}
); } export function RoutingDiscardDialog({ feature }: { feature: RoutingFeature }) { return feature.setConfirmingClose(false)} onConfirm={feature.discard} />; }