From 02369001cf5ebc514faaf559fce327c63100783f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 16:29:13 +0000 Subject: [PATCH 1/2] Fix Windows Connect by staging generations in ProgramData. NSIS perMachine expands $LOCALAPPDATA to C:\ProgramData, so 4.2 helper.toml looked in C:\ProgramData\biflow\runtime\generations while the desktop wrote under the user's LocalAppData. Stage at C:\ProgramData\iran-split\staging, grant Users modify, and bump to 4.3.0. Co-authored-by: Dariush vesal --- AGENTS.md | 4 +- Cargo.toml | 2 +- apps/desktop/package.json | 2 +- crates/iran-split-helper/src/lib.rs | 27 +++++++++ crates/iran-split-helper/src/windows.rs | 56 +++++++++++++++++- crates/iran-split-platform-win/src/lib.rs | 30 ++++++++-- docs/adr/0033-windows-platform-backend.md | 10 ++-- .../0035-windows-reveal-and-helper-install.md | 11 ++-- ...064-windows-machine-wide-helper-staging.md | 57 +++++++++++++++++++ docs/adr/README.md | 1 + package.json | 2 +- packaging/windows/installer-hooks.nsh | 2 +- scripts/tauri-contract.test.mjs | 19 ++++++- src-tauri/src/helper_install.rs | 49 ++++++++++------ src-tauri/src/lib.rs | 7 ++- src-tauri/tauri.conf.json | 2 +- version | 2 +- 17 files changed, 232 insertions(+), 51 deletions(-) create mode 100644 docs/adr/0064-windows-machine-wide-helper-staging.md diff --git a/AGENTS.md b/AGENTS.md index 18a1373..07edea4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -163,11 +163,11 @@ already in progress"`. Cache the last `UpdateInfo` (never log asset URLs). - Diagnostics accepts any pasted form (`https://www.rade.ir/`, `host:port`, IPv6) and reduces it with `extractHost` before calling `test_route`; the rule parser rejects URLs outright. The flow result offers the opposite move — Add to direct for a VPN host, Add to VPN for a direct one — and re-tests afterwards so the card shows the new routing. - User route pins are **bidirectional** (ADR 0034): `DirectRulesDocument.rules` pins DIRECT and `vpn_rules` pins VPN, a host lives in exactly one list, and `RuleManager::pin` moves it. Pins store the PSL registrable root (private suffixes included); `api.shop.example.com` is `example.com`, `user.github.io` is not all of `github.io`. `pin` must not wait on DoH. Writers emit `+.example.com` and never copy `resolved_ips` into IP providers. Precedence in `RuleSet::decide` must match generated Mihomo rules — private/LAN first, then VPN pins, then DIRECT pins, then bundled Iran domains, then curated `iran-business-domains`, then `MATCH`. Never let a private, loopback, or CGNAT address onto the VPN list. Custom VPN providers and the curated catalog need helper `GENERATION_FILES` entries (nine files). Live apply when the stack is running or degraded; persist-only when stopped. - `resources/rules/manifest.json` `commit` is the **upstream Chocolate4U** revision the rules came from, not a BiFlow commit. Building a `raw.githubusercontent.com/devlifeX/BiFlow//…` URL from it always 404s and surfaces as "cloud rule download failed". Fetch snapshot files from the same branch the manifest itself is fetched from; the per-file SHA-256 in the manifest is what guarantees integrity, not the ref. -- Windows `.join("biflow/debug.log")` keeps the `/`, so Explorer `/select,C:\…\biflow/debug.log` is ignored and opens This PC. Join `biflow` and `debug.log` as separate components, normalize leftover `/` to `\`, and pass `/select,` with `raw_arg`. The same class of bug is `mihomo_file_name()` returning `"bin/mihomo.exe"` — join `bin` and the file name separately. Helper Install must not elevate `C:\ProgramData\iran-split\bin\iran-split-helper.exe`: a leftover copy `fs::copy`s onto itself and a GUI-subsystem helper leaves stderr empty. Elevate the packaged helper, skip same-file copy, write `ProgramData\iran-split\install.log` on failure, and use NSIS `$PROGRAMDATA` (not `$COMMONPROGRAMDATA`) for helper/Mihomo install destinations. NSIS `--staging-dir` must be `$LOCALAPPDATA\biflow\runtime\generations`, the same path in-app Install writes; `$PROGRAMDATA\iran-split\staging` leaves providers at `0 / 0`. +- Windows `.join("biflow/debug.log")` keeps the `/`, so Explorer `/select,C:\…\biflow/debug.log` is ignored and opens This PC. Join `biflow` and `debug.log` as separate components, normalize leftover `/` to `\`, and pass `/select,` with `raw_arg`. The same class of bug is `mihomo_file_name()` returning `"bin/mihomo.exe"` — join `bin` and the file name separately. Helper Install must not elevate `C:\ProgramData\iran-split\bin\iran-split-helper.exe`: a leftover copy `fs::copy`s onto itself and a GUI-subsystem helper leaves stderr empty. Elevate the packaged helper, skip same-file copy, write `ProgramData\iran-split\install.log` on failure, and use NSIS `$PROGRAMDATA` (not `$COMMONPROGRAMDATA`) for helper/Mihomo install destinations. Packaged Windows helper staging is `$PROGRAMDATA\iran-split\staging` (ADR 0064). NSIS `perMachine` `$LOCALAPPDATA` expands to `C:\ProgramData`, so `$LOCALAPPDATA\biflow\runtime\generations` recorded `C:\ProgramData\biflow\runtime\generations` while the desktop wrote under the user's LocalAppData. The elevated installer ignores a mismatched `--staging-dir`, grants Builtin\Users modify with `icacls`, and the desktop stages into `WindowsPaths.generation_staging_dir`. - `Start-Process -ArgumentList @('--mihomo', 'C:\Program Files\…')` concatenates array entries without quoting, so clap sees `C:\Program` and `Files\…`, exits 2 inside `Arguments::parse()`, and never reaches `persist_install_error`. Pass one Windows-quoted command line. Use `try_parse()` and write a redacted clap kind (`unexpected argument`) to `install.log` before `error.exit()`. - `schtasks /TR "\"exe\" --config \"file\""` stores one broken action: `/Create` and `/Run` return 0, the GUI-subsystem helper never starts, and the desktop times out with “installed but is not reachable yet” while every pipe open is `os error 2`. Register the task from UTF-16 XML with separate `Command` and `Arguments`, wait for `\\.\pipe\iran-split-helper-v1` before returning success, and persist `run_named_pipe` failures to `install.log`. - Never probe a named pipe with `Path::exists()`. It calls `fs::metadata`, an NPFS object has no file attributes to return, and the check reports a healthy helper as missing — turning a working install into a 15s timeout. Open the pipe the way the desktop does and treat only `ERROR_FILE_NOT_FOUND` (2) as absent; a busy instance or a denied ACL still proves it exists. Related Task Scheduler footguns: `false` makes `schtasks /End` a no-op, so a reinstall then dies on `ERROR_SHARING_VIOLATION` copying over the running helper's own image; `schtasks` writes UTF-16 to a pipe (sometimes with no BOM), so `from_utf8_lossy` alone silently yields text nothing can be found in; and `install.log` is read back one line at a time, so every message written to it must be collapsed to one line first. -- Windows `register_runtime_generation` failing with `IO_FAILED … cannot find the file specified (os error 2)` means helper.toml's `staging_dir` is not where the app stages generations. That path is recorded once at install time from `dirs::data_local_dir()`, so launching the app **elevated** for the helper install records the administrator's `%LOCALAPPDATA%` and every later run as the normal user misses it — the Windows twin of the Linux `sudo` uid-0 bug. Reinstalling the helper from a non-elevated app rewrites the path. Never let `canonicalize` errors reach the client bare; name the staging root and the generation directory so one log line identifies the mismatch. +- Windows `register_runtime_generation` failing with `INVALID_GENERATION` / `cannot find the file specified (os error 2)` at `C:\ProgramData\biflow\runtime\generations` is the 4.2 NSIS `$LOCALAPPDATA` all-users expansion (ADR 0064), not a missing generation UUID. Do not point helper.toml at a user profile: an elevated Install records the administrator's `%LOCALAPPDATA%` and every later run as the normal user misses it — the Windows twin of the Linux `sudo` uid-0 bug. Reinstalling the 4.3 helper rewrites staging to `C:\ProgramData\iran-split\staging` and the ACL. Never let `canonicalize` errors reach the client bare; name the staging root and the generation directory so one log line identifies the mismatch. - `RuleManager::pin` must not wait on DoH. Resolve remains metadata-only on `refresh()`. A live apply rebuilds the generation and starts Mihomo on it; success is persist plus apply when the stack is running or degraded. - The connection glow rings the shell from `.connection-glow::after` (a fixed, `pointer-events: none` overlay), never the shell's own border — a real border shifts the fixed 1120x760 layout. `running` is green, `paused` amber, every other phase unringed; `data-connection-glow` carries the state so e2e can assert it without reading colours. The pulse is disabled under `prefers-reduced-motion`. - `install.log` is the only channel out of the elevated Windows helper (`Start-Process -Verb RunAs` cannot redirect stdio and the helper is a `windows` subsystem binary), so the desktop must delete it **before** elevating. The helper overwrites it only when it reaches its own error path; a process that dies earlier leaves the previous attempt's line behind, and reporting a stale reason is worse than reporting none. diff --git a/Cargo.toml b/Cargo.toml index fde8943..bd52dc4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ members = [ ] [workspace.package] -version = "4.2.0" +version = "4.3.0" edition = "2021" license = "MIT OR Apache-2.0" rust-version = "1.88" diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 171998c..5cd3c99 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -1,6 +1,6 @@ { "name": "@iran-split/desktop", - "version": "4.2.0", + "version": "4.3.0", "private": true, "type": "module", "scripts": { diff --git a/crates/iran-split-helper/src/lib.rs b/crates/iran-split-helper/src/lib.rs index 058ddee..12cf165 100644 --- a/crates/iran-split-helper/src/lib.rs +++ b/crates/iran-split-helper/src/lib.rs @@ -876,6 +876,28 @@ tun_name = "clash-iran" delete_owned_interface("unused"); } + #[cfg(windows)] + #[test] + fn windows_production_staging_is_beside_runtime_not_inside_it() { + let settings = HelperSettings { + authorized_uid: 0, + authorized_gid: 0, + socket_path: r"\\.\pipe\iran-split-helper-v1".into(), + staging_dir: r"C:\ProgramData\iran-split\staging".into(), + runtime_dir: r"C:\ProgramData\iran-split\runtime".into(), + mihomo_binary: r"C:\ProgramData\iran-split\bin\mihomo.exe".into(), + mihomo_sha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + .into(), + tun_name: "clash-iran".into(), + }; + assert!(settings.validate().is_ok()); + let nested = HelperSettings { + staging_dir: r"C:\ProgramData\iran-split\runtime\generations".into(), + ..settings + }; + assert!(nested.validate().is_err()); + } + #[cfg(unix)] #[test] fn production_linux_helper_paths_are_absolute() { @@ -972,6 +994,11 @@ tun_name = "clash-iran" assert!(source.contains("wait_until_pipe_ready")); assert!(source.contains("wintun.dll")); assert!(!source.contains("\"/TR\"")); + assert!(source.contains(r#"root.join("staging")"#)); + assert!(source.contains("fn grant_users_modify")); + assert!(source.contains("*S-1-5-32-545:(OI)(CI)M")); + assert!(source.contains("icacls")); + assert!(!source.contains("LOCALAPPDATA")); } /// `fs::metadata` fails on an NPFS object, so an `exists` check would report diff --git a/crates/iran-split-helper/src/windows.rs b/crates/iran-split-helper/src/windows.rs index ce85ad1..c9ef381 100644 --- a/crates/iran-split-helper/src/windows.rs +++ b/crates/iran-split-helper/src/windows.rs @@ -17,6 +17,9 @@ use tracing::{info, warn}; const PIPE_NAME: &str = r"\\.\pipe\iran-split-helper-v1"; const INSTALL_ROOT: &str = r"C:\ProgramData\iran-split"; const INSTALL_LOG: &str = r"C:\ProgramData\iran-split\install.log"; +/// Builtin\Users. Medium-integrity desktop processes can then write generations +/// that the SYSTEM helper later publishes. `(OI)(CI)M` is modify, inherited. +const USERS_MODIFY_ACE: &str = "*S-1-5-32-545:(OI)(CI)M"; const TASK_NAME: &str = "BiFlowHelper"; const PIPE_READY_TIMEOUT: Duration = Duration::from_secs(15); const PIPE_READY_POLL: Duration = Duration::from_millis(100); @@ -106,7 +109,7 @@ pub fn install( fn install_inner( mihomo_src: &Path, - staging_dir: &Path, + requested_staging_dir: &Path, tun_name: &str, ) -> Result<(), HelperServiceError> { let helper_src = std::env::current_exe()?; @@ -115,9 +118,25 @@ fn install_inner( let helper_dest = bin.join("iran-split-helper.exe"); let mihomo_dest = bin.join("mihomo.exe"); let config_dest = root.join("helper.toml"); + // NSIS perMachine `SetShellVarContext all` makes `$LOCALAPPDATA` expand to + // `C:\ProgramData`, and an elevated in-app Install can record an admin + // profile. The helper always stages beside `runtime`, never inside a user + // profile and never inside `runtime_dir` (those two must not nest). + let staging_dir = root.join("staging"); + if requested_staging_dir != staging_dir { + warn!( + event = "helper.staging_dir_overridden", + section = "helper_install", + initiator = "elevated_installer", + cause = "machine_wide_staging", + trace_route = "elevated_helper->helper.toml", + "Windows helper staging is always ProgramData\\iran-split\\staging" + ); + } fs::create_dir_all(&bin)?; fs::create_dir_all(root.join("runtime"))?; - fs::create_dir_all(staging_dir)?; + fs::create_dir_all(&staging_dir)?; + grant_users_modify(&staging_dir)?; stop_previous_helper(); super::copy_file_unless_same(&helper_src, &helper_dest)?; super::copy_file_unless_same(mihomo_src, &mihomo_dest)?; @@ -131,7 +150,7 @@ fn install_inner( authorized_uid: 0, authorized_gid: 0, socket_path: PathBuf::from(PIPE_NAME), - staging_dir: staging_dir.to_path_buf(), + staging_dir, runtime_dir: root.join("runtime"), mihomo_binary: mihomo_dest, mihomo_sha256, @@ -162,6 +181,37 @@ fn install_inner( Ok(()) } +/// Lets the unelevated desktop write generation files that SYSTEM later copies +/// into `runtime_dir`. `icacls /grant` adds Builtin\Users modify without +/// replacing SYSTEM or Administrators inherited from `ProgramData`. +fn grant_users_modify(path: &Path) -> Result<(), HelperServiceError> { + let output = Command::new("icacls") + .arg(path) + .arg("/grant") + .arg(USERS_MODIFY_ACE) + .creation_flags(CREATE_NO_WINDOW) + .output()?; + if output.status.success() { + info!( + event = "helper.staging_acl_granted", + section = "helper_install", + initiator = "elevated_installer", + cause = "users_modify", + trace_route = "elevated_helper->staging_acl", + "Users can write helper staging generations" + ); + return Ok(()); + } + let mut detail = super::single_line(&super::decode_console_output(&output.stderr)); + if detail.is_empty() { + detail = super::single_line(&super::decode_console_output(&output.stdout)); + } + Err(HelperServiceError::Install(format!( + "could not grant Users modify on the helper staging directory: {}", + detail.chars().take(300).collect::() + ))) +} + fn register_and_start_task(helper: &Path, config: &Path) -> Result<(), HelperServiceError> { let xml_path = PathBuf::from(INSTALL_ROOT).join("helper-task.xml"); write_utf16_le_bom(&xml_path, &super::scheduled_task_xml(helper, config))?; diff --git a/crates/iran-split-platform-win/src/lib.rs b/crates/iran-split-platform-win/src/lib.rs index 7cb62ae..b03b7ca 100644 --- a/crates/iran-split-platform-win/src/lib.rs +++ b/crates/iran-split-platform-win/src/lib.rs @@ -233,6 +233,10 @@ pub struct WindowsPaths { pub pipe_name: String, pub user_data_dir: PathBuf, pub system_runtime_dir: PathBuf, + /// Directory the SYSTEM helper reads in `register_runtime_generation`. + /// Packaged installs use `C:\ProgramData\iran-split\staging` so NSIS and + /// the unelevated desktop share one machine-wide root (ADR 0064). + pub generation_staging_dir: PathBuf, pub resources_dir: PathBuf, pub rules_cache_dir: PathBuf, pub mihomo_binary: PathBuf, @@ -699,9 +703,7 @@ impl PlatformBackend for WindowsBackend { let generation_id = Uuid::new_v4(); let staging_root = self .paths - .user_data_dir - .join("runtime") - .join("generations") + .generation_staging_dir .join(generation_id.to_string()); fs::create_dir_all(&staging_root).map_err(|error| platform_error(&error))?; let runtime_paths = RuntimePaths { @@ -746,6 +748,15 @@ impl PlatformBackend for WindowsBackend { generation: generation.clone(), config_path: staging_root.join("config.yaml"), }); + info!( + event = "runtime.generation_prepared", + section = "runtime_generation", + initiator = "windows_platform_backend", + cause = "stack_start", + trace_id = %generation_id, + trace_route = "desktop_engine->windows_platform_backend->prepare_runtime", + "runtime generation staged for the helper" + ); Ok(generation) } @@ -1138,6 +1149,7 @@ mod tests { pipe_name: HELPER_PIPE.to_owned(), user_data_dir: root.join("user-data"), system_runtime_dir: PathBuf::from(r"C:\ProgramData\iran-split\runtime"), + generation_staging_dir: root.join("staging"), resources_dir: resources, rules_cache_dir: root.join("rules-cache"), mihomo_binary: root.join("mihomo.exe"), @@ -1150,6 +1162,14 @@ mod tests { assert!(!HELPER_PIPE.contains("..")); } + #[test] + fn packaged_connect_stages_into_the_helper_generation_root() { + let source = include_str!("lib.rs"); + assert!(source.contains("generation_staging_dir")); + assert!(source.contains(".generation_staging_dir")); + assert!(!source.contains(r#"user_data_dir.join("runtime").join("generations")"#)); + } + #[test] fn detects_busy_pipe_errors_for_bounded_retries() { assert!(is_pipe_busy(&io::Error::from_raw_os_error(231))); @@ -1205,9 +1225,7 @@ mod tests { let backend = WindowsBackend::new(AppConfig::default(), paths.clone()); let generation = backend.prepare_runtime().await.expect("prepare"); let root = paths - .user_data_dir - .join("runtime") - .join("generations") + .generation_staging_dir .join(generation.generation_id.to_string()); let names = fs::read_dir(&root) .expect("generation") diff --git a/docs/adr/0033-windows-platform-backend.md b/docs/adr/0033-windows-platform-backend.md index 200ddf8..7039c92 100644 --- a/docs/adr/0033-windows-platform-backend.md +++ b/docs/adr/0033-windows-platform-backend.md @@ -27,11 +27,11 @@ attempts across three sessions each failed instantly at `ensure_hiddify`. is synchronous, so the retry loop lives inside a timeout and retries only `ERROR_PIPE_BUSY`. A `NotFound` / `ConnectionRefused` / timeout on the pipe reports an uninstalled helper rather than an error banner. -- Runtime generations stage into `/runtime/generations/` — the same - root the elevated installer records as `staging_dir` in `helper.toml`, so a - generation staged by the app is the one SYSTEM is allowed to publish. - `generate_config` is called with `Platform::Windows`, which is what sets - `strict-route`. +- Runtime generations for packaged Windows stage into + `C:\ProgramData\iran-split\staging\` — the same root the elevated + installer records as `staging_dir` in `helper.toml` (ADR 0064). Tests pass a + temp `generation_staging_dir`. `generate_config` is called with + `Platform::Windows`, which is what sets `strict-route`. - TUN state is read from Mihomo's own `/configs`, not from the adapter list. Enumerating adapters needs `GetAdaptersAddresses`, and this crate is under the workspace `unsafe_code = "forbid"`; Mihomo owns the Wintun adapter, so its diff --git a/docs/adr/0035-windows-reveal-and-helper-install.md b/docs/adr/0035-windows-reveal-and-helper-install.md index e259c8e..ebceab7 100644 --- a/docs/adr/0035-windows-reveal-and-helper-install.md +++ b/docs/adr/0035-windows-reveal-and-helper-install.md @@ -53,13 +53,10 @@ path)` so the switch is not quoted. Mixed `/` in an old `debug.log` path is and then exit with clap's code. Runtime install/uninstall failures still persist their own message. The desktop reads that file after a non-zero exit so the dialog is not a bare exit code. -- NSIS and in-app Install use the same helper `staging_dir`: - `%LOCALAPPDATA%\biflow\runtime\generations` (`$LOCALAPPDATA\biflow\runtime\generations` - in the NSIS hook). A packaged NSIS run is not part of this repository's - done gate; the installer-hook contract test is the proof. Do not point - `--staging-dir` at `$PROGRAMDATA\iran-split\staging` — the desktop stages - generations under the user LocalAppData tree, and a mismatch leaves - providers at `0 / 0`. +- NSIS and in-app Install used to pass `%LOCALAPPDATA%\biflow\runtime\generations` + as helper `staging_dir`. That is superseded by ADR 0064: NSIS `perMachine` + `$LOCALAPPDATA` is `C:\ProgramData`, so the helper and desktop never shared a + directory. Packaged Windows staging is `$PROGRAMDATA\iran-split\staging`. ## Consequences diff --git a/docs/adr/0064-windows-machine-wide-helper-staging.md b/docs/adr/0064-windows-machine-wide-helper-staging.md new file mode 100644 index 0000000..eee4959 --- /dev/null +++ b/docs/adr/0064-windows-machine-wide-helper-staging.md @@ -0,0 +1,57 @@ +# ADR 0064: Windows machine-wide helper staging + +## Status + +Accepted + +## Context + +A 4.2.0 Windows `debug.log` showed Connect dying at +`register_runtime_generation` with: + +`staged generation … is unreadable at C:\ProgramData\biflow\runtime\generations\…: The system cannot find the file specified. (os error 2)` + +The desktop stages generations under the user's +`%LOCALAPPDATA%\biflow\runtime\generations`. The SYSTEM helper only reads +`helper.toml`'s `staging_dir`, which is recorded once at install time. + +Two install paths wrote the wrong root: + +1. NSIS `perMachine` runs with `SetShellVarContext all`. `$LOCALAPPDATA` then + expands to `C:\ProgramData`, so the post-install hook recorded + `C:\ProgramData\biflow\runtime\generations` — not the user's profile. +2. ADR 0035 told NSIS to use `$LOCALAPPDATA\biflow\runtime\generations` and + forbade `$PROGRAMDATA\iran-split\staging` because the desktop still wrote + under LocalAppData. That left helper and desktop looking at different trees + on every packaged Windows install. + +A Medium-integrity user also cannot write into a SYSTEM-created `ProgramData` +directory unless Builtin\Users is granted modify. + +`staging_dir` must not nest inside `runtime_dir` +(`C:\ProgramData\iran-split\runtime`). A sibling `staging` directory is valid. + +## Decision + +- Packaged Windows helper staging is always + `C:\ProgramData\iran-split\staging`. The elevated installer ignores a caller + `--staging-dir` that points somewhere else and writes that path into + `helper.toml`. +- NSIS uses `$PROGRAMDATA\iran-split\staging`. `$PROGRAMDATA` is `C:\ProgramData` + under both current and all-users shell context. Do not use `$LOCALAPPDATA` or + `$COMMONPROGRAMDATA`. +- In-app Install and `WindowsPaths.generation_staging_dir` use the same + `ProgramData` root. Tests keep a temp `generation_staging_dir`. +- After creating the directory, the elevated installer runs + `icacls /grant *S-1-5-32-545:(OI)(CI)M` so unelevated Users can write + generation files that SYSTEM later publishes. +- Linux is unchanged: the root helper can read the installing user's data + directory. + +## Consequences + +A 4.3 NSIS install or in-app Helper Install rewrites `helper.toml` and the +staging ACL. Connect then stages where the helper already looks. A leftover +4.2 helper that still points at `C:\ProgramData\biflow\runtime\generations` +starts working only after that reinstall, because the 4.2 binary cannot see +the new directory. diff --git a/docs/adr/README.md b/docs/adr/README.md index ab14d06..7945356 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -67,3 +67,4 @@ Keep this index current. Add a new ADR for each non-obvious change, or update th | [0061](./0061-direct-domains-always-skip-fake-ip.md) | DIRECT domains always skip fake-ip | Accepted | | [0062](./0062-no-hiddify-system-proxy-while-running.md) | No Hiddify system proxy while running | Accepted | | [0063](./0063-reject-quic-toward-vpn.md) | Reject QUIC toward the VPN | Accepted | +| [0064](./0064-windows-machine-wide-helper-staging.md) | Windows machine-wide helper staging | Accepted | diff --git a/package.json b/package.json index 5e3d6dd..ed4bb8a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "iran-split-desktop-workspace", - "version": "4.2.0", + "version": "4.3.0", "private": true, "packageManager": "pnpm@9.0.1", "engines": { diff --git a/packaging/windows/installer-hooks.nsh b/packaging/windows/installer-hooks.nsh index 003c923..a9c7448 100644 --- a/packaging/windows/installer-hooks.nsh +++ b/packaging/windows/installer-hooks.nsh @@ -1,5 +1,5 @@ !macro NSIS_HOOK_POSTINSTALL - nsExec::ExecToLog '"$INSTDIR\helper\iran-split-helper.exe" --install --mihomo "$INSTDIR\dependencies\mihomo.exe" --staging-dir "$LOCALAPPDATA\biflow\runtime\generations" --tun-name clash-iran' + nsExec::ExecToLog '"$INSTDIR\helper\iran-split-helper.exe" --install --mihomo "$INSTDIR\dependencies\mihomo.exe" --staging-dir "$PROGRAMDATA\iran-split\staging" --tun-name clash-iran' !macroend !macro NSIS_HOOK_PREUNINSTALL diff --git a/scripts/tauri-contract.test.mjs b/scripts/tauri-contract.test.mjs index 4e02ff0..3d54695 100644 --- a/scripts/tauri-contract.test.mjs +++ b/scripts/tauri-contract.test.mjs @@ -82,13 +82,26 @@ describe("Tauri frontend contract", () => { ); }); - it("points NSIS helper staging at LocalAppData generations", () => { + it("points NSIS helper staging at ProgramData iran-split staging", () => { const hook = readFileSync( join(root, "packaging/windows/installer-hooks.nsh"), "utf8", ); - assert.match(hook, /\$LOCALAPPDATA\\biflow\\runtime\\generations/); - assert.doesNotMatch(hook, /\$PROGRAMDATA\\iran-split\\staging/); + const desktop = readFileSync(join(root, "src-tauri/src/lib.rs"), "utf8"); + const install = readFileSync( + join(root, "src-tauri/src/helper_install.rs"), + "utf8", + ); + const helper = readFileSync( + join(root, "crates/iran-split-helper/src/windows.rs"), + "utf8", + ); + assert.match(hook, /\$PROGRAMDATA\\iran-split\\staging/); + assert.doesNotMatch(hook, /\$LOCALAPPDATA\\biflow\\runtime\\generations/); + assert.match(install, /C:\\\\ProgramData\\\\iran-split\\\\staging/); + assert.match(desktop, /generation_staging_dir/); + assert.match(helper, /root\.join\("staging"\)/); + assert.match(helper, /fn grant_users_modify/); }); it("keeps a resizable main window with a 390x640 minimum", () => { diff --git a/src-tauri/src/helper_install.rs b/src-tauri/src/helper_install.rs index a39dcb7..9d3fbb2 100644 --- a/src-tauri/src/helper_install.rs +++ b/src-tauri/src/helper_install.rs @@ -37,6 +37,11 @@ const UAC_CANCELLED: i32 = 1223; pub(crate) const CREATE_NO_WINDOW: u32 = 0x0800_0000; #[cfg(target_os = "windows")] const WINDOWS_INSTALL_LOG: &str = r"C:\ProgramData\iran-split\install.log"; +/// Machine-wide generation root. NSIS `perMachine` `$LOCALAPPDATA` is +/// `C:\ProgramData`, not the user's profile, so a user-profile staging path +/// recorded at install time never matches what the desktop writes later. +#[cfg(any(target_os = "windows", test))] +pub(crate) const WINDOWS_HELPER_STAGING: &str = r"C:\ProgramData\iran-split\staging"; #[derive(Debug, Clone, Serialize)] pub struct InstallHelperResult { @@ -66,10 +71,6 @@ pub async fn install_helper(app: &AppHandle) -> Result(app: &AppHandle) -> Result Result { #[cfg(target_os = "windows")] let backend = { let (pipe_name, system_runtime_dir) = windows_helper_paths(); - // The helper installer records this same staging root in helper.toml, - // so a generation staged here is the one SYSTEM is allowed to publish. + // Packaged Connect stages here, and the elevated installer records the + // same root in helper.toml, so SYSTEM can publish the generation. + let generation_staging_dir = PathBuf::from(helper_install::WINDOWS_HELPER_STAGING); let mihomo_binary = deps::first_existing(&deps::mihomo_candidates(&paths.data)) .unwrap_or_else(windows_programdata_mihomo); info!( @@ -2203,6 +2204,7 @@ fn create_services(app: &AppHandle) -> Result { trace_route = "application_process->create_services->windows_backend", pipe_name = pipe_name.as_str(), runtime_path = %system_runtime_dir.display(), + staging_path = %generation_staging_dir.display(), mihomo_binary = %mihomo_binary.display(), "Windows helper paths selected" ); @@ -2212,6 +2214,7 @@ fn create_services(app: &AppHandle) -> Result { pipe_name, user_data_dir: paths.data.clone(), system_runtime_dir, + generation_staging_dir, resources_dir: bundled_rules.clone(), rules_cache_dir: rules_cache.clone(), mihomo_binary, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c9e8d88..3b885f7 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "BiFlow", - "version": "4.2.0", + "version": "4.3.0", "identifier": "app.biflow.desktop", "build": { "beforeDevCommand": "pnpm bundle:check && pnpm --dir apps/desktop dev", diff --git a/version b/version index 6aba2b2..8089590 100644 --- a/version +++ b/version @@ -1 +1 @@ -4.2.0 +4.3.0 From 22388db8e273aedcb33813e0d209a2b4a34e6ee1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 18 Aug 2026 16:37:51 +0000 Subject: [PATCH 2/2] Sync Cargo.lock to 4.3.0 and tighten Windows staging contracts. Keep helper source contracts free of LOCALAPPDATA literals and match the ProgramData staging path in the Tauri installer tests. Co-authored-by: Dariush vesal --- Cargo.lock | 22 +++++++++++----------- crates/iran-split-helper/src/windows.rs | 9 +++++---- scripts/tauri-contract.test.mjs | 4 ++-- 3 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0c76a48..9af8cf7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2066,7 +2066,7 @@ dependencies = [ [[package]] name = "iran-split-cli" -version = "4.2.0" +version = "4.3.0" dependencies = [ "async-trait", "chrono", @@ -2083,7 +2083,7 @@ dependencies = [ [[package]] name = "iran-split-config" -version = "4.2.0" +version = "4.3.0" dependencies = [ "hex", "rand 0.9.5", @@ -2095,7 +2095,7 @@ dependencies = [ [[package]] name = "iran-split-core" -version = "4.2.0" +version = "4.3.0" dependencies = [ "async-trait", "chrono", @@ -2110,7 +2110,7 @@ dependencies = [ [[package]] name = "iran-split-desktop" -version = "4.2.0" +version = "4.3.0" dependencies = [ "chrono", "dirs 6.0.0", @@ -2146,7 +2146,7 @@ dependencies = [ [[package]] name = "iran-split-helper" -version = "4.2.0" +version = "4.3.0" dependencies = [ "clap", "hex", @@ -2167,7 +2167,7 @@ dependencies = [ [[package]] name = "iran-split-helper-winacl" -version = "4.2.0" +version = "4.3.0" dependencies = [ "tokio", "windows", @@ -2175,7 +2175,7 @@ dependencies = [ [[package]] name = "iran-split-ipc" -version = "4.2.0" +version = "4.3.0" dependencies = [ "bytes", "chrono", @@ -2188,7 +2188,7 @@ dependencies = [ [[package]] name = "iran-split-mihomo" -version = "4.2.0" +version = "4.3.0" dependencies = [ "chrono", "futures-util", @@ -2210,7 +2210,7 @@ dependencies = [ [[package]] name = "iran-split-platform-linux" -version = "4.2.0" +version = "4.3.0" dependencies = [ "async-trait", "chrono", @@ -2233,7 +2233,7 @@ dependencies = [ [[package]] name = "iran-split-platform-win" -version = "4.2.0" +version = "4.3.0" dependencies = [ "async-trait", "chrono", @@ -2255,7 +2255,7 @@ dependencies = [ [[package]] name = "iran-split-rules" -version = "4.2.0" +version = "4.3.0" dependencies = [ "async-trait", "chrono", diff --git a/crates/iran-split-helper/src/windows.rs b/crates/iran-split-helper/src/windows.rs index c9ef381..4b36cee 100644 --- a/crates/iran-split-helper/src/windows.rs +++ b/crates/iran-split-helper/src/windows.rs @@ -118,10 +118,11 @@ fn install_inner( let helper_dest = bin.join("iran-split-helper.exe"); let mihomo_dest = bin.join("mihomo.exe"); let config_dest = root.join("helper.toml"); - // NSIS perMachine `SetShellVarContext all` makes `$LOCALAPPDATA` expand to - // `C:\ProgramData`, and an elevated in-app Install can record an admin - // profile. The helper always stages beside `runtime`, never inside a user - // profile and never inside `runtime_dir` (those two must not nest). + // NSIS perMachine `SetShellVarContext all` makes the local-appdata shell + // variable expand to `C:\ProgramData`, and an elevated in-app Install can + // record an admin profile. The helper always stages beside `runtime`, never + // inside a user profile and never inside `runtime_dir` (those two must not + // nest). let staging_dir = root.join("staging"); if requested_staging_dir != staging_dir { warn!( diff --git a/scripts/tauri-contract.test.mjs b/scripts/tauri-contract.test.mjs index 3d54695..b0daf79 100644 --- a/scripts/tauri-contract.test.mjs +++ b/scripts/tauri-contract.test.mjs @@ -98,7 +98,7 @@ describe("Tauri frontend contract", () => { ); assert.match(hook, /\$PROGRAMDATA\\iran-split\\staging/); assert.doesNotMatch(hook, /\$LOCALAPPDATA\\biflow\\runtime\\generations/); - assert.match(install, /C:\\\\ProgramData\\\\iran-split\\\\staging/); + assert.ok(install.includes(String.raw`C:\ProgramData\iran-split\staging`)); assert.match(desktop, /generation_staging_dir/); assert.match(helper, /root\.join\("staging"\)/); assert.match(helper, /fn grant_users_modify/); @@ -411,7 +411,7 @@ describe("Tauri frontend contract", () => { ); assert.match( source, - /#\[cfg\(target_os = "linux"\)\]\s*let payload_dir = /, + /#\[cfg\(target_os = "linux"\)\]\s*\{\s*let staging_dir[\s\S]*?let payload_dir = /, ); assert.match( source,