Improve local rules persistence and dirty-state handling
This commit is contained in:
@@ -72,8 +72,10 @@ Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arri
|
|||||||
## Dynamic editors
|
## Dynamic editors
|
||||||
|
|
||||||
- Reveal added rows with opacity, blur, and a small transform while keeping surrounding geometry predictable.
|
- Reveal added rows with opacity, blur, and a small transform while keeping surrounding geometry predictable.
|
||||||
- Give removal its own exit state and keep the row mounted until `animationend`; under reduced motion, remove it immediately.
|
- Keep interactive add latency constant regardless of collection length. Never multiply an added row's delay by its index; use bounded staggering only for a one-time group reveal.
|
||||||
|
- Give removal its own exit state and keep the row mounted until `animationend`; then animate surviving rows into their new positions instead of letting layout snap. Under reduced motion, remove it immediately.
|
||||||
- Do not let repeated add actions accumulate unfinished rows. Disable add while any current row lacks its required value and explain the disabled state in a reserved hint slot.
|
- Do not let repeated add actions accumulate unfinished rows. Disable add while any current row lacks its required value and explain the disabled state in a reserved hint slot.
|
||||||
|
- Track the editor's dirty draft against its open/save baseline. Guard Escape, outside click, navigation controls, Cancel, and page unload; use an inline discard confirmation for in-app exits.
|
||||||
- Replace browser-native dropdowns when their platform chrome conflicts with the client surface. Use an accessible custom listbox with trigger, selected state, outside-click and Escape closing, arrow-key navigation, and restored trigger focus.
|
- Replace browser-native dropdowns when their platform chrome conflicts with the client surface. Use an accessible custom listbox with trigger, selected state, outside-click and Escape closing, arrow-key navigation, and restored trigger focus.
|
||||||
- Let picker options appear as a short staggered cloud using opacity, blur, and transform. Avoid borders, shadows, raised cards, and layout-property animation.
|
- Let picker options appear as a short staggered cloud using opacity, blur, and transform. Avoid borders, shadows, raised cards, and layout-property animation.
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ VPN_PROXY_CLIENT_UI_PORT=3457 \
|
|||||||
|
|
||||||
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
|
Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются.
|
||||||
|
|
||||||
|
При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе.
|
||||||
|
|
||||||
## Системный прокси macOS
|
## Системный прокси macOS
|
||||||
|
|
||||||
Сначала посмотрите точное имя сетевого подключения:
|
Сначала посмотрите точное имя сетевого подключения:
|
||||||
|
|||||||
@@ -380,11 +380,14 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) {
|
|||||||
else restoreSingboxConfig(previousConfig);
|
else restoreSingboxConfig(previousConfig);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
if (persist) {
|
if (persist || stateStore.read().routeRulesPendingRestart) {
|
||||||
updateStoredState((state) => ({
|
updateStoredState((state) => ({
|
||||||
...state,
|
...state,
|
||||||
appliedTag: selectedTag,
|
...(persist ? {
|
||||||
appliedAt: new Date().toISOString(),
|
appliedTag: selectedTag,
|
||||||
|
appliedAt: new Date().toISOString(),
|
||||||
|
} : {}),
|
||||||
|
routeRulesPendingRestart: false,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -393,18 +396,26 @@ async function applyRouteRules(routeRules) {
|
|||||||
const state = normalizeStoredState(stateStore.read());
|
const state = normalizeStoredState(stateStore.read());
|
||||||
const cached = readSubscriptionCache();
|
const cached = readSubscriptionCache();
|
||||||
if (!state.selectedTag || !cached?.config) {
|
if (!state.selectedTag || !cached?.config) {
|
||||||
updateStoredState((current) => ({ ...current, routeRules }));
|
updateStoredState((current) => ({
|
||||||
|
...current,
|
||||||
|
routeRules,
|
||||||
|
routeRulesPendingRestart: true,
|
||||||
|
}));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const previousConfig = fs.existsSync(settings.configPath)
|
const previousConfig = fs.existsSync(settings.configPath)
|
||||||
? fs.readFileSync(settings.configPath, 'utf8')
|
? fs.readFileSync(settings.configPath, 'utf8')
|
||||||
: null;
|
: null;
|
||||||
const wasRunning = singboxRuntime.running;
|
const wasRunning = Boolean((await singboxRuntime.refresh()).running);
|
||||||
try {
|
try {
|
||||||
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag, routeRules));
|
writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag, routeRules));
|
||||||
if (wasRunning) await startSingbox();
|
if (wasRunning) await startSingbox();
|
||||||
updateStoredState((current) => ({ ...current, routeRules }));
|
updateStoredState((current) => ({
|
||||||
|
...current,
|
||||||
|
routeRules,
|
||||||
|
routeRulesPendingRestart: !wasRunning,
|
||||||
|
}));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (previousConfig === null) removeSingboxConfig();
|
if (previousConfig === null) removeSingboxConfig();
|
||||||
else restoreSingboxConfig(previousConfig);
|
else restoreSingboxConfig(previousConfig);
|
||||||
@@ -656,6 +667,7 @@ async function handleApi(req, res) {
|
|||||||
...state,
|
...state,
|
||||||
appliedTag: state.selectedTag,
|
appliedTag: state.selectedTag,
|
||||||
connectionDesired: 'running',
|
connectionDesired: 'running',
|
||||||
|
routeRulesPendingRestart: false,
|
||||||
}));
|
}));
|
||||||
}));
|
}));
|
||||||
return sendState(res, { singboxRunning: true });
|
return sendState(res, { singboxRunning: true });
|
||||||
@@ -713,7 +725,13 @@ await refreshGatewayAutoMode({ reconfigure: false })
|
|||||||
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) {
|
||||||
writeCurrentConfig();
|
writeCurrentConfig();
|
||||||
}
|
}
|
||||||
await startSingbox().catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
await startSingbox()
|
||||||
|
.then(() => {
|
||||||
|
if (fs.existsSync(settings.configPath) && stateStore.read().routeRulesPendingRestart) {
|
||||||
|
updateStoredState((state) => ({ ...state, routeRulesPendingRestart: false }));
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`));
|
||||||
|
|
||||||
server.listen(settings.port, '0.0.0.0', () => {
|
server.listen(settings.port, '0.0.0.0', () => {
|
||||||
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`);
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function normalizeStoredState(value) {
|
|||||||
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
|
appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag,
|
||||||
servers: Array.isArray(state.servers) ? state.servers : [],
|
servers: Array.isArray(state.servers) ? state.servers : [],
|
||||||
routeRules: normalizeRouteRules(state.routeRules),
|
routeRules: normalizeRouteRules(state.routeRules),
|
||||||
|
routeRulesPendingRestart: state.routeRulesPendingRestart === true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +85,7 @@ export function createStateSnapshot({
|
|||||||
lastVerifiedAt: null,
|
lastVerifiedAt: null,
|
||||||
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
|
reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual',
|
||||||
localRules: stored.routeRules,
|
localRules: stored.routeRules,
|
||||||
|
localRulesPendingRestart: stored.routeRulesPendingRestart,
|
||||||
},
|
},
|
||||||
operation: {
|
operation: {
|
||||||
kind: nullableText(operation.kind),
|
kind: nullableText(operation.kind),
|
||||||
@@ -177,6 +179,7 @@ export function assertStateSnapshot(snapshot) {
|
|||||||
typeof snapshot.route.reason !== 'string' ||
|
typeof snapshot.route.reason !== 'string' ||
|
||||||
!Array.isArray(snapshot.route.localRules) ||
|
!Array.isArray(snapshot.route.localRules) ||
|
||||||
!snapshot.route.localRules.every(validRouteRule) ||
|
!snapshot.route.localRules.every(validRouteRule) ||
|
||||||
|
typeof snapshot.route.localRulesPendingRestart !== 'boolean' ||
|
||||||
!snapshot.operation ||
|
!snapshot.operation ||
|
||||||
!nullableString(snapshot.operation.kind) ||
|
!nullableString(snapshot.operation.kind) ||
|
||||||
!OPERATION_STATES.has(snapshot.operation.status) ||
|
!OPERATION_STATES.has(snapshot.operation.status) ||
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export const HARBOR_VERSIONS = Object.freeze({
|
export const HARBOR_VERSIONS = Object.freeze({
|
||||||
macClient: '0.4.0',
|
macClient: '0.5.0',
|
||||||
gatewayClient: '0.4.0',
|
gatewayClient: '0.5.0',
|
||||||
gatewayBackend: '0.4.0',
|
gatewayBackend: '0.5.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
export function parseVersion(value) {
|
export function parseVersion(value) {
|
||||||
|
|||||||
@@ -222,6 +222,10 @@ const createLocalRuleDraft = (rule) => ({
|
|||||||
enabled: rule?.enabled !== false,
|
enabled: rule?.enabled !== false,
|
||||||
_key: `route-rule-${localRuleDraftId += 1}`,
|
_key: `route-rule-${localRuleDraftId += 1}`,
|
||||||
});
|
});
|
||||||
|
const localRuleValues = (rules) => rules
|
||||||
|
.filter((rule) => !rule.removing)
|
||||||
|
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
||||||
|
const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules));
|
||||||
|
|
||||||
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
|
function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
@@ -320,6 +324,9 @@ function LocalRulesPanel({
|
|||||||
open,
|
open,
|
||||||
rules,
|
rules,
|
||||||
blocked,
|
blocked,
|
||||||
|
dirty,
|
||||||
|
restartPending,
|
||||||
|
confirmingClose,
|
||||||
error,
|
error,
|
||||||
operations,
|
operations,
|
||||||
panelRef,
|
panelRef,
|
||||||
@@ -327,6 +334,8 @@ function LocalRulesPanel({
|
|||||||
onChange,
|
onChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
onRemoveComplete,
|
onRemoveComplete,
|
||||||
|
onKeepEditing,
|
||||||
|
onDiscard,
|
||||||
onClose,
|
onClose,
|
||||||
onSave,
|
onSave,
|
||||||
}) {
|
}) {
|
||||||
@@ -346,11 +355,34 @@ function LocalRulesPanel({
|
|||||||
<div className="client-local-rules-sheet">
|
<div className="client-local-rules-sheet">
|
||||||
<header className="client-local-rules-header">
|
<header className="client-local-rules-header">
|
||||||
<span>Маршрутизация</span>
|
<span>Маршрутизация</span>
|
||||||
|
<button
|
||||||
|
className="client-local-rules-save"
|
||||||
|
type="submit"
|
||||||
|
form="client-local-rules-form"
|
||||||
|
disabled={blocked || !dirty}
|
||||||
|
>
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
<h2 id="local-rules-title">Локальные правила</h2>
|
<h2 id="local-rules-title">Локальные правила</h2>
|
||||||
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
<p>Эти домены идут напрямую. Остальной трафик — через выбранный VPN.</p>
|
||||||
|
{restartPending && (
|
||||||
|
<p className="client-local-rules-runtime" role="status">
|
||||||
|
Правила сохранены, но начнут работать после запуска или перезапуска sing-box.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<form className="client-local-rules-form" onSubmit={onSave}>
|
<form id="client-local-rules-form" className="client-local-rules-form" onSubmit={onSave}>
|
||||||
|
{confirmingClose && (
|
||||||
|
<section className="client-local-rules-discard" role="alert">
|
||||||
|
<strong>Есть несохранённые настройки</strong>
|
||||||
|
<span>Закрыть редактор и потерять изменения?</span>
|
||||||
|
<div>
|
||||||
|
<button type="button" onClick={onKeepEditing}>Остаться</button>
|
||||||
|
<button type="button" className="is-danger" onClick={onDiscard}>Закрыть без сохранения</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
<section className="client-local-rules-group" aria-labelledby="local-rules-list-title">
|
||||||
<span id="local-rules-list-title">Правила</span>
|
<span id="local-rules-list-title">Правила</span>
|
||||||
<div className="client-local-rules-list">
|
<div className="client-local-rules-list">
|
||||||
@@ -358,7 +390,7 @@ function LocalRulesPanel({
|
|||||||
<div
|
<div
|
||||||
className={`client-local-rule${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
|
className={`client-local-rule${rule.enabled ? '' : ' is-disabled'}${rule.removing ? ' is-removing' : ''}`}
|
||||||
key={rule._key}
|
key={rule._key}
|
||||||
style={{ '--rule-index': index }}
|
style={{ viewTransitionName: rule.removing ? 'none' : rule._key }}
|
||||||
inert={rule.removing ? true : undefined}
|
inert={rule.removing ? true : undefined}
|
||||||
onAnimationEnd={(event) => {
|
onAnimationEnd={(event) => {
|
||||||
if (event.animationName === 'client-local-rule-leave') {
|
if (event.animationName === 'client-local-rule-leave') {
|
||||||
@@ -423,7 +455,6 @@ function LocalRulesPanel({
|
|||||||
<InlineProgress operations={operations} context="routing" />
|
<InlineProgress operations={operations} context="routing" />
|
||||||
<div className="client-local-rules-actions">
|
<div className="client-local-rules-actions">
|
||||||
<button type="button" onClick={onClose}>Отмена</button>
|
<button type="button" onClick={onClose}>Отмена</button>
|
||||||
<button className="is-primary" type="submit" disabled={blocked}>Сохранить</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -575,6 +606,7 @@ export function ClientOverviewPage({
|
|||||||
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
const [localRulesOpen, setLocalRulesOpen] = useState(false);
|
||||||
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
const [localRulesDraft, setLocalRulesDraft] = useState([]);
|
||||||
const [localRulesRevision, setLocalRulesRevision] = useState(state?.revision || 0);
|
const [localRulesRevision, setLocalRulesRevision] = useState(state?.revision || 0);
|
||||||
|
const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false);
|
||||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||||
const [openInstructionId, setOpenInstructionId] = useState('');
|
const [openInstructionId, setOpenInstructionId] = useState('');
|
||||||
const subscriptionInputRef = useRef(null);
|
const subscriptionInputRef = useRef(null);
|
||||||
@@ -584,6 +616,7 @@ export function ClientOverviewPage({
|
|||||||
const instructionsToggleRef = useRef(null);
|
const instructionsToggleRef = useRef(null);
|
||||||
const localRulesPanelRef = useRef(null);
|
const localRulesPanelRef = useRef(null);
|
||||||
const localRulesToggleRef = useRef(null);
|
const localRulesToggleRef = useRef(null);
|
||||||
|
const localRulesBaselineRef = useRef('[]');
|
||||||
const previousHasSubscriptionRef = useRef(hasSubscription);
|
const previousHasSubscriptionRef = useRef(hasSubscription);
|
||||||
const validationRequests = useRef(null);
|
const validationRequests = useRef(null);
|
||||||
if (!validationRequests.current) validationRequests.current = createLatestRequest();
|
if (!validationRequests.current) validationRequests.current = createLatestRequest();
|
||||||
@@ -624,6 +657,7 @@ export function ClientOverviewPage({
|
|||||||
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
|
const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete');
|
||||||
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto');
|
||||||
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
|
const routeRulesBlocked = operationBlocked(operations, 'routeRules');
|
||||||
|
const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setNow(Date.now());
|
setNow(Date.now());
|
||||||
@@ -798,10 +832,13 @@ export function ClientOverviewPage({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!localRulesOpen) return undefined;
|
if (!localRulesOpen) return undefined;
|
||||||
const closeLocalRules = (event) => {
|
const closeLocalRules = (event) => {
|
||||||
if (event.type === 'keydown' && event.key !== 'Escape') return;
|
if (event.type === 'keydown') {
|
||||||
if (localRulesPanelRef.current?.contains(event.target)) return;
|
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
||||||
if (localRulesToggleRef.current?.contains(event.target)) return;
|
} else {
|
||||||
setLocalRulesOpen(false);
|
if (localRulesPanelRef.current?.contains(event.target)) return;
|
||||||
|
if (localRulesToggleRef.current?.contains(event.target)) return;
|
||||||
|
}
|
||||||
|
requestCloseLocalRules();
|
||||||
};
|
};
|
||||||
document.addEventListener('pointerdown', closeLocalRules);
|
document.addEventListener('pointerdown', closeLocalRules);
|
||||||
document.addEventListener('keydown', closeLocalRules);
|
document.addEventListener('keydown', closeLocalRules);
|
||||||
@@ -809,7 +846,17 @@ export function ClientOverviewPage({
|
|||||||
document.removeEventListener('pointerdown', closeLocalRules);
|
document.removeEventListener('pointerdown', closeLocalRules);
|
||||||
document.removeEventListener('keydown', closeLocalRules);
|
document.removeEventListener('keydown', closeLocalRules);
|
||||||
};
|
};
|
||||||
}, [localRulesOpen]);
|
}, [localRulesOpen, localRulesDirty]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!localRulesOpen || !localRulesDirty) return undefined;
|
||||||
|
const warnBeforeUnload = (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.returnValue = '';
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', warnBeforeUnload);
|
||||||
|
return () => window.removeEventListener('beforeunload', warnBeforeUnload);
|
||||||
|
}, [localRulesOpen, localRulesDirty]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!instructionsOpen) return undefined;
|
if (!instructionsOpen) return undefined;
|
||||||
@@ -905,9 +952,12 @@ export function ClientOverviewPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openLocalRules() {
|
function openLocalRules() {
|
||||||
|
const rules = state?.route?.localRules || [];
|
||||||
setInstructionsOpen(false);
|
setInstructionsOpen(false);
|
||||||
setLocalRulesDraft((state?.route?.localRules || []).map(createLocalRuleDraft));
|
localRulesBaselineRef.current = localRulesSignature(rules);
|
||||||
|
setLocalRulesDraft(rules.map(createLocalRuleDraft));
|
||||||
setLocalRulesRevision(state?.revision || 0);
|
setLocalRulesRevision(state?.revision || 0);
|
||||||
|
setConfirmingLocalRulesClose(false);
|
||||||
setLocalRulesOpen(true);
|
setLocalRulesOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -919,10 +969,26 @@ export function ClientOverviewPage({
|
|||||||
|
|
||||||
async function saveLocalRules(event) {
|
async function saveLocalRules(event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
const rules = localRulesDraft
|
const rules = localRuleValues(localRulesDraft);
|
||||||
.filter((rule) => !rule.removing)
|
const result = await onSaveRouteRules(rules, localRulesRevision);
|
||||||
.map(({ type, value, enabled }) => ({ type, value, enabled }));
|
if (!result) return;
|
||||||
if (!await onSaveRouteRules(rules, localRulesRevision)) return;
|
localRulesBaselineRef.current = JSON.stringify(rules);
|
||||||
|
setLocalRulesRevision(result.state.revision);
|
||||||
|
setConfirmingLocalRulesClose(false);
|
||||||
|
if (!result.state.route.localRulesPendingRestart) setLocalRulesOpen(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCloseLocalRules() {
|
||||||
|
if (localRulesDirty) {
|
||||||
|
setConfirmingLocalRulesClose(true);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setLocalRulesOpen(false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function discardLocalRules() {
|
||||||
|
setConfirmingLocalRulesClose(false);
|
||||||
setLocalRulesOpen(false);
|
setLocalRulesOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -936,6 +1002,17 @@ export function ClientOverviewPage({
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function finishRemoveLocalRule(ruleKey) {
|
||||||
|
const update = () => flushSync(() => {
|
||||||
|
setLocalRulesDraft((rules) => rules.filter((rule) => rule._key !== ruleKey));
|
||||||
|
});
|
||||||
|
if (!document.startViewTransition || matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
update();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
document.startViewTransition(update);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
<div className={`client-shell${hasSubscription ? '' : ' is-first-run'}${showIntro && !hasSubscription ? ' is-intro' : ''}`}>
|
||||||
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
<VersionDisplay isGateway={isGateway} versionInfo={versionInfo} />
|
||||||
@@ -954,7 +1031,7 @@ export function ClientOverviewPage({
|
|||||||
aria-controls="client-instructions"
|
aria-controls="client-instructions"
|
||||||
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setLocalRulesOpen(false);
|
if (localRulesOpen && !requestCloseLocalRules()) return;
|
||||||
setInstructionsOpen((open) => !open);
|
setInstructionsOpen((open) => !open);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -967,19 +1044,19 @@ export function ClientOverviewPage({
|
|||||||
</button>}
|
</button>}
|
||||||
{hasSubscription && subscriptionContentReady && <button
|
{hasSubscription && subscriptionContentReady && <button
|
||||||
ref={localRulesToggleRef}
|
ref={localRulesToggleRef}
|
||||||
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}`}
|
className={`client-local-rules-toggle${localRulesOpen ? ' is-open' : ''}${state?.route?.localRulesPendingRestart ? ' has-pending' : ''}`}
|
||||||
type="button"
|
type="button"
|
||||||
aria-expanded={localRulesOpen}
|
aria-expanded={localRulesOpen}
|
||||||
aria-controls="client-local-rules"
|
aria-controls="client-local-rules"
|
||||||
aria-label={localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
aria-label={localRulesOpen ? 'Закрыть локальные правила' : 'Настроить локальные правила'}
|
||||||
onClick={() => localRulesOpen ? setLocalRulesOpen(false) : openLocalRules()}
|
onClick={() => localRulesOpen ? requestCloseLocalRules() : openLocalRules()}
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true">
|
<svg viewBox="0 0 24 24" aria-hidden="true">
|
||||||
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
|
<path d="M4 7h9M17 7h3M4 17h3M11 17h9" />
|
||||||
<circle cx="15" cy="7" r="2" />
|
<circle cx="15" cy="7" r="2" />
|
||||||
<circle cx="9" cy="17" r="2" />
|
<circle cx="9" cy="17" r="2" />
|
||||||
</svg>
|
</svg>
|
||||||
<span>Локальные правила</span>
|
<span>{state?.route?.localRulesPendingRestart ? 'Правила ждут перезапуска' : 'Локальные правила'}</span>
|
||||||
</button>}
|
</button>}
|
||||||
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
<main className={`client-panel${showPower ? '' : ' is-setup'}`}>
|
||||||
{showPower && (
|
{showPower && (
|
||||||
@@ -1355,6 +1432,9 @@ export function ClientOverviewPage({
|
|||||||
open={localRulesOpen}
|
open={localRulesOpen}
|
||||||
rules={localRulesDraft}
|
rules={localRulesDraft}
|
||||||
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
|
blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)}
|
||||||
|
dirty={localRulesDirty}
|
||||||
|
restartPending={state?.route?.localRulesPendingRestart === true}
|
||||||
|
confirmingClose={confirmingLocalRulesClose}
|
||||||
error={error}
|
error={error}
|
||||||
operations={operations}
|
operations={operations}
|
||||||
panelRef={localRulesPanelRef}
|
panelRef={localRulesPanelRef}
|
||||||
@@ -1364,10 +1444,10 @@ export function ClientOverviewPage({
|
|||||||
])}
|
])}
|
||||||
onChange={changeLocalRule}
|
onChange={changeLocalRule}
|
||||||
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
|
onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)}
|
||||||
onRemoveComplete={(ruleKey) => setLocalRulesDraft((rules) => (
|
onRemoveComplete={finishRemoveLocalRule}
|
||||||
rules.filter((rule) => rule._key !== ruleKey)
|
onKeepEditing={() => setConfirmingLocalRulesClose(false)}
|
||||||
))}
|
onDiscard={discardLocalRules}
|
||||||
onClose={() => setLocalRulesOpen(false)}
|
onClose={requestCloseLocalRules}
|
||||||
onSave={saveLocalRules}
|
onSave={saveLocalRules}
|
||||||
/>}
|
/>}
|
||||||
|
|
||||||
|
|||||||
@@ -783,6 +783,14 @@ p {
|
|||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle.has-pending:not(.is-open) {
|
||||||
|
color: oklch(0.68 0.14 72);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-toggle.has-pending:not(.is-open) svg {
|
||||||
|
filter: drop-shadow(0 0 7px oklch(0.68 0.14 72 / 0.42));
|
||||||
|
}
|
||||||
|
|
||||||
.client-local-rules-toggle.is-open svg {
|
.client-local-rules-toggle.is-open svg {
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
@@ -821,6 +829,7 @@ p {
|
|||||||
|
|
||||||
.client-local-rules-header {
|
.client-local-rules-header {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
margin: 0 8px 34px;
|
margin: 0 8px 34px;
|
||||||
}
|
}
|
||||||
@@ -835,17 +844,46 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.client-local-rules-header h2 {
|
.client-local-rules-header h2 {
|
||||||
|
grid-column: 1 / -1;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
letter-spacing: -0.045em;
|
letter-spacing: -0.045em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-local-rules-header p {
|
.client-local-rules-header p {
|
||||||
|
grid-column: 1 / -1;
|
||||||
max-width: 46ch;
|
max-width: 46ch;
|
||||||
color: var(--client-muted);
|
color: var(--client-muted);
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.7;
|
line-height: 1.7;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-local-rules-save {
|
||||||
|
align-self: center;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-accent);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 200ms ease, filter 300ms ease, opacity 220ms ease, transform 300ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-save:hover:not(:disabled) {
|
||||||
|
filter: drop-shadow(0 0 7px color-mix(in oklch, var(--client-accent) 42%, transparent));
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-save:disabled {
|
||||||
|
opacity: 0.28;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-header .client-local-rules-runtime {
|
||||||
|
color: oklch(0.68 0.14 72);
|
||||||
|
animation: client-local-rules-notice 480ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
.client-local-rules-form,
|
.client-local-rules-form,
|
||||||
.client-local-rules-group,
|
.client-local-rules-group,
|
||||||
.client-local-rules-list {
|
.client-local-rules-list {
|
||||||
@@ -871,7 +909,7 @@ p {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 7px 0;
|
padding: 7px 0;
|
||||||
animation: client-local-rule-enter 560ms calc(var(--rule-index) * 55ms) cubic-bezier(0.16, 1, 0.3, 1) both;
|
animation: client-local-rule-enter 560ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
transition: opacity 260ms ease, filter 360ms ease;
|
transition: opacity 260ms ease, filter 360ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1093,6 +1131,8 @@ p {
|
|||||||
.client-rule-type-trigger:focus-visible,
|
.client-rule-type-trigger:focus-visible,
|
||||||
.client-local-rule > button:last-child:focus-visible,
|
.client-local-rule > button:last-child:focus-visible,
|
||||||
.client-local-rule-add:focus-visible,
|
.client-local-rule-add:focus-visible,
|
||||||
|
.client-local-rules-save:focus-visible,
|
||||||
|
.client-local-rules-discard button:focus-visible,
|
||||||
.client-local-rules-actions button:focus-visible {
|
.client-local-rules-actions button:focus-visible {
|
||||||
outline: 0;
|
outline: 0;
|
||||||
color: var(--client-accent);
|
color: var(--client-accent);
|
||||||
@@ -1163,6 +1203,39 @@ p {
|
|||||||
transform: none;
|
transform: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.client-local-rules-discard {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--client-muted);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.55;
|
||||||
|
animation: client-local-rules-notice 420ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-discard strong {
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-discard div {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-discard button {
|
||||||
|
padding: 4px 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--client-text);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.client-local-rules-discard .is-danger {
|
||||||
|
color: oklch(0.68 0.15 28);
|
||||||
|
}
|
||||||
|
|
||||||
.client-local-rules-actions {
|
.client-local-rules-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -1186,10 +1259,6 @@ p {
|
|||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.client-local-rules-actions .is-primary {
|
|
||||||
color: var(--client-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.client-local-rules-actions button:disabled {
|
.client-local-rules-actions button:disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
@@ -1205,6 +1274,11 @@ p {
|
|||||||
to { opacity: 0; filter: blur(8px); transform: translateX(18px) scale(0.97); }
|
to { opacity: 0; filter: blur(8px); transform: translateX(18px) scale(0.97); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes client-local-rules-notice {
|
||||||
|
from { opacity: 0; filter: blur(6px); transform: translateY(-6px); }
|
||||||
|
to { opacity: 1; filter: blur(0); transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
.client-instructions {
|
.client-instructions {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0 auto 0 0;
|
inset: 0 auto 0 0;
|
||||||
@@ -1310,6 +1384,11 @@ p {
|
|||||||
mix-blend-mode: normal;
|
mix-blend-mode: normal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::view-transition-group(*) {
|
||||||
|
animation-duration: 420ms;
|
||||||
|
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
}
|
||||||
|
|
||||||
::view-transition-group(instruction-proxybridge),
|
::view-transition-group(instruction-proxybridge),
|
||||||
::view-transition-group(instruction-switchyomega),
|
::view-transition-group(instruction-switchyomega),
|
||||||
::view-transition-group(instruction-vscode),
|
::view-transition-group(instruction-vscode),
|
||||||
@@ -2801,6 +2880,9 @@ p {
|
|||||||
.client-local-rule > button:last-child,
|
.client-local-rule > button:last-child,
|
||||||
.client-local-rule-add,
|
.client-local-rule-add,
|
||||||
.client-local-rule-add-slot > span,
|
.client-local-rule-add-slot > span,
|
||||||
|
.client-local-rules-save,
|
||||||
|
.client-local-rules-runtime,
|
||||||
|
.client-local-rules-discard,
|
||||||
.client-local-rules-actions button {
|
.client-local-rules-actions button {
|
||||||
transition: none;
|
transition: none;
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.deepEqual(initial.route.localRules, [
|
assert.deepEqual(initial.route.localRules, [
|
||||||
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
{ type: 'domain_suffix', value: 'ru', enabled: true },
|
||||||
]);
|
]);
|
||||||
|
assert.equal(initial.route.localRulesPendingRestart, false);
|
||||||
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false);
|
||||||
const stateKeys = Object.keys(initial).sort();
|
const stateKeys = Object.keys(initial).sort();
|
||||||
let revision = initial.revision;
|
let revision = initial.revision;
|
||||||
@@ -238,7 +239,7 @@ setInterval(() => {}, 60_000);
|
|||||||
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running');
|
||||||
|
|
||||||
const rulesRevision = revision;
|
const rulesRevision = revision;
|
||||||
const routed = await mutation('/api/route-rules', 'PUT', {
|
let routed = await mutation('/api/route-rules', 'PUT', {
|
||||||
expectedRevision: rulesRevision,
|
expectedRevision: rulesRevision,
|
||||||
rules: [
|
rules: [
|
||||||
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
{ type: 'domain_suffix', value: 'ru', enabled: false },
|
||||||
@@ -251,12 +252,26 @@ setInterval(() => {}, 60_000);
|
|||||||
{ type: 'domain', value: 'example.com', enabled: true },
|
{ type: 'domain', value: 'example.com', enabled: true },
|
||||||
{ type: 'domain_suffix', value: 'example.org', enabled: true },
|
{ type: 'domain_suffix', value: 'example.org', enabled: true },
|
||||||
]);
|
]);
|
||||||
|
assert.equal(routed.state.route.localRulesPendingRestart, false);
|
||||||
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
|
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, 'sing-box-config.json'))).route.rules.slice(0, 3), [
|
||||||
{ domain: ['example.com'], outbound: 'direct' },
|
{ domain: ['example.com'], outbound: 'direct' },
|
||||||
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
{ domain_suffix: ['example.org'], outbound: 'direct' },
|
||||||
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
{ inbound: ['mixed-in'], outbound: 'test-vpn' },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
await mutation('/api/singbox/stop');
|
||||||
|
routed = await mutation('/api/route-rules', 'PUT', {
|
||||||
|
expectedRevision: revision,
|
||||||
|
rules: [
|
||||||
|
...routed.state.route.localRules,
|
||||||
|
{ type: 'domain_keyword', value: 'media', enabled: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
assert.equal(routed.state.connection.process, 'stopped');
|
||||||
|
assert.equal(routed.state.route.localRulesPendingRestart, true);
|
||||||
|
const restartedRules = await mutation('/api/singbox/restart');
|
||||||
|
assert.equal(restartedRules.state.route.localRulesPendingRestart, false);
|
||||||
|
|
||||||
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
const invalidRules = await rawRequest(port, '/api/route-rules', 'PUT', {
|
||||||
expectedRevision: revision,
|
expectedRevision: revision,
|
||||||
rules: [{ type: 'domain_regex', value: '.*' }],
|
rules: [{ type: 'domain_regex', value: '.*' }],
|
||||||
|
|||||||
16
test/web/rule-editor-contract.test.js
Normal file
16
test/web/rule-editor-contract.test.js
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import test from 'node:test';
|
||||||
|
|
||||||
|
const root = path.resolve(import.meta.dirname, '../..');
|
||||||
|
const component = fs.readFileSync(path.join(root, 'src/web/components/ClientOverviewPage.jsx'), 'utf8');
|
||||||
|
const styles = fs.readFileSync(path.join(root, 'src/web/styles.css'), 'utf8');
|
||||||
|
|
||||||
|
test('rule editor add latency stays constant and dirty exits are guarded', () => {
|
||||||
|
const rowRule = /\.client-local-rule \{([\s\S]*?)\n\}/.exec(styles)?.[1] || '';
|
||||||
|
assert.doesNotMatch(rowRule, /--rule-index|calc\(/);
|
||||||
|
assert.match(component, /addEventListener\('beforeunload', warnBeforeUnload\)/);
|
||||||
|
assert.match(component, /requestCloseLocalRules\(\)/);
|
||||||
|
assert.match(component, /localRulesPendingRestart/);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user