diff --git a/src/server/index.js b/src/server/index.js index 88b6c36..b32c19b 100644 --- a/src/server/index.js +++ b/src/server/index.js @@ -380,16 +380,14 @@ async function applySelectedServer(selectedTag, { persist = true } = {}) { else restoreSingboxConfig(previousConfig); throw error; } - if (persist || stateStore.read().routeRulesPendingRestart) { - updateStoredState((state) => ({ - ...state, - ...(persist ? { - appliedTag: selectedTag, - appliedAt: new Date().toISOString(), - } : {}), - routeRulesPendingRestart: false, - })); - } + updateStoredState((state) => ({ + ...state, + ...(persist ? { + appliedTag: selectedTag, + appliedAt: new Date().toISOString(), + } : {}), + appliedRouteRules: state.routeRules, + })); } async function applyRouteRules(routeRules) { @@ -399,7 +397,7 @@ async function applyRouteRules(routeRules) { updateStoredState((current) => ({ ...current, routeRules, - routeRulesPendingRestart: true, + routeRulesRevision: current.routeRulesRevision + 1, })); return; } @@ -414,7 +412,8 @@ async function applyRouteRules(routeRules) { updateStoredState((current) => ({ ...current, routeRules, - routeRulesPendingRestart: !wasRunning, + ...(wasRunning ? { appliedRouteRules: routeRules } : {}), + routeRulesRevision: current.routeRulesRevision + 1, })); } catch (error) { if (previousConfig === null) removeSingboxConfig(); @@ -611,19 +610,23 @@ async function handleApi(req, res) { } if (req.method === 'PUT' && req.url === '/api/route-rules') { - const { rules, expectedRevision } = await readBody(req); + const { rules, expectedRulesRevision, expectedRevision } = await readBody(req); let routeRules; try { routeRules = normalizeRouteRules(rules, { strict: true }); } catch (cause) { throw new HarborError('REQUEST_INVALID', { cause }); } - if (!Number.isSafeInteger(expectedRevision) || expectedRevision < 0) { + const rulesRevision = expectedRulesRevision ?? expectedRevision; + if (!Number.isSafeInteger(rulesRevision) || rulesRevision < 0) { throw new HarborError('REQUEST_INVALID'); } await serializeControl(async () => { const current = normalizeStoredState(stateStore.read()); - if (current.revision !== expectedRevision) throw new HarborError('STATE_CONFLICT'); + const currentRevision = expectedRulesRevision == null + ? current.revision + : current.routeRulesRevision; + if (currentRevision !== rulesRevision) throw new HarborError('STATE_CONFLICT'); if (isDeepStrictEqual(current.routeRules, routeRules)) return; await withOperation('route-rules', () => applyRouteRules(routeRules)); }); @@ -667,7 +670,7 @@ async function handleApi(req, res) { ...state, appliedTag: state.selectedTag, connectionDesired: 'running', - routeRulesPendingRestart: false, + appliedRouteRules: state.routeRules, })); })); return sendState(res, { singboxRunning: true }); @@ -727,8 +730,8 @@ if (settings.appMode === 'client' || !fs.existsSync(settings.configPath)) { } await startSingbox() .then(() => { - if (fs.existsSync(settings.configPath) && stateStore.read().routeRulesPendingRestart) { - updateStoredState((state) => ({ ...state, routeRulesPendingRestart: false })); + if (fs.existsSync(settings.configPath)) { + updateStoredState((state) => ({ ...state, appliedRouteRules: state.routeRules })); } }) .catch((error) => console.warn(`[control] sing-box не запущен: ${error.message}`)); diff --git a/src/shared/contracts/state.js b/src/shared/contracts/state.js index 21d5bb9..5140265 100644 --- a/src/shared/contracts/state.js +++ b/src/shared/contracts/state.js @@ -20,7 +20,10 @@ 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, + appliedRouteRules: normalizeRouteRules(state.appliedRouteRules), + routeRulesRevision: Number.isSafeInteger(state.routeRulesRevision) && state.routeRulesRevision >= 0 + ? state.routeRulesRevision + : 0, }; } @@ -57,6 +60,7 @@ export function createStateSnapshot({ }; }); const routeMode = mode === 'client' ? gatewayAuto?.mode || 'local-vpn' : 'gateway-transparent'; + const activeLocalRules = runtime?.running ? stored.appliedRouteRules : []; return assertStateSnapshot({ apiVersion: 1, @@ -85,7 +89,9 @@ export function createStateSnapshot({ lastVerifiedAt: null, reason: mode === 'client' && stored.gatewayAutoEnabled !== false ? 'auto' : 'manual', localRules: stored.routeRules, - localRulesPendingRestart: stored.routeRulesPendingRestart, + activeLocalRules, + localRulesRevision: stored.routeRulesRevision, + localRulesPendingRestart: !isSameRules(stored.routeRules, activeLocalRules), }, operation: { kind: nullableText(operation.kind), @@ -179,6 +185,10 @@ export function assertStateSnapshot(snapshot) { typeof snapshot.route.reason !== 'string' || !Array.isArray(snapshot.route.localRules) || !snapshot.route.localRules.every(validRouteRule) || + !Array.isArray(snapshot.route.activeLocalRules) || + !snapshot.route.activeLocalRules.every(validRouteRule) || + !Number.isSafeInteger(snapshot.route.localRulesRevision) || + snapshot.route.localRulesRevision < 0 || typeof snapshot.route.localRulesPendingRestart !== 'boolean' || !snapshot.operation || !nullableString(snapshot.operation.kind) || @@ -193,3 +203,7 @@ export function assertStateSnapshot(snapshot) { return snapshot; } + +function isSameRules(left, right) { + return JSON.stringify(left) === JSON.stringify(right); +} diff --git a/src/shared/versions.js b/src/shared/versions.js index f415153..281847a 100644 --- a/src/shared/versions.js +++ b/src/shared/versions.js @@ -1,7 +1,7 @@ export const HARBOR_VERSIONS = Object.freeze({ - macClient: '0.5.1', - gatewayClient: '0.5.1', - gatewayBackend: '0.5.0', + macClient: '0.6.1', + gatewayClient: '0.6.0', + gatewayBackend: '0.6.0', }); export function parseVersion(value) { diff --git a/src/web/App.jsx b/src/web/App.jsx index 86bea72..883a25a 100644 --- a/src/web/App.jsx +++ b/src/web/App.jsx @@ -89,7 +89,7 @@ function App() { setError({ context, message: context === 'routing' && safeError.code === 'STATE_CONFLICT' - ? 'Правила изменились. Закройте панель и откройте её снова.' + ? 'Правила уже изменились в другом окне. Проверьте статусы строк и сохраните ещё раз.' : safeError.message, code: safeError.code, correlationId: safeError.correlationId, @@ -165,6 +165,7 @@ function App() { () => api.routeRules.update(rules, expectedRevision), 'routing', )} + onDismissError={() => setError(null)} /> diff --git a/src/web/api.js b/src/web/api.js index 530f257..0089e3d 100644 --- a/src/web/api.js +++ b/src/web/api.js @@ -77,9 +77,9 @@ export const api = { }), }, routeRules: { - update: (rules, expectedRevision) => request('/api/route-rules', { + update: (rules, expectedRulesRevision) => request('/api/route-rules', { method: 'PUT', - body: JSON.stringify({ rules, expectedRevision }), + body: JSON.stringify({ rules, expectedRulesRevision }), }), }, singbox: { diff --git a/src/web/components/ClientOverviewPage.jsx b/src/web/components/ClientOverviewPage.jsx index 6f0c1e4..1ac4f4b 100644 --- a/src/web/components/ClientOverviewPage.jsx +++ b/src/web/components/ClientOverviewPage.jsx @@ -138,9 +138,7 @@ function InlineError({ error, context }) { const operationProgress = { connection: ['connection', 'Меняем состояние подключения…'], serverApply: ['connection', 'Применяем сервер…'], - gatewayAuto: ['connection', 'Переключаем маршрут…'], subscriptionImport: ['subscription', 'Загружаем подписку…'], - subscriptionRefresh: ['subscription', 'Обновляем подписку…'], subscriptionDelete: ['subscription', 'Удаляем подписку…'], routeRules: ['routing', 'Применяем локальные правила…'], }; @@ -227,6 +225,15 @@ const localRuleValues = (rules) => rules .filter((rule) => !rule.removing) .map(({ type, value, enabled }) => ({ type, value, enabled })); const localRulesSignature = (rules) => JSON.stringify(localRuleValues(rules)); +const localRuleKey = ({ type, value, enabled }) => `${type}:${String(value || '').trim().toLowerCase()}:${enabled}`; + +function localRuleStatus(rule, savedRules, activeRules) { + const key = localRuleKey(rule); + if (!savedRules.some((saved) => localRuleKey(saved) === key)) return ['unsaved', 'Не сохранено']; + if (!rule.enabled) return ['disabled', 'Выключено']; + if (activeRules.some((active) => localRuleKey(active) === key)) return ['active', 'Активно']; + return ['pending', 'Ждёт перезапуска']; +} function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) { const [open, setOpen] = useState(false); @@ -324,6 +331,8 @@ function RuleTypePicker({ value, ruleKey, index, disabled, onChange }) { function LocalRulesPanel({ open, rules, + savedRules, + activeRules, blocked, dirty, restartPending, @@ -337,9 +346,9 @@ function LocalRulesPanel({ onClose, onSave, }) { - const activeRules = rules.filter((rule) => !rule.removing); - const canAdd = canAppendRouteRule(activeRules) && !blocked; - const incomplete = activeRules.some((rule) => !String(rule.value || '').trim()); + const draftRules = rules.filter((rule) => !rule.removing); + const canAdd = canAppendRouteRule(draftRules) && !blocked; + const incomplete = draftRules.some((rule) => !String(rule.value || '').trim()); return (