Update Harbor client and gateway functionality
Build and Deploy Gateway / build-and-push (push) Successful in 35s
Build and Deploy Gateway / deploy (push) Successful in 14s

This commit is contained in:
2026-08-17 15:23:16 +03:00
parent 0b39211fbd
commit 7c255192b0
41 changed files with 1443 additions and 218 deletions
@@ -237,7 +237,7 @@ export function createConnectionService(dependencies: ConnectionServiceDependenc
appliedServerId: server.id,
appliedServerSnapshot: server,
connectionDesired: 'running',
appliedRouteRules: state.routeRules,
appliedRouteRules: dependencies.route?.isGatewayDirect() ? [] : state.routeRules,
}));
} catch (error) {
await finishRollback(error, [
@@ -205,7 +205,12 @@ export function createGatewayAutoService(dependencies: GatewayAutoServiceDepende
current = candidate;
gatewayAutoPublished = true;
stateCommitStarted = true;
dependencies.state.update((state) => state);
dependencies.state.update((state) => ({
...state,
...(modeChanged && wasRunning && reconfigure ? {
appliedRouteRules: candidate.mode === 'gateway-direct' ? [] : state.routeRules,
} : {}),
}));
}
if (persistEnabled !== undefined) {
stateCommitStarted = true;
@@ -7,7 +7,10 @@ import {
type StoredState,
} from '../../../shared/contracts/state.js';
import { HarborError } from '../../../shared/errors.js';
import { normalizeRouteRules } from '../../../shared/routingRules.js';
import {
normalizeRouteRules,
ROUTE_RULES_CONTRACT_VERSION,
} from '../../../shared/routingRules.js';
import type { RuntimeCommandResult } from '../connection/index.js';
import { finishRollback } from '../../services/rollback.js';
@@ -29,6 +32,7 @@ interface RouteRulesDependencies {
applyCommand(): Promise<RuntimeCommandResult>;
restoreRunning(): Promise<unknown>;
};
route?: { isGatewayDirect(): boolean };
serialize<T>(operation: () => Promise<T>): Promise<T>;
runOperation<T>(operation: () => Promise<T>): Promise<T>;
}
@@ -36,6 +40,7 @@ interface RouteRulesDependencies {
export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const applyRules = async (previousState: StoredState, routeRules: RouteRule[]) => {
const wasRunning = await dependencies.runtime.isRunning();
const bypassed = dependencies.route?.isGatewayDirect() === true;
const targetProfile = wasRunning
? appliedProfile(previousState)
: desiredProfile(previousState);
@@ -45,13 +50,14 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
const subscriptionConfig = targetProfile
? dependencies.subscription.readConfig(targetProfile.id)
: null;
if (!targetServerId || !subscriptionConfig) {
if (bypassed || !targetServerId || !subscriptionConfig) {
let stateCommitStarted = false;
try {
stateCommitStarted = true;
dependencies.state.update((state) => ({
...state,
routeRules,
...(bypassed ? { appliedRouteRules: [] } : {}),
routeRulesRevision: state.routeRulesRevision + 1,
}));
} catch (error) {
@@ -68,14 +74,17 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
routeRules,
);
const previousConfig = dependencies.config.read();
const configChanged = previousConfig !== JSON.stringify(candidateConfig, null, 2);
let configMutationStarted = false;
let runtimeMutationStarted = false;
let stateCommitStarted = false;
try {
configMutationStarted = true;
dependencies.config.write(candidateConfig);
if (wasRunning) {
if (configChanged) {
configMutationStarted = true;
dependencies.config.write(candidateConfig);
}
if (wasRunning && configChanged) {
const command = await dependencies.runtime.applyCommand();
runtimeMutationStarted = command.mutationStarted;
if (!command.ok) throw command.error;
@@ -103,24 +112,27 @@ export function createRouteRulesService(dependencies: RouteRulesDependencies) {
}
};
const update = (rules: unknown, expectedRulesRevision: unknown, expectedRevision: unknown) => {
const update = (
rules: unknown,
expectedRulesRevision: unknown,
rulesContractVersion: unknown,
) => {
if (rulesContractVersion !== ROUTE_RULES_CONTRACT_VERSION) {
throw new HarborError('REQUEST_INVALID');
}
let routeRules: RouteRule[];
try {
routeRules = normalizeRouteRules(rules, { strict: true }) as RouteRule[];
} catch (cause) {
throw new HarborError('REQUEST_INVALID', { cause });
}
const rulesRevision = expectedRulesRevision ?? expectedRevision;
if (!Number.isSafeInteger(rulesRevision) || Number(rulesRevision) < 0) {
if (!Number.isSafeInteger(expectedRulesRevision) || Number(expectedRulesRevision) < 0) {
throw new HarborError('REQUEST_INVALID');
}
return dependencies.serialize(async () => {
const current = dependencies.state.read();
const currentRevision = expectedRulesRevision == null
? current.revision
: current.routeRulesRevision;
if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT');
if (current.routeRulesRevision !== expectedRulesRevision) throw new HarborError('STATE_CONFLICT');
if (isDeepStrictEqual(current.routeRules, routeRules)) return;
await dependencies.runOperation(() => applyRules(current, routeRules));
});
@@ -297,6 +297,9 @@ export function createSubscriptionService(dependencies: SubscriptionServiceDepen
profiles: replaceProfile(current, refreshedProfile),
appliedServerId: nextAppliedServerId,
appliedServerSnapshot: nextAppliedServer,
appliedRouteRules: dependencies.gatewayAuto.read().mode === 'gateway-direct'
? []
: current.routeRules,
}));
} catch (error) {
await finishRollback(error, [
+3 -3
View File
@@ -11,9 +11,9 @@ interface RouteRulesRouteDependencies {
export function createRouteRulesRoute(dependencies: RouteRulesRouteDependencies) {
return {
async handle(req: IncomingMessage, res: ServerResponse) {
if (req.method !== 'PUT' || req.url !== '/api/route-rules') return false;
const { rules, expectedRulesRevision, expectedRevision } = await dependencies.readBody(req);
await dependencies.routeRules.update(rules, expectedRulesRevision, expectedRevision);
if (req.method !== 'PUT' || !['/api/route-rules', '/api/route-rules/v2'].includes(req.url || '')) return false;
const { rules, expectedRulesRevision, rulesContractVersion } = await dependencies.readBody(req);
await dependencies.routeRules.update(rules, expectedRulesRevision, rulesContractVersion);
await dependencies.sendState(res);
return true;
},
+8 -1
View File
@@ -538,6 +538,10 @@ const routeRulesService = createRouteRulesService({
),
restoreRunning: () => startSingbox(),
},
route: {
isGatewayDirect: () => settings.appMode === 'client'
&& gatewayAutoService.read().mode === 'gateway-direct',
},
serialize: serializeControl,
runOperation: (operation) => withOperation('route-rules', operation),
});
@@ -851,7 +855,10 @@ if (bootWantsRunning) {
appliedProfileId: appliedProfile.id,
appliedServerId: appliedServer.id,
appliedServerSnapshot: appliedServer,
...(target ? { appliedRouteRules: state.routeRules } : {}),
...(settings.appMode === 'client'
&& gatewayAutoService.read().mode === 'gateway-direct'
? { appliedRouteRules: [] }
: target ? { appliedRouteRules: state.routeRules } : {}),
}));
}
})
+6 -3
View File
@@ -5,7 +5,7 @@ import {
normalizeStoredState,
type PersistedState,
} from '../../shared/contracts/state.js';
import { INITIAL_ROUTE_RULES } from '../../shared/routingRules.js';
import { INITIAL_ROUTE_RULES, normalizeRouteRules } from '../../shared/routingRules.js';
import {
normalizeServers,
resolveServerId,
@@ -13,7 +13,7 @@ import {
type NormalizedServer,
} from '../../shared/serverIdentity.js';
export const STATE_SCHEMA_VERSION = 5;
export const STATE_SCHEMA_VERSION = 6;
export interface AtomicWriteOptions {
beforeRename?: (temporaryPath: string, filePath: string) => void;
@@ -143,6 +143,8 @@ export function migrateStoredState(
const routeRules = version < 3
? [...INITIAL_ROUTE_RULES, ...(Array.isArray(stored.routeRules) ? stored.routeRules : [])]
: stored.routeRules;
const migratedRouteRules = normalizeRouteRules(routeRules, { strict: version >= 6 });
const migratedAppliedRouteRules = normalizeRouteRules(stored.appliedRouteRules, { strict: version >= 6 });
const legacyCache = record(legacySubscriptionCache);
const storedSubscriptionUrl = String(stored.subscriptionUrl || '').trim();
const cachedSubscriptionUrl = String(legacyCache.url || '').trim();
@@ -168,7 +170,8 @@ export function migrateStoredState(
const keepLegacyApplied = !(version < 5 && stored.connectionDesired === 'stopped');
const normalized = normalizeStoredState({
...stored,
routeRules,
routeRules: migratedRouteRules,
appliedRouteRules: migratedAppliedRouteRules,
...(version < 5 && !Array.isArray(stored.profiles) ? {
subscriptionUrl: storedSubscriptionUrl || (cacheOwnsStoredSubscription ? cachedSubscriptionUrl : ''),
subscriptionConfig: cacheOwnsStoredSubscription ? legacyCache.config : null,
+7 -4
View File
@@ -70,9 +70,12 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
set_system_proxy: false,
},
];
const directRules = normalizeRouteRules(routeRules)
const userRules = (directClient ? [] : normalizeRouteRules(routeRules))
.filter((rule) => rule.enabled)
.map((rule) => ({ [rule.type]: [rule.value], outbound: 'direct' }));
.map((rule) => ({
[rule.type]: [rule.value],
outbound: rule.outbound === 'vpn' ? vpnOutbound.tag : 'direct',
}));
const rules = clientMode
? [
{
@@ -82,7 +85,7 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
timeout: SNIFF_TIMEOUT,
},
{ inbound: [DIAGNOSTICS_INBOUND], outbound: vpnOutbound.tag },
...directRules,
...userRules,
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
]
: [
@@ -93,7 +96,7 @@ export function buildGatewayConfig(subscriptionConfig: unknown, selectedTag: unk
timeout: SNIFF_TIMEOUT,
},
{ inbound: [DIAGNOSTICS_INBOUND], outbound: outboundTag },
...directRules,
...userRules,
{ inbound: [TPROXY_INBOUND], outbound: outboundTag },
{ inbound: [MIXED_INBOUND], outbound: outboundTag },
];
+34 -5
View File
@@ -1,4 +1,8 @@
import { normalizeRouteRules } from '../routingRules.js';
import {
normalizeRouteRules,
ROUTE_RULES_CONTRACT_VERSION,
type RouteRuleOutbound,
} from '../routingRules.js';
import { normalizeServers, resolveServerId } from '../serverIdentity.js';
export type HarborMode = 'client' | 'gateway';
@@ -18,6 +22,7 @@ export interface RouteRule {
type: 'domain' | 'domain_suffix' | 'domain_keyword';
value: string;
enabled: boolean;
outbound: RouteRuleOutbound;
}
export interface StoredProfile {
@@ -74,6 +79,7 @@ export interface StateSnapshot {
lastError: string | null;
};
route: {
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
mode: string;
gatewayAddress: string | null;
gatewayUiOrigin: string | null;
@@ -343,7 +349,8 @@ export function createStateSnapshot({
: routeMode === 'gateway-direct'
? gatewayAuto?.failures ? 'gateway-stale' : 'gateway-found'
: gatewayAuto?.lastError ? 'gateway-lost' : 'local';
const activeLocalRules = running ? stored.appliedRouteRules : [];
const localRulesBypassed = mode === 'client' && routeMode === 'gateway-direct';
const activeLocalRules = running && !localRulesBypassed ? stored.appliedRouteRules : [];
const appliedServerSnapshot = stored.appliedServerSnapshot;
return assertStateSnapshot({
@@ -377,6 +384,7 @@ export function createStateSnapshot({
lastError: null,
},
route: {
rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION,
mode: routeMode,
gatewayAddress: mode === 'client' ? gatewayAuto?.gateway?.gateway || null : null,
gatewayUiOrigin: mode === 'client' ? gatewayAuto?.uiOrigin || null : null,
@@ -387,7 +395,9 @@ export function createStateSnapshot({
localRules: stored.routeRules,
activeLocalRules,
localRulesRevision: stored.routeRulesRevision,
localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules),
localRulesPendingRestart: localRulesBypassed
? false
: !isSameRules(stored.routeRules, activeLocalRules),
},
operation: {
kind: nullableText(operation.kind),
@@ -402,7 +412,24 @@ export function createStateSnapshot({
}
export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
const candidate = snapshot as StateSnapshot;
const rawCandidate = snapshot as StateSnapshot;
const legacyRule = (rule: RouteRule): RouteRule => (
rule && !Object.hasOwn(rule, 'outbound')
? { ...rule, outbound: 'direct' }
: rule
);
const candidate = rawCandidate?.route?.rulesContractVersion === undefined
&& Array.isArray(rawCandidate?.route?.localRules)
&& Array.isArray(rawCandidate?.route?.activeLocalRules)
? {
...rawCandidate,
route: {
...rawCandidate.route,
localRules: rawCandidate.route.localRules.map(legacyRule),
activeLocalRules: rawCandidate.route.activeLocalRules.map(legacyRule),
},
}
: rawCandidate;
const validDate = (value: unknown) => typeof value === 'string' && Number.isFinite(Date.parse(value));
const nullableDate = (value: unknown) => value === null || validDate(value);
const nullableString = (value: unknown) => value === null || typeof value === 'string';
@@ -420,7 +447,8 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
['domain', 'domain_suffix', 'domain_keyword'].includes(rule.type) &&
typeof rule.value === 'string' &&
Boolean(rule.value) &&
typeof rule.enabled === 'boolean'
typeof rule.enabled === 'boolean' &&
['vpn', 'direct'].includes(rule.outbound)
);
const validProfile = (profile: ProfileSnapshot) => (
profile &&
@@ -469,6 +497,7 @@ export function assertStateSnapshot(snapshot: unknown): StateSnapshot {
!nullableDate(candidate.connection.startedAt) ||
!nullableString(candidate.connection.lastError) ||
!candidate.route ||
![undefined, ROUTE_RULES_CONTRACT_VERSION].includes(candidate.route.rulesContractVersion) ||
typeof candidate.route.mode !== 'string' ||
!nullableString(candidate.route.gatewayAddress) ||
!nullableString(candidate.route.gatewayUiOrigin) ||
+20 -4
View File
@@ -1,16 +1,20 @@
export const INITIAL_ROUTE_RULES = Object.freeze([
Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true }),
Object.freeze({ type: 'domain_suffix', value: 'ru', enabled: true, outbound: 'direct' }),
]);
const RULE_TYPES = new Set(['domain', 'domain_suffix', 'domain_keyword']);
const RULE_OUTBOUNDS = new Set(['vpn', 'direct']);
export const ROUTE_RULES_CONTRACT_VERSION = 2;
export const MAX_ROUTE_RULES = 200;
export type RouteRuleType = 'domain' | 'domain_suffix' | 'domain_keyword';
export type RouteRuleOutbound = 'vpn' | 'direct';
export interface NormalizedRouteRule {
type: RouteRuleType;
value: string;
enabled: boolean;
outbound: RouteRuleOutbound;
}
function record(value: unknown): Record<string, unknown> {
@@ -28,20 +32,32 @@ function hostname(value: unknown) {
return normalized;
}
function normalizeRule(input: unknown): NormalizedRouteRule {
function normalizeRule(input: unknown, strict: boolean): NormalizedRouteRule {
const rule = record(input);
const type = String(rule.type || '').trim();
if (!RULE_TYPES.has(type)) throw new TypeError('Invalid domain rule type');
if (Object.hasOwn(rule, 'enabled') && typeof rule.enabled !== 'boolean') {
throw new TypeError('Invalid domain rule enabled state');
}
if (strict && !Object.hasOwn(rule, 'outbound')) {
throw new TypeError('Route rule outbound is required');
}
const outbound = Object.hasOwn(rule, 'outbound')
? String(rule.outbound || '').trim()
: 'direct';
if (!RULE_OUTBOUNDS.has(outbound)) throw new TypeError('Invalid route rule outbound');
const value = type === 'domain_keyword'
? String(rule.value || '').trim().toLowerCase()
: hostname(rule.value);
if (!value || value.length > 253 || /[\s/:?#]/.test(value)) {
throw new TypeError('Invalid domain rule value');
}
return { type: type as RouteRuleType, value, enabled: rule.enabled !== false };
return {
type: type as RouteRuleType,
value,
enabled: rule.enabled !== false,
outbound: outbound as RouteRuleOutbound,
};
}
export function normalizeRouteRules(
@@ -60,7 +76,7 @@ export function normalizeRouteRules(
const normalized: NormalizedRouteRule[] = [];
for (const candidate of value.slice(0, MAX_ROUTE_RULES)) {
try {
const rule = normalizeRule(candidate);
const rule = normalizeRule(candidate, strict);
const key = `${rule.type}:${rule.value}`;
if (seen.has(key)) continue;
seen.add(key);
+3 -3
View File
@@ -1,7 +1,7 @@
export const HARBOR_VERSIONS = Object.freeze({
macClient: '0.25.16',
gatewayClient: '0.26.15',
gatewayBackend: '0.26.5',
macClient: '0.26.0',
gatewayClient: '0.27.0',
gatewayBackend: '0.27.0',
});
export interface ParsedVersion {
+3 -2
View File
@@ -1,5 +1,6 @@
import { ERROR_DEFINITIONS, errorDefinition } from '../../shared/errors.js';
import { assertStateSnapshot, type StateSnapshot } from '../../shared/contracts/state.js';
import { ROUTE_RULES_CONTRACT_VERSION } from '../../shared/routingRules.js';
type RequestOptions = Omit<RequestInit, 'headers'> & {
headers?: Record<string, string>;
@@ -148,9 +149,9 @@ export const api = {
}),
},
routeRules: {
update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules', {
update: (rules: unknown[], expectedRulesRevision: number) => request('/api/route-rules/v2', {
method: 'PUT',
body: JSON.stringify({ rules, expectedRulesRevision }),
body: JSON.stringify({ rules, expectedRulesRevision, rulesContractVersion: ROUTE_RULES_CONTRACT_VERSION }),
}),
},
devices: {
+503 -46
View File
@@ -2,16 +2,37 @@ import {
useEffect,
useRef,
useState,
type FocusEvent as ReactFocusEvent,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
type PointerEvent as ReactPointerEvent,
type ReactNode,
} from 'react';
import { flushSync } from 'react-dom';
import { canAppendRouteRule } from '../../../shared/routingRules.js';
import {
canAppendRouteRule,
ROUTE_RULES_CONTRACT_VERSION,
} 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';
import {
beginRuleReorder,
crossedRuleIndex,
edgeScrollDelta,
endRuleReorder,
keyboardRuleIndex,
moveRule,
RULE_DROP_DURATION_MS,
RULE_REORDER_DURATION_MS,
restoreRuleOrder,
shouldLiftRule,
type RuleReorderEndReason,
type RuleReorderLifecycle,
} from './ruleReorderModel.js';
const ROUTE_RULE_OPTIONS: Array<[RouteRule['type'], string]> = [
['domain', 'Точный домен'],
@@ -31,6 +52,8 @@ interface DraftRule extends RouteRule {
}
interface RoutingState {
rulesContractVersion?: typeof ROUTE_RULES_CONTRACT_VERSION;
mode?: string;
localRules?: RouteRule[];
activeLocalRules?: RouteRule[];
localRulesRevision?: number;
@@ -50,39 +73,57 @@ interface RoutingSaveState {
localRulesPendingRestart: boolean;
}
interface RuleDragSession extends RuleReorderLifecycle {
originRules: DraftRule[];
pointerId: number | null;
startY: number;
latestY: number;
pointerOffsetY: number;
translateY: number;
raf: number | null;
handle: HTMLButtonElement;
}
let localRuleDraftId = 0;
const createLocalRuleDraft = (rule: RouteRule): DraftRule => ({
...rule,
enabled: rule?.enabled !== false,
outbound: rule?.outbound === 'vpn' ? 'vpn' : 'direct',
_key: `route-rule-${localRuleDraftId += 1}`,
});
const localRuleValues = (rules: DraftRule[]): RouteRule[] => rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled }));
.map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound }));
const localRulesSignature = (rules: Array<RouteRule & { removing?: boolean }>) => JSON.stringify(
rules
.filter((rule) => !rule.removing)
.map(({ type, value, enabled }) => ({ type, value, enabled })),
.map(({ type, value, enabled, outbound }) => ({ type, value, enabled, outbound })),
);
const localRuleKey = ({ type, value, enabled }: RouteRule) => (
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}`
const localRuleKey = ({ type, value, enabled, outbound }: RouteRule) => (
`${type}:${String(value || '').trim().toLowerCase()}:${enabled}:${outbound}`
);
const sameRule = (left?: RouteRule, right?: RouteRule) => (
Boolean(left && right && localRuleKey(left) === localRuleKey(right))
);
function localRuleStatus(
rule: DraftRule,
index: number,
savedRules: RouteRule[],
activeRules: RouteRule[],
runtimeActive: boolean,
bypassed: boolean,
) {
const key = localRuleKey(rule);
if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено'];
if (!sameRule(rule, savedRules[index])) return ['unsaved', 'Не сохранено'];
if (!rule.enabled) return ['disabled', 'Выключено'];
if (bypassed) return ['bypassed', 'Обходится'];
if (!runtimeActive) return ['saved', 'Сохранено'];
if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно'];
if (sameRule(rule, activeRules[index])) return ['active', 'Активно'];
return ['pending', 'Ждёт перезапуска'];
}
@@ -121,19 +162,34 @@ export function useRoutingFeature({
const [rules, setRules] = useState<DraftRule[]>([]);
const [revision, setRevision] = useState(route?.localRulesRevision || 0);
const [confirmingClose, setConfirmingClose] = useState(false);
const [liftedKey, setLiftedKey] = useState<string | null>(null);
const [reorderAnnouncement, setReorderAnnouncement] = useState('');
const panelRef = useRef<HTMLElement>(null);
const toggleRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const baselineRef = useRef('[]');
const rulesRef = useRef<DraftRule[]>(rules);
const dragRef = useRef<RuleDragSession | null>(null);
const suppressHandleClickRef = useRef(false);
rulesRef.current = rules;
const savedRules = route?.localRules || [];
const activeRules = route?.activeLocalRules || [];
const editable = route?.rulesContractVersion === ROUTE_RULES_CONTRACT_VERSION;
const bypassed = route?.mode === 'gateway-direct';
const dirty = localRulesSignature(rules) !== baselineRef.current;
const pendingRestart = connected && route?.localRulesPendingRestart === true;
const pendingRestart = connected && !bypassed && route?.localRulesPendingRestart === true;
const savedUntilStart = !connected && !bypassed && route?.localRulesPendingRestart === true;
const pendingCount = pendingRestart
? savedRules.filter((rule) => (
rule.enabled && !activeRules.some((active) => localRuleKey(active) === localRuleKey(rule))
)).length
? savedRules.filter((rule, index) => rule.enabled && !sameRule(rule, activeRules[index])).length
: 0;
const reorderedOnly = pendingRestart
&& savedRules.length === activeRules.length
&& JSON.stringify(savedRules.map(localRuleKey).sort()) === JSON.stringify(activeRules.map(localRuleKey).sort());
const pendingMessage = !pendingRestart
? ''
: reorderedOnly || pendingCount === 0
? 'Изменения правил не применены'
: `${pendingCount} ${pendingCount === 1 ? 'правило не применено' : 'правила не применены'}`;
const blocked = operationBlocked(operations, 'routeRules') || rules.some((rule) => rule.removing);
useEffect(() => {
@@ -153,6 +209,11 @@ export function useRoutingFeature({
if (event.type === 'keydown') {
const keyboardEvent = event as KeyboardEvent;
if (keyboardEvent.key !== 'Escape' || keyboardEvent.defaultPrevented) return;
if (dragRef.current?.lifted) {
keyboardEvent.preventDefault();
cancelReorder();
return;
}
} else {
if (panelRef.current?.contains(event.target as Node)) return;
if (toggleRef.current?.contains(event.target as Node)) return;
@@ -167,6 +228,14 @@ export function useRoutingFeature({
};
}, [isOpen, dirty]);
useEffect(() => () => {
const session = dragRef.current;
if (session && endRuleReorder(session, 'unmount').stopAutoScroll && session.raf !== null) {
cancelAnimationFrame(session.raf);
}
dragRef.current = null;
}, []);
useEffect(() => {
if (!isOpen || !dirty) return undefined;
const warnBeforeUnload = (event: BeforeUnloadEvent) => {
@@ -178,8 +247,10 @@ export function useRoutingFeature({
}, [isOpen, dirty]);
function open() {
baselineRef.current = JSON.stringify(savedRules.map(({ type, value, enabled }) => ({ type, value, enabled })));
setRules(savedRules.map(createLocalRuleDraft));
const nextRules = savedRules.map(createLocalRuleDraft);
baselineRef.current = localRulesSignature(savedRules);
rulesRef.current = nextRules;
setRules(nextRules);
setRevision(route?.localRulesRevision || 0);
setConfirmingClose(false);
onDismissError();
@@ -187,11 +258,13 @@ export function useRoutingFeature({
}
function forceClose() {
cancelReorder(false);
setIsOpen(false);
}
function requestClose() {
if (dirty) {
const currentRules = cancelReorder(false);
if (localRulesSignature(currentRules) !== baselineRef.current) {
setConfirmingClose(true);
return false;
}
@@ -200,36 +273,52 @@ export function useRoutingFeature({
}
function discard() {
cancelReorder(false);
setConfirmingClose(false);
setIsOpen(false);
}
function change(index: number, field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled'>, value: unknown) {
setRules((current) => current.map((rule, ruleIndex) => (
function updateRules(updater: (current: DraftRule[]) => DraftRule[]) {
setRules((current) => {
const next = updater(current);
rulesRef.current = next;
return next;
});
}
function change(
index: number,
field: keyof Pick<RouteRule, 'type' | 'value' | 'enabled' | 'outbound'>,
value: unknown,
) {
if (!editable || blocked) return;
updateRules((current) => current.map((rule, ruleIndex) => (
ruleIndex === index ? { ...rule, [field]: value } as DraftRule : rule
)));
}
function add() {
setRules((current) => [
if (!editable || blocked) return;
updateRules((current) => [
...current,
createLocalRuleDraft({ type: 'domain', value: '', enabled: true }),
createLocalRuleDraft({ type: 'domain', value: '', enabled: true, outbound: 'direct' }),
]);
}
function remove(ruleKey: string) {
if (!editable || blocked) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
setRules((current) => current.filter((rule) => rule._key !== ruleKey));
updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
return;
}
setRules((current) => current.map((rule) => (
updateRules((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));
updateRules((current) => current.filter((rule) => rule._key !== ruleKey));
});
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
update();
@@ -240,25 +329,298 @@ export function useRoutingFeature({
async function save(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const values = localRuleValues(rules);
if (!editable || blocked) return;
if (dragRef.current) dropReorder(false);
const values = localRuleValues(rulesRef.current);
const result = routingSaveState(await onSave(values, revision));
if (!result) return;
baselineRef.current = JSON.stringify(values);
baselineRef.current = localRulesSignature(values);
setRevision(result.localRulesRevision);
setConfirmingClose(false);
if (!connected || !result.localRulesPendingRestart) setIsOpen(false);
}
function reducedMotion() {
return matchMedia('(prefers-reduced-motion: reduce)').matches;
}
function ruleRows() {
return new Map(Array.from(
panelRef.current?.querySelectorAll<HTMLElement>('[data-rule-key]') || [],
(row) => [row.dataset.ruleKey || '', row],
));
}
function replaceRuleOrder(next: DraftRule[], draggedRuleKey: string) {
const rows = ruleRows();
const previousTops = new Map(Array.from(rows, ([key, row]) => [key, row.getBoundingClientRect().top]));
rulesRef.current = next;
flushSync(() => setRules(next));
if (reducedMotion()) return;
for (const [key, row] of ruleRows()) {
if (key === draggedRuleKey) continue;
const previousTop = previousTops.get(key);
if (previousTop === undefined) continue;
const delta = previousTop - row.getBoundingClientRect().top;
if (!delta) continue;
row.animate(
[{ transform: `translateY(${delta}px)` }, { transform: 'translateY(0)' }],
{ duration: RULE_REORDER_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
);
}
}
function moveRuleTo(ruleKey: string, target: number) {
const current = rulesRef.current;
const from = current.findIndex((rule) => rule._key === ruleKey);
if (from < 0 || target === from) return from;
replaceRuleOrder(moveRule(current, from, target), ruleKey);
return target;
}
function positionPointerRule(pointerY: number) {
const session = dragRef.current;
if (!session?.lifted) return;
const rows = ruleRows();
let row = rows.get(session.key);
if (!row) return;
let rect = row.getBoundingClientRect();
const desiredCenter = pointerY - session.pointerOffsetY;
const baseCenter = rect.top + rect.height / 2 - session.translateY;
session.translateY = desiredCenter - baseCenter;
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
const currentIndex = rulesRef.current.findIndex((rule) => rule._key === session.key);
const centers = rulesRef.current.map((rule) => {
const ruleRect = rows.get(rule._key)?.getBoundingClientRect();
return ruleRect ? ruleRect.top + ruleRect.height / 2 : desiredCenter;
});
const target = crossedRuleIndex(currentIndex, desiredCenter, centers);
if (target === currentIndex) return;
moveRuleTo(session.key, target);
row = ruleRows().get(session.key);
if (!row) return;
rect = row.getBoundingClientRect();
session.translateY = desiredCenter - (rect.top + rect.height / 2 - session.translateY);
row.style.setProperty('--client-rule-drag-y', `${session.translateY}px`);
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
}
function stopAutoScroll(session: RuleDragSession) {
if (session.raf !== null) cancelAnimationFrame(session.raf);
session.raf = null;
}
function continueAutoScroll(session: RuleDragSession) {
if (session.raf !== null) return;
const tick = () => {
session.raf = null;
if (dragRef.current !== session || !session.lifted) return;
const container = panelRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
const delta = edgeScrollDelta(session.latestY, rect.top, rect.bottom);
if (!delta) return;
const previousTop = container.scrollTop;
container.scrollTop += delta;
if (container.scrollTop === previousTop) return;
positionPointerRule(session.latestY);
session.raf = requestAnimationFrame(tick);
};
session.raf = requestAnimationFrame(tick);
}
function startPointerReorder(event: ReactPointerEvent<HTMLButtonElement>, ruleKey: string) {
if (event.button !== 0 || !event.isPrimary || !editable || blocked || rulesRef.current.length < 2) return;
if (dragRef.current?.input === 'keyboard') finishReorder('focus-leave');
const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'pointer');
if (!lifecycle) return;
const row = event.currentTarget.closest<HTMLElement>('[data-rule-key]');
if (!row) return;
const rect = row.getBoundingClientRect();
event.currentTarget.setPointerCapture(event.pointerId);
dragRef.current = {
...lifecycle,
originRules: [...rulesRef.current],
pointerId: event.pointerId,
startY: event.clientY,
latestY: event.clientY,
pointerOffsetY: event.clientY - (rect.top + rect.height / 2),
translateY: 0,
raf: null,
handle: event.currentTarget,
};
}
function movePointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
const session = dragRef.current;
if (!session || session.pointerId !== event.pointerId) return;
session.latestY = event.clientY;
if (!session.lifted) {
if (!shouldLiftRule(session.startY, event.clientY)) return;
event.preventDefault();
session.lifted = true;
flushSync(() => setLiftedKey(session.key));
setReorderAnnouncement('Правило поднято');
}
event.preventDefault();
positionPointerRule(event.clientY);
const container = panelRef.current;
const bounds = container?.getBoundingClientRect();
if (!bounds || !edgeScrollDelta(session.latestY, bounds.top, bounds.bottom)) {
stopAutoScroll(session);
} else {
continueAutoScroll(session);
}
}
function clearDraggedStyle(session: RuleDragSession, settle: boolean) {
const row = ruleRows().get(session.key);
if (!row) return;
const translateY = session.translateY;
row.style.removeProperty('--client-rule-drag-y');
if (settle && translateY && !reducedMotion()) {
row.animate(
[{ transform: `translateY(${translateY}px)` }, { transform: 'translateY(0)' }],
{ duration: RULE_DROP_DURATION_MS, easing: 'cubic-bezier(0.16, 1, 0.3, 1)' },
);
}
}
function finishReorder(reason: RuleReorderEndReason, announce = true) {
const session = dragRef.current;
if (!session) return rulesRef.current;
const outcome = endRuleReorder(session, reason, reducedMotion());
if (outcome.stopAutoScroll) stopAutoScroll(session);
dragRef.current = null;
let nextRules = rulesRef.current;
if (outcome.restoreOrder) {
nextRules = restoreRuleOrder(session.originRules, rulesRef.current, (rule) => rule._key);
replaceRuleOrder(nextRules, session.key);
}
clearDraggedStyle(session, outcome.animateDrop);
setLiftedKey(null);
if (
outcome.releasePointerCapture
&& session.pointerId !== null
&& session.handle.hasPointerCapture(session.pointerId)
) {
session.handle.releasePointerCapture(session.pointerId);
}
if (session.lifted) {
if (reason === 'drop' && session.input === 'pointer') suppressHandleClickRef.current = true;
if (announce && reason !== 'unmount') {
setReorderAnnouncement(reason === 'drop' ? 'Правило размещено' : 'Перемещение отменено');
}
}
if (outcome.restoreFocus) session.handle.focus();
return nextRules;
}
function dropReorder(announce = true) {
return finishReorder('drop', announce);
}
function cancelReorder(announce = true) {
return finishReorder('cancel', announce);
}
function finishPointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
dropReorder();
}
function cancelPointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
cancelReorder();
}
function losePointerReorder(event: ReactPointerEvent<HTMLButtonElement>) {
if (dragRef.current?.pointerId !== event.pointerId) return;
finishReorder('lost-capture');
}
function toggleKeyboardReorder(handle: HTMLButtonElement, ruleKey: string) {
const active = dragRef.current;
if (active?.key === ruleKey && active.input === 'keyboard') {
dropReorder();
return;
}
if (active?.input === 'keyboard') finishReorder('focus-leave');
const lifecycle = beginRuleReorder(dragRef.current, ruleKey, 'keyboard');
if (!lifecycle || !editable || blocked || rulesRef.current.length < 2) return;
dragRef.current = {
...lifecycle,
originRules: [...rulesRef.current],
pointerId: null,
startY: 0,
latestY: 0,
pointerOffsetY: 0,
translateY: 0,
raf: null,
handle,
};
setLiftedKey(ruleKey);
setReorderAnnouncement('Правило поднято');
}
function handleReorderKey(event: ReactKeyboardEvent<HTMLButtonElement>, ruleKey: string) {
const session = dragRef.current;
const ownsSession = session?.key === ruleKey && session.input === 'keyboard';
if (!ownsSession) return;
if (event.key === 'Tab') {
finishReorder('focus-leave');
return;
}
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
cancelReorder();
return;
}
if (!['ArrowUp', 'ArrowDown'].includes(event.key)) return;
event.preventDefault();
const current = rulesRef.current.findIndex((rule) => rule._key === ruleKey);
const target = keyboardRuleIndex(current, event.key === 'ArrowUp' ? -1 : 1, rulesRef.current.length);
if (target === current) return;
moveRuleTo(ruleKey, target);
setReorderAnnouncement(`Правило перемещено на позицию ${target + 1}`);
event.currentTarget.focus();
}
function handleReorderClick(event: ReactMouseEvent<HTMLButtonElement>, ruleKey: string) {
if (suppressHandleClickRef.current) {
suppressHandleClickRef.current = false;
event.preventDefault();
event.stopPropagation();
return;
}
if (event.detail === 0) toggleKeyboardReorder(event.currentTarget, ruleKey);
}
function handleReorderBlur(event: ReactFocusEvent<HTMLButtonElement>, ruleKey: string) {
const session = dragRef.current;
if (session?.key === ruleKey && session.input === 'keyboard' && event.relatedTarget !== event.currentTarget) {
finishReorder('focus-leave');
}
}
return {
isOpen,
rules,
savedRules,
activeRules,
connected,
editable,
bypassed,
dirty,
pendingRestart,
savedUntilStart,
pendingCount,
pendingMessage,
blocked,
liftedKey,
reorderAnnouncement,
confirmingClose,
panelRef,
toggleRef,
@@ -272,6 +634,14 @@ export function useRoutingFeature({
add,
remove,
finishRemove,
startPointerReorder,
movePointerReorder,
finishPointerReorder,
cancelPointerReorder,
losePointerReorder,
handleReorderKey,
handleReorderClick,
handleReorderBlur,
save,
};
}
@@ -379,6 +749,32 @@ function RuleTypePicker({ value, ruleKey, index, disabled, onChange }: RuleTypeP
);
}
function RuleOutboundPicker({
value,
index,
disabled,
onChange,
}: {
value: RouteRule['outbound'];
index: number;
disabled?: boolean;
onChange: (value: RouteRule['outbound']) => void;
}) {
return <div className="client-rule-outbound" role="group" aria-label={`Маршрут правила ${index + 1}`}>
{([['vpn', 'VPN'], ['direct', 'Напрямую']] as const).map(([outbound, label]) => (
<button
type="button"
key={outbound}
aria-pressed={value === outbound}
disabled={disabled}
onClick={() => onChange(outbound)}
>
{label}
</button>
))}
</div>;
}
export function RoutingToggle({
feature,
open,
@@ -394,7 +790,7 @@ export function RoutingToggle({
hasSubscription: boolean;
onOpen: () => void;
}) {
const disabled = gatewayDirect || (isGateway && !hasSubscription);
const disabled = isGateway && !hasSubscription;
return (
<RailAction
buttonRef={feature.toggleRef}
@@ -403,15 +799,13 @@ export function RoutingToggle({
controls="client-local-rules"
disabled={disabled}
ariaLabel={disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Harbor Gateway'
: 'Локальные правила недоступны: сначала добавьте подписку'
: open ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
? 'Правила маршрутизации недоступны: сначала добавьте подписку'
: gatewayDirect
? open ? 'Закрыть правила маршрутизации' : 'Настроить правила, обходящиеся Harbor Gateway'
: open ? 'Закрыть правила маршрутизации' : 'Настроить правила маршрутизации'}
label={disabled
? hasSubscription
? 'Локальные правила недоступны: сейчас работают правила Gateway'
: 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}
? 'Сначала добавьте подписку'
: feature.pendingRestart ? 'Правила ждут перезапуска' : 'Правила маршрутизации'}
onClick={() => open ? feature.requestClose() : onOpen()}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
@@ -434,10 +828,10 @@ export function RoutingPendingStatus({
blocked: boolean;
onRestart: () => unknown;
}) {
return <div className={`client-route-rules-pending${feature.pendingCount ? ' is-visible' : ''}`} role="status" aria-live="polite">
{feature.pendingCount > 0 && (
return <div className={`client-route-rules-pending${feature.pendingMessage ? ' is-visible' : ''}`} role="status" aria-live="polite">
{feature.pendingMessage && (
<>
<span>{feature.pendingCount} {feature.pendingCount === 1 ? 'правило не применено' : 'правила не применены'}</span>
<span>{feature.pendingMessage}</span>
<button type="button" disabled={blocked} onClick={onRestart}>Перезапустить VPN</button>
</>
)}
@@ -446,7 +840,8 @@ export function RoutingPendingStatus({
export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature; statusSlot?: ReactNode }) {
const draftRules = feature.rules.filter((rule) => !rule.removing);
const canAdd = canAppendRouteRule(draftRules) && !feature.blocked;
const editorDisabled = feature.blocked || !feature.editable;
const canAdd = canAppendRouteRule(draftRules) && !editorDisabled;
const incomplete = draftRules.some((rule) => !String(rule.value || '').trim());
return (
@@ -458,7 +853,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
sheetClassName="client-local-rules-sheet"
open={feature.isOpen}
labelledBy="local-rules-title"
closeLabel="Закрыть локальные правила"
closeLabel="Закрыть правила маршрутизации"
onClose={feature.requestClose}
>
<header className="client-local-rules-header">
@@ -467,43 +862,92 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
className="client-local-rules-save"
type="submit"
form="client-local-rules-form"
disabled={feature.blocked || !feature.dirty}
disabled={editorDisabled || !feature.dirty}
>
Сохранить
</button>
<h2 id="local-rules-title">Локальные правила</h2>
<p>Эти домены идут напрямую. Остальной трафик через выбранный VPN.</p>
{feature.pendingRestart && (
<h2 id="local-rules-title">Правила маршрутизации</h2>
<p>Проверяются сверху вниз. Первое совпадение выбирает маршрут.</p>
{!feature.editable && (
<p className="client-local-rules-runtime" role="status">
Обновите Harbor, чтобы редактировать правила.
</p>
)}
{feature.bypassed && (
<p className="client-local-rules-runtime" role="status">
Локальный список сейчас обходится Harbor Gateway.
</p>
)}
{!feature.bypassed && feature.pendingRestart && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
</p>
)}
{feature.savedUntilStart && (
<p className="client-local-rules-runtime" role="status">
Правила сохранены и начнут работать после запуска Harbor.
</p>
)}
</header>
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={feature.save}>
<span id="client-rule-reorder-instructions" className="client-rule-reorder-instructions">
Нажмите пробел или Enter, затем используйте стрелки вверх и вниз. Повторное нажатие размещает правило, Escape отменяет.
</span>
<span className="client-rule-reorder-live" role="status" aria-live="polite" aria-atomic="true">
{feature.reorderAnnouncement}
</span>
<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,
index,
feature.savedRules,
feature.activeRules,
feature.connected,
feature.bypassed,
);
const lifted = feature.liftedKey === rule._key;
const handleDisabled = editorDisabled || rule.removing || draftRules.length < 2;
return (
<div
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
className={`client-local-rule client-deletable-row is-${status}${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}${lifted ? ' is-dragging' : ''}`}
key={rule._key}
data-rule-key={rule._key}
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
inert={rule.removing ? true : undefined}
>
<button
className="client-rule-handle"
type="button"
aria-label={`Переместить правило, позиция ${index + 1} из ${draftRules.length}`}
aria-describedby="client-rule-reorder-instructions"
aria-pressed={lifted}
disabled={handleDisabled}
onPointerDown={(event) => feature.startPointerReorder(event, rule._key)}
onPointerMove={feature.movePointerReorder}
onPointerUp={feature.finishPointerReorder}
onPointerCancel={feature.cancelPointerReorder}
onLostPointerCapture={feature.losePointerReorder}
onKeyDown={(event) => feature.handleReorderKey(event, rule._key)}
onClick={(event) => feature.handleReorderClick(event, rule._key)}
onBlur={(event) => feature.handleReorderBlur(event, rule._key)}
>
<svg viewBox="0 0 12 28" aria-hidden="true">
<circle cx="6" cy="6" r="1.6" />
<circle cx="6" cy="14" r="1.6" />
<circle cx="6" cy="22" r="1.6" />
</svg>
</button>
<button
className="client-local-rule-enabled"
type="button"
role="switch"
aria-checked={rule.enabled}
aria-label={`${rule.enabled ? 'Выключить' : 'Включить'} правило ${index + 1}`}
disabled={editorDisabled}
onClick={() => feature.change(index, 'enabled', !rule.enabled)}
>
<svg viewBox="0 0 20 20" aria-hidden="true">
@@ -515,7 +959,7 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
value={rule.type}
ruleKey={rule._key}
index={index}
disabled={rule.removing}
disabled={editorDisabled || rule.removing}
onChange={(type) => feature.change(index, 'type', type)}
/>
<input
@@ -524,16 +968,17 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
autoComplete="off"
spellCheck={false}
required
disabled={editorDisabled}
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}`}
disabled={editorDisabled}
onClick={() => feature.remove(rule._key)}
>
×
@@ -543,10 +988,19 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
aria-hidden="true"
onAnimationEnd={() => feature.finishRemove(rule._key)}
/>
<div className="client-local-rule-meta">
<RuleOutboundPicker
value={rule.outbound}
index={index}
disabled={editorDisabled}
onChange={(outbound) => feature.change(index, 'outbound', outbound)}
/>
<span className="client-local-rule-status" role="status">{statusLabel}</span>
</div>
</div>
);
})}
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Весь трафик идёт через VPN.</p>}
{!feature.rules.length && <p className="client-local-rules-empty">Правил пока нет. Harbor использует выбранный режим соединения.</p>}
</div>
<div className="client-row-add-slot">
<button className="client-row-add" type="button" disabled={!canAdd} onClick={feature.add}>
@@ -556,6 +1010,9 @@ export function RoutingPanel({ feature, statusSlot }: { feature: RoutingFeature;
</div>
</section>
<p className="client-local-rules-note">
Правила применяются только к трафику, который вошёл в VPN-маршрутизацию Harbor. Устройство Gateway в режиме «Напрямую» и Connect при активном Harbor Gateway обходят локальный список; «Напрямую» внутри правила результат уже найденного совпадения.
</p>
<p className="client-local-rules-note">
Можно вставить полный URL: Harbor сохранит только домен. Путь и параметры HTTPS недоступны для маршрутизации.
</p>
@@ -0,0 +1,86 @@
export const RULE_DRAG_THRESHOLD_PX = 4;
export const RULE_EDGE_ZONE_PX = 32;
export const RULE_REORDER_DURATION_MS = 220;
export const RULE_DROP_DURATION_MS = 260;
export type RuleReorderInput = 'pointer' | 'keyboard';
export type RuleReorderEndReason = 'drop' | 'cancel' | 'focus-leave' | 'lost-capture' | 'unmount';
export interface RuleReorderLifecycle {
key: string;
input: RuleReorderInput;
lifted: boolean;
}
export function beginRuleReorder(
current: RuleReorderLifecycle | null,
key: string,
input: RuleReorderInput,
): RuleReorderLifecycle | null {
if (current) return null;
return { key, input, lifted: input === 'keyboard' };
}
export function endRuleReorder(
session: RuleReorderLifecycle,
reason: RuleReorderEndReason,
reducedMotion = false,
) {
return {
restoreOrder: reason !== 'drop' && session.lifted,
stopAutoScroll: true as const,
restoreFocus: !['focus-leave', 'unmount'].includes(reason),
releasePointerCapture: session.input === 'pointer' && !['lost-capture', 'unmount'].includes(reason),
animateDrop: reason === 'drop' && session.lifted && !reducedMotion,
};
}
export function shouldLiftRule(startY: number, currentY: number) {
return Math.abs(currentY - startY) >= RULE_DRAG_THRESHOLD_PX;
}
export function moveRule<T>(rules: readonly T[], from: number, to: number): T[] {
if (from === to || from < 0 || to < 0 || from >= rules.length || to >= rules.length) {
return [...rules];
}
const next = [...rules];
const [rule] = next.splice(from, 1);
next.splice(to, 0, rule);
return next;
}
export function restoreRuleOrder<T>(
origin: readonly T[],
current: readonly T[],
keyOf: (item: T) => string,
): T[] {
const currentByKey = new Map(current.map((item) => [keyOf(item), item]));
const originKeys = new Set(origin.map(keyOf));
return [
...origin.map((item) => currentByKey.get(keyOf(item))).filter((item): item is T => item !== undefined),
...current.filter((item) => !originKeys.has(keyOf(item))),
];
}
export function crossedRuleIndex(current: number, draggedCenterY: number, centers: readonly number[]) {
let target = current;
while (target > 0 && draggedCenterY < centers[target - 1]) target -= 1;
while (target < centers.length - 1 && draggedCenterY > centers[target + 1]) target += 1;
return target;
}
export function keyboardRuleIndex(current: number, direction: -1 | 1, length: number) {
return Math.max(0, Math.min(length - 1, current + direction));
}
export function edgeScrollDelta(
pointerY: number,
top: number,
bottom: number,
zone = RULE_EDGE_ZONE_PX,
) {
const edgeDelta = (distance: number) => Math.min(12, Math.max(2, 12 - (distance / zone) * 10));
if (pointerY >= top && pointerY < top + zone) return -edgeDelta(pointerY - top);
if (pointerY <= bottom && pointerY > bottom - zone) return edgeDelta(bottom - pointerY);
return 0;
}
+114 -4
View File
@@ -76,16 +76,18 @@
}
.client-local-rule {
--client-rule-drag-y: 0px;
position: relative;
isolation: isolate;
min-width: 0;
display: grid;
grid-template-columns: 24px 116px minmax(0, 1fr) 112px 28px;
grid-template-columns: 44px 24px 116px minmax(0, 1fr) 28px;
align-items: center;
gap: 10px;
column-gap: 8px;
row-gap: 2px;
padding: 7px 0;
animation: client-row-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both;
transition: opacity 260ms ease, filter 360ms ease;
transition: background-color 180ms ease, box-shadow 180ms ease, opacity 260ms ease, filter 360ms ease;
}
.client-local-rule:has(.client-rule-type.is-open) {
@@ -98,6 +100,7 @@
letter-spacing: var(--type-label-tracking);
text-transform: var(--type-label-transform);
text-align: right;
white-space: nowrap;
transition: color 280ms ease, opacity 280ms ease, filter 360ms ease;
}
@@ -110,11 +113,19 @@
color: var(--client-warning, oklch(0.72 0.12 72));
}
.client-local-rule.is-disabled {
.client-local-rule.is-disabled > :not(.client-rule-handle):not(.client-row-delete):not(.client-delete-strike) {
opacity: 0.42;
filter: saturate(0);
}
.client-local-rule.is-dragging {
z-index: 6;
animation: none;
background: color-mix(in oklch, var(--client-accent) 5%, transparent);
box-shadow: 0 10px 28px color-mix(in oklch, var(--client-accent) 12%, transparent);
transform: translateY(var(--client-rule-drag-y));
}
.client-local-rule.is-removing {
pointer-events: none;
animation: client-row-leave 820ms cubic-bezier(0.7, 0, 0.84, 0) both;
@@ -132,6 +143,44 @@
cursor: pointer;
}
.client-rule-handle {
width: 44px;
height: 44px;
display: grid;
place-items: center;
padding: 0;
border: 0;
background: transparent;
color: var(--client-muted);
cursor: grab;
touch-action: none;
transition: color 180ms ease, filter 180ms ease;
}
.client-rule-handle svg {
width: 12px;
height: 28px;
overflow: visible;
fill: currentColor;
}
.client-rule-handle:hover:not(:disabled),
.client-rule-handle:focus-visible,
.client-rule-handle[aria-pressed='true'] {
outline: 0;
color: var(--client-accent);
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 48%, transparent));
}
.client-rule-handle[aria-pressed='true'] {
cursor: grabbing;
}
.client-rule-handle:disabled {
opacity: 0.28;
cursor: default;
}
.client-local-rule-enabled svg {
width: 18px;
height: 18px;
@@ -312,6 +361,67 @@
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 22%, transparent);
}
.client-local-rule-meta {
min-width: 0;
grid-column: 2 / -1;
grid-row: 2;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.client-rule-outbound {
width: 128px;
height: 30px;
flex: 0 0 128px;
display: grid;
grid-template-columns: 48px minmax(0, 1fr);
align-items: center;
}
.client-rule-outbound button {
height: 30px;
padding: 0 5px;
border: 0;
background: transparent;
color: var(--client-muted);
font: var(--type-label);
letter-spacing: var(--type-label-tracking);
text-transform: var(--type-label-transform);
cursor: pointer;
transition: color 280ms ease, filter 280ms ease, text-shadow 280ms ease;
}
.client-rule-outbound button[aria-pressed='true'] {
color: var(--client-accent);
filter: drop-shadow(0 0 6px color-mix(in oklch, var(--client-accent) 34%, transparent));
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 28%, transparent);
}
.client-rule-outbound button:disabled {
cursor: default;
}
.client-rule-outbound button:focus-visible {
outline: 0;
color: var(--client-accent);
text-shadow: 0 0 9px color-mix(in oklch, var(--client-accent) 42%, transparent);
}
.client-rule-reorder-instructions,
.client-rule-reorder-live {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
.client-local-rules-empty {
padding: 14px 4px 4px;
color: var(--client-muted);
+46 -13
View File
@@ -629,15 +629,6 @@
}
@media (max-width: 560px) {
.client-local-rule {
grid-template-columns: 24px 104px minmax(0, 1fr) 28px;
}
.client-local-rule-status {
grid-column: 3;
text-align: left;
}
.client-mode .app-main {
padding: 20px 14px;
}
@@ -713,15 +704,27 @@
}
.client-local-rule {
grid-template-columns: 44px minmax(0, 1fr) 44px;
grid-template-columns: 44px 44px minmax(0, 1fr) 44px;
}
.client-rule-handle,
.client-local-rule-enabled,
.client-row-delete {
width: 44px;
height: 44px;
}
.client-rule-type-trigger,
.client-local-rule input,
.client-rule-outbound,
.client-rule-outbound button {
height: 44px;
}
.client-rule-type-list button {
min-height: 44px;
}
.client-instructions-sheet {
padding: 40px 58px 60px 18px;
}
@@ -734,26 +737,36 @@
gap: 8px;
}
.client-local-rule-enabled {
.client-rule-handle {
grid-column: 1;
grid-row: 1;
}
.client-rule-type {
.client-local-rule-enabled {
grid-column: 2;
grid-row: 1;
}
.client-row-delete {
.client-rule-type {
grid-column: 3;
grid-row: 1;
}
.client-row-delete {
grid-column: 4;
grid-row: 1;
}
.client-local-rule input {
grid-column: 2 / -1;
grid-row: 2;
}
.client-local-rule-meta {
grid-column: 2 / -1;
grid-row: 3;
}
.client-instruction-block:nth-child(n) {
margin-inline: 0;
}
@@ -769,3 +782,23 @@
}
}
@media (max-width: 360px) {
.client-local-rule-meta {
flex-wrap: wrap;
justify-content: flex-start;
}
.client-rule-outbound {
width: 100%;
flex-basis: 100%;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.client-local-rule-status {
width: 100%;
min-height: 1em;
text-align: left;
white-space: normal;
}
}
+2
View File
@@ -314,8 +314,10 @@
transform: rotate(8deg) scale(1.1);
}
.client-rule-handle:focus-visible,
.client-local-rule-enabled:focus-visible,
.client-rule-type-trigger:focus-visible,
.client-rule-outbound button:focus-visible,
.client-row-delete:focus-visible,
.client-row-add:focus-visible,
.client-local-rules-save:focus-visible,
+2
View File
@@ -122,6 +122,7 @@
}
.client-local-rule,
.client-rule-handle,
.client-local-rule-enabled svg,
.client-local-rule-enabled circle,
.client-local-rule-enabled path,
@@ -130,6 +131,7 @@
.client-rule-type-list,
.client-rule-type-list button,
.client-local-rule input,
.client-rule-outbound button,
.client-row-delete,
.client-row-add,
.client-row-add-slot > span,