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. +
+ )}