* 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.
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>