- Detect custom DNS in raw subscriptions and require confirmation
- Bind confirmation to the subscription and DNS content for the current session
- Sync the override switch after successful core application
- Retain auto-disable notices until the window becomes visible
Client-side hardening of the CPX airport plugin, squashed from 46 commits
forked at 89c2bb0e. Behaviour changes:
- Unified operation model (§0.4/0.5): one deadline + one AbortSignal + one
persistence commit per operation; per-plugin lock → vault lock hierarchy;
tombstone on delete; every wait (lock, DNS preflight, proxy resolution,
vault decrypt) is bounded by the same budget.
- Routing: direct/proxy auto-fallback with a pre-send guard; proxied https
builds its own CONNECT tunnel (an aborted hung CONNECT closes its socket);
invalid local-proxy ports are refused instead of falling back to :80; the
core's inbound credentials are carried to the local proxy; NAT64 and
site-local IPv6 ranges are non-public.
- Gateways: multi-gateway recovery with one rediscovery per operation,
normalized endpoint paths, signed discovery documents (Ed25519, seq/digest
accept/align/rollback/equivocation), commit order vault → plugin.yaml.
- Subscriptions: a fetched subscription is validated by the core (mihomo -t)
against the current override set before it replaces the profile, inside
the profile write critical section (profile.yaml and override.yaml share
one write queue); schedule fields are read at write time; the first
subscription is activated through the real switch flow; profile deletion
removes the record last so any failure stays retryable.
- Devices: a re-login that replaces a still-valid device records it in the
vault (staleDevices) and retires it after the login, after later
successful fetches and on removal; enroll compensation restores the old
vault and keeps an un-revoked new device for retirement.
- Vault: on Linux the vault is persisted only behind a system secret store
(backend name + ciphertext-prefix canary); otherwise it stays in memory.
A cache-miss read releases the caller at the budget while the lock is held
until the decrypt ends.
- Config caches (plugin.yaml, profile.yaml, override.yaml) can no longer be
rolled back by a late cold read.
- Reference gateway and provider guides updated (deploy contract, discoveryUrls
same-origin rule, /revoke after re-login); https-proxy-agent dropped.
Reviewed in a Codex loop (gpt-6, 38 calls): 74 findings, 68 fixed and
verified, 6 invalid, no backlog. Tests: vitest 501, gateway 106.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
contextBridge wraps every function crossing the world boundary in a fresh
proxy object, so ipcRenderer.on() and removeListener() each receive a
different wrapper and reference-based removal always fails. Components
that subscribe in useEffect then return removeListener as cleanup leave a
ghost listener behind after unmount.
The connections page is the worst offender: the /connections stream
pushes the full connection list every second, and each ghost handler
dispatches a fresh connection tree into a React update queue that is
never rendered — heap grows ~22-25MB/min after visiting the page a few
times, scaling with the number of visits.
Make on() return an unsubscribe closure that captures the exact wrapper
registered with ipcRenderer, and switch every renderer call site to use
it as its effect cleanup.
Verified with a minimal Electron harness (removed listener stops firing)
and against the real app: hammering the connections page 20x now leaves
memory flat instead of growing ~25MB/min, with zero connections-chunk
allocations sampled.
Co-authored-by: tuqiming <tuqiming@camel4u>
Co-authored-by: Claude Code <noreply@anthropic.com>
- create-config-context passed the SWR key straight to the fetcher. SWR
calls fetcher(key), so getAppConfig/getProfileConfig/getOverrideConfig/
getPluginConfig all received a non-empty string as their `force`
argument and re-read from disk on every single revalidation. Call the
fetcher with no arguments.
- webdav-config created its debounced writer inline during render, so
every keystroke produced a fresh function with a fresh timer and the
debounce never took effect - each character rewrote config.yaml. Keep
one instance across renders and cancel it on unmount.
- The traffic table's pager acted on the raw page number rather than the
clamped one, so after the list shrank the "previous page" button did
nothing until it had been clicked as many times as the overshoot.
- The rules editor swallowed load failures, then saved anyway - which
overwrote the user's existing prepend/append/delete rule overrides with
an empty set. Refuse to save when the current content was never loaded.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix: apply Smart core settings on hot reload
The Smart core switches (LightGBM, data collection, strategy, collector
size) are delivered to the kernel through a generated global override
script, but that script was only regenerated inside prepareCore(). The
settings page persists the change and then hot reloads, so the reload
re-applied the previous script and the new values silently did not reach
the kernel until the core happened to be restarted.
Regenerate the override before generating the profile on hot reload, and
skip the write when the generated content is unchanged so routine hot
reloads no longer churn the override file.
* fix: reuse the downloaded Smart model when checking a profile
The Smart core downloads Model.bin when it is missing from its working
directory. Profile checks run the core with `-d <test dir>`, which never
receives the model, so every check starts a fresh download.
That download runs after the previous core has already been stopped and
the system proxy torn down, so it times out instead of completing, and a
failed download leaves nothing behind - the next restart repeats it. On a
restart here it cost 20s, against 0.5s once the model is in place.
Copy the model over from the working directory before running the check,
mirroring how the geo databases are already shared with the test dir.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Three problems in installMihomoCore / downloadGitHubAsset.
1. chromeRequest resolves for any status code, so downloadGitHubAsset
never noticed an HTTP error. A mirror answering 404 or 502 with an
HTML page was treated as a successful download: the error page was
written out as the core binary and the function returned, so the
remaining mirrors and the direct GitHub URL were never tried. Check
the status and throw so the fallback loop continues.
2. The .gz path decompressed straight onto the live core file. Any
failure mid-stream left the previously working core truncated with
nothing to fall back to. Decompress to a staging file and rename it
into place only after it is complete, removing the staging file if
anything goes wrong. The pipeline also only had an error handler on
the gunzip and write streams, so a read error was unhandled; wire all
three and destroy them on failure.
3. Permissions were set with `execSync('chmod 755 ' + targetPath)`, with
the path neither quoted nor escaped. On macOS the data directory is
under "~/Library/Application Support/", so the command always split on
the space and failed. The failure was only logged as a warning, so the
result was a freshly installed core with no execute permission - it
looks installed but will not start. Use fs.chmodSync.
The downloaded archive was also left behind whenever extraction failed,
accumulating one copy per retry; clean it up on the error path.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
- addOverrideItem read the config outside the write queue, then awaited
createOverride - which for a remote override downloads over the network
and can take seconds - and finally wrote the stale snapshot back in
full. Any change made meanwhile was lost. updateOverrideItem and
removeOverrideItem had the same shape. Move the read inside the queue
and re-read from disk there, matching what profile.ts already does.
- The remote override download never checked the HTTP status, so a 404 or
502 error page was written out as the override body. applyOverrides then
parses it with no try, and the failure surfaces as a core restart error.
- The download built its proxy from the mixed port with a destructuring
default, so with the port turned off (0) it went to 127.0.0.1:0.
- patchControledMihomoConfig ran the patch through JSON.parse(stringify),
which drops keys whose value is undefined. The startup migration uses
exactly that to delete obsolete keys such as external-controller-unix,
so those deletions never happened - the keys stayed in mihomo.yaml and
the migration re-ran its full rewrite, config regeneration and Gist
upload on every launch. Restore top-level undefined keys after the
round-trip so yaml.stringify can drop them for real.
- Dropping a file read `(file as File & { path: string }).path`. Electron
removed File.path, so the value is undefined and readTextFile always
failed. Use webUtils.getPathForFile, which preload already exposes and
which profiles.tsx already uses for the same purpose.
- Reordering computed indices against the sorted copy but spliced in
`items[activeIndex]` - an element from the unsorted array. Whenever the
two orders differ this duplicated one entry and dropped another. Splice
out and reinsert the same element, as profiles.tsx does, and bail out if
either index is -1.
- The drop handler read `file.name` without checking that the FileList was
non-empty, so dropping selected text threw a TypeError. The throw also
skipped the setFileOver(false) reset, leaving the whole page stuck
behind a blur. Guard the access and reset the flag in a finally block.
The copy button only rendered while the card was hovered, and the proxy
checkbox that sits next to it re-laid the row out on hover, so the button
moved out from under the pointer as it was being approached.
Closes#476
- The PAC server called listen() with no 'error' listener. A host the user
cannot bind to - or a port already in use - emitted an unhandled 'error'
event, which in Node terminates the process, so a bad PAC host setting
crashed the whole main process. Attach an error handler and surface the
failure to the caller.
- The Gist upload never checked the HTTP status. Once the token expired,
GitHub answered 401 and the code still recorded the runtime config as
successfully synced, so the user saw a healthy backup that did not
exist. Check the status and report the failure.
- On 'render-process-gone' the floating window handler only set the module
reference to null without destroying the BrowserWindow. The dead window
stayed on screen, always-on-top, with nothing left holding a reference
to close it. Destroy it before clearing the reference.
The generated Smart override creates the "Smart Group" conditionally but
rewrites rules unconditionally, so the rules can end up pointing at a
group that was never added.
Two reachable cases:
- The group is only created when `config.proxies` is a non-empty array.
A subscription that ships only `proxy-providers` (which the app
explicitly accepts) skips creation, yet every non-builtin rule target
is still replaced with the literal string 'Smart Group'.
- When the profile already contains a `type: smart` group under a
different name, `smartGroupExists` becomes true so nothing is
created, and the existing group is never renamed - but the rules are
still rewritten to 'Smart Group'.
In both cases the generated config.yaml references a missing proxy
group, so the core refuses to start ("proxy Smart Group not found") and
the app is left with no working core.
Track the name of the smart group that actually exists - the existing
group's own name, or 'Smart Group' when one was created - and use it for
the replacement. When neither applies, skip rule rewriting entirely and
leave the profile's own targets untouched.
Closes#885
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* feat: auto-switch profile by WiFi SSID
Add ssidProfileMap (SSID -> profile ID) and ssidProfileRestore to app config.
When connecting to a mapped WiFi SSID, automatically switch to the
corresponding subscription profile. Optionally restore the previous
profile when leaving the mapped SSID. pauseSSID takes priority.
* fix: send profileConfigUpdated after SSID-triggered profile switch
changeCurrentProfile() does not notify the renderer via IPC.
The renderer relies on the profileConfigUpdated event to revalidate
its SWR cache, so SSID-based switching was effective in the backend
but the UI didn't reflect it until a manual refresh.
* perf: event-driven SSID detection on macOS via scutil -w
Replace 30s polling with scutil -w child process on macOS.
scutil -w blocks on a SystemConfiguration key, consuming zero CPU
until the network state changes, then exits. On exit we debounce
500ms and re-check the SSID.
Windows/Linux keep polling, reduced from 30s to 15s.
Also add anti-tight-loop guard (1s minimum restart interval).
convertMrsRuleset built a shell command string from `behavior` and the
resolved ruleset path and ran it through `exec`. `behavior` comes from
the runtime config's `rule-providers`, which originates from the user's
subscription YAML and is therefore untrusted input.
A malicious or compromised subscription can set a behavior such as
`domain & <command> &`; opening that ruleset in the resource viewer then
executes arbitrary commands with the app's privileges. The quoted paths
are also breakable on Windows via an embedded double quote.
Switch to `execFile` with an argument array so nothing is parsed by a
shell, and reject any behavior outside the set the core supports.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
The existing build.yml only runs on v* tags and never invokes the test
suite, so pull requests get no automated checks. Add a lightweight CI
workflow that runs format:check, lint:check and typecheck once on Linux
and the unit tests on both Linux and Windows. Uses --ignore-scripts so it
does not download the mihomo cores, which the checks and tests do not need.
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
The macos and macos10 jobs submit the signed .pkg to notarytool but never
staple the resulting ticket to it. Without a stapled ticket Gatekeeper has to
reach Apple's notary service to verify the package, so an install on a machine
that is offline — or behind a blocked network, which is a plausible state for
this particular app before it is running — fails with "cannot be opened because
Apple cannot check it for malicious software".
Staple after a successful submission, which is the documented final step of the
Developer ID distribution flow.