diff --git a/codec_observer.py b/codec_observer.py index 181c0be..57bd76e 100644 --- a/codec_observer.py +++ b/codec_observer.py @@ -120,6 +120,18 @@ "cadence_idle_s": 300, "idle_threshold_s": 60, "buffer_depth_min": 10, + # OCR enable flag (2026-05-02 hotfix). Default True preserves Step 5 + # design behavior on machines where Screen Recording permission is + # granted to the python3.13 process running codec-observer. Set false + # to skip the screencapture+Vision call entirely — buffer still gets + # active_window + clipboard + recent_files signals, just no OCR. + # Flipping this to false is the recommended workaround for the macOS + # popup-storm bug when permissions aren't granted to the PM2 child: + # screencapture blocks subprocess.run waiting for the popup AND the + # ThreadPoolExecutor's `with` exit waits for the thread to finish, + # so each poll generates ~2 popups + ~5s of blocking until dismissal. + # See ~/.codec/config.json:observer.ocr_enabled to override. + "ocr_enabled": True, "ocr_timeout_ms": 100, "ocr_retry_timeout_ms": 200, # Q5.1 "reset_on_long_idle": True, @@ -514,9 +526,16 @@ def poll(buffer: Optional[RingBuffer] = None, "content_type": _classify_clipboard_kind(cb_now), } - # 3. Screenshot OCR (with retry per Q5.1) - ocr_text, ocr_skipped = _get_screenshot_ocr( - int(cfg["ocr_timeout_ms"]), int(cfg["ocr_retry_timeout_ms"])) + # 3. Screenshot OCR (with retry per Q5.1). + # 2026-05-02 hotfix: bypass entirely when ocr_enabled=False to avoid + # the screencapture popup storm on machines without Screen Recording + # permission granted to the PM2 child process. Buffer still gets + # active_window + clipboard + recent_files signals. + if cfg.get("ocr_enabled", True): + ocr_text, ocr_skipped = _get_screenshot_ocr( + int(cfg["ocr_timeout_ms"]), int(cfg["ocr_retry_timeout_ms"])) + else: + ocr_text, ocr_skipped = ("", True) # 4. Recent files recent_files = _get_recent_files(window_seconds=300) diff --git a/tests/test_observer.py b/tests/test_observer.py index ec3abf9..569e8e9 100644 --- a/tests/test_observer.py +++ b/tests/test_observer.py @@ -492,3 +492,61 @@ def test_inject_empty_buffer_returns_skipped(monkeypatch, temp_audit_log): # No audit emit on skipped recs = _records(temp_audit_log) assert _events_of(recs, codec_audit.OBSERVATION_SUMMARY_INJECTED) == [] + + +# ───────────────────────────────────────────────────────────────────────────── +# 2026-05-02 hotfix — ocr_enabled flag (popup storm mitigation) +# ───────────────────────────────────────────────────────────────────────────── + +def test_ocr_enabled_false_skips_screenshot_call(fresh_buffer, temp_audit_log, + monkeypatch): + """When config.observer.ocr_enabled=False, poll() must NOT call + _get_screenshot_ocr. Verified by sentinel that raises if called.""" + cfg = dict(codec_observer._DEFAULT_CONFIG) + cfg["ocr_enabled"] = False + monkeypatch.setattr(codec_observer, "_idle_seconds", lambda: 0.0) + monkeypatch.setattr(codec_observer, "_get_active_window", lambda: {}) + monkeypatch.setattr(codec_observer, "_get_clipboard_now", lambda: "") + monkeypatch.setattr(codec_observer, "_get_recent_files", + lambda window_seconds=300: []) + + def _should_not_be_called(*a, **kw): + raise AssertionError("_get_screenshot_ocr called despite ocr_enabled=False") + monkeypatch.setattr(codec_observer, "_get_screenshot_ocr", + _should_not_be_called) + + snap = codec_observer.poll(buffer=fresh_buffer, cfg=cfg, emit_audit=True) + assert snap["ocr_skipped"] is True + assert snap["screenshot_ocr"] == "" + # Audit emit still records the skipped state correctly + recs = _records(temp_audit_log) + extra = _events_of(recs, codec_audit.OBSERVATION_TICK)[0]["extra"] + assert extra["ocr_skipped"] is True + assert extra["ocr_chars"] == 0 + + +def test_ocr_enabled_default_is_true(): + """Default config has ocr_enabled=True (preserves Step 5 behavior + on properly-permissioned machines).""" + assert codec_observer._DEFAULT_CONFIG["ocr_enabled"] is True + + +def test_ocr_enabled_true_calls_screenshot(fresh_buffer, temp_audit_log, + monkeypatch): + """Sanity check: when ocr_enabled=True (default), _get_screenshot_ocr + IS called. Confirms the new flag isn't accidentally always-bypassing.""" + cfg = dict(codec_observer._DEFAULT_CONFIG) + assert cfg["ocr_enabled"] is True + monkeypatch.setattr(codec_observer, "_idle_seconds", lambda: 0.0) + monkeypatch.setattr(codec_observer, "_get_active_window", lambda: {}) + monkeypatch.setattr(codec_observer, "_get_clipboard_now", lambda: "") + monkeypatch.setattr(codec_observer, "_get_recent_files", + lambda window_seconds=300: []) + call_count = [0] + + def _track_call(*a, **kw): + call_count[0] += 1 + return ("", True) # mocked skip — doesn't actually screencapture + monkeypatch.setattr(codec_observer, "_get_screenshot_ocr", _track_call) + codec_observer.poll(buffer=fresh_buffer, cfg=cfg, emit_audit=False) + assert call_count[0] == 1