Skip to content

Commit 537e89d

Browse files
committed
Merge branch 'develop'
# Conflicts: # package-lock.json # package.json
2 parents ac4f09a + 313c38f commit 537e89d

52 files changed

Lines changed: 6362 additions & 1050 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/workflows/release.yml

Lines changed: 226 additions & 64 deletions
Large diffs are not rendered by default.

package-lock.json

Lines changed: 642 additions & 786 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"private": true,
33
"name": "switchhosts",
4-
"version": "5.0.0",
4+
"version": "5.0.1",
55
"description": "Switch hosts quickly!",
66
"homepage": "https://switchhosts.vercel.app",
77
"license": "Apache-2.0",
@@ -20,6 +20,7 @@
2020
"dev:renderer": "vite --config ./vite.render.config.mts",
2121
"build:renderer": "vite build --config ./vite.render.config.mts",
2222
"build:renderer:tauri": "node scripts/sync-tauri-version.mjs && vite build --config ./vite.render.config.mts --outDir ../../build-tauri --emptyOutDir",
23+
"build:universal-helper": "node scripts/bundle-universal-helper.mjs",
2324
"sort-lang": "node ./scripts/sort-lang.mjs",
2425
"tauri": "tauri",
2526
"tauri:dev": "tauri dev",
@@ -30,49 +31,49 @@
3031
"dependencies": {
3132
"@codemirror/commands": "^6.10.3",
3233
"@codemirror/state": "^6.6.0",
33-
"@codemirror/view": "^6.41.1",
34-
"dayjs": "1.11.20",
34+
"@codemirror/view": "^6.43.1",
35+
"dayjs": "1.11.21",
3536
"lodash": "4.18.1",
3637
"tslib": "2.8.1",
3738
"uuid": "14.0.0"
3839
},
3940
"devDependencies": {
4041
"@eslint/js": "^10.0.1",
41-
"@mantine/core": "^9.1.1",
42-
"@mantine/hooks": "^9.1.1",
43-
"@mantine/notifications": "^9.1.1",
44-
"@playwright/test": "^1.59.1",
45-
"@tabler/icons-react": "3.42.0",
42+
"@mantine/core": "^9.3.1",
43+
"@mantine/hooks": "^9.3.1",
44+
"@mantine/notifications": "^9.3.1",
45+
"@playwright/test": "^1.60.0",
46+
"@tabler/icons-react": "3.44.0",
4647
"@tauri-apps/api": "^2.11.0",
47-
"@tauri-apps/cli": "^2.11.0",
48+
"@tauri-apps/cli": "^2.11.2",
4849
"@testing-library/react": "^16.3.2",
4950
"@types/assert": "1.5.11",
5051
"@types/lodash": "4.17.24",
51-
"@types/node": "25.6.0",
52-
"@types/react": "19.2.14",
52+
"@types/node": "25.9.3",
53+
"@types/react": "19.2.17",
5354
"@types/react-dom": "19.2.3",
5455
"@types/uuid": "11.0.0",
55-
"@vitejs/plugin-react": "^6.0.1",
56+
"@vitejs/plugin-react": "^6.0.2",
5657
"ahooks": "3.9.7",
5758
"clsx": "2.1.1",
58-
"eslint": "^10.3.0",
59+
"eslint": "^10.4.1",
5960
"eslint-plugin-react-hooks": "^7.1.1",
6061
"globals": "^17.6.0",
6162
"jotai": "2.20.0",
6263
"jsdom": "^29.1.1",
63-
"prettier": "3.8.3",
64+
"prettier": "3.8.4",
6465
"pretty-bytes": "7.1.0",
65-
"react": "19.2.5",
66-
"react-dom": "19.2.5",
66+
"react": "19.2.7",
67+
"react-dom": "19.2.7",
6768
"react-icons": "5.6.0",
68-
"react-router": "7.15.1",
69+
"react-router": "7.17.0",
6970
"rimraf": "^6.1.3",
70-
"sass": "1.99.0",
71+
"sass": "1.100.0",
7172
"smooth-scroll-into-view-if-needed": "2.0.2",
7273
"typescript": "6.0.3",
73-
"typescript-eslint": "^8.59.2",
74+
"typescript-eslint": "^8.61.0",
7475
"vite": "8.0.16",
75-
"vite-plugin-static-copy": "4.1.0",
76-
"vitest": "^4.1.5"
76+
"vite-plugin-static-copy": "4.1.1",
77+
"vitest": "^4.1.8"
7778
}
7879
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
#!/usr/bin/env node
2+
/**
3+
* Lipo the extra cargo bin target(s) into the universal-apple-darwin build
4+
* so the Tauri bundler can copy them into the app bundle.
5+
*
6+
* Why this exists:
7+
* `tauri build --target universal-apple-darwin` compiles the crate for
8+
* both aarch64 and x86_64, then `lipo`-combines the two slices into one
9+
* fat binary — but the Tauri CLI (2.x) does this only for the *main* app
10+
* binary (`switchhosts`). Any additional cargo bin target — here
11+
* `swh_helper`, the privileged hosts-writing helper from
12+
* `src-tauri/src/bin/swh_helper.rs` — is left un-merged. It exists at
13+
* target/{aarch64,x86_64}-apple-darwin/release/swh_helper
14+
* but NOT at
15+
* target/universal-apple-darwin/release/swh_helper
16+
* so the bundler, which iterates over *all* bin targets and copies each
17+
* from the universal dir into `Contents/MacOS/`, fails with
18+
* failed to bundle project Failed to copy binary ... swh_helper ...
19+
* does not exist
20+
* The single-arch macOS jobs are unaffected — cargo emits swh_helper
21+
* straight into their per-arch release dir.
22+
*
23+
* Fix: wired up as tauri.conf.json `beforeBundleCommand`, which fires
24+
* AFTER the compile + main-binary lipo and BEFORE the bundler copies
25+
* binaries. We lipo each extra bin into the universal dir ourselves.
26+
*
27+
* Cross-platform & safe on every other build: `beforeBundleCommand` also
28+
* runs on the Windows and Linux release jobs (where `node` is present
29+
* but `lipo` and the universal dir are not) and on single-arch macOS
30+
* builds. It no-ops unless we are on macOS AND the universal target dir
31+
* exists, i.e. an actual universal build.
32+
*/
33+
34+
import { existsSync, chmodSync } from 'fs'
35+
import { dirname, join } from 'path'
36+
import { fileURLToPath } from 'url'
37+
import { spawnSync } from 'child_process'
38+
39+
const __dirname = dirname(fileURLToPath(import.meta.url))
40+
const root = join(__dirname, '..')
41+
42+
// Windows / Linux release jobs run this hook too — nothing to merge there.
43+
if (process.platform !== 'darwin') {
44+
process.exit(0)
45+
}
46+
47+
const targetRoot = join(root, 'src-tauri', 'target')
48+
const universalDir = join(targetRoot, 'universal-apple-darwin', 'release')
49+
50+
// Single-arch macOS builds (--target {aarch64,x86_64}-apple-darwin) never
51+
// create this dir; only a universal build does, and by the time this hook
52+
// fires the main binary has already been lipo'd into it. No dir → no-op.
53+
if (!existsSync(universalDir)) {
54+
process.exit(0)
55+
}
56+
57+
// Extra cargo bin targets (besides the main `switchhosts` app binary) that
58+
// Tauri's universal lipo step skips. Keep in sync with src-tauri/src/bin/.
59+
const EXTRA_BINS = ['swh_helper']
60+
const ARCHES = ['aarch64-apple-darwin', 'x86_64-apple-darwin']
61+
62+
for (const bin of EXTRA_BINS) {
63+
const out = join(universalDir, bin)
64+
65+
// Idempotent: a re-run — or a future Tauri that learns to lipo extra
66+
// bins itself — leaves an already-fat binary untouched.
67+
if (existsSync(out)) {
68+
console.log(`[universal-helper] ${bin} already present in universal dir`)
69+
continue
70+
}
71+
72+
const inputs = ARCHES.map((arch) => join(targetRoot, arch, 'release', bin))
73+
const missing = inputs.filter((p) => !existsSync(p))
74+
if (missing.length > 0) {
75+
// The per-arch builds must have produced this bin. If they didn't,
76+
// bundling would fail downstream with the same "does not exist"; fail
77+
// loudly here with a clearer message instead of shipping a broken app.
78+
console.error(
79+
`[universal-helper] cannot lipo ${bin}: missing per-arch build(s):\n ` +
80+
missing.join('\n '),
81+
)
82+
process.exit(1)
83+
}
84+
85+
console.log(`[universal-helper] lipo ${bin}${out}`)
86+
const res = spawnSync('lipo', ['-create', '-output', out, ...inputs], {
87+
stdio: 'inherit',
88+
})
89+
if (res.error || res.status !== 0) {
90+
console.error(
91+
`[universal-helper] lipo failed for ${bin}` +
92+
(res.error ? `: ${res.error.message}` : ` (exit ${res.status})`),
93+
)
94+
process.exit(res.status || 1)
95+
}
96+
97+
// lipo's output may not carry the executable bit; the bundler copies and
98+
// code-signs this as Contents/MacOS/swh_helper, which must be runnable.
99+
chmodSync(out, 0o755)
100+
}

src-tauri/Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src-tauri/Cargo.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
[package]
22
name = "switchhosts"
3-
version = "5.0.0"
3+
version = "5.0.1"
44
description = "A GUI tool for managing your hosts file."
55
authors = ["oldj"]
66
edition = "2021"
77
rust-version = "1.77"
8+
# Disambiguate the main app binary from the `swh_helper` bin target so
9+
# the Tauri bundler / `cargo run` pick the app, not the helper (which
10+
# would otherwise win alphabetically). See src/bin/swh_helper.rs.
11+
default-run = "switchhosts"
812

913
[lib]
1014
name = "switchhosts_lib"

src-tauri/build.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,12 @@ fn main() {
1717
println!("cargo:rustc-env=SWH_VERSION_LABEL=v{version}");
1818

1919
#[cfg(target_os = "macos")]
20-
println!("cargo:rustc-link-lib=framework=Security");
20+
{
21+
println!("cargo:rustc-link-lib=framework=Security");
22+
// ServiceManagement: SMAppService daemon registration (the
23+
// privileged helper install path). See hosts_apply::helper_admin.
24+
println!("cargo:rustc-link-lib=framework=ServiceManagement");
25+
}
2126

2227
tauri_build::build()
2328
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3+
<!--
4+
LaunchDaemon definition for the SwitchHosts privileged helper.
5+
6+
Shipped inside the signed app bundle at
7+
`Contents/Library/LaunchDaemons/net.oldj.switchhosts.helper.plist` and
8+
registered via `SMAppService.daemonServiceWithPlistName:` (macOS 13+).
9+
launchd starts the daemon as root on demand when the app connects to
10+
the `net.oldj.switchhosts.helper` Mach service, and lets it idle out
11+
when not in use (no KeepAlive).
12+
-->
13+
<plist version="1.0">
14+
<dict>
15+
<key>Label</key>
16+
<string>net.oldj.switchhosts.helper</string>
17+
18+
<!-- Executable to run, relative to the app bundle. This is the
19+
`swh_helper` cargo bin target, which Tauri builds, copies into
20+
Contents/MacOS/, and code-signs as part of the app bundle. -->
21+
<key>BundleProgram</key>
22+
<string>Contents/MacOS/swh_helper</string>
23+
24+
<!-- The Mach service the daemon listens on; the app connects to this
25+
name in the privileged (system) launchd domain. -->
26+
<key>MachServices</key>
27+
<dict>
28+
<key>net.oldj.switchhosts.helper</key>
29+
<true/>
30+
</dict>
31+
32+
<!-- Attribute the daemon to the main app in System Settings. -->
33+
<key>AssociatedBundleIdentifiers</key>
34+
<array>
35+
<string>net.oldj.switchhosts</string>
36+
</array>
37+
</dict>
38+
</plist>

src-tauri/src/app_menu.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ pub const MENU_ID_FIND: &str = "app-find";
2323
pub const MENU_ID_COMMENT: &str = "app-comment";
2424
pub const MENU_ID_FEEDBACK: &str = "app-feedback";
2525
pub const MENU_ID_HOMEPAGE: &str = "app-homepage";
26+
pub const MENU_ID_QUIT_APP: &str = "app-quit";
2627
#[cfg(target_os = "macos")]
2728
pub const MENU_ID_HIDE_APP: &str = "app-hide";
2829

@@ -88,7 +89,13 @@ fn build_macos_app_menu<R: Runtime>(
8889
.build(app)?;
8990
let hide_others = PredefinedMenuItem::hide_others(app, Some(labels.hide_others))?;
9091
let show_all = PredefinedMenuItem::show_all(app, Some(labels.show_all))?;
91-
let quit = PredefinedMenuItem::quit(app, Some(labels.quit_app))?;
92+
// Custom Quit item (rather than PredefinedMenuItem::quit) so its
93+
// handler can set `is_will_quit` before exiting — letting the
94+
// CloseRequested / ExitRequested hooks distinguish a real user-
95+
// initiated quit from a window-close in lightweight_mode.
96+
let quit = MenuItemBuilder::with_id(MENU_ID_QUIT_APP, labels.quit_app)
97+
.accelerator("CmdOrCtrl+Q")
98+
.build(app)?;
9299

93100
SubmenuBuilder::new(app, "SwitchHosts")
94101
.item(&about)
@@ -129,7 +136,11 @@ fn build_file_menu<R: Runtime>(
129136

130137
#[cfg(not(target_os = "macos"))]
131138
{
132-
let quit = PredefinedMenuItem::quit(app, Some(labels.quit))?;
139+
// See macOS Quit comment above — same rationale for using a
140+
// custom item.
141+
let quit = MenuItemBuilder::with_id(MENU_ID_QUIT_APP, labels.quit)
142+
.accelerator("CmdOrCtrl+Q")
143+
.build(app)?;
133144
builder = builder.separator().item(&quit);
134145
}
135146

src-tauri/src/bin/swh_helper.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
//! `swh_helper` — the SwitchHosts privileged helper daemon (macOS).
2+
//!
3+
//! Installed once via `SMAppService.daemon` and run as `root` by
4+
//! launchd, this tiny binary performs the system `/etc/hosts` writes
5+
//! that would otherwise require a per-write admin authorization prompt.
6+
//! It links none of the app's heavy machinery (no Tauri, no reqwest, no
7+
//! storage) — only the shared [`switchhosts_lib::helper_proto`] contract
8+
//! — keeping the privileged attack surface minimal.
9+
//!
10+
//! Lifecycle: launchd starts this binary as `root` on demand (the
11+
//! daemon plist declares the `net.oldj.switchhosts.helper` Mach
12+
//! service). [`helper_proto::daemon::run`] brings up the XPC listener,
13+
//! pins each client to the genuine SwitchHosts app's code signature, and
14+
//! never returns.
15+
16+
#[cfg(target_os = "macos")]
17+
fn main() {
18+
// `run()` diverges (parks on `dispatch_main`); it never returns.
19+
switchhosts_lib::helper_proto::daemon::run()
20+
}
21+
22+
#[cfg(not(target_os = "macos"))]
23+
fn main() {
24+
eprintln!("swh_helper is only supported on macOS");
25+
std::process::exit(1);
26+
}

0 commit comments

Comments
 (0)