Skip to content

Commit bea5da2

Browse files
chenchaoyiclaude
andauthored
fix(helper): scan unredacts + popup localised + longer auto-close (#42)
v1.0.2 finally landed working release assets, but the install-and-run flow hung at "需要以下权限:定位服务" because the helper's `scan` subcommand kept returning ssid/bssid as null even after the user clicked Allow. Locally verified all three fixes against the dev-rebuilt bundle: BSSID + SSID + Beacon IE fields all flow. Three changes in helper/Sources/diting-tianer/main.swift + a small launcher patch in src/diting/cli.py: 1. **Scan unredacts under install-script TCC grants.** Two parallel issues kept BSSIDs/SSIDs null: a. The `scan` subcommand inherited responsibility from its terminal parent, so tccd attributed the Location request to Terminal (no NSLocationUsageDescription), and CWNetwork silently redacted ssid/bssid. The BLE path has done a responsibility_spawnattrs_setdisclaim re-exec since v0.5.0; `scan` now does the same hop. b. macOS 14.4+ / 26 requires an active CLLocationManager in the calling process at the moment of scanForNetworks — the bundle's TCC grant on disk is necessary but not sufficient. The `scan` subcommand now initialises a CLLocationManager and calls startUpdatingLocation() before the CoreWLAN call, mirroring what the GUI bundle has always done. The earlier code comment claiming CoreLocation was "more lenient than CoreBluetooth" was wrong; CoreWLAN on macOS 26 enforces both conditions. 2. **Helper popup is localised.** When DITING_LANG=zh (passed via `open --env` from Python's launcher) or when macOS's Locale.preferredLanguages starts with "zh", the popup shows Chinese instead of English. Title becomes "diting 天耳"; intro, status lines, and the "All permissions granted" message all translate. Resolution order (env var first, system preference second) matches the Python CLI's i18n. 3. **Popup auto-close delay extended 1.5s → 4s.** Users reported the "All permissions granted" confirmation flashed by too fast to read. 4. **Python launcher passes DITING_LANG to `open`.** Without this, the bundle inherits the user's login session env (which doesn't know about `diting --lang zh`) and the popup would show English even when diting is running in Chinese. Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 1d68e8d commit bea5da2

6 files changed

Lines changed: 230 additions & 32 deletions

File tree

CHANGELOG.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,49 @@ 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.3] — 2026-05-13
13+
14+
First end-user-installed release surfaced a long-latent CoreWLAN
15+
bug that only bites under the install.sh flow, plus two small
16+
helper-popup UX issues. v1.0.2's install path worked but `diting`
17+
itself hung at "需要以下权限:定位服务" because the helper's `scan`
18+
subcommand kept returning redacted BSSIDs even after the user
19+
clicked Allow.
20+
21+
### Fixed
22+
- **Wi-Fi scan unredacts under install-script TCC grants.** Two
23+
parallel issues kept BSSIDs / SSIDs `null`:
24+
1. The helper's `scan` subcommand inherited responsibility from
25+
its terminal parent, so tccd attributed the request to
26+
Terminal (no `NSLocationUsageDescription`) instead of the
27+
bundle. The BLE path has done a
28+
`responsibility_spawnattrs_setdisclaim` re-exec since
29+
v0.5.0; `scan` now does the same hop.
30+
2. macOS 14.4+ / 26 requires an active `CLLocationManager` in
31+
the calling process at the moment of `scanForNetworks` — the
32+
bundle's TCC grant on disk is necessary but not sufficient.
33+
The `scan` subcommand now initialises a `CLLocationManager`
34+
and calls `startUpdatingLocation()` before the CoreWLAN
35+
call, mirroring what the GUI bundle has always done.
36+
The earlier code comment claiming CoreLocation was "more
37+
lenient than CoreBluetooth" was wrong — verified locally that
38+
with both fixes BSSIDs / SSIDs / IE diagnostics flow as
39+
expected.
40+
41+
### Changed
42+
- **Helper popup window is localised.** When `DITING_LANG=zh` (passed
43+
via `open --env` from Python's launcher) or when macOS's
44+
`Locale.preferredLanguages` starts with `zh`, the popup window
45+
shows Chinese instead of English. Title becomes "diting 天耳",
46+
body / status lines translate accordingly. The first-launch
47+
install.sh popup also picks up zh automatically for users on a
48+
Chinese-locale Mac.
49+
- **Helper popup auto-close delay 1.5s → 4s.** Users reported the
50+
"All permissions granted" confirmation flashed by too fast to
51+
read; 4 s is long enough to comfortably take in without being
52+
annoying. The Python launcher's polling loop picks up the
53+
grants immediately regardless of how long the window stays up.
54+
1255
## [1.0.2] — 2026-05-13
1356

1457
Second hot-fix to the v1.0.0 release pipeline. v1.0.1 unblocked the

docs/zh/CHANGELOG.md

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

11+
## [1.0.3] — 2026-05-13
12+
13+
第一个真正可被最终用户装出来的 release。装出来之后 `diting` 一直卡
14+
在「需要以下权限:定位服务」,原因是 helper 的 `scan` 子命令即使在
15+
用户点了 Allow 之后,依然返回被 TCC 屏蔽的 BSSID/SSID。同时修了两
16+
个 helper 弹窗的小 UX 问题。
17+
18+
### 修复
19+
- **install.sh 安装路径下 Wi-Fi 扫描真正解除 TCC 屏蔽**。两个并行的
20+
问题让 BSSID / SSID 都是 `null`
21+
1. helper 的 `scan` 子命令继承了 terminal 父进程的 responsibility,
22+
tccd 把 Location 请求算到 Terminal 头上(Terminal 没有
23+
`NSLocationUsageDescription`),CWNetwork 直接静默屏蔽
24+
ssid / bssid。BLE 路径自 v0.5.0 起就用
25+
`responsibility_spawnattrs_setdisclaim` 重启自己来打断这条
26+
继承;`scan` 现在做同样的 hop。
27+
2. macOS 14.4+ / 26 要求**当前进程**`scanForNetworks` 调用
28+
的瞬间有活动的 `CLLocationManager`,bundle 的 TCC 授权只是必
29+
要条件不是充分条件。`scan` 子命令现在在调 CoreWLAN 之前先
30+
`CLLocationManager` + `startUpdatingLocation()`,跟 GUI bundle
31+
一直在做的同。
32+
代码里原来注释说 CoreLocation 比 CoreBluetooth「宽松」是错的——
33+
本地验证两个修复同时上之后,BSSID / SSID / Beacon IE 数据如期
34+
流出。
35+
36+
### 变更
37+
- **helper 弹窗本地化**`DITING_LANG=zh`(Python 启动器通过
38+
`open --env` 传过来)或 macOS 的 `Locale.preferredLanguages`
39+
`zh` 开头时,弹窗切到中文:标题改为「diting 天耳」,正文 / 状态
40+
行也都翻译过来。中文 locale 用户跑 install.sh 时弹的第一个窗也
41+
自动是中文。
42+
- **弹窗自动关闭从 1.5 s 改到 4 s**。多位用户反馈「全部权限已授予」
43+
闪一下就消失,看不清;4 s 既够看完,又不至于让人觉得磨蹭。Python
44+
启动器的轮询不依赖窗口停留时长,授权一落地立刻被识别。
45+
1146
## [1.0.2] — 2026-05-13
1247

1348
第二个针对 v1.0.0 发布流水线的热修复。v1.0.1 解开了 Swift helper 构建

helper/Sources/diting-tianer/main.swift

Lines changed: 133 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,43 @@ import IOBluetooth
5454
// ---------------------------------------------------------------------
5555

5656
func runScanAndDumpJSON() -> Never {
57+
// macOS 14.4+ gates CWNetwork.ssid / .bssid behind Location Services
58+
// at the calling process level — having the bundle's TCC grant on
59+
// disk is not enough. Two things have to be true at scanForNetworks
60+
// time:
61+
//
62+
// 1. The TCC subject seen by macOS is the helper bundle (not the
63+
// terminal that launched us). Inherited responsibility from
64+
// Terminal → diting → diting-tianer breaks this on macOS 26 —
65+
// tccd attributes the request to Terminal, which has no
66+
// NSLocationUsageDescription, and CWNetwork redacts ssid /
67+
// bssid silently. The fix is to disclaim our parent so we
68+
// become our own responsible process, same hop the ble-scan
69+
// subcommand has done since v0.5.0.
70+
//
71+
// 2. A CLLocationManager has called startUpdatingLocation() in
72+
// this process. The bundle grant alone isn't enough; CoreWLAN
73+
// checks for an active location signal at the moment of
74+
// scanForNetworks(). We init the manager, kick it, hold a
75+
// reference for the lifetime of the function so the runtime
76+
// can't release it before the scan call hits CoreWLAN.
77+
//
78+
// The earlier code comment claiming CoreLocation was "more lenient"
79+
// than CoreBluetooth was wrong; CoreWLAN on macOS 26 enforces both.
80+
if ProcessInfo.processInfo.environment[kDisclaimEnv] == nil {
81+
reExecWithDisclaimedResponsibility()
82+
}
83+
84+
let locationManager = CLLocationManager()
85+
locationManager.requestWhenInUseAuthorization()
86+
locationManager.startUpdatingLocation()
87+
// Brief settle so CoreLocation's TCC registration lands before
88+
// CoreWLAN inspects it. Empirically ~50ms is enough on M-series
89+
// Macs; 300ms gives headroom on slower hardware without making
90+
// the user wait an obvious extra beat.
91+
Thread.sleep(forTimeInterval: 0.3)
92+
_ = locationManager // hold the reference until the scan returns
93+
5794
let client = CWWiFiClient.shared()
5895
guard let iface = client.interface() else {
5996
emitError("no Wi-Fi interface")
@@ -854,6 +891,73 @@ func runBluetoothStatusProbe() -> Never {
854891
dispatchMain()
855892
}
856893

894+
// ---------------------------------------------------------------------
895+
// App-mode localization
896+
//
897+
// Resolution order matches the Python CLI's i18n: DITING_LANG env var
898+
// first (so `open --env DITING_LANG=zh bundle.app` from the Python
899+
// launcher wins), then the user's macOS locale preference. install.sh's
900+
// first-launch `open -g` can't pass env, so the macOS preference is the
901+
// fallback that gets the first popup right for a Chinese-locale user.
902+
// ---------------------------------------------------------------------
903+
904+
private enum HelperLang { case en, zh }
905+
906+
private func detectHelperLang() -> HelperLang {
907+
if let env = ProcessInfo.processInfo.environment["DITING_LANG"]?.lowercased() {
908+
return env == "zh" ? .zh : .en
909+
}
910+
if let pref = Locale.preferredLanguages.first?.lowercased(),
911+
pref.hasPrefix("zh") {
912+
return .zh
913+
}
914+
return .en
915+
}
916+
917+
private struct HelperStrings {
918+
let lang: HelperLang
919+
var title: String { lang == .zh ? "diting 天耳" : "diting tianer" }
920+
var intro: String {
921+
switch lang {
922+
case .zh:
923+
return "这个辅助 .app 让 diting(Python TUI)能够读取附近 Wi-Fi 的 SSID / BSSID,并扫描附近 BLE 设备 —— 否则 macOS 的「定位服务」和「蓝牙」权限会拦下 Python 进程。下面的弹窗点 Allow 各一次(只用一次),授权完毕关闭此窗口即可。"
924+
case .en:
925+
return "This helper exists so diting (the Python TUI) can read nearby Wi-Fi network names / BSSIDs and scan for nearby BLE devices without being blocked by macOS Location Services or Bluetooth permissions. Grant the prompts below — a one-time action — and you can close this window."
926+
}
927+
}
928+
var requestingStatus: String { lang == .zh ? "正在请求权限…" : "Requesting permissions..." }
929+
var allGranted: String {
930+
switch lang {
931+
case .zh:
932+
return "全部权限已授予。本窗口将在几秒后自动关闭…"
933+
case .en:
934+
return "All permissions granted. This window will close automatically in a few seconds..."
935+
}
936+
}
937+
// Location lines
938+
func locationWaiting() -> String { lang == .zh ? "定位服务:等待用户决定…" : "Location: waiting for permission decision..." }
939+
func locationRestricted() -> String { lang == .zh ? "定位服务:被系统策略限制。" : "Location: restricted by a system policy." }
940+
func locationDenied() -> String {
941+
lang == .zh
942+
? "定位服务:被拒绝。请到 系统设置 → 隐私与安全性 → 定位服务 → diting-tianer 启用。"
943+
: "Location: denied. Enable it in System Settings → Privacy & Security → Location Services → diting-tianer."
944+
}
945+
func locationGranted() -> String { lang == .zh ? "定位服务:已授权。" : "Location: granted." }
946+
func locationUnknown(_ raw: Int) -> String { lang == .zh ? "定位服务:未知状态 \(raw)" : "Location: unknown state \(raw)." }
947+
// Bluetooth lines
948+
func bluetoothQuerying() -> String { lang == .zh ? "蓝牙:正在查询状态…" : "Bluetooth: querying state..." }
949+
func bluetoothResetting() -> String { lang == .zh ? "蓝牙:正在重置…" : "Bluetooth: resetting..." }
950+
func bluetoothUnsupported() -> String { lang == .zh ? "蓝牙:本机硬件不支持。" : "Bluetooth: unsupported on this hardware." }
951+
func bluetoothUnauthorized() -> String {
952+
lang == .zh
953+
? "蓝牙:被拒绝。请到 系统设置 → 隐私与安全性 → 蓝牙 → diting-tianer 启用。"
954+
: "Bluetooth: denied. Enable it in System Settings → Privacy & Security → Bluetooth → diting-tianer."
955+
}
956+
func bluetoothOff() -> String { lang == .zh ? "蓝牙:已关闭。请在控制中心打开蓝牙。" : "Bluetooth: turned off. Toggle it on in Control Center." }
957+
func bluetoothGranted() -> String { lang == .zh ? "蓝牙:已授权。" : "Bluetooth: granted." }
958+
func bluetoothUnknown(_ raw: Int) -> String { lang == .zh ? "蓝牙:未知状态 \(raw)" : "Bluetooth: unknown state \(raw)." }
959+
}
960+
857961
// ---------------------------------------------------------------------
858962
// App mode (Finder launch / open)
859963
// ---------------------------------------------------------------------
@@ -865,6 +969,8 @@ final class HelperAppDelegate: NSObject, NSApplicationDelegate, CLLocationManage
865969
private var bluetoothManager: CBCentralManager!
866970
private var locationGranted = false
867971
private var bluetoothGranted = false
972+
private var autoCloseScheduled = false
973+
private let strings = HelperStrings(lang: detectHelperLang())
868974

869975
func applicationDidFinishLaunching(_ notification: Notification) {
870976
let frame = NSRect(x: 0, y: 0, width: 520, height: 280)
@@ -874,7 +980,7 @@ final class HelperAppDelegate: NSObject, NSApplicationDelegate, CLLocationManage
874980
backing: .buffered,
875981
defer: false
876982
)
877-
window.title = "diting tianer"
983+
window.title = strings.title
878984
window.center()
879985

880986
let body = NSStackView(frame: NSRect(x: 24, y: 24, width: 472, height: 232))
@@ -883,21 +989,15 @@ final class HelperAppDelegate: NSObject, NSApplicationDelegate, CLLocationManage
883989
body.spacing = 12
884990
body.translatesAutoresizingMaskIntoConstraints = false
885991

886-
let title = NSTextField(labelWithString: "diting tianer")
992+
let title = NSTextField(labelWithString: strings.title)
887993
title.font = NSFont.systemFont(ofSize: 18, weight: .semibold)
888994
body.addArrangedSubview(title)
889995

890-
let intro = NSTextField(wrappingLabelWithString:
891-
"This helper exists so diting (the Python TUI) can read " +
892-
"nearby Wi-Fi network names / BSSIDs and scan for nearby BLE " +
893-
"devices without being blocked by macOS Location Services or " +
894-
"Bluetooth permissions. Grant the prompts below — a one-time " +
895-
"action — and you can close this window."
896-
)
996+
let intro = NSTextField(wrappingLabelWithString: strings.intro)
897997
intro.preferredMaxLayoutWidth = 472
898998
body.addArrangedSubview(intro)
899999

900-
statusLabel = NSTextField(wrappingLabelWithString: "Requesting permissions...")
1000+
statusLabel = NSTextField(wrappingLabelWithString: strings.requestingStatus)
9011001
statusLabel.font = NSFont.systemFont(ofSize: 13, weight: .regular)
9021002
statusLabel.preferredMaxLayoutWidth = 472
9031003
body.addArrangedSubview(statusLabel)
@@ -953,13 +1053,17 @@ final class HelperAppDelegate: NSObject, NSApplicationDelegate, CLLocationManage
9531053
lines.append(bluetoothLine(btState))
9541054
statusLabel.stringValue = lines.joined(separator: "\n")
9551055

956-
if locationGranted && bluetoothGranted {
957-
statusLabel.stringValue += "\n\nAll permissions granted. This window will close automatically..."
958-
// Give the user ~1.5 s to read the message, then exit so
959-
// they do not have to find Cmd+Q. The TCC grants are
960-
// persistent — diting's Python TUI immediately picks
961-
// them up the next time it polls the helper.
962-
DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) {
1056+
if locationGranted && bluetoothGranted && !autoCloseScheduled {
1057+
autoCloseScheduled = true
1058+
statusLabel.stringValue += "\n\n" + strings.allGranted
1059+
// 4 s gives the user a beat to actually read "All
1060+
// permissions granted" before the window vanishes. The
1061+
// 1.5 s default felt too snappy and a few users
1062+
// reported being confused that the window blinked
1063+
// closed. TCC grants are persistent — diting's Python
1064+
// launcher will pick them up on its next poll cycle
1065+
// regardless of how long this window stays up.
1066+
DispatchQueue.main.asyncAfter(deadline: .now() + 4.0) {
9631067
NSApp.terminate(nil)
9641068
}
9651069
}
@@ -968,34 +1072,34 @@ final class HelperAppDelegate: NSObject, NSApplicationDelegate, CLLocationManage
9681072
private func locationLine(_ status: CLAuthorizationStatus) -> String {
9691073
switch status {
9701074
case .notDetermined:
971-
return "Location: waiting for permission decision..."
1075+
return strings.locationWaiting()
9721076
case .restricted:
973-
return "Location: restricted by a system policy."
1077+
return strings.locationRestricted()
9741078
case .denied:
975-
return "Location: denied. Enable it in System Settings → Privacy & Security → Location Services → diting-tianer."
1079+
return strings.locationDenied()
9761080
case .authorizedAlways, .authorizedWhenInUse:
977-
return "Location: granted."
1081+
return strings.locationGranted()
9781082
@unknown default:
979-
return "Location: unknown state \(status.rawValue)."
1083+
return strings.locationUnknown(Int(status.rawValue))
9801084
}
9811085
}
9821086

9831087
private func bluetoothLine(_ state: CBManagerState) -> String {
9841088
switch state {
9851089
case .unknown:
986-
return "Bluetooth: querying state..."
1090+
return strings.bluetoothQuerying()
9871091
case .resetting:
988-
return "Bluetooth: resetting..."
1092+
return strings.bluetoothResetting()
9891093
case .unsupported:
990-
return "Bluetooth: unsupported on this hardware."
1094+
return strings.bluetoothUnsupported()
9911095
case .unauthorized:
992-
return "Bluetooth: denied. Enable it in System Settings → Privacy & Security → Bluetooth → diting-tianer."
1096+
return strings.bluetoothUnauthorized()
9931097
case .poweredOff:
994-
return "Bluetooth: turned off. Toggle it on in Control Center."
1098+
return strings.bluetoothOff()
9951099
case .poweredOn:
996-
return "Bluetooth: granted."
1100+
return strings.bluetoothGranted()
9971101
@unknown default:
998-
return "Bluetooth: unknown state \(state.rawValue)."
1102+
return strings.bluetoothUnknown(Int(state.rawValue))
9991103
}
10001104
}
10011105

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "diting"
3-
version = "1.0.2"
3+
version = "1.0.3"
44
description = "macOS terminal listening post for Wi-Fi, BLE, link health, and the RF environment — your Mac hears more than it tells you"
55
readme = "README.md"
66
license = "MIT"

src/diting/cli.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -891,8 +891,24 @@ def _ensure_helper_ready() -> str | None:
891891
print(t("(Ctrl+C to skip and start the TUI with degraded views.)"))
892892
print()
893893
try:
894+
# `open --env KEY=VALUE` bridges our process env into the
895+
# LaunchServices-spawned bundle. Without this, the bundle would
896+
# inherit the user's login session env, which doesn't know
897+
# anything about diting's --lang flag, and the popup window
898+
# would show English even when the user is running
899+
# `diting --lang zh`. We pass DITING_LANG explicitly so the
900+
# Swift HelperAppDelegate's HelperStrings struct picks the
901+
# right localisation. The bundle falls back to
902+
# `Locale.preferredLanguages` if we don't set this, which
903+
# covers install.sh's first-launch `open -g` call (no Python
904+
# in the chain to know about --lang yet).
905+
open_argv = [
906+
"/usr/bin/open",
907+
"--env", f"DITING_LANG={i18n.get_lang()}",
908+
bundle,
909+
]
894910
subprocess.Popen(
895-
["/usr/bin/open", bundle],
911+
open_argv,
896912
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
897913
)
898914
except OSError as e:

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

0 commit comments

Comments
 (0)