Skip to content

Commit 75402b3

Browse files
chenchaoyiclaude
andauthored
fix(helper): scan re-launches itself via LaunchServices (the real macOS 26 fix) (#46)
* fix(helper): scan-with-retry loop for macOS 26 cold-start unredaction v1.0.6 didn't actually verify cold. The RunLoop.current.run pump in v1.0.6 was load-bearing on locationd's warm cache rather than its own dispatch handshake — when tested 30+ minutes after a fresh grant (cache cold) it still returned redacted rows. User surfaced this when `uv run diting` worked (in-repo bundle re-opened many times in the session, keeping locationd warm) while `diting` (curl-installed, cold) still hung indefinitely. Restructure the scan subcommand around dispatchMain + scan-with- retry. Same dispatch pattern as the working bluetooth-status probe, plus adaptive retry that adjusts to locationd cold-start latency. How it works: 1. ScanWorker.start() calls requestWhenInUseAuthorization / startUpdatingLocation, then attemptScan(0) immediately. 2. attemptScan runs scanForNetworks. If any row has a bssid, emit JSON and exit (warm path: ~90 ms). 3. If every row is redacted but the scan returned data, schedule attemptScan(n+1) 500 ms later. While we wait, dispatchMain keeps libdispatch + CoreLocation pumping so locationd registration finishes in the background. 4. Up to 6 attempts ≈ 5 s worst case; locationManager DidChange Authorization short-circuits early only on .denied / .restricted (waiting wouldn't unstuck those). 5. Global g_scanWorker pins the worker (and its CLLocationManager) for the lifetime of the process so the consumer registration isn't released mid-scan. Why retry instead of a fixed wait: the auth callback fires within ~100 ms when TCC.db has a grant, but CoreLocation's registration with locationd lands non-deterministically (~200 ms warm, up to ~3 s cold). A fixed wait either makes warm scans needlessly slow or misses cold state. Retry adapts. Verified locally on the user's machine, freshly-rebuilt bundle, locationd cold: - 4.2 s, 95/95 rows unredacted (first cold run, ~4 retries) - 1.7 s, 102/102 rows unredacted (subsequent, ~1 retry) - 0.088 s, 122/122 unredacted (warm, no retry) - 1.9 s, 99/99 unredacted (cold again) - 0 redacted-result runs across all four tests Bonus discovery: macOS extends System Settings → Location grants from the original cdhash to fresh rebuilds of the same bundle id (at least for ad-hoc-signed bundles). New cdhashes inherit without a fresh prompt — useful for the install.sh-then-update flow. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(helper): scan re-launches itself via LaunchServices for TCC This supersedes the dispatchMain + retry-loop approach in the prior commit on this branch. That approach turned out to also ride on locationd warm cache and failed cold on the user's machine — same install hang it was supposed to fix. Diagnostic that nailed it: print `CLLocationManager.authorizationStatus().rawValue` to stderr at the top of the disclaimed scan subprocess. Returns `0` (.notDetermined) on macOS 26 — even though the GUI bundle launched via `open` from the same binary at the same path with the same cdhash sees the SAME bundle as `.authorizedWhenInUse`. The asymmetry: - Direct-exec subprocess of <bundle>/Contents/MacOS/binary: TCC attributes by raw-process identity. No grant matches. Bundle's Location entry is invisible to this process — no run-loop pump, no NSApp.run(), no disclaim, no retry can fix it. - LaunchServices-launched bundle (via `open` from outside, or our own `/usr/bin/open --args ...` from inside): TCC attributes by bundle. Grant matches. scanForNetworks unredacts normally. CoreBluetooth on the same machine is fine with direct-exec — that's why `bluetooth-status` has been working the whole time. CoreLocation tightened on macOS 26 and the gate requires LaunchServices launch. Fix: split `runScanAndDumpJSON` into outer + inner halves. Outer (Python's subprocess.run([binary, "scan"])): Spawn /usr/bin/open with `-W -g -a <bundle>` and env vars DITING_SCAN_VIA_LAUNCH=1 + DITING_SCAN_OUT=<tempfile>. Wait for the LaunchServices'd child to write JSON, relay to stdout, exit. Python sees this process as if it were the original subprocess — no protocol change. Inner (DITING_SCAN_VIA_LAUNCH=1 in env): Initialise NSApp with .prohibited activation policy (no Dock icon, no focus theft), start the ScanWorker, run NSApp.run(). Worker does the actual scan-with-retry, writes to DITING_SCAN_OUT, exits. Verified on the user's macOS 26 machine, installed bundle path, locationd cold (3 cold tests): - 1.56 s, 116/116 unredacted - 0.29 s, 139/139 unredacted (locationd cached after run 1) - 1.84 s, 103/103 unredacted The end-to-end install flow (`diting --lang zh --log --notify`) now enters the TUI within ~3 s instead of hanging indefinitely. Documentation: - CHANGELOG.md / docs/zh/CHANGELOG.md updated to describe the real fix (not the abandoned dispatchMain approach) - docs/RELEASE.md / docs/zh/RELEASE.md gain a troubleshooting entry with the recipe to verify the fix isn't regressed - Memory entry macos26_corewlan_launchservices.md captures the hours of debugging context for future sessions Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 18b3104 commit 75402b3

7 files changed

Lines changed: 452 additions & 128 deletions

File tree

CHANGELOG.md

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,80 @@ the project follows [Semantic Versioning](https://semver.org/) where
99
practical. The leading `v0.x` line is allowed to break minor
1010
behaviours between releases.
1111

12+
## [1.0.7] — 2026-05-13
13+
14+
The macOS 26 install hang that survived v1.0.3 → v1.0.5 had a deeper
15+
root cause than every prior fix attempted: **direct-exec subprocesses
16+
of an ad-hoc-signed bundle's binary don't inherit the bundle's
17+
Location TCC grant on macOS 26**. CoreLocation's TCC check requires
18+
the process to be LaunchServices-attributed; CoreBluetooth's doesn't
19+
(which is why `bluetooth-status` worked the whole time).
20+
21+
User surfaced the asymmetry: `uv run diting` worked because they'd
22+
been repeatedly `open`-ing the in-repo bundle this session, keeping
23+
locationd warm-cached for that cdhash at that path; `diting`
24+
(curl-installed) hung indefinitely because the install path's bundle
25+
was cold and the direct-exec scan subprocess saw
26+
`CLLocationManager.authorizationStatus = .notDetermined` regardless
27+
of whatever run-loop pump / NSApp.run / disclaim trick was layered
28+
on top.
29+
30+
### Fixed
31+
- **`scan` subcommand re-launches itself via LaunchServices.** Two
32+
halves of the same function, switched on `DITING_SCAN_VIA_LAUNCH`
33+
env var:
34+
- **Outer half** (no env var): spawns
35+
`/usr/bin/open -W -g -a <bundle> --env DITING_SCAN_VIA_LAUNCH=1
36+
--env DITING_SCAN_OUT=<tempfile> --args scan`. Waits for the
37+
LaunchServices'd child, reads the JSON it wrote, relays to
38+
stdout, exits. Python's `subprocess.run([binary, "scan"])` sees
39+
this as if the original subprocess produced the output directly
40+
— no Python-side protocol change.
41+
- **Inner half** (`DITING_SCAN_VIA_LAUNCH=1`): runs as the
42+
LaunchServices-launched bundle instance. `NSApplication.shared.
43+
setActivationPolicy(.prohibited)` keeps it out of the Dock and
44+
prevents focus theft. `ScanWorker : CLLocationManagerDelegate`
45+
initialises `CLLocationManager`, calls
46+
`requestWhenInUseAuthorization` + `startUpdatingLocation`, then
47+
runs a scan-with-retry loop (up to 6 attempts × 500 ms apart)
48+
until any returned row has a `bssid` — or denied / restricted
49+
cuts the loop short. `NSApp.run()` pumps both the run loop and
50+
the libdispatch main queue. JSON is written to `$DITING_SCAN_OUT`
51+
via atomic file write; `exit(0)` ends the inner process.
52+
- **`ble-scan` and `bluetooth-status` are unchanged.** They keep
53+
using disclaim + direct-exec; CBCentralManager's TCC honours
54+
cdhash directly so no LaunchServices hop is needed there.
55+
56+
### Empirical timings (cold subprocess scan, user's macOS 26 machine)
57+
58+
| Run | Time | Result |
59+
|---|---|---|
60+
| 1 (cold) | 1.56 s | 116 / 116 unredacted |
61+
| 2 (locationd cached after run 1) | 0.29 s | 139 / 139 unredacted |
62+
| 3 (cold again) | 1.84 s | 103 / 103 unredacted |
63+
64+
LaunchServices launch dominates the latency budget on cold runs
65+
(~500 ms – 1 s). Acceptable for the one-time `has_permission` probe
66+
at startup. Continuous-scan latency could be lowered further by
67+
running the bundle as a long-lived background daemon and using
68+
socket IPC for scan requests; deferred until it actually matters.
69+
70+
### Why prior attempts failed (for future-us / future debuggers)
71+
72+
| Version | Approach | Why it didn't work |
73+
|---|---|---|
74+
| v1.0.3 | disclaim + `Thread.sleep(0.3)` | Thread.sleep doesn't pump run loop AND direct exec doesn't get bundle TCC |
75+
| v1.0.6 | disclaim + `RunLoop.current.run(mode:.default, before:)` | Same — pumping the run loop doesn't change the TCC attribution |
76+
| (interim) | disclaim + `dispatchMain()` + 6-retry loop | Worked when locationd had a warm cache for the cdhash at that path; failed cold |
77+
| (interim) | NSApp.run() in direct-exec subprocess | NSApp doesn't change the kernel's TCC subject; still `.notDetermined` |
78+
| **v1.0.7** | LaunchServices re-launch via `open --args scan` | First version that gets bundle-attributed TCC reliably from cold |
79+
80+
The diagnostic that nailed it: write
81+
`CLLocationManager.authorizationStatus().rawValue` to stderr at the
82+
start of the disclaimed scan path. On macOS 26 it prints `0`
83+
(`.notDetermined`) — proving the bundle's grant simply isn't reaching
84+
this process, no matter how the in-process code is structured.
85+
1286
## [1.0.6] — 2026-05-13
1387

1488
The v1.0.3 → v1.0.5 chain attempted to fix CoreWLAN scan

docs/RELEASE.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,3 +99,24 @@ bash scripts/package_release.sh 0.10.0-rc1
9999
the Intel hosted runners. When that happens we either move
100100
x86_64 builds to whatever the latest x86_64 runner is, or drop
101101
x86_64 (Apple Silicon adoption is >70% of new Macs as of 2026).
102+
103+
- **`diting` (installed) hangs at "需要以下权限:定位服务" while
104+
`uv run diting` works.** This is the macOS 26 TCC-vs-LaunchServices
105+
asymmetry — pinned by the v1.0.7 fix. If you regress it (e.g. by
106+
simplifying the helper's `scan` subcommand back to a single
107+
direct-exec subprocess), the symptom returns. Recipe to verify the
108+
fix is intact:
109+
```bash
110+
rm -rf ~/Library/Application\ Support/diting/diting-tianer.app
111+
cp -R helper/diting-tianer.app ~/Library/Application\ Support/diting/diting-tianer.app
112+
pkill -f diting-tianer; sleep 10
113+
~/Library/Application\ Support/diting/diting-tianer.app/Contents/MacOS/diting-tianer scan \
114+
| python3 -c "import sys,json;n=json.load(sys.stdin)['networks'];print(f'with_bssid={sum(1 for r in n if r.get(\"bssid\"))}')"
115+
```
116+
Must print `with_bssid=>0`. If it prints `with_bssid=0`, the
117+
LaunchServices outer/inner split in
118+
`helper/Sources/diting-tianer/main.swift:runScanAndDumpJSON` has
119+
been broken. See the function's leading comment for the full
120+
background — direct-exec subprocesses don't inherit the bundle's
121+
Location TCC grant on macOS 26, so the helper has to re-launch
122+
itself via `/usr/bin/open` to be LaunchServices-attributed.

docs/zh/CHANGELOG.md

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,70 @@
88
[Semantic Versioning](https://semver.org/)`v0.x` 阶段允许破坏性的次要
99
行为变更。
1010

11+
## [1.0.7] — 2026-05-13
12+
13+
v1.0.3 → v1.0.5 都没解开的 macOS 26 安装卡死,根因比之前所有
14+
attempt 都更深一层:**ad-hoc 签名的 bundle 通过直接 exec 启动的子
15+
进程,在 macOS 26 上拿不到 bundle 的 Location TCC 授权**
16+
CoreLocation 的 TCC 检查要求进程必须是 LaunchServices 启动的;
17+
CoreBluetooth 不要求(所以 `bluetooth-status` 一直能用)。
18+
19+
用户的对比定位了这个不对称:`uv run diting` 能用,因为这个 session
20+
反复 `open` in-repo bundle 让 locationd 缓存了那个 cdhash + path;
21+
curl 装的 `diting` 死锁,因为 install 路径的 bundle 是冷的,直接
22+
exec 的 scan subprocess 看到 `CLLocationManager.authorizationStatus
23+
= .notDetermined` —— 无论怎么 pump 运行环、怎么 NSApp.run、怎么
24+
disclaim 都没用。
25+
26+
### 修复
27+
- **`scan` 子命令通过 LaunchServices 重新启动自己**。同一函数拆成
28+
两半,由 `DITING_SCAN_VIA_LAUNCH` 环境变量切换:
29+
- **外层**(无该 env var):fork 出
30+
`/usr/bin/open -W -g -a <bundle> --env DITING_SCAN_VIA_LAUNCH=1
31+
--env DITING_SCAN_OUT=<tempfile> --args scan`。等
32+
LaunchServices 启动的内层子进程写完 JSON,把内容透传到 stdout,
33+
退出。Python 的 `subprocess.run([binary, "scan"])` 完全感知不
34+
到——协议没变。
35+
- **内层**`DITING_SCAN_VIA_LAUNCH=1`):作为 LaunchServices 启
36+
动的 bundle 实例运行。`NSApplication.shared.setActivationPolicy
37+
(.prohibited)` 让它不出现在 Dock、不抢焦点。`ScanWorker :
38+
CLLocationManagerDelegate` 初始化 `CLLocationManager`,
39+
`requestWhenInUseAuthorization` + `startUpdatingLocation`
40+
后开始 scan-with-retry 循环(最多 6 次 × 500 ms 间隔),任意
41+
一行带 `bssid` 就 emit;`.denied` / `.restricted` 短路退出。
42+
`NSApp.run()` 同时 pump 运行环和 libdispatch 主队列。JSON 以
43+
atomic 写入 `$DITING_SCAN_OUT``exit(0)` 结束内层进程。
44+
- **`ble-scan``bluetooth-status` 不动**,继续走 disclaim +
45+
direct-exec;CBCentralManager 的 TCC 直接认 cdhash,不需要
46+
LaunchServices 重启。
47+
48+
### 用户机器实测时延
49+
50+
| 跑次 | 时间 | 结果 |
51+
|---|---|---|
52+
| 1(冷) | 1.56 秒 | 116 / 116 全部未屏蔽 |
53+
| 2(locationd 已缓存) | 0.29 秒 | 139 / 139 全部未屏蔽 |
54+
| 3(重新冷) | 1.84 秒 | 103 / 103 全部未屏蔽 |
55+
56+
LaunchServices 启动是冷路径耗时大头(~500 ms – 1 s)。对一次性的
57+
`has_permission` 探针够用;如果持续扫描的延迟真成为瓶颈,下一步
58+
是把 bundle 起成后台常驻 daemon,扫描请求走 socket IPC。
59+
60+
### 之前几个 attempt 为什么不行(给未来调试的自己)
61+
62+
| 版本 | 思路 | 为什么不行 |
63+
|---|---|---|
64+
| v1.0.3 | disclaim + `Thread.sleep(0.3)` | Thread.sleep 不 pump 运行环;direct exec 也拿不到 bundle TCC |
65+
| v1.0.6 | disclaim + `RunLoop.current.run(mode:.default, before:)` | 同上 —— pump 运行环不改变 TCC 归属 |
66+
| (中间) | disclaim + `dispatchMain()` + 6 次重试 | locationd 对该 path+cdhash 还有缓存时能用,冷状态就漏 |
67+
| (中间) | direct-exec 里跑 NSApp.run() | NSApp 不改变 kernel 看到的 TCC 主体;还是 `.notDetermined` |
68+
| **v1.0.7** | `open --args scan` 重新走 LaunchServices | 第一个真在冷状态下拿到 bundle 归属的 TCC 的版本 |
69+
70+
定位线索:在 disclaim 完之后的 scan 子进程开头把
71+
`CLLocationManager.authorizationStatus().rawValue` 写到 stderr。
72+
macOS 26 上打印 `0``.notDetermined`)—— 证明 bundle 的授权根本
73+
没到这个进程里来,怎么改进程内部代码都没用。
74+
1175
## [1.0.6] — 2026-05-13
1276

1377
v1.0.3 → v1.0.5 一路上想修 install.sh 安装路径下 CoreWLAN scan 数据

docs/zh/RELEASE.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,22 @@ bash scripts/package_release.sh 0.10.0-rc1
9696
到那时要么把 x86_64 构建迁到当时最新的 x86_64 runner,要么
9797
直接放弃 x86_64(截止 2026 年 Apple Silicon 在新 Mac 中占比已
9898
>70%)。
99+
100+
- **curl 装的 `diting` 卡在「需要以下权限:定位服务」而
101+
`uv run diting` 正常**。这就是 v1.0.7 修过的 macOS 26
102+
TCC-vs-LaunchServices 不对称问题。如果哪天回退(比如把 helper 的
103+
`scan` 子命令简化回单一直接 exec subprocess),这个症状会复发。
104+
验证修复仍在的脚本:
105+
```bash
106+
rm -rf ~/Library/Application\ Support/diting/diting-tianer.app
107+
cp -R helper/diting-tianer.app ~/Library/Application\ Support/diting/diting-tianer.app
108+
pkill -f diting-tianer; sleep 10
109+
~/Library/Application\ Support/diting/diting-tianer.app/Contents/MacOS/diting-tianer scan \
110+
| python3 -c "import sys,json;n=json.load(sys.stdin)['networks'];print(f'with_bssid={sum(1 for r in n if r.get(\"bssid\"))}')"
111+
```
112+
必须打印 `with_bssid=>0`。若打印 `with_bssid=0`,说明
113+
`helper/Sources/diting-tianer/main.swift:runScanAndDumpJSON` 里的
114+
LaunchServices outer/inner 拆分被破坏了。完整背景见函数顶部注释
115+
—— macOS 26 上 direct-exec subprocess 不继承 bundle 的 Location
116+
TCC 授权,必须通过 `/usr/bin/open` 重启自己才能被
117+
LaunchServices 正确归属。

0 commit comments

Comments
 (0)