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>
EditTunnelsModal saves by spreading the in-memory `profile` state and
writing the whole document back with setProfileStr, which replaces the
profile file outright and then hot-reloads the core. `profile` starts as
`{}` and is only filled in once the async load succeeds.
Neither failure path stops the user from saving:
- When yaml.load throws (a subscription with a duplicated mapping key,
for example) the catch only shows a toast. `profile` stays `{}`, so
pressing save serialises "{}\n" over the profile and every proxy,
proxy-group and rule is gone.
- When the parsed value is not an object the code silently substituted
`{}` with no toast at all. An age-encrypted profile hits this: the
file on disk is ciphertext and getProfileStr does not decrypt, so
yaml.load returns a string, the dialog shows "no tunnels", and saving
destroys the encrypted profile.
The save button was also enabled while the initial load was still in
flight.
Treat a non-object parse result as an error instead of silently
substituting `{}`, record the failure, and refuse to save - both in
handleSave and by disabling the button - until the profile has actually
been read.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Move the renderer listener into the page effect and gate the main-process log WebSocket on renderer demand. Prevent an in-flight stream startup from surviving page teardown.
dirs.test.ts asserts on values produced by `path.join`, but compares
them against hardcoded POSIX literals such as
'/tmp/app-data/mihomo-party-dev'. On Windows `path.join` returns
'\tmp\app-data\mihomo-party-dev', so two of the three cases fail on a
clean checkout:
× isolates an unpackaged local development app
× keeps portable userData precedence over local development isolation
The fs mock had the same problem: it detected the portable marker with
`value.endsWith('/PORTABLE')`, which never matches the backslash path
that `path.join` produces on Windows.
dirs.ts itself is correct; only the test encodes a platform assumption.
Build the expected values with `path.join` and match the portable
marker with `path.basename`, so the suite is platform-agnostic.
Full suite now passes on Windows 11: 20 files, 176 tests.
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* fix: treat HTTP errors as failures in the updater and fix the pkg install path
1. tryDownload returned the first response it got. chromeRequest only
rejects on transport errors, so an HTTP 404/502 from a mirror was
returned as success and the remaining mirrors were never tried.
checkUpdate then ran `parse()` over an HTML error page; the result has
no `version`, so compareVersions calls `.replace` on undefined and
throws a TypeError instead of reporting that the check failed. Check
the status code and throw so the fallback loop keeps going.
2. downloadAndInstallUpdate only cleared updateInstallPromise when
installUpdate rejected. The macOS .pkg branch catches an osascript
failure and falls back to `shell.openPath`, which resolves normally
and leaves the app running. From then on updateInstallPromise held a
settled promise, so every later attempt returned it immediately and
the update button did nothing. Clear it in `finally`.
3. The pkg command escaped spaces with `.replace(' ', '\\\\ ')`. A string
pattern replaces only the first occurrence, and the macOS data
directory ("~/Library/Application Support/...") contains more than
one space, so the installer command was still split. Quote the path
once and escape it for the AppleScript string literal, and invoke
osascript through execFile so the outer command is not shell-parsed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* fix: validate latest.yml and skip the proxy when the mixed port is off
- checkUpdate parsed latest.yml and used the result without checking it.
A proxy error page can still parse as YAML (`404: Not Found` is a valid
mapping), so `latest.version` was undefined and compareVersions threw a
TypeError on `a.replace` instead of reporting a failed check.
- Both the update check and the download built the proxy from the mixed
port through a destructuring default, which only covers undefined.
Turning the port off stores 0, so every request went to 127.0.0.1:0 and
failed. Go direct when no port is enabled.
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Turning the traffic display off sent trayIconUpdate with enabled=false,
but the handler ignores that flag and always applies the supplied data
URL, so the tray kept showing the renderer-composed image until the app
was restarted. Ask the main process to redraw from its own resources
afterwards.
Refs #1080
This covers the "does not recover" half of the report. The colours being
wrong while the traffic display is on has the same root cause as #1143 -
the handler calls setTemplateImage(true) unconditionally, so macOS renders
alpha only - and needs a change in tray.ts.
The scheduled updater calls addProfileItem, which never sends
profileConfigUpdated. The subscription list revalidates on that event, so
the "updated N minutes ago" line and the traffic figures stayed stale
until the window regained focus - which reads exactly like the interval
not working. The plugin update path already notifies; only the remote one
did not.
Refs #1570
This addresses the stale UI half of the report. A scheduled update of the
profile that is currently active still writes the new YAML without
reloading it into the running core, because changeCurrentProfile returns
early when the id is unchanged; that part is left for a separate change.
Two independent robustness defects in mihomoApi.
1. In mihomoTraffic(), `JSON.parse(data)` sat outside the try block,
unlike the memory, logs and connections streams where it is inside.
The handler was also `async`, so a non-JSON frame from the core threw
inside a promise with no rejection handler, producing an
unhandledRejection instead of being ignored like the other three
streams. Move the parse inside the try and drop the unnecessary
`async`.
2. SysProxyStatus() read `appConfig.sysProxy.enable` with no optional
chaining, while the neighbouring TunStatus() already guards with
`config?.tun?.enable`. If the app config is missing or truncated,
sysProxy is undefined and the property access throws. That rejection
propagates through getTrayIconStatus(), so the tray icon stops
reflecting the real state. Guard it the same way TunStatus does.
Closes#1286
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>