diff --git a/.github/workflows/frontend-check.yml b/.github/workflows/frontend-check.yml index 0bdfe42fc..023e762c6 100644 --- a/.github/workflows/frontend-check.yml +++ b/.github/workflows/frontend-check.yml @@ -78,6 +78,10 @@ jobs: if: steps.check_frontend.outputs.frontend == 'true' run: pnpm typecheck + - name: Run tests + if: steps.check_frontend.outputs.frontend == 'true' + run: pnpm test + - name: Run Knip if: steps.check_frontend.outputs.frontend == 'true' run: pnpm knip:check diff --git a/Cargo.lock b/Cargo.lock index 174a212e3..a3aa55c0e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1212,8 +1212,8 @@ dependencies = [ [[package]] name = "clash_verge_service_ipc" -version = "2.5.3" -source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?tag=v2.5.3#fd229aafae36b0bba547af56167b4ee26cc73a65" +version = "2.6.0" +source = "git+https://github.com/clash-verge-rev/clash-verge-service-ipc.git?branch=feat%2Fstage-runtime#fe6a771a35488eb3e8f3a26c901d3a03b5e2953c" dependencies = [ "anyhow", "compact_str", @@ -3808,7 +3808,7 @@ dependencies = [ [[package]] name = "kode-bridge" version = "0.4.2" -source = "git+https://github.com/clash-verge-rev/kode-bridge.git?branch=dev#fbd9888431cff8fba30934c9fbe1a553fce1ef1b" +source = "git+https://github.com/clash-verge-rev/kode-bridge.git?branch=dev#9dc3ee4689e46252600b9c1b12d20961fce113b4" dependencies = [ "bytes", "futures", diff --git a/crates/clash-verge-draft/src/lib.rs b/crates/clash-verge-draft/src/lib.rs index c1c5befbd..95fa02d48 100644 --- a/crates/clash-verge-draft/src/lib.rs +++ b/crates/clash-verge-draft/src/lib.rs @@ -145,3 +145,73 @@ impl Draft { } } } + +/// A draft layer that can be committed or rolled back without knowing what it holds. +/// +/// Exists so a change spanning several differently-typed layers can be treated as one unit. +pub trait DraftLayer: Send + Sync { + fn apply_draft(&self); + fn discard_draft(&self); +} + +impl DraftLayer for Draft { + #[inline] + fn apply_draft(&self) { + self.apply(); + } + + #[inline] + fn discard_draft(&self) { + self.discard(); + } +} + +/// Commits a set of draft layers together, or rolls them all back. +/// +/// Rolls back when dropped, so an early return — a `?`, a `bail!`, a panic — cannot leave a +/// change half-applied across layers. Committing is the explicit act, because forgetting to +/// commit costs a retry while forgetting to roll back leaves the app describing a state it is +/// not in. +#[must_use = "a transaction that is dropped without committing rolls back"] +pub struct DraftTransaction<'a> { + layers: Vec<&'a dyn DraftLayer>, + committed: bool, +} + +impl<'a> DraftTransaction<'a> { + /// Begin a transaction over `layers`, which are committed in the order given. + pub fn new(layers: Vec<&'a dyn DraftLayer>) -> Self { + Self { + layers, + committed: false, + } + } + + /// Commit every layer. Consumes the transaction so it cannot be committed twice. + pub fn commit(mut self) { + for layer in &self.layers { + layer.apply_draft(); + } + self.committed = true; + } + + /// Roll every layer back now, rather than whenever this value happens to drop. + /// + /// Needed where recovery does more than forget the drafts — restoring files, or + /// regenerating them from the committed configuration. Those read the layers, so the + /// rollback has to have already happened, not be pending on a later drop. + pub fn rollback(self) { + drop(self); + } +} + +impl Drop for DraftTransaction<'_> { + fn drop(&mut self) { + if self.committed { + return; + } + for layer in &self.layers { + layer.discard_draft(); + } + } +} diff --git a/crates/clash-verge-draft/tests/test_me.rs b/crates/clash-verge-draft/tests/test_me.rs index c7efa4eef..cc17fbaf9 100644 --- a/crates/clash-verge-draft/tests/test_me.rs +++ b/crates/clash-verge-draft/tests/test_me.rs @@ -303,3 +303,78 @@ mod tests { assert_eq!(latest.enable_tun_mode, Some(false)); } } + +mod transactions { + use clash_verge_draft::{Draft, DraftTransaction}; + + #[test] + fn committing_applies_every_layer() { + let first = Draft::new(1_u8); + let second = Draft::new("a".to_owned()); + + let transaction = DraftTransaction::new(vec![&first, &second]); + first.edit_draft(|value| *value = 2); + second.edit_draft(|value| *value = "b".to_owned()); + transaction.commit(); + + assert_eq!(**first.data_arc(), 2); + assert_eq!(**second.data_arc(), "b"); + } + + #[test] + fn dropping_without_committing_rolls_every_layer_back() { + let first = Draft::new(1_u8); + let second = Draft::new("a".to_owned()); + + { + let _transaction = DraftTransaction::new(vec![&first, &second]); + first.edit_draft(|value| *value = 2); + second.edit_draft(|value| *value = "b".to_owned()); + } + + assert_eq!(**first.data_arc(), 1); + assert_eq!(**second.data_arc(), "a"); + assert_eq!( + **first.latest_arc(), + 1, + "the draft itself is gone, not just uncommitted" + ); + } + + #[test] + fn an_early_return_rolls_back_without_the_caller_saying_so() { + fn attempt(draft: &Draft, succeed: bool) -> Result<(), &'static str> { + let transaction = DraftTransaction::new(vec![draft]); + draft.edit_draft(|value| *value = 9); + if !succeed { + return Err("failed after editing"); + } + transaction.commit(); + Ok(()) + } + + let draft = Draft::new(1_u8); + assert!(attempt(&draft, false).is_err()); + assert_eq!(**draft.data_arc(), 1, "a failed attempt leaves nothing behind"); + + assert!(attempt(&draft, true).is_ok()); + assert_eq!(**draft.data_arc(), 9); + } + + #[test] + fn a_partly_edited_transaction_still_rolls_back_whole() { + // The layer nobody edited must not be left holding a stale draft either. + let edited = Draft::new(1_u8); + let untouched = Draft::new(5_u8); + + drop(DraftTransaction::new(vec![&edited, &untouched])); + edited.edit_draft(|value| *value = 2); + + { + let _transaction = DraftTransaction::new(vec![&edited, &untouched]); + } + + assert_eq!(**edited.latest_arc(), 1, "the earlier stray draft is cleared too"); + assert_eq!(**untouched.latest_arc(), 5); + } +} diff --git a/eslint.config.ts b/eslint.config.ts index c7bd663ae..2f788ec11 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -43,6 +43,7 @@ export default defineConfig([ 'eslint.config.ts', `vite.config.mts`, 'scripts/*.mjs', + 'tests/*.ts', 'src/polyfills/*.js', 'tests/*.mjs', ], diff --git a/package.json b/package.json index 11a4da70b..00faaeb55 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,8 @@ "i18n:format": "node scripts/cleanup-unused-i18n.mjs --align --apply", "i18n:types": "node scripts/generate-i18n-keys.mjs", "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", "test:dev-control": "node --test scripts/dev-control.test.mjs scripts/service-release.test.mjs", "knip:check": "knip", "knip:report": "knip --no-exit-code" @@ -88,6 +90,7 @@ "@eslint-react/eslint-plugin": "^5.8.5", "@eslint/js": "^10.0.1", "@tauri-apps/cli": "2.11.4", + "@testing-library/react": "^16.3.2", "@types/js-yaml": "^4.0.9", "@types/lodash-es": "^4.17.12", "@types/node": "^24.12.4", @@ -115,6 +118,7 @@ "https-proxy-agent": "^9.0.0", "husky": "^9.1.7", "jiti": "^2.7.0", + "jsdom": "^29.1.1", "knip": "6.26.0", "lint-staged": "^17.0.5", "sass": "^1.100.0", @@ -123,7 +127,8 @@ "typescript": "^6.0.3", "typescript-eslint": "^8.59.4", "vite": "^8.0.14", - "vite-plugin-svgr": "^5.2.0" + "vite-plugin-svgr": "^5.2.0", + "vitest": "^4.1.10" }, "lint-staged": { "*.{ts,tsx,js,mjs}": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e9716e59..571e02ef3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -150,6 +150,9 @@ importers: '@tauri-apps/cli': specifier: 2.11.4 version: 2.11.4 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 @@ -231,6 +234,9 @@ importers: jiti: specifier: ^2.7.0 version: 2.7.0 + jsdom: + specifier: ^29.1.1 + version: 29.1.1 knip: specifier: 6.26.0 version: 6.26.0 @@ -258,6 +264,9 @@ importers: vite-plugin-svgr: specifier: ^5.2.0 version: 5.2.0(typescript@6.0.3)(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + vitest: + specifier: ^4.1.10 + version: 4.1.10(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) packages: @@ -267,6 +276,21 @@ packages: '@actions/http-client@3.0.2': resolution: {integrity: sha512-JP38FYYpyqvUsz+Igqlc/JG6YO9PaKuvqjM3iGvaLqFnJ7TFmcLyy2IDrY0bI0qCQug8E9K+elv5ZNfw62ZJzA==} + '@asamuzakjp/css-color@5.1.11': + resolution: {integrity: sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@7.1.1': + resolution: {integrity: sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/generational-cache@1.0.1': + resolution: {integrity: sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + '@asamuzakjp/nwsapi@2.3.9': + resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -832,6 +856,46 @@ packages: cpu: [x64] os: [win32] + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + '@dnd-kit/accessibility@3.1.1': resolution: {integrity: sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==} peerDependencies: @@ -1023,6 +1087,15 @@ packages: resolution: {integrity: sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + '@humanfs/core@0.19.2': resolution: {integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==} engines: {node: '>=18.18.0'} @@ -1652,6 +1725,9 @@ packages: rollup: optional: true + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0': resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} engines: {node: '>=14'} @@ -1826,12 +1902,40 @@ packages: '@tauri-apps/plugin-updater@2.10.1': resolution: {integrity: sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA==} + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -2101,6 +2205,35 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + '@vscode/l10n@0.0.18': resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} @@ -2139,10 +2272,18 @@ packages: resolution: {integrity: sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg==} engines: {node: '>=18'} + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + ansi-regex@6.2.2: resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} engines: {node: '>=12'} + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + ansi-styles@6.2.3: resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} engines: {node: '>=12'} @@ -2150,6 +2291,13 @@ packages: argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -2190,6 +2338,9 @@ packages: before-after-hook@4.0.0: resolution: {integrity: sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==} + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + birecord@0.1.1: resolution: {integrity: sha512-VUpsf/qykW0heRlC8LooCq28Kxn3mAqKohhDG/49rrsQ1dT1CXyj/pgXS+5BSRzFTR/3DyIBOqQOrGyZOh71Aw==} @@ -2230,6 +2381,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + character-entities-html4@2.1.0: resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} @@ -2335,6 +2490,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2342,6 +2501,10 @@ packages: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + dayjs@1.11.21: resolution: {integrity: sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==} @@ -2354,6 +2517,9 @@ packages: supports-color: optional: true + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2375,6 +2541,9 @@ packages: devlop@1.1.0: resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dom-helpers@5.2.1: resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} @@ -2402,6 +2571,10 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + environment@1.1.0: resolution: {integrity: sha512-xUtoPkMggbz0MPyPiIWr1Kp4aeWJjDZ6SMvURhimjdZgsRuDplF5/s9hcgGhyXMhs+6vpnuoiZ2kFiu3FMnS8Q==} engines: {node: '>=18'} @@ -2417,6 +2590,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} @@ -2616,6 +2792,9 @@ packages: estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + esutils@2.0.3: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} @@ -2629,6 +2808,10 @@ packages: eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + ext@1.7.0: resolution: {integrity: sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==} @@ -2791,6 +2974,10 @@ packages: hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + html-parse-stringify@3.0.1: resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} @@ -2885,6 +3072,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-promise@2.2.2: resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} @@ -2905,6 +3095,15 @@ packages: resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true + jsdom@29.1.1: + resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==} + engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0} + peerDependencies: + canvas: ^3.0.0 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -3068,6 +3267,10 @@ packages: lru-queue@0.1.0: resolution: {integrity: sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==} + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3104,6 +3307,9 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + memoizee@0.4.17: resolution: {integrity: sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==} engines: {node: '>=0.12'} @@ -3263,6 +3469,10 @@ packages: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + onetime@7.0.0: resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} engines: {node: '>=18'} @@ -3300,6 +3510,9 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + path-browserify@1.0.1: resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} @@ -3322,6 +3535,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3342,6 +3558,10 @@ packages: engines: {node: '>=14'} hasBin: true + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3411,6 +3631,9 @@ packages: react-is@16.13.1: resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-is@19.2.7: resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} @@ -3474,6 +3697,10 @@ packages: remark-rehype@11.1.2: resolution: {integrity: sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resize-observer-polyfill@1.5.1: resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} @@ -3506,6 +3733,10 @@ packages: engines: {node: '>=20.19.0'} hasBin: true + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3533,6 +3764,9 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} @@ -3574,9 +3808,15 @@ packages: resolution: {integrity: sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==} engines: {node: '>=12.0.0'} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-argv@0.3.2: resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} engines: {node: '>=0.6.19'} @@ -3624,6 +3864,9 @@ packages: peerDependencies: react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + systemjs@6.15.1: resolution: {integrity: sha512-Nk8c4lXvMB98MtbmjX7JwJRgJOL8fluecYCfCeYBznwmpOs8Bf15hLM6z4z71EDAhQVrQrI+wt1aLWSXZq+hXA==} @@ -3644,6 +3887,9 @@ packages: resolution: {integrity: sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==} engines: {node: '>=0.12'} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + tinyexec@1.2.4: resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} engines: {node: '>=18'} @@ -3652,6 +3898,25 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.9: + resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + + tldts@7.4.9: + resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -3707,6 +3972,10 @@ packages: resolution: {integrity: sha512-YmfV3YnEDzXRC5lZ2jWtWWHKGUm1zIt8AhesR1tens+HTNv+YZlN/dp6G727LOvMJ8xjP9Be7Y2Sdr96LDm+pg==} engines: {node: '>=18.17'} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} engines: {node: '>=4'} @@ -3822,6 +4091,47 @@ packages: yaml: optional: true + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + void-elements@3.1.0: resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} engines: {node: '>=0.10.0'} @@ -3842,6 +4152,10 @@ packages: vscode-uri@3.1.0: resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + walk-up-path@4.0.0: resolution: {integrity: sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==} engines: {node: 20 || >=22} @@ -3849,11 +4163,28 @@ packages: web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -3866,6 +4197,13 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -3924,6 +4262,26 @@ snapshots: tunnel: 0.0.6 undici: 6.27.0 + '@asamuzakjp/css-color@5.1.11': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@asamuzakjp/dom-selector@7.1.1': + dependencies: + '@asamuzakjp/generational-cache': 1.0.1 + '@asamuzakjp/nwsapi': 2.3.9 + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + + '@asamuzakjp/generational-cache@1.0.1': {} + + '@asamuzakjp/nwsapi@2.3.9': {} + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -4630,6 +4988,34 @@ snapshots: '@biomejs/cli-win32-x64@2.5.2': optional: true + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + '@dnd-kit/accessibility@3.1.1(react@19.2.7)': dependencies: react: 19.2.7 @@ -4904,6 +5290,8 @@ snapshots: '@eslint/core': 1.2.1 levn: 0.4.1 + '@exodus/bytes@1.15.1': {} + '@humanfs/core@0.19.2': dependencies: '@humanfs/types': 0.15.0 @@ -5370,6 +5758,8 @@ snapshots: estree-walker: 2.0.2 picomatch: 4.0.5 + '@standard-schema/spec@1.1.0': {} + '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)': dependencies: '@babel/core': 7.29.7 @@ -5521,15 +5911,45 @@ snapshots: dependencies: '@tauri-apps/api': 2.11.1 + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 optional: true + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/debug@4.1.13': dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/esrecurse@4.3.1': {} '@types/estree-jsx@1.0.5': @@ -5776,6 +6196,47 @@ snapshots: '@rolldown/pluginutils': 1.0.1 vite: 8.1.3(@types/node@24.13.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0) + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.10(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0))': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.1.3(@types/node@24.13.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0) + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + '@vscode/l10n@0.0.18': {} acorn-jsx@5.3.2(acorn@8.17.0): @@ -5820,12 +6281,22 @@ snapshots: dependencies: environment: 1.1.0 + ansi-regex@5.0.1: {} + ansi-regex@6.2.2: {} + ansi-styles@5.2.0: {} + ansi-styles@6.2.3: {} argparse@2.0.1: {} + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + asynckit@0.4.0: {} axios@1.18.1: @@ -5876,6 +6347,10 @@ snapshots: before-after-hook@4.0.0: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + birecord@0.1.1: {} brace-expansion@5.0.7: @@ -5910,6 +6385,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + character-entities-html4@2.1.0: {} character-entities-legacy@3.0.0: {} @@ -6003,6 +6480,11 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + csstype@3.2.3: {} d@1.0.2: @@ -6010,12 +6492,21 @@ snapshots: es5-ext: 0.10.64 type: 2.7.3 + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + dayjs@1.11.21: {} debug@4.4.3: dependencies: ms: 2.1.3 + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -6032,6 +6523,8 @@ snapshots: dependencies: dequal: 2.0.3 + dom-accessibility-api@0.5.16: {} + dom-helpers@5.2.1: dependencies: '@babel/runtime': 7.29.7 @@ -6060,6 +6553,8 @@ snapshots: entities@6.0.1: {} + entities@8.0.0: {} + environment@1.1.0: {} error-ex@1.3.4: @@ -6070,6 +6565,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.3.1: {} + es-object-atoms@1.1.2: dependencies: es-errors: 1.3.0 @@ -6373,6 +6870,10 @@ snapshots: estree-walker@2.0.2: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + esutils@2.0.3: {} event-emitter@0.3.5: @@ -6384,6 +6885,8 @@ snapshots: eventemitter3@5.0.4: {} + expect-type@1.4.0: {} + ext@1.7.0: dependencies: type: 2.7.3 @@ -6585,6 +7088,12 @@ snapshots: dependencies: react-is: 16.13.1 + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + html-parse-stringify@3.0.1: dependencies: void-elements: 3.1.0 @@ -6665,6 +7174,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-promise@2.2.2: {} isexe@2.0.0: {} @@ -6679,6 +7190,32 @@ snapshots: dependencies: argparse: 2.0.1 + jsdom@29.1.1: + dependencies: + '@asamuzakjp/css-color': 5.1.11 + '@asamuzakjp/dom-selector': 7.1.1 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.1 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 7.29.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + jsesc@3.1.0: {} json-buffer@3.0.1: {} @@ -6826,6 +7363,8 @@ snapshots: dependencies: es5-ext: 0.10.64 + lz-string@1.5.0: {} + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -6923,6 +7462,8 @@ snapshots: dependencies: '@types/mdast': 4.0.4 + mdn-data@2.27.1: {} + memoizee@0.4.17: dependencies: d: 1.0.2 @@ -7151,6 +7692,8 @@ snapshots: object-assign@4.1.1: {} + obug@2.1.4: {} + onetime@7.0.0: dependencies: mimic-function: 5.0.1 @@ -7244,6 +7787,10 @@ snapshots: dependencies: entities: 6.0.1 + parse5@8.0.1: + dependencies: + entities: 8.0.0 + path-browserify@1.0.1: {} path-exists@4.0.0: {} @@ -7259,6 +7806,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@4.0.5: {} @@ -7273,6 +7822,12 @@ snapshots: prettier@3.9.4: {} + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -7321,6 +7876,8 @@ snapshots: react-is@16.13.1: {} + react-is@17.0.2: {} + react-is@19.2.7: {} react-markdown@10.1.0(@types/react@19.2.17)(react@19.2.7): @@ -7407,6 +7964,8 @@ snapshots: unified: 11.0.5 vfile: 6.0.3 + require-from-string@2.0.2: {} + resize-observer-polyfill@1.5.1: {} resolve-from@4.0.0: {} @@ -7456,6 +8015,10 @@ snapshots: optionalDependencies: '@parcel/watcher': 2.5.6 + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} screenfull@5.2.0: {} @@ -7472,6 +8035,8 @@ snapshots: shebang-regex@3.0.0: {} + siginfo@2.0.0: {} + signal-exit@4.1.0: {} slice-ansi@7.1.2: @@ -7506,8 +8071,12 @@ snapshots: stable-hash-x@0.2.0: {} + stackback@0.0.2: {} + state-local@1.0.7: {} + std-env@4.2.0: {} + string-argv@0.3.2: {} string-ts@2.3.1: {} @@ -7554,6 +8123,8 @@ snapshots: react: 19.2.7 use-sync-external-store: 1.6.0(react@19.2.7) + symbol-tree@3.2.4: {} + systemjs@6.15.1: {} tar@7.5.19: @@ -7580,6 +8151,8 @@ snapshots: es5-ext: 0.10.64 next-tick: 1.1.0 + tinybench@2.9.0: {} + tinyexec@1.2.4: {} tinyglobby@0.2.17: @@ -7587,6 +8160,22 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyrainbow@3.1.0: {} + + tldts-core@7.4.9: {} + + tldts@7.4.9: + dependencies: + tldts-core: 7.4.9 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.9 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -7628,6 +8217,8 @@ snapshots: undici@6.27.0: {} + undici@7.29.0: {} + unicode-canonical-property-names-ecmascript@2.0.1: {} unicode-match-property-ecmascript@2.0.0: @@ -7758,6 +8349,34 @@ snapshots: terser: 5.48.0 yaml: 2.9.0 + vitest@4.1.10(@types/node@24.13.2)(jsdom@29.1.1)(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.1.3(@types/node@24.13.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.2.4 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.0 + vite: 8.1.3(@types/node@24.13.2)(jiti@2.7.0)(sass@1.101.0)(terser@5.48.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.13.2 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + void-elements@3.1.0: {} vscode-jsonrpc@9.0.1: {} @@ -7773,14 +8392,35 @@ snapshots: vscode-uri@3.1.0: {} + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + walk-up-path@4.0.0: {} web-namespaces@2.0.1: {} + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + word-wrap@1.2.5: {} wrap-ansi@10.0.0: @@ -7795,6 +8435,10 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.2.0 + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + yallist@3.1.1: {} yallist@5.0.0: {} diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 74f6cc972..6a8929ef6 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -104,7 +104,8 @@ tauri-plugin-devtools = { version = "2.1.0" } tauri-plugin-mihomo = { git = "https://github.com/clash-verge-rev/tauri-plugin-mihomo", branch = "fix/api-robustness" } clash_verge_logger = { git = "https://github.com/clash-verge-rev/clash-verge-logger" } async-trait = "0.1.89" -clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.5.3", version = "2.5.3", features = [ +# Tracks a branch until the protocol change it carries is tagged; move this to v2.6.0 then. +clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", branch = "feat/stage-runtime", version = "2.6.0", features = [ "client", ] } arc-swap = "1.9.2" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index b67dd4d05..8cdf2daf5 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,4 +1,34 @@ -fn main() { +fn main() -> Result<(), Box> { + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") + && std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") + && std::env::var_os("CARGO_FEATURE_CLIPPY").is_some() + { + // Tauri's resource is linked to the app binary, but not to Rust unit-test harnesses. + // Give mock-context builds their own manifest so tests can resolve TaskDialogIndirect. + let out_dir = std::env::var_os("OUT_DIR").ok_or_else(|| std::io::Error::other("OUT_DIR is not set"))?; + let manifest_path = std::path::PathBuf::from(out_dir).join("windows-test-manifest.xml"); + std::fs::write( + &manifest_path, + r#" + + + + + + +"#, + )?; + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest_path.display()); + } + #[cfg(feature = "clippy")] { println!("cargo:warning=Skipping tauri_build during Clippy"); @@ -6,4 +36,6 @@ fn main() { #[cfg(not(feature = "clippy"))] tauri_build::build(); + + Ok(()) } diff --git a/src-tauri/src/cmd/service.rs b/src-tauri/src/cmd/service.rs index b4e076744..9e2470954 100644 --- a/src-tauri/src/cmd/service.rs +++ b/src-tauri/src/cmd/service.rs @@ -1,7 +1,7 @@ use super::{CmdResult, WithErrorCode as _}; use crate::core::{ CoreManager, - service::{self, SERVICE_MANAGER, ServiceStatus}, + service::{SERVICE_MANAGER, ServiceStatus}, }; async fn execute_service_operation_sync(status: ServiceStatus, error_code: &str) -> CmdResult { @@ -42,11 +42,6 @@ pub async fn repair_service() -> CmdResult { execute_service_operation_sync(ServiceStatus::ForceReinstallRequired, "SERVICE_REPAIR_FAILED").await } -#[tauri::command] -pub async fn get_service_install_state() -> CmdResult { - Ok(SERVICE_MANAGER.install_state().await) -} - #[tauri::command] pub async fn continue_with_sidecar() -> CmdResult { crate::core::CoreManager::global() diff --git a/src-tauri/src/cmd/system.rs b/src-tauri/src/cmd/system.rs index 6eaeb3f15..bb460bafb 100644 --- a/src-tauri/src/cmd/system.rs +++ b/src-tauri/src/cmd/system.rs @@ -1,9 +1,11 @@ -use std::sync::Arc; +use crate::core::runstate::{RUN_STATE, RunStateView}; -use crate::core::{CoreManager, manager::RunningMode}; - -/// 获取当前内核运行模式 +/// The whole Run State in one call. +/// +/// Replaces the separate running-mode and service-install-state commands: they were three +/// caches on three refresh intervals describing one fact, which the frontend then had to keep +/// coherent by hand. #[tauri::command] -pub async fn get_running_mode() -> Result, String> { - Ok(CoreManager::global().get_running_mode()) +pub async fn get_runtime_state() -> Result { + Ok(RUN_STATE.settled().await.to_view()) } diff --git a/src-tauri/src/config/mixed_port.rs b/src-tauri/src/config/mixed_port.rs new file mode 100644 index 000000000..0c969a464 --- /dev/null +++ b/src-tauri/src/config/mixed_port.rs @@ -0,0 +1,107 @@ +//! Resolving the Mixed Port. +//! +//! Two different questions were being asked with one answer: +//! +//! - *What have we configured?* — the user's selection, else the Merge Config. Everything that +//! writes configuration or decides what to start needs this, and it has to be answerable +//! before the Core exists. +//! - *What is the Core actually serving on?* — what the Core reports, else what we configured. +//! Anything that points a client at the proxy wants this, because the two can differ: a +//! startup fallback moves the port, and a hand-edited Core config can move it too. +//! +//! Before, six call sites hand-copied the first chain (one of them skipping the Merge Config +//! entirely) and the frontend hand-copied a version of the second. + +use anyhow::Result; + +use super::Config; +use crate::core::handle::Handle; + +/// The port mihomo listens on when nothing else is configured. +pub const DEFAULT_MIXED_PORT: u16 = 7897; + +/// Resolving the Mixed Port. +pub struct MixedPort; + +impl MixedPort { + /// The Mixed Port the app has configured. + /// + /// Reads the draft layer, so an edit in progress is visible to the code applying it. Safe + /// to call before the Core is running, unlike [`Self::effective`]. + pub async fn desired() -> u16 { + let selected = Config::verge().await.latest_arc().verge_mixed_port; + // `get_mixed_port` already falls back to the default when the Merge Config is silent. + let merged = Config::clash().await.latest_arc().get_mixed_port(); + resolve_desired(selected, merged) + } + + /// The Mixed Port the Core is actually serving on. + /// + /// Costs one round-trip to the Core, so it belongs on paths a user triggers rather than on + /// paths served per request. Falls back to [`Self::desired`] when the Core cannot be asked, + /// which is the right answer whenever the Core is not running anyway. + pub async fn effective() -> u16 { + let desired = Self::desired().await; + resolve_effective( + || async { Ok(Handle::mihomo().await.get_base_config().await?.mixed_port) }, + desired, + ) + .await + } +} + +/// Prefer what the user selected, else what the Merge Config resolved to. +const fn resolve_desired(selected: Option, merged: u16) -> u16 { + match selected { + Some(port) => port, + None => merged, + } +} + +/// Prefer what the Core reports; fall back to what we configured. +async fn resolve_effective(live: Live, desired: u16) -> u16 +where + Live: FnOnce() -> LiveFuture, + LiveFuture: Future>, +{ + match live().await { + // A Core reporting port 0 is a Core that has not bound anything yet. + Ok(port) if port != 0 => port, + _ => desired, + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod tests { + use super::*; + + #[test] + fn a_selected_port_wins_over_the_merge_config() { + assert_eq!(resolve_desired(Some(9000), 7897), 9000); + } + + #[test] + fn the_merge_config_answers_when_nothing_is_selected() { + assert_eq!(resolve_desired(None, 8080), 8080); + } + + #[tokio::test] + async fn a_reporting_core_overrides_what_we_configured() { + // This is the startup-fallback case: we asked for 7897 and the Core landed on 7898. + assert_eq!(resolve_effective(|| async { Ok(7898) }, 7897).await, 7898); + } + + #[tokio::test] + async fn an_unreachable_core_leaves_the_configured_port_standing() { + assert_eq!( + resolve_effective(|| async { anyhow::bail!("core is not running") }, 7897).await, + 7897 + ); + } + + #[tokio::test] + async fn a_core_that_has_not_bound_yet_is_not_believed() { + assert_eq!(resolve_effective(|| async { Ok(0) }, 7897).await, 7897); + } +} diff --git a/src-tauri/src/config/mod.rs b/src-tauri/src/config/mod.rs index 249db08dd..173a4b304 100644 --- a/src-tauri/src/config/mod.rs +++ b/src-tauri/src/config/mod.rs @@ -2,6 +2,7 @@ mod clash; #[allow(clippy::module_inception)] mod config; mod encrypt; +mod mixed_port; mod port; mod prfitem; pub mod profiles; @@ -9,7 +10,7 @@ pub mod runtime; pub(crate) mod snapshot; mod verge; -pub use self::{clash::*, config::*, encrypt::*, prfitem::*, profiles::*, verge::*}; +pub use self::{clash::*, config::*, encrypt::*, mixed_port::*, prfitem::*, profiles::*, verge::*}; pub const DEFAULT_PAC: &str = r#"function FindProxyForURL(url, host) { return "PROXY 127.0.0.1:%mixed-port%; SOCKS5 127.0.0.1:%mixed-port%; DIRECT;"; diff --git a/src-tauri/src/config/port.rs b/src-tauri/src/config/port.rs index b1493f5c6..7b66b2723 100644 --- a/src-tauri/src/config/port.rs +++ b/src-tauri/src/config/port.rs @@ -6,7 +6,7 @@ use crate::{ constants::timing, core::{ handle::Handle, - listener::ListenerBindScope, + listener::{ListenerBindScope, MIXED_PORT_KEY, proxy_listener_keys}, owner_identity::current_owner_credentials, service::{SERVICE_MANAGER, ServiceStatus}, validate::CoreConfigValidator, @@ -15,6 +15,7 @@ use crate::{ utils::port::find_next_available_port, }; use anyhow::{Context as _, Result, anyhow, bail}; +use clash_verge_draft::DraftTransaction; use clash_verge_logging::{Type, logging}; use once_cell::sync::Lazy; use parking_lot::Mutex; @@ -88,46 +89,29 @@ impl Config { let clash = Self::clash().await; let verge = Self::verge().await; let runtime = Self::runtime().await; + // Every failure below leaves the three layers as they were, without saying so. + let transaction = DraftTransaction::new(vec![&clash, &verge, &runtime]); clash.edit_draft(|draft| { - draft.0.insert("mixed-port".into(), new_port.into()); + draft.0.insert(MIXED_PORT_KEY.into(), new_port.into()); }); verge.edit_draft(|draft| { draft.verge_mixed_port = Some(new_port); }); - if let Err(error) = Self::generate().await { - clash.discard(); - verge.discard(); - runtime.discard(); - return Err(error).context("failed to materialize runtime configuration with fallback port"); - } + Self::generate() + .await + .context("failed to materialize runtime configuration with fallback port")?; - let validation = match CoreConfigValidator::global().validate_config_outcome().await { - Ok(outcome) => outcome, - Err(error) => { - clash.discard(); - verge.discard(); - runtime.discard(); - return Err(error).context("failed to validate runtime configuration with fallback port"); - } - }; + let validation = CoreConfigValidator::global() + .validate_config_outcome() + .await + .context("failed to validate runtime configuration with fallback port")?; if !validation.is_valid() { - clash.discard(); - verge.discard(); - runtime.discard(); bail!("runtime configuration with fallback port is invalid: {validation}"); } - let snapshots = match capture_config_files().await { - Ok(snapshots) => snapshots, - Err(error) => { - clash.discard(); - verge.discard(); - runtime.discard(); - return Err(error); - } - }; + let snapshots = capture_config_files().await?; let candidate_clash = clash.latest_arc(); let candidate_verge = verge.latest_arc(); @@ -147,19 +131,18 @@ impl Config { } .await; + // Files are not part of the transaction, so a failed write is restored explicitly. + // The drafts are rolled back first: restoring reads the committed configuration, and + // must not see a candidate that is being abandoned. if let Err(error) = persist_result { - clash.discard(); - verge.discard(); - runtime.discard(); + transaction.rollback(); return match restore_files(&snapshots).await { Ok(()) => Err(error), Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")), }; } - clash.apply(); - verge.apply(); - runtime.apply(); + transaction.commit(); record_fallback(old_port, new_port); Handle::refresh_clash(); Handle::refresh_verge(); @@ -278,7 +261,9 @@ async fn owned_service_core_uses_port(port: u16) -> bool { fn configured_listener_ports(clash: &IClashTemp, verge: &IVerge) -> HashSet { let mut ports = HashSet::new(); - for key in ["socks-port", "port", "redir-port", "tproxy-port"] { + // Every listener except the Mixed Port itself: that is the one being reassigned, so + // colliding with its current value is exactly what we are trying to do. + for key in proxy_listener_keys().filter(|key| *key != MIXED_PORT_KEY) { if let Some(port) = mapping_port(&clash.0, key) { ports.insert(port); } @@ -308,6 +293,27 @@ fn mapping_port(mapping: &serde_yaml_ng::Mapping, key: &str) -> Option { }) } +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod listener_key_tests { + use super::{MIXED_PORT_KEY, proxy_listener_keys}; + + #[test] + fn the_reserved_set_covers_every_listener_but_the_one_being_moved() { + let reserved: Vec<_> = proxy_listener_keys().filter(|key| *key != MIXED_PORT_KEY).collect(); + + assert!( + !reserved.contains(&MIXED_PORT_KEY), + "the port being reassigned must not reserve itself" + ); + assert_eq!( + reserved.len(), + proxy_listener_keys().count() - 1, + "every other listener must be reserved, so a new one cannot be forgotten here" + ); + } +} + #[cfg(test)] mod tests { use super::configured_listener_ports; diff --git a/src-tauri/src/core/handle.rs b/src-tauri/src/core/handle.rs index a0b40acbf..b79248223 100644 --- a/src-tauri/src/core/handle.rs +++ b/src-tauri/src/core/handle.rs @@ -48,6 +48,17 @@ impl Handle { Self::send_event(FrontendEvent::RefreshProfiles); } + /// Push a Run State snapshot to the frontend. + /// + /// Sent on every transition, so the frontend does not have to poll to notice that the Core + /// stopped or that the Service came back. + pub fn notify_run_state(state: &crate::core::runstate::RunStateView) { + let Ok(state) = serde_json::to_value(state) else { + return; + }; + Self::send_event(FrontendEvent::RunStateChanged { state }); + } + pub fn notify_profile_changed(profile_id: &String) { Self::send_event(FrontendEvent::ProfileChanged { current_profile_id: profile_id, diff --git a/src-tauri/src/core/listener.rs b/src-tauri/src/core/listener.rs index 82ba97fe8..1d01f7a7a 100644 --- a/src-tauri/src/core/listener.rs +++ b/src-tauri/src/core/listener.rs @@ -8,8 +8,15 @@ use std::{ str::FromStr as _, }; +/// The config key of the Mixed Port, named once so callers stop spelling it out. +pub(crate) const MIXED_PORT_KEY: &str = "mixed-port"; + const PROXY_LISTENERS: [(&str, &str, &[ListenerTransport]); 5] = [ - ("mixed-port", "mixed", &[ListenerTransport::Tcp, ListenerTransport::Udp]), + ( + MIXED_PORT_KEY, + "mixed", + &[ListenerTransport::Tcp, ListenerTransport::Udp], + ), ("socks-port", "socks", &[ListenerTransport::Tcp, ListenerTransport::Udp]), ("port", "http", &[ListenerTransport::Tcp]), ("redir-port", "redir", &[ListenerTransport::Tcp]), @@ -20,6 +27,14 @@ const PROXY_LISTENERS: [(&str, &str, &[ListenerTransport]); 5] = [ ), ]; +/// The config key of every proxy listener. +/// +/// Callers that need "every listener except one" filter this rather than keeping their own +/// list, so adding a listener cannot be half-applied. +pub(crate) fn proxy_listener_keys() -> impl Iterator { + PROXY_LISTENERS.iter().map(|(key, _, _)| *key) +} + #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct ProxyPortSettings { diff --git a/src-tauri/src/core/manager/config.rs b/src-tauri/src/core/manager/config.rs index a5b8435d6..d2cc60dc3 100644 --- a/src-tauri/src/core/manager/config.rs +++ b/src-tauri/src/core/manager/config.rs @@ -1,4 +1,5 @@ use super::{CoreManager, RunningMode}; +use crate::core::service::StageRequest; use crate::{ config::{Config, ConfigType, runtime::IRuntime}, constants::timing, @@ -9,12 +10,96 @@ use crate::{ utils::{dirs, help}, }; use anyhow::{Result, anyhow}; +use clash_verge_draft::DraftTransaction; use clash_verge_logging::{Type, logging}; +use clash_verge_service_ipc::StageRuntimeOutcome; use scopeguard::defer; use smartstring::alias::String; use std::{collections::HashSet, path::PathBuf, time::Instant}; use tauri_plugin_mihomo::Error as MihomoError; +/// What came back from asking the Service to stage a runtime. +/// +/// Separated from the decision below so the decision stays a pure function of it. The variants are +/// the four ways an attempt can end, and what distinguishes them is how much they let us conclude: +/// a refusal is the Service's verdict on this bundle, while silence says nothing at all. +#[derive(Debug, PartialEq, Eq)] +enum StageAttempt { + /// The Service that owns the running Core predates staging. + Unsupported, + /// The Service answered, and its answer was a refusal about the bundle itself — the kind a + /// fresh start would run into again, because it would materialise the same bundle. + RefusedTheBundle(String), + /// The Service answered with a refusal about something other than the bundle: the session, the + /// protocol, the request. Starting is how several of those get resolved, so it is not a reason + /// to leave the Core where it is. + RefusedForAnotherReason(String), + /// The request did not come back. The Service is authoritative about its own runtime, so + /// without an answer nothing may be assumed about what it did or did not write. + Unanswered(String), + Answered(StageRuntimeOutcome), +} + +/// How the Core should be made to pick up a configuration. +#[derive(Debug, PartialEq, Eq)] +enum ConfigApplication { + /// Point the running Core at this path. + ReloadFrom(String), + /// Stop the Core and start it again from a freshly materialised runtime. + ReplaceCore, + /// Leave the Core alone and report why the configuration cannot be applied. + Fail(String), +} + +/// Decide how to apply a configuration, given how staging went. +/// +/// Two branches avoid replacing the Core, for opposite reasons. One is success. The other is a +/// refusal *about the bundle*: the Service inspected it and would not take it, and a fresh start +/// materialises the same bundle — so it would fail the same way, having stopped a Core that was +/// working. +/// +/// The distinction matters more than it looks. A refusal about the session is not a refusal about +/// the bundle: starting proposes a new session rather than presenting the old one, and the restart +/// path is also what notices that ownership was lost and clears the system proxy. Treating every +/// non-zero code alike would skip that. +/// +/// Everything else — an older Service, a request that never came back, or a Service that asked to +/// be restarted — replaces the Core, which is what this did before staging existed and what the +/// Service guarantees is still safe after every way of declining. +fn plan_config_application(attempt: &StageAttempt) -> ConfigApplication { + match attempt { + StageAttempt::Answered(StageRuntimeOutcome::Staged { config_path }) => { + ConfigApplication::ReloadFrom(config_path.into()) + } + StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason }) => { + logging!( + info, + Type::Core, + "Service declined to stage the runtime ({reason:?}); replacing the core instead" + ); + ConfigApplication::ReplaceCore + } + StageAttempt::RefusedTheBundle(message) => ConfigApplication::Fail(message.clone()), + StageAttempt::RefusedForAnotherReason(message) => { + logging!( + warn, + Type::Core, + "Service refused to stage the runtime ({message}); replacing the core instead" + ); + ConfigApplication::ReplaceCore + } + StageAttempt::Unanswered(error) => { + logging!( + warn, + Type::Core, + "Failed to stage the service runtime, replacing the core instead: {error}" + ); + ConfigApplication::ReplaceCore + } + StageAttempt::Unsupported => ConfigApplication::ReplaceCore, + } +} + impl CoreManager { pub async fn use_default_config(&self, error_key: &str, error_msg: &str) -> Result<()> { use crate::constants::files::RUNTIME_CONFIG; @@ -92,13 +177,15 @@ impl CoreManager { } async fn perform_config_update(&self) -> Result { + let runtime = Config::runtime().await; + let transaction = DraftTransaction::new(vec![&runtime]); + if let Err(err) = Config::generate().await { let message: String = err.to_string().into(); - Config::runtime().await.discard(); return Ok(ValidationOutcome::invalid_from_message(message)); } - self.apply_generate_config_inner().await + self.validate_and_apply(transaction).await } pub(crate) async fn update_runtime_config(&self, f: F) -> Result @@ -113,75 +200,127 @@ impl CoreManager { self.finish_config_update(); } - Config::runtime().await.edit_draft(f); - self.apply_generate_config_inner().await + let runtime = Config::runtime().await; + let transaction = DraftTransaction::new(vec![&runtime]); + runtime.edit_draft(f); + self.validate_and_apply(transaction).await } - async fn apply_generate_config_inner(&self) -> Result { - match CoreConfigValidator::global().validate_config_outcome().await { - Ok(outcome) if outcome.is_valid() => { - let run_path = Config::generate_file(ConfigType::Run).await?; - self.apply_config(run_path).await?; - Ok(ValidationOutcome::Valid) - } - Ok(outcome) => { - Config::runtime().await.discard(); - Ok(outcome) - } - Err(e) => { - Config::runtime().await.discard(); - Err(e) - } + /// Validate the staged Runtime Config and hand it to the Core, committing only if both work. + /// + /// Takes the transaction rather than opening one, so it covers the staging its callers did. + /// Every way out of here other than the last line rolls that staging back. + async fn validate_and_apply(&self, transaction: DraftTransaction<'_>) -> Result { + let outcome = CoreConfigValidator::global().validate_config_outcome().await?; + if !outcome.is_valid() { + return Ok(outcome); } + + let run_path = Config::generate_file(ConfigType::Run).await?; + self.apply_config(run_path).await?; + transaction.commit(); + Ok(ValidationOutcome::Valid) } + /// Hand the generated configuration to the Core. + /// + /// Says nothing about drafts: whether the staged Runtime Config is kept follows from + /// whether this succeeded, and the caller's transaction decides that. + /// + /// Both modes have the same shape — put the configuration where the Core can read it, ask the + /// Core to reload, restart it if that did not work. Only the first step differs: in Sidecar + /// mode the Core already reads the app's own directory, while in Service mode the Service has + /// to materialise the configuration into the directory it started the Core in. async fn apply_config(&self, path: PathBuf) -> Result<()> { if matches!(*self.get_running_mode(), RunningMode::Service) { let _lifecycle = self.lifecycle_lock.lock().await; if !matches!(*self.get_running_mode(), RunningMode::Service) { - Config::runtime().await.discard(); return Err(anyhow!("core mode changed while applying service configuration")); } - let result = self.replace_service_core_with_config(&path).await; - - return match result { - Ok(()) => { - Config::runtime().await.apply(); - logging!(info, Type::Core, "Configuration materialized and applied by service"); - Ok(()) - } - Err(error) => { - Config::runtime().await.discard(); - Err(error) - } - }; + return self.apply_config_by_service(&path).await; } let path = dirs::path_to_str(&path)?; - match self.reload_config(path).await { - Ok(_) => { - Config::runtime().await.apply(); - logging!(info, Type::Core, "Configuration applied"); - Ok(()) - } - Err(err) => { + self.reload_or_restart(path).await + } + + /// Apply a configuration in Service mode, staging it in place when that is possible. + /// + /// Every way of not staging leads to the same place it always led: stop the Core and start it + /// again from a freshly materialised runtime. That is what makes declining safe — the Service + /// leaves the runtime it could not stage exactly as the running Core left it. + /// + /// Caller must hold `lifecycle_lock`. + async fn apply_config_by_service(&self, path: &std::path::Path) -> Result<()> { + match plan_config_application(&self.attempt_staging(path).await) { + ConfigApplication::Fail(message) => { + // The Service looked at this bundle and would not take it. Replacing the Core would + // hand it the same bundle, so the outage would buy nothing. logging!( warn, Type::Core, - "Failed to apply configuration by mihomo api, restart core to apply it, error msg: {err}" + "Service refused the runtime, leaving the core running: {message}" ); - match self.restart_core().await { - Ok(_) => { - Config::runtime().await.apply(); - logging!(info, Type::Core, "Configuration applied after restart"); - Ok(()) - } - Err(err) => { - logging!(error, Type::Core, "Failed to restart core: {}", err); - Config::runtime().await.discard(); - Err(anyhow!("Failed to apply config: {}", err)) - } + return Err(anyhow!("{message}")); + } + ConfigApplication::ReloadFrom(staged) => match self.reload_config(&staged).await { + Ok(()) => { + logging!(info, Type::Core, "Configuration staged and applied by service"); + return Ok(()); } + Err(err) => logging!( + warn, + Type::Core, + "Failed to reload the staged service runtime, replacing the core instead: {err}" + ), + }, + ConfigApplication::ReplaceCore => {} + } + + self.replace_service_core_with_config(path).await?; + logging!(info, Type::Core, "Configuration materialized and applied by service"); + Ok(()) + } + + /// Ask the Service to stage the runtime, reporting the attempt rather than judging it. + async fn attempt_staging(&self, path: &std::path::Path) -> StageAttempt { + if !crate::core::service::active_service_supports_runtime_staging() { + return StageAttempt::Unsupported; + } + match crate::core::service::stage_runtime_by_service(path).await { + Ok(StageRequest::Answered(outcome)) => StageAttempt::Answered(outcome), + Ok(StageRequest::Refused { code, message }) => { + let message = message.to_string().into(); + if StageRequest::is_about_the_bundle(code) { + StageAttempt::RefusedTheBundle(message) + } else { + StageAttempt::RefusedForAnotherReason(message) + } + } + Err(err) => StageAttempt::Unanswered(format!("{err:#}").into()), + } + } + + /// Reload the Core from `path`, and replace the Core if it will not take it. + async fn reload_or_restart(&self, path: &str) -> Result<()> { + let Err(err) = self.reload_config(path).await else { + logging!(info, Type::Core, "Configuration applied"); + return Ok(()); + }; + + logging!( + warn, + Type::Core, + "Failed to apply configuration by mihomo api, restart core to apply it, error msg: {err}" + ); + match self.restart_core().await { + Ok(_) => { + logging!(info, Type::Core, "Configuration applied after restart"); + Ok(()) + } + Err(err) => { + logging!(error, Type::Core, "Failed to restart core: {}", err); + Err(anyhow!("Failed to apply config: {}", err)) } } } @@ -190,3 +329,105 @@ impl CoreManager { handle::Handle::mihomo().await.reload_config(true, path).await } } + +#[cfg(test)] +mod tests { + use super::{ConfigApplication, StageAttempt, StageRequest, plan_config_application}; + use clash_verge_service_ipc::{StageRejection, StageRuntimeOutcome}; + + #[test] + fn a_staged_runtime_is_reloaded_from_where_the_service_put_it() { + let attempt = StageAttempt::Answered(StageRuntimeOutcome::Staged { + config_path: "/service/runtime.generation-1/config.yaml".to_owned(), + }); + + assert_eq!( + plan_config_application(&attempt), + ConfigApplication::ReloadFrom("/service/runtime.generation-1/config.yaml".into()), + "the core must be pointed at the service's copy, never at the app's own" + ); + } + + #[test] + fn a_service_that_declined_makes_the_core_be_replaced() { + for reason in [ + StageRejection::CoreNotRunning, + StageRejection::CorePathChanged, + StageRejection::RuntimeUnwritable { + detail: "held open".to_owned(), + }, + ] { + let attempt = StageAttempt::Answered(StageRuntimeOutcome::RestartRequired { reason: reason.clone() }); + + assert_eq!( + plan_config_application(&attempt), + ConfigApplication::ReplaceCore, + "declining is an outcome, not an error: {reason:?} must fall back, not fail" + ); + } + } + + #[test] + fn a_service_too_old_to_stage_makes_the_core_be_replaced() { + assert_eq!( + plan_config_application(&StageAttempt::Unsupported), + ConfigApplication::ReplaceCore, + "an installed service without staging must keep working, not demand a reinstall" + ); + } + + #[test] + fn a_refusal_about_the_bundle_leaves_the_core_alone() { + // Replacing the core would materialise the same bundle the Service just rejected, so the + // outage buys nothing: the configuration update fails either way, and this way the user's + // proxy is still working when it does. + assert_eq!( + plan_config_application(&StageAttempt::RefusedTheBundle("runtime asset is unavailable".into())), + ConfigApplication::Fail("runtime asset is unavailable".into()) + ); + } + + #[test] + fn a_refusal_about_anything_else_still_replaces_the_core() { + // A stale session is the case that matters: starting proposes a new one, so it is the cure + // rather than a repeat of the failure — and the restart path is also what notices ownership + // was lost and clears the system proxy. + assert_eq!( + plan_config_application(&StageAttempt::RefusedForAnotherReason("owner session is stale".into())), + ConfigApplication::ReplaceCore + ); + } + + #[test] + fn only_bundle_codes_count_as_a_refusal_of_the_bundle() { + use clash_verge_service_ipc::ServiceErrorCode; + + assert!(StageRequest::is_about_the_bundle( + ServiceErrorCode::InvalidRuntimeAsset as u16 + )); + assert!(StageRequest::is_about_the_bundle( + ServiceErrorCode::InvalidInstallLocation as u16 + )); + for other in [ + ServiceErrorCode::StaleOwnerSession, + ServiceErrorCode::UnauthorizedOwner, + ServiceErrorCode::ProtocolMismatch, + ServiceErrorCode::NotActive, + ] { + assert!( + !StageRequest::is_about_the_bundle(other as u16), + "{other:?} says nothing about the bundle, so starting must still be tried" + ); + } + } + + #[test] + fn a_request_that_never_came_back_makes_the_core_be_replaced() { + // Not knowing what the service did is the one case where reloading would be a guess: the + // staged path might not exist, or might hold the previous configuration. + assert_eq!( + plan_config_application(&StageAttempt::Unanswered("connection reset".into())), + ConfigApplication::ReplaceCore + ); + } +} diff --git a/src-tauri/src/core/manager/lifecycle.rs b/src-tauri/src/core/manager/lifecycle.rs index f40fe54cc..1eeeb4f7c 100644 --- a/src-tauri/src/core/manager/lifecycle.rs +++ b/src-tauri/src/core/manager/lifecycle.rs @@ -10,7 +10,6 @@ use clash_verge_logging::{Type, logging}; use scopeguard::defer; use smartstring::alias::String; use std::path::Path; -use tauri_plugin_clash_verge_sysinfo; #[cfg(target_os = "windows")] use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin; @@ -263,9 +262,7 @@ enum HandoffOutcome { impl CoreManager { async fn rollback_failed_start(&self) { proxy_control::stop_guard().await; - crate::utils::server::set_pac_available(false); - self.invalidate_core_readiness(); - self.set_running_mode(RunningMode::NotRunning); + self.core_stopped(); } pub async fn start_core(&self) -> Result<()> { @@ -360,7 +357,6 @@ impl CoreManager { self.stop_sidecar_after_proxy_clear().await?; } self.rollback_failed_start().await; - self.after_core_process(); Ok(()) } @@ -370,7 +366,7 @@ impl CoreManager { /// Replaces a Service-owned core while the caller holds `lifecycle_lock`. pub(super) async fn replace_service_core_with_config(&self, config_file: &Path) -> Result<()> { - let result = run_service_config_replacement_transition( + run_service_config_replacement_transition( cfg!(target_os = "macos"), proxy_control::stop_guard, proxy_control::clear, @@ -382,9 +378,7 @@ impl CoreManager { }, || self.apply_proxy_after_start(), ) - .await; - self.after_core_process(); - result + .await } pub(crate) async fn apply_proxy_after_start(&self) -> Result<()> { @@ -439,16 +433,11 @@ impl CoreManager { let startup = self.prepare_startup().await; if matches!(startup, StartupDecision::Wait) { self.rollback_failed_start().await; - self.after_core_process(); return Ok(()); } - defer! { - self.after_core_process(); - } - // 等待服务期间可能进入退出;未真正启动时回滚状态。 if Handle::global().is_exiting() { - self.set_running_mode(RunningMode::NotRunning); + self.core_stopped(); return Ok(()); } @@ -496,10 +485,6 @@ impl CoreManager { /// 调用者须已持有 `lifecycle_lock`,且已完成受控代理清理。 async fn stop_core_unprepared_inner(&self) -> Result<()> { CLASH_LOGGER.clear_logs().await; - defer! { - self.after_core_process(); - } - match *self.get_running_mode() { RunningMode::Service => self.stop_core_by_service().await, RunningMode::Sidecar => { @@ -560,15 +545,9 @@ impl CoreManager { startup_decision(&SERVICE_MANAGER.current().await, service_required) } - pub(in crate::core) fn after_core_process(&self) { - let app_handle = Handle::app_handle(); - tauri_plugin_clash_verge_sysinfo::set_app_core_mode(app_handle, self.get_running_mode().to_string()); - } - #[cfg(target_os = "windows")] async fn wait_for_service_if_needed(&self) { use crate::{config::Config, constants::timing}; - use backon::{ConstantBuilder, Retryable as _}; let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); let service_ready = matches!(SERVICE_MANAGER.current().await, ServiceStatus::Ready); @@ -585,22 +564,15 @@ impl CoreManager { return; } - let max_times = timing::SERVICE_WAIT_MAX.as_millis() / timing::SERVICE_WAIT_INTERVAL.as_millis(); - let backoff = ConstantBuilder::default() - .with_delay(timing::SERVICE_WAIT_INTERVAL) - .with_max_times(max_times as usize); + if crate::core::runstate::RUN_STATE.state().service_usable() { + return; + } - let wait_for_ready = (|| async { - if SERVICE_MANAGER.is_ready_cached() { - return Ok(()); - } - - // Probe the service directly. A transient transport failure does not overwrite - // the last confirmed install state and is retried within the startup deadline. - SERVICE_MANAGER.confirm_ready().await - }) - .retry(backoff); - let _ = tokio::time::timeout(timing::SERVICE_WAIT_MAX, wait_for_ready).await; + // Failing to become ready is not fatal here: the caller falls back to Sidecar, and a + // transient transport failure must not overwrite the last confirmed install state. + let attempts = (timing::SERVICE_WAIT_MAX.as_millis() / timing::SERVICE_WAIT_INTERVAL.as_millis()) as usize; + let wait = crate::core::runstate::RUN_STATE.await_ready(attempts, timing::SERVICE_WAIT_INTERVAL); + let _ = tokio::time::timeout(timing::SERVICE_WAIT_MAX, wait).await; } /// 在窗口内等待服务就绪,再从 sidecar 交接到 service @@ -668,7 +640,7 @@ impl CoreManager { if SERVICE_MANAGER.confirm_ready().await.is_err() { return false; } - SERVICE_MANAGER.is_ready_cached() + crate::core::runstate::RUN_STATE.state().service_usable() } /// 服务就绪后停止 sidecar,再以 service 重启内核 @@ -1135,9 +1107,9 @@ mod tests { async fn service_config_replacement_is_controlled_and_generation_safe_on_every_platform() { for is_macos in [false, true] { let calls = Arc::new(Mutex::new(Vec::new())); - let manager = CoreManager::default(); + let manager = CoreManager::isolated(); let old_readiness_generation = manager.mark_core_ready(); - manager.set_running_mode(RunningMode::Service); + manager.core_started(RunningMode::Service); let owner_monitor_generation = AtomicU64::new(1); let old_owner_monitor_generation = owner_monitor_generation.load(Ordering::Acquire); @@ -1151,14 +1123,14 @@ mod tests { || { calls.lock().push("helper-stop"); owner_monitor_generation.fetch_add(1, Ordering::AcqRel); - manager.set_running_mode(RunningMode::NotRunning); + manager.core_stopped(); future::ready(Ok(())) }, || { calls.lock().push("service-start"); owner_monitor_generation.fetch_add(1, Ordering::AcqRel); manager.mark_core_ready(); - manager.set_running_mode(RunningMode::Service); + manager.core_started(RunningMode::Service); if owner_monitor_generation .compare_exchange( old_owner_monitor_generation, @@ -1304,12 +1276,12 @@ mod tests { #[tokio::test] async fn failed_start_rolls_back_even_from_service_mode() { - let manager = CoreManager::default(); - manager.set_running_mode(RunningMode::Service); + let manager = CoreManager::isolated(); + manager.core_started(RunningMode::Service); manager.rollback_failed_start().await; assert_eq!(*manager.get_running_mode(), RunningMode::NotRunning); - manager.set_running_mode(RunningMode::Sidecar); + manager.core_started(RunningMode::Sidecar); manager.rollback_failed_start().await; assert_eq!(*manager.get_running_mode(), RunningMode::NotRunning); } diff --git a/src-tauri/src/core/manager/mod.rs b/src-tauri/src/core/manager/mod.rs index eb8dc9806..a91167c1a 100644 --- a/src-tauri/src/core/manager/mod.rs +++ b/src-tauri/src/core/manager/mod.rs @@ -17,6 +17,7 @@ use std::{ }; use tauri_plugin_shell::process::CommandChild; +use crate::core::runstate::{RUN_STATE, RealEnv, RunStateStore}; use crate::singleton; #[cfg(target_os = "windows")] use std::os::windows::io::OwnedHandle; @@ -72,6 +73,11 @@ impl fmt::Display for RunningMode { #[derive(Debug)] pub struct CoreManager { + /// The Run State this manager reports transitions to. + /// + /// A reference rather than the global directly, so a test can supervise a Core without + /// racing every other test for one process-wide Running Mode. + run_state: &'static RunStateStore, state: ArcSwap, last_update: ArcSwapOption, #[cfg(target_os = "windows")] @@ -86,24 +92,19 @@ pub struct CoreManager { handoff_watcher_running: AtomicBool, } -#[derive(Debug)] +/// Process-level state owned by `CoreManager`. +/// +/// Running Mode deliberately does *not* live here — it belongs to `core::runstate`, which +/// keeps it consistent with Service Health and derives PAC availability from it. +#[derive(Debug, Default)] struct State { - running_mode: ArcSwap, child_sidecar: ArcSwapOption, } -impl Default for State { - fn default() -> Self { - Self { - running_mode: ArcSwap::new(Arc::new(RunningMode::NotRunning)), - child_sidecar: ArcSwapOption::new(None), - } - } -} - impl Default for CoreManager { fn default() -> Self { Self { + run_state: &RUN_STATE, state: ArcSwap::new(Arc::new(State::default())), last_update: ArcSwapOption::new(None), #[cfg(target_os = "windows")] @@ -122,8 +123,17 @@ impl CoreManager { Self::default() } + /// A manager with its own Run State, for tests that must not disturb the process-wide one. + #[cfg(test)] + fn isolated() -> Self { + Self { + run_state: Box::leak(Box::new(RunStateStore::new(RealEnv))), + ..Self::default() + } + } + pub fn get_running_mode(&self) -> Arc { - Arc::clone(&self.state.load().running_mode.load()) + self.run_state.mode_arc() } pub fn take_child_sidecar(&self) -> Option { @@ -142,12 +152,26 @@ impl CoreManager { self.last_update.load_full() } - pub fn set_running_mode(&self, mode: RunningMode) { - if matches!(mode, RunningMode::NotRunning) { - self.invalidate_core_readiness(); - } - let state = self.state.load(); - state.running_mode.store(Arc::new(mode)); + /// The Core is now running in `mode`. + /// + /// Run State derives PAC availability and the outward mode mirror from this; callers must + /// not set those alongside. + pub fn core_started(&self, mode: RunningMode) { + self.run_state.core_started(mode); + } + + /// The Core is no longer running, for any reason. + /// + /// Core readiness is invalidated here rather than inside Run State because readiness + /// belongs to the process this manager supervises. + pub fn core_stopped(&self) { + self.invalidate_core_readiness(); + self.run_state.core_stopped(); + } + + /// A start attempt is under way and the Core is not serving yet. + pub fn core_starting(&self) { + self.run_state.core_starting(); } pub fn set_running_child_sidecar(&self, child: CommandChild) { diff --git a/src-tauri/src/core/manager/state.rs b/src-tauri/src/core/manager/state.rs index 221780168..455de2e6f 100644 --- a/src-tauri/src/core/manager/state.rs +++ b/src-tauri/src/core/manager/state.rs @@ -6,7 +6,7 @@ use crate::{ config::Config, core::{handle, logger::Logger, manager::CLASH_LOGGER, proxy_control, service}, logging, - utils::{dirs, server}, + utils::dirs, }; use anyhow::{Context as _, Result}; use clash_verge_logging::Type; @@ -75,8 +75,7 @@ impl CoreManager { pub(super) async fn start_core_by_sidecar(&self) -> Result<()> { logging!(info, Type::Core, "Starting core in sidecar mode"); - server::set_pac_available(false); - self.set_running_mode(RunningMode::NotRunning); + self.core_stopped(); let sidecar_ipc = dirs::sidecar_ipc_path()?; handle::Handle::app_handle() @@ -149,9 +148,7 @@ impl CoreManager { .await; if let Err(readiness_error) = readiness { proxy_control::stop_guard().await; - self.invalidate_core_readiness(); - self.set_running_mode(RunningMode::NotRunning); - server::set_pac_available(false); + self.core_stopped(); return match child.kill() { Ok(()) => Err(readiness_error), Err(kill_error) => Err(anyhow::anyhow!( @@ -164,8 +161,7 @@ impl CoreManager { self.set_job_handle(Some(job)); self.set_running_child_sidecar(child); let core_readiness_generation = self.mark_core_ready(); - self.set_running_mode(RunningMode::Sidecar); - server::set_pac_available(true); + self.core_started(RunningMode::Sidecar); AsyncHandler::spawn(move || async move { while let Some(event) = rx.recv().await { @@ -204,8 +200,7 @@ impl CoreManager { pub(super) fn stop_core_by_sidecar_unprepared(&self) { logging!(info, Type::Core, "Stopping sidecar"); defer! { - server::set_pac_available(false); - self.set_running_mode(RunningMode::NotRunning); + self.core_stopped(); } if let Some(child) = self.take_child_sidecar() { let pid = child.pid(); @@ -236,7 +231,7 @@ impl CoreManager { pub(super) async fn start_core_by_service(&self) -> Result<()> { logging!(info, Type::Core, "Starting core in service mode"); - server::set_pac_available(false); + self.core_starting(); let service_ipc = dirs::ipc_path()?; let config_file = Config::generate_file(crate::config::ConfigType::Run).await?; handle::Handle::app_handle() @@ -258,7 +253,7 @@ impl CoreManager { match service::run_core_by_service(config_file).await { Ok(()) => { self.mark_core_ready(); - self.set_running_mode(RunningMode::Service); + self.core_started(RunningMode::Service); return Ok(()); } Err(e) => { @@ -282,7 +277,7 @@ impl CoreManager { { service::run_core_by_service(config_file).await?; self.mark_core_ready(); - self.set_running_mode(RunningMode::Service); + self.core_started(RunningMode::Service); Ok(()) } } @@ -290,8 +285,7 @@ impl CoreManager { pub(super) async fn stop_core_by_service(&self) -> Result<()> { logging!(info, Type::Core, "Stopping service"); service::stop_core_by_service().await?; - server::set_pac_available(false); - self.set_running_mode(RunningMode::NotRunning); + self.core_stopped(); Ok(()) } @@ -305,10 +299,7 @@ impl CoreManager { #[cfg(target_os = "windows")] self.set_job_handle(None); proxy_control::stop_guard().await; - server::set_pac_available(false); - self.invalidate_core_readiness(); - self.set_running_mode(RunningMode::NotRunning); - self.after_core_process(); + self.core_stopped(); } } @@ -374,9 +365,9 @@ mod readiness_tests { #[test] fn invalidated_core_readiness_cannot_be_recaptured_from_stale_mode() { - let manager = CoreManager::default(); + let manager = CoreManager::isolated(); manager.mark_core_ready(); - manager.set_running_mode(RunningMode::Service); + manager.core_started(RunningMode::Service); manager.invalidate_core_readiness(); diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 465c76593..c718c41f2 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -9,6 +9,7 @@ mod notification; pub(crate) mod owner_identity; pub mod proxy_control; pub mod proxy_view; +pub mod runstate; mod runtime_bundle; pub mod service; pub mod sysopt; diff --git a/src-tauri/src/core/notification.rs b/src-tauri/src/core/notification.rs index 0c6263039..f6ebadabd 100644 --- a/src-tauri/src/core/notification.rs +++ b/src-tauri/src/core/notification.rs @@ -14,6 +14,7 @@ pub enum FrontendEvent<'a> { TimerUpdated { profile_index: &'a String }, ProfileUpdateStarted { uid: &'a String }, ProfileUpdateCompleted { uid: &'a String }, + RunStateChanged { state: serde_json::Value }, } #[derive(Debug)] @@ -38,6 +39,7 @@ impl NotificationSystem { FrontendEvent::TimerUpdated { profile_index } => ("verge://timer-updated", Ok(json!(profile_index))), FrontendEvent::ProfileUpdateStarted { uid } => ("profile-update-started", Ok(json!({ "uid": uid }))), FrontendEvent::ProfileUpdateCompleted { uid } => ("profile-update-completed", Ok(json!({ "uid": uid }))), + FrontendEvent::RunStateChanged { state } => ("verge://run-state-changed", Ok(state)), } } diff --git a/src-tauri/src/core/proxy_control.rs b/src-tauri/src/core/proxy_control.rs index 4a00b526d..2d2db4d50 100644 --- a/src-tauri/src/core/proxy_control.rs +++ b/src-tauri/src/core/proxy_control.rs @@ -1,5 +1,5 @@ use crate::{ - config::{Config, IVerge}, + config::{Config, IVerge, MixedPort}, core::{CoreManager, manager::RunningMode, service, sysopt::Sysopt}, process::AsyncHandler, utils::server, @@ -172,10 +172,8 @@ async fn current_service_proxy_config(verge: &IVerge) -> Result port, - None => Config::clash().await.latest_arc().get_mixed_port(), - }; + // Configured: this builds the proxy settings handed to the service alongside a start. + let mixed_port = MixedPort::desired().await; service_proxy_config(verge, mixed_port, 0) } diff --git a/src-tauri/src/core/runstate/env.rs b/src-tauri/src/core/runstate/env.rs new file mode 100644 index 000000000..998731fd7 --- /dev/null +++ b/src-tauri/src/core/runstate/env.rs @@ -0,0 +1,283 @@ +//! The seam between Run State and the machine it observes. +//! +//! Two adapters satisfy it: [`RealEnv`] talks to the Service over IPC and to the platform's +//! service registry, [`FakeEnv`] replays scripted answers. Because the store is generic over +//! this trait rather than reaching for globals, the state machine can be exercised without +//! a running app, an installed Service, or elevation. + +use anyhow::Result; + +use super::health::{PendingAction, RunState}; +use super::probe::ServiceVersionReply; + +/// Everything Run State needs from outside itself. +pub trait RunStateEnv: Send + Sync + 'static { + /// Ask the Service which protocol it speaks. Fails when it cannot be reached at all. + fn probe_service_version(&self) -> impl Future> + Send; + + /// Whether the platform's service registry has a trusted record of an installation. + /// + /// Fails when the registry itself could not be inspected, which is different from + /// "no installation" and is treated as unavailable rather than not-installed. + fn trusted_install_evidence(&self) -> Result; + + /// Whether this app process is running elevated. + fn is_elevated(&self) -> bool; + + /// Open or close the local PAC endpoint. + /// + /// Derived from the Running Mode, never set independently: a PAC script that points at a + /// proxy port nothing is listening on is worse than no PAC at all. + fn set_pac_available(&self, available: bool); + + /// Publish the Run State to observers outside the store. + /// + /// Two of them today: the diagnostic snapshot, which reports the Running Mode, and the + /// frontend, which gets the whole thing pushed rather than polling for it. + fn publish(&self, state: &RunState); + + /// Carry out a privileged operation against the Service. + /// + /// Blocking and platform-specific — SCM, systemd, launchd, elevation prompts — which is + /// exactly why it sits behind the seam: the state machine around it can then be exercised + /// without installing anything. + fn run_privileged(&self, action: PendingAction) -> Result<()>; +} + +/// The production adapter: real IPC, real platform registry, real elevation check. +#[derive(Debug, Default, Clone, Copy)] +pub struct RealEnv; + +impl RunStateEnv for RealEnv { + async fn probe_service_version(&self) -> Result { + let response = clash_verge_service_ipc::get_version().await?; + Ok(ServiceVersionReply { + code: response.code, + message: response.message, + protocol: response.data, + }) + } + + fn trusted_install_evidence(&self) -> Result { + crate::core::service::trusted_service_evidence() + } + + fn is_elevated(&self) -> bool { + // Deliberately non-panicking, unlike `Handle::app_handle()`: Run State is read from + // startup paths that run before the app handle exists, and "not yet known" must + // degrade to "not elevated" rather than abort the process. + crate::APP_HANDLE + .get() + .is_some_and(tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin) + } + + fn set_pac_available(&self, available: bool) { + crate::utils::server::set_pac_available(available); + } + + fn publish(&self, state: &RunState) { + // Reconciling capability is a reaction to the new state, not part of publishing it, so + // it is spawned: a transition must not wait on a config write it triggered. It is also + // spawned *before* the app-handle gate below, because a Core that stops and starts + // before the app handle exists still needs reconciling; the task re-reads the Run + // State itself rather than closing over this snapshot. + crate::process::AsyncHandler::spawn(|| async { + crate::feat::reconcile_tun_availability().await; + }); + + // Non-panicking for the same reason as `is_elevated`: the Core can stop and start + // before the app handle exists, and a missed notification is better than an abort. + let Some(app_handle) = crate::APP_HANDLE.get() else { + return; + }; + tauri_plugin_clash_verge_sysinfo::set_app_core_mode(app_handle, state.mode.to_string()); + crate::core::handle::Handle::notify_run_state(&state.to_view()); + } + + fn run_privileged(&self, action: PendingAction) -> Result<()> { + crate::core::service::run_privileged_service_action(action) + } +} + +#[cfg(test)] +pub use fake::FakeEnv; + +#[cfg(test)] +mod fake { + use anyhow::{Result, anyhow}; + use clash_verge_service_ipc::ProtocolInfo; + use parking_lot::Mutex; + + use super::{PendingAction, RunState, RunStateEnv, ServiceVersionReply}; + use crate::core::manager::RunningMode; + + /// A scripted stand-in for the machine. + /// + /// Defaults to the least capable environment — no Service, no elevation — so a test only + /// has to say what it needs. Outbound effects are recorded rather than performed. + #[derive(Debug)] + pub struct FakeEnv { + version_replies: Mutex>>, + evidence: Result, + elevated: bool, + probe_count: Mutex, + pac_available: Mutex>, + published: Mutex>, + privileged_outcome: Mutex>, + privileged_actions: Mutex>, + } + + impl Default for FakeEnv { + fn default() -> Self { + Self { + version_replies: Mutex::new(Vec::new()), + evidence: Ok(false), + elevated: false, + probe_count: Mutex::new(0), + pac_available: Mutex::new(None), + published: Mutex::new(Vec::new()), + privileged_outcome: Mutex::new(Ok(())), + privileged_actions: Mutex::new(Vec::new()), + } + } + } + + impl FakeEnv { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// An installed, reachable, protocol-compatible Service. + #[must_use] + pub fn service_ready(self) -> Self { + self.with_evidence(true).always_replying(Ok(ServiceVersionReply { + code: 0, + message: "ok".to_owned(), + protocol: Some(ProtocolInfo::current()), + })) + } + + /// An installed Service that answers with an incompatible protocol. + #[must_use] + pub fn service_version_mismatch(self) -> Self { + self.with_evidence(true).always_replying(Ok(ServiceVersionReply { + code: 0, + message: "ok".to_owned(), + protocol: None, + })) + } + + /// Registered with the platform but not answering. + #[must_use] + pub fn service_unreachable(self) -> Self { + self.with_evidence(true) + .always_replying(Err("ipc transport refused".to_owned())) + } + + /// The platform registry itself could not be inspected. + #[must_use] + pub fn evidence_unavailable(mut self) -> Self { + self.evidence = Err("registry probe failed".to_owned()); + self + } + + #[must_use] + pub const fn elevated(mut self) -> Self { + self.elevated = true; + self + } + + #[must_use] + pub fn with_evidence(mut self, exists: bool) -> Self { + self.evidence = Ok(exists); + self + } + + /// Queue replies consumed one per probe; the last one repeats once exhausted. + #[must_use] + pub fn replying(self, replies: Vec>) -> Self { + *self.version_replies.lock() = replies; + self + } + + #[must_use] + pub fn always_replying(self, reply: Result) -> Self { + self.replying(vec![reply]) + } + + /// How many times the Service was probed — asserts that retries actually retried. + #[must_use] + pub fn probe_count(&self) -> usize { + *self.probe_count.lock() + } + + /// The last PAC availability the store asked for, or `None` if it never asked. + #[must_use] + pub fn pac_available(&self) -> Option { + *self.pac_available.lock() + } + + /// Every Running Mode published outward, in order. + #[must_use] + pub fn published_modes(&self) -> Vec { + self.published.lock().iter().map(|state| state.mode).collect() + } + + /// Every Run State published outward, in order. + #[must_use] + pub fn published(&self) -> Vec { + self.published.lock().clone() + } + + /// Make every privileged operation fail with `reason`. + #[must_use] + pub fn privileged_operations_fail(self, reason: &str) -> Self { + *self.privileged_outcome.lock() = Err(reason.to_owned()); + self + } + + /// Every privileged operation actually attempted, in order. + #[must_use] + pub fn privileged_actions(&self) -> Vec { + self.privileged_actions.lock().clone() + } + } + + impl RunStateEnv for FakeEnv { + async fn probe_service_version(&self) -> Result { + *self.probe_count.lock() += 1; + let mut replies = self.version_replies.lock(); + let reply = if replies.len() > 1 { + replies.remove(0) + } else { + replies + .first() + .cloned() + .unwrap_or_else(|| Err("no service configured".to_owned())) + }; + reply.map_err(|error| anyhow!(error)) + } + + fn trusted_install_evidence(&self) -> Result { + self.evidence.clone().map_err(|error| anyhow!(error)) + } + + fn is_elevated(&self) -> bool { + self.elevated + } + + fn set_pac_available(&self, available: bool) { + *self.pac_available.lock() = Some(available); + } + + fn publish(&self, state: &RunState) { + self.published.lock().push(state.clone()); + } + + fn run_privileged(&self, action: PendingAction) -> Result<()> { + self.privileged_actions.lock().push(action); + self.privileged_outcome.lock().clone().map_err(|error| anyhow!(error)) + } + } +} diff --git a/src-tauri/src/core/runstate/health.rs b/src-tauri/src/core/runstate/health.rs new file mode 100644 index 000000000..4d6958065 --- /dev/null +++ b/src-tauri/src/core/runstate/health.rs @@ -0,0 +1,393 @@ +//! Run State value types. +//! +//! The central distinction: [`ServiceHealth`] is what we *observed* about the Service, +//! [`PendingAction`] is what this session *asked for*. The legacy `ServiceStatus` enum +//! conflated the two into one slot, which is why narrowing it for the frontend had to be +//! lossy. Nothing narrows now: [`RunStateView`] carries both, plus the derived answers. +//! +//! This module owns state only. Starting, stopping and restarting the Core stay in +//! `CoreManager`: a module that both observes Service Health and drives the Core has no point +//! at which it is consistent, because a snapshot taken mid-restart would describe a transition +//! rather than a state. + +use crate::core::manager::RunningMode; + +/// What was last observed about the Service. A fact about the machine, never a request. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum ServiceHealth { + /// Not probed yet. + #[default] + Unknown, + /// Installed, reachable, and speaking a compatible protocol. + Ready, + /// No trusted installation evidence on this machine. + NotInstalled, + /// Installed but speaking an incompatible protocol; needs reinstall. + VersionMismatch, + /// Installed but unusable, with the reason we recorded. + Unavailable(String), +} + +impl ServiceHealth { + /// The variant name alone, for callers that carry the reason separately. + const fn kind(&self) -> &'static str { + match self { + Self::Unknown => "unknown", + Self::Ready => "ready", + Self::NotInstalled => "notInstalled", + Self::VersionMismatch => "versionMismatch", + Self::Unavailable(_) => "unavailable", + } + } + + fn reason(&self) -> Option { + match self { + Self::Unavailable(reason) => Some(reason.clone()), + _ => None, + } + } +} + +/// A privileged operation this session asked for against the Service. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub enum PendingAction { + Install, + Uninstall, + Reinstall, + ForceReinstall, +} + +/// The single consistent answer to "how is the Core running, and what backs it". +/// +/// Every question about Core availability is answered by a method here, so that no +/// caller writes its own formula. Construct one via `RunStateStore::state`/`settled`/`probe`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RunState { + pub health: ServiceHealth, + pub pending: Option, + pub sidecar_allowed: bool, + pub mode: RunningMode, + pub is_admin: bool, + /// A privileged operation is currently in flight, so `health` may be stale. + pub op_in_flight: bool, +} + +impl RunState { + /// The Service reported itself ready the last time we looked. + /// + /// Says nothing about in-flight operations — prefer [`Self::service_usable`] when + /// deciding whether to rely on the Service right now. + #[must_use] + pub const fn service_ready(&self) -> bool { + matches!(self.health, ServiceHealth::Ready) + } + + /// The Service can be relied upon right now: ready, and not mid-operation. + #[must_use] + pub const fn service_usable(&self) -> bool { + self.service_ready() && !self.op_in_flight + } + + /// TUN mode can work right now — either the app is elevated, or the Service backs it. + #[must_use] + pub const fn tun_capable(&self) -> bool { + self.is_admin || self.service_usable() + } + + /// The Service cannot be used and the user has to choose what to do about it. + /// + /// A requested action always needs an answer. Otherwise it is only the states where the + /// Service is present but unusable: a Service that is simply absent, or a session that has + /// already settled on Sidecar, needs nothing from anyone. + #[must_use] + pub const fn service_needs_attention(&self) -> bool { + if self.pending.is_some() { + return true; + } + if self.sidecar_allowed { + return false; + } + matches!( + self.health, + ServiceHealth::VersionMismatch | ServiceHealth::Unavailable(_) + ) + } + + /// TUN is switched on but cannot work, and we know that for certain. + /// + /// "For certain" is the whole difficulty: an unprobed Service, an operation in flight, or a + /// decision still sitting in front of the user all mean *not yet known*, and turning TUN + /// off on a guess would silently undo something the user asked for. This replaces the fixed + /// startup grace period the frontend used to wait out, which guessed at the same thing. + #[must_use] + pub const fn tun_should_be_disabled(&self, tun_enabled: bool) -> bool { + if !tun_enabled || self.tun_capable() { + return false; + } + !matches!(self.health, ServiceHealth::Unknown) && !self.op_in_flight && !self.service_needs_attention() + } + + /// The shape sent across the IPC seam. + /// + /// Carries the derived answers, not just the raw fields, so that no caller on the other + /// side reinvents `tun_capable` or the "needs attention" ladder. + #[must_use] + pub fn to_view(&self) -> RunStateView { + RunStateView { + mode: self.mode, + service: self.health.kind(), + service_unavailable_reason: self.health.reason(), + pending_action: self.pending, + sidecar_allowed: self.sidecar_allowed, + is_admin: self.is_admin, + op_in_flight: self.op_in_flight, + service_usable: self.service_usable(), + tun_capable: self.tun_capable(), + service_needs_attention: self.service_needs_attention(), + } + } +} + +/// The Run State as the frontend sees it. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RunStateView { + pub mode: RunningMode, + pub service: &'static str, + pub service_unavailable_reason: Option, + pub pending_action: Option, + pub sidecar_allowed: bool, + pub is_admin: bool, + pub op_in_flight: bool, + pub service_usable: bool, + pub tun_capable: bool, + pub service_needs_attention: bool, +} + +/// The service-owned part of the Run State, stored behind one lock. +/// +/// At most one of `pending` / `sidecar_allowed` is set at a time; the transitions in +/// [`super::RunStateStore`] maintain that, mirroring the single-slot semantics the legacy +/// `ServiceStatus` had. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(super) struct StoredService { + pub health: ServiceHealth, + pub pending: Option, + pub sidecar_allowed: bool, +} + +impl StoredService { + /// Record an observation, clearing any request it answers. + pub fn observe(&mut self, health: ServiceHealth) { + self.health = health; + self.pending = None; + self.sidecar_allowed = false; + } + + /// Record a requested privileged operation, preserving the last observation. + pub const fn request(&mut self, action: PendingAction) { + self.pending = Some(action); + self.sidecar_allowed = false; + } + + /// Record that the user accepted Sidecar for this session. + pub const fn allow_sidecar(&mut self) { + self.pending = None; + self.sidecar_allowed = true; + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod tests { + use super::*; + + fn state(health: ServiceHealth, is_admin: bool, op_in_flight: bool) -> RunState { + RunState { + health, + pending: None, + sidecar_allowed: false, + mode: RunningMode::NotRunning, + is_admin, + op_in_flight, + } + } + + #[test] + fn service_usable_requires_no_operation_in_flight() { + assert!(state(ServiceHealth::Ready, false, false).service_usable()); + assert!(!state(ServiceHealth::Ready, false, true).service_usable()); + assert!(!state(ServiceHealth::NotInstalled, false, false).service_usable()); + } + + #[test] + fn service_ready_ignores_operations_in_flight() { + assert!(state(ServiceHealth::Ready, false, true).service_ready()); + } + + #[test] + fn tun_capable_when_elevated_even_without_service() { + assert!(state(ServiceHealth::NotInstalled, true, false).tun_capable()); + assert!(state(ServiceHealth::Unavailable("boom".into()), true, true).tun_capable()); + assert!(!state(ServiceHealth::NotInstalled, false, false).tun_capable()); + } + + #[test] + fn a_service_that_is_merely_absent_needs_no_decision() { + assert!(!state(ServiceHealth::NotInstalled, false, false).service_needs_attention()); + assert!(!state(ServiceHealth::Ready, false, false).service_needs_attention()); + assert!(!state(ServiceHealth::Unknown, false, false).service_needs_attention()); + } + + #[test] + fn a_present_but_unusable_service_needs_a_decision() { + assert!(state(ServiceHealth::VersionMismatch, false, false).service_needs_attention()); + assert!(state(ServiceHealth::Unavailable("boom".into()), false, false).service_needs_attention()); + } + + #[test] + fn a_requested_action_always_needs_an_answer() { + for action in [ + PendingAction::Install, + PendingAction::Uninstall, + PendingAction::Reinstall, + PendingAction::ForceReinstall, + ] { + let mut run_state = state(ServiceHealth::NotInstalled, false, false); + run_state.pending = Some(action); + assert!(run_state.service_needs_attention(), "{action:?}"); + } + } + + #[test] + fn a_session_that_settled_on_sidecar_needs_nothing() { + let mut run_state = state(ServiceHealth::VersionMismatch, false, false); + run_state.sidecar_allowed = true; + + assert!(!run_state.service_needs_attention()); + } + + #[test] + fn the_view_carries_the_derived_answers_and_the_unavailable_reason() { + let mut run_state = state(ServiceHealth::Unavailable("socket refused".into()), true, false); + run_state.pending = Some(PendingAction::Reinstall); + + let view = run_state.to_view(); + + assert_eq!(view.service, "unavailable"); + assert_eq!(view.service_unavailable_reason.as_deref(), Some("socket refused")); + assert_eq!(view.pending_action, Some(PendingAction::Reinstall)); + assert!(view.tun_capable, "elevation alone makes TUN possible"); + assert!(!view.service_usable); + assert!(view.service_needs_attention); + } + + #[test] + fn a_healthy_view_reports_no_unavailable_reason() { + let view = state(ServiceHealth::Ready, false, false).to_view(); + + assert_eq!(view.service, "ready"); + assert_eq!(view.service_unavailable_reason, None); + assert!(view.service_usable); + assert!(!view.service_needs_attention); + } + + #[test] + fn tun_is_left_alone_when_the_user_has_not_asked_for_it() { + assert!(!state(ServiceHealth::NotInstalled, false, false).tun_should_be_disabled(false)); + } + + #[test] + fn tun_is_left_alone_while_it_still_works() { + assert!(!state(ServiceHealth::Ready, false, false).tun_should_be_disabled(true)); + assert!(!state(ServiceHealth::NotInstalled, true, false).tun_should_be_disabled(true)); + } + + #[test] + fn tun_is_disabled_once_we_know_it_cannot_work() { + assert!(state(ServiceHealth::NotInstalled, false, false).tun_should_be_disabled(true)); + } + + #[test] + fn tun_is_never_disabled_while_an_operation_is_in_flight() { + // Mid-install the service is not usable, but that says nothing about what it will be + // once the operation finishes. + assert!(!state(ServiceHealth::Ready, false, true).tun_should_be_disabled(true)); + assert!(!state(ServiceHealth::NotInstalled, false, true).tun_should_be_disabled(true)); + } + + #[test] + fn tun_is_never_disabled_while_the_core_is_not_running() { + // The startup regression this guards: the service is observed as absent before the + // startup path has decided whether to install it, and acting on that snapshot both + // rewrites the user's setting and removes the prompt that would have fixed it. + // `reconcile_tun_availability` refuses to act at all while the mode is NotRunning; + // this pins the state that made it dangerous. + let stopped = state(ServiceHealth::NotInstalled, false, false); + + assert_eq!(stopped.mode, RunningMode::NotRunning); + assert!( + stopped.tun_should_be_disabled(true), + "the predicate alone says yes, which is why the caller must gate on the mode" + ); + } + + #[test] + fn tun_is_never_disabled_on_an_unprobed_service() { + // The old frontend guard waited out a fixed ten seconds to approximate this. + assert!(!state(ServiceHealth::Unknown, false, false).tun_should_be_disabled(true)); + } + + #[test] + fn tun_is_never_disabled_while_a_decision_is_pending() { + for health in [ + ServiceHealth::VersionMismatch, + ServiceHealth::Unavailable("boom".into()), + ] { + assert!( + !state(health.clone(), false, false).tun_should_be_disabled(true), + "{health:?} is the user's to resolve", + ); + } + + let mut requested = state(ServiceHealth::NotInstalled, false, false); + requested.pending = Some(PendingAction::Install); + assert!(!requested.tun_should_be_disabled(true)); + } + + #[test] + fn tun_is_disabled_once_the_user_settles_on_sidecar() { + let mut settled = state(ServiceHealth::NotInstalled, false, false); + settled.sidecar_allowed = true; + + assert!(settled.tun_should_be_disabled(true)); + } + + #[test] + fn observing_clears_a_pending_request() { + let mut stored = StoredService::default(); + stored.request(PendingAction::Install); + stored.observe(ServiceHealth::Ready); + + assert_eq!(stored.pending, None); + assert!(!stored.sidecar_allowed); + assert_eq!(stored.health, ServiceHealth::Ready); + } + + #[test] + fn requesting_and_allowing_sidecar_are_mutually_exclusive() { + let mut stored = StoredService::default(); + stored.observe(ServiceHealth::NotInstalled); + + stored.request(PendingAction::Install); + assert!(!stored.sidecar_allowed); + + stored.allow_sidecar(); + assert_eq!(stored.pending, None); + assert!(stored.sidecar_allowed); + + // The observation survives both requests. + assert_eq!(stored.health, ServiceHealth::NotInstalled); + } +} diff --git a/src-tauri/src/core/runstate/mod.rs b/src-tauri/src/core/runstate/mod.rs new file mode 100644 index 000000000..7c69ed0e6 --- /dev/null +++ b/src-tauri/src/core/runstate/mod.rs @@ -0,0 +1,932 @@ +//! Run State — the single answer to "how is the Core running, and what backs it". +//! +//! This module owns Service Health, Running Mode, Pending Action and the privileged-operation +//! lock. It deliberately does **not** own starting, stopping or restarting the Core — those +//! stay in `CoreManager`, which reports transitions in and reads snapshots back. Merging the +//! two would leave no point at which the state is consistent, since a snapshot taken +//! mid-restart would describe a transition rather than a state. +//! +//! Reads come in three flavours that differ only in *freshness* — [`RunStateStore::state`] +//! (cached), [`RunStateStore::settled`] (waits for any in-flight operation) and +//! [`RunStateStore::probe`] (forces live IPC). What the answer *means* is decided by methods +//! on [`RunState`], so no caller writes its own availability formula. + +mod env; +mod health; +mod owner; +mod probe; + +use std::{ + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, + time::Duration, +}; + +use anyhow::{Context as _, Result, bail}; +use arc_swap::ArcSwap; +use clash_verge_logging::{Type, logging}; +use once_cell::sync::Lazy; +use parking_lot::Mutex; +use tokio::sync::Notify; + +#[cfg(test)] +pub use env::FakeEnv; +pub use env::{RealEnv, RunStateEnv}; +pub use health::{PendingAction, RunState, RunStateView, ServiceHealth}; +pub use owner::{OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch}; +pub use probe::{ServiceVersionCheck, ServiceVersionReply, classify_service_version_reply}; + +use crate::core::manager::RunningMode; +use health::StoredService; +use probe::{CurrentServiceProbe, classify_service_health, probe_outcome}; + +/// The process-wide Run State, observing the real machine. +pub static RUN_STATE: Lazy> = Lazy::new(|| RunStateStore::new(RealEnv)); + +/// Why a wait for Service readiness ended without a ready Service. +/// +/// The two are not interchangeable: an unreachable Service told us nothing, so the caller +/// decides whether silence means unavailable; a rejected one already updated health and must +/// not have that verdict overwritten with a vaguer one. +#[derive(Debug)] +pub enum ReadyWaitError { + /// No readable reply within the attempts budget. + Unreachable(anyhow::Error), + /// The Service answered and we rejected the answer; health records why. + Rejected(anyhow::Error), +} + +impl std::fmt::Display for ReadyWaitError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Unreachable(error) | Self::Rejected(error) => write!(formatter, "{error:#}"), + } + } +} + +/// The service-owned state together with the counter that versions it. +/// +/// They share one lock so a reader can never see a change without its version bump. +#[derive(Debug, Default)] +struct VersionedService { + service: StoredService, + generation: u64, +} + +impl VersionedService { + const fn bump(&mut self) { + self.generation = self.generation.wrapping_add(1); + } +} + +/// Holds the Run State and the effects needed to keep it current. +#[derive(Debug)] +pub struct RunStateStore { + env: E, + service: Mutex, + mode: ArcSwap, + operation_running: AtomicBool, + operation_done: Notify, +} + +impl RunStateStore { + pub fn new(env: E) -> Self { + Self { + env, + service: Mutex::new(VersionedService::default()), + mode: ArcSwap::new(Arc::new(RunningMode::NotRunning)), + operation_running: AtomicBool::new(false), + operation_done: Notify::new(), + } + } + + // ─────────────────────────── reads: freshness only ─────────────────────────── + + /// The last known Run State, without waiting and without probing. + /// + /// May be stale while a privileged operation is in flight — the snapshot says so via + /// [`RunState::op_in_flight`], and [`RunState::service_usable`] accounts for it. + pub fn state(&self) -> RunState { + let service = self.service.lock().service.clone(); + self.snapshot(service) + } + + /// The Run State once any in-flight privileged operation has finished. + pub async fn settled(&self) -> RunState { + loop { + let notified = self.operation_done.notified(); + tokio::pin!(notified); + // Register as a waiter *before* checking the flag. `notify_waiters` wakes only + // those already registered and leaves no permit behind, so without this an + // operation finishing between the check and the await is a wakeup lost for good + // — and this future would then wait for some unrelated later operation, or + // forever. + notified.as_mut().enable(); + + if !self.operation_running.load(Ordering::Acquire) { + return self.state(); + } + notified.await; + } + } + + /// Probe the Service now, record what came back, and return the fresh Run State. + /// + /// A *transport* failure is not an observation: the Service may simply be restarting, so + /// the last confirmed health survives and the caller decides whether to retry. Only a + /// reply we could read and reject updates health. + pub async fn probe(&self) -> Result { + let reply = self + .env + .probe_service_version() + .await + .context("service readiness probe failed")?; + self.record_reply(&reply) + } + + /// Probe until the Service answers, giving it `attempts` tries `interval` apart. + /// + /// Only an unreachable Service is worth retrying. A reply we could read is the Service's + /// answer — retrying an incompatible version twenty times would just delay telling the + /// user to reinstall — so the wait ends on the first readable reply either way. + pub async fn await_ready(&self, attempts: usize, interval: Duration) -> Result { + let mut last_error = None; + for attempt in 0..attempts { + match self.env.probe_service_version().await { + Ok(reply) => return self.record_reply(&reply).map_err(ReadyWaitError::Rejected), + Err(error) => last_error = Some(error), + } + if attempt + 1 < attempts { + tokio::time::sleep(interval).await; + } + } + + Err(ReadyWaitError::Unreachable(last_error.unwrap_or_else(|| { + anyhow::anyhow!("service readiness wait was configured with no attempts") + }))) + } + + /// Record a readable reply as an observation, and report whether it was acceptable. + fn record_reply(&self, reply: &ServiceVersionReply) -> Result { + match classify_service_version_reply(reply) { + ServiceVersionCheck::Ready => { + self.observe(ServiceHealth::Ready); + Ok(self.state()) + } + ServiceVersionCheck::NeedsReinstall(error) => { + self.observe(ServiceHealth::VersionMismatch); + bail!(error) + } + } + } + + /// Work out the Service's health from scratch: platform evidence first, then a live probe. + /// + /// Platform evidence that cannot be inspected is *unavailable*, not *absent* — assuming + /// "not installed" there would offer the user an install that is already there. + pub async fn detect_service_health(&self) -> ServiceHealth { + let has_marker = match self.env.trusted_install_evidence() { + Ok(exists) => exists, + Err(error) => { + logging!( + warn, + Type::Service, + "failed to inspect trusted service evidence: {error:#}" + ); + return ServiceHealth::Unavailable(format!("service detection failed: {error:#}")); + } + }; + + if !has_marker { + return classify_service_health(CurrentServiceProbe::Missing, false, ""); + } + + match self.env.probe_service_version().await { + Ok(reply) => classify_service_health(probe_outcome(&reply), has_marker, ""), + Err(error) => { + logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}"); + classify_service_health( + CurrentServiceProbe::Unavailable, + has_marker, + &format!("service detection failed: {error:#}"), + ) + } + } + } + + // ─────────────────────────── writes ─────────────────────────── + + /// Record an observation about the Service, clearing any request it answers. + pub fn observe(&self, health: ServiceHealth) { + let mut state = self.service.lock(); + if state.service.health == health && state.service.pending.is_none() && !state.service.sidecar_allowed { + return; + } + state.service.observe(health); + state.bump(); + drop(state); + self.announce(); + } + + /// Record a requested privileged operation without any eligibility check. + /// + /// Used when the request came from an explicit user action that has already been + /// authorised; the guarded session transitions below are for automatic decisions. + pub fn request_action(&self, action: PendingAction) { + let mut state = self.service.lock(); + state.service.request(action); + state.bump(); + drop(state); + self.announce(); + } + + /// Accept Sidecar for the rest of this app session without any eligibility check. + /// + /// For builds and paths where Sidecar is the decided outcome rather than a fallback. + pub fn accept_sidecar(&self) { + let mut state = self.service.lock(); + if state.service.sidecar_allowed && state.service.pending.is_none() { + return; + } + state.service.allow_sidecar(); + state.bump(); + drop(state); + self.announce(); + } + + /// Accept Sidecar for the rest of this app session. + /// + /// Rejected while an operation is in flight, or from a state where the Service is + /// already usable or is mid-privileged-operation other than an install. + pub fn allow_sidecar_for_session(&self) -> Result<()> { + let mut state = self.service.lock(); + if self.operation_running.load(Ordering::Acquire) { + bail!("service operation already running"); + } + let permitted = match (state.service.pending, state.service.sidecar_allowed) { + (Some(PendingAction::Install), _) => true, + (Some(_), _) | (None, true) => false, + (None, false) => matches!( + state.service.health, + ServiceHealth::NotInstalled | ServiceHealth::VersionMismatch | ServiceHealth::Unavailable(_) + ), + }; + if !permitted { + bail!("sidecar cannot be allowed from service state {:?}", state.service); + } + state.service.allow_sidecar(); + state.bump(); + drop(state); + self.announce(); + Ok(()) + } + + /// Require the Service to be installed before the Core starts, when it is simply absent. + /// + /// A no-op from any other state, mirroring the rule that an already-requested or + /// already-usable Service must not be downgraded to "needs installing". + pub fn require_install_for_session(&self) -> Result<()> { + let mut state = self.service.lock(); + if self.operation_running.load(Ordering::Acquire) { + bail!("service operation already running"); + } + if state.service.pending.is_none() + && !state.service.sidecar_allowed + && state.service.health == ServiceHealth::NotInstalled + { + state.service.request(PendingAction::Install); + state.bump(); + } + drop(state); + self.announce(); + Ok(()) + } + + /// Carry out a privileged operation and record what it did to the Service. + /// + /// Only an uninstall has an outcome we can record without asking again: it either removed + /// the Service or left it in a state we no longer trust. After an install or repair the + /// Service has to be asked whether it came back, which is [`Self::await_ready`]'s job. + pub fn perform(&self, action: PendingAction) -> Result<()> { + let outcome = self.env.run_privileged(action); + if !matches!(action, PendingAction::Uninstall) { + return outcome; + } + + match outcome { + Ok(()) => { + self.observe(ServiceHealth::NotInstalled); + Ok(()) + } + Err(error) => { + self.observe(ServiceHealth::Unavailable(format!( + "Service uninstall failed: {error:#}" + ))); + Err(error) + } + } + } + + // ─────────────────────────── running mode ─────────────────────────── + + /// The Running Mode, shared so hot callers such as the tray do not allocate. + pub fn mode_arc(&self) -> Arc { + Arc::clone(&self.mode.load()) + } + + /// The Core is now running, and serving, in `mode`. + pub fn core_started(&self, mode: RunningMode) { + self.enter_mode(mode); + } + + /// The Core is no longer running. + pub fn core_stopped(&self) { + self.enter_mode(RunningMode::NotRunning); + } + + /// A start attempt is under way: the Core is not serving yet, whatever the mode says. + /// + /// Closes the PAC endpoint without disturbing the Running Mode, so a handover cannot + /// hand out a PAC script for a proxy port that is between owners. + pub fn core_starting(&self) { + self.env.set_pac_available(false); + } + + /// Move to `mode` and re-derive everything that follows from it. + /// + /// PAC availability and the outward mirror are derived here and nowhere else, so they + /// cannot drift from the Running Mode the way ten hand-paired call sites could. + /// + /// PAC closes *before* the mode changes and opens *after*, so whatever a concurrent + /// reader catches mid-transition is the closed state: being refused a PAC script beats + /// being handed one that points at a proxy port between owners. + fn enter_mode(&self, mode: RunningMode) { + let running = !matches!(mode, RunningMode::NotRunning); + if !running { + self.env.set_pac_available(false); + } + self.mode.store(Arc::new(mode)); + if running { + self.env.set_pac_available(true); + } + self.announce(); + } + + // ─────────────────────────── privileged operations ─────────────────────────── + + /// Claim the privileged-operation slot, releasing it when the guard drops. + /// + /// Only one privileged operation may be in flight; [`Self::settled`] waits on this. + pub fn begin_operation(&self) -> Result> { + let mut state = self.service.lock(); + if self.operation_running.swap(true, Ordering::AcqRel) { + bail!("service operation already running"); + } + state.bump(); + drop(state); + self.announce(); + Ok(OperationGuard { store: self }) + } + + /// Whether a privileged operation is in flight right now. + /// + /// Production code reads this through [`RunState::op_in_flight`]; this accessor exists for + /// tests that assert on the slot without taking a snapshot. + #[cfg(test)] + pub fn operation_in_flight(&self) -> bool { + self.operation_running.load(Ordering::Acquire) + } + + /// The environment this store observes, so tests can assert on the effects it recorded. + #[cfg(test)] + pub const fn env(&self) -> &E { + &self.env + } + + /// Bumped on every state change; lets tests assert that a no-op really was a no-op. + #[cfg(test)] + pub fn generation_count(&self) -> u64 { + self.service.lock().generation + } + + /// Tell the outside world the Run State moved. + /// + /// Called after every mutation and never while the state lock is held, so a publisher that + /// reads back the state cannot deadlock against the writer that triggered it. + fn announce(&self) { + self.env.publish(&self.state()); + } + + fn snapshot(&self, service: StoredService) -> RunState { + RunState { + health: service.health, + pending: service.pending, + sidecar_allowed: service.sidecar_allowed, + mode: *self.mode.load().as_ref(), + is_admin: self.env.is_elevated(), + op_in_flight: self.operation_running.load(Ordering::Acquire), + } + } +} + +/// Releases the privileged-operation slot and wakes everything waiting in [`RunStateStore::settled`]. +#[derive(Debug)] +pub struct OperationGuard<'a, E: RunStateEnv> { + store: &'a RunStateStore, +} + +impl Drop for OperationGuard<'_, E> { + fn drop(&mut self) { + self.store.operation_running.store(false, Ordering::Release); + self.store.operation_done.notify_waiters(); + self.store.announce(); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod tests { + use super::*; + use env::FakeEnv; + + fn with_env(env: FakeEnv) -> RunStateStore { + RunStateStore::new(env) + } + + fn ready_reply() -> ServiceVersionReply { + ServiceVersionReply { + code: 0, + message: "ok".to_owned(), + protocol: Some(clash_verge_service_ipc::ProtocolInfo::current()), + } + } + + #[test] + fn a_fresh_store_knows_nothing() { + let store = with_env(FakeEnv::new()); + let state = store.state(); + + assert_eq!(state.health, ServiceHealth::Unknown); + assert_eq!(state.pending, None); + assert!(!state.sidecar_allowed); + assert_eq!(state.mode, RunningMode::NotRunning); + assert!(!state.is_admin); + assert!(!state.op_in_flight); + assert!(!state.service_usable()); + } + + #[test] + fn elevation_comes_from_the_environment() { + assert!(with_env(FakeEnv::new().elevated()).state().is_admin); + assert!(!with_env(FakeEnv::new()).state().is_admin); + } + + #[test] + fn repeated_identical_observations_do_not_bump_the_generation() { + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::Ready); + let generation = store.generation_count(); + + store.observe(ServiceHealth::Ready); + + assert_eq!(store.generation_count(), generation); + } + + #[test] + fn observing_clears_a_pending_request() { + let store = with_env(FakeEnv::new()); + store.request_action(PendingAction::Install); + store.observe(ServiceHealth::Ready); + + let state = store.state(); + assert_eq!(state.pending, None); + assert!(state.service_usable()); + } + + #[test] + fn an_in_flight_operation_makes_a_ready_service_unusable() { + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::Ready); + assert!(store.state().service_usable()); + + let guard = store.begin_operation().expect("slot should be free"); + assert!(store.state().service_ready()); + assert!(!store.state().service_usable()); + + drop(guard); + assert!(store.state().service_usable()); + } + + #[test] + fn only_one_privileged_operation_runs_at_a_time() { + let store = with_env(FakeEnv::new()); + let guard = store.begin_operation().expect("slot should be free"); + + assert!(store.begin_operation().is_err()); + + drop(guard); + assert!(store.begin_operation().is_ok()); + } + + #[test] + fn sidecar_may_be_allowed_when_the_service_is_absent_or_broken() { + for health in [ + ServiceHealth::NotInstalled, + ServiceHealth::VersionMismatch, + ServiceHealth::Unavailable("boom".to_owned()), + ] { + let store = with_env(FakeEnv::new()); + store.observe(health.clone()); + + store + .allow_sidecar_for_session() + .unwrap_or_else(|error| panic!("{health:?} should permit sidecar: {error}")); + assert!(store.state().sidecar_allowed); + } + } + + #[test] + fn sidecar_may_be_allowed_after_an_install_was_requested() { + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::NotInstalled); + store.require_install_for_session().expect("install may be required"); + assert_eq!(store.state().pending, Some(PendingAction::Install)); + + store.allow_sidecar_for_session().expect("install may be abandoned"); + + let state = store.state(); + assert!(state.sidecar_allowed); + assert_eq!(state.pending, None); + } + + #[test] + fn sidecar_is_refused_when_the_service_is_usable_or_unknown() { + for health in [ServiceHealth::Unknown, ServiceHealth::Ready] { + let store = with_env(FakeEnv::new()); + store.observe(health.clone()); + + assert!( + store.allow_sidecar_for_session().is_err(), + "{health:?} should refuse sidecar" + ); + } + } + + #[test] + fn sidecar_is_refused_during_a_privileged_operation() { + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::NotInstalled); + let _guard = store.begin_operation().expect("slot should be free"); + + assert!(store.allow_sidecar_for_session().is_err()); + } + + #[test] + fn requiring_an_install_only_applies_to_an_absent_service() { + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::NotInstalled); + store.require_install_for_session().expect("absent service"); + assert_eq!(store.state().pending, Some(PendingAction::Install)); + + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::Ready); + store.require_install_for_session().expect("no-op, not an error"); + assert_eq!(store.state().pending, None); + } + + #[test] + fn requiring_an_install_is_refused_during_a_privileged_operation() { + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::NotInstalled); + let _guard = store.begin_operation().expect("slot should be free"); + + assert!(store.require_install_for_session().is_err()); + } + + #[tokio::test] + async fn settled_waits_for_an_operation_to_finish() { + let store = Arc::new(with_env(FakeEnv::new())); + store.observe(ServiceHealth::NotInstalled); + let guard = store.begin_operation().expect("slot should be free"); + + let waiter = { + let store = Arc::clone(&store); + tokio::spawn(async move { store.settled().await }) + }; + + // The waiter cannot have finished while the operation is in flight. + tokio::task::yield_now().await; + assert!(!waiter.is_finished()); + + store.observe(ServiceHealth::Ready); + drop(guard); + + let settled = waiter.await.expect("waiter should not panic"); + assert!(settled.service_usable()); + assert!(!settled.op_in_flight); + } + + #[tokio::test] + async fn probing_a_ready_service_records_it() { + let store = with_env(FakeEnv::new().service_ready()); + + let state = store.probe().await.expect("a ready service should probe cleanly"); + + assert!(state.service_usable()); + assert_eq!(store.state().health, ServiceHealth::Ready); + } + + #[tokio::test] + async fn probing_an_incompatible_service_records_a_version_mismatch() { + let store = with_env(FakeEnv::new().service_version_mismatch()); + + let error = store.probe().await.expect_err("an incompatible service should fail"); + + assert!( + error.to_string().contains("protocol mismatch"), + "error should explain the mismatch: {error}" + ); + assert_eq!(store.state().health, ServiceHealth::VersionMismatch); + } + + #[tokio::test] + async fn awaiting_readiness_retries_a_silent_service_then_accepts_its_reply() { + let store = with_env(FakeEnv::new().replying(vec![Err("ipc path not ready".to_owned()), Ok(ready_reply())])); + + let state = store + .await_ready(2, Duration::ZERO) + .await + .expect("the second attempt should succeed"); + + assert!(state.service_usable()); + assert_eq!(store.env.probe_count(), 2, "the first attempt should have been retried"); + } + + #[tokio::test] + async fn awaiting_readiness_gives_up_after_the_last_attempt() { + let store = with_env(FakeEnv::new().service_unreachable()); + store.observe(ServiceHealth::Ready); + + let error = store + .await_ready(3, Duration::ZERO) + .await + .expect_err("a silent service should never become ready"); + + assert!(matches!(error, ReadyWaitError::Unreachable(_))); + assert_eq!(store.env.probe_count(), 3, "every attempt should have been used"); + assert_eq!( + store.state().health, + ServiceHealth::Ready, + "silence is the caller's to interpret, not an observation" + ); + } + + #[tokio::test] + async fn awaiting_readiness_stops_at_the_first_readable_reply() { + // Retrying an incompatible version would only delay telling the user to reinstall. + let store = with_env(FakeEnv::new().service_version_mismatch()); + + let error = store + .await_ready(20, Duration::ZERO) + .await + .expect_err("an incompatible service is not ready"); + + assert!(matches!(error, ReadyWaitError::Rejected(_))); + assert_eq!(store.env.probe_count(), 1, "a readable reply ends the wait"); + assert_eq!(store.state().health, ServiceHealth::VersionMismatch); + } + + #[tokio::test] + async fn awaiting_readiness_with_no_attempts_is_unreachable_not_ready() { + let store = with_env(FakeEnv::new().service_ready()); + + let error = store + .await_ready(0, Duration::ZERO) + .await + .expect_err("no attempts cannot confirm anything"); + + assert!(matches!(error, ReadyWaitError::Unreachable(_))); + assert_eq!(store.env.probe_count(), 0); + } + + #[tokio::test] + async fn a_transport_failure_does_not_overwrite_confirmed_health() { + // A Service that is restarting must not be downgraded to "unavailable" on one + // missed probe — the last confirmed observation stands until we hear otherwise. + let store = with_env(FakeEnv::new().service_unreachable()); + store.observe(ServiceHealth::Ready); + + let error = store.probe().await.expect_err("an unreachable service should fail"); + + assert!( + error.to_string().contains("service readiness probe failed"), + "error should name the failed probe: {error}" + ); + assert_eq!(store.state().health, ServiceHealth::Ready); + } + + #[tokio::test] + async fn detection_without_platform_evidence_is_not_installed() { + let store = with_env(FakeEnv::new().with_evidence(false)); + + assert_eq!(store.detect_service_health().await, ServiceHealth::NotInstalled); + } + + #[tokio::test] + async fn detection_skips_the_probe_when_there_is_no_evidence() { + let env = FakeEnv::new().with_evidence(false); + let store = with_env(env); + + store.detect_service_health().await; + + assert_eq!(store.env.probe_count(), 0, "no evidence means nothing to probe"); + } + + #[tokio::test] + async fn detection_reports_a_registered_but_unreachable_service_as_unavailable() { + let store = with_env(FakeEnv::new().service_unreachable()); + + let health = store.detect_service_health().await; + + assert!( + matches!(health, ServiceHealth::Unavailable(_)), + "registered but silent is unavailable, not absent: {health:?}" + ); + } + + #[tokio::test] + async fn detection_treats_an_uninspectable_registry_as_unavailable() { + let store = with_env(FakeEnv::new().evidence_unavailable()); + + let health = store.detect_service_health().await; + + assert!( + matches!(health, ServiceHealth::Unavailable(_)), + "an uninspectable registry must not be reported as absent: {health:?}" + ); + } + + #[tokio::test] + async fn detection_reports_a_healthy_service_as_ready() { + let store = with_env(FakeEnv::new().service_ready()); + + assert_eq!(store.detect_service_health().await, ServiceHealth::Ready); + } + + #[test] + fn running_mode_round_trips() { + let store = with_env(FakeEnv::new()); + assert_eq!(*store.mode_arc(), RunningMode::NotRunning); + + store.core_started(RunningMode::Service); + + assert_eq!(*store.mode_arc(), RunningMode::Service); + assert_eq!(store.state().mode, RunningMode::Service); + } + + #[test] + fn starting_the_core_opens_pac_and_mirrors_the_mode() { + for mode in [RunningMode::Service, RunningMode::Sidecar] { + let store = with_env(FakeEnv::new()); + + store.core_started(mode); + + assert_eq!(store.env.pac_available(), Some(true), "{mode} should open PAC"); + assert_eq!(store.env.published_modes(), vec![mode]); + } + } + + #[test] + fn stopping_the_core_closes_pac_and_mirrors_the_mode() { + let store = with_env(FakeEnv::new()); + store.core_started(RunningMode::Sidecar); + + store.core_stopped(); + + assert_eq!(store.state().mode, RunningMode::NotRunning); + assert_eq!(store.env.pac_available(), Some(false)); + assert_eq!( + store.env.published_modes(), + vec![RunningMode::Sidecar, RunningMode::NotRunning] + ); + } + + #[test] + fn a_start_attempt_closes_pac_without_disturbing_the_mode() { + // A handover must not hand out a PAC script for a proxy port between owners, but the + // Core we are replacing is still the one that is running. + let store = with_env(FakeEnv::new()); + store.core_started(RunningMode::Sidecar); + + store.core_starting(); + + assert_eq!(store.env.pac_available(), Some(false)); + assert_eq!(store.state().mode, RunningMode::Sidecar); + assert_eq!( + store.env.published_modes(), + vec![RunningMode::Sidecar], + "a start attempt is not a mode change" + ); + } + + #[test] + fn pac_availability_can_never_disagree_with_the_running_mode() { + let store = with_env(FakeEnv::new()); + + for mode in [ + RunningMode::Sidecar, + RunningMode::NotRunning, + RunningMode::Service, + RunningMode::NotRunning, + ] { + store.core_started(mode); + + let running = !matches!(mode, RunningMode::NotRunning); + assert_eq!(store.env.pac_available(), Some(running), "mode {mode}"); + assert_eq!(store.state().mode, mode); + } + } + + #[test] + fn every_change_is_published_not_just_mode_changes() { + // The frontend is pushed to rather than polling, so a health change it would otherwise + // wait up to a poll interval to notice has to be announced too. + let store = with_env(FakeEnv::new()); + + store.observe(ServiceHealth::NotInstalled); + store.require_install_for_session().expect("absent service"); + store.core_started(RunningMode::Sidecar); + + let published = store.env.published(); + assert_eq!(published.len(), 3, "each change announces exactly once"); + assert_eq!(published[0].health, ServiceHealth::NotInstalled); + assert_eq!(published[1].pending, Some(PendingAction::Install)); + assert_eq!(published[2].mode, RunningMode::Sidecar); + } + + #[test] + fn a_no_op_change_announces_nothing() { + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::Ready); + let published = store.env.published().len(); + + store.observe(ServiceHealth::Ready); + + assert_eq!(store.env.published().len(), published); + } + + #[test] + fn starting_and_finishing_an_operation_are_both_published() { + // op_in_flight is part of the snapshot, so the frontend must learn when it clears. + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::Ready); + let baseline = store.env.published().len(); + + let guard = store.begin_operation().expect("slot should be free"); + drop(guard); + + let published = store.env.published(); + assert_eq!(published.len(), baseline + 2); + assert!(published[baseline].op_in_flight, "the claim is announced"); + assert!(!published[baseline + 1].op_in_flight, "so is the release"); + } + + #[test] + fn service_health_survives_the_core_stopping() { + // Stopping the Core says nothing about whether the Service is installed. + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::Ready); + store.core_started(RunningMode::Service); + + store.core_stopped(); + + assert_eq!(store.state().health, ServiceHealth::Ready); + } + + #[test] + fn tun_is_capable_when_elevated_even_with_no_service() { + let store = with_env(FakeEnv::new().elevated()); + store.observe(ServiceHealth::NotInstalled); + + assert!(store.state().tun_capable()); + } + + #[test] + fn tun_is_capable_via_a_ready_service_without_elevation() { + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::Ready); + + assert!(store.state().tun_capable()); + } + + #[test] + fn tun_is_not_capable_mid_operation_without_elevation() { + let store = with_env(FakeEnv::new()); + store.observe(ServiceHealth::Ready); + let _guard = store.begin_operation().expect("slot should be free"); + + assert!(!store.state().tun_capable()); + } +} diff --git a/src-tauri/src/core/runstate/owner.rs b/src-tauri/src/core/runstate/owner.rs new file mode 100644 index 000000000..8d20856e4 --- /dev/null +++ b/src-tauri/src/core/runstate/owner.rs @@ -0,0 +1,371 @@ +//! Deciding when the Service has stopped being ours. +//! +//! While the Core runs under the Service, the app polls the Service for owner status. Three +//! things can go wrong — we cannot read a reply, the Service says someone else owns it, or it +//! answers but describes a Core that is gone — and each needs a different response. This is +//! the decision, separated from the polling and from the recovery it triggers. +//! +//! Recovery *actions* deliberately live with the Core lifecycle rather than here: this module +//! decides that the Service can no longer be trusted, and `CoreManager` is what tears the Core +//! down in response. + +use clash_verge_service_ipc::ServiceLifecycleState; + +/// How many consecutive bad samples we tolerate before acting. +/// +/// Service restarts and slow status calls are normal; acting on the first one would tear down +/// a working proxy for a blip. +const SUSTAINED_SAMPLES: u8 = 3; + +/// Why the app stopped trusting the Service that was running its Core. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OwnerRecoveryReason { + /// Another owner took the Service over. + Displaced, + /// Still ours, but the Core it was running is gone. + SameOwnerFailure, + /// We could not reach the Service at all for long enough to give up. + TransportFailure, +} + +/// One reading of Service owner status. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OwnerSample { + /// No usable answer: transport failed, an error code came back, or the payload was empty. + Unreadable, + /// The Service reports it is no longer running for us. + NotActive, + /// A complete answer. + Status { + is_active: bool, + desired_core_should_be_running: bool, + service_state: ServiceLifecycleState, + core_pid: Option, + }, +} + +/// What the caller should do about a sample. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OwnerStep { + /// Nothing is wrong, or not wrong for long enough yet. + Continue, + /// Unreadable for long enough that it might be real. Check whether the Core's own endpoint + /// still answers, then report back via [`OwnerWatch::resolve_transport`]. + /// + /// A Service we cannot reach while the Core is still serving is a broken status channel, + /// not a lost Core — tearing down the proxy there would be the cure causing the disease. + VerifyTransport, + /// Stop trusting the Service and recover. + Recover(OwnerRecoveryReason), +} + +/// Counts consecutive bad samples so that a blip is not mistaken for a failure. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct OwnerWatch { + unreadable_samples: u8, + missing_core_samples: u8, +} + +impl OwnerWatch { + #[must_use] + pub const fn new() -> Self { + Self { + unreadable_samples: 0, + missing_core_samples: 0, + } + } + + /// Whether this sample warrants a log line: only the moment a run becomes sustained, so a + /// permanently broken Service does not fill the log at the poll interval. + #[must_use] + pub const fn just_became_sustained(&self) -> bool { + self.unreadable_samples == SUSTAINED_SAMPLES + } + + /// Fold one reading into the decision. + pub const fn observe(&mut self, sample: OwnerSample) -> OwnerStep { + match sample { + OwnerSample::Unreadable => { + self.unreadable_samples = self.unreadable_samples.saturating_add(1); + if self.unreadable_samples >= SUSTAINED_SAMPLES { + OwnerStep::VerifyTransport + } else { + OwnerStep::Continue + } + } + OwnerSample::NotActive => OwnerStep::Recover(OwnerRecoveryReason::Displaced), + OwnerSample::Status { + is_active, + desired_core_should_be_running, + service_state, + core_pid, + } => { + self.unreadable_samples = 0; + self.missing_core_samples = if core_pid.is_none() && !is_settling(service_state) { + self.missing_core_samples.saturating_add(1) + } else { + 0 + }; + match recovery_reason( + is_active, + desired_core_should_be_running, + service_state, + core_pid, + self.missing_core_samples, + ) { + Some(reason) => OwnerStep::Recover(reason), + None => OwnerStep::Continue, + } + } + } + } + + /// Report what the transport check found after [`OwnerStep::VerifyTransport`]. + /// + /// If the Core's own endpoint still answers, only the status channel is broken: the run is + /// reset so the next stretch of silence has to earn its own verdict. + pub const fn resolve_transport(&mut self, owner_endpoint_available: bool) -> OwnerStep { + if owner_endpoint_available { + self.unreadable_samples = 0; + return OwnerStep::Continue; + } + OwnerStep::Recover(OwnerRecoveryReason::TransportFailure) + } +} + +/// States in which a missing Core PID is expected rather than alarming. +const fn is_settling(service_state: ServiceLifecycleState) -> bool { + matches!( + service_state, + ServiceLifecycleState::Starting | ServiceLifecycleState::RecoveringCore + ) +} + +const fn recovery_reason( + is_active: bool, + desired_running: bool, + service_state: ServiceLifecycleState, + core_pid: Option, + missing_core_samples: u8, +) -> Option { + if !is_active { + return Some(OwnerRecoveryReason::Displaced); + } + let core_is_gone = !is_settling(service_state) && core_pid.is_none() && missing_core_samples >= SUSTAINED_SAMPLES; + if !desired_running || matches!(service_state, ServiceLifecycleState::Fatal) || core_is_gone { + return Some(OwnerRecoveryReason::SameOwnerFailure); + } + None +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod tests { + use super::*; + + const fn healthy() -> OwnerSample { + OwnerSample::Status { + is_active: true, + desired_core_should_be_running: true, + service_state: ServiceLifecycleState::Running, + core_pid: Some(42), + } + } + + const fn core_gone(service_state: ServiceLifecycleState) -> OwnerSample { + OwnerSample::Status { + is_active: true, + desired_core_should_be_running: true, + service_state, + core_pid: None, + } + } + + #[test] + fn a_healthy_sample_changes_nothing() { + let mut watch = OwnerWatch::new(); + assert_eq!(watch.observe(healthy()), OwnerStep::Continue); + assert_eq!(watch, OwnerWatch::new()); + } + + #[test] + fn a_short_run_of_unreadable_samples_is_tolerated() { + let mut watch = OwnerWatch::new(); + assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue); + assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue); + } + + #[test] + fn a_sustained_run_of_unreadable_samples_asks_about_the_transport() { + let mut watch = OwnerWatch::new(); + for _ in 0..2 { + watch.observe(OwnerSample::Unreadable); + } + assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::VerifyTransport); + assert!(watch.just_became_sustained(), "the sustained moment should log once"); + } + + #[test] + fn a_sustained_run_logs_only_once_however_long_it_lasts() { + let mut watch = OwnerWatch::new(); + for _ in 0..3 { + watch.observe(OwnerSample::Unreadable); + } + assert!(watch.just_became_sustained()); + + watch.observe(OwnerSample::Unreadable); + assert!(!watch.just_became_sustained()); + } + + #[test] + fn a_reachable_core_endpoint_means_only_the_status_channel_broke() { + let mut watch = OwnerWatch::new(); + for _ in 0..3 { + watch.observe(OwnerSample::Unreadable); + } + + assert_eq!(watch.resolve_transport(true), OwnerStep::Continue); + + // The run restarted: the next stretch of silence must earn its own verdict. + assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue); + assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue); + assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::VerifyTransport); + } + + #[test] + fn an_unreachable_core_endpoint_confirms_transport_failure() { + let mut watch = OwnerWatch::new(); + for _ in 0..3 { + watch.observe(OwnerSample::Unreadable); + } + + assert_eq!( + watch.resolve_transport(false), + OwnerStep::Recover(OwnerRecoveryReason::TransportFailure) + ); + } + + #[test] + fn a_readable_sample_clears_an_unreadable_run() { + let mut watch = OwnerWatch::new(); + watch.observe(OwnerSample::Unreadable); + watch.observe(OwnerSample::Unreadable); + + watch.observe(healthy()); + + assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue); + assert_eq!(watch.observe(OwnerSample::Unreadable), OwnerStep::Continue); + } + + #[test] + fn a_service_reporting_not_active_is_displacement() { + let mut watch = OwnerWatch::new(); + assert_eq!( + watch.observe(OwnerSample::NotActive), + OwnerStep::Recover(OwnerRecoveryReason::Displaced) + ); + } + + #[test] + fn a_status_saying_we_are_not_the_owner_is_displacement() { + let mut watch = OwnerWatch::new(); + let sample = OwnerSample::Status { + is_active: false, + desired_core_should_be_running: true, + service_state: ServiceLifecycleState::Running, + core_pid: Some(1), + }; + assert_eq!( + watch.observe(sample), + OwnerStep::Recover(OwnerRecoveryReason::Displaced) + ); + } + + #[test] + fn a_fatal_service_recovers_immediately() { + let mut watch = OwnerWatch::new(); + let sample = OwnerSample::Status { + is_active: true, + desired_core_should_be_running: true, + service_state: ServiceLifecycleState::Fatal, + core_pid: Some(1), + }; + assert_eq!( + watch.observe(sample), + OwnerStep::Recover(OwnerRecoveryReason::SameOwnerFailure) + ); + } + + #[test] + fn a_service_that_no_longer_wants_the_core_running_recovers_immediately() { + let mut watch = OwnerWatch::new(); + let sample = OwnerSample::Status { + is_active: true, + desired_core_should_be_running: false, + service_state: ServiceLifecycleState::Running, + core_pid: Some(1), + }; + assert_eq!( + watch.observe(sample), + OwnerStep::Recover(OwnerRecoveryReason::SameOwnerFailure) + ); + } + + #[test] + fn a_briefly_missing_core_is_tolerated_then_recovered() { + let mut watch = OwnerWatch::new(); + assert_eq!( + watch.observe(core_gone(ServiceLifecycleState::Running)), + OwnerStep::Continue + ); + assert_eq!( + watch.observe(core_gone(ServiceLifecycleState::Running)), + OwnerStep::Continue + ); + assert_eq!( + watch.observe(core_gone(ServiceLifecycleState::Running)), + OwnerStep::Recover(OwnerRecoveryReason::SameOwnerFailure) + ); + } + + #[test] + fn a_missing_core_is_expected_while_the_service_is_still_settling() { + for state in [ServiceLifecycleState::Starting, ServiceLifecycleState::RecoveringCore] { + let mut watch = OwnerWatch::new(); + for _ in 0..10 { + assert_eq!(watch.observe(core_gone(state)), OwnerStep::Continue, "{state:?}"); + } + } + } + + #[test] + fn a_core_that_comes_back_clears_the_missing_run() { + let mut watch = OwnerWatch::new(); + watch.observe(core_gone(ServiceLifecycleState::Running)); + watch.observe(core_gone(ServiceLifecycleState::Running)); + + watch.observe(healthy()); + + assert_eq!( + watch.observe(core_gone(ServiceLifecycleState::Running)), + OwnerStep::Continue + ); + assert_eq!( + watch.observe(core_gone(ServiceLifecycleState::Running)), + OwnerStep::Continue + ); + } + + #[test] + fn an_unreadable_run_does_not_count_towards_a_missing_core() { + // The two counters are independent: silence says nothing about the Core's PID. + let mut watch = OwnerWatch::new(); + watch.observe(OwnerSample::Unreadable); + watch.observe(OwnerSample::Unreadable); + + assert_eq!( + watch.observe(core_gone(ServiceLifecycleState::Running)), + OwnerStep::Continue + ); + } +} diff --git a/src-tauri/src/core/runstate/probe.rs b/src-tauri/src/core/runstate/probe.rs new file mode 100644 index 000000000..311e5ecb8 --- /dev/null +++ b/src-tauri/src/core/runstate/probe.rs @@ -0,0 +1,249 @@ +//! Turning raw Service probes into [`ServiceHealth`]. +//! +//! Everything here is a pure function of its arguments: the probing itself is an effect +//! on [`super::env::RunStateEnv`], so these classifications are testable without IPC, +//! systemd, SCM or launchd. + +use clash_verge_service_ipc::{MIN_REQUIRED_SERVICE_REVISION, ProtocolInfo, ProtocolVersion}; + +use super::health::ServiceHealth; + +/// What the Service replied to a protocol-version query. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ServiceVersionReply { + pub code: u16, + pub message: String, + pub protocol: Option, +} + +/// The verdict on a [`ServiceVersionReply`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ServiceVersionCheck { + Ready, + NeedsReinstall(String), +} + +/// What a live probe of the currently-installed Service told us. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CurrentServiceProbe { + Missing, + Ready, + VersionMismatch, + Unavailable, +} + +/// Decide whether the Service we reached speaks a protocol this client can use. +pub fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck { + let client = ProtocolVersion::current(); + if reply.code == 0 + && reply + .protocol + .as_ref() + .is_some_and(|info| info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION)) + { + return ServiceVersionCheck::Ready; + } + + let detail = if reply.code == 0 { + match reply.protocol.as_ref() { + Some(info) => format!( + "client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})", + client.epoch, + MIN_REQUIRED_SERVICE_REVISION, + info.protocol.epoch, + info.protocol.revision, + info.build_version + ), + None => "service did not report protocol information".to_owned(), + } + } else { + format!( + "protocol query returned code {} ({}) while expecting epoch {}", + reply.code, reply.message, client.epoch + ) + }; + ServiceVersionCheck::NeedsReinstall(format!( + "Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue" + )) +} + +/// Classify a version reply into the probe outcome used by [`classify_service_health`]. +pub fn probe_outcome(reply: &ServiceVersionReply) -> CurrentServiceProbe { + if classify_service_version_reply(reply) == ServiceVersionCheck::Ready { + CurrentServiceProbe::Ready + } else { + CurrentServiceProbe::VersionMismatch + } +} + +/// Combine a live probe with platform installation evidence into a health verdict. +/// +/// The `has_install_marker` case matters: a Service registered with the platform that does +/// not answer is broken, not absent, so it needs reinstalling rather than installing. +/// `unavailable_reason` is only consulted for [`CurrentServiceProbe::Unavailable`]. +pub fn classify_service_health( + current: CurrentServiceProbe, + has_install_marker: bool, + unavailable_reason: &str, +) -> ServiceHealth { + match current { + CurrentServiceProbe::Ready => ServiceHealth::Ready, + CurrentServiceProbe::VersionMismatch => ServiceHealth::VersionMismatch, + CurrentServiceProbe::Unavailable => ServiceHealth::Unavailable(unavailable_reason.to_owned()), + CurrentServiceProbe::Missing if has_install_marker => ServiceHealth::VersionMismatch, + CurrentServiceProbe::Missing => ServiceHealth::NotInstalled, + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod tests { + use super::*; + + fn ready_reply() -> ServiceVersionReply { + ServiceVersionReply { + code: 0, + message: "ok".to_owned(), + protocol: Some(ProtocolInfo::current()), + } + } + + #[test] + fn a_compatible_reply_is_ready() { + assert_eq!( + classify_service_version_reply(&ready_reply()), + ServiceVersionCheck::Ready + ); + assert_eq!(probe_outcome(&ready_reply()), CurrentServiceProbe::Ready); + } + + #[test] + fn a_reply_without_protocol_information_needs_reinstall() { + let reply = ServiceVersionReply { + code: 0, + message: "ok".to_owned(), + protocol: None, + }; + let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else { + panic!("expected a reinstall verdict"); + }; + assert!( + detail.contains("did not report protocol information"), + "detail should explain the missing protocol: {detail}" + ); + assert_eq!(probe_outcome(&reply), CurrentServiceProbe::VersionMismatch); + } + + #[test] + fn a_nonzero_code_needs_reinstall() { + let reply = ServiceVersionReply { + code: 7, + message: "nope".to_owned(), + protocol: Some(ProtocolInfo::current()), + }; + let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else { + panic!("expected a reinstall verdict"); + }; + assert!(detail.contains("code 7"), "detail should name the code: {detail}"); + } + + #[test] + fn a_mismatched_epoch_needs_reinstall() { + let mut info = ProtocolInfo::current(); + info.protocol.epoch = info.protocol.epoch.wrapping_add(1); + let reply = ServiceVersionReply { + code: 0, + message: "ok".to_owned(), + protocol: Some(info), + }; + assert!(matches!( + classify_service_version_reply(&reply), + ServiceVersionCheck::NeedsReinstall(_) + )); + assert_eq!(probe_outcome(&reply), CurrentServiceProbe::VersionMismatch); + } + + #[test] + fn a_service_revision_below_the_minimum_needs_reinstall() { + let Some(revision) = MIN_REQUIRED_SERVICE_REVISION.checked_sub(1) else { + // Nothing below the minimum exists to test against. + return; + }; + let mut info = ProtocolInfo::current(); + info.protocol.revision = revision; + let reply = ServiceVersionReply { + code: 0, + message: "ok".to_owned(), + protocol: Some(info), + }; + assert!(matches!( + classify_service_version_reply(&reply), + ServiceVersionCheck::NeedsReinstall(_) + )); + } + + #[test] + fn every_rejection_tells_the_user_what_to_do_about_it() { + let mut old_epoch = ProtocolInfo::current(); + old_epoch.protocol.epoch = old_epoch.protocol.epoch.wrapping_add(1); + + for protocol in [None, Some(old_epoch)] { + for code in [0, 1] { + let reply = ServiceVersionReply { + code, + message: "unsupported command".to_owned(), + protocol: protocol.clone(), + }; + let ServiceVersionCheck::NeedsReinstall(detail) = classify_service_version_reply(&reply) else { + continue; + }; + assert!( + detail.contains("epoch") || detail.contains("protocol"), + "detail should say what mismatched: {detail}" + ); + assert!( + detail.contains("Reinstall") || detail.contains("Repair"), + "detail should name the action that fixes it: {detail}" + ); + } + } + } + + #[test] + fn missing_without_evidence_is_not_installed() { + assert_eq!( + classify_service_health(CurrentServiceProbe::Missing, false, ""), + ServiceHealth::NotInstalled + ); + } + + #[test] + fn missing_with_evidence_means_broken_not_absent() { + assert_eq!( + classify_service_health(CurrentServiceProbe::Missing, true, ""), + ServiceHealth::VersionMismatch + ); + } + + #[test] + fn an_unreachable_service_keeps_the_reason() { + assert_eq!( + classify_service_health(CurrentServiceProbe::Unavailable, true, "socket refused"), + ServiceHealth::Unavailable("socket refused".to_owned()) + ); + } + + #[test] + fn a_live_probe_dominates_the_install_marker() { + for has_marker in [false, true] { + assert_eq!( + classify_service_health(CurrentServiceProbe::Ready, has_marker, ""), + ServiceHealth::Ready + ); + assert_eq!( + classify_service_health(CurrentServiceProbe::VersionMismatch, has_marker, ""), + ServiceHealth::VersionMismatch + ); + } + } +} diff --git a/src-tauri/src/core/runtime_bundle.rs b/src-tauri/src/core/runtime_bundle.rs index df55ef3df..b2a1c13c9 100644 --- a/src-tauri/src/core/runtime_bundle.rs +++ b/src-tauri/src/core/runtime_bundle.rs @@ -1,5 +1,6 @@ use anyhow::{Context as _, Result, bail}; -use clash_verge_service_ipc::{RuntimeAsset, RuntimeBundle}; +use clash_verge_logging::{Type, logging}; +use clash_verge_service_ipc::{RemoteProvider, RuntimeAsset, RuntimeBundle}; use serde_yaml_ng::Value; use std::collections::HashSet; use std::path::{Component, Path, PathBuf}; @@ -24,6 +25,7 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) let config_root = std::fs::canonicalize(config_root)?; let mut assets = Vec::new(); let mut destinations = HashSet::new(); + let mut remote_providers = Vec::new(); collect_provider_assets( &mut config, @@ -31,6 +33,7 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) &config_root, &mut destinations, &mut assets, + &mut remote_providers, )?; collect_provider_assets( &mut config, @@ -38,6 +41,7 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) &config_root, &mut destinations, &mut assets, + &mut remote_providers, )?; for filename in GEO_ASSETS { let source = config_root.join(filename); @@ -52,16 +56,25 @@ pub(crate) async fn collect_runtime_bundle(config_file: &Path, core_path: &Path) Ok(RuntimeBundle { yaml: serde_yaml_ng::to_string(&config).context("failed to serialize service runtime config")?, assets, + remote_providers, core_path: core_path.to_string_lossy().into_owned(), }) } +/// Rewrite one provider section's paths to service-side destinations, and record what each +/// destination is going to hold. +/// +/// The two outputs are not the same thing. `assets` are files the app owns and the service copies; +/// `remote_providers` are files the *core* downloads, which the service never writes and only +/// needs to recognise — a download cache is reusable exactly while the url that produced it is +/// unchanged, and the service has no other way to tell. fn collect_provider_assets( config: &mut Value, section: &str, config_root: &Path, destinations: &mut HashSet, assets: &mut Vec, + remote_providers: &mut Vec, ) -> Result<()> { let Some(providers) = config .as_mapping_mut() @@ -71,23 +84,81 @@ fn collect_provider_assets( return Ok(()); }; - for provider in providers.values_mut().filter_map(Value::as_mapping_mut) { + for (name, provider) in providers.iter_mut() { + let Some(provider) = provider.as_mapping_mut() else { + continue; + }; let Some(raw_path) = provider.get("path").and_then(Value::as_str) else { continue; }; - let is_remote = provider.get("url").and_then(Value::as_str).is_some(); - let destination = if is_remote { - provider_destination(config_root, raw_path)? - } else { - let source = local_provider_source(config_root, raw_path)?; - let destination = destination_below_root(config_root, &source)?; - if destinations.insert(destination.clone()) { - assets.push(RuntimeAsset { - source: source.to_string_lossy().into_owned(), - destination: destination.clone(), - }); + // The Core decides what a provider is by its `type`, so anything else here would disagree + // with it. Keying off the presence of a `url` used to do that: a `file` provider carrying + // a stray `url` was treated as remote, its local file left uncopied, and the Core then + // could not find what the rewritten path pointed at. + // + // Disagreeing the other way is just as bad, though, and it is why neither branch may fail + // the whole bundle over one odd provider. Measured against mihomo v1.19.26: it accepts a + // `http` provider with no url, an `inline` provider carrying both a url and a path that + // does not exist, and a `file` provider whose path is missing. Refusing any of those here + // would turn a configuration the Core is willing to load into one that cannot be applied + // at all — including at start, since this is where a Service-mode start gets its bundle. + // A provider we cannot classify keeps its path unrewritten and is simply not declared. + let is_remote = provider.get("type").and_then(Value::as_str) == Some("http"); + let url = provider.get("url").and_then(Value::as_str).map(str::to_owned); + let destination = match (is_remote, url) { + (true, Some(url)) => { + let destination = provider_destination(config_root, raw_path)?; + // Remote providers take their destination out of circulation as much as local ones + // do: the Service refuses a bundle where one path is claimed both by a file it must + // copy and by a file the Core downloads, because the two would overwrite each + // other. Two providers naming the same file *and* the same source are not that — + // they are one file with two names for it, which the Service folds. + match remote_providers + .iter() + .find(|declared| declared.destination == destination) + { + Some(declared) if declared.url == url => {} + Some(_) => { + bail!("runtime provider destination {destination:?} is declared for two different sources") + } + None => { + if !destinations.insert(destination.clone()) { + bail!("runtime provider destination {destination:?} is claimed more than once"); + } + remote_providers.push(RemoteProvider { + destination: destination.clone(), + url, + }); + } + } + destination + } + (true, None) => { + logging!( + warn, + Type::Config, + "remote provider {name:?} declares no url; leaving its path to the core" + ); + continue; + } + (false, _) => { + let Ok(source) = local_provider_source(config_root, raw_path) else { + logging!( + warn, + Type::Config, + "local provider {name:?} is unavailable at {raw_path:?}; leaving its path to the core" + ); + continue; + }; + let destination = destination_below_root(config_root, &source)?; + if destinations.insert(destination.clone()) { + assets.push(RuntimeAsset { + source: source.to_string_lossy().into_owned(), + destination: destination.clone(), + }); + } + destination } - destination }; provider.insert(Value::String("path".to_owned()), Value::String(destination)); } @@ -210,6 +281,18 @@ mod tests { ); assert!(bundle.assets.iter().any(|asset| asset.destination == "Country.mmdb")); assert!(!bundle.assets.iter().any(|asset| asset.destination.contains("remote"))); + // A remote provider is not copied, but it must still be declared: the Service decides + // whether the Core's download cache is reusable by comparing the url that produced it, + // and an undeclared provider is one it can only ever discard. + assert_eq!( + bundle + .remote_providers + .iter() + .map(|provider| (provider.destination.as_str(), provider.url.as_str())) + .collect::>(), + [("providers/remote.yaml", "https://example.com/p.yaml")], + "the declaration must pair the rewritten destination with the url, not the original path" + ); assert!( !bundle .assets @@ -220,6 +303,85 @@ mod tests { Ok(()) } + #[tokio::test] + async fn two_names_for_one_remote_file_are_folded_rather_than_refused() -> anyhow::Result<()> { + // The Service folds an identical repeat, so refusing it here would block a configuration + // mihomo itself accepts — and this is also where a Service-mode *start* gets its bundle, + // so the refusal would stop the core coming up at all. + let root = std::env::temp_dir().join(format!("clash-verge-bundle-dup-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root)?; + let config = root.join("config.yaml"); + std::fs::write( + &config, + "rule-providers:\n a:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n b:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n", + )?; + let core = root.join("mihomo"); + std::fs::write(&core, b"core")?; + + let bundle = collect_runtime_bundle(&config, &core).await?; + + assert_eq!(bundle.remote_providers.len(), 1, "one file, declared twice"); + assert_eq!(bundle.remote_providers[0].destination, "rules/x.yaml"); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[tokio::test] + async fn two_sources_for_one_remote_file_are_refused() -> anyhow::Result<()> { + let root = std::env::temp_dir().join(format!("clash-verge-bundle-conflict-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root)?; + let config = root.join("config.yaml"); + std::fs::write( + &config, + "rule-providers:\n a:\n type: http\n url: https://one.example/x.yaml\n path: ./rules/x.yaml\n b:\n type: http\n url: https://two.example/x.yaml\n path: ./rules/x.yaml\n", + )?; + let core = root.join("mihomo"); + std::fs::write(&core, b"core")?; + + let Err(error) = collect_runtime_bundle(&config, &core).await else { + anyhow::bail!("two sources cannot own one file"); + }; + + assert!(error.to_string().contains("two different sources"), "{error}"); + std::fs::remove_dir_all(root)?; + Ok(()) + } + + #[tokio::test] + async fn an_odd_provider_is_left_alone_instead_of_failing_the_whole_bundle() -> anyhow::Result<()> { + // Measured against mihomo v1.19.26: it accepts a `http` provider with no url, and a + // non-http provider whose path does not exist. Neither may stop the rest being materialised. + let root = std::env::temp_dir().join(format!("clash-verge-bundle-odd-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root)?; + let config = root.join("config.yaml"); + std::fs::write( + &config, + "rule-providers:\n nourl:\n type: http\n path: ./rules/nourl.yaml\n missing:\n type: inline\n url: https://one.example/i.yaml\n path: ./rules/missing.yaml\n good:\n type: http\n url: https://one.example/g.yaml\n path: ./rules/g.yaml\n", + )?; + let core = root.join("mihomo"); + std::fs::write(&core, b"core")?; + + let bundle = collect_runtime_bundle(&config, &core).await?; + + assert_eq!( + bundle.remote_providers.len(), + 1, + "only the provider that could be classified is declared" + ); + assert_eq!(bundle.remote_providers[0].destination, "rules/g.yaml"); + let reserialized: serde_yaml_ng::Value = serde_yaml_ng::from_str(&bundle.yaml)?; + assert_eq!( + reserialized["rule-providers"]["nourl"]["path"].as_str(), + Some("./rules/nourl.yaml"), + "an unclassifiable provider keeps the path the core was given" + ); + std::fs::remove_dir_all(root)?; + Ok(()) + } + #[tokio::test] async fn rejects_provider_paths_outside_the_config_root() -> anyhow::Result<()> { let root = std::env::temp_dir().join(format!("clash-verge-runtime-bundle-outside-{}", std::process::id())); diff --git a/src-tauri/src/core/service.rs b/src-tauri/src/core/service.rs index 17bf31092..8a2cd3d8e 100644 --- a/src-tauri/src/core/service.rs +++ b/src-tauri/src/core/service.rs @@ -3,17 +3,25 @@ use crate::utils::dirs; use crate::{ config::Config, core::{ - CoreManager, handle::Handle, manager::RunningMode, owner_identity::current_owner_credentials, proxy_control, - runtime_bundle::collect_runtime_bundle, tray::Tray, + CoreManager, + handle::Handle, + manager::RunningMode, + owner_identity::current_owner_credentials, + proxy_control, + runstate::{ + OwnerRecoveryReason, OwnerSample, OwnerStep, OwnerWatch, PendingAction, RUN_STATE, ReadyWaitError, + RunState, RunStateEnv, RunStateStore, ServiceHealth, + }, + runtime_bundle::collect_runtime_bundle, + tray::Tray, }, process::AsyncHandler, - utils::server, }; use anyhow::{Context as _, Result, bail}; use clash_verge_logging::{Type, logging}; use clash_verge_service_ipc::{ - MIN_REQUIRED_SERVICE_REVISION, MacosProxyConfig, OwnerSessionProof, ProtocolInfo, ProtocolVersion, - ProxyApplyOutcome, StartClashRequest, WriterConfig, + MacosProxyConfig, OwnerSessionProof, ProxyApplyOutcome, RuntimeBundle, ServiceErrorCode, StageRuntimeOutcome, + StartClashRequest, WriterConfig, }; use compact_str::CompactString; use once_cell::sync::Lazy; @@ -24,13 +32,24 @@ use std::{ future::Future, path::{Path, PathBuf}, process::Command as StdCommand, - sync::atomic::{AtomicBool, AtomicU64, Ordering}, + sync::atomic::{AtomicU64, Ordering}, time::Duration, }; -use tokio::sync::Notify; static OWNER_MONITOR_GENERATION: AtomicU64 = AtomicU64::new(0); -static ACTIVE_SERVICE_SESSION: Lazy>> = Lazy::new(|| Mutex::new(None)); +static ACTIVE_SERVICE_SESSION: Lazy>> = Lazy::new(|| Mutex::new(None)); + +/// The Service session that owns the running Core, and what that Service can do. +/// +/// The capability is learned once, when the Core is started, and discarded with the session +/// rather than cached globally — it describes *the Service instance that owns this Core*, which +/// is the only scope where it is safe to act on. A Service upgraded underneath a running Core +/// does not silently gain abilities the Core it owns was not started under. +#[derive(Clone)] +struct ActiveServiceSession { + proof: OwnerSessionProof, + supports_runtime_staging: bool, +} fn generate_service_session_token() -> Result { let mut bytes = [0_u8; 32]; @@ -41,14 +60,57 @@ fn generate_service_session_token() -> Result { pub(crate) fn active_service_session() -> Result { ACTIVE_SERVICE_SESSION .lock() - .clone() + .as_ref() + .map(|session| session.proof.clone()) .context("service owner session is not active") } +/// Whether the Service that started the running Core can stage a runtime in place. +/// +/// False for every reason that is not "yes": no session, an older Service, or a protocol query +/// that did not come back. All of them mean the same thing to the caller — take the slow path. +pub(crate) fn active_service_supports_runtime_staging() -> bool { + ACTIVE_SERVICE_SESSION + .lock() + .as_ref() + .is_some_and(|session| session.supports_runtime_staging) +} + pub(crate) fn clear_active_service_session() { ACTIVE_SERVICE_SESSION.lock().take(); } +/// Ask the Service whether it speaks the staging half of the protocol. +/// +/// A failure here is not a failure to start: it only costs the fast path, so it is reported as +/// "no" and logged rather than propagated. +async fn probe_runtime_staging_support() -> bool { + match clash_verge_service_ipc::get_version().await { + Ok(response) if response.code == 0 => response + .data + .as_ref() + .is_some_and(clash_verge_service_ipc::ProtocolInfo::supports_runtime_staging), + Ok(response) => { + logging!( + warn, + Type::Service, + "服务协议查询返回 {}: {};配置变更将走重启路径", + response.code, + response.message + ); + false + } + Err(error) => { + logging!( + warn, + Type::Service, + "无法查询服务协议版本: {error:#};配置变更将走重启路径" + ); + false + } + } +} + fn session_matches_status(proof: &OwnerSessionProof, is_active: bool, active_generation: Option) -> bool { is_active && active_generation == Some(proof.generation) } @@ -68,153 +130,29 @@ pub enum ServiceStatus { } impl ServiceStatus { - const fn install_state(&self) -> ServiceInstallState { - match self { - Self::Checking => ServiceInstallState::Checking, - Self::Ready => ServiceInstallState::Ready, - Self::NotInstalled => ServiceInstallState::NotInstalled, - Self::InstallRequired => ServiceInstallState::InstallRequired, - Self::SidecarAllowed => ServiceInstallState::SidecarAllowed, - Self::NeedsReinstall | Self::ReinstallRequired | Self::ForceReinstallRequired => { - ServiceInstallState::NeedsReinstall - } - Self::UninstallRequired | Self::Unavailable(_) => ServiceInstallState::Unavailable, + /// Flatten a Run State snapshot into the legacy single-slot status. + /// + /// Precedence mirrors what the single slot used to hold: a requested action shadows an + /// accepted Sidecar, which shadows the last observation. Kept until the frontend seam + /// moves to `RunState` wholesale. + fn from_run_state(state: &RunState) -> Self { + if let Some(action) = state.pending { + return match action { + PendingAction::Install => Self::InstallRequired, + PendingAction::Uninstall => Self::UninstallRequired, + PendingAction::Reinstall => Self::ReinstallRequired, + PendingAction::ForceReinstall => Self::ForceReinstallRequired, + }; } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)] -#[serde(rename_all = "camelCase")] -pub enum ServiceInstallState { - Checking, - NotInstalled, - InstallRequired, - Ready, - NeedsReinstall, - SidecarAllowed, - Unavailable, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CurrentServiceProbe { - Missing, - Ready, - VersionMismatch, - Unavailable, -} - -const fn classify_service_install_state(current: CurrentServiceProbe, has_install_marker: bool) -> ServiceInstallState { - match current { - CurrentServiceProbe::Ready => ServiceInstallState::Ready, - CurrentServiceProbe::VersionMismatch => ServiceInstallState::NeedsReinstall, - CurrentServiceProbe::Unavailable => ServiceInstallState::Unavailable, - CurrentServiceProbe::Missing if has_install_marker => ServiceInstallState::NeedsReinstall, - CurrentServiceProbe::Missing => ServiceInstallState::NotInstalled, - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ServiceVersionReply { - code: u16, - message: String, - protocol: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ServiceVersionCheck { - Ready, - NeedsReinstall(String), -} - -fn classify_service_version_reply(reply: &ServiceVersionReply) -> ServiceVersionCheck { - let client = ProtocolVersion::current(); - if reply.code == 0 - && reply - .protocol - .as_ref() - .is_some_and(|info| info.supports_client(client, MIN_REQUIRED_SERVICE_REVISION)) - { - return ServiceVersionCheck::Ready; - } - - let detail = if reply.code == 0 { - match reply.protocol.as_ref() { - Some(info) => format!( - "client requires epoch {} revision >= {}, service reports epoch {} revision {} (build {})", - client.epoch, - MIN_REQUIRED_SERVICE_REVISION, - info.protocol.epoch, - info.protocol.revision, - info.build_version - ), - None => "service did not report protocol information".to_owned(), + if state.sidecar_allowed { + return Self::SidecarAllowed; } - } else { - format!( - "protocol query returned code {} ({}) while expecting epoch {}", - reply.code, reply.message, client.epoch - ) - }; - ServiceVersionCheck::NeedsReinstall(format!( - "Service helper protocol mismatch: {detail}. Choose Reinstall or Repair to continue" - )) -} - -async fn probe_service_version_once() -> Result { - let response = clash_verge_service_ipc::get_version().await?; - Ok(ServiceVersionReply { - code: response.code, - message: response.message, - protocol: response.data, - }) -} - -async fn poll_service_version( - max_attempts: usize, - retry_delay: Duration, - mut probe: F, -) -> Result -where - F: FnMut() -> Fut, - Fut: Future>, -{ - let mut last_error = None; - for attempt in 0..max_attempts { - match probe().await { - Ok(reply) => return Ok(reply), - Err(error) => last_error = Some(error), - } - if attempt + 1 < max_attempts { - tokio::time::sleep(retry_delay).await; - } - } - - Err(last_error.unwrap_or_else(|| anyhow::anyhow!("service version probe was configured with no attempts"))) -} - -fn apply_service_version_reply(manager: &ServiceManager, reply: &ServiceVersionReply) -> Result<()> { - match classify_service_version_reply(reply) { - ServiceVersionCheck::Ready => { - manager.set_status(ServiceStatus::Ready); - Ok(()) - } - ServiceVersionCheck::NeedsReinstall(error) => { - manager.set_status(ServiceStatus::NeedsReinstall); - bail!(error) - } - } -} - -fn apply_service_version_result( - manager: &ServiceManager, - result: Result, - unavailable_context: &'static str, -) -> Result<()> { - match result { - Ok(reply) => apply_service_version_reply(manager, &reply), - Err(error) => { - manager.set_status(ServiceStatus::Unavailable(format!("{unavailable_context}: {error:#}"))); - Err(error).context(unavailable_context) + match &state.health { + ServiceHealth::Unknown => Self::Checking, + ServiceHealth::Ready => Self::Ready, + ServiceHealth::NotInstalled => Self::NotInstalled, + ServiceHealth::VersionMismatch => Self::NeedsReinstall, + ServiceHealth::Unavailable(reason) => Self::Unavailable(reason.clone()), } } } @@ -257,7 +195,7 @@ fn macos_service_install_marker_exists() -> std::io::Result { } #[cfg(windows)] -fn trusted_service_evidence() -> Result { +pub(crate) fn trusted_service_evidence() -> Result { use windows_service::{ Error as WindowsServiceError, service::ServiceAccess, @@ -282,7 +220,7 @@ fn trusted_service_evidence() -> Result { } #[cfg(target_os = "linux")] -fn trusted_service_evidence() -> Result { +pub(crate) fn trusted_service_evidence() -> Result { let unit = format!("{}.service", clash_verge_service_ipc::SERVICE_SLUG); let output = StdCommand::new("systemctl") .args(["show", "--property=LoadState", "--value", &unit]) @@ -298,45 +236,16 @@ fn trusted_service_evidence() -> Result { } #[cfg(target_os = "macos")] -fn trusted_service_evidence() -> Result { +pub(crate) fn trusted_service_evidence() -> Result { macos_service_install_marker_exists().context("failed to inspect launchd service registration") } -async fn detect_service_install_state() -> ServiceInstallState { - let has_trusted_install_marker = match trusted_service_evidence() { - Ok(exists) => exists, - Err(error) => { - logging!( - warn, - Type::Service, - "failed to inspect trusted service evidence: {error:#}" - ); - return ServiceInstallState::Unavailable; - } - }; - - if !has_trusted_install_marker { - return classify_service_install_state(CurrentServiceProbe::Missing, false); - } - - let current = match probe_service_version_once().await { - Ok(reply) if classify_service_version_reply(&reply) == ServiceVersionCheck::Ready => CurrentServiceProbe::Ready, - Ok(_) => CurrentServiceProbe::VersionMismatch, - Err(error) => { - logging!(warn, Type::Service, "current service IPC is unavailable: {error:#}"); - CurrentServiceProbe::Unavailable - } - }; - - classify_service_install_state(current, has_trusted_install_marker) -} - -pub struct ServiceManager { - status: Mutex, - status_generation: AtomicU64, - operation_running: AtomicBool, - operation_done: Notify, -} +/// Legacy façade over [`RUN_STATE`]. +/// +/// Holds no state of its own: Service Health, the requested action and the privileged-operation +/// lock all live in `core::runstate`. This type survives only so that existing call sites keep +/// compiling while the seam moves; it is retired once they read `RunState` directly. +pub struct ServiceManager; #[cfg(any(all(target_os = "macos", feature = "verge-dev"), test))] static SERVICE_CORE_STAGING_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -890,43 +799,85 @@ fn force_reinstall_service() -> Result<()> { }) } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum PrivilegedServiceAction { - Install, - Uninstall, - Reinstall, - ForceReinstall, +/// Dispatch a privileged operation to the platform implementation. +/// +/// Blocking work, so it is handed to a blocking thread: the caller is async. +pub(crate) fn run_privileged_service_action(action: PendingAction) -> Result<()> { + let (operation, label): (fn() -> Result<()>, &'static str) = match action { + PendingAction::Install => (install_service, "install service"), + PendingAction::Uninstall => (uninstall_service, "uninstall service"), + PendingAction::Reinstall => (reinstall_service, "reinstall service"), + PendingAction::ForceReinstall => (force_reinstall_service, "force reinstall service"), + }; + tokio::task::block_in_place(operation).with_context(|| format!("{label} failed")) } -const fn privileged_service_action(status: &ServiceStatus) -> Option { - match status { - ServiceStatus::InstallRequired => Some(PrivilegedServiceAction::Install), - ServiceStatus::UninstallRequired => Some(PrivilegedServiceAction::Uninstall), - ServiceStatus::ReinstallRequired => Some(PrivilegedServiceAction::Reinstall), - ServiceStatus::ForceReinstallRequired => Some(PrivilegedServiceAction::ForceReinstall), - ServiceStatus::Checking - | ServiceStatus::Ready - | ServiceStatus::NotInstalled - | ServiceStatus::NeedsReinstall - | ServiceStatus::SidecarAllowed - | ServiceStatus::Unavailable(_) => None, +/// Describe what the Service should hold, for the Core binary this app would start. +/// +/// Shared by starting and staging so the two cannot disagree about the binary or about how the +/// configuration's provider paths are rewritten — a staged bundle naming a different core is +/// exactly what makes the Service refuse to stage. +async fn collect_service_runtime_bundle(config_file: &Path) -> Result { + let verge_config = Config::verge().await; + let clash_core = verge_config.latest_arc().get_valid_clash_core(); + drop(verge_config); + + let bin_ext = if cfg!(windows) { ".exe" } else { "" }; + let bin_path = service_core_path(&clash_core, bin_ext)?; + collect_runtime_bundle(config_file, &bin_path).await +} + +/// What asking the Service to stage a runtime produced. +/// +/// A refusal carries its code because only some refusals say anything about starting. "This bundle +/// names an asset I will not accept" does: a fresh start materialises the same bundle and is +/// refused the same way, so stopping a working Core would add an outage to a failure that already +/// happened. "You do not own this Core" does not — `start_clash` proposes a new session rather than +/// presenting the old one, so starting is exactly how that gets resolved. +pub(super) enum StageRequest { + Refused { code: u16, message: CompactString }, + Answered(StageRuntimeOutcome), +} + +impl StageRequest { + /// Whether a refusal is about the bundle, and so would be repeated by starting from it. + pub(super) const fn is_about_the_bundle(code: u16) -> bool { + code == ServiceErrorCode::InvalidRuntimeAsset as u16 || code == ServiceErrorCode::InvalidInstallLocation as u16 } } +/// Have the Service make the running Core's runtime match `config_file`, without restarting it. +/// +/// `Err` means the request did not get an answer. A refusal, and `RestartRequired`, both come back +/// as `Ok`: neither is a failure of this function, and only the caller can decide what to do about +/// them. +pub(super) async fn stage_runtime_by_service(config_file: &Path) -> Result { + let session = active_service_session()?; + let credentials = current_owner_credentials()?; + let runtime = collect_service_runtime_bundle(config_file).await?; + + let response = clash_verge_service_ipc::stage_runtime(&credentials, &session, &runtime) + .await + .context("无法连接到Clash Verge Service")?; + if response.code > 0 { + return Ok(StageRequest::Refused { + code: response.code, + message: response.message.into(), + }); + } + response + .data + .map(StageRequest::Answered) + .context("Clash Verge Service 未返回运行时暂存结果") +} + /// 尝试使用服务启动core pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<()> { logging!(info, Type::Service, "尝试使用现有服务启动核心"); clear_active_service_session(); - let verge_config = Config::verge().await; - let clash_core = verge_config.latest_arc().get_valid_clash_core(); - drop(verge_config); - - let bin_ext = if cfg!(windows) { ".exe" } else { "" }; - let bin_path = service_core_path(&clash_core, bin_ext)?; - let credentials = current_owner_credentials()?; - let runtime = collect_runtime_bundle(config_file, &bin_path).await?; + let runtime = collect_service_runtime_bundle(config_file).await?; let proposed_session_token = generate_service_session_token()?; let request = StartClashRequest { runtime, @@ -950,12 +901,16 @@ pub(super) async fn start_with_existing_service(config_file: &Path) -> Result<() } let result = response.data.context("Clash Verge Service 未返回会话信息")?; - *ACTIVE_SERVICE_SESSION.lock() = Some(OwnerSessionProof { - generation: result.session.generation, - token: proposed_session_token, + let supports_runtime_staging = probe_runtime_staging_support().await; + *ACTIVE_SERVICE_SESSION.lock() = Some(ActiveServiceSession { + proof: OwnerSessionProof { + generation: result.session.generation, + token: proposed_session_token, + }, + supports_runtime_staging, }); - server::set_pac_available(true); + // PAC follows the Running Mode; the caller opens it via `core_started(Service)`. start_owner_monitor(); logging!(info, Type::Service, "服务成功启动核心"); Ok(()) @@ -1110,13 +1065,6 @@ pub(super) async fn set_system_proxy_by_service_with_session( response.data.context("Clash Verge Service 未返回系统代理结果") } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OwnerRecoveryReason { - Displaced, - SameOwnerFailure, - TransportFailure, -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] struct OwnerRecoveryPolicy { reset_system_proxy: bool, @@ -1128,189 +1076,48 @@ const fn owner_recovery_policy(_reason: OwnerRecoveryReason, is_macos: bool) -> } } -fn owner_status_recovery_reason( - is_active: bool, - desired_running: bool, - service_state: clash_verge_service_ipc::ServiceLifecycleState, - core_pid: Option, - missing_core_samples: u8, -) -> Option { - if !is_active { - return Some(OwnerRecoveryReason::Displaced); - } - if !desired_running - || service_state == clash_verge_service_ipc::ServiceLifecycleState::Fatal - || (!matches!( - service_state, - clash_verge_service_ipc::ServiceLifecycleState::Starting - | clash_verge_service_ipc::ServiceLifecycleState::RecoveringCore - ) && core_pid.is_none() - && missing_core_samples >= 3) - { - return Some(OwnerRecoveryReason::SameOwnerFailure); - } - None -} - -const fn transport_failure_recovery_reason( - failed_status_samples: u8, - owner_endpoint_available: bool, -) -> Option { - if failed_status_samples >= 3 && !owner_endpoint_available { - Some(OwnerRecoveryReason::TransportFailure) - } else { - None - } -} - -fn mark_service_unavailable_after_owner_loss(manager: &ServiceManager, reason: OwnerRecoveryReason) { +fn mark_service_unavailable_after_owner_loss(store: &RunStateStore, reason: OwnerRecoveryReason) { if matches!(reason, OwnerRecoveryReason::TransportFailure) { - manager.mark_unavailable("service control IPC unavailable after sustained transport failure"); + store.observe(ServiceHealth::Unavailable( + "service control IPC unavailable after sustained transport failure".to_owned(), + )); } } -async fn recover_after_sustained_status_failure(generation: u64, failed_status_samples: u8) -> bool { - if failed_status_samples < 3 { - return false; - } - - let owner_endpoint_available = Handle::mihomo().await.get_version().await.is_ok(); - if let Some(reason) = transport_failure_recovery_reason(failed_status_samples, owner_endpoint_available) { - recover_after_owner_loss(generation, reason).await; - true - } else { - false - } -} - -async fn recover_after_status_mismatch_with( - session: &Mutex>, - generation: u64, - is_active: bool, - active_generation: Option, - recover: F, -) -> bool -where - F: FnOnce(u64, OwnerRecoveryReason) -> Fut, - Fut: Future, -{ - let session_matches = session - .lock() - .as_ref() - .is_some_and(|proof| session_matches_status(proof, is_active, active_generation)); - if session_matches { - return false; - } - recover(generation, OwnerRecoveryReason::Displaced).await; - true -} +/// How often the owner monitor samples Service status. +const OWNER_MONITOR_INTERVAL: Duration = Duration::from_secs(2); +/// Mirrors `OwnerWatch`'s tolerance, for the log line only. +const SUSTAINED_OWNER_SAMPLES: u8 = 3; fn start_owner_monitor() { let generation = OWNER_MONITOR_GENERATION.fetch_add(1, Ordering::AcqRel) + 1; AsyncHandler::spawn(move || async move { - let mut missing_core_samples = 0u8; - let mut failed_status_samples = 0u8; + let mut watch = OwnerWatch::new(); loop { - tokio::time::sleep(Duration::from_secs(2)).await; + tokio::time::sleep(OWNER_MONITOR_INTERVAL).await; if OWNER_MONITOR_GENERATION.load(Ordering::Acquire) != generation { break; } if !matches!(*CoreManager::global().get_running_mode(), RunningMode::Service) { break; } - let response = match current_owner_credentials() { - Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await, - Err(error) => Err(error), - }; - let response = match response { - Ok(response) => response, - Err(error) => { - failed_status_samples = failed_status_samples.saturating_add(1); - if failed_status_samples == 3 { - logging!( - warn, - Type::Service, - "service owner status temporarily unavailable; preserving local proxy state: {error:#}" - ); - } - if recover_after_sustained_status_failure(generation, failed_status_samples).await { - break; - } - if failed_status_samples >= 3 { - failed_status_samples = 0; - } - continue; - } - }; - if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { - recover_after_owner_loss(generation, OwnerRecoveryReason::Displaced).await; - break; - } - if response.code != 0 { - failed_status_samples = failed_status_samples.saturating_add(1); - if failed_status_samples == 3 { + + let sample = read_owner_sample().await; + let mut step = watch.observe(sample); + if matches!(step, OwnerStep::VerifyTransport) { + if watch.just_became_sustained() { logging!( warn, Type::Service, - "service owner status returned error {}; preserving local proxy state: {}", - response.code, - response.message + "service owner status unavailable for {SUSTAINED_OWNER_SAMPLES} samples; \ + preserving local proxy state while the core endpoint still answers" ); } - if recover_after_sustained_status_failure(generation, failed_status_samples).await { - break; - } - if failed_status_samples >= 3 { - failed_status_samples = 0; - } - continue; + let owner_endpoint_available = Handle::mihomo().await.get_version().await.is_ok(); + step = watch.resolve_transport(owner_endpoint_available); } - let Some(status) = response.data else { - failed_status_samples = failed_status_samples.saturating_add(1); - if failed_status_samples == 3 { - logging!( - warn, - Type::Service, - "service owner status omitted data; preserving local proxy state" - ); - } - if recover_after_sustained_status_failure(generation, failed_status_samples).await { - break; - } - if failed_status_samples >= 3 { - failed_status_samples = 0; - } - continue; - }; - if recover_after_status_mismatch_with( - &ACTIVE_SERVICE_SESSION, - generation, - status.is_active, - status.active_generation, - recover_after_owner_loss, - ) - .await - { - break; - } - failed_status_samples = 0; - missing_core_samples = if status.core_pid.is_none() - && !matches!( - status.service_state, - clash_verge_service_ipc::ServiceLifecycleState::Starting - | clash_verge_service_ipc::ServiceLifecycleState::RecoveringCore - ) { - missing_core_samples.saturating_add(1) - } else { - 0 - }; - if let Some(reason) = owner_status_recovery_reason( - status.is_active, - status.desired_core_should_be_running, - status.service_state, - status.core_pid, - missing_core_samples, - ) { + + if let OwnerStep::Recover(reason) = step { recover_after_owner_loss(generation, reason).await; break; } @@ -1318,6 +1125,62 @@ fn start_owner_monitor() { }); } +/// Ask the Service who owns it, flattening every unusable answer into one sample. +/// +/// A transport error, an error code and an empty payload are the same thing to the watch: +/// we did not learn anything. Only the log line distinguishes them. +async fn read_owner_sample() -> OwnerSample { + let response = match current_owner_credentials() { + Ok(credentials) => clash_verge_service_ipc::get_status(&credentials).await, + Err(error) => Err(error), + }; + + let response = match response { + Ok(response) => response, + Err(error) => { + logging!(debug, Type::Service, "service owner status was unreadable: {error:#}"); + return OwnerSample::Unreadable; + } + }; + + if response.code == clash_verge_service_ipc::ServiceErrorCode::NotActive as u16 { + return OwnerSample::NotActive; + } + if response.code != 0 { + logging!( + debug, + Type::Service, + "service owner status returned error {}: {}", + response.code, + response.message + ); + return OwnerSample::Unreadable; + } + let Some(status) = response.data else { + logging!(debug, Type::Service, "service owner status omitted data"); + return OwnerSample::Unreadable; + }; + + // A session that no longer matches is another owner's, whatever the flags say. + if !session_matches_active_status(status.is_active, status.active_generation) { + return OwnerSample::NotActive; + } + + OwnerSample::Status { + is_active: status.is_active, + desired_core_should_be_running: status.desired_core_should_be_running, + service_state: status.service_state, + core_pid: status.core_pid, + } +} + +fn session_matches_active_status(is_active: bool, active_generation: Option) -> bool { + ACTIVE_SERVICE_SESSION + .lock() + .as_ref() + .is_some_and(|session| session_matches_status(&session.proof, is_active, active_generation)) +} + fn cancel_owner_monitors() { OWNER_MONITOR_GENERATION.fetch_add(1, Ordering::AcqRel); } @@ -1363,14 +1226,10 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) { Type::Service, "service owner recovery ({reason:?}); clearing local proxy and PAC state" ); - mark_service_unavailable_after_owner_loss(&SERVICE_MANAGER, reason); + mark_service_unavailable_after_owner_loss(&RUN_STATE, reason); proxy_control::stop_guard().await; - server::set_pac_available(false); clear_active_service_session(); - let manager = CoreManager::global(); - manager.invalidate_core_readiness(); - manager.set_running_mode(RunningMode::NotRunning); - manager.after_core_process(); + CoreManager::global().core_stopped(); if !owner_recovery_policy(reason, cfg!(target_os = "macos")).reset_system_proxy { return; @@ -1395,11 +1254,23 @@ async fn recover_after_owner_loss_while_locked(reason: OwnerRecoveryReason) { } } -async fn wait_for_service_ipc(manager: &ServiceManager) -> Result<()> { +/// Wait for a freshly installed or repaired Service to answer. +/// +/// Silence for the whole budget *is* an observation here — the Service had its window and +/// never spoke — unlike a single failed probe. A readable but rejected reply already recorded +/// its own verdict, which must not be flattened into "unavailable". +async fn wait_for_service_ipc() -> Result<()> { + const CONTEXT: &str = "service IPC did not become available"; let config = ServiceManager::config(); - let result = poll_service_version(config.max_retries, config.retry_delay, probe_service_version_once).await; - apply_service_version_result(manager, result, "service IPC did not become available") + match RUN_STATE.await_ready(config.max_retries, config.retry_delay).await { + Ok(_) => Ok(()), + Err(ReadyWaitError::Unreachable(error)) => { + RUN_STATE.observe(ServiceHealth::Unavailable(format!("{CONTEXT}: {error:#}"))); + Err(error).context(CONTEXT) + } + Err(ReadyWaitError::Rejected(error)) => Err(error).context(CONTEXT), + } } impl ServiceManager { @@ -1412,66 +1283,19 @@ impl ServiceManager { } pub async fn confirm_ready(&self) -> Result<()> { - let reply = probe_service_version_once() - .await - .context("service readiness probe failed")?; - apply_service_version_reply(self, &reply) - } - - /// Returns the latest confirmed readiness without performing IPC. - pub fn is_ready_cached(&self) -> bool { - let status = self.status.lock(); - !self.operation_running.load(Ordering::Acquire) && matches!(*status, ServiceStatus::Ready) + RUN_STATE.probe().await.map(|_| ()) } pub async fn current(&self) -> ServiceStatus { - loop { - let notified = self.operation_done.notified(); - if !self.operation_running.load(Ordering::Acquire) { - let status = self.status.lock().clone(); - if !self.operation_running.load(Ordering::Acquire) { - return status; - } - } - notified.await; - } - } - - pub async fn install_state(&self) -> ServiceInstallState { - self.current().await.install_state() + ServiceStatus::from_run_state(&RUN_STATE.settled().await) } pub fn allow_sidecar_for_session(&self) -> Result<()> { - let mut status = self.status.lock(); - if self.operation_running.load(Ordering::Acquire) { - bail!("service operation already running"); - } - if !matches!( - *status, - ServiceStatus::NotInstalled - | ServiceStatus::NeedsReinstall - | ServiceStatus::InstallRequired - | ServiceStatus::Unavailable(_) - ) { - bail!("sidecar cannot be allowed from service status {status:?}"); - } - self.status_generation.fetch_add(1, Ordering::AcqRel); - *status = ServiceStatus::SidecarAllowed; - drop(status); - Ok(()) + RUN_STATE.allow_sidecar_for_session() } pub fn require_install_for_session(&self) -> Result<()> { - let mut status = self.status.lock(); - if self.operation_running.load(Ordering::Acquire) { - bail!("service operation already running"); - } - if matches!(*status, ServiceStatus::NotInstalled) { - self.status_generation.fetch_add(1, Ordering::AcqRel); - *status = ServiceStatus::InstallRequired; - } - drop(status); - Ok(()) + RUN_STATE.require_install_for_session() } pub(crate) fn mark_unavailable(&self, reason: impl Into) { @@ -1480,56 +1304,18 @@ impl ServiceManager { pub async fn detect_startup_status(&self) { if cfg!(feature = "dev-sidecar") { - self.set_status(ServiceStatus::SidecarAllowed); + RUN_STATE.accept_sidecar(); return; } - let status = match detect_service_install_state().await { - ServiceInstallState::Checking => ServiceStatus::Checking, - ServiceInstallState::Ready => ServiceStatus::Ready, - ServiceInstallState::NotInstalled => ServiceStatus::NotInstalled, - ServiceInstallState::InstallRequired => ServiceStatus::InstallRequired, - ServiceInstallState::NeedsReinstall => ServiceStatus::NeedsReinstall, - ServiceInstallState::SidecarAllowed => ServiceStatus::SidecarAllowed, - ServiceInstallState::Unavailable => ServiceStatus::Unavailable("service detection failed".into()), - }; - self.set_status(status); + RUN_STATE.observe(RUN_STATE.detect_service_health().await); } fn set_status(&self, status: ServiceStatus) { - let mut current = self.status.lock(); - self.status_generation.fetch_add(1, Ordering::AcqRel); - *current = status; - } - - async fn run_operation_and_then( - &self, - operation: impl Future>, - post_operation: Post, - ) -> Result<()> - where - Post: FnOnce() -> PostFuture, - PostFuture: Future>, - { - { - let _status = self.status.lock(); - if self.operation_running.swap(true, Ordering::AcqRel) { - bail!("service operation already running"); - } - self.status_generation.fetch_add(1, Ordering::AcqRel); - } - let operation_completion = scopeguard::guard(self, |manager| { - manager.operation_running.store(false, Ordering::Release); - manager.operation_done.notify_waiters(); - }); - - let result = operation.await; - drop(operation_completion); - result?; - post_operation().await + record_status(&RUN_STATE, status); } async fn run_operation(&self, operation: impl Future>) -> Result<()> { - self.run_operation_and_then(operation, || async { + run_operation_and_then(&RUN_STATE, operation, || async { if let Err(error) = Tray::global().update_menu().await { logging!( warn, @@ -1551,112 +1337,157 @@ impl ServiceManager { } async fn apply_service_status(&self, status: ServiceStatus) -> Result<()> { - self.set_status(status.clone()); - match privileged_service_action(&status) { - Some(PrivilegedServiceAction::Reinstall) => { - logging!(info, Type::Service, "服务需要重装,执行重装流程"); - run_service_command(reinstall_service, "reinstall service")?; - wait_for_service_ipc(self).await?; - Config::restore_tun_for_session().await; - } - Some(PrivilegedServiceAction::ForceReinstall) => { - logging!(info, Type::Service, "服务需要强制重装,执行强制重装流程"); - run_service_command(force_reinstall_service, "force reinstall service")?; - wait_for_service_ipc(self).await?; - Config::restore_tun_for_session().await; - } - Some(PrivilegedServiceAction::Install) => { - logging!(info, Type::Service, "需要安装服务,执行安装流程"); - run_service_command(install_service, "install service")?; - wait_for_service_ipc(self).await?; - Config::restore_tun_for_session().await; - } - Some(PrivilegedServiceAction::Uninstall) => { - logging!(info, Type::Service, "服务需要卸载,执行卸载流程"); - if let Err(error) = run_service_command(uninstall_service, "uninstall service") { - self.set_status(ServiceStatus::Unavailable(format!( - "Service uninstall failed: {error:#}" - ))); - return Err(error); - } - self.set_status(ServiceStatus::NotInstalled); - } - None => match status { - ServiceStatus::Checking => bail!("service status is still being checked"), - ServiceStatus::Ready => logging!(info, Type::Service, "服务就绪,直接启动"), - ServiceStatus::NotInstalled => { - logging!(info, Type::Service, "service is not installed; Sidecar is available"); - } - ServiceStatus::NeedsReinstall => { - bail!("service needs reinstall; explicit authorization is required"); - } - ServiceStatus::Unavailable(reason) => { - logging!(info, Type::Service, "服务不可用: {},将使用Sidecar模式", reason); - bail!("服务不可用: {}", reason); - } - ServiceStatus::SidecarAllowed => { - logging!( - info, - Type::Service, - "Sidecar was explicitly allowed for this app session" - ); - } - ServiceStatus::InstallRequired - | ServiceStatus::UninstallRequired - | ServiceStatus::ReinstallRequired - | ServiceStatus::ForceReinstallRequired => { - bail!("invalid privileged service action mapping") - } - }, - } + // Derived from the caller's own argument, not read back out of the store: an + // observation racing in between would clear the pending action and silently turn a + // user-authorised install into a no-op. + let Some(action) = requested_action(&status) else { + self.set_status(status.clone()); + return report_non_actionable_status(status); + }; + self.set_status(status); + logging!(info, Type::Service, "running privileged service action {action:?}"); + RUN_STATE.perform(action)?; + if !matches!(action, PendingAction::Uninstall) { + wait_for_service_ipc().await?; + Config::restore_tun_for_session().await; + } Ok(()) } } -fn run_service_command(operation: impl FnOnce() -> Result<()>, label: &'static str) -> Result<()> { - tokio::task::block_in_place(operation).with_context(|| format!("{label} failed")) +/// Explain a status that asks for no privileged action, refusing the ones we cannot act on. +fn report_non_actionable_status(status: ServiceStatus) -> Result<()> { + match status { + ServiceStatus::Checking => bail!("service status is still being checked"), + ServiceStatus::Ready => logging!(info, Type::Service, "服务就绪,直接启动"), + ServiceStatus::NotInstalled => { + logging!(info, Type::Service, "service is not installed; Sidecar is available"); + } + ServiceStatus::NeedsReinstall => { + bail!("service needs reinstall; explicit authorization is required"); + } + ServiceStatus::Unavailable(reason) => { + logging!(info, Type::Service, "服务不可用: {},将使用Sidecar模式", reason); + bail!("服务不可用: {}", reason); + } + ServiceStatus::SidecarAllowed => { + logging!( + info, + Type::Service, + "Sidecar was explicitly allowed for this app session" + ); + } + ServiceStatus::InstallRequired + | ServiceStatus::UninstallRequired + | ServiceStatus::ReinstallRequired + | ServiceStatus::ForceReinstallRequired => { + bail!("a requested action should have been handled as a privileged operation") + } + } + Ok(()) } -pub static SERVICE_MANAGER: Lazy = Lazy::new(|| ServiceManager { - status: Mutex::new(ServiceStatus::Checking), - status_generation: AtomicU64::new(0), - operation_running: AtomicBool::new(false), - operation_done: Notify::new(), -}); +/// Run a privileged operation while holding the Run State operation slot. +/// +/// The slot is released — and `settled` waiters woken — before `post_operation` runs, so a +/// post-operation refresh such as the tray menu observes the final state rather than a +/// state still flagged as in-flight. +async fn run_operation_and_then( + store: &RunStateStore, + operation: impl Future>, + post_operation: Post, +) -> Result<()> +where + E: RunStateEnv, + Post: FnOnce() -> PostFuture, + PostFuture: Future>, +{ + let result = { + let _operation = store.begin_operation()?; + operation.await + }; + result?; + post_operation().await +} + +/// Apply a legacy single-slot status to a Run State, splitting it back into observation and +/// request. The inverse of [`ServiceStatus::from_run_state`]. +/// The privileged operation a status is asking for, if any. +/// +/// A pure function of the status so that a caller can decide what to run without reading the +/// store back and racing an observation. +const fn requested_action(status: &ServiceStatus) -> Option { + match status { + ServiceStatus::InstallRequired => Some(PendingAction::Install), + ServiceStatus::UninstallRequired => Some(PendingAction::Uninstall), + ServiceStatus::ReinstallRequired => Some(PendingAction::Reinstall), + ServiceStatus::ForceReinstallRequired => Some(PendingAction::ForceReinstall), + ServiceStatus::Checking + | ServiceStatus::Ready + | ServiceStatus::NotInstalled + | ServiceStatus::NeedsReinstall + | ServiceStatus::SidecarAllowed + | ServiceStatus::Unavailable(_) => None, + } +} + +fn record_status(store: &RunStateStore, status: ServiceStatus) { + if let Some(action) = requested_action(&status) { + store.request_action(action); + return; + } + + match status { + ServiceStatus::SidecarAllowed => store.accept_sidecar(), + ServiceStatus::Checking => store.observe(ServiceHealth::Unknown), + ServiceStatus::Ready => store.observe(ServiceHealth::Ready), + ServiceStatus::NotInstalled => store.observe(ServiceHealth::NotInstalled), + ServiceStatus::NeedsReinstall => store.observe(ServiceHealth::VersionMismatch), + ServiceStatus::Unavailable(reason) => store.observe(ServiceHealth::Unavailable(reason)), + ServiceStatus::InstallRequired + | ServiceStatus::UninstallRequired + | ServiceStatus::ReinstallRequired + | ServiceStatus::ForceReinstallRequired => { + // Recorded by the early return above; listed so a new variant still fails to + // compile here rather than falling through a catch-all. + } + } +} + +pub static SERVICE_MANAGER: ServiceManager = ServiceManager; #[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] mod tests { use super::{ - CurrentServiceProbe, OwnerRecoveryReason, PrivilegedServiceAction, ServiceInstallState, ServiceStatus, - ServiceVersionCheck, ServiceVersionReply, apply_service_version_result, capture_generation_before, - claim_owner_recovery_generation, classify_service_install_state, classify_service_version_reply, + ServiceHealth, ServiceStatus, capture_generation_before, claim_owner_recovery_generation, generate_service_session_token, macos_install_shell, mark_service_unavailable_after_owner_loss, - owner_recovery_policy, owner_status_recovery_reason, poll_service_version, privileged_service_action, - service_core_path_for, session_matches_status, transport_failure_recovery_reason, + owner_recovery_policy, service_core_path_for, session_matches_status, }; #[cfg(unix)] use super::{service_core_path_for_with_publisher, service_tool_path_for}; - use clash_verge_service_ipc::{OwnerSessionProof, ProtocolInfo, ProtocolVersion, ServiceLifecycleState}; + use crate::core::runstate::{FakeEnv, OwnerRecoveryReason, PendingAction, RunStateStore}; + use clash_verge_service_ipc::OwnerSessionProof; #[cfg(unix)] use std::cell::Cell; use std::{ path::{Path, PathBuf}, - sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering}, - time::Duration, + sync::atomic::{AtomicU64, Ordering}, }; - fn current_protocol() -> ProtocolInfo { - ProtocolInfo::current() + /// A Run State backed by a scripted environment, so these tests never touch the global. + fn fake_store() -> RunStateStore { + RunStateStore::new(FakeEnv::new()) } - fn incompatible_protocol() -> ProtocolInfo { - let mut info = ProtocolInfo::current(); - info.protocol = ProtocolVersion { - epoch: info.protocol.epoch.saturating_add(1), - revision: info.protocol.revision, - }; - info + /// The legacy single-slot view of a store, for assertions carried over from before the split. + fn status_of(store: &RunStateStore) -> ServiceStatus { + ServiceStatus::from_run_state(&store.state()) + } + + async fn status_of_settled(store: &RunStateStore) -> ServiceStatus { + ServiceStatus::from_run_state(&store.settled().await) } static TEST_DIRECTORY_GENERATION: AtomicU64 = AtomicU64::new(0); @@ -1872,37 +1703,23 @@ mod tests { assert!(!session_matches_status(&proof, false, Some(7))); } - #[tokio::test] - async fn stale_monitor_mismatch_preserves_newer_session_proof() { + #[test] + fn a_stale_monitor_cannot_displace_a_newer_session() { + // Sample classification now lives in `core::runstate::owner`; what stays here is the + // guard that stops a monitor from a previous Core from tearing down the current one. let generation = AtomicU64::new(8); - let recovery_attempted = AtomicBool::new(false); let newer_proof = OwnerSessionProof { generation: 8, token: "22".repeat(32), }; let session = parking_lot::Mutex::new(Some(newer_proof.clone())); - let generation_for_recovery = &generation; - let recovery_attempted_for_recovery = &recovery_attempted; - let session_for_recovery = &session; - let mismatch_recovered = super::recover_after_status_mismatch_with( - &session, - 7, - true, - Some(7), - |captured_generation, reason| async move { - recovery_attempted_for_recovery.store(true, Ordering::Release); - assert_eq!(reason, OwnerRecoveryReason::Displaced); - if claim_owner_recovery_generation(generation_for_recovery, captured_generation).is_some() { - session_for_recovery.lock().take(); - } - }, - ) - .await; + // A monitor started at generation 7 decides it has been displaced and tries to recover. + if claim_owner_recovery_generation(&generation, 7).is_some() { + session.lock().take(); + } - assert!(mismatch_recovered); - assert!(recovery_attempted.load(Ordering::Acquire)); - assert_eq!(generation.load(Ordering::Acquire), 8); + assert_eq!(generation.load(Ordering::Acquire), 8, "the newer generation stands"); assert_eq!(session.lock().as_ref(), Some(&newer_proof)); } @@ -1918,38 +1735,6 @@ mod tests { Ok(()) } - #[test] - fn owner_loss_reason_distinguishes_displacement_from_same_owner_failure() { - assert_eq!( - owner_status_recovery_reason(false, true, ServiceLifecycleState::Running, Some(42), 0), - Some(OwnerRecoveryReason::Displaced) - ); - assert_eq!( - owner_status_recovery_reason(true, false, ServiceLifecycleState::Running, None, 1), - Some(OwnerRecoveryReason::SameOwnerFailure) - ); - assert_eq!( - owner_status_recovery_reason(true, true, ServiceLifecycleState::Running, None, 2), - None - ); - assert_eq!( - owner_status_recovery_reason(true, true, ServiceLifecycleState::Running, None, 3), - Some(OwnerRecoveryReason::SameOwnerFailure) - ); - assert_eq!( - owner_status_recovery_reason(true, true, ServiceLifecycleState::RecoveringCore, None, u8::MAX), - None - ); - assert_eq!( - owner_status_recovery_reason(true, true, ServiceLifecycleState::Fatal, None, 0), - Some(OwnerRecoveryReason::SameOwnerFailure) - ); - assert_eq!( - owner_status_recovery_reason(true, true, ServiceLifecycleState::Running, Some(42), 3), - None - ); - } - #[test] fn macos_recovery_never_resets_machine_wide_proxy() { for reason in [ @@ -1967,151 +1752,50 @@ mod tests { assert_eq!(claim_owner_recovery_generation(&generation, 7), None); } - #[test] - fn service_version_requires_a_compatible_protocol_reply() { - let exact = ServiceVersionReply { - code: 0, - message: String::new(), - protocol: Some(current_protocol()), - }; - assert_eq!(classify_service_version_reply(&exact), ServiceVersionCheck::Ready); - - for reply in [ - ServiceVersionReply { - code: 0, - message: String::new(), - protocol: Some(incompatible_protocol()), - }, - ServiceVersionReply { - code: 0, - message: String::new(), - protocol: None, - }, - ServiceVersionReply { - code: 1, - message: "unsupported command".to_owned(), - protocol: Some(current_protocol()), - }, - ] { - let check = classify_service_version_reply(&reply); - assert!(matches!(check, ServiceVersionCheck::NeedsReinstall(_))); - if let ServiceVersionCheck::NeedsReinstall(error) = check { - assert!(error.contains("epoch") || error.contains("protocol")); - assert!(error.contains("Reinstall") || error.contains("Repair")); - } - } - } - - #[test] - fn service_version_result_sets_only_safe_terminal_statuses() { - let manager = super::ServiceManager { - status: parking_lot::Mutex::new(ServiceStatus::Checking), - status_generation: AtomicU64::new(0), - operation_running: std::sync::atomic::AtomicBool::new(false), - operation_done: tokio::sync::Notify::new(), - }; - let exact = ServiceVersionReply { - code: 0, - message: String::new(), - protocol: Some(current_protocol()), - }; - assert!(apply_service_version_result(&manager, Ok(exact), "probe failed").is_ok()); - assert_eq!(*manager.status.lock(), ServiceStatus::Ready); - - let mismatch = ServiceVersionReply { - code: 0, - message: String::new(), - protocol: Some(incompatible_protocol()), - }; - assert!(apply_service_version_result(&manager, Ok(mismatch), "probe failed").is_err()); - assert_eq!(*manager.status.lock(), ServiceStatus::NeedsReinstall); - - assert!( - apply_service_version_result(&manager, Err(anyhow::anyhow!("IPC path missing")), "probe failed").is_err() - ); - assert!(matches!(*manager.status.lock(), ServiceStatus::Unavailable(_))); - } - - #[tokio::test] - async fn service_version_poll_retries_transport_then_returns_exact_reply() { - let attempts = AtomicUsize::new(0); - let result = poll_service_version(2, Duration::ZERO, || async { - if attempts.fetch_add(1, Ordering::AcqRel) == 0 { - anyhow::bail!("IPC path not ready"); - } - Ok(ServiceVersionReply { - code: 0, - message: String::new(), - protocol: Some(current_protocol()), - }) - }) - .await; - - assert_eq!(attempts.load(Ordering::Acquire), 2); - assert!(result.is_ok()); - let reply = result.unwrap_or_else(|_| ServiceVersionReply { - code: 1, - message: "probe failed".to_owned(), - protocol: None, - }); - assert_eq!(classify_service_version_reply(&reply), ServiceVersionCheck::Ready); - } - #[test] fn cached_readiness_reflects_confirmed_state_without_mutating_it() { - let manager = super::ServiceManager { - status: parking_lot::Mutex::new(ServiceStatus::Ready), - status_generation: AtomicU64::new(0), - operation_running: std::sync::atomic::AtomicBool::new(false), - operation_done: tokio::sync::Notify::new(), - }; + let store = fake_store(); + store.observe(ServiceHealth::Ready); + let generation = store.generation_count(); - assert!(manager.is_ready_cached()); - assert_eq!(*manager.status.lock(), ServiceStatus::Ready); - assert_eq!(manager.status_generation.load(Ordering::Acquire), 0); + assert!(store.state().service_usable()); + assert_eq!(status_of(&store), ServiceStatus::Ready); + assert_eq!(store.generation_count(), generation, "reading must not change state"); - manager.set_status(ServiceStatus::NotInstalled); - assert!(!manager.is_ready_cached()); - assert_eq!(*manager.status.lock(), ServiceStatus::NotInstalled); - assert_eq!(manager.status_generation.load(Ordering::Acquire), 1); + store.observe(ServiceHealth::NotInstalled); + assert!(!store.state().service_usable()); + assert_eq!(status_of(&store), ServiceStatus::NotInstalled); + assert_eq!(store.generation_count(), generation + 1); } #[test] fn cached_readiness_is_false_while_a_service_operation_is_running() { - let manager = super::ServiceManager { - status: parking_lot::Mutex::new(ServiceStatus::Ready), - status_generation: AtomicU64::new(0), - operation_running: AtomicBool::new(true), - operation_done: tokio::sync::Notify::new(), - }; + let store = fake_store(); + store.observe(ServiceHealth::Ready); + let _operation = store.begin_operation().expect("slot should be free"); - assert!(!manager.is_ready_cached()); - assert_eq!(*manager.status.lock(), ServiceStatus::Ready); - assert_eq!(manager.status_generation.load(Ordering::Acquire), 0); + assert!(!store.state().service_usable()); + // The confirmed observation survives — only usability is withheld. + assert_eq!(status_of(&store), ServiceStatus::Ready); } #[tokio::test] async fn service_operation_finishes_before_post_operation_refresh() { - let manager = super::ServiceManager { - status: parking_lot::Mutex::new(ServiceStatus::Checking), - status_generation: AtomicU64::new(0), - operation_running: AtomicBool::new(false), - operation_done: tokio::sync::Notify::new(), - }; + let store = fake_store(); - let result = manager - .run_operation_and_then( - async { - manager.set_status(ServiceStatus::Ready); - Ok(()) - }, - || async { - assert!(!manager.operation_running.load(Ordering::Acquire)); - assert_eq!(manager.current().await, ServiceStatus::Ready); - Ok(()) - }, - ) - .await; + let result = super::run_operation_and_then( + &store, + async { + store.observe(ServiceHealth::Ready); + Ok(()) + }, + || async { + assert!(!store.operation_in_flight()); + assert_eq!(status_of_settled(&store).await, ServiceStatus::Ready); + Ok(()) + }, + ) + .await; assert!(result.is_ok()); } @@ -2130,75 +1814,32 @@ mod tests { assert_eq!(generation.load(Ordering::Acquire), 8); } - #[test] - fn sustained_transport_failure_recovers_only_when_owner_endpoint_is_also_unavailable() { - assert_eq!(transport_failure_recovery_reason(2, false), None); - assert_eq!(transport_failure_recovery_reason(3, true), None); - assert_eq!( - transport_failure_recovery_reason(3, false), - Some(OwnerRecoveryReason::TransportFailure) - ); - } - #[test] fn only_transport_owner_loss_marks_cached_readiness_unavailable() { for reason in [OwnerRecoveryReason::Displaced, OwnerRecoveryReason::SameOwnerFailure] { - let manager = super::ServiceManager { - status: parking_lot::Mutex::new(ServiceStatus::Ready), - status_generation: AtomicU64::new(0), - operation_running: AtomicBool::new(false), - operation_done: tokio::sync::Notify::new(), - }; - mark_service_unavailable_after_owner_loss(&manager, reason); - assert!(manager.is_ready_cached()); - assert_eq!(*manager.status.lock(), ServiceStatus::Ready); - assert_eq!(manager.status_generation.load(Ordering::Acquire), 0); + let store = fake_store(); + store.observe(ServiceHealth::Ready); + let generation = store.generation_count(); + + mark_service_unavailable_after_owner_loss(&store, reason); + + assert!(store.state().service_usable(), "{reason:?} must not affect readiness"); + assert_eq!(status_of(&store), ServiceStatus::Ready); + assert_eq!(store.generation_count(), generation); } - let manager = super::ServiceManager { - status: parking_lot::Mutex::new(ServiceStatus::Ready), - status_generation: AtomicU64::new(0), - operation_running: AtomicBool::new(false), - operation_done: tokio::sync::Notify::new(), - }; + let store = fake_store(); + store.observe(ServiceHealth::Ready); - mark_service_unavailable_after_owner_loss(&manager, OwnerRecoveryReason::TransportFailure); - assert!(!manager.is_ready_cached()); - let status = manager.status.lock().clone(); + mark_service_unavailable_after_owner_loss(&store, OwnerRecoveryReason::TransportFailure); + + assert!(!store.state().service_usable()); assert!(matches!( - status, + status_of(&store), ServiceStatus::Unavailable(reason) if reason.contains("service control IPC unavailable") )); } - #[test] - fn macos_service_evidence_distinguishes_missing_old_and_current_service() { - assert_eq!( - classify_service_install_state(CurrentServiceProbe::Missing, false), - ServiceInstallState::NotInstalled - ); - assert_eq!( - classify_service_install_state(CurrentServiceProbe::Missing, true), - ServiceInstallState::NeedsReinstall - ); - assert_eq!( - classify_service_install_state(CurrentServiceProbe::Ready, true), - ServiceInstallState::Ready - ); - assert_eq!( - classify_service_install_state(CurrentServiceProbe::VersionMismatch, false), - ServiceInstallState::NeedsReinstall - ); - assert_eq!( - classify_service_install_state(CurrentServiceProbe::Unavailable, false), - ServiceInstallState::Unavailable - ); - assert_eq!( - classify_service_install_state(CurrentServiceProbe::Unavailable, true), - ServiceInstallState::Unavailable - ); - } - #[cfg(target_os = "macos")] #[test] fn legacy_socket_alone_is_not_install_evidence() { @@ -2207,58 +1848,89 @@ mod tests { .iter() .any(|marker| marker == "/tmp/verge/clash-verge-service.sock") ); - assert_eq!( - classify_service_install_state(CurrentServiceProbe::Missing, false), - ServiceInstallState::NotInstalled - ); - } - - #[test] - fn checking_is_public_and_unavailable() { - assert_eq!(ServiceStatus::Checking.install_state(), ServiceInstallState::Checking); - assert_ne!(ServiceInstallState::Checking, ServiceInstallState::Ready); } #[test] fn failed_install_status_can_be_replaced_with_sidecar_allowance() { - let manager = super::ServiceManager { - status: parking_lot::Mutex::new(ServiceStatus::InstallRequired), - status_generation: AtomicU64::new(0), - operation_running: AtomicBool::new(false), - operation_done: tokio::sync::Notify::new(), - }; + let store = fake_store(); + store.request_action(super::PendingAction::Install); + assert_eq!(status_of(&store), ServiceStatus::InstallRequired); + let generation = store.generation_count(); - assert!(manager.allow_sidecar_for_session().is_ok()); - assert_eq!(*manager.status.lock(), ServiceStatus::SidecarAllowed); - assert_eq!(manager.status_generation.load(Ordering::Acquire), 1); + assert!(store.allow_sidecar_for_session().is_ok()); + + assert_eq!(status_of(&store), ServiceStatus::SidecarAllowed); + assert_eq!(store.generation_count(), generation + 1); } #[test] - fn only_explicit_action_states_map_to_privileged_operations() { - assert_eq!(privileged_service_action(&ServiceStatus::Checking), None); - assert_eq!(privileged_service_action(&ServiceStatus::Ready), None); - assert_eq!(privileged_service_action(&ServiceStatus::NotInstalled), None); - assert_eq!(privileged_service_action(&ServiceStatus::NeedsReinstall), None); - assert_eq!(privileged_service_action(&ServiceStatus::SidecarAllowed), None); - assert_eq!( - privileged_service_action(&ServiceStatus::Unavailable("offline".into())), - None - ); - assert_eq!( - privileged_service_action(&ServiceStatus::InstallRequired), - Some(PrivilegedServiceAction::Install) - ); - assert_eq!( - privileged_service_action(&ServiceStatus::UninstallRequired), - Some(PrivilegedServiceAction::Uninstall) - ); - assert_eq!( - privileged_service_action(&ServiceStatus::ReinstallRequired), - Some(PrivilegedServiceAction::Reinstall) - ); - assert_eq!( - privileged_service_action(&ServiceStatus::ForceReinstallRequired), - Some(PrivilegedServiceAction::ForceReinstall) - ); + fn only_explicit_action_states_ask_for_a_privileged_operation() { + for status in [ + ServiceStatus::Checking, + ServiceStatus::Ready, + ServiceStatus::NotInstalled, + ServiceStatus::NeedsReinstall, + ServiceStatus::SidecarAllowed, + ServiceStatus::Unavailable("offline".into()), + ] { + let store = fake_store(); + super::record_status(&store, status.clone()); + assert_eq!( + store.state().pending, + None, + "{status:?} is an observation, not a request" + ); + } + + for (status, expected) in [ + (ServiceStatus::InstallRequired, PendingAction::Install), + (ServiceStatus::UninstallRequired, PendingAction::Uninstall), + (ServiceStatus::ReinstallRequired, PendingAction::Reinstall), + (ServiceStatus::ForceReinstallRequired, PendingAction::ForceReinstall), + ] { + let store = fake_store(); + super::record_status(&store, status.clone()); + assert_eq!(store.state().pending, Some(expected), "{status:?}"); + } + } + + #[test] + fn a_failed_uninstall_leaves_the_service_untrusted_rather_than_absent() { + let store = RunStateStore::new(FakeEnv::new().privileged_operations_fail("no authorization")); + store.observe(crate::core::runstate::ServiceHealth::Ready); + + let error = store + .perform(PendingAction::Uninstall) + .expect_err("an unauthorized uninstall should fail"); + + assert!(error.to_string().contains("no authorization")); + assert!(matches!(status_of(&store), ServiceStatus::Unavailable(_))); + } + + #[test] + fn a_successful_uninstall_records_an_absent_service() { + let store = fake_store(); + store.observe(crate::core::runstate::ServiceHealth::Ready); + + store + .perform(PendingAction::Uninstall) + .expect("uninstall should succeed"); + + assert_eq!(status_of(&store), ServiceStatus::NotInstalled); + assert_eq!(store.env().privileged_actions(), vec![PendingAction::Uninstall]); + } + + #[test] + fn a_failed_install_does_not_claim_the_service_is_gone() { + // Only an uninstall has an outcome we can record without asking the Service again. + let store = RunStateStore::new(FakeEnv::new().privileged_operations_fail("elevation refused")); + store.observe(crate::core::runstate::ServiceHealth::NotInstalled); + super::record_status(&store, ServiceStatus::InstallRequired); + + store + .perform(PendingAction::Install) + .expect_err("a refused install should fail"); + + assert_eq!(status_of(&store), ServiceStatus::InstallRequired); } } diff --git a/src-tauri/src/core/sysopt.rs b/src-tauri/src/core/sysopt.rs index 513e90ce1..7073c9fa3 100644 --- a/src-tauri/src/core/sysopt.rs +++ b/src-tauri/src/core/sysopt.rs @@ -1,4 +1,8 @@ -use crate::{config::Config, singleton, utils::server}; +use crate::{ + config::{Config, MixedPort}, + singleton, + utils::server, +}; use anyhow::Result; use clash_verge_logging::{Type, logging}; use parking_lot::RwLock; @@ -148,10 +152,9 @@ impl Sysopt { pub(super) async fn update_sysproxy(&self) -> Result<()> { let _lock = self.update_lock.lock().await; let verge = Config::verge().await.latest_arc(); - let port = match verge.verge_mixed_port { - Some(port) => port, - None => Config::clash().await.latest_arc().get_mixed_port(), - }; + // Configured, not live: this runs while the Core is being started or restarted, and + // asking a Core that is not up yet would only fall back here anyway. + let port = MixedPort::desired().await; let pac_port = server::embedded_server_port()?; // 先 await, 避免持有锁导致的 Send 问题 let bypass = get_bypass().await; diff --git a/src-tauri/src/core/tray/mod.rs b/src-tauri/src/core/tray/mod.rs index 17b807267..8a98fd14c 100644 --- a/src-tauri/src/core/tray/mod.rs +++ b/src-tauri/src/core/tray/mod.rs @@ -1,5 +1,4 @@ use crate::config::{IProfilePreview, IVerge}; -use crate::core::service; use crate::core::tray::menu_def::TrayAction; use crate::module::lightweight; use crate::process::AsyncHandler; @@ -15,7 +14,6 @@ use crate::{ use clash_verge_limiter::{Limiter, SystemClock, SystemLimiter}; use clash_verge_logging::logging_error; use tauri::tray::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; -use tauri_plugin_clash_verge_sysinfo::is_current_app_handle_admin; use tauri_plugin_mihomo::models::Proxies; use tokio::fs; @@ -208,7 +206,7 @@ impl Tray { let verge = Config::verge().await.latest_arc(); let system_proxy = verge.enable_system_proxy.as_ref().unwrap_or(&false); let tun_mode = verge.enable_tun_mode.as_ref().unwrap_or(&false); - let tun_mode_available = is_current_app_handle_admin(app_handle) || service::SERVICE_MANAGER.is_ready_cached(); + let tun_mode_available = crate::core::runstate::RUN_STATE.state().tun_capable(); let mode = { Config::clash() .await diff --git a/src-tauri/src/enhance/mod.rs b/src-tauri/src/enhance/mod.rs index c1ef11713..1b5c1e777 100644 --- a/src-tauri/src/enhance/mod.rs +++ b/src-tauri/src/enhance/mod.rs @@ -36,6 +36,7 @@ struct ConfigValues { socks_enabled: bool, http_enabled: bool, enable_dns_settings: bool, + enable_external_controller: bool, #[cfg(not(target_os = "windows"))] redir_enabled: bool, #[cfg(target_os = "linux")] @@ -116,8 +117,10 @@ async fn get_config_values() -> ConfigValues { ref verge_socks_enabled, ref verge_http_enabled, ref enable_dns_settings, + ref enable_external_controller, .. } = **verge_arc; + let enable_external_controller = enable_external_controller.unwrap_or(false); let (clash_core, enable_tun, enable_builtin, socks_enabled, http_enabled, enable_dns_settings) = ( Some(verge_arc.get_valid_clash_core()), @@ -145,6 +148,7 @@ async fn get_config_values() -> ConfigValues { socks_enabled, http_enabled, enable_dns_settings, + enable_external_controller, #[cfg(not(target_os = "windows"))] redir_enabled, #[cfg(target_os = "linux")] @@ -330,6 +334,32 @@ const CONTROL_PLANE_KEYS: &[&str] = &[ "unified-delay", ]; +/// The fields the app owns, held across the stages that apply the user's manual overrides. +/// +/// Capturing and restoring are one value rather than two calls because the order is a +/// correctness requirement, not a style: capture must happen after the app has finished +/// deriving these fields and before any override runs, and restore must happen after every +/// override. As four separate calls that contract lived only in comments. +struct AuthoritativeFields { + control_plane: Mapping, + /// Only tracked when the DNS page owns it; otherwise overrides may set `dns.ipv6` freely. + dns_ipv6: Option, +} + +impl AuthoritativeFields { + fn capture(config: &Mapping, enable_dns_settings: bool) -> Self { + Self { + control_plane: snapshot_control_plane(config), + dns_ipv6: enable_dns_settings.then(|| snapshot_dns_ipv6(config)).flatten(), + } + } + + fn enforce(self, config: Mapping) -> Mapping { + let config = enforce_control_plane(config, self.control_plane); + enforce_dns_ipv6(config, self.dns_ipv6) + } +} + /// 手动 merge/script 前保存 app 最终控制面值,只记录当前存在的键。 fn snapshot_control_plane(config: &Mapping) -> Mapping { let mut snapshot = Mapping::new(); @@ -440,11 +470,17 @@ async fn process_profile_items( (config, exists_keys, result_map) } -async fn merge_default_config( +/// Merge the Application Merge Config over the profile's own configuration. +/// +/// Every switch it consults is a parameter: it used to read `enable_external_controller` +/// out of the global config from inside its loop, which made a stage that looks pure depend +/// on process state and left it untestable. +fn merge_default_config( mut config: Mapping, clash_config: Mapping, socks_enabled: bool, http_enabled: bool, + enable_external_controller: bool, #[cfg(not(target_os = "windows"))] redir_enabled: bool, #[cfg(target_os = "linux")] tproxy_enabled: bool, ) -> Mapping { @@ -496,12 +532,6 @@ async fn merge_default_config( } // 处理 external-controller 键的开关逻辑 if key.as_str() == Some("external-controller") { - let enable_external_controller = Config::verge() - .await - .latest_arc() - .enable_external_controller - .unwrap_or(false); - if enable_external_controller { config.insert(key, value); } else { @@ -694,6 +724,7 @@ pub async fn enhance() -> Result<(Mapping, HashSet, HashMap Result<(Mapping, HashSet, HashMap Result<(Mapping, HashSet, HashMap Result<(Mapping, HashSet, HashMap Mapping { + let mut map = Mapping::new(); + for (key, value) in pairs { + map.insert(Value::from(*key), value.clone()); + } + map + } + + const RANGE6: &str = "fdfe:dcba:9876::1/64"; + + #[test] + fn an_ipv6_fake_ip_setup_missing_its_range_gets_one() { + let mut config = dns(&[("ipv6", Value::from(true)), ("enhanced-mode", Value::from("fake-ip"))]); + + ensure_fake_ip_range6(&mut config); + + assert_eq!(config.get(Value::from("fake-ip-range6")), Some(&Value::from(RANGE6))); + } + + #[test] + fn fake_ip_is_the_assumed_mode_when_unstated() { + let mut config = dns(&[("ipv6", Value::from(true))]); + + ensure_fake_ip_range6(&mut config); + + assert_eq!(config.get(Value::from("fake-ip-range6")), Some(&Value::from(RANGE6))); + } + + #[test] + fn a_hand_edited_empty_range_counts_as_missing() { + // The reason this is not just a `contains_key` check: YAML edited by hand often + // leaves the key present with nothing after the colon. + for blank in ["", " "] { + let mut config = dns(&[ + ("ipv6", Value::from(true)), + ("enhanced-mode", Value::from("fake-ip")), + ("fake-ip-range6", Value::from(blank)), + ]); + + ensure_fake_ip_range6(&mut config); + + assert_eq!( + config.get(Value::from("fake-ip-range6")), + Some(&Value::from(RANGE6)), + "blank range {blank:?} should be filled in" + ); + } + } + + #[test] + fn an_existing_range_is_never_overwritten() { + let mut config = dns(&[ + ("ipv6", Value::from(true)), + ("enhanced-mode", Value::from("fake-ip")), + ("fake-ip-range6", Value::from("fc00::1/64")), + ]); + + ensure_fake_ip_range6(&mut config); + + assert_eq!( + config.get(Value::from("fake-ip-range6")), + Some(&Value::from("fc00::1/64")) + ); + } + + #[test] + fn nothing_is_added_without_ipv6_or_outside_fake_ip() { + let mut without_ipv6 = dns(&[("enhanced-mode", Value::from("fake-ip"))]); + ensure_fake_ip_range6(&mut without_ipv6); + assert!(!without_ipv6.contains_key(Value::from("fake-ip-range6"))); + + let mut redir_host = dns(&[ + ("ipv6", Value::from(true)), + ("enhanced-mode", Value::from("redir-host")), + ]); + ensure_fake_ip_range6(&mut redir_host); + assert!(!redir_host.contains_key(Value::from("fake-ip-range6"))); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod use_tun_tests { + use super::{Mapping, Value, use_tun}; + + fn mapping(pairs: &[(&str, Value)]) -> Mapping { + let mut map = Mapping::new(); + for (key, value) in pairs { + map.insert(Value::from(*key), value.clone()); + } + map + } + + fn tun_of(config: &Mapping) -> &Mapping { + config + .get(Value::from("tun")) + .and_then(Value::as_mapping) + .expect("tun should be a mapping") + } + + fn dns_of(config: &Mapping) -> Option<&Mapping> { + config.get(Value::from("dns")).and_then(Value::as_mapping) + } + + #[tokio::test] + async fn the_switch_is_written_either_way() { + assert_eq!( + tun_of(&use_tun(Mapping::new(), true)).get(Value::from("enable")), + Some(&Value::from(true)) + ); + assert_eq!( + tun_of(&use_tun(Mapping::new(), false)).get(Value::from("enable")), + Some(&Value::from(false)) + ); + } + + #[tokio::test] + async fn existing_tun_keys_survive_being_switched_on() { + let config = mapping(&[("tun", Value::Mapping(mapping(&[("stack", Value::from("gvisor"))])))]); + + let result = use_tun(config, true); + + assert_eq!(tun_of(&result).get(Value::from("stack")), Some(&Value::from("gvisor"))); + } + + #[tokio::test] + async fn enabling_tun_sets_up_fake_ip_dns() { + let result = use_tun(Mapping::new(), true); + + let dns = dns_of(&result).expect("enabling tun should write dns settings"); + assert_eq!(dns.get(Value::from("enable")), Some(&Value::from(true))); + assert_eq!(dns.get(Value::from("enhanced-mode")), Some(&Value::from("fake-ip"))); + assert_eq!( + dns.get(Value::from("fake-ip-range")), + Some(&Value::from("198.18.0.1/16")) + ); + } + + #[tokio::test] + async fn a_deliberate_redir_host_setup_is_left_alone() { + // Only fake-ip DNS is ours to configure; someone who chose redir-host meant it. + let config = mapping(&[( + "dns", + Value::Mapping(mapping(&[("enhanced-mode", Value::from("redir-host"))])), + )]); + + let result = use_tun(config, true); + + let dns = dns_of(&result).expect("dns should still be present"); + assert_eq!(dns.get(Value::from("enhanced-mode")), Some(&Value::from("redir-host"))); + assert!(!dns.contains_key(Value::from("fake-ip-range"))); + } + + #[tokio::test] + async fn the_ipv6_fake_ip_range_follows_the_top_level_switch() { + let with_ipv6 = use_tun(mapping(&[("ipv6", Value::from(true))]), true); + let dns = dns_of(&with_ipv6).expect("dns should be written"); + assert_eq!(dns.get(Value::from("ipv6")), Some(&Value::from(true))); + assert_eq!( + dns.get(Value::from("fake-ip-range6")), + Some(&Value::from("fdfe:dcba:9876::1/64")) + ); + + let without_ipv6 = use_tun(Mapping::new(), true); + let dns = dns_of(&without_ipv6).expect("dns should be written"); + assert!(!dns.contains_key(Value::from("fake-ip-range6"))); + } + + #[tokio::test] + async fn switching_tun_off_leaves_dns_untouched() { + // Turning TUN off must not rewrite DNS the user or profile chose; only the system + // resolver is restored, which is not part of the configuration. + let config = mapping(&[("dns", Value::Mapping(mapping(&[("enable", Value::from(false))])))]); + + let result = use_tun(config, false); + + let dns = dns_of(&result).expect("dns should be untouched"); + assert_eq!(dns.get(Value::from("enable")), Some(&Value::from(false))); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod use_sort_tests { + use super::{Mapping, Value, use_sort}; + + #[test] + fn every_key_survives_sorting() { + let mut config = Mapping::new(); + for key in ["rules", "mode", "proxies", "not-a-known-field", "log-level"] { + config.insert(Value::from(key), Value::from(key)); + } + let expected = config.len(); + + let sorted = use_sort(config); + + assert_eq!(sorted.len(), expected, "sorting must not drop or invent keys"); + for key in ["rules", "mode", "proxies", "not-a-known-field", "log-level"] { + assert_eq!(sorted.get(Value::from(key)), Some(&Value::from(key))); + } + } + + #[test] + fn the_bulky_list_fields_are_written_last() { + // These are the fields that make a config file unreadable from the top, so they are + // pushed below the settings a human actually scans for. + let mut config = Mapping::new(); + config.insert(Value::from("rules"), Value::from("rules")); + config.insert(Value::from("mode"), Value::from("rule")); + + let sorted = use_sort(config); + let order: Vec<_> = sorted.keys().filter_map(Value::as_str).collect(); + + let mode = order.iter().position(|key| *key == "mode"); + let rules = order.iter().position(|key| *key == "rules"); + assert!(mode < rules, "expected mode before rules, got {order:?}"); + } + + #[test] + fn sorting_is_stable_when_applied_twice() { + let mut config = Mapping::new(); + for key in ["proxies", "mode", "unknown-key", "rules"] { + config.insert(Value::from(key), Value::from(1)); + } + + let once = use_sort(config); + let twice = use_sort(once.clone()); + + assert_eq!(once.keys().collect::>(), twice.keys().collect::>()); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod merge_default_config_tests { + use super::{Mapping, Value, merge_default_config}; + + fn mapping(pairs: &[(&str, Value)]) -> Mapping { + let mut map = Mapping::new(); + for (key, value) in pairs { + map.insert(Value::from(*key), value.clone()); + } + map + } + + /// Call with every listener enabled, so a test only says what it is turning off. + fn merge(config: Mapping, clash_config: Mapping, external_controller: bool) -> Mapping { + merge_default_config( + config, + clash_config, + true, + true, + external_controller, + #[cfg(not(target_os = "windows"))] + true, + #[cfg(target_os = "linux")] + true, + ) + } + + fn merge_without_optional_listeners(config: Mapping, clash_config: Mapping) -> Mapping { + merge_default_config( + config, + clash_config, + false, + false, + true, + #[cfg(not(target_os = "windows"))] + false, + #[cfg(target_os = "linux")] + false, + ) + } + + #[test] + fn the_merge_config_overwrites_the_profile() { + let config = mapping(&[("mode", Value::from("rule"))]); + let clash_config = mapping(&[("mode", Value::from("global"))]); + + let merged = merge(config, clash_config, true); + + assert_eq!(merged.get(Value::from("mode")), Some(&Value::from("global"))); + } + + #[test] + fn keys_the_merge_config_says_nothing_about_survive() { + let config = mapping(&[("profile-only", Value::from(1))]); + + let merged = merge(config, Mapping::new(), true); + + assert_eq!(merged.get(Value::from("profile-only")), Some(&Value::from(1))); + } + + #[test] + fn a_disabled_listener_is_removed_rather_than_merged() { + // Removed, not just skipped: the profile may have asked for the port itself, and a + // listener the user switched off must not be opened by the profile's own value. + let config = mapping(&[("socks-port", Value::from(7891)), ("port", Value::from(7890))]); + let clash_config = mapping(&[("socks-port", Value::from(1080)), ("port", Value::from(8080))]); + + let merged = merge_without_optional_listeners(config, clash_config); + + assert!(!merged.contains_key(Value::from("socks-port"))); + assert!(!merged.contains_key(Value::from("port"))); + } + + #[test] + fn an_enabled_listener_takes_the_merge_config_value() { + let config = mapping(&[("socks-port", Value::from(7891))]); + let clash_config = mapping(&[("socks-port", Value::from(1080))]); + + let merged = merge(config, clash_config, true); + + assert_eq!(merged.get(Value::from("socks-port")), Some(&Value::from(1080))); + } + + #[test] + fn a_disabled_external_controller_is_blanked_rather_than_dropped() { + // Blanked, not removed: an absent key lets mihomo fall back to its own default and + // listen anyway, which is the opposite of what switching it off means. + let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]); + + let merged = merge(Mapping::new(), clash_config, false); + + assert_eq!( + merged.get(Value::from("external-controller")), + Some(&Value::from("")), + "the key must remain, holding an empty value" + ); + } + + #[test] + fn an_enabled_external_controller_keeps_its_address() { + let clash_config = mapping(&[("external-controller", Value::from("127.0.0.1:9090"))]); + + let merged = merge(Mapping::new(), clash_config, true); + + assert_eq!( + merged.get(Value::from("external-controller")), + Some(&Value::from("127.0.0.1:9090")) + ); + } + + #[test] + fn tun_settings_are_merged_key_by_key_rather_than_replaced() { + // The profile's other tun keys have to survive a merge config that sets only one. + let config = mapping(&[( + "tun", + Value::Mapping(mapping(&[ + ("device", Value::from("utun9")), + ("stack", Value::from("system")), + ])), + )]); + let clash_config = mapping(&[("tun", Value::Mapping(mapping(&[("stack", Value::from("gvisor"))])))]); + + let merged = merge(config, clash_config, true); + + let tun = merged + .get(Value::from("tun")) + .and_then(Value::as_mapping) + .expect("tun should still be a mapping"); + assert_eq!(tun.get(Value::from("device")), Some(&Value::from("utun9"))); + assert_eq!(tun.get(Value::from("stack")), Some(&Value::from("gvisor"))); + } +} + +#[cfg(test)] +#[allow(clippy::expect_used, clippy::panic, reason = "tests assert by panicking")] +mod authoritative_field_tests { + use super::{AuthoritativeFields, Mapping, Value}; + + fn config_with(pairs: &[(&str, Value)]) -> Mapping { + let mut config = Mapping::new(); + for (key, value) in pairs { + config.insert(Value::from(*key), value.clone()); + } + config + } + + fn dns_with_ipv6(enabled: bool) -> Value { + let mut dns = Mapping::new(); + dns.insert(Value::from("ipv6"), Value::from(enabled)); + dns.insert(Value::from("enable"), Value::from(true)); + Value::Mapping(dns) + } + + #[test] + fn an_override_cannot_change_a_field_the_app_owns() { + let derived = config_with(&[("mode", Value::from("rule")), ("secret", Value::from("ours"))]); + let authoritative = AuthoritativeFields::capture(&derived, false); + + let overridden = config_with(&[("mode", Value::from("global")), ("secret", Value::from("theirs"))]); + let result = authoritative.enforce(overridden); + + assert_eq!(result.get(Value::from("mode")), Some(&Value::from("rule"))); + assert_eq!(result.get(Value::from("secret")), Some(&Value::from("ours"))); + } + + #[test] + fn an_override_cannot_introduce_a_field_the_app_left_out() { + // The app decided not to expose the external controller; a profile must not re-add it. + let derived = Mapping::new(); + let authoritative = AuthoritativeFields::capture(&derived, false); + + let overridden = config_with(&[("external-controller", Value::from("0.0.0.0:9090"))]); + let result = authoritative.enforce(overridden); + + assert!(!result.contains_key(Value::from("external-controller"))); + } + + #[test] + fn fields_the_app_does_not_own_survive_an_override() { + let derived = config_with(&[("mode", Value::from("rule"))]); + let authoritative = AuthoritativeFields::capture(&derived, false); + + let overridden = config_with(&[("mode", Value::from("global")), ("profile-key", Value::from(1))]); + let result = authoritative.enforce(overridden); + + assert_eq!(result.get(Value::from("profile-key")), Some(&Value::from(1))); + } + + #[test] + fn dns_ipv6_is_only_reclaimed_when_the_dns_page_owns_it() { + let derived = config_with(&[("dns", dns_with_ipv6(true))]); + + let owned = AuthoritativeFields::capture(&derived, true); + let restored = owned.enforce(config_with(&[("dns", dns_with_ipv6(false))])); + assert_eq!( + restored.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")), + Some(&Value::from(true)), + "with the DNS page on, the app's value wins" + ); + + let unowned = AuthoritativeFields::capture(&derived, false); + let left_alone = unowned.enforce(config_with(&[("dns", dns_with_ipv6(false))])); + assert_eq!( + left_alone.get(Value::from("dns")).and_then(|dns| dns.get("ipv6")), + Some(&Value::from(false)), + "with the DNS page off, an override may set it" + ); + } + + #[test] + fn restoring_dns_ipv6_never_invents_a_dns_block() { + let derived = config_with(&[("dns", dns_with_ipv6(true))]); + let authoritative = AuthoritativeFields::capture(&derived, true); + + // An override removed DNS entirely; reinstating just `ipv6` would be a half-config. + let result = authoritative.enforce(Mapping::new()); + + assert!(!result.contains_key(Value::from("dns"))); + } +} + #[allow(clippy::expect_used)] #[cfg(test)] mod tests { diff --git a/src-tauri/src/feat/clash.rs b/src-tauri/src/feat/clash.rs index 1c34c2948..9f10479ef 100644 --- a/src-tauri/src/feat/clash.rs +++ b/src-tauri/src/feat/clash.rs @@ -1,5 +1,5 @@ use crate::{ - config::Config, + config::{Config, MixedPort}, core::{CoreManager, handle, tray}, feat::clean_async, process::AsyncHandler, @@ -142,10 +142,7 @@ pub async fn test_delay(url: String) -> anyhow::Result { let verge = Config::verge().await.latest_arc(); let proxy_enabled = verge.enable_system_proxy.unwrap_or(false) || verge.enable_tun_mode.unwrap_or(false); let proxy_port = if proxy_enabled { - Some(match verge.verge_mixed_port { - Some(p) => p, - None => Config::clash().await.data_arc().get_mixed_port(), - }) + Some(MixedPort::desired().await) } else { None }; diff --git a/src-tauri/src/feat/config.rs b/src-tauri/src/feat/config.rs index 88bb1bc32..172406be3 100644 --- a/src-tauri/src/feat/config.rs +++ b/src-tauri/src/feat/config.rs @@ -5,7 +5,7 @@ use crate::{ }; use anyhow::Result; use bitflags::bitflags; -use clash_verge_draft::SharedDraft; +use clash_verge_draft::{DraftTransaction, SharedDraft}; use clash_verge_logging::{Type, logging, logging_error}; use serde_yaml_ng::Mapping; @@ -269,24 +269,23 @@ async fn process_terminated_flags(update_flags: UpdateFlags, patch: &IVerge) -> } pub async fn patch_verge(patch: &IVerge, not_save_file: bool) -> Result<()> { - Config::verge().await.edit_draft(|d| d.patch_config(patch)); + let verge = Config::verge().await; + // Applying the flags can fail, and until now that `?` returned straight out of here past + // an `if let Err(..) { discard() }` the compiler could never reach — leaving the failed + // edit sitting in the draft, where every later reader saw a value that was never applied + // and never written to disk. + let transaction = DraftTransaction::new(vec![&verge]); + verge.edit_draft(|d| d.patch_config(patch)); let update_flags = determine_update_flags(patch); logging!(debug, Type::Setup, "Determined update flags: {:?}", update_flags); - let process_flag_result: std::result::Result<(), anyhow::Error> = { - process_terminated_flags(update_flags, patch).await?; - Ok(()) - }; + process_terminated_flags(update_flags, patch).await?; + transaction.commit(); - if let Err(err) = process_flag_result { - Config::verge().await.discard(); - return Err(err); - } - Config::verge().await.apply(); logging_error!(Type::Backup, AutoBackupManager::global().refresh_settings().await); if !not_save_file { // 分离数据获取和异步调用 - let verge_data = Config::verge().await.data_arc(); + let verge_data = verge.data_arc(); logging!(debug, Type::Setup, "Saving Verge configuration to file..."); verge_data.save_file().await?; } diff --git a/src-tauri/src/feat/listener.rs b/src-tauri/src/feat/listener.rs index e63241f90..1a9ab80fb 100644 --- a/src-tauri/src/feat/listener.rs +++ b/src-tauri/src/feat/listener.rs @@ -16,6 +16,7 @@ use crate::{ process::AsyncHandler, }; use anyhow::{Context as _, Result, anyhow, bail}; +use clash_verge_draft::DraftTransaction; use clash_verge_logging::{Type, logging}; use scopeguard::defer; @@ -36,60 +37,45 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result candidate, - Err(error) => { - discard_proxy_port_drafts().await; - return Err(error); - } - }; + // The candidate ports are staged but nothing is serving them yet, so close PAC rather + // than hand out a script for a port that is between owners. + manager.core_starting(); + Config::generate() + .await + .context("failed to generate candidate proxy port configuration")?; + let candidate = latest_runtime_mapping().await?; + let was_running = !matches!(*manager.get_running_mode(), RunningMode::NotRunning); - let assessment = match probe_proxy_ports(current.clone(), candidate.clone(), was_running).await { - Ok(assessment) => assessment, - Err(error) => { - discard_proxy_port_drafts().await; - return Err(error); - } - }; - match rejected_save_outcome(assessment) { - Ok(Some(outcome)) => { - discard_proxy_port_drafts().await; - return Ok(outcome); - } - Ok(None) => {} - Err(error) => { - discard_proxy_port_drafts().await; - return Err(error); - } + let assessment = probe_proxy_ports(current.clone(), candidate.clone(), was_running).await?; + if let Some(outcome) = rejected_save_outcome(assessment)? { + return Ok(outcome); } - let validation = match CoreConfigValidator::global().validate_config_outcome().await { - Ok(validation) => validation, - Err(error) => { - discard_proxy_port_drafts().await; - return Err(error).context("failed to validate candidate proxy port configuration"); - } - }; + let validation = CoreConfigValidator::global() + .validate_config_outcome() + .await + .context("failed to validate candidate proxy port configuration")?; if !validation.is_valid() { - discard_proxy_port_drafts().await; bail!("candidate proxy port configuration is invalid: {validation}"); } - let snapshots = match capture_config_files().await { - Ok(snapshots) => snapshots, - Err(error) => { - discard_proxy_port_drafts().await; - return Err(error); - } - }; + let snapshots = capture_config_files().await?; + // From here the drafts alone are no longer enough to undo things: files are on disk and + // the core may be restarted, so failures restore explicitly as well as rolling back. if let Err(error) = Config::generate_file(ConfigType::Run).await { - discard_proxy_port_drafts().await; + transaction.rollback(); return match restore_files(&snapshots).await { Ok(()) => Err(error).context("failed to persist candidate Runtime Configuration"), Err(rollback_error) => Err(anyhow!("{error:#}; configuration rollback failed: {rollback_error:#}")), @@ -97,6 +83,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result Result Err(persist_error), Err(rollback_error) => Err(anyhow!( @@ -125,9 +113,7 @@ pub async fn save_proxy_ports(settings: ProxyPortSettings) -> Result Result<()> { Ok(()) } +/// Roll the drafts back *now*, rather than on the way out. +/// +/// The transaction discards when it drops, but rollback has to regenerate files from the +/// committed configuration first, so the drafts must be gone before that happens. async fn discard_proxy_port_drafts() { Config::clash().await.discard(); Config::verge().await.discard(); diff --git a/src-tauri/src/feat/mod.rs b/src-tauri/src/feat/mod.rs index 2d353fe23..c2da6df3d 100644 --- a/src-tauri/src/feat/mod.rs +++ b/src-tauri/src/feat/mod.rs @@ -5,6 +5,7 @@ mod icon; mod listener; mod profile; mod proxy; +mod tun; mod window; // Re-export all functions from modules @@ -15,4 +16,5 @@ pub use icon::*; pub use listener::*; pub use profile::*; pub use proxy::*; +pub use tun::*; pub use window::*; diff --git a/src-tauri/src/feat/proxy.rs b/src-tauri/src/feat/proxy.rs index 1abcd9d83..1f1e3b2f2 100644 --- a/src-tauri/src/feat/proxy.rs +++ b/src-tauri/src/feat/proxy.rs @@ -1,5 +1,5 @@ use crate::{ - config::{Config, IVerge}, + config::{Config, IVerge, MixedPort}, core::handle, }; use clash_verge_logging::{Type, logging}; @@ -77,7 +77,10 @@ pub async fn copy_clash_env() { .unwrap_or_else(|| verge_cfg.proxy_host.as_deref().unwrap_or("127.0.0.1")); let app_handle = handle::Handle::app_handle(); - let port = verge_cfg.verge_mixed_port.unwrap_or(7897); + // The user is about to paste this into a shell, so it has to be the port the Core is + // really on — and this path is user-triggered, so a round-trip is affordable. It also + // used to fall back to a hardcoded 7897, ignoring the Merge Config entirely. + let port = MixedPort::effective().await; let http_proxy = format!("http://{ip}:{port}"); let socks5_proxy = format!("socks5://{ip}:{port}"); diff --git a/src-tauri/src/feat/tun.rs b/src-tauri/src/feat/tun.rs new file mode 100644 index 000000000..90e81dc9b --- /dev/null +++ b/src-tauri/src/feat/tun.rs @@ -0,0 +1,80 @@ +//! Turning TUN off when it stops being possible. +//! +//! The decision is [`RunState::tun_should_be_disabled`]; this is the action it triggers. They +//! are separate because the decision is a question about Service capability, which Run State +//! owns, while writing user configuration is not something Run State should do. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use clash_verge_logging::{Type, logging}; +use scopeguard::defer; + +use crate::{ + config::{Config, IVerge}, + core::{handle::Handle, manager::RunningMode, runstate::RUN_STATE}, +}; + +/// Set while a disable is being written. +/// +/// Patching the config produces further Run State transitions, each of which reconciles again; +/// without this the first loss of capability would start an unbounded cascade. Cleared on the +/// way out however that happens — an early return, an error, or a panic — because a flag stuck +/// on would silence every later reconciliation for the life of the process. +static DISABLING_TUN: AtomicBool = AtomicBool::new(false); + +/// Turn TUN off if the Run State says it cannot work. +/// +/// Reads the current Run State rather than taking a snapshot: this is invoked from a spawned +/// task, so whatever transition triggered it may be several states out of date by the time it +/// runs, and acting on a stale "cannot work" is how a transient startup state would silently +/// rewrite the user's configuration. +/// +/// A no-op in every case except a settled one, so it is safe to call on every transition. +pub async fn reconcile_tun_availability() { + let state = RUN_STATE.state(); + + // Before the Core is running the startup decision has not been taken yet: the Service may + // be about to be installed, and `prepare_startup` reads `enable_tun_mode` to decide + // whether to ask. Turning TUN off here would both undo the user's setting and remove the + // very prompt that would have made it work. + if matches!(state.mode, RunningMode::NotRunning) { + return; + } + + // Falling back to Sidecar already suppresses TUN for this session. That is deliberately + // not a persistent change — `Config::disable_tun_and_persist` is the separate, explicit + // act — so honour the decision that was already taken rather than escalating it. + if Config::tun_suppressed_for_session() { + return; + } + + let tun_enabled = Config::verge().await.latest_arc().enable_tun_mode.unwrap_or(false); + if !state.tun_should_be_disabled(tun_enabled) { + return; + } + + if DISABLING_TUN.swap(true, Ordering::AcqRel) { + return; + } + defer! { + DISABLING_TUN.store(false, Ordering::Release); + } + + logging!( + info, + Type::Core, + "TUN mode cannot work in the current run state; turning it off" + ); + let patch = IVerge { + enable_tun_mode: Some(false), + ..IVerge::default() + }; + + match super::patch_verge(&patch, false).await { + Ok(()) => Handle::notice_message("tun_mode::auto_disabled", ""), + Err(error) => { + logging!(error, Type::Core, "failed to turn TUN mode off: {error:#}"); + Handle::notice_message("tun_mode::auto_disable_failed", ""); + } + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 626f45875..0db42b6fe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -150,7 +150,7 @@ mod app_init { cmd::start_core, cmd::stop_core, cmd::restart_core, - cmd::get_running_mode, + cmd::get_runtime_state, cmd::get_auto_launch_status, cmd::entry_lightweight_mode, cmd::exit_lightweight_mode, @@ -158,7 +158,6 @@ mod app_init { cmd::uninstall_service, cmd::reinstall_service, cmd::repair_service, - cmd::get_service_install_state, cmd::continue_with_sidecar, cmd::get_clash_info, cmd::patch_clash_config, diff --git a/src-tauri/src/utils/network.rs b/src-tauri/src/utils/network.rs index e9463cc99..b47545b87 100644 --- a/src-tauri/src/utils/network.rs +++ b/src-tauri/src/utils/network.rs @@ -1,4 +1,4 @@ -use crate::config::Config; +use crate::config::MixedPort; use anyhow::Result; use base64::{Engine as _, engine::general_purpose}; use reqwest::{ @@ -252,13 +252,7 @@ impl NetworkManager { let proxy_url: Option = match proxy_type { ProxyType::None => None, ProxyType::Localhost => { - let port = { - let verge_port = Config::verge().await.data_arc().verge_mixed_port; - match verge_port { - Some(port) => port, - None => Config::clash().await.data_arc().get_mixed_port(), - } - }; + let port = MixedPort::desired().await; Some(format!("http://127.0.0.1:{port}")) } ProxyType::System => { diff --git a/src-tauri/src/utils/server.rs b/src-tauri/src/utils/server.rs index f83846ffd..0354338c0 100644 --- a/src-tauri/src/utils/server.rs +++ b/src-tauri/src/utils/server.rs @@ -1,6 +1,6 @@ use super::resolve; use crate::{ - config::{Config, DEFAULT_PAC}, + config::{Config, DEFAULT_PAC, MixedPort}, module::lightweight, process::AsyncHandler, utils::{dirs, window_manager::WindowManager}, @@ -209,13 +209,11 @@ fn start_embedded_server(listener: tokio::net::TcpListener, token: String) { ); } let verge_config = Config::verge().await; - let clash_config = Config::clash().await; let verge_data = verge_config.data_arc(); - let clash_data = clash_config.data_arc(); let pac_content = verge_data.pac_file_content.as_deref().unwrap_or(DEFAULT_PAC); - let pac_port = verge_data - .verge_mixed_port - .unwrap_or_else(|| clash_data.get_mixed_port()); + // Served per browser request, so this stays a configuration read rather than a + // round-trip to the Core. PAC is closed while the Core is between ports anyway. + let pac_port = MixedPort::desired().await; let processed_content = pac_content.replace("%mixed-port%", &format!("{pac_port}")); Ok::<_, warp::Rejection>( warp::http::Response::builder() @@ -445,21 +443,28 @@ fn replace_file_atomic(source: &Path, destination: &Path) -> Result<()> { #[cfg(test)] mod tests { - use super::{InstanceRecord, PAC_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record}; + use super::{ + InstanceRecord, PAC_INITIAL_AVAILABLE, bind_primary_listener, read_instance_record, write_instance_record, + }; #[cfg(unix)] use super::{open_instance_lock, try_lock_instance}; - use std::sync::atomic::Ordering; #[cfg(feature = "verge-dev")] use super::{INSTANCE_TOKEN_HEADER, dev_quit_route, release_dev_quit_latch}; #[cfg(feature = "verge-dev")] + use std::sync::atomic::Ordering; + #[cfg(feature = "verge-dev")] use std::sync::{ Arc, atomic::{AtomicBool, AtomicUsize}, }; #[test] fn pac_is_fail_closed_before_core_readiness() { - assert!(!PAC_AVAILABLE.load(Ordering::Acquire)); + // Asserts the default rather than the live flag: PAC availability is now derived from + // the Running Mode by `core::runstate`, so any test that drives a Core transition + // legitimately moves the live flag. That the derivation itself can never disagree with + // the Running Mode is covered in `core::runstate` against a fake environment. + const { assert!(!PAC_INITIAL_AVAILABLE) } } #[cfg(feature = "verge-dev")] diff --git a/src/components/home/current-proxy-card.tsx b/src/components/home/current-proxy-card.tsx index cb2f34210..b40f796c9 100644 --- a/src/components/home/current-proxy-card.tsx +++ b/src/components/home/current-proxy-card.tsx @@ -33,6 +33,8 @@ import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' import { EnhancedCard } from '@/components/home/enhanced-card' +import type { ProxySortType } from '@/components/proxy/use-filter-sort' +import { useGroupDelays } from '@/hooks/use-group-delays' import { useProfiles } from '@/hooks/use-profiles' import { useProxySelection } from '@/hooks/use-proxy-selection' import { useVerge } from '@/hooks/use-verge' @@ -53,6 +55,7 @@ import { type ResolvedProxyMember, } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' +import { compareByDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' // 本地存储的键名 const STORAGE_KEY_GROUP = 'clash-verge-selected-proxy-group' @@ -68,7 +71,6 @@ interface ProxyOption { } // 排序类型: 默认 | 按延迟 | 按字母 -type ProxySortType = 0 | 1 | 2 function convertDelayColor( delayValue: number, @@ -233,9 +235,10 @@ export const CurrentProxyCard = () => { const savedSortType = localStorage.getItem(STORAGE_KEY_SORT_TYPE) return savedSortType ? (Number(savedSortType) as ProxySortType) : 0 }) - const [delaySortRefresh, setDelaySortRefresh] = useState(0) const [selectedGroupName, setSelectedGroupName] = useState('') + // Sorting reads delays from a store outside React; this hands them over as a value. + const delays = useGroupDelays(selectedGroupName || null) const autoCheckInProgressRef = useRef(false) const latestTimeoutRef = useRef( @@ -454,17 +457,8 @@ export const CurrentProxyCard = () => { } finally { autoCheckInProgressRef.current = false refreshProxy() - if (sortType === 1) { - setDelaySortRefresh((prev) => prev + 1) - } } - }, [ - isDirectMode, - refreshProxy, - selectedGroupName, - selectedProxyName, - sortType, - ]) + }, [isDirectMode, refreshProxy, selectedGroupName, selectedProxyName]) useEffect(() => { if (isDirectMode) return @@ -559,9 +553,6 @@ export const CurrentProxyCard = () => { } refreshProxy() - if (sortType === 1) { - setDelaySortRefresh((prev) => prev + 1) - } }) // 计算要显示的代理选项(增加非空校验) @@ -572,35 +563,19 @@ export const CurrentProxyCard = () => { const list = [...proxiesToSort] if (sortType === 1) { - const refreshTick = delaySortRefresh const effectiveTimeout = typeof defaultLatencyTimeout === 'number' && defaultLatencyTimeout > 0 ? defaultLatencyTimeout - : 10000 - - const categorizeDelay = (delay: number): [number, number] => { - if (!Number.isFinite(delay)) return [5, Number.MAX_SAFE_INTEGER] - if (delay > 1e5) return [4, delay] - if (delay === 0 || (delay >= effectiveTimeout && delay <= 1e5)) { - return [3, delay || effectiveTimeout] - } - if (delay < 0) return [5, Number.MAX_SAFE_INTEGER] - return [0, delay] - } + : DEFAULT_DELAY_TIMEOUT list.sort((a, b) => { - const [ar, av] = categorizeDelay( - delayManager.getDelayFix(a.member, selectedGroupName), + const byDelay = compareByDelay( + delays.of(a.member), + delays.of(b.member), + effectiveTimeout, ) - const [br, bv] = categorizeDelay( - delayManager.getDelayFix(b.member, selectedGroupName), - ) - - if (ar !== br) return ar - br - if (av !== bv) return av - bv - return refreshTick >= 0 - ? a.member.ref.name.localeCompare(b.member.ref.name) - : 0 + if (byDelay !== 0) return byDelay + return a.member.ref.name.localeCompare(b.member.ref.name) }) } else { list.sort((a, b) => a.member.ref.name.localeCompare(b.member.ref.name)) @@ -614,11 +589,10 @@ export const CurrentProxyCard = () => { } return sortWithLatency(unsortedProxyOptions) }, [ + delays, isDirectMode, unsortedProxyOptions, - selectedGroupName, sortType, - delaySortRefresh, defaultLatencyTimeout, ]) diff --git a/src/components/layout/service-migration-dialog.tsx b/src/components/layout/service-migration-dialog.tsx index 32253936f..37adb93e8 100644 --- a/src/components/layout/service-migration-dialog.tsx +++ b/src/components/layout/service-migration-dialog.tsx @@ -3,21 +3,19 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { BaseDialog } from '@/components/base' -import { fetchSystemState } from '@/hooks/use-system-state' +import { runStateQueryKey } from '@/hooks/use-system-state' import { useVisibility } from '@/hooks/use-visibility' import { continueWithSidecar, - getRunningMode, - getServiceInstallState, + getRuntimeState, installService, reinstallService, repairService, restartCore, - type ServiceInstallState, + type RunState, } from '@/services/cmds' import { showNotice } from '@/services/notice-service' import { setCacheDataAsync, useQuery } from '@/services/query-client' -const installStateQueryKey = ['getServiceInstallState'] as const export const ServiceMigrationDialog = () => { const { t } = useTranslation() @@ -25,61 +23,49 @@ export const ServiceMigrationDialog = () => { const [loading, setLoading] = useState(false) const [stateRefreshFailed, setStateRefreshFailed] = useState(false) const [workflowIncomplete, setWorkflowIncomplete] = useState(false) - const { data: state } = useQuery({ - queryKey: installStateQueryKey, - queryFn: getServiceInstallState, + const { data: runState } = useQuery({ + queryKey: runStateQueryKey, + queryFn: getRuntimeState, enabled: true, retry: 1, - refetchInterval: pageVisible ? 2000 : false, + refetchInterval: pageVisible ? 30000 : false, }) - const dialogState = stateRefreshFailed ? 'unavailable' : state - const open = - loading || - workflowIncomplete || - dialogState === 'installRequired' || - dialogState === 'needsReinstall' || - dialogState === 'unavailable' - const showCheckingMessage = - loading || - (dialogState !== 'installRequired' && - dialogState !== 'needsReinstall' && - dialogState !== 'unavailable') + // Whether the service needs a decision is derived once, in Rust, and travels with the + // snapshot; a failed refresh is treated as needing one, since we cannot tell otherwise. + const needsDecision = + stateRefreshFailed || Boolean(runState?.serviceNeedsAttention) + // Which of the three remedies the dialog offers. A refresh we could not complete is + // treated as an unreachable service, which is what 'repair' is for. + const remedy: 'install' | 'repair' | 'reinstall' = + runState?.pendingAction === 'install' + ? 'install' + : stateRefreshFailed || runState?.service === 'unavailable' + ? 'repair' + : 'reinstall' + const open = loading || workflowIncomplete || needsDecision + const showCheckingMessage = loading || !needsDecision - const refreshInstallState = async () => { + // One cache entry to refresh, so there is nothing left to keep coherent by hand. + const refreshRunState = async () => { try { - const data = await getServiceInstallState() - await setCacheDataAsync(installStateQueryKey, data) + const data = await getRuntimeState() + await setCacheDataAsync(runStateQueryKey, data) setStateRefreshFailed(false) return data } catch (error) { setStateRefreshFailed(true) - await setCacheDataAsync( - installStateQueryKey, - 'unavailable', - ) throw error } } - const refreshSystemAndRunning = async () => { - const [systemState, runningMode] = await Promise.all([ - fetchSystemState(), - getRunningMode(), - ]) - await Promise.all([ - setCacheDataAsync(['getSystemState'], { ...systemState, runningMode }), - setCacheDataAsync(['getRunningMode'], runningMode), - ]) - } - const handleServiceAction = async () => { setLoading(true) setWorkflowIncomplete(true) let actionSucceeded = false try { - if (dialogState === 'installRequired') { + if (remedy === 'install') { await installService() - } else if (dialogState === 'unavailable') { + } else if (remedy === 'repair') { await repairService() } else { await reinstallService() @@ -94,7 +80,7 @@ export const ServiceMigrationDialog = () => { let initialRefreshSucceeded = false try { - await refreshInstallState() + await refreshRunState() initialRefreshSucceeded = true } catch (error) { showNotice.error( @@ -120,7 +106,7 @@ export const ServiceMigrationDialog = () => { let finalRefreshSucceeded = false try { - await refreshInstallState() + await refreshRunState() finalRefreshSucceeded = true } catch (error) { showNotice.error( @@ -128,17 +114,7 @@ export const ServiceMigrationDialog = () => { error, ) } - let revalidationSucceeded = false - try { - await refreshSystemAndRunning() - revalidationSucceeded = true - } catch (error) { - showNotice.error( - 'layout.components.serviceMigration.errors.revalidationFailed', - error, - ) - } - if (restartSucceeded && finalRefreshSucceeded && revalidationSucceeded) { + if (restartSucceeded && finalRefreshSucceeded) { setWorkflowIncomplete(false) showNotice.success('layout.components.serviceMigration.success') } @@ -157,7 +133,7 @@ export const ServiceMigrationDialog = () => { let installRefreshSucceeded = false try { - await refreshInstallState() + await refreshRunState() installRefreshSucceeded = true } catch (error) { showNotice.error( @@ -165,22 +141,12 @@ export const ServiceMigrationDialog = () => { error, ) } - let revalidationSucceeded = false - try { - await refreshSystemAndRunning() - revalidationSucceeded = true - } catch (error) { - showNotice.error( - 'layout.components.serviceMigration.errors.revalidationFailed', - error, - ) - } if (startupError) { showNotice.error( 'layout.components.serviceMigration.errors.sidecarFailed', startupError, ) - } else if (installRefreshSucceeded && revalidationSucceeded) { + } else if (installRefreshSucceeded) { setWorkflowIncomplete(false) } setLoading(false) @@ -191,9 +157,9 @@ export const ServiceMigrationDialog = () => { open={open} title={t('layout.components.serviceMigration.title')} okBtn={t( - dialogState === 'installRequired' + remedy === 'install' ? 'settings.sections.proxyControl.actions.installService' - : dialogState === 'unavailable' + : remedy === 'repair' ? 'layout.components.serviceMigration.repair' : 'layout.components.serviceMigration.reinstall', )} @@ -208,9 +174,9 @@ export const ServiceMigrationDialog = () => { {t( showCheckingMessage ? 'layout.components.serviceMigration.checkingMessage' - : dialogState === 'installRequired' || dialogState === 'unavailable' - ? 'layout.components.serviceMigration.unavailableMessage' - : 'layout.components.serviceMigration.message', + : remedy === 'reinstall' + ? 'layout.components.serviceMigration.message' + : 'layout.components.serviceMigration.unavailableMessage', )} diff --git a/src/components/proxy/proxy-empty-state-model.test.ts b/src/components/proxy/proxy-empty-state-model.test.ts new file mode 100644 index 000000000..075524036 --- /dev/null +++ b/src/components/proxy/proxy-empty-state-model.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, test } from 'vitest' + +import { + resolveEmptyListReason, + resolveProxyListState, +} from './proxy-empty-state-model' + +const settled = { + isProfilesPending: false, + isProxyViewPending: false, + isRunningModePending: false, +} + +const subscribed = { + current: 'a', + items: [{ uid: 'a', type: 'remote' }], +} + +describe('resolveProxyListState', () => { + test('direct mode is its own outcome, not an empty list', () => { + const state = resolveProxyListState({ + ...settled, + mode: 'direct', + profiles: { current: undefined, items: [] }, + }) + + expect(state).toEqual({ kind: 'direct' }) + }) + + test('anything still loading outranks every verdict about emptiness', () => { + for (const pending of [ + { isProfilesPending: true }, + { isProxyViewPending: true }, + { isRunningModePending: true }, + ]) { + const state = resolveProxyListState({ + ...settled, + ...pending, + mode: 'rule', + profiles: { current: undefined, items: [] }, + }) + + expect(state).toEqual({ kind: 'loading' }) + } + }) + + test('no subscription at all is reported before anything about proxies', () => { + const state = resolveProxyListState({ + ...settled, + mode: 'rule', + profiles: { current: undefined, items: [{ uid: 'a', type: 'merge' }] }, + }) + + expect(state).toEqual({ kind: 'empty', reason: 'no-subscriptions' }) + }) + + test('a subscription that is not the active one is its own explanation', () => { + const state = resolveProxyListState({ + ...settled, + mode: 'rule', + profiles: { current: 'other', items: [{ uid: 'a', type: 'remote' }] }, + }) + + expect(state).toEqual({ kind: 'empty', reason: 'inactive-subscription' }) + }) + + test('with a usable subscription it defers to the list rather than guessing', () => { + // The point of the split: this function no longer has an opinion on whether the + // renderer will find anything, because the two opinions used to disagree. + for (const mode of ['rule', 'script', 'global']) { + expect( + resolveProxyListState({ ...settled, mode, profiles: subscribed }), + ).toEqual({ + kind: 'render', + }) + } + }) + + test('it asks nothing about the proxy view at all', () => { + // Guarding the shape, not just the behaviour: reintroducing a proxyView input here is + // how the second, divergent derivation came back. + const inputs = Object.keys({ + ...settled, + mode: 'rule', + profiles: subscribed, + }) + + expect(inputs).not.toContain('proxyView') + expect(inputs).not.toContain('isChainMode') + }) +}) + +describe('resolveEmptyListReason', () => { + test('a stopped core explains an empty list better than the list does', () => { + expect( + resolveEmptyListReason({ + runningMode: 'NotRunning', + isProxyViewError: false, + }), + ).toBe('core-unavailable') + }) + + test('a failed proxy fetch is reported as the core being unavailable', () => { + expect( + resolveEmptyListReason({ + runningMode: 'Sidecar', + isProxyViewError: true, + }), + ).toBe('core-unavailable') + }) + + test('a running core with a clean fetch means the profile simply has nothing', () => { + expect( + resolveEmptyListReason({ + runningMode: 'Sidecar', + isProxyViewError: false, + }), + ).toBe('no-proxy-info') + }) +}) diff --git a/src/components/proxy/proxy-empty-state-model.ts b/src/components/proxy/proxy-empty-state-model.ts index 537d0eb80..03b621e4a 100644 --- a/src/components/proxy/proxy-empty-state-model.ts +++ b/src/components/proxy/proxy-empty-state-model.ts @@ -1,5 +1,3 @@ -import type { ProxyViewV1 } from '@/types/proxy-view' - export type ProxyEmptyStateReason = | 'no-subscriptions' | 'inactive-subscription' @@ -14,75 +12,74 @@ type ProfileSummary = { }> } -interface ProxyEmptyStateInput { +interface ProxyListStateInput { mode: string - isChainMode: boolean profiles?: ProfileSummary isProfilesPending: boolean - proxyView?: ProxyViewV1 isProxyViewPending: boolean - isProxyViewError: boolean - runningMode?: string isRunningModePending: boolean } const isSubscriptionProfile = (item: { type?: string }): boolean => item.type === 'remote' || item.type === 'local' -const hasRenderableProxyContent = ( - proxyView: ProxyViewV1 | undefined, - mode: string, - isChainMode: boolean, -) => { - if (!proxyView) return false +/** + * What the proxy page should show. + * + * Deliberately does *not* answer "is there anything to render". That question is answered by + * the list itself, in `hasRenderableItems`, because predicting it from beside the renderer is + * how the two came to disagree. This decides only the things knowable without building a list: + * direct mode, still-loading, and having no usable subscription to render from. + */ +export type ProxyListState = + | { kind: 'direct' } + | { kind: 'loading' } + | { kind: 'empty'; reason: ProxyEmptyStateReason } + | { kind: 'render' } - if (isChainMode) { - return proxyView.groups.some( - (group) => group.type === 'Selector' || group.type === 'URLTest', - ) - } - - if (mode === 'rule' || mode === 'script') { - return proxyView.groups.some((group) => !group.hidden) - } - - return proxyView.global !== null -} - -export const resolveProxyEmptyState = ({ +export const resolveProxyListState = ({ mode, - isChainMode, profiles, isProfilesPending, - proxyView, isProxyViewPending, - isProxyViewError, - runningMode, isRunningModePending, -}: ProxyEmptyStateInput): ProxyEmptyStateReason | 'loading' | null => { - if (mode === 'direct') return null +}: ProxyListStateInput): ProxyListState => { + if (mode === 'direct') return { kind: 'direct' } if (isProfilesPending || isProxyViewPending || isRunningModePending) { - return 'loading' + return { kind: 'loading' } } if (profiles) { const subscriptions = (profiles.items ?? []).filter(isSubscriptionProfile) - if (subscriptions.length === 0) return 'no-subscriptions' + if (subscriptions.length === 0) { + return { kind: 'empty', reason: 'no-subscriptions' } + } const hasActiveSubscription = subscriptions.some( (item) => item.uid === profiles.current, ) - if (!hasActiveSubscription) return 'inactive-subscription' + if (!hasActiveSubscription) { + return { kind: 'empty', reason: 'inactive-subscription' } + } } - if (hasRenderableProxyContent(proxyView, mode, isChainMode)) { - return null - } - - if (runningMode === 'NotRunning' || isProxyViewError) { - return 'core-unavailable' - } - - return 'no-proxy-info' + return { kind: 'render' } } + +/** + * Why a list that turned out to be empty is empty. + * + * Only reachable once the renderer has built its list and found nothing, so it explains an + * observed emptiness rather than predicting one. + */ +export const resolveEmptyListReason = ({ + runningMode, + isProxyViewError, +}: { + runningMode?: string + isProxyViewError: boolean +}): ProxyEmptyStateReason => + runningMode === 'NotRunning' || isProxyViewError + ? 'core-unavailable' + : 'no-proxy-info' diff --git a/src/components/proxy/proxy-empty-state.tsx b/src/components/proxy/proxy-empty-state.tsx index 52bf1f41b..03b1b663e 100644 --- a/src/components/proxy/proxy-empty-state.tsx +++ b/src/components/proxy/proxy-empty-state.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router' +import { runStateQueryKey } from '@/hooks/use-system-state' import { useAppRefreshers } from '@/providers/app-data-context' import { openLogsDir, restartCore } from '@/services/cmds' import { showNotice } from '@/services/notice-service' @@ -25,7 +26,7 @@ export const ProxyEmptyState = ({ reason }: Props) => { setIsRestarting(true) try { await restartCore() - await Promise.all([refreshProxy(), revalidateQuery(['getRunningMode'])]) + await Promise.all([refreshProxy(), revalidateQuery(runStateQueryKey)]) } catch (error) { showNotice.error(error) } finally { diff --git a/src/components/proxy/proxy-groups.tsx b/src/components/proxy/proxy-groups.tsx index b1d2bcfd3..912c1c593 100644 --- a/src/components/proxy/proxy-groups.tsx +++ b/src/components/proxy/proxy-groups.tsx @@ -32,13 +32,20 @@ import { import { debugLog } from '@/utils/debug' import { ProxyEmptyState } from './proxy-empty-state' -import { resolveProxyEmptyState } from './proxy-empty-state-model' +import { + resolveEmptyListReason, + resolveProxyListState, +} from './proxy-empty-state-model' import { DEFAULT_HOVER_DELAY, ProxyGroupNavigator, } from './proxy-group-navigator' import { ProxyRender } from './proxy-render' -import { type IRenderItem, useRenderList } from './use-render-list' +import { + hasRenderableItems, + type IRenderItem, + useRenderList, +} from './use-render-list' const ProxyGroupsChain = lazy(() => import('./proxy-groups-chain').then((m) => ({ @@ -58,6 +65,22 @@ interface Props { chainConfigData?: string | null } +/** + * The empty state to draw when the render list turns out to contain nothing. + * + * Shared by both list components so the observation and its explanation stay together. + */ +function useEmptyRenderList() { + const { isProxyViewError } = useProxiesData() + const { runningMode } = useSystemData() + + return ( + + ) +} + function useProxyRenderState( mode: string, isChainMode: boolean, @@ -78,16 +101,6 @@ function useProxyRenderState( [activeSelectedGroup, isChainMode, mode], ) - const getGroupHeadState = useCallback( - (groupName: string) => { - const headItem = renderList.find( - (item) => item.type === 1 && item.group?.name === groupName, - ) - return headItem?.headState - }, - [renderList], - ) - const timeout = verge?.default_latency_timeout || 10000 // 测全部延迟 @@ -120,10 +133,8 @@ function useProxyRenderState( } catch (error) { console.error(`[ProxyGroups] 延迟测试出错,组: ${groupName}`, error) } finally { - const headState = getGroupHeadState(groupName) - if (headState?.sortType === 1) { - onHeadState(groupName, { sortType: headState.sortType }) - } + // Re-sorting is no longer poked from here: the delay store announces that the test + // settled and the render list recomputes from that. onProxies() } }), @@ -202,6 +213,7 @@ function ChainProxyGroups(props: { getScrollPosition, saveScrollPosition, } = useProxyRenderState(mode, true, activeSelectedGroup) + const emptyList = useEmptyRenderList() const parentRef = useRef(null) const scrollTopRef = useRef(0) @@ -323,6 +335,9 @@ function ChainProxyGroups(props: { } }) + // The list is built; whether it holds anything is now an observation, not a guess. + if (!hasRenderableItems(renderList)) return emptyList + return ( }> { const { mode, isChainMode = false, chainConfigData } = props const { profiles, isLoading: isProfilesLoading } = useProfiles() - const { proxyView, isProxyViewPending, isProxyViewError } = useProxiesData() - const { runningMode, isRunningModePending } = useSystemData() + const { isProxyViewPending } = useProxiesData() + const { isRunningModePending } = useSystemData() - const emptyState = resolveProxyEmptyState({ + const listState = resolveProxyListState({ mode, - isChainMode, profiles, isProfilesPending: !profiles && isProfilesLoading, - proxyView, isProxyViewPending, - isProxyViewError, - runningMode, isRunningModePending, }) - if (mode === 'direct') { - return + switch (listState.kind) { + case 'direct': + return + case 'loading': + return + case 'empty': + return + case 'render': + // Whether there is anything to draw is the list's own answer, given below. + return isChainMode ? ( + + ) : ( + + ) } - - if (emptyState === 'loading') { - return - } - - if (emptyState) { - return - } - - if (isChainMode) { - return - } - - return } diff --git a/src/components/proxy/use-filter-sort.ts b/src/components/proxy/use-filter-sort.ts index 30bf39694..c5f68f235 100644 --- a/src/components/proxy/use-filter-sort.ts +++ b/src/components/proxy/use-filter-sort.ts @@ -1,5 +1,6 @@ import delayManager from '@/services/delay' import { memberDetails } from '@/types/proxy-view' +import { compareByDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' import { compileStringMatcher } from '@/utils/search-matcher' import type { ResolvedMemberOccurrence } from './use-render-list' @@ -105,31 +106,16 @@ function sortProxies( const effectiveTimeout = typeof latencyTimeout === 'number' && latencyTimeout > 0 ? latencyTimeout - : 10000 + : DEFAULT_DELAY_TIMEOUT if (sortType === 1) { - const categorizeDelay = (delay: number): [number, number] => { - if (!Number.isFinite(delay)) return [3, Number.MAX_SAFE_INTEGER] - if (delay > 1e5) return [4, delay] - if (delay === 0 || (delay >= effectiveTimeout && delay <= 1e5)) { - return [3, delay || effectiveTimeout] - } - if (delay < 0) { - // sentinel delays (-1, -2, etc.) should always sort after real measurements - return [5, Number.MAX_SAFE_INTEGER] - } - return [0, delay] - } - - list.sort((a, b) => { - const ad = delayManager.getDelayFix(a.member, groupName) - const bd = delayManager.getDelayFix(b.member, groupName) - const [ar, av] = categorizeDelay(ad) - const [br, bv] = categorizeDelay(bd) - - if (ar !== br) return ar - br - return av - bv - }) + list.sort((a, b) => + compareByDelay( + delayManager.getDelayFix(a.member, groupName), + delayManager.getDelayFix(b.member, groupName), + effectiveTimeout, + ), + ) } else { list.sort((a, b) => a.member.ref.name.localeCompare(b.member.ref.name)) } diff --git a/src/components/proxy/use-render-list.test.ts b/src/components/proxy/use-render-list.test.ts new file mode 100644 index 000000000..af4fd2e6c --- /dev/null +++ b/src/components/proxy/use-render-list.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from 'vitest' + +import { hasRenderableItems, type IRenderItem } from './use-render-list' + +const group = (hidden = false) => + ({ name: 'Proxies', hidden }) as unknown as IRenderItem['group'] + +/** A group header row. On its own it is only content when the group is visible. */ +const header = (hidden = false): IRenderItem => ({ + type: 0, + key: 'header', + group: group(hidden), +}) + +/** A member row — in chain mode the list is made entirely of these. */ +const member = (key: string): IRenderItem => ({ + type: 2, + key, + group: group(), +}) + +/** A column of members, which is how the chain list renders at column counts above one. */ +const memberColumn = (key: string): IRenderItem => ({ + type: 4, + key, + group: group(), +}) + +describe('hasRenderableItems', () => { + test('an empty list has nothing to show', () => { + expect(hasRenderableItems([])).toBe(false) + }) + + test('chain mode is answered by the chain list itself', () => { + // This is the case the old prediction got wrong in both directions: it asked whether any + // Selector or URLTest group existed, which is unrelated to what the chain list is built + // from. Here there are rows, so there is content, whatever groups happen to exist. + expect(hasRenderableItems([member('chain:a')])).toBe(true) + expect(hasRenderableItems([memberColumn('chain-col:a')])).toBe(true) + }) + + test('a visible group counts even before it is expanded', () => { + expect(hasRenderableItems([header()])).toBe(true) + }) + + test('hidden groups alone are not content', () => { + expect(hasRenderableItems([header(true), header(true)])).toBe(false) + }) + + test('one visible group among hidden ones is enough', () => { + expect(hasRenderableItems([header(true), header(), header(true)])).toBe( + true, + ) + }) + + test('rows inside a hidden but expanded group are still rows on screen', () => { + // Truthfulness over tidiness: the user can see these, so calling the page empty would be + // a lie. The old predicate said "empty" here. + expect(hasRenderableItems([header(true), member('member:a')])).toBe(true) + }) +}) diff --git a/src/components/proxy/use-render-list.ts b/src/components/proxy/use-render-list.ts index 1e202c6cb..a47f44c92 100644 --- a/src/components/proxy/use-render-list.ts +++ b/src/components/proxy/use-render-list.ts @@ -1,9 +1,10 @@ import { useEffect, useMemo, useRef } from 'react' import { useRuntimeConfig } from '@/hooks/use-clash' +import { useGroupsDelays } from '@/hooks/use-group-delays' import { useVerge } from '@/hooks/use-verge' import { useAppRefreshers, useProxiesData } from '@/providers/app-data-context' -import delayManager from '@/services/delay' +import delayManager, { type DelaySnapshot } from '@/services/delay' import { isInteractableMember, resolveMember, @@ -42,12 +43,30 @@ export interface IRenderItem { testUrl?: string } +/** + * Whether the list about to be drawn contains anything a user would call content. + * + * Derived from the list itself rather than predicted beside it. The prediction that used to + * live in the empty-state model asked a different question in chain mode — whether any + * Selector or URLTest group existed — which is unrelated to what the chain list is actually + * built from, so both a false "empty" and a false "not empty" were reachable. + * + * A group header alone counts only when the group is visible; every other row is content by + * definition, including the members of a group that is hidden but expanded. + */ +export const hasRenderableItems = ( + renderList: readonly IRenderItem[], +): boolean => renderList.some((item) => item.type !== 0 || !item.group.hidden) + type GroupCache = { now: string | undefined members: ProxyGroupView['members'] headState: HeadState col: number latencyTimeout: number | undefined + /// This group's own delays. Compared by identity so that a test settling in one group + /// does not throw away every other group's sorted order. + delays: DelaySnapshot | undefined items: IRenderItem[] } @@ -188,6 +207,19 @@ export const useRenderList = ( verge?.default_latency_timeout, ]) + // Every group this list draws, so a test settling in any of them re-sorts that group. + const renderedGroupNames = useMemo(() => { + if (!proxyView) return [] + if (isChainMode) + return selectedGroup ? [selectedGroup] : [CHAIN_DELAY_GROUP] + return mode === 'rule' || mode === 'script' + ? proxyView.groups.map(({ name }) => name) + : proxyView.global + ? [proxyView.global.name] + : [] + }, [isChainMode, mode, proxyView, selectedGroup]) + const groupDelays = useGroupsDelays(renderedGroupNames) + const groupCacheRef = useRef>(new Map()) const prevListRef = useRef([]) @@ -244,7 +276,8 @@ export const useRenderList = ( cached.members === group.members && cached.headState === headState && cached.col === col && - cached.latencyTimeout === latencyTimeout + cached.latencyTimeout === latencyTimeout && + cached.delays === groupDelays.get(group.name) ) { return cached.items } @@ -309,6 +342,7 @@ export const useRenderList = ( headState, col, latencyTimeout, + delays: groupDelays.get(group.name), items: ret, }) return ret @@ -325,6 +359,7 @@ export const useRenderList = ( }, [ chainOccurrences, col, + groupDelays, headStates, isChainMode, latencyTimeout, diff --git a/src/components/shared/proxy-control-switches.tsx b/src/components/shared/proxy-control-switches.tsx index 807fc25f6..d196d6e60 100644 --- a/src/components/shared/proxy-control-switches.tsx +++ b/src/components/shared/proxy-control-switches.tsx @@ -135,8 +135,9 @@ const ProxyControlSwitches = ({ const { uninstallServiceAndStartSidecar } = useServiceUninstaller() const { indicator: systemProxyIndicator, toggleSystemProxy } = useSystemProxyState() - const { isServiceInstallReady, isTunModeAvailable, mutateSystemState } = - useSystemState() + const { runState, isTunModeAvailable, mutateSystemState } = useSystemState() + // Offer to uninstall only a service that is actually there and working. + const isServiceInstallReady = runState.serviceUsable const sysproxyRef = useRef(null) const tunRef = useRef(null) diff --git a/src/components/test/test-item.tsx b/src/components/test/test-item.tsx index 965a80c55..86991506e 100644 --- a/src/components/test/test-item.tsx +++ b/src/components/test/test-item.tsx @@ -2,18 +2,16 @@ import { useSortable } from '@dnd-kit/sortable' import { CSS } from '@dnd-kit/utilities' import { LanguageRounded } from '@mui/icons-material' import { Box, Divider, MenuItem, Menu, styled, alpha } from '@mui/material' -import { UnlistenFn } from '@tauri-apps/api/event' import { useLockFn } from 'ahooks' import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { BaseLoading } from '@/components/base' import { useIconCache } from '@/hooks/use-icon-cache' -import { useListen } from '@/hooks/use-listen' import { cmdTestDelay } from '@/services/cmds' import delayManager from '@/services/delay' +import { subscribeVergeEvents } from '@/services/events' import { showNotice } from '@/services/notice-service' -import { debugLog } from '@/utils/debug' import { TestBox } from './test-box' @@ -47,7 +45,6 @@ export const TestItem = ({ const [delay, setDelay] = useState(-1) const { uid, name, icon, url } = itemData const iconCachePath = useIconCache({ icon, cacheKey: uid }) - const { addListener } = useListen() const onDelay = useCallback(async () => { setDelay(-2) @@ -74,29 +71,10 @@ export const TestItem = ({ { label: t('shared.actions.delete'), handler: onDelete }, ] - useEffect(() => { - let unlistenFn: UnlistenFn | null = null - - const setupListener = async () => { - if (unlistenFn) { - unlistenFn() - } - unlistenFn = await addListener('verge://test-all', () => { - onDelay() - }) - } - - setupListener() - - return () => { - if (unlistenFn) { - debugLog( - `TestItem for ${id} unmounting or url changed, cleaning up test-all listener.`, - ) - unlistenFn() - } - } - }, [url, addListener, onDelay, id]) + useEffect( + () => subscribeVergeEvents({ 'verge://test-all': () => onDelay() }), + [url, onDelay], + ) return ( -1 } + +/** + * The delays a list sorts by, as a value it can depend on. + * + * Delays live in a module-level store outside React, so a memo that reads them has nothing + * React can see and goes stale — which is why the two places that sorted by delay each grew + * their own way of forcing a recompute, and why both missed the single-proxy path. + * + * `useSyncExternalStore` rather than an effect plus state: the value is read during render + * while the store notifies from an animation frame, so a settle landing between the two would + * otherwise be dropped with nothing left to re-trigger it. It also keeps the identity stable + * between settles, which a snapshot rebuilt per render would not — that would re-sort on + * every render, which is worse than the hack it replaces. + * + * The store announces when a test *settles*, not on every measurement: a list re-sorting on + * each result would reshuffle for the length of a batch, moving rows out from under the + * pointer. Per-proxy displays stay live on their own subscription and are unaffected. + */ +export const useGroupDelays = (group: string | null): DelaySnapshot => { + const subscribe = useCallback( + (onSettle: () => void) => + group ? delayManager.addGroupListener(group, onSettle) : () => {}, + [group], + ) + const read = useCallback( + () => (group ? delayManager.groupDelays(group) : NO_DELAYS), + [group], + ) + + return useSyncExternalStore(subscribe, read, read) +} + +/** + * The delays for several groups at once, keyed by group name. + * + * The map's identity changes whenever any of them settles, but each group's own entry keeps + * its identity unless that group settled — so a per-group cache is not thrown away wholesale + * because a neighbouring group finished a test. + */ +export const useGroupsDelays = ( + groups: readonly string[], +): ReadonlyMap => { + // Joined so the subscription tracks membership rather than the array identity, which a + // list rebuilds on every render. + const groupKey = groups.join(' ') + + const subscribe = useCallback( + (onSettle: () => void) => { + const names = groupKey ? groupKey.split(' ') : [] + const unsubscribes = names.map((name) => + delayManager.addGroupListener(name, onSettle), + ) + return () => { + for (const unsubscribe of unsubscribes) unsubscribe() + } + }, + [groupKey], + ) + + const read = useCallback( + () => delayManager.groupsDelays(groupKey), + [groupKey], + ) + + return useSyncExternalStore(subscribe, read, read) +} diff --git a/src/hooks/use-system-state.ts b/src/hooks/use-system-state.ts index 941a36d1b..0f04eeb7a 100644 --- a/src/hooks/use-system-state.ts +++ b/src/hooks/use-system-state.ts @@ -1,162 +1,63 @@ -import { useEffect, useRef, useState } from 'react' - import { - getRunningMode, - getServiceInstallState, - isAdmin, + getRuntimeState, + type RunState, type RunningMode, - type ServiceInstallState, } from '@/services/cmds' -import { showNotice } from '@/services/notice-service' import { useQuery } from '@/services/query-client' -import { useVerge } from './use-verge' import { useVisibility } from './use-visibility' -export interface SystemState { - runningMode: RunningMode - isAdminMode: boolean - isServiceInstallReady: boolean - serviceInstallState: ServiceInstallState | null -} - -const defaultSystemState = { - runningMode: 'Sidecar', - isAdminMode: false, - isServiceInstallReady: false, - serviceInstallState: null, -} as SystemState - -export const fetchSystemState = async (): Promise => { - const [runningMode, isAdminMode, serviceInstallState] = await Promise.all([ - getRunningMode(), - isAdmin(), - getServiceInstallState().catch(() => null), - ]) - return { - runningMode, - isAdminMode, - isServiceInstallReady: serviceInstallState === 'ready', - serviceInstallState, - } -} - -const isServiceChoicePending = ( - state: ServiceInstallState | null, - serviceInstallReady: boolean, - queryFailed: boolean, -) => - queryFailed || - state === 'sidecarAllowed' || - !(state === 'notInstalled' || (state === 'ready' && serviceInstallReady)) - -// Grace period for service initialization during startup -const STARTUP_GRACE_MS = 10_000 +export const runStateQueryKey = ['getRuntimeState'] as const /** - * 自定义 hook 用于获取系统运行状态 - * 包括运行模式、管理员状态、系统服务是否可用 + * Until the first snapshot arrives, assume the least capable environment: no service, no + * elevation, nothing asked of the user. Guessing "ready" here would flash a usable TUN toggle. + */ +const unknownRunState: RunState = { + mode: 'NotRunning', + service: 'unknown', + serviceUnavailableReason: null, + pendingAction: null, + sidecarAllowed: false, + isAdmin: false, + opInFlight: false, + serviceUsable: false, + tunCapable: false, + serviceNeedsAttention: false, +} + +/** + * The Run State: how the core is running and what backs it. + * + * One query key, kept fresh by `verge://run-state-changed` rather than polling. Every derived + * answer is computed in Rust and travels with the snapshot, so there is exactly one definition + * of "TUN can work" in the app. */ export function useSystemState() { const pageVisible = useVisibility() - const [isStartingUp, setIsStartingUp] = useState(true) - - useEffect(() => { - const timer = setTimeout(() => setIsStartingUp(false), STARTUP_GRACE_MS) - return () => clearTimeout(timer) - }, []) const { - data: systemState = defaultSystemState, + data: runState = unknownRunState, refetch: mutateSystemState, isLoading, - error: systemStateError, } = useQuery({ - queryKey: ['getSystemState'], - queryFn: fetchSystemState, - refetchInterval: pageVisible ? (isStartingUp ? 2000 : 30000) : false, + queryKey: runStateQueryKey, + queryFn: getRuntimeState, + // A safety net only: transitions are pushed, so this is not the primary path. + refetchInterval: pageVisible ? 30000 : false, refetchOnWindowFocus: true, refetchOnReconnect: true, }) - const isSidecarMode = systemState.runningMode === 'Sidecar' - const isServiceMode = systemState.runningMode === 'Service' - const isTunModeAvailable = - systemState.isAdminMode || systemState.isServiceInstallReady - const serviceChoicePending = isServiceChoicePending( - systemState.serviceInstallState, - systemState.isServiceInstallReady, - systemStateError !== undefined, - ) - return { - runningMode: systemState.runningMode, - isAdminMode: systemState.isAdminMode, - isServiceInstallReady: systemState.isServiceInstallReady, - isSidecarMode, - isServiceMode, - isTunModeAvailable, - serviceChoicePending, + runState, + runningMode: runState.mode as RunningMode, + isAdminMode: runState.isAdmin, + isSidecarMode: runState.mode === 'Sidecar', + isServiceMode: runState.mode === 'Service', + isTunModeAvailable: runState.tunCapable, + serviceNeedsAttention: runState.serviceNeedsAttention, mutateSystemState, isLoading, - isStartingUp, } } - -export function useTunAvailabilityGuard() { - const { verge, patchVerge } = useVerge() - const { isTunModeAvailable, serviceChoicePending, isLoading, isStartingUp } = - useSystemState() - const disablingTunRef = useRef(false) - const cooldownTimerRef = useRef | null>(null) - const enable_tun_mode = verge?.enable_tun_mode - - useEffect(() => { - if (enable_tun_mode === undefined) return - - if ( - !disablingTunRef.current && - enable_tun_mode && - !isTunModeAvailable && - !serviceChoicePending && - !isLoading && - !isStartingUp - ) { - disablingTunRef.current = true - patchVerge({ enable_tun_mode: false }) - .then(() => { - showNotice.info( - 'settings.sections.system.notifications.tunMode.autoDisabled', - ) - }) - .catch((err) => { - console.error('[useVerge] 自动关闭TUN模式失败:', err) - showNotice.error( - 'settings.sections.system.notifications.tunMode.autoDisableFailed', - ) - }) - .finally(() => { - // 避免 verge 数据更新不及时导致重复执行关闭 Tun 模式 - cooldownTimerRef.current = setTimeout(() => { - disablingTunRef.current = false - cooldownTimerRef.current = null - }, 1000) - }) - } - - return () => { - if (cooldownTimerRef.current != null) { - clearTimeout(cooldownTimerRef.current) - cooldownTimerRef.current = null - disablingTunRef.current = false - } - } - }, [ - enable_tun_mode, - isTunModeAvailable, - serviceChoicePending, - patchVerge, - isLoading, - isStartingUp, - ]) -} diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 638524af4..2f4f0a5bb 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -42,7 +42,6 @@ import { WindowResizeHandles, } from '@/components/layout/window-controller' import { useI18n } from '@/hooks/use-i18n' -import { useTunAvailabilityGuard } from '@/hooks/use-system-state' import { useVerge } from '@/hooks/use-verge' import { useWindowDecorations } from '@/hooks/use-window' import { useThemeMode } from '@/services/states' @@ -123,8 +122,6 @@ const Layout = () => { const navigate = useNavigate() const themeReady = useMemo(() => Boolean(theme), [theme]) - useTunAvailabilityGuard() - const [menuUnlocked, setMenuUnlocked] = useState(false) const [menuContextPosition, setMenuContextPosition] = useState(null) diff --git a/src/pages/_layout/hooks/use-layout-events.ts b/src/pages/_layout/hooks/use-layout-events.ts index b42a5a119..fc584231c 100644 --- a/src/pages/_layout/hooks/use-layout-events.ts +++ b/src/pages/_layout/hooks/use-layout-events.ts @@ -1,46 +1,20 @@ import { useEffect } from 'react' -import { useListen } from '@/hooks/use-listen' -import { revalidateQueries } from '@/services/query-client' +import { runStateQueryKey } from '@/hooks/use-system-state' +import type { RunState } from '@/services/cmds' +import { subscribeVergeEvents } from '@/services/events' +import { revalidateQueries, setCacheDataAsync } from '@/services/query-client' export const useLayoutEvents = ( handleNotice: (payload: [string, string]) => void, ) => { - const { addListener } = useListen() - useEffect(() => { - const unlisteners: Array<() => void> = [] - let disposed = false const revalidateKeys = (keys: readonly string[]) => { void revalidateQueries(keys.map((key) => [key])) } - const register = ( - maybeUnlisten: void | (() => void) | Promise void)>, - ) => { - if (!maybeUnlisten) return - - if (typeof maybeUnlisten === 'function') { - unlisteners.push(maybeUnlisten) - return - } - - maybeUnlisten - .then((unlisten) => { - if (!unlisten) return - if (disposed) { - unlisten() - } else { - unlisteners.push(unlisten) - } - }) - .catch((error) => - console.error('[Event Listener] Registration failed:', error), - ) - } - - register( - addListener('verge://refresh-clash-config', () => { + return subscribeVergeEvents({ + 'verge://refresh-clash-config': () => { revalidateKeys([ 'getProxyView', 'getVersion', @@ -51,47 +25,16 @@ export const useLayoutEvents = ( 'getRules', 'getRuleProviders', ]) - }), - ) - - register( - addListener('verge://refresh-verge-config', () => { - revalidateKeys([ - 'getVergeConfig', - 'getSystemProxy', - 'getAutotemProxy', - 'getRunningMode', - 'getSystemState', - ]) - }), - ) - - register( - addListener('verge://notice-message', ({ payload }) => - handleNotice(payload as [string, string]), - ), - ) - - return () => { - disposed = true - const errors: Error[] = [] - - unlisteners.forEach((unlisten) => { - try { - unlisten() - } catch (error) { - errors.push(error instanceof Error ? error : new Error(String(error))) - } - }) - - if (errors.length > 0) { - console.error( - `[Event Listener] Encountered ${errors.length} errors during cleanup:`, - errors, - ) - } - - unlisteners.length = 0 - } - }, [addListener, handleNotice]) + }, + 'verge://refresh-verge-config': () => { + revalidateKeys(['getVergeConfig', 'getSystemProxy', 'getAutotemProxy']) + }, + // The Run State is pushed, not polled: every transition carries the new snapshot, so it + // is written straight into the cache instead of triggering a fetch. + 'verge://run-state-changed': (payload) => { + void setCacheDataAsync(runStateQueryKey, payload) + }, + 'verge://notice-message': handleNotice, + }) + }, [handleNotice]) } diff --git a/src/pages/_layout/utils/notification-handlers.ts b/src/pages/_layout/utils/notification-handlers.ts index 82c8c520f..b8930dc46 100644 --- a/src/pages/_layout/utils/notification-handlers.ts +++ b/src/pages/_layout/utils/notification-handlers.ts @@ -24,6 +24,14 @@ export const handleNoticeMessage = ( showNotice.error(msg) }, 'set_config::error': () => showNotice.error(msg), + 'tun_mode::auto_disabled': () => + showNotice.info( + 'settings.sections.system.notifications.tunMode.autoDisabled', + ), + 'tun_mode::auto_disable_failed': () => + showNotice.error( + 'settings.sections.system.notifications.tunMode.autoDisableFailed', + ), 'app_restart::core_stop_failed': () => showNotice.error('layout.feedback.errors.restartCoreStopFailed'), 'app_quit::core_stop_failed': () => diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 7299640a5..f274255d5 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -25,7 +25,7 @@ import { TextSnippetOutlined, } from '@mui/icons-material' import { Box, Button, Divider, Grid, IconButton, Stack } from '@mui/material' -import { listen, TauriEvent } from '@tauri-apps/api/event' +import { TauriEvent } from '@tauri-apps/api/event' import { readText } from '@tauri-apps/plugin-clipboard-manager' import { readTextFile } from '@tauri-apps/plugin-fs' import { useLockFn } from 'ahooks' @@ -60,6 +60,7 @@ import { reorderProfile, updateProfile, } from '@/services/cmds' +import { subscribeVergeEvents } from '@/services/events' import { showNotice } from '@/services/notice-service' import { fetchCacheData, @@ -584,52 +585,32 @@ const ProfilePage = () => { [setLoadingCache], ) - useEffect(() => { - let disposed = false - let unlisteners: Array<() => void> = [] - - Promise.allSettled([ - listen<{ uid?: string }>('profile-update-started', ({ payload }) => { - if (payload.uid) setLoadingProfiles([payload.uid], true) + useEffect( + () => + subscribeVergeEvents({ + 'profile-update-started': ({ uid }) => { + if (uid) setLoadingProfiles([uid], true) + }, + 'profile-update-completed': ({ uid }) => { + if (!uid) return + setLoadingProfiles([uid], false) + setCompletedUpdateRevisions((current) => { + const next = new Map(current) + next.set(uid, (next.get(uid) ?? 0) + 1) + return next + }) + void mutateProfiles() + }, + 'verge://timer-updated': (uid) => { + setTimerUpdateRevisions((current) => { + const next = new Map(current) + next.set(uid, (next.get(uid) ?? 0) + 1) + return next + }) + }, }), - listen<{ uid?: string }>('profile-update-completed', ({ payload }) => { - const { uid } = payload - if (!uid) return - setLoadingProfiles([uid], false) - setCompletedUpdateRevisions((current) => { - const next = new Map(current) - next.set(uid, (next.get(uid) ?? 0) + 1) - return next - }) - void mutateProfiles() - }), - listen('verge://timer-updated', ({ payload: uid }) => { - setTimerUpdateRevisions((current) => { - const next = new Map(current) - next.set(uid, (next.get(uid) ?? 0) + 1) - return next - }) - }), - ]).then((results) => { - const registeredUnlisteners = results.flatMap((result) => - result.status === 'fulfilled' ? [result.value] : [], - ) - results.forEach((result) => { - if (result.status === 'rejected') console.error(result.reason) - }) - - if (disposed) { - registeredUnlisteners.forEach((unlisten) => unlisten()) - } else { - unlisteners = registeredUnlisteners - } - }) - - return () => { - disposed = true - unlisteners.forEach((unlisten) => unlisten()) - } - }, [mutateProfiles, setLoadingProfiles]) + [mutateProfiles, setLoadingProfiles], + ) const runProfileUpdates = useCallback( async (uids: string[]) => { diff --git a/src/providers/app-data-provider.test.tsx b/src/providers/app-data-provider.test.tsx new file mode 100644 index 000000000..a5a4f003b --- /dev/null +++ b/src/providers/app-data-provider.test.tsx @@ -0,0 +1,74 @@ +// @vitest-environment jsdom +import { render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, test, vi } from 'vitest' + +/** + * Stub the Tauri boundary and nothing above it. + * + * Everything the provider mounts — SWR queries, the config hooks, the event subscription, + * the mihomo plugin — goes through `invoke` or `listen`, so replacing just those two leaves + * all the real application code running. A test that stubbed the hooks instead could not + * catch what this one exists to catch. + */ +vi.mock('@tauri-apps/api/core', () => ({ + invoke: vi.fn(async () => undefined), + Channel: class {}, +})) +vi.mock('@tauri-apps/api/event', () => ({ + listen: vi.fn(async () => () => {}), + TauriEvent: {}, +})) + +import { AppDataProvider } from './app-data-provider' + +beforeEach(() => { + // The provider's queries poll; keep them from firing during the assertion. + vi.useFakeTimers({ shouldAdvanceTime: true }) +}) + +afterEach(() => { + vi.useRealTimers() +}) + +/** + * Mounting the provider is the whole test. + * + * A hook inside `AppDataProvider` that reads a context `AppDataProvider` itself supplies + * throws during render and blanks the entire UI. That shipped once. `typecheck`, `lint`, + * `knip` and `web:build` all passed with it present, and so did an independent review — + * nothing but rendering can see it. + */ +test('AppDataProvider mounts and renders its children', () => { + render( + + child rendered + , + ) + + expect(screen.getByText('child rendered')).toBeDefined() +}) + +test('AppDataProvider does not consume a context it provides', () => { + // The failure mode is specific enough to name: `useCtx` throws + // " must be used within AppDataProvider" when a provider reads its own context, + // which is indistinguishable at the type level from a legitimate call site. + const errors: unknown[] = [] + const consoleError = vi + .spyOn(console, 'error') + .mockImplementation((...args) => { + errors.push(args) + }) + + expect(() => + render( + + child rendered + , + ), + ).not.toThrow() + + const reported = errors.flat().join(' ') + expect(reported).not.toContain('must be used within AppDataProvider') + + consoleError.mockRestore() +}) diff --git a/src/providers/app-data-provider.tsx b/src/providers/app-data-provider.tsx index f1d7d3a0e..b28a173bb 100644 --- a/src/providers/app-data-provider.tsx +++ b/src/providers/app-data-provider.tsx @@ -1,4 +1,3 @@ -import { listen } from '@tauri-apps/api/event' import React, { useCallback, useEffect, useMemo, useRef } from 'react' import { getBaseConfig, @@ -7,13 +6,15 @@ import { } from 'tauri-plugin-mihomo-api' import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash' +import { runStateQueryKey } from '@/hooks/use-system-state' import { useVerge } from '@/hooks/use-verge' import { getAppUptime, getProxyView, - getRunningMode, + getRuntimeState, getSystemProxy, } from '@/services/cmds' +import { subscribeVergeEvents } from '@/services/events' import { revalidateQueries, useQuery } from '@/services/query-client' import { resolveDisplayedMixedPort } from '@/utils/mixed-port' @@ -100,11 +101,13 @@ export const AppDataProvider = ({ ...TQ_DEFAULTS, }) - const { data: runningMode, isPending: isRunningModePending } = useQuery({ - queryKey: ['getRunningMode'], - queryFn: getRunningMode, + // Same key as `useSystemState`, so this is the one Run State cache entry, not a second one. + const { data: runState, isPending: isRunningModePending } = useQuery({ + queryKey: runStateQueryKey, + queryFn: getRuntimeState, ...TQ_DEFAULTS, }) + const runningMode = runState?.mode const { data: uptimeData } = useQuery({ queryKey: ['appUptime'], @@ -125,10 +128,7 @@ export const AppDataProvider = ({ let lastProfileUpdateTime = 0 let lastProxyUpdateTime = 0 const refreshThrottle = 800 - const cleanupFns: Array<() => void> = [] - - const handleProfileChanged = (event: { payload: string }) => { - const newProfileId = event.payload + const handleProfileChanged = (newProfileId: string) => { const now = Date.now() if ( lastProfileId === newProfileId && @@ -152,49 +152,11 @@ export const AppDataProvider = ({ void revalidateQueries([['getProfiles']]) } - const initializeListeners = async () => { - try { - const unlistenProfile = await listen( - 'profile-changed', - handleProfileChanged, - ) - cleanupFns.push(unlistenProfile) - } catch (error) { - console.error('[AppDataProvider] 监听 Profile 事件失败:', error) - } - - try { - const unlistenProfiles = await listen( - 'verge://refresh-profiles', - handleRefreshProfiles, - ) - cleanupFns.push(unlistenProfiles) - } catch (error) { - console.error('[AppDataProvider] 监听 Profiles 刷新事件失败:', error) - } - - try { - const unlistenProxy = await listen( - 'verge://refresh-proxy-config', - handleRefreshProxy, - ) - cleanupFns.push(unlistenProxy) - } catch (error) { - console.warn('[AppDataProvider] 设置 Tauri 事件监听器失败:', error) - } - } - - void initializeListeners() - - return () => { - cleanupFns.forEach((fn) => { - try { - fn() - } catch (error) { - console.error('[DataProvider] Cleanup error:', error) - } - }) - } + return subscribeVergeEvents({ + 'profile-changed': handleProfileChanged, + 'verge://refresh-profiles': handleRefreshProfiles, + 'verge://refresh-proxy-config': handleRefreshProxy, + }) }, [refreshProxy]) const refreshAll = useCallback(async () => { @@ -238,14 +200,16 @@ export const AppDataProvider = ({ [clashConfig, isClashConfigPending], ) - const systemValue = useMemo(() => { - const displayedMixedPort = resolveDisplayedMixedPort({ - live: clashConfig?.mixedPort, - runtime: runtimeConfig?.['mixed-port'], - selected: verge?.verge_mixed_port, - merge: clashInfo?.mixed_port, - }) + // Resolved from local sources rather than via useDisplayedMixedPort: that hook reads the + // ClashConfig context, and this component is the one providing it. + const displayedMixedPort = resolveDisplayedMixedPort({ + live: clashConfig?.mixedPort, + runtime: runtimeConfig?.['mixed-port'], + selected: verge?.verge_mixed_port, + merge: clashInfo?.mixed_port, + }) + const systemValue = useMemo(() => { const calculateSystemProxyAddress = () => { if (!verge) return '-' @@ -278,15 +242,7 @@ export const AppDataProvider = ({ isRunningModePending, systemProxyAddress: calculateSystemProxyAddress(), } - }, [ - sysproxy, - runningMode, - isRunningModePending, - verge, - clashConfig, - runtimeConfig, - clashInfo, - ]) + }, [sysproxy, runningMode, isRunningModePending, verge, displayedMixedPort]) const uptimeValue = useMemo(() => ({ uptime: uptimeData || 0 }), [uptimeData]) diff --git a/src/services/cmds.ts b/src/services/cmds.ts index fd85ce17c..d0414dc60 100644 --- a/src/services/cmds.ts +++ b/src/services/cmds.ts @@ -360,8 +360,40 @@ export async function listLocalBackup() { // 获取当前运行模式 export type RunningMode = 'Service' | 'Sidecar' | 'NotRunning' -export const getRunningMode = async () => { - return invoke('get_running_mode') +type ServiceHealth = + | 'unknown' + | 'ready' + | 'notInstalled' + | 'versionMismatch' + | 'unavailable' + +type PendingServiceAction = + | 'install' + | 'uninstall' + | 'reinstall' + | 'forceReinstall' + +/** + * How the core is running and what backs it, as one consistent snapshot. + * + * The derived answers travel with it — `tunCapable`, `serviceUsable`, + * `serviceNeedsAttention` — so nothing here is recomputed from the raw fields. + */ +export interface RunState { + mode: RunningMode + service: ServiceHealth + serviceUnavailableReason: string | null + pendingAction: PendingServiceAction | null + sidecarAllowed: boolean + isAdmin: boolean + opInFlight: boolean + serviceUsable: boolean + tunCapable: boolean + serviceNeedsAttention: boolean +} + +export const getRuntimeState = async () => { + return invoke('get_runtime_state') } // 获取应用运行时间 @@ -379,19 +411,6 @@ export const uninstallService = async () => { return invoke('uninstall_service') } -export type ServiceInstallState = - | 'checking' - | 'notInstalled' - | 'installRequired' - | 'ready' - | 'needsReinstall' - | 'sidecarAllowed' - | 'unavailable' - -export const getServiceInstallState = async () => { - return invoke('get_service_install_state') -} - export const reinstallService = async () => { return invoke('reinstall_service') } @@ -408,15 +427,6 @@ export const entry_lightweight_mode = async () => { return invoke('entry_lightweight_mode') } -export const isAdmin = async () => { - try { - return await invoke('app_is_admin') - } catch (error) { - console.error('检查管理员权限失败:', error) - return false - } -} - export async function getNextUpdateTime(uid: string) { return invoke('get_next_update_time', { uid }) } diff --git a/src/services/delay.test.ts b/src/services/delay.test.ts new file mode 100644 index 000000000..d544ee871 --- /dev/null +++ b/src/services/delay.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest' + +/** + * The delay API is the only thing stubbed. What is under test is when the manager announces + * that a group has settled — the granularity that decides whether a sorted list re-orders + * once per test or once per result. + */ +vi.mock('tauri-plugin-mihomo-api', () => ({ + delayProxyByName: vi.fn(async () => ({ delay: 120 })), + healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })), +})) + +import type { ResolvedProxyMember } from '@/types/proxy-view' + +import delayManager from './delay' + +const node = (name: string) => + ({ + kind: 'node', + ref: { kind: 'node', name, recordId: `r:${name}` }, + node: { + recordId: `r:${name}`, + name, + history: [], + source: { kind: 'core', proxyName: name }, + }, + }) as unknown as ResolvedProxyMember + +/** The manager coalesces notifications onto a frame; give it one. */ +const nextFrame = () => new Promise((resolve) => setTimeout(resolve, 0)) + +let settles = 0 +let unsubscribe: () => void + +beforeEach(() => { + settles = 0 + unsubscribe = delayManager.addGroupListener('g', () => { + settles += 1 + }) +}) + +afterEach(() => { + unsubscribe() +}) + +describe('group settle notifications', () => { + test('a single test settles the group once', async () => { + // The bug this fixes: a single-proxy retest updated that proxy's own display but never + // told the list to re-sort, so a group sorted by delay kept a stale order. + await delayManager.checkDelay(node('a') as never, 'g', 5000) + await nextFrame() + + expect(settles).toBe(1) + }) + + test('a batch settles the group once, however many proxies it covered', async () => { + // The granularity that matters. Announcing per result would re-sort continuously for + // the length of the test, moving rows out from under the pointer — and the symptom + // would be jumpy scrolling, which nothing else here could assert. + const proxies = Array.from({ length: 20 }, (_, index) => node(`n${index}`)) + + await delayManager.checkListDelay(proxies as never, 'g', 5000, 4) + await nextFrame() + + expect(settles).toBe(1) + }) + + test('an unsubscribed listener stops being called', async () => { + unsubscribe() + + await delayManager.checkDelay(node('a') as never, 'g', 5000) + await nextFrame() + + expect(settles).toBe(0) + }) + + test('two listeners on one group both hear it', async () => { + // Group listeners used to be one-per-group, so a second view silently replaced the + // first one's subscription. + let second = 0 + const stop = delayManager.addGroupListener('g', () => { + second += 1 + }) + + await delayManager.checkDelay(node('a') as never, 'g', 5000) + await nextFrame() + + expect(settles).toBe(1) + expect(second).toBe(1) + stop() + }) + + test('a single test inside a running batch waits for the batch', async () => { + // The window the batch counter exists for: the per-row delay button and "test all" hold + // separate locks, so a user can start one inside the other. Announcing then would sort a + // half-measured group, which is the reordering this design is meant to prevent. + const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`)) + + const batch = delayManager.checkListDelay(proxies as never, 'g', 5000, 2) + await delayManager.checkDelay(node('single') as never, 'g', 5000) + await nextFrame() + expect(settles).toBe(0) + + await batch + await nextFrame() + expect(settles).toBe(1) + }) + + test('unsubscribing one listener leaves the others subscribed', async () => { + let second = 0 + const stop = delayManager.addGroupListener('g', () => { + second += 1 + }) + + unsubscribe() + await delayManager.checkDelay(node('a') as never, 'g', 5000) + await nextFrame() + + expect(settles).toBe(0) + expect(second).toBe(1) + stop() + }) + + test('another group is not disturbed', async () => { + let other = 0 + const stop = delayManager.addGroupListener('other', () => { + other += 1 + }) + + await delayManager.checkDelay(node('a') as never, 'g', 5000) + await nextFrame() + + expect(settles).toBe(1) + expect(other).toBe(0) + stop() + }) +}) diff --git a/src/services/delay.ts b/src/services/delay.ts index dd6f25d12..51213e972 100644 --- a/src/services/delay.ts +++ b/src/services/delay.ts @@ -11,6 +11,12 @@ import { type ResolvedProxyMember, } from '@/types/proxy-view' import { debugLog } from '@/utils/debug' +import { classifyDelay, DEFAULT_DELAY_TIMEOUT } from '@/utils/delay' + +/** A group's delays, handed to sorting as a value it can depend on. */ +export type DelaySnapshot = { + of: (member: ResolvedProxyMember) => number +} const hashKey = (name: string, group: string) => `${group ?? ''}::${name}` @@ -30,7 +36,19 @@ class DelayManager { private listenerMap = new Map void>() // 每个分组的监听 - private groupListenerMap = new Map void>() + private groupListenerMap = new Map void>>() + /// A stable handle per group, replaced when that group settles. Consumers compare its + /// identity, so it must not be rebuilt on every read. + private groupSnapshots = new Map() + /// Keyed by the joined group names a consumer asked for; cleared whenever any group + /// settles, so the map identity changes while unaffected groups keep theirs. + private groupSetSnapshots = new Map< + string, + ReadonlyMap + >() + /// Batches in flight per group. A single test that lands inside one must not announce: + /// sorting from a half-measured group is the reordering this design exists to avoid. + private activeBatches = new Map() private pendingItemUpdates = new Map() private pendingGroupUpdates = new Set() @@ -89,25 +107,69 @@ class DelayManager { this.pendingGroupUpdates = new Set() groups.forEach((group) => { - const listener = this.groupListenerMap.get(group) - if (!listener) return - try { - listener() - } catch (error) { - console.error( - `[DelayManager] 通知分组延迟监听器失败: ${group}`, - error, - ) + const listeners = this.groupListenerMap.get(group) + if (!listeners) return + // Copied before iterating: a listener is free to unsubscribe as it runs. + for (const listener of [...listeners]) { + try { + listener() + } catch (error) { + console.error( + `[DelayManager] 通知分组延迟监听器失败: ${group}`, + error, + ) + } } }) }) } private queueGroupNotification(group: string) { + if ((this.activeBatches.get(group) ?? 0) > 0) return + // Dropped so the next read builds a fresh identity. Only this group's snapshot changes; + // the set-level map is rebuilt too, but its other entries keep their identities. + this.groupSnapshots.delete(group) + this.groupSetSnapshots.clear() this.pendingGroupUpdates.add(group) this.scheduleGroupFlush() } + /** + * A handle to this group's delays whose identity changes only when a test settles. + * + * Read during render and compared by identity, so it is cached rather than rebuilt: a + * fresh object per read would make every consumer recompute on every render. + */ + /** + * The delays for a set of groups, keyed by group name. + * + * Cached so its identity is stable between settles, while each group's own snapshot keeps + * its identity unless *that* group settled — which is what lets a per-group cache survive + * a test in a neighbouring group. + */ + groupsDelays(groupKey: string): ReadonlyMap { + const cached = this.groupSetSnapshots.get(groupKey) + if (cached) return cached + + const names = groupKey ? groupKey.split(' ') : [] + const snapshots = new Map( + names.map((name) => [name, this.groupDelays(name)]), + ) + this.groupSetSnapshots.set(groupKey, snapshots) + return snapshots + } + + groupDelays(group: string): DelaySnapshot { + const existing = this.groupSnapshots.get(group) + if (existing) return existing + + const snapshot: DelaySnapshot = { + of: (member) => this.getDelayFix(member, group), + } + this.groupSnapshots.set(group, snapshot) + return snapshot + } + setUrl(group: string, url: string) { debugLog(`[DelayManager] 设置测试URL,组: ${group}, URL: ${url}`) this.urlMap.set(group, url) @@ -136,12 +198,27 @@ class DelayManager { this.listenerMap.delete(key) } - setGroupListener(group: string, listener: () => void) { - this.groupListenerMap.set(group, listener) - } + /** + * Called when a delay test for `group` has settled: once per single test, and once for a + * whole batch however many proxies it covered. + * + * "Settled" rather than "changed" on purpose. Per-proxy display updates are already live + * through `setListener`; this exists for the things that must not move while results are + * still arriving, chiefly sort order. + */ + /// Returns its own unsubscribe, so two views may watch the same group without one + /// silently replacing the other's listener. + addGroupListener(group: string, listener: () => void): () => void { + const listeners = this.groupListenerMap.get(group) ?? new Set() + listeners.add(listener) + this.groupListenerMap.set(group, listeners) - removeGroupListener(group: string) { - this.groupListenerMap.delete(group) + return () => { + const current = this.groupListenerMap.get(group) + if (!current) return + current.delete(listener) + if (current.size === 0) this.groupListenerMap.delete(group) + } } setDelay( @@ -219,10 +296,26 @@ class DelayManager { return delayProxyByName(name, url, timeout) } + /** + * Test one proxy, then tell the group its ordering may have changed. + * + * The announcement is the point at which a sorted list is allowed to re-sort. It is + * deliberately *not* made per result inside a batch — see `checkListDelay`. + */ async checkDelay( member: InteractableProxyMember, group: string, timeout: number, + ): Promise { + const update = await this.measureDelay(member, group, timeout) + this.queueGroupNotification(group) + return update + } + + private async measureDelay( + member: InteractableProxyMember, + group: string, + timeout: number, ): Promise { const name = member.ref.name const providerName = @@ -287,6 +380,7 @@ class DelayManager { `[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`, ) const names = proxies.map((member) => member.ref.name) + this.activeBatches.set(group, (this.activeBatches.get(group) ?? 0) + 1) // 设置正在延迟测试中 names.forEach((name) => { this.setDelay(name, group, -2) @@ -294,7 +388,6 @@ class DelayManager { let index = 0 const startTime = Date.now() - const listener = this.groupListenerMap.get(group) const help = async (): Promise => { const currMember = proxies[index++] @@ -313,10 +406,10 @@ class DelayManager { ) } - await this.checkDelay(currMember, group, timeout) - if (listener) { - this.queueGroupNotification(group) - } + // Measured without announcing: a sorted list that re-ordered on every result would + // reshuffle continuously for the length of the test, with rows moving out from under + // the pointer. The group is told once, below, when the batch has settled. + await this.measureDelay(currMember, group, timeout) } catch (error) { console.error( `[DelayManager] 批量测试单个代理出错,代理: ${currName}`, @@ -338,28 +431,55 @@ class DelayManager { promiseList.push(help()) } - await Promise.all(promiseList) + try { + await Promise.all(promiseList) + } finally { + // In a `finally` so a throw cannot leave the group unannounced: the proxies would sit + // at -2 and the order would stay stale with nothing later to repair it. + const remaining = (this.activeBatches.get(group) ?? 1) - 1 + if (remaining > 0) { + this.activeBatches.set(group, remaining) + } else { + this.activeBatches.delete(group) + this.queueGroupNotification(group) + } + } const totalTime = Date.now() - startTime debugLog( `[DelayManager] 批量测试延迟完成,组: ${group}, 总耗时: ${totalTime}ms`, ) } - formatDelay(delay: number, timeout = 10000) { - if (delay === -1) return '-' - if (delay === -2) return 'testing' - if (delay === 0 || (delay >= timeout && delay <= 1e5)) return 'Timeout' - if (delay > 1e5) return 'Error' - return `${delay}` + formatDelay(delay: number, timeout = DEFAULT_DELAY_TIMEOUT) { + switch (classifyDelay(delay, timeout)) { + case 'untested': + return '-' + case 'testing': + return 'testing' + case 'timeout': + return 'Timeout' + case 'error': + return 'Error' + case 'measured': + return `${delay}` + } } - formatDelayColor(delay: number, timeout = 10000) { - if (delay < 0) return '' - if (delay === 0 || delay >= timeout) return 'error.main' - if (delay >= 10000) return 'error.main' - if (delay >= 400) return 'warning.main' - if (delay >= 250) return 'primary.main' - return 'success.main' + formatDelayColor(delay: number, timeout = DEFAULT_DELAY_TIMEOUT) { + switch (classifyDelay(delay, timeout)) { + case 'untested': + case 'testing': + return '' + case 'timeout': + case 'error': + return 'error.main' + case 'measured': + // How a measurement is graded is this widget's own decision; the thresholds differ + // from the signal icon's on purpose, because a colour has fewer steps than four bars. + if (delay >= 400) return 'warning.main' + if (delay >= 250) return 'primary.main' + return 'success.main' + } } } diff --git a/src/services/events.ts b/src/services/events.ts new file mode 100644 index 000000000..731b08304 --- /dev/null +++ b/src/services/events.ts @@ -0,0 +1,84 @@ +import { listen, type UnlistenFn } from '@tauri-apps/api/event' + +import type { RunState } from './cmds' + +/** + * Every event pushed to the frontend, and what it carries. + * + * The names were string literals on both sides of the IPC seam, so renaming one was a silent + * no-op rather than a compile error. Declaring them here does not make the Rust side check + * itself, but it does mean the frontend has exactly one place to look and one payload type + * per name instead of a `listen` guess at each call site. + */ +interface VergeEvents { + /** The core's own configuration changed. */ + 'verge://refresh-clash-config': string + /** The app's configuration changed. */ + 'verge://refresh-verge-config': string + /** The set of profiles changed. */ + 'verge://refresh-profiles': string + /** The active profile's proxies changed. */ + 'verge://refresh-proxy-config': null + /** A backend message for the user: `[status, message]`. */ + 'verge://notice-message': [string, string] + /** A profile's auto-update timer was rescheduled. */ + 'verge://timer-updated': string + /** How the core is running changed; carries the whole snapshot. */ + 'verge://run-state-changed': RunState + /** Which profile is active changed. */ + 'profile-changed': string + 'profile-update-started': { uid?: string } + 'profile-update-completed': { uid?: string } + /** Frontend to frontend: the home card asks the test page to run every test. */ + 'verge://test-all': null +} + +type VergeEventName = keyof VergeEvents + +type VergeEventHandlers = { + [Name in VergeEventName]?: (payload: VergeEvents[Name]) => void +} + +/** + * Subscribe to backend events, returning the teardown. + * + * Registration is asynchronous, so a subscription can resolve after the caller has already + * torn down; that is tracked here rather than at each call site, where it was implemented + * four different ways and omitted in one of them. + */ +export const subscribeVergeEvents = ( + handlers: VergeEventHandlers, +): (() => void) => { + let disposed = false + const unlisteners: UnlistenFn[] = [] + + for (const [name, handler] of Object.entries(handlers)) { + if (!handler) continue + + listen(name, ({ payload }) => { + ;(handler as (payload: unknown) => void)(payload) + }) + .then((unlisten) => { + // Resolved after teardown: unsubscribe immediately rather than leak. + if (disposed) { + unlisten() + return + } + unlisteners.push(unlisten) + }) + .catch((error) => { + console.error(`[events] failed to subscribe to ${name}:`, error) + }) + } + + return () => { + disposed = true + for (const unlisten of unlisteners.splice(0)) { + try { + unlisten() + } catch (error) { + console.error('[events] teardown failed:', error) + } + } + } +} diff --git a/src/utils/delay.test.ts b/src/utils/delay.test.ts new file mode 100644 index 000000000..37a2a5471 --- /dev/null +++ b/src/utils/delay.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from 'vitest' + +import { classifyDelay, compareByDelay, DEFAULT_DELAY_TIMEOUT } from './delay' + +describe('classifyDelay', () => { + test('an ordinary latency is a measurement', () => { + expect(classifyDelay(120)).toBe('measured') + expect(classifyDelay(DEFAULT_DELAY_TIMEOUT - 1)).toBe('measured') + }) + + test('the core reports an in-flight test as -2', () => { + expect(classifyDelay(-2)).toBe('testing') + }) + + test('any other negative means we have never measured it', () => { + expect(classifyDelay(-1)).toBe('untested') + expect(classifyDelay(-99)).toBe('untested') + }) + + test('zero and anything past the timeout are timeouts, not fast results', () => { + // Zero is the trap: sorted as a number it would come first. + expect(classifyDelay(0)).toBe('timeout') + expect(classifyDelay(DEFAULT_DELAY_TIMEOUT)).toBe('timeout') + }) + + test('an implausible value is an error rather than a very slow node', () => { + expect(classifyDelay(1e5 + 1)).toBe('error') + }) + + test('a non-number is treated as never measured', () => { + expect(classifyDelay(Number.NaN)).toBe('untested') + expect(classifyDelay(Number.POSITIVE_INFINITY)).toBe('untested') + }) + + test('the timeout is configurable per test URL', () => { + expect(classifyDelay(3000, 2000)).toBe('timeout') + expect(classifyDelay(3000, 5000)).toBe('measured') + }) +}) + +describe('compareByDelay', () => { + const sorted = (delays: number[]) => + [...delays].sort((a, b) => compareByDelay(a, b)) + + test('faster measurements come first', () => { + expect(sorted([300, 100, 200])).toEqual([100, 200, 300]) + }) + + test('a timeout never sorts ahead of a measurement, however it is encoded', () => { + expect(sorted([0, 150])).toEqual([150, 0]) + expect(sorted([DEFAULT_DELAY_TIMEOUT, 150])).toEqual([ + 150, + DEFAULT_DELAY_TIMEOUT, + ]) + }) + + test('untested nodes sink below everything measurable', () => { + expect(sorted([-1, 0, 1e5 + 1, 150])).toEqual([150, 0, 1e5 + 1, -1]) + }) + + test('a node being tested outranks one never tested', () => { + expect(sorted([-1, -2])).toEqual([-2, -1]) + }) + + test('non-numbers sink rather than scrambling the order around them', () => { + // This is the case the two previous copies disagreed on: one sorted a non-number + // among the timeouts, the other after the sentinels. + expect(compareByDelay(Number.NaN, 150)).toBeGreaterThan(0) + expect(compareByDelay(Number.NaN, -1)).toBe(0) + }) + + test('the order is total, so sorting is stable whichever way a pair is compared', () => { + const direction = (value: number) => (value > 0 ? 1 : value < 0 ? -1 : 0) + const samples = [150, 0, -1, -2, 1e5 + 1, 20] + + for (const a of samples) { + for (const b of samples) { + // Summing avoids comparing against -0, which Object.is treats as distinct from 0. + expect( + direction(compareByDelay(a, b)) + direction(compareByDelay(b, a)), + ).toBe(0) + } + } + }) +}) diff --git a/src/utils/delay.ts b/src/utils/delay.ts new file mode 100644 index 000000000..1c5e73a12 --- /dev/null +++ b/src/utils/delay.ts @@ -0,0 +1,81 @@ +/** + * What a delay number means, before anyone decides how to draw it. + * + * The core reports latency through a single number that encodes several non-measurements: + * sentinels for "never tested" and "testing now", zero and large values for timeouts, and + * absurd values for errors. Every surface agreed on those rules and restated them; they are + * stated once here. + * + * How a *measured* delay is graded — fast, slow, and the thresholds between — is deliberately + * not here. A three-colour chip and a four-bar signal icon draw different distinctions, and + * flattening them would be inventing a UI decision rather than removing a duplicate one. + */ + +/** The delay above which the core is considered to have timed out. */ +export const DEFAULT_DELAY_TIMEOUT = 10000 + +/** The value the core reports for a node whose test is in flight. */ +const TESTING = -2 + +/** Above this, the number is not a latency at all. */ +const IMPLAUSIBLE_DELAY = 1e5 + +export type DelayState = + | 'testing' + | 'untested' + | 'error' + | 'timeout' + /** An actual latency measurement, in milliseconds. */ + | 'measured' + +export const classifyDelay = ( + delay: number, + timeout: number = DEFAULT_DELAY_TIMEOUT, +): DelayState => { + if (!Number.isFinite(delay)) return 'untested' + if (delay === TESTING) return 'testing' + if (delay < 0) return 'untested' + if (delay > IMPLAUSIBLE_DELAY) return 'error' + if (delay === 0 || delay >= timeout) return 'timeout' + return 'measured' +} + +/** + * The order delays sort in: measured first and fastest first, then timeouts, then errors, + * then everything we have no measurement for. + * + * Ranks are compared before values so that a non-measurement can never sort ahead of a real + * one just because its sentinel happens to be a small number. + */ +const rankOf = (state: DelayState): number => { + switch (state) { + case 'measured': + return 0 + case 'timeout': + return 1 + case 'error': + return 2 + case 'testing': + return 3 + case 'untested': + return 4 + } +} + +/** A total order over delays, for sorting a proxy list fastest-first. */ +export const compareByDelay = ( + a: number, + b: number, + timeout: number = DEFAULT_DELAY_TIMEOUT, +): number => { + const [aState, bState] = [ + classifyDelay(a, timeout), + classifyDelay(b, timeout), + ] + const rankDifference = rankOf(aState) - rankOf(bState) + if (rankDifference !== 0) return rankDifference + + // Within a rank, only a real measurement has a meaningful magnitude. + if (aState !== 'measured') return 0 + return a - b +} diff --git a/tests/proxy-view.test.mjs b/tests/proxy-view.test.ts similarity index 86% rename from tests/proxy-view.test.mjs rename to tests/proxy-view.test.ts index d7d0fc921..e41b85248 100644 --- a/tests/proxy-view.test.mjs +++ b/tests/proxy-view.test.ts @@ -1,9 +1,9 @@ import assert from 'node:assert/strict' -import test from 'node:test' + +import { test } from 'vitest' import { findCurrentGroupMember, - findGroup, getRecord, isInteractableMember, memberDetails, @@ -13,10 +13,8 @@ import { resolveMember, selectGlobalChainNodes, selectRuleChainMembers, - selectRuntimeStandaloneNodes, toMemberOccurrenceBinding, - toNodeBinding, -} from '../src/types/proxy-view.ts' +} from '@/types/proxy-view' const node = { recordId: 'p:0:0', @@ -70,13 +68,6 @@ test('resolves nodes only through recordId and preserves provider identity', () assert.equal(isInteractableMember(resolved), true) }) -test('finds group refs without constructing a name-keyed records map', () => { - const resolved = resolveMember(view, { kind: 'group', name: 'Group' }) - assert.equal(resolved.kind, 'group') - assert.equal(findGroup(view, 'Group'), group) - assert.equal(memberDetails(resolved), group) -}) - test('keeps unresolved members visible but non-interactable', () => { const resolved = resolveMember(view, { kind: 'unresolved', @@ -118,7 +109,10 @@ test('rebinds a node semantically when its response-scoped id moves', () => { }) assert.equal( - rebindNode(movedCandidates, toNodeBinding(previous.node))?.recordId, + rebindNode(movedCandidates, { + name: previous.node.name, + source: previous.node.source, + })?.recordId, 'p:1:0', ) assert.equal( @@ -128,7 +122,10 @@ test('rebinds a node semantically when its response-scoped id moves', () => { const duplicate = { ...movedNode, recordId: 'p:1:1' } assert.equal( - rebindNode([movedNode, duplicate], toNodeBinding(previous.node)), + rebindNode([movedNode, duplicate], { + name: previous.node.name, + source: previous.node.source, + }), undefined, ) @@ -145,36 +142,6 @@ test('rebinds a node semantically when its response-scoped id moves', () => { ) }) -test('keeps a runtime core node that is absent from GLOBAL as standalone', () => { - const runtimeOnly = { - ...node, - recordId: 'c:0', - name: 'runtime-only', - source: { kind: 'core', proxyName: 'runtime-only' }, - } - const excluded = { - ...runtimeOnly, - recordId: 'c:1', - name: 'not-in-runtime', - source: { kind: 'core', proxyName: 'not-in-runtime' }, - } - const standaloneView = { - ...view, - global: { ...group, name: 'GLOBAL', members: [] }, - records: { 'c:0': runtimeOnly, 'c:1': excluded }, - standalone: ['c:0', 'c:1'], - } - - assert.deepEqual( - selectRuntimeStandaloneNodes(standaloneView, [{ name: 'runtime-only' }]), - [runtimeOnly], - ) - assert.deepEqual( - selectGlobalChainNodes(standaloneView, [{ name: 'runtime-only' }]), - [runtimeOnly], - ) -}) - test('does not expose global-chain candidates without GLOBAL', () => { const runtimeOnly = { ...node, diff --git a/vitest.config.mts b/vitest.config.mts new file mode 100644 index 000000000..0fe041b82 --- /dev/null +++ b/vitest.config.mts @@ -0,0 +1,25 @@ +import path from 'node:path' + +import { defineConfig } from 'vitest/config' + +// Separate from vite.config.mts because that one sets `root: 'src'` for the app build, +// while tests live alongside the code they cover *and* under tests/. +export default defineConfig({ + resolve: { + alias: { + '@': path.resolve('./src'), + '@root': path.resolve('.'), + }, + }, + define: { + OS_PLATFORM: `"${process.platform}"`, + }, + test: { + include: ['tests/**/*.test.ts', 'src/**/*.test.{ts,tsx}'], + // Node by default because most tests are pure functions; render tests opt in per file + // with `// @vitest-environment jsdom`, so the DOM cost is paid only where it buys + // something. + environment: 'node', + restoreMocks: true, + }, +})