Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,7 +281,29 @@ If a required command fails or emits a warning from project code, fix it in the

- A Windows diagnostic PowerShell run with `-File <temp .ps1>` can exit 1 in milliseconds with no stdout (execution policy set by GPO, or temp-file access), and a stdout-only runner then logs a bare exit code. Run scripts with `-EncodedCommand` (UTF-16LE Base64), force UTF-8 output, and keep the first stderr line in the error (ADR 0110).

- On Windows, top-level `ipv6: false` makes Mihomo drop the TUN inet6 address. sing-tun `strict-route` then installs an unconditional WFP "block ipv6" connect filter that only exempts Mihomo, so `localhost` -> `::1` fails instantly while connected. A route exclusion cannot fix a WFP block. Keep top-level `ipv6: true` on Windows and restrict AAAA through `dns.ipv6: false` instead (ADR 0112).
- On Windows, top-level `ipv6: false` makes Mihomo drop the TUN inet6 address. sing-tun `strict-route` then installs an unconditional WFP "block ipv6" connect filter that only exempts Mihomo, so `localhost` -> `::1` fails instantly while connected. A route exclusion cannot fix a WFP block. Keep top-level `ipv6: true` on Windows and restrict AAAA through `dns.ipv6: false` instead (ADR 0112). Mihomo v1.19.29 does **not** apply `inet6-address` to the Wintun adapter even when it is in the config (`GET /configs` reports `inet4-address` but no `inet6-address`, adapter only gets link-local `fe80::`), so `strict-route: true` still installs the block filter and `::1` stays refused. Windows must use `strict-route: false` until a Mihomo build that actually sets the inet6 address is available; `dns.ipv6: false` keeps the IPv6 leak surface minimal (ADR 0112).
- `spawn_mihomo` uses `kill_on_drop(false)` so connectivity survives a helper crash, but when the helper restarts (reboot, reinstall, new session) `self.child` is empty and the previous Mihomo is an orphan still holding the controller port and TUN adapter. The new Mihomo cannot bind `127.0.0.1:19090`, the desktop silently talks to the stale process, and a new config (e.g. `inet6-address`) is never applied. The helper must `kill_orphaned_mihomo` (`taskkill /F /IM mihomo.exe` / `pkill -x mihomo`) before every spawn to reclaim the port and adapter (ADR 0112).
- Sniffer `override-destination: true` with fake-ip DNS breaks IP-based TLS connections that carry an SNI. kubectl connecting to a cluster API IP (78.109.203.123:443) with a `tls-server-name` SNI has its destination overridden to the SNI's fake-ip (198.18.x.x), so even a `PROCESS-NAME,kubectl.exe,DIRECT` rule connects to the fake-ip and the TLS handshake fails with EOF. Generate `override-destination: false` so the connection keeps its original IP; domain-based rules still use the sniffed SNI (ADR 0112).
- `ipv6: true` alone is not enough on Mihomo v1.19.29: `GET /configs`
reported `inet4-address` but no `inet6-address`, so the TUN had no inet6
address, strict-route still installed the WFP "block ipv6" filter, and
`loopback_ipv6_blocked` (with `client_ipv4_only_but_localhost_prefers_ipv6`)
persisted on 6.2.51. Emit `tun.inet6-address: fdfe:dcba:9876::1/126`
explicitly so the TUN keeps its inet6 address and the block filter is
never installed. The user must Disconnect/Connect once to recreate the
TUN with the new address (ADR 0112).
- Node 26 ships an experimental `localStorage` global that stays
`undefined` unless `--localstorage-file` is passed, and jsdom 26 defers
to it instead of providing its own, so `window.localStorage` is also
absent under vitest. `i18n/config.ts` read the saved language at
module-load time, so every frontend test failed with "Cannot read
properties of undefined (reading 'getItem')". Install a minimal
in-memory `Storage` shim from a `setupFiles` entry that runs _before_
`src/test/setup.ts` (ES module imports are hoisted, so the shim must
live in its own file listed first), and guard the i18n read with
`typeof localStorage !== "undefined"`. Keep the shim returning
`string | undefined` (not `false` from `&&`) so the `lng` option stays
typed `string | undefined`.

- Older Pillow has no `Image.Resampling`; generate icons with `Image.LANCZOS` / `Image.BICUBIC`.
- Inner `#![allow(...)]` attributes must be the first item in a Rust module, before `use`.
Expand Down
24 changes: 12 additions & 12 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ members = [
]

[workspace.package]
version = "6.2.52"
version = "6.2.56"
edition = "2021"
license = "MIT OR Apache-2.0"
rust-version = "1.88"
Expand Down
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@iran-split/desktop",
"version": "6.2.52",
"version": "6.2.56",
"private": true,
"type": "module",
"scripts": {
Expand Down
11 changes: 7 additions & 4 deletions apps/desktop/src/i18n/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -813,12 +813,15 @@ const resources = {
},
} as const;

const savedLanguage =
typeof localStorage !== "undefined"
? (localStorage.getItem("biflow-language") ??
localStorage.getItem("iran-split-language"))
: undefined;

void i18n.use(initReactI18next).init({
resources,
lng:
localStorage.getItem("biflow-language") ??
localStorage.getItem("iran-split-language") ??
"en",
lng: savedLanguage ?? "en",
fallbackLng: "en",
interpolation: { escapeValue: false },
});
Expand Down
50 changes: 50 additions & 0 deletions apps/desktop/src/test/localStorage-shim.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
// vitest runs `setupFiles` in order; this file is listed before setup.ts so
// the shim is installed before any setup import (notably `i18n/config`,
// which reads the saved language at module-load time) runs.
//
// Node 26 ships an experimental `localStorage` global that stays
// `undefined` unless `--localstorage-file` is passed, and jsdom 26 defers
// to it instead of providing its own, so `window.localStorage` is also
// absent. Tests and the i18n bootstrap both need a working Storage, so
// install a minimal in-memory implementation on the shared global.

interface StorageEntry {
value: string;
}

const store = new Map<string, StorageEntry>();

const storage: Storage = {
get length(): number {
return store.size;
},
clear(): void {
store.clear();
},
getItem(key: string): string | null {
return store.has(key) ? (store.get(key) as StorageEntry).value : null;
},
key(index: number): string | null {
return Array.from(store.keys())[index] ?? null;
},
removeItem(key: string): void {
store.delete(key);
},
setItem(key: string, value: string): void {
store.set(key, { value: String(value) });
},
};

Object.defineProperty(globalThis, "localStorage", {
value: storage,
writable: true,
configurable: true,
});

if (typeof window !== "undefined" && window.localStorage === undefined) {
Object.defineProperty(window, "localStorage", {
value: storage,
writable: true,
configurable: true,
});
}
2 changes: 1 addition & 1 deletion apps/desktop/tsconfig.app.tsbuildinfo
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"root":["./src/App.test.tsx","./src/App.tsx","./src/installContextMenuGuard.ts","./src/main.tsx","./src/palette.test.ts","./src/shell.test.ts","./src/version.test.ts","./src/version.ts","./src/vite-env.d.ts","./src/api/desktop.native.test.ts","./src/api/desktop.ts","./src/api/mock.test.ts","./src/api/mock.ts","./src/api/models.ts","./src/components/About.test.tsx","./src/components/About.tsx","./src/components/AddSiteBar.tsx","./src/components/AppButton.test.tsx","./src/components/AppButton.tsx","./src/components/AppStatusBar.test.tsx","./src/components/AppStatusBar.tsx","./src/components/BasicDashboard.test.tsx","./src/components/BasicDashboard.tsx","./src/components/BottomNav.test.tsx","./src/components/BottomNav.tsx","./src/components/ClientRegistry.test.tsx","./src/components/ClientRegistry.tsx","./src/components/ConnectionActionButton.test.tsx","./src/components/ConnectionActionButton.tsx","./src/components/Dashboard.test.tsx","./src/components/Dashboard.tsx","./src/components/DefaultRouteSelect.tsx","./src/components/Diagnostics.test.tsx","./src/components/Diagnostics.tsx","./src/components/DirectRules.test.tsx","./src/components/DirectRules.tsx","./src/components/InputContextMenu.test.tsx","./src/components/InputContextMenu.tsx","./src/components/LifecycleCancelButton.tsx","./src/components/MihomoConfigDialog.tsx","./src/components/PageSkeleton.tsx","./src/components/RecentSites.test.tsx","./src/components/RecentSites.tsx","./src/components/Settings.test.tsx","./src/components/Settings.tsx","./src/components/SettingsApplyBanner.test.tsx","./src/components/SettingsApplyBanner.tsx","./src/components/SortHeader.tsx","./src/components/StatusPill.tsx","./src/components/TrafficFlow.tsx","./src/components/UiModeSwitch.test.tsx","./src/components/UiModeSwitch.tsx","./src/components/country.ts","./src/components/ui/InfoTip.tsx","./src/components/ui/PageHeader.tsx","./src/components/ui/Tabs.tsx","./src/components/ui/Toast.tsx","./src/components/ui/usePageTab.ts","./src/dev/local-deps.test.ts","./src/i18n/config.ts","./src/lib/clients.test.ts","./src/lib/clients.ts","./src/lib/clipboard.ts","./src/lib/connectComponentProgress.test.ts","./src/lib/connectRequirements.test.ts","./src/lib/connectRequirements.ts","./src/lib/connectionGroups.test.ts","./src/lib/connectionGroups.ts","./src/lib/connectionProgress.test.ts","./src/lib/connectionProgress.ts","./src/lib/directDns.test.ts","./src/lib/directDns.ts","./src/lib/editableTarget.test.ts","./src/lib/editableTarget.ts","./src/lib/failureReason.test.ts","./src/lib/failureReason.ts","./src/lib/formatTraffic.test.ts","./src/lib/formatTraffic.ts","./src/lib/hiddenHosts.test.ts","./src/lib/hiddenHosts.ts","./src/lib/host.test.ts","./src/lib/host.ts","./src/lib/lifecycle.test.ts","./src/lib/lifecycle.ts","./src/lib/navigation.ts","./src/lib/outbound.ts","./src/lib/presets.test.ts","./src/lib/presets.ts","./src/lib/settingsApply.test.ts","./src/lib/settingsApply.ts","./src/lib/sideTunnelConnect.test.ts","./src/lib/sideTunnelConnect.ts","./src/lib/tableSort.test.ts","./src/lib/tableSort.ts","./src/lib/uiMode.ts","./src/lib/viewport.test.ts","./src/lib/viewport.ts","./src/lib/yamlHighlight.test.ts","./src/lib/yamlHighlight.ts","./src/store/app.test.ts","./src/store/app.ts","./src/test/fixtures.ts","./src/test/setup.ts"],"version":"5.8.3"}
{"root":["./src/app.test.tsx","./src/app.tsx","./src/installcontextmenuguard.ts","./src/main.tsx","./src/palette.test.ts","./src/shell.test.ts","./src/version.test.ts","./src/version.ts","./src/vite-env.d.ts","./src/api/desktop.native.test.ts","./src/api/desktop.ts","./src/api/mock.test.ts","./src/api/mock.ts","./src/api/models.ts","./src/components/about.test.tsx","./src/components/about.tsx","./src/components/addsitebar.tsx","./src/components/appbutton.test.tsx","./src/components/appbutton.tsx","./src/components/appstatusbar.test.tsx","./src/components/appstatusbar.tsx","./src/components/basicdashboard.test.tsx","./src/components/basicdashboard.tsx","./src/components/bottomnav.test.tsx","./src/components/bottomnav.tsx","./src/components/clientregistry.test.tsx","./src/components/clientregistry.tsx","./src/components/connectionactionbutton.test.tsx","./src/components/connectionactionbutton.tsx","./src/components/dashboard.test.tsx","./src/components/dashboard.tsx","./src/components/defaultrouteselect.tsx","./src/components/diagnostics.test.tsx","./src/components/diagnostics.tsx","./src/components/directrules.test.tsx","./src/components/directrules.tsx","./src/components/inputcontextmenu.test.tsx","./src/components/inputcontextmenu.tsx","./src/components/lifecyclecancelbutton.tsx","./src/components/mihomoconfigdialog.tsx","./src/components/pageskeleton.tsx","./src/components/recentsites.test.tsx","./src/components/recentsites.tsx","./src/components/settings.test.tsx","./src/components/settings.tsx","./src/components/settingsapplybanner.test.tsx","./src/components/settingsapplybanner.tsx","./src/components/sortheader.tsx","./src/components/statuspill.tsx","./src/components/trafficflow.tsx","./src/components/uimodeswitch.test.tsx","./src/components/uimodeswitch.tsx","./src/components/country.ts","./src/components/ui/infotip.tsx","./src/components/ui/pageheader.tsx","./src/components/ui/tabs.tsx","./src/components/ui/toast.tsx","./src/components/ui/usepagetab.ts","./src/dev/local-deps.test.ts","./src/i18n/config.ts","./src/lib/clients.test.ts","./src/lib/clients.ts","./src/lib/clipboard.ts","./src/lib/connectcomponentprogress.test.ts","./src/lib/connectrequirements.test.ts","./src/lib/connectrequirements.ts","./src/lib/connectiongroups.test.ts","./src/lib/connectiongroups.ts","./src/lib/connectionprogress.test.ts","./src/lib/connectionprogress.ts","./src/lib/directdns.test.ts","./src/lib/directdns.ts","./src/lib/editabletarget.test.ts","./src/lib/editabletarget.ts","./src/lib/failurereason.test.ts","./src/lib/failurereason.ts","./src/lib/formattraffic.test.ts","./src/lib/formattraffic.ts","./src/lib/hiddenhosts.test.ts","./src/lib/hiddenhosts.ts","./src/lib/host.test.ts","./src/lib/host.ts","./src/lib/lifecycle.test.ts","./src/lib/lifecycle.ts","./src/lib/navigation.ts","./src/lib/outbound.ts","./src/lib/presets.test.ts","./src/lib/presets.ts","./src/lib/settingsapply.test.ts","./src/lib/settingsapply.ts","./src/lib/sidetunnelconnect.test.ts","./src/lib/sidetunnelconnect.ts","./src/lib/tablesort.test.ts","./src/lib/tablesort.ts","./src/lib/uimode.ts","./src/lib/viewport.test.ts","./src/lib/viewport.ts","./src/lib/yamlhighlight.test.ts","./src/lib/yamlhighlight.ts","./src/store/app.test.ts","./src/store/app.ts","./src/test/fixtures.ts","./src/test/localstorage-shim.ts","./src/test/setup.ts"],"version":"5.8.3"}
2 changes: 1 addition & 1 deletion apps/desktop/tsconfig.node.tsbuildinfo

Large diffs are not rendered by default.

11 changes: 10 additions & 1 deletion apps/desktop/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,16 @@ export default defineConfig({
},
test: {
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
environmentOptions: {
jsdom: {
// jsdom only exposes `localStorage` for a real origin; the default
// `about:blank` is opaque, so `window.localStorage` is undefined and
// every test that reads the language or the mock-deps flag fails
// (Node 26 also warns `localStorage is not available`).
url: "http://localhost/",
},
},
setupFiles: ["./src/test/localStorage-shim.ts", "./src/test/setup.ts"],
isolate: true,
css: true,
exclude: ["**/node_modules/**", "**/e2e/**", "**/dist/**"],
Expand Down
43 changes: 43 additions & 0 deletions crates/iran-split-helper/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -515,11 +515,40 @@ impl Supervisor {
}
}

/// Kills any Mihomo process the helper is not tracking.
///
/// `spawn_mihomo` uses `kill_on_drop(false)` so connectivity survives a helper
/// crash. When the helper restarts (machine reboot, reinstall, or a new
/// desktop session), `self.child` is empty and the previous Mihomo is now an
/// orphan holding the controller port and the TUN adapter. The new Mihomo
/// then fails to bind and the desktop silently talks to the stale process.
/// This runs before every spawn to reclaim the port and the adapter.
fn kill_orphaned_mihomo(binary: &Path) {
let Some(name) = binary.file_name().and_then(|n| n.to_str()) else {
return;
};
let mut command = if cfg!(windows) {
let mut c = std::process::Command::new("taskkill");
c.args(["/F", "/IM", name]);
c
} else {
let mut c = std::process::Command::new("pkill");
c.args(["-x", name]);
c
};
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = command.status();
}

fn spawn_mihomo(
settings: &HelperSettings,
generation_root: &Path,
config_path: &Path,
) -> Result<Child, HelperServiceError> {
kill_orphaned_mihomo(&settings.mihomo_binary);
let mut command = Command::new(&settings.mihomo_binary);
command
.arg("-d")
Expand Down Expand Up @@ -1048,6 +1077,20 @@ tun_name = "clash-iran"
delete_owned_interface("unused");
}

/// `kill_orphaned_mihomo` must not panic when no matching process exists.
/// It swallows the non-zero exit from `taskkill`/`pkill` and returns, so a
/// fresh helper can reclaim the controller port even when no orphan is
/// running.
#[test]
fn kill_orphaned_mihomo_swallows_missing_process() {
let name = if cfg!(windows) {
"biflow-test-no-such-process-9f3a.exe"
} else {
"biflow-test-no-such-process-9f3a"
};
kill_orphaned_mihomo(Path::new(name));
}

#[cfg(windows)]
#[test]
fn windows_production_staging_is_beside_runtime_not_inside_it() {
Expand Down
Loading