Update Harbor client and gateway functionality
This commit is contained in:
@@ -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, [
|
||||
|
||||
@@ -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
@@ -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 } : {}),
|
||||
}));
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user