Compare commits

..
Author SHA1 Message Date
Tunglies 334cf3ef37 fix(build): regenerate polyfills after release version rewrites 2026-11-11 06:44:17 +08:00
Tunglies cf20bcd76d perf(build): guard generated modern polyfill inventory 2026-11-11 06:31:29 +08:00
Tunglies 84783abb73 perf(build): filter SVGR loads before the JavaScript bridge 2026-11-11 06:17:02 +08:00
❤是纱雾酱哟~andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> f86db4b344 chore(deps): update cargo dependencies (#8619)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 17:29:51 +00:00
renovate[bot]renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>❤是纱雾酱哟~
389dddf512 chore(deps): update github/gh-aw-actions action to v1.2.2 (#8617)
* chore(deps): update github/gh-aw-actions action to v1.2.2

* ci(workflows): recompile workflow lockfiles with gh-aw v1.2.2

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

* ci(workflows): set permissions to suppress tips

Agentic workflows of this repository are using available credits by some
collaborators' personal API Key. Configuring permissions of these
workflows to suppress warnings by GitHub Agentic Workflow extension
during lockfile compilation.

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>

---------

Signed-off-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: ❤是纱雾酱哟~ <49941141+Dragon1573@users.noreply.github.com>
2026-11-10 14:01:34 +00:00
renovate[bot]andrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com> 304a65fc36 chore(deps): update npm dependencies (#8615)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-11-10 05:17:19 +00:00
26 changed files with 1319 additions and 547 deletions
+3 -3
View File
@@ -5,10 +5,10 @@
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
"github/gh-aw-actions/setup@v1.0.1": {
"github/gh-aw-actions/setup@v1.2.2": {
"repo": "github/gh-aw-actions/setup",
"version": "v1.0.1",
"sha": "221ba4f4319de6b8e809d1a15528b3ae6a818aa5"
"version": "v1.2.2",
"sha": "53d0749e19424a918acd82b4d140264ce48f2f14"
}
},
"containers": {
+9 -3
View File
@@ -191,7 +191,9 @@ jobs:
pnpm run prebuild ${{ matrix.target }}
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest
run: |
pnpm release-version autobuild-latest
pnpm polyfills:generate
- name: Tauri build for Windows-macOS-Linux
uses: tauri-apps/tauri-action@v1
@@ -271,7 +273,9 @@ jobs:
pnpm run prebuild ${{ matrix.target }}
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest
run: |
pnpm release-version autobuild-latest
pnpm polyfills:generate
- name: 'Setup for linux'
run: |-
@@ -404,7 +408,9 @@ jobs:
pnpm run prebuild ${{ matrix.target }}
- name: Release ${{ env.TAG_CHANNEL }} Version
run: pnpm release-version autobuild-latest
run: |
pnpm release-version autobuild-latest
pnpm polyfills:generate
- name: Download WebView2 Runtime
run: |
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
with:
persist-credentials: false
- name: Install gh-aw extension
uses: github/gh-aw-actions/setup-cli@406c7719e31d7fe354224d4bb8fe3d4aeb47da1c # v1.2.1
uses: github/gh-aw-actions/setup-cli@53d0749e19424a918acd82b4d140264ce48f2f14 # v1.2.2
with:
version: v1.0.1
- name: Install Rust Stable
+3 -1
View File
@@ -115,7 +115,9 @@ jobs:
- name: Release ${{ env.TAG_CHANNEL }} Version
if: github.event.inputs[matrix.input] == 'true'
run: pnpm release-version ${{ env.TAG_NAME }}
run: |
pnpm release-version ${{ env.TAG_NAME }}
pnpm polyfills:generate
- name: Add Rust Target
if: github.event.inputs[matrix.input] == 'true'
+3
View File
@@ -58,6 +58,9 @@ jobs:
- name: Run TypeScript typecheck
run: pnpm typecheck
- name: Verify modern polyfill inventory against automatic discovery
run: pnpm polyfills:check
- name: Run tests
run: pnpm test
File diff suppressed because one or more lines are too long
+1
View File
@@ -33,6 +33,7 @@ max-daily-ai-credits: 5000
permissions:
issues: read
pull-requests: read
copilot-requests: none
pre-agent-steps:
- name: Make Copilot quota exhaustion non-blocking
+288 -107
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -30,6 +30,7 @@ permissions:
contents: read
issues: read
pull-requests: read
copilot-requests: none
steps:
- name: Load review rules from the base revision
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@ permissions:
contents: read
issues: read
pull-requests: read
copilot-requests: none
max-ai-credits: 60
max-turns: 12
max-daily-ai-credits: 500
+11
View File
@@ -101,6 +101,17 @@ For frontend-only development, use `pnpm web:dev`. It does not start the Rust ba
### Build the Project
`pnpm web:build` uses a generated modern-polyfill inventory and rejects changed
emitted module inputs, output, targets, or locked dependencies before writing
output. Inputs include dependency code and code later removed by minification.
When it reports a stale inventory, run `pnpm polyfills:generate` and commit
`scripts/modern-polyfills.json`. Generation preserves automatic discovery's import
order and requires byte-identical automatic/explicit outputs for the macOS, Linux,
and Windows production defines. CI repeats this comparison with
`pnpm polyfills:check`; browser targets and worker configuration stay unchanged.
Release build jobs regenerate after rewriting versions because version data is
also emitted in frontend modules.
Standard build:
```bash
Generated
+4 -4
View File
@@ -159,9 +159,9 @@ dependencies = [
[[package]]
name = "arc-swap"
version = "1.9.4"
version = "1.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db4c94f57a570fce5d381be3289b3f79aef6332d72948fe82ab5772e423ccf92"
checksum = "84a22b5d51209f6de83d37a0b33d320acb8da88823e42a94c075140708f032a5"
dependencies = [
"rustversion",
]
@@ -10126,9 +10126,9 @@ dependencies = [
[[package]]
name = "zip"
version = "9.4.2"
version = "9.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3845f734baabdd02de353a20580e30ceccd03897def44a4eb8e9654992e294"
checksum = "0700e6c427aff64acdbf02e85fbfd946c2ee12319d1af013092f3a2178914ff8"
dependencies = [
"aes",
"bzip2",
-1
View File
@@ -29,7 +29,6 @@
- 修复全局模式默认停在 `DIRECT` 或 `REJECT` 导致流量未经代理,现自动切换到第一个策略组
- 修复订阅中未加引号的 REALITY `short-id`(如 `1234e123`)被误读为数字,导致订阅无法启用
- 修复订阅中唯一的代理组名为 `GLOBAL` 或没有代理组时,规则模式下代理页面显示「未获取到代理信息」
- 修复切换订阅后节点延迟显示旧订阅结果,进入代理页面时主动刷新延迟记录
**🍎 macOS**
+4 -2
View File
@@ -15,6 +15,8 @@
"tauri": "tauri",
"web:dev": "vite",
"web:build": "tsrs -p . --noEmit && vite build",
"polyfills:generate": "node scripts/generate-modern-polyfills.mjs --write",
"polyfills:check": "node scripts/generate-modern-polyfills.mjs --check",
"web:serve": "vite preview",
"prebuild": "node scripts/prebuild.mjs",
"updater": "node scripts/updater.mjs",
@@ -67,7 +69,7 @@
"dayjs": "1.11.23",
"foxact": "^0.3.12",
"foxts": "^5.9.1",
"i18next": "^26.4.2",
"i18next": "^27.0.0",
"js-yaml": "^5.4.2",
"lodash-es": "^4.18.1",
"meta-json-schema": "^1.19.31",
@@ -79,7 +81,7 @@
"react-dom": "19.3.1",
"react-error-boundary": "6.1.6",
"react-hook-form": "^7.88.0",
"react-i18next": "17.1.13",
"react-i18next": "18.0.1",
"react-markdown": "10.1.0",
"react-router": "^8.4.0",
"rehype-raw": "^7.0.0",
+18 -21
View File
@@ -239,8 +239,8 @@ importers:
specifier: ^5.9.1
version: 5.9.1
i18next:
specifier: ^26.4.2
version: 26.4.2(typescript@6.0.3)
specifier: ^27.0.0
version: 27.0.1(typescript@6.0.3)
js-yaml:
specifier: ^5.4.2
version: 5.4.2
@@ -275,8 +275,8 @@ importers:
specifier: ^7.88.0
version: 7.88.0(react@19.3.1)
react-i18next:
specifier: 17.1.13
version: 17.1.13(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3)
specifier: 18.0.1
version: 18.0.1(@types/react@19.3.0)(i18next@27.0.1(typescript@6.0.3))(react@19.3.1)(typescript@6.0.3)
react-markdown:
specifier: 10.1.0
version: 10.1.0(@types/react@19.3.0)(react@19.3.1)(supports-color@7.2.0)
@@ -3689,8 +3689,9 @@ packages:
engines: {node: '>=18'}
hasBin: true
i18next@26.4.2:
resolution: {integrity: sha512-RX+R0VLg13IbvRuJSxnqykUFS9vQZTl8wYpWPCIUDWVrSGjsQywB5Y+pjzrkboxGAuYfJZVH1InFTdgBdxq6ug==}
i18next@27.0.1:
resolution: {integrity: sha512-KaE5ozP8UUeVpOJIwMVbbnpRKpyYQuxd9ZbseJoDzezE3nvLnZZQn20pkzyIJwPI2Uet/Qs3WzPTG812f6F/Eg==}
engines: {node: '>=22'}
peerDependencies:
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
@@ -4396,18 +4397,16 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17 || ^18 || ^19
react-i18next@17.1.13:
resolution: {integrity: sha512-ZxhOSlyKSKPtbUNPLwYMmE3w3bhoc0jaZJZ6bc7wGOT6cLbrbs//6m01HxCLtFdSjHvFpwFwoPhxxzIlHSs9cw==}
react-i18next@18.0.1:
resolution: {integrity: sha512-kPWXI0E/lrPj3+/umcKNzYZVnWmEedLiamMzDIHfbs+5ANf6yzvlZsjKou8f7kaGkfMMDLIZBejV+4aSN1CyBw==}
engines: {node: '>=22'}
peerDependencies:
i18next: '>= 26.2.0'
react: '>= 16.8.0'
react-dom: '*'
react-native: '*'
'@types/react': '*'
i18next: '>= 27.0.1'
react: '>= 18.0.0'
typescript: ^5 || ^6 || ^7
peerDependenciesMeta:
react-dom:
optional: true
react-native:
'@types/react':
optional: true
typescript:
optional: true
@@ -8533,7 +8532,7 @@ snapshots:
husky@9.1.7: {}
i18next@26.4.2(typescript@6.0.3):
i18next@27.0.1(typescript@6.0.3):
optionalDependencies:
typescript: 6.0.3
@@ -9357,15 +9356,13 @@ snapshots:
dependencies:
react: 19.3.1
react-i18next@17.1.13(i18next@26.4.2(typescript@6.0.3))(react-dom@19.3.1(react@19.3.1))(react@19.3.1)(typescript@6.0.3):
react-i18next@18.0.1(@types/react@19.3.0)(i18next@27.0.1(typescript@6.0.3))(react@19.3.1)(typescript@6.0.3):
dependencies:
'@babel/runtime': 7.29.7
html-parse-stringify: 4.0.1
i18next: 26.4.2(typescript@6.0.3)
i18next: 27.0.1(typescript@6.0.3)
react: 19.3.1
use-sync-external-store: 1.7.0(react@19.3.1)
optionalDependencies:
react-dom: 19.3.1(react@19.3.1)
'@types/react': 19.3.0
typescript: 6.0.3
react-is@16.13.1: {}
+80
View File
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { build } from 'vite'
const [command, ...extra] = process.argv.slice(2)
if (!['--write', '--check'].includes(command) || extra.length) {
throw new Error(
'Usage: node scripts/generate-modern-polyfills.mjs --write|--check',
)
}
const inventoryPath = 'scripts/modern-polyfills.json'
const staleMessage =
'Modern polyfill inventory is stale; run pnpm polyfills:generate and commit scripts/modern-polyfills.json'
function outputHashes(result) {
assert(
!Array.isArray(result) && 'output' in result,
'Expected one app output',
)
return result.output
.map((file) => [
file.fileName,
createHash('sha256')
.update(file.type === 'chunk' ? file.code : file.source)
.digest('hex'),
])
.sort(([a], [b]) => a.localeCompare(b))
}
const temporary = mkdtempSync(path.join(os.tmpdir(), 'modern-polyfills-'))
const capture = path.join(temporary, 'capture.json')
const candidate = path.join(temporary, 'inventory.json')
const snapshots = []
const fingerprints = []
let imports
try {
process.env.MODERN_POLYFILLS_CAPTURE = capture
// OS_PLATFORM changes emitted code; certify each production define without
// launching a native app or normalizing away code the detector actually sees.
for (const platform of ['darwin', 'linux', 'win32']) {
const config = {
logLevel: 'error',
define: { OS_PLATFORM: JSON.stringify(platform) },
build: { write: false },
}
const automatic = await build(config)
const detected = JSON.parse(readFileSync(capture, 'utf8'))
if (imports) assert.deepEqual(detected.imports, imports)
imports = detected.imports
fingerprints.push(detected.fingerprint)
snapshots.push({ config, hashes: outputHashes(automatic) })
}
const content = `${JSON.stringify({ imports, fingerprints: [...new Set(fingerprints)].sort() }, null, 2)}\n`
writeFileSync(candidate, content)
delete process.env.MODERN_POLYFILLS_CAPTURE
process.env.MODERN_POLYFILLS_INVENTORY = candidate
for (const { config, hashes } of snapshots) {
assert.deepEqual(
outputHashes(await build(config)),
hashes,
'Explicit polyfills must be byte-identical to automatic discovery',
)
}
if (command === '--check') {
assert.equal(readFileSync(inventoryPath, 'utf8'), content, staleMessage)
} else {
writeFileSync(inventoryPath, content)
}
console.log(
`Modern polyfills ${command === '--check' ? 'verified' : 'generated'}: ${imports.length} imports; all three OS outputs byte-identical to automatic discovery`,
)
} finally {
delete process.env.MODERN_POLYFILLS_CAPTURE
delete process.env.MODERN_POLYFILLS_INVENTORY
rmSync(temporary, { recursive: true })
}
+72
View File
@@ -0,0 +1,72 @@
{
"imports": [
"core-js/modules/es.error.cause.js",
"core-js/modules/es.array.at.js",
"core-js/modules/es.array.includes.js",
"core-js/modules/es.array.push.js",
"core-js/modules/es.array.unshift.js",
"core-js/modules/es.array-buffer.detached.js",
"core-js/modules/es.array-buffer.transfer.js",
"core-js/modules/es.array-buffer.transfer-to-fixed-length.js",
"core-js/modules/es.iterator.constructor.js",
"core-js/modules/es.iterator.every.js",
"core-js/modules/es.iterator.filter.js",
"core-js/modules/es.iterator.find.js",
"core-js/modules/es.iterator.flat-map.js",
"core-js/modules/es.iterator.for-each.js",
"core-js/modules/es.iterator.map.js",
"core-js/modules/es.iterator.reduce.js",
"core-js/modules/es.iterator.some.js",
"core-js/modules/es.iterator.to-array.js",
"core-js/modules/es.json.parse.js",
"core-js/modules/es.json.stringify.js",
"core-js/modules/es.map.get-or-insert.js",
"core-js/modules/es.map.get-or-insert-computed.js",
"core-js/modules/es.regexp.flags.js",
"core-js/modules/es.set.difference.v2.js",
"core-js/modules/es.set.intersection.v2.js",
"core-js/modules/es.set.is-disjoint-from.v2.js",
"core-js/modules/es.set.is-subset-of.v2.js",
"core-js/modules/es.set.is-superset-of.v2.js",
"core-js/modules/es.set.symmetric-difference.v2.js",
"core-js/modules/es.set.union.v2.js",
"core-js/modules/es.string.at-alternative.js",
"core-js/modules/es.typed-array.at.js",
"core-js/modules/es.typed-array.fill.js",
"core-js/modules/es.typed-array.find-last.js",
"core-js/modules/es.typed-array.find-last-index.js",
"core-js/modules/es.typed-array.set.js",
"core-js/modules/es.typed-array.sort.js",
"core-js/modules/es.typed-array.to-reversed.js",
"core-js/modules/es.typed-array.to-sorted.js",
"core-js/modules/es.typed-array.with.js",
"core-js/modules/es.uint8-array.set-from-base64.js",
"core-js/modules/es.uint8-array.set-from-hex.js",
"core-js/modules/es.uint8-array.to-base64.js",
"core-js/modules/es.uint8-array.to-hex.js",
"core-js/modules/es.weak-map.get-or-insert.js",
"core-js/modules/es.weak-map.get-or-insert-computed.js",
"core-js/modules/esnext.array.group.js",
"core-js/modules/web.dom-exception.stack.js",
"core-js/modules/web.immediate.js",
"core-js/modules/web.url-search-params.delete.js",
"core-js/modules/web.url-search-params.has.js",
"core-js/modules/web.url-search-params.size.js",
"core-js/modules/es.object.has-own.js",
"core-js/modules/es.array.with.js",
"core-js/modules/es.symbol.async-dispose.js",
"core-js/modules/es.async-iterator.async-dispose.js",
"core-js/modules/es.symbol.dispose.js",
"core-js/modules/es.iterator.dispose.js",
"core-js/modules/es.aggregate-error.cause.js",
"core-js/modules/es.array.find-last.js",
"core-js/modules/es.iterator.drop.js",
"core-js/modules/es.iterator.take.js",
"core-js/modules/web.structured-clone.js"
],
"fingerprints": [
"30dd6a7f16e7065fa9803260e11aa2f2db56cabb69664257c940ab05bae8505b",
"5630fe8a486687073b6d9894dd39735b77d5d8177432a4ada81947a429ff6991",
"ddbb980f9ad3fe92ee7c30248d46ed4cdeb421c714266e627b825b59042c30bc"
]
}
+139
View File
@@ -0,0 +1,139 @@
import { createHash } from 'node:crypto'
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import legacy, { detectPolyfills } from '@vitejs/plugin-legacy'
const digest = (value) => createHash('sha256').update(value).digest('hex')
const inventoryPath = 'scripts/modern-polyfills.json'
const staleMessage =
'Modern polyfill inventory is stale; run pnpm polyfills:generate and commit scripts/modern-polyfills.json'
// Bind complete emitted module inputs as well as output: minification can hide APIs.
// Generation double-checks the exported detector against legacy's automatic build.
export function guardedLegacy(options) {
const capturePath = process.env.MODERN_POLYFILLS_CAPTURE
const inventory = capturePath
? null
: JSON.parse(
readFileSync(
process.env.MODERN_POLYFILLS_INVENTORY || inventoryPath,
'utf8',
),
)
const plugins = legacy({
...options,
modernPolyfills: Boolean(capturePath),
additionalModernPolyfills: [
...options.additionalModernPolyfills,
...(capturePath ? [] : inventory.imports),
],
})
const postProcess = plugins.find(
(plugin) => plugin.name === 'vite:legacy-post-process',
)
if (!postProcess?.renderChunk) {
throw new Error('plugin-legacy must provide its post-process render hook')
}
const original = postProcess.renderChunk
const render = typeof original === 'function' ? original : original.handler
const discovered = new Map()
let chunkNames
let context
if (capturePath) {
postProcess.renderChunk = async function (raw, chunk, output, meta) {
chunkNames ??= Object.keys(meta.chunks)
const imports = new Set()
await detectPolyfills(
raw,
options.modernTargets,
options.assumptions || {},
imports,
)
discovered.set(chunk.fileName, imports)
return render.call(this, raw, chunk, output, meta)
}
}
return [
...plugins,
{
name: 'guard-modern-polyfills',
enforce: 'post',
apply: 'build',
configResolved(config) {
if (config.build.ssr || options.renderLegacyChunks !== false) {
throw new Error(
'Modern polyfill guard requires a modern-only app build',
)
}
// Custom import paths must not bind the inventory to a checkout location.
context = [
{
...options,
additionalModernPolyfills: options.additionalModernPolyfills.map(
(file) =>
path.relative(process.cwd(), file).split(path.sep).join('/'),
),
},
config.build.target,
config.mode,
...[
'package.json',
'pnpm-lock.yaml',
'vite.config.mts',
'scripts/modern-polyfills.mjs',
'scripts/generate-modern-polyfills.mjs',
].map((file) => digest(readFileSync(file))),
...[
'.browserslistrc',
'browserslist',
'src/.browserslistrc',
'src/browserslist',
].map((file) =>
existsSync(file) ? digest(readFileSync(file)) : null,
),
digest(
readFileSync(new URL(import.meta.resolve('@vitejs/plugin-legacy'))),
),
]
},
generateBundle(_output, bundle) {
const imports = capturePath
? [
...new Set(
chunkNames.flatMap((name) => [...discovered.get(name)]),
),
]
: inventory.imports
const chunks = Object.values(bundle).filter(
(file) => file.type === 'chunk',
)
const moduleIds = new Set(
chunks.flatMap((chunk) => Object.keys(chunk.modules)),
)
const inputs = [...moduleIds].map((id) => {
// Rolldown's runtime is generated, not a transformed input module.
if (id === '\0rolldown/runtime.js') return null
// Vite's HTML entry code embeds an absolute import to the main module.
if (id.endsWith('.html')) return digest(readFileSync(id))
const code = this.getModuleInfo(id)?.code
if (code == null) this.error(`Missing emitted module input: ${id}`)
return digest(code)
})
const fingerprint = digest(
JSON.stringify([
context,
inputs.sort(),
chunks.map((chunk) => digest(chunk.code)).sort(),
imports,
]),
)
if (capturePath) {
writeFileSync(capturePath, JSON.stringify({ fingerprint, imports }))
} else if (!inventory.fingerprints.includes(fingerprint)) {
this.error(staleMessage)
}
},
},
]
}
+2 -2
View File
@@ -77,7 +77,7 @@ tauri-plugin-dialog = "2.8.4"
tauri-plugin-fs = "2.7.1"
tauri-plugin-deep-link = "2.6.3"
tauri-plugin-window-state = "2.5.0"
zip = "9.4.2"
zip = "9.5.0"
flate2 = "1.1.11"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
@@ -97,7 +97,7 @@ async-trait = "0.1.92"
clash_verge_service_ipc = { git = "https://github.com/clash-verge-rev/clash-verge-service-ipc.git", tag = "v2.7.7", version = "2.7.7", features = [
"client",
] }
arc-swap = "1.9.4"
arc-swap = "1.9.5"
tokio-rustls = "0.26.6"
rustls = { version = "0.23.46", features = ["ring"] }
webpki-roots = "1.0.11"
+51 -25
View File
@@ -1,5 +1,5 @@
import { useLockFn } from 'ahooks'
import { useCallback, useSyncExternalStore } from 'react'
import { useCallback, useEffect, useReducer } from 'react'
import { useVerge } from '@/hooks/use-verge'
import delayManager, { type DelayUpdate } from '@/services/delay'
@@ -17,6 +17,13 @@ const PRESET_PROXY_NAMES = [
'COMPATIBLE',
]
const identity = (previous: DelayUpdate, next: DelayUpdate): DelayUpdate =>
previous.delay === next.delay &&
previous.elapsed === next.elapsed &&
previous.updatedAt === next.updatedAt
? previous
: next
const INITIAL_DELAY: DelayUpdate = { delay: -1, updatedAt: 0 }
export interface UseProxyDelayState {
@@ -35,37 +42,56 @@ export function useProxyDelayState(
const details = memberDetails(member)
const unresolved = member.kind === 'unresolved'
const isPreset = unresolved || PRESET_PROXY_NAMES.includes(name)
const [delayState, setDelayState] = useReducer(identity, INITIAL_DELAY)
const { verge } = useVerge()
const timeout = verge?.default_latency_timeout || 10000
const subscribe = useCallback(
(onChange: () => void) =>
isPreset ? () => {} : delayManager.addListener(name, groupName, onChange),
[name, groupName, isPreset],
)
const read = useCallback(
() =>
unresolved ? undefined : delayManager.getDelayUpdate(name, groupName),
[name, groupName, unresolved],
)
const cachedUpdate = useSyncExternalStore(subscribe, read, read)
useEffect(() => {
if (isPreset) return
delayManager.setListener(name, groupName, setDelayState)
return () => {
delayManager.removeListener(name, groupName)
}
}, [name, groupName, isPreset])
const fallbackDelay = delayManager.getDelayFix(member, groupName)
const history = details?.history
const lastRecord = history?.[history.length - 1]
const parsed = lastRecord ? Date.parse(lastRecord.time) : NaN
const delayState =
cachedUpdate ??
(fallbackDelay === -1
? INITIAL_DELAY
: {
delay: fallbackDelay,
updatedAt: Number.isNaN(parsed) ? 0 : parsed,
})
const updateDelay = useCallback(() => {
if (unresolved) {
setDelayState(INITIAL_DELAY)
return
}
const cachedUpdate = delayManager.getDelayUpdate(name, groupName)
if (cachedUpdate) {
setDelayState({ ...cachedUpdate })
return
}
const fallbackDelay = delayManager.getDelayFix(member, groupName)
if (fallbackDelay === -1) {
setDelayState({ delay: -1, updatedAt: 0 })
return
}
let updatedAt = 0
const history = details?.history
if (history && history.length > 0) {
const lastRecord = history[history.length - 1]
const parsed = Date.parse(lastRecord.time)
if (!Number.isNaN(parsed)) {
updatedAt = parsed
}
}
setDelayState({ delay: fallbackDelay, updatedAt })
}, [details?.history, groupName, member, name, unresolved])
useEffect(() => {
updateDelay()
}, [updateDelay])
const onDelay = useLockFn(async () => {
if (!isInteractableMember(member)) return
await delayManager.checkDelay(member, groupName, timeout)
setDelayState({ delay: -2, updatedAt: Date.now() })
setDelayState(await delayManager.checkDelay(member, groupName, timeout))
})
return {
+1 -5
View File
@@ -49,11 +49,7 @@ const ProxyPage = () => {
>(null)
const { clashConfig } = useClashConfigData()
const { refreshClashConfig, refreshProxy } = useAppRefreshers()
useEffect(() => {
refreshProxy().catch((error) => showNotice.error(error))
}, [refreshProxy])
const { refreshClashConfig } = useAppRefreshers()
const normalizedMode = clashConfig?.mode?.toLowerCase()
const curMode = isMode(normalizedMode) ? normalizedMode : undefined
+1 -11
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useRef } from 'react'
import React, { useCallback, useMemo, useRef } from 'react'
import {
getBaseConfig,
getRuleProviders,
@@ -6,10 +6,8 @@ import {
} from 'tauri-plugin-mihomo-api'
import { useClashInfo, useRuntimeConfig } from '@/hooks/use-clash'
import { useProfiles } from '@/hooks/use-profiles'
import { useVerge } from '@/hooks/use-verge'
import { getProxyView, getSystemProxy } from '@/services/cmds'
import delayManager from '@/services/delay'
import { useQuery } from '@/services/query-client'
import { useRunState } from '@/store/app-store-context'
import { resolveDisplayedMixedPort } from '@/utils/mixed-port'
@@ -55,8 +53,6 @@ export const AppDataProvider = ({
const { verge } = useVerge()
const { data: runtimeConfig } = useRuntimeConfig()
const { clashInfo } = useClashInfo()
const { profiles } = useProfiles()
const currentProfile = profiles?.current
const {
data: proxyView,
@@ -106,12 +102,6 @@ export const AppDataProvider = ({
const runningMode = runState?.mode
const refreshProxy = useStableFn(_refetchProxyView)
useEffect(() => {
if (currentProfile === undefined) return
delayManager.setProfile(currentProfile)
void refreshProxy()
}, [currentProfile, refreshProxy])
const refreshClashConfig = useStableFn(_refetchClashConfig)
const refreshRules = useStableFn(_refetchRules)
const refreshSysproxy = useStableFn(_refetchSysproxy)
+16 -66
View File
@@ -5,32 +5,26 @@ vi.mock('tauri-plugin-mihomo-api', () => ({
healthcheckNodeInProvider: vi.fn(async () => ({ delay: 120 })),
}))
import type { InteractableProxyMember } from '@/types/proxy-view'
import type { ResolvedProxyMember } from '@/types/proxy-view'
import delayManager from './delay'
const node = (
name: string,
): Extract<InteractableProxyMember, { kind: 'node' }> => ({
kind: 'node',
ref: { kind: 'node', name, recordId: `r:${name}` },
node: {
recordId: `r:${name}`,
name,
type: 'Shadowsocks',
history: [],
source: { kind: 'core', proxyName: name },
udp: false,
xudp: false,
tfo: false,
mptcp: false,
smux: false,
},
})
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
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
let settles = 0
let unsubscribe: () => void
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
beforeEach(() => {
settles = 0
@@ -45,7 +39,7 @@ describe('group delay completion', () => {
test('notifies once after a batch settles', async () => {
const proxies = Array.from({ length: 6 }, (_, index) => node(`n${index}`))
await delayManager.checkListDelay(proxies, 'g', 5000, 2)
await delayManager.checkListDelay(proxies as never, 'g', 5000, 2)
await flush()
expect(settles).toBe(1)
@@ -57,7 +51,7 @@ describe('group delay completion', () => {
other += 1
})
await delayManager.checkDelay(node('a'), 'g', 5000)
await delayManager.checkDelay(node('a') as never, 'g', 5000)
await flush()
expect(settles).toBe(1)
@@ -65,47 +59,3 @@ describe('group delay completion', () => {
stop()
})
})
describe('profile delay isolation', () => {
test('notifies both shared-key occurrences and preserves the surviving subscription', async () => {
const first = vi.fn()
const second = vi.fn()
const stopFirst = delayManager.addListener('a', 'g', first)
const stopSecond = delayManager.addListener('a', 'g', second)
try {
delayManager.setDelay('a', 'g', -2)
const pending = delayManager.getDelayUpdate('a', 'g')
expect(delayManager.getDelayUpdate('a', 'g')).toBe(pending)
await flush()
stopFirst()
delayManager.setDelay('a', 'g', 120)
expect(delayManager.getDelayUpdate('a', 'g')).not.toBe(pending)
await flush()
expect(first.mock.calls.map(([update]) => update.delay)).toEqual([-2])
expect(second.mock.calls.map(([update]) => update.delay)).toEqual([
-2, 120,
])
} finally {
stopFirst()
stopSecond()
}
})
test('clears cached delays and fences an old batch across a profile switch', async () => {
delayManager.setProfile('initial')
delayManager.setDelay('cached', 'g', 900)
const oldBatch = delayManager.checkListDelay(
[node('a'), node('not-started')],
'g',
5000,
1,
)
delayManager.setProfile('next')
expect(delayManager.getDelayUpdate('cached', 'g')).toBeUndefined()
delayManager.setDelay('a', 'g', 42)
await oldBatch
await flush()
expect(delayManager.getDelay('a', 'g')).toBe(42)
expect(delayManager.getDelayUpdate('not-started', 'g')).toBeUndefined()
})
})
+33 -83
View File
@@ -29,12 +29,10 @@ export interface DelayUpdate {
const CACHE_TTL = 30 * 60 * 1000
class DelayManager {
private profile: string | null = null
private generation = 0
private cache = new Map<string, DelayUpdate>()
private urlMap = new Map<string, string>()
private listenerMap = new Map<string, Set<(update: DelayUpdate) => void>>()
private listenerMap = new Map<string, (update: DelayUpdate) => void>()
private groupListenerMap = new Map<string, Set<() => void>>()
// Consumers compare snapshot identity; replace it only when the group settles.
@@ -51,35 +49,6 @@ class DelayManager {
private itemFlushScheduled = false
private groupFlushScheduled = false
setProfile(profile: string | null) {
if (this.profile === profile) return
this.profile = profile
this.generation += 1
this.cache.clear()
this.activeBatches.clear()
this.pendingItemUpdates.clear()
this.pendingGroupUpdates.clear()
this.groupSnapshots.clear()
this.groupSetSnapshots.clear()
// Reset before a refreshed proxy snapshot can publish newer history.
for (const [key, listeners] of this.listenerMap) {
for (const listener of [...listeners]) {
try {
listener({ delay: -1, updatedAt: 0 })
} catch (error) {
console.error(
`[DelayManager] Failed to reset delay listener: ${key}`,
error,
)
}
}
}
for (const group of this.groupListenerMap.keys()) {
this.queueGroupNotification(group)
}
}
private scheduleOnNextFrame(run: () => void): void {
if (typeof window !== 'undefined') {
if (typeof window.requestAnimationFrame === 'function') {
@@ -105,19 +74,17 @@ class DelayManager {
this.pendingItemUpdates = new Map()
updates.forEach((queue, key) => {
const listeners = this.listenerMap.get(key)
if (!listeners) return
const listener = this.listenerMap.get(key)
if (!listener) return
queue.forEach((update) => {
for (const listener of [...listeners]) {
try {
listener(update)
} catch (error) {
console.error(
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
error,
)
}
try {
listener(update)
} catch (error) {
console.error(
`[DelayManager] 通知节点延迟监听器失败: ${key}`,
error,
)
}
})
})
@@ -203,22 +170,18 @@ class DelayManager {
return url || 'http://cp.cloudflare.com/generate_204'
}
addListener(
setListener(
name: string,
group: string,
listener: (update: DelayUpdate) => void,
): () => void {
) {
const key = hashKey(name, group)
const listeners = this.listenerMap.get(key) ?? new Set()
listeners.add(listener)
this.listenerMap.set(key, listeners)
this.listenerMap.set(key, listener)
}
return () => {
const current = this.listenerMap.get(key)
if (!current) return
current.delete(listener)
if (current.size === 0) this.listenerMap.delete(key)
}
removeListener(name: string, group: string) {
const key = hashKey(name, group)
this.listenerMap.delete(key)
}
/** Multiple views may subscribe independently; notifications occur only after settle. */
@@ -274,8 +237,7 @@ class DelayManager {
return undefined
}
// React's external-store reads must retain identity until the value changes.
return entry
return { ...entry }
}
getDelay(name: string, group: string) {
@@ -314,21 +276,19 @@ class DelayManager {
member: InteractableProxyMember,
group: string,
timeout: number,
): Promise<void> {
const generation = this.generation
): Promise<DelayUpdate> {
// Entry points own the pending mark; measureDelay only writes results.
this.setDelay(member.ref.name, group, -2)
await this.measureDelay(member, group, timeout, generation)
if (generation === this.generation) this.queueGroupNotification(group)
const update = await this.measureDelay(member, group, timeout)
this.queueGroupNotification(group)
return update
}
private async measureDelay(
member: InteractableProxyMember,
group: string,
timeout: number,
generation: number,
): Promise<void> {
if (generation !== this.generation) return
): Promise<DelayUpdate> {
const name = member.ref.name
const providerName =
member.kind === 'node' ? providerNameOf(member.node) : undefined
@@ -364,18 +324,14 @@ class DelayManager {
const elapsed = elapsedTime
debugLog(`[DelayManager] 延迟测试完成,代理: ${name}, 结果: ${delay}ms`)
if (generation === this.generation) {
this.setDelay(name, group, delay, { elapsed })
}
return this.setDelay(name, group, delay, { elapsed })
} catch (error) {
await new Promise((resolve) => setTimeout(resolve, 500))
console.error(`[DelayManager] 延迟测试出错,代理: ${name}`, error)
const delay = 1e6 // error
const elapsed = Date.now() - startTime
if (generation === this.generation) {
this.setDelay(name, group, delay, { elapsed })
}
return this.setDelay(name, group, delay, { elapsed })
}
}
@@ -385,7 +341,6 @@ class DelayManager {
timeout: number,
concurrency = 36,
) {
const generation = this.generation
debugLog(
`[DelayManager] 批量测试延迟开始,组: ${group}, 数量: ${proxies.length}, 并发数: ${concurrency}`,
)
@@ -399,7 +354,6 @@ class DelayManager {
const startTime = Date.now()
const help = async (): Promise<void> => {
if (generation !== this.generation) return
const currMember = proxies[index++]
if (!currMember) return
const currName = currMember.ref.name
@@ -413,15 +367,13 @@ class DelayManager {
}
// Do not reorder a list around the pointer while batch results are arriving.
await this.measureDelay(currMember, group, timeout, generation)
await this.measureDelay(currMember, group, timeout)
} catch (error) {
console.error(
`[DelayManager] 批量测试单个代理出错,代理: ${currName}`,
error,
)
if (generation === this.generation) {
this.setDelay(currName, group, 1e6)
}
this.setDelay(currName, group, 1e6)
}
return help()
@@ -439,14 +391,12 @@ class DelayManager {
await Promise.all(promiseList)
} finally {
// Always release the batch and notify; otherwise failures leave stale sort state.
if (generation === this.generation) {
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 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
+13 -6
View File
@@ -1,22 +1,29 @@
import path from 'node:path'
import legacy from '@vitejs/plugin-legacy'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
import svgr from 'vite-plugin-svgr'
import { guardedLegacy } from './scripts/modern-polyfills.mjs'
const svgPlugin = svgr()
const svgLoad = svgPlugin.load
if (!svgLoad) throw new Error('vite-plugin-svgr must provide a load hook')
// SVGR's internal include filter still crosses the JS bridge for every module.
svgPlugin.load = {
filter: { id: /\.svg\?react$/ },
handler: typeof svgLoad === 'function' ? svgLoad : svgLoad.handler,
}
export default defineConfig({
root: 'src',
server: { port: 3000 },
plugins: [
svgr(),
svgPlugin,
react(),
legacy({
guardedLegacy({
modernTargets: ['edge>=109', 'safari>=14'],
renderLegacyChunks: false,
// Auto-detected from the emitted bundle, dependencies included,
// so post-Safari-14 APIs never depend on extending a manual list.
modernPolyfills: true,
additionalModernPolyfills: [
path.resolve('./src/polyfills/matchMedia.js'),
path.resolve('./src/polyfills/WeakRef.js'),