macos-harnessDetail
§ · Nested detail page

Browser Harness 与隐私遥测 深度解析

这一页看两个容易被忽略的模块:真实 Chrome 连接为什么延迟到第一次使用,以及遥测如何避免记录用户数据。

§ 00 · IMPORT CHAIN

导入链与契约

BrowserHarness 在 src/macos_harness/cli.py:24 被放入 namespace,但它不会立刻连接浏览器。真正连接发生在 src/macos_harness/browser.py:114 到 src/macos_harness/browser.py:153;遥测则由 CLI finally 块 src/macos_harness/cli.py:153 到 src/macos_harness/cli.py:154 统一捕获命令结果。

src/macos_harness/browser.py · L106-168Python
106class BrowserHarness:
107    """Expose Browser Harness helpers without connecting until first use."""
108
109    def __init__(self, *, name: str | None = None, wait: float = 60.0) -> None:
110        self.name = name
111        self.wait = wait
112        self._helpers: ModuleType | None = None
113
114    def connect(self) -> BrowserHarness:
115        if self._helpers is not None:
116            return self
117
118        if self.name:
119            os.environ["BU_NAME"] = self.name
120
121        try:
122            from browser_harness import admin, helpers
123        except ImportError as exc:  # pragma: no cover - packaging failure
124            raise RuntimeError(
125                "Browser Harness is unavailable. Install the project dependencies "
126                "with `uv sync`."
127            ) from exc
128
129        approver = subprocess.Popen(
130            [
131                sys.executable,
132                "-c",
133                (
134                    "from macos_harness.browser import _watch_chrome_prompts; "
135                    f"_watch_chrome_prompts({self.wait!r})"
136                ),
137            ],
138            stdout=subprocess.DEVNULL,
139            stderr=subprocess.DEVNULL,
140        )
141        try:
142            admin.ensure_daemon(wait=self.wait, name=self.name)
143        finally:
144            if approver.poll() is None:
145                approver.terminate()
146                try:
147                    approver.wait(timeout=0.2)
148                except subprocess.TimeoutExpired:
149                    approver.kill()
150        if self.name:
151            helpers.NAME = self.name
152        self._helpers = helpers
153        return self
154
155    def __getattr__(self, name: str) -> Any:
156        if name.startswith("_"):
157            raise AttributeError(name)
158        self.connect()
159        assert self._helpers is not None
160        try:
161            return getattr(self._helpers, name)
162        except AttributeError as exc:
163            raise AttributeError(f"Browser Harness has no helper {name!r}") from exc
164
165    def __repr__(self) -> str:
166        status = "connected" if self._helpers is not None else "lazy"
167        suffix = f", name={self.name!r}" if self.name else ""
168        return f"BrowserHarness({status}{suffix})"
§ 01 · CORE IMPL

核心实现,逐段拆

▸Chrome 远程调试弹窗用 AX 精确批准

弹窗处理代码 src/macos_harness/browser.py:15 到 src/macos_harness/browser.py:68 很具体:只看 Google Chrome、窗口名必须等于 Allow remote debugging?,按钮 role 必须是 AXButton 且 description 必须是 Allow。tests/test_browser.py:8 到 tests/test_browser.py:54 用 FakeAS 锁定这个路径,避免把“批准”做成泛化点击。

src/macos_harness/browser.py · L15-68Python
15def _approve_chrome_prompt(stop: threading.Event, timeout: float) -> None:
16    """Press Chrome's exact remote-debugging Allow button through system-wide AX."""
17    if platform.system() != "Darwin":
18        return
19    try:
20        import ApplicationServices as AS
21    except ImportError:  # pragma: no cover - macOS dependency failure
22        return
23
24    deadline = time.monotonic() + timeout
25    while not stop.is_set() and time.monotonic() < deadline:
26        root = AS.AXUIElementCreateSystemWide()
27        pressed = False
28        windows = AS.CGWindowListCopyWindowInfo(
29            AS.kCGWindowListOptionOnScreenOnly, AS.kCGNullWindowID
30        )
31        for window in windows:
32            if window.get("kCGWindowOwnerName") != "Google Chrome":
33                continue
34            if window.get("kCGWindowName") != "Allow remote debugging?":
35                continue
36            bounds = window.get("kCGWindowBounds") or {}
37            left = float(bounds.get("X", 0))
38            top = float(bounds.get("Y", 0))
39            width = float(bounds.get("Width", 0))
40            height = float(bounds.get("Height", 0))
41            points = [(left + width - 58, top + height - 38)]
42            points.extend(
43                (x, y)
44                for y in range(int(top + height - 16), int(top + height - 96), -16)
45                for x in range(int(left + width - 16), int(left), -16)
46            )
47            for x, y in points:
48                error, element = AS.AXUIElementCopyElementAtPosition(root, x, y, None)
49                if error != 0 or element is None:
50                    continue
51                role_error, role = AS.AXUIElementCopyAttributeValue(
52                    element, "AXRole", None
53                )
54                description_error, description = AS.AXUIElementCopyAttributeValue(
55                    element, "AXDescription", None
56                )
57                if (
58                    role_error == 0
59                    and description_error == 0
60                    and str(role) == "AXButton"
61                    and str(description) == "Allow"
62                ):
63                    AS.AXUIElementPerformAction(element, "AXPress")
64                    pressed = True
65                    break
66            if pressed:
67                break
68        stop.wait(0.2 if pressed else 0.05)

▸overlay helper 是 click-through 非激活面板

Overlay 的核心在 src/macos_harness/overlay.py:170 到 src/macos_harness/overlay.py:208:NSPanel 使用 borderless + nonactivating,src/macos_harness/overlay.py:193 设置 ignoresMouseEvents,src/macos_harness/overlay.py:196 把层级放到状态窗口之上。配合 src/macos_harness/overlay.py:280 到 src/macos_harness/overlay.py:297 的命令处理,它只是一层可视反馈,不会接管用户输入。

src/macos_harness/overlay.py · L170-208Python
170    class OverlayController(AppKit.NSObject):
171        def init(self) -> Any:
172            controller = objc.super(OverlayController, self).init()
173            if controller is None:
174                return None
175            frame = AppKit.NSMakeRect(-100, -100, POINTER_WIDTH, POINTER_HEIGHT)
176            style = (
177                AppKit.NSWindowStyleMaskBorderless
178                | AppKit.NSWindowStyleMaskNonactivatingPanel
179            )
180            controller.panel = (
181                AppKit.NSPanel.alloc().initWithContentRect_styleMask_backing_defer_(
182                    frame, style, AppKit.NSBackingStoreBuffered, False
183                )
184            )
185            controller.view = PointerView.alloc().initWithFrame_(
186                AppKit.NSMakeRect(0, 0, POINTER_WIDTH, POINTER_HEIGHT)
187            )
188            controller.panel.setContentView_(controller.view)
189            controller.panel.setTitle_("macOS Harness Pointer")
190            controller.panel.setOpaque_(False)
191            controller.panel.setBackgroundColor_(AppKit.NSColor.clearColor())
192            controller.panel.setHasShadow_(False)
193            controller.panel.setIgnoresMouseEvents_(True)
194            controller.panel.setHidesOnDeactivate_(False)
195            controller.panel.setReleasedWhenClosed_(False)
196            controller.panel.setLevel_(AppKit.NSStatusWindowLevel + 1)
197            controller.panel.setCollectionBehavior_(
198                AppKit.NSWindowCollectionBehaviorCanJoinAllSpaces
199                | AppKit.NSWindowCollectionBehaviorFullScreenAuxiliary
200                | AppKit.NSWindowCollectionBehaviorStationary
201            )
202            controller.shown = False
203            controller.animation_timer = None
204            controller.animation_started = 0.0
205            controller.animation_duration = 0.0
206            controller.animation_start = (0.0, 0.0)
207            controller.animation_end = (0.0, 0.0)
208            return controller

▸遥测 payload 没有用户数据通道

遥测配置在 src/macos_harness/telemetry.py:24 到 src/macos_harness/telemetry.py:52,会把配置目录设成 0700、文件设成 0600。发送逻辑 src/macos_harness/telemetry.py:157 到 src/macos_harness/telemetry.py:180 只允许命令类别、成功标记和环境版本,测试 tests/test_telemetry.py:35 到 tests/test_telemetry.py:58 则直接检查序列化 payload 不含敏感字段名。

src/macos_harness/telemetry.py · L24-52Python
24def _config_dir() -> Path:
25    override = os.environ.get("MACOS_HARNESS_HOME")
26    if override:
27        return Path(override).expanduser()
28    return Path.home() / "Library" / "Application Support" / "macos-harness"
29
30
31def _config_path() -> Path:
32    return _config_dir() / "telemetry.json"
33
34
35def _load() -> dict:
36    try:
37        return json.loads(_config_path().read_text(encoding="utf-8"))
38    except (FileNotFoundError, OSError, ValueError):
39        return {}
40
41
42def _save(config: dict) -> None:
43    try:
44        path = _config_path()
45        path.parent.mkdir(parents=True, exist_ok=True)
46        os.chmod(path.parent, 0o700)
47        path.write_text(
48            json.dumps(config, indent=2, sort_keys=True) + "\n", encoding="utf-8"
49        )
50        os.chmod(path, 0o600)
51    except OSError:
52        pass
§ 02 · KEY FLOW

关键流程

browser.page_info() → __getattr__ → connect → start prompt approver → ensure_daemon → helpers bound cli command → try branch → finally capture_cli → is_enabled → _send coarse payload

这个流程让浏览器和遥测都保持低侵入。浏览器只有访问 helper 时才连接,见 src/macos_harness/browser.py:155 到 src/macos_harness/browser.py:163;遥测只有在 src/macos_harness/telemetry.py:80 到 src/macos_harness/telemetry.py:82 返回 enabled 时才发送,且 src/macos_harness/telemetry.py:16 到 src/macos_harness/telemetry.py:21 支持环境变量禁用。

src/macos_harness/telemetry.py · L80-101Python
80def is_enabled() -> bool:
81    return not _env_disabled() and not bool(_load().get("disabled"))
82
83
84def status() -> dict:
85    config = _load()
86    env_disabled = _env_disabled()
87    enabled = not env_disabled and not bool(config.get("disabled"))
88    return {
89        "enabled": enabled,
90        "disabled_by_env": env_disabled,
91        "disabled_by_config": bool(config.get("disabled")),
92        "install_id": _install_id(config, create=enabled),
93        "config_path": str(_config_path()),
94    }
95
96
97def set_enabled(enabled: bool) -> dict:
98    config = _load()
99    config["disabled"] = not enabled
100    _save(config)
101    return status()
§ 03 · EDGE CASES

边界 / 失败模式

  • src/macos_harness/browser.py:121 到 src/macos_harness/browser.py:127 如果 browser_harness 依赖缺失,会把安装问题显式返回给调用方。
  • src/macos_harness/browser.py:144 到 src/macos_harness/browser.py:149 负责终止 approver,避免临时进程长驻。
  • src/macos_harness/telemetry.py:138 到 src/macos_harness/telemetry.py:155 使用后台子进程发送,失败会被吞掉,不能把它当成可靠审计日志。
§ 04 · RELATED

关联模块

CLI finally
src/macos_harness/cli.py:91 到 src/macos_harness/cli.py:154 把每个命令的成功与否统一交给 telemetry。
测试隐私边界
tests/test_telemetry.py:9 到 tests/test_telemetry.py:32 还覆盖私有配置文件权限和禁用开关。