diff --git a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md index c4e76e4..5451650 100644 --- a/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md +++ b/.codex/skills/design-vpn-client-ui/references/motion-and-interaction.md @@ -72,8 +72,10 @@ Use exponential ease-out curves such as `cubic-bezier(0.16, 1, 0.3, 1)` for arri ## Dynamic editors - 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. +- 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. - Let picker options appear as a short staggered cloud using opacity, blur, and transform. Avoid borders, shadows, raised cards, and layout-property animation. diff --git a/README.md b/README.md index 664990a..2b85547 100644 --- a/README.md +++ b/README.md @@ -127,6 +127,8 @@ VPN_PROXY_CLIENT_UI_PORT=3457 \ Полный URL можно вставить в поле точного домена, но Harbor сохранит только hostname. Путь и параметры HTTPS зашифрованы и недоступны sing-box на уровне маршрутизации. GeoSite, GeoIP и подключаемые списки пока не поддерживаются. +При сохранении Harbor проверяет фактическое состояние sing-box. Работающий процесс автоматически перезагружает новую конфигурацию. Если sing-box остановлен, правила сохраняются с признаком «ждут перезапуска» и начнут работать при следующем запуске или restart; этот статус виден в интерфейсе. + ## Системный прокси macOS Сначала посмотрите точное имя сетевого подключения: diff --git a/src/server/index.js b/src/server/index.js index 0ef1b19..88b6c36 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -380,11 +380,14 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) { else restoreSingboxConfig(previousConfig); throw error; } - if (persist) { + if (persist || stateStore.read().routeRulesPendingRestart) { updateStoredState((state) => ({ ...state, - appliedTag: selectedTag, - appliedAt: new Date().toISOString(), + ...(persist ? { + appliedTag: selectedTag, + appliedAt: new Date().toISOString(), + } : {}), + routeRulesPendingRestart: false, })); } } @@ -393,18 +396,26 @@ async function applyRouteRules(routeRules) { const state = normalizeStoredState(stateStore.read()); const cached = readSubscriptionCache(); if (!state.selectedTag || !cached?.config) { - updateStoredState((current) => ({ ...current, routeRules })); + updateStoredState((current) => ({ + ...current, + routeRules, + routeRulesPendingRestart: true, + })); return; } const previousConfig = fs.existsSync(settings.configPath) ? fs.readFileSync(settings.configPath, 'utf8') : null; - const wasRunning = singboxRuntime.running; + const wasRunning = Boolean((await singboxRuntime.refresh()).running); try { writeSingboxConfig(buildActiveConfig(cached.config, state.selectedTag, routeRules)); if (wasRunning) await startSingbox(); - updateStoredState((current) => ({ ...current, routeRules })); + updateStoredState((current) => ({ + ...current, + routeRules, + routeRulesPendingRestart: !wasRunning, + })); } catch (error) { if (previousConfig === null) removeSingboxConfig(); else restoreSingboxConfig(previousConfig); @@ -656,6 +667,7 @@ async function handleApi(req, res) { ...state, appliedTag: state.selectedTag, connectionDesired: 'running', + routeRulesPendingRestart: false, })); })); return sendState(res, { singboxRunning: true }); @@ -713,7 +725,13 @@ await refreshGatewayAutoMode({ reconfigure: false }) if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) { 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', () => { console.log(`[control] ${settings.appMode} UI слушает :${settings.port}`); diff --git a/src/shared/contracts/state.js b/src/shared/contracts/state.js index bd47f67..21d5bb9 100644 --- a/src/shared/contracts/state.js +++ b/src/shared/contracts/state.js @@ -20,6 +20,7 @@ export function normalizeStoredState(value) { appliedTag: Object.hasOwn(state, 'appliedTag') ? text(state.appliedTag) : selectedTag, servers: Array.isArray(state.servers) ? state.servers : [], routeRules: normalizeRouteRules(state.routeRules), + routeRulesPendingRestart: state.routeRulesPendingRestart === true, }; } @@ -84,6 +85,7 @@ export function createStateSnapshot({ lastVerifiedAt: null, reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual', localRules: stored.routeRules, + localRulesPendingRestart: stored.routeRulesPendingRestart, }, operation: { kind: nullableText(operation.kind), @@ -177,6 +179,7 @@ export function assertStateSnapshot(snapshot) { typeof snapshot.route.reason !== 'string' || !Array.isArray(snapshot.route.localRules) || !snapshot.route.localRules.every(validRouteRule) || + typeof snapshot.route.localRulesPendingRestart !== 'boolean' || !snapshot.operation || !nullableString(snapshot.operation.kind) || !OPERATION_STATES.has(snapshot.operation.status) || diff --git a/src/shared/versions.js b/src/shared/versions.js index 127822a..33fd823 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.4.0', - gatewayClient: '0.4.0', - gatewayBackend: '0.4.0', + macClient: '0.5.0', + gatewayClient: '0.5.0', + gatewayBackend: '0.5.0', }); export function parseVersion(value) { diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index ffd94ea..59a7926 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -222,6 +222,10 @@ const createLocalRuleDraft = (rule) => ({ enabled: rule?.enabled !== false, _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 }) { const [open, setOpen] = useState(false); @@ -320,6 +324,9 @@ function LocalRulesPanel({ open, rules, blocked, + dirty, + restartPending, + confirmingClose, error, operations, panelRef, @@ -327,6 +334,8 @@ function LocalRulesPanel({ onChange, onRemove, onRemoveComplete, + onKeepEditing, + onDiscard, onClose, onSave, }) { @@ -346,11 +355,34 @@ function LocalRulesPanel({
Маршрутизация +

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

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

+ {restartPending && ( +

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

+ )}
-
+ + {confirmingClose && ( +
+ Есть несохранённые настройки + Закрыть редактор и потерять изменения? +
+ + +
+
+ )}
Правила
@@ -358,7 +390,7 @@ function LocalRulesPanel({
{ if (event.animationName === 'client-local-rule-leave') { @@ -423,7 +455,6 @@ function LocalRulesPanel({
-
@@ -575,6 +606,7 @@ export function ClientOverviewPage({ const [localRulesOpen, setLocalRulesOpen] = useState(false); const [localRulesDraft, setLocalRulesDraft] = useState([]); const [localRulesRevision, setLocalRulesRevision] = useState(state?.revision || 0); + const [confirmingLocalRulesClose, setConfirmingLocalRulesClose] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); const [openInstructionId, setOpenInstructionId] = useState(''); const subscriptionInputRef = useRef(null); @@ -584,6 +616,7 @@ export function ClientOverviewPage({ const instructionsToggleRef = useRef(null); const localRulesPanelRef = useRef(null); const localRulesToggleRef = useRef(null); + const localRulesBaselineRef = useRef('[]'); const previousHasSubscriptionRef = useRef(hasSubscription); const validationRequests = useRef(null); if (!validationRequests.current) validationRequests.current = createLatestRequest(); @@ -624,6 +657,7 @@ export function ClientOverviewPage({ const subscriptionDeleteBlocked = operationBlocked(operations, 'subscriptionDelete'); const gatewayAutoBlocked = operationBlocked(operations, 'gatewayAuto'); const routeRulesBlocked = operationBlocked(operations, 'routeRules'); + const localRulesDirty = localRulesSignature(localRulesDraft) !== localRulesBaselineRef.current; useEffect(() => { setNow(Date.now()); @@ -798,10 +832,13 @@ export function ClientOverviewPage({ useEffect(() => { if (!localRulesOpen) return undefined; const closeLocalRules = (event) => { - if (event.type === 'keydown' && event.key !== 'Escape') return; - if (localRulesPanelRef.current?.contains(event.target)) return; - if (localRulesToggleRef.current?.contains(event.target)) return; - setLocalRulesOpen(false); + if (event.type === 'keydown') { + if (event.key !== 'Escape' || event.defaultPrevented) return; + } else { + if (localRulesPanelRef.current?.contains(event.target)) return; + if (localRulesToggleRef.current?.contains(event.target)) return; + } + requestCloseLocalRules(); }; document.addEventListener('pointerdown', closeLocalRules); document.addEventListener('keydown', closeLocalRules); @@ -809,7 +846,17 @@ export function ClientOverviewPage({ document.removeEventListener('pointerdown', 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(() => { if (!instructionsOpen) return undefined; @@ -905,9 +952,12 @@ export function ClientOverviewPage({ } function openLocalRules() { + const rules = state?.route?.localRules || []; setInstructionsOpen(false); - setLocalRulesDraft((state?.route?.localRules || []).map(createLocalRuleDraft)); + localRulesBaselineRef.current = localRulesSignature(rules); + setLocalRulesDraft(rules.map(createLocalRuleDraft)); setLocalRulesRevision(state?.revision || 0); + setConfirmingLocalRulesClose(false); setLocalRulesOpen(true); } @@ -919,10 +969,26 @@ export function ClientOverviewPage({ async function saveLocalRules(event) { event.preventDefault(); - const rules = localRulesDraft - .filter((rule) => !rule.removing) - .map(({ type, value, enabled }) => ({ type, value, enabled })); - if (!await onSaveRouteRules(rules, localRulesRevision)) return; + const rules = localRuleValues(localRulesDraft); + const result = await onSaveRouteRules(rules, localRulesRevision); + if (!result) 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); } @@ -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 (
@@ -954,7 +1031,7 @@ export function ClientOverviewPage({ aria-controls="client-instructions" aria-label={instructionsOpen ? 'Закрыть инструкции' : 'Как использовать'} onClick={() => { - setLocalRulesOpen(false); + if (localRulesOpen && !requestCloseLocalRules()) return; setInstructionsOpen((open) => !open); }} > @@ -967,19 +1044,19 @@ export function ClientOverviewPage({ } {hasSubscription && subscriptionContentReady && }
{showPower && ( @@ -1355,6 +1432,9 @@ export function ClientOverviewPage({ open={localRulesOpen} rules={localRulesDraft} blocked={routeRulesBlocked || localRulesDraft.some((rule) => rule.removing)} + dirty={localRulesDirty} + restartPending={state?.route?.localRulesPendingRestart === true} + confirmingClose={confirmingLocalRulesClose} error={error} operations={operations} panelRef={localRulesPanelRef} @@ -1364,10 +1444,10 @@ export function ClientOverviewPage({ ])} onChange={changeLocalRule} onRemove={(index) => removeLocalRule(localRulesDraft[index]._key)} - onRemoveComplete={(ruleKey) => setLocalRulesDraft((rules) => ( - rules.filter((rule) => rule._key !== ruleKey) - ))} - onClose={() => setLocalRulesOpen(false)} + onRemoveComplete={finishRemoveLocalRule} + onKeepEditing={() => setConfirmingLocalRulesClose(false)} + onDiscard={discardLocalRules} + onClose={requestCloseLocalRules} onSave={saveLocalRules} />} diff --git a/src/web/styles.css b/src/web/styles.css index b4718cb..e77f9e6 100644 --- a/src/web/styles.css +++ b/src/web/styles.css @@ -783,6 +783,14 @@ p { 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 { transform: rotate(90deg); } @@ -821,6 +829,7 @@ p { .client-local-rules-header { display: grid; + grid-template-columns: minmax(0, 1fr) auto; gap: 9px; margin: 0 8px 34px; } @@ -835,17 +844,46 @@ p { } .client-local-rules-header h2 { + grid-column: 1 / -1; font-size: 22px; letter-spacing: -0.045em; } .client-local-rules-header p { + grid-column: 1 / -1; max-width: 46ch; color: var(--client-muted); font-size: 11px; 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-group, .client-local-rules-list { @@ -871,7 +909,7 @@ p { align-items: center; gap: 10px; 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; } @@ -1093,6 +1131,8 @@ p { .client-rule-type-trigger:focus-visible, .client-local-rule > button:last-child: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 { outline: 0; color: var(--client-accent); @@ -1163,6 +1203,39 @@ p { 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 { display: flex; justify-content: flex-end; @@ -1186,10 +1259,6 @@ p { transform: translateY(-1px); } -.client-local-rules-actions .is-primary { - color: var(--client-accent); -} - .client-local-rules-actions button:disabled { opacity: 0.45; cursor: wait; @@ -1205,6 +1274,11 @@ p { 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 { position: fixed; inset: 0 auto 0 0; @@ -1310,6 +1384,11 @@ p { 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-switchyomega), ::view-transition-group(instruction-vscode), @@ -2801,6 +2880,9 @@ p { .client-local-rule > button:last-child, .client-local-rule-add, .client-local-rule-add-slot > span, + .client-local-rules-save, + .client-local-rules-runtime, + .client-local-rules-discard, .client-local-rules-actions button { transition: none; animation: none; diff --git a/test/server/state-contract.test.js b/test/server/state-contract.test.js index 8534b04..8c9e581 100644 --- a/test/server/state-contract.test.js +++ b/test/server/state-contract.test.js @@ -172,6 +172,7 @@ setInterval(() => {}, 60_000); assert.deepEqual(initial.route.localRules, [ { type: 'domain_suffix', value: 'ru', enabled: true }, ]); + assert.equal(initial.route.localRulesPendingRestart, false); assert.equal(JSON.stringify(initial).includes(subscriptionUrl), false); const stateKeys = Object.keys(initial).sort(); let revision = initial.revision; @@ -238,7 +239,7 @@ setInterval(() => {}, 60_000); assert.equal((await mutation('/api/singbox/restart')).state.connection.desired, 'running'); const rulesRevision = revision; - const routed = await mutation('/api/route-rules', 'PUT', { + let routed = await mutation('/api/route-rules', 'PUT', { expectedRevision: rulesRevision, rules: [ { type: 'domain_suffix', value: 'ru', enabled: false }, @@ -251,12 +252,26 @@ setInterval(() => {}, 60_000); { type: 'domain', value: 'example.com', 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), [ { domain: ['example.com'], outbound: 'direct' }, { domain_suffix: ['example.org'], outbound: 'direct' }, { 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', { expectedRevision: revision, rules: [{ type: 'domain_regex', value: '.*' }], diff --git a/test/web/rule-editor-contract.test.js b/test/web/rule-editor-contract.test.js new file mode 100644 index 0000000..f205362 --- /dev/null +++ b/test/web/rule-editor-contract.test.js @@ -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/); +});