From ee3b4cf188290d2f26707437cc882433e9a35089 Mon Sep 17 00:00:00 2001 From: John Wallace Date: Sat, 5 Sep 2026 17:11:36 -0400 Subject: [PATCH] fix(zoom): stop popping a modal for the normal record-privilege wait Starting the engine was showing a "Zoom Join" error modal for raw_media_start_failed/privilege_requested -- the NORMAL first half of Zoom's record-privilege handshake (canStartRawRecording -> NoPermission, engine asks the host, raw_media_ready follows once granted), not a failure. handle_event() already refused to route this into the join-failure/reconnect machinery; it still set m_last_error and fired the error-callback list, which is what popped the modal. Adds a separate NoticeCallback path (ZoomEngineClient::add_notice_callback/ pending_privilege_notice) instead of a severity flag on the existing error callback, so "never reaches a QMessageBox" holds by construction rather than by every subscriber remembering to check a flag. m_last_error is left untouched. The operator copy and the first-vs-repeat classification live in src/zoom-privilege-notice.h (pure, host-tested in tests/zoom-privilege-notice-test.cpp, three mutations killed). The notice clears on raw_media_ready and on leave, rendered as a CvBanner(Warning) in the dock instead of the modal. Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 40 ++++++++++ CMakeLists.txt | 11 +++ src/zoom-dock.cpp | 37 ++++++++++ src/zoom-dock.h | 5 ++ src/zoom-engine-client.cpp | 96 ++++++++++++++++++++---- src/zoom-engine-client.h | 35 +++++++++ src/zoom-privilege-notice.h | 77 +++++++++++++++++++ tests/zoom-privilege-notice-test.cpp | 106 +++++++++++++++++++++++++++ 8 files changed, 394 insertions(+), 13 deletions(-) create mode 100644 src/zoom-privilege-notice.h create mode 100644 tests/zoom-privilege-notice-test.cpp diff --git a/CLAUDE.md b/CLAUDE.md index de92e843..f96c5af9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1164,6 +1164,46 @@ Every one of these is documented at length where it lives; the list is the map. extracted from `refresh()` (`paint_banner()`/`paint_plan()`) and are SHARED rather than copied, because an instrument painting its own approximation would verify a layout the product does not have. +- **The record-privilege handshake is a STATE, not an error** (live defect, + 2026-09-05, launch day: starting the engine popped a "Zoom Join" modal + reading "raw recording failed" on a session working exactly as designed). + `canStartRawRecording()` returning `NoPermission(6)` is the NORMAL first + half of Zoom's record-privilege handshake -- the engine asks the host + (`requestLocalRecordingPrivilege()`, `engine/src/main-macos.mm`'s + `handle_start_media()`), the host grants it, and `raw_media_ready` follows + on its own. `handle_event()`'s `raw_media_start_failed` + + `privilege_requested` branch already refused (before this fix, and + unchanged by it) to route this into the join-failure/reconnect machinery -- + its own comment documents a live incident where doing so once flipped a + healthy joined session to Failed and gated `start_engine`, resubscription + and recovery for the rest of the session. What it still got wrong: it set + `m_last_error` and fired every registered `ErrorCallback`, which is what + pops the dock's `QMessageBox`. Fixed with a SEPARATE `NoticeCallback` list + (`ZoomEngineClient::add_notice_callback()`/`m_privilege_notice`, + `src/zoom-engine-client.h`) rather than a severity flag on the existing one: + every `ErrorCallback` subscriber's contract -- today just the dock, but a + public extension point -- is "this means show a failure", so a severity + field is a branch every current and future subscriber has to remember to + check, where a separate list makes "this can never reach a QMessageBox" true + by construction. `m_last_error` is deliberately left untouched, because + other code (the `"left"` handler's `keep_failed` check) reads it as "the + session actually failed," which a pending grant is not. The operator-facing + copy and the first-vs-repeat classification (the engine asks the host only + ONCE per meeting; every later report is the same still-pending wait, told + apart only by its `"detail"` text containing "already requested") are pure + functions in `src/zoom-privilege-notice.h`, extracted for the same reason + `zoom-join-decision.h`/`join-watchdog.h` are: host-tested without Qt/OBS in + `tests/zoom-privilege-notice-test.cpp`, three mutations run and killed + (breaking the classifier substring, collapsing the two notices to identical + copy, and inverting the fail-safe so an empty/unrecognized `detail` reads as + already-requested). The notice CLEARS on `raw_media_ready` (the engine event + that means the grant landed) and on the `"left"` per-meeting reset, and the + dock's `update_privilege_banner()` is driven BOTH by the callback and by a + 100ms poll of `pending_privilege_notice()` in `update_state_indicator()` -- + the poll is what actually hides a stale banner after a leave/rejoin, since + the `"left"` handler clears the field without a separate notify call, same + as it has never notified roster callbacks either. Rendered as a + `CvBanner(Warning)` under the engine controls, not the modal it replaces. ## Live testing against a real meeting diff --git a/CMakeLists.txt b/CMakeLists.txt index 051dd3c6..41eb99e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -801,6 +801,17 @@ if(BUILD_TESTING) add_test(NAME CoreVideoJoinDecision COMMAND CoreVideoJoinDecisionTest) + # Record-privilege handshake notice copy/classification (2026-09-05 live + # defect fix). Header-only logic; no Qt/OBS/SDK dependencies. + add_executable(CoreVideoPrivilegeNoticeTest + tests/zoom-privilege-notice-test.cpp + ) + target_include_directories(CoreVideoPrivilegeNoticeTest PRIVATE + "${CMAKE_CURRENT_SOURCE_DIR}/src" + ) + add_test(NAME CoreVideoPrivilegeNotice + COMMAND CoreVideoPrivilegeNoticeTest) + # IPC line-I/O framing and write-failure semantics (POSIX socket-based test). if(NOT WIN32) add_executable(CoreVideoIpcLineIoTest diff --git a/src/zoom-dock.cpp b/src/zoom-dock.cpp index 7edc8d4f..4d307820 100644 --- a/src/zoom-dock.cpp +++ b/src/zoom-dock.cpp @@ -698,6 +698,15 @@ ZoomDock::ZoomDock(QWidget *parent) engine_layout->addWidget(m_stop_engine_btn); vLayout->addWidget(engine_group); + // Record-privilege handshake notice. Hidden until (and unless) the engine + // reports the host needs to grant recording permission -- see + // src/zoom-privilege-notice.h for why this is a notice and not the + // "Zoom Join" error modal. Placed right under the engine controls: this + // is specifically about clicking Start Engine again once the host acts. + m_privilege_banner = new CvBanner(CvBannerKind::Warning, QString(), this); + m_privilege_banner->setVisible(false); + vLayout->addWidget(m_privilege_banner); + auto *routing_group = new QGroupBox("Routing", this); auto *routing_layout = new QHBoxLayout(routing_group); routing_layout->setSpacing(8); @@ -828,6 +837,12 @@ ZoomDock::ZoomDock(QWidget *parent) QMessageBox::warning(self, "Zoom Join", text); }, Qt::QueuedConnection); }); + ZoomEngineClient::instance().add_notice_callback(this, [self, alive](const std::string &message) { + QMetaObject::invokeMethod(self, [self, alive, message]() { + if (!alive->load(std::memory_order_acquire)) return; + self->update_privilege_banner(QString::fromStdString(message)); + }, Qt::QueuedConnection); + }); // -- Apply stylesheet last so all properties are set before evaluation ----- setStyleSheet(cv_stylesheet()); @@ -844,6 +859,7 @@ ZoomDock::~ZoomDock() m_join_thread.join(); ZoomEngineClient::instance().remove_roster_callback(this); ZoomEngineClient::instance().remove_error_callback(this); + ZoomEngineClient::instance().remove_notice_callback(this); } void ZoomDock::prepare_shutdown() @@ -859,6 +875,7 @@ void ZoomDock::prepare_shutdown() QObject::disconnect(m_speaker_exclude_combo_2, nullptr, this, nullptr); ZoomEngineClient::instance().remove_roster_callback(this); ZoomEngineClient::instance().remove_error_callback(this); + ZoomEngineClient::instance().remove_notice_callback(this); stop_pending_oauth_join(); if (m_countdown_timer) m_countdown_timer->stop(); @@ -913,6 +930,19 @@ void ZoomDock::show_update_banner(const QString &tag, const QString &html_url) m_update_banner->setVisible(true); } +void ZoomDock::update_privilege_banner(const QString &message) +{ + if (!m_alive->load(std::memory_order_acquire) || !m_privilege_banner) + return; + // Empty means the handshake resolved (raw_media_ready) or was reset by a + // fresh leave/rejoin -- see clear_privilege_notice_and_notify() and the + // "left" handler in zoom-engine-client.cpp. A notice that never clears is + // its own defect, so this path has to be exercised as often as the one + // that shows it. + m_privilege_banner->setText(message); + m_privilege_banner->setVisible(!message.isEmpty()); +} + void ZoomDock::start_pending_oauth_join() { if (m_pending_oauth_join_timer && !m_pending_oauth_join_timer->isActive()) @@ -1050,6 +1080,13 @@ void ZoomDock::update_state_indicator() if (media_active && !m_last_media_active) ZoomOutputManager::instance().resubscribe_all(); m_last_media_active = media_active; + // Resync from the getter as well as the callback (last_error()/ + // is_media_active() above get the same dual treatment): the "left" + // handler clears m_privilege_notice without a separate notify call, so + // this 100ms poll is what actually hides a stale banner after a + // leave/rejoin, not the callback. + update_privilege_banner(QString::fromStdString( + ZoomEngineClient::instance().pending_privilege_notice())); m_join_btn->setEnabled(!in_meeting && !transitioning && !recovering); m_leave_btn->setEnabled(in_meeting || transitioning || recovering); m_leave_btn->setText(in_meeting ? "Leave" : "Cancel"); diff --git a/src/zoom-dock.h b/src/zoom-dock.h index aaf8e909..f9fffdee 100644 --- a/src/zoom-dock.h +++ b/src/zoom-dock.h @@ -47,6 +47,7 @@ class ZoomDock : public QWidget { void update_recovery_panel(); void update_credentials_banner(); void show_update_banner(const QString &tag, const QString &html_url); + void update_privilege_banner(const QString &message); void start_pending_oauth_join(); void stop_pending_oauth_join(); @@ -76,6 +77,10 @@ class ZoomDock : public QWidget { // Non-intrusive "a newer CoreVideo build is available" notice CvBanner *m_update_banner = nullptr; QString m_update_url; + // Record-privilege handshake notice (src/zoom-privilege-notice.h): a + // non-error banner telling the operator the host needs to grant recording + // permission, in place of the modal QMessageBox this used to pop. + CvBanner *m_privilege_banner = nullptr; // Join controls QLineEdit *m_meeting_id = nullptr; diff --git a/src/zoom-engine-client.cpp b/src/zoom-engine-client.cpp index 39dd40a5..2bdc5d83 100644 --- a/src/zoom-engine-client.cpp +++ b/src/zoom-engine-client.cpp @@ -4,6 +4,7 @@ #include "talkback-key.h" // talkback_session_mic_blocked() -- Law 1 #include "talkback-plan.h" // talkback_dedup_preserve_order() -- Task 5 fix round 1, F4 #include "zoom-join-decision.h" +#include "zoom-privilege-notice.h" // record-privilege handshake copy/classification #include "zoom-reconnect.h" #include "zoom-sdk-init-retry.h" #include @@ -1361,6 +1362,34 @@ void ZoomEngineClient::set_error_and_notify(const std::string &message) for (const auto &cb : callbacks) cb(message); } +void ZoomEngineClient::set_privilege_notice_and_notify(const std::string &message) +{ + std::vector callbacks; + { + std::lock_guard lk(m_mtx); + m_privilege_notice = message; + for (const auto &entry : m_notice_callbacks) + if (entry.second) callbacks.push_back(entry.second); + } + // m_mtx is released here — a callback may call back into this client, same + // reason as set_error_and_notify() above. + for (const auto &cb : callbacks) cb(message); +} + +void ZoomEngineClient::clear_privilege_notice_and_notify() +{ + std::vector callbacks; + { + std::lock_guard lk(m_mtx); + if (m_privilege_notice.empty()) + return; // nothing pending -- most raw_media_ready reports land here. + m_privilege_notice.clear(); + for (const auto &entry : m_notice_callbacks) + if (entry.second) callbacks.push_back(entry.second); + } + for (const auto &cb : callbacks) cb(std::string()); +} + void ZoomEngineClient::reader_loop() { std::string line; @@ -1416,9 +1445,15 @@ void ZoomEngineClient::handle_event(const std::string &line) while (m_debug_events.size() > 300) m_debug_events.pop_front(); } - if (stage == "raw_media_ready") + if (stage == "raw_media_ready") { m_media_active.store(true, std::memory_order_release); - else if (stage == "raw_media_stopped") + // raw_media_ready is the engine event that means the record- + // privilege handshake (if one was in progress) just succeeded -- + // see src/zoom-privilege-notice.h. A notice that never clears is + // its own defect, so clear it on every successful start, not just + // ones that followed a notice. + clear_privilege_notice_and_notify(); + } else if (stage == "raw_media_stopped") m_media_active.store(false, std::memory_order_release); return; } @@ -1584,6 +1619,13 @@ void ZoomEngineClient::handle_event(const std::string &line) talkback_nomination_reset(m_talkback_nomination_status); m_talkback_nomination_pending = TalkbackNominationPending{}; talkback_presence_reset(m_talkback_channel_presence); + // A leave/rejoin starts a fresh handshake; a notice from the + // previous meeting must not survive into it. No separate notify + // call needed -- like the resets above, this is picked up by the + // dock's own poll (pending_privilege_notice()) on its next tick, + // same as this "left" handler has never notified roster callbacks + // either (see this function's own doc comment). + m_privilege_notice.clear(); keep_failed = !m_last_error.empty() && !m_user_leaving.load(std::memory_order_acquire); } @@ -1691,19 +1733,26 @@ void ZoomEngineClient::handle_event(const std::string &line) // lands moments later (121ms observed live 2026-08-20) followed by // raw_media_ready. Routing it into the join-failure tail flipped a // healthy joined session to Failed, which then gated start_engine, - // resubscription and recovery for the rest of the session. Surface - // the message; never vote against the meeting. + // resubscription and recovery for the rest of the session. Never vote + // against the meeting -- unchanged from before this fix. + // + // What changed (live defect, 2026-09-05): this used to ALSO set + // m_last_error and fire the error-callback list, which is what pops + // the "Zoom Join" QMessageBox -- so a session working exactly as + // designed showed the operator a modal reading "raw recording + // failed". A pending grant is a STATE, not a failure, so it now goes + // to the separate notice-callback list instead (see NoticeCallback's + // doc comment in zoom-engine-client.h) and m_last_error is left + // untouched -- other code (e.g. the "left" handler's keep_failed + // check) reads that field as "the session actually failed", which + // this is not. src/zoom-privilege-notice.h picks the operator-facing + // copy and tells a first request apart from a repeat one (the engine + // only asks the host once per meeting; every later report is the same + // still-pending wait, distinguished only by its "detail" text). if (emsg == "raw_media_start_failed" && obj.value("privilege_requested").toBool()) { - std::vector error_callbacks; - const std::string error_message = zoom_error_message(obj); - { - std::lock_guard lk(m_mtx); - m_last_error = error_message; - for (const auto &entry : m_error_callbacks) - if (entry.second) error_callbacks.push_back(entry.second); - } - for (const auto &cb : error_callbacks) cb(error_message); + const std::string detail = obj.value("detail").toString().toStdString(); + set_privilege_notice_and_notify(zoom_privilege_notice_text(detail)); return; } if (emsg == "video_subscribe_failed") { @@ -1907,6 +1956,12 @@ std::string ZoomEngineClient::last_error() const return m_last_error; } +std::string ZoomEngineClient::pending_privilege_notice() const +{ + std::lock_guard lk(m_mtx); + return m_privilege_notice; +} + void ZoomEngineClient::clear_last_error() { std::vector callbacks; @@ -1962,6 +2017,21 @@ void ZoomEngineClient::remove_error_callback(void *key) m_error_callbacks.erase(key); } +void ZoomEngineClient::add_notice_callback(void *key, NoticeCallback cb) +{ + std::lock_guard lk(m_mtx); + if (cb) + m_notice_callbacks[key] = std::move(cb); + else + m_notice_callbacks.erase(key); +} + +void ZoomEngineClient::remove_notice_callback(void *key) +{ + std::lock_guard lk(m_mtx); + m_notice_callbacks.erase(key); +} + bool ZoomEngineClient::write_json(const std::string &json) { std::lock_guard lk(m_mtx); diff --git a/src/zoom-engine-client.h b/src/zoom-engine-client.h index 2c6ede87..7ccb5500 100644 --- a/src/zoom-engine-client.h +++ b/src/zoom-engine-client.h @@ -291,6 +291,13 @@ class ZoomEngineClient { bool is_media_active() const { return m_media_active.load(std::memory_order_acquire); } std::string last_error() const; void clear_last_error(); + // Empty when no record-privilege notice is pending. See + // src/zoom-privilege-notice.h for what this state means and + // add_notice_callback() below for how it is pushed. Exposed as a getter + // too, mirroring last_error(), so a dock can resync on its own poll tick + // (update_state_indicator()'s 100ms timer) rather than depend solely on + // catching the callback. + std::string pending_privilege_notice() const; uint32_t active_speaker_id() const; uint32_t raw_active_speaker_id() const; std::vector roster() const; @@ -321,6 +328,19 @@ class ZoomEngineClient { using ErrorCallback = std::function; void add_error_callback(void *key, ErrorCallback cb); void remove_error_callback(void *key); + // A SEPARATE callback from ErrorCallback above, deliberately. Every + // existing (and future) error-callback subscriber's contract is "this + // means show the operator a failure" -- the dock's own registration pops + // a QMessageBox unconditionally whenever the message is non-empty. The + // record-privilege wait (src/zoom-privilege-notice.h) is not a failure, + // so it never enters that list at all; the invariant "this can never + // reach a QMessageBox" is enforced by which list a report goes to, not by + // a severity flag every subscriber has to remember to check. Empty + // message means "clear the notice", exactly like ErrorCallback's empty + // message convention (see clear_last_error()). + using NoticeCallback = std::function; + void add_notice_callback(void *key, NoticeCallback cb); + void remove_notice_callback(void *key); private: // Starts the two media dispatch lanes; see MediaDispatchLane below. @@ -348,6 +368,16 @@ class ZoomEngineClient { // this client (the dock reads last_error() from one) and m_mtx is not // recursive. void set_error_and_notify(const std::string &message); + // Same snapshot-then-dispatch shape as set_error_and_notify(), over + // m_privilege_notice / m_notice_callbacks instead of m_last_error / + // m_error_callbacks -- see NoticeCallback's doc comment above for why + // these are separate lists rather than one. + void set_privilege_notice_and_notify(const std::string &message); + // Clears m_privilege_notice and dispatches an empty message, but only if + // a notice was actually pending -- called from the "raw_media_ready" + // debug stage on every media start, most of which never had a notice to + // clear. + void clear_privilege_notice_and_notify(); // Runs on the monitor thread ONLY (see m_init_teardown_pending). Stops the // engine process, then surfaces the operator-facing failure. void fail_after_init_retries_exhausted(); @@ -417,7 +447,12 @@ class ZoomEngineClient { std::unordered_map m_sources; std::unordered_map m_roster_callbacks; std::unordered_map m_error_callbacks; + std::unordered_map m_notice_callbacks; std::string m_last_error; + // Empty when no record-privilege notice is pending. Deliberately NEVER + // written to/from m_last_error -- see pending_privilege_notice()'s doc + // comment and NoticeCallback's above. Guarded by m_mtx like m_last_error. + std::string m_privilege_notice; // Raw compact JSON of the most recent talkback_probe stage line; see // talkback_probe_status() above. std::string m_talkback_probe_status; diff --git a/src/zoom-privilege-notice.h b/src/zoom-privilege-notice.h new file mode 100644 index 00000000..219f96d8 --- /dev/null +++ b/src/zoom-privilege-notice.h @@ -0,0 +1,77 @@ +#pragma once + +#include + +// The record-privilege handshake, and why the first half of it is not an +// error (live defect, 2026-09-05: starting the engine popped a modal "raw +// recording failed" dialog on a session that was working exactly as +// designed). +// +// Zoom's canStartRawRecording() comes back NoPermission for a participant who +// has not been granted local recording; the engine's own response is to call +// requestLocalRecordingPrivilege() and report +// "cmd":"error","msg":"raw_media_start_failed","privilege_requested":true -- +// see engine/src/main-macos.mm's handle_start_media(). The host sees a Zoom +// prompt, and once they grant it the SDK's own delegate callback restarts raw +// media and the engine reports "raw_media_ready". Nothing failed; this is the +// NORMAL first half of the handshake, and the operator's fix is just to click +// Start Engine again once the host has granted it. +// +// ZoomEngineClient::handle_event() already refuses to route this report into +// the join-failure/reconnect machinery -- a live incident once flipped a +// healthy joined session to Failed over exactly this report, which then gated +// start_engine, resubscription and recovery for the rest of the session (see +// the comment on that branch). What it got wrong downstream of that guard is +// the subject of this fix: it still set m_last_error and fired every +// registered error callback, which is what pops the "Zoom Join" +// QMessageBox -- the modal this header exists to stop. +// +// This header decides only the operator-facing TEXT and which of the two +// wire shapes a report is; the notice-vs-error plumbing (storage, callbacks, +// clearing on raw_media_ready) lives in zoom-engine-client.h/.cpp, which need +// the Qt JSON types this header stays free of, so the classification below +// can be host-tested without Qt or libobs. +// +// The engine's own retry loop (main-macos.mm's g_privilege_requested) asks +// the host only ONCE per meeting; every later cannot_start_raw_recording +// report is just a re-report of the SAME still-pending wait, and the engine +// marks it with a different "detail" string ("...was already requested and +// has not been granted.") rather than a separate machine-readable field. That +// substring is the only wire signal telling the two apart, so the +// classification below keys on it. + +// Whether `detail` (the engine's raw_media_start_failed "detail" field) says +// the privilege was already asked for this meeting and the host has not +// granted it yet -- i.e. a REPEAT report of the same still-pending wait, not +// a fresh request just sent to the host. +inline bool zoom_privilege_already_requested(const std::string &detail) +{ + return detail.find("already requested") != std::string::npos; +} + +// Operator-facing copy for each half of the handshake. Short and actionable +// on purpose (the owner's ask, live 2026-09-05): name the ACTION, not the +// error code. "Start Engine" is named literally because that is the exact +// button label the operator has to press again. +inline const char *zoom_privilege_notice_first_request() +{ + return "Waiting for the meeting host to approve recording. Once they " + "approve it, click Start Engine again."; +} + +// Firmer than the first-request copy on purpose: a repeat report means the +// host has not acted yet, so this is not a fresh ask -- it says so. +inline const char *zoom_privilege_notice_still_pending() +{ + return "Still waiting on the host to approve recording -- nothing will " + "start until they do. Ask them to approve the Zoom prompt now, " + "then click Start Engine again."; +} + +// Picks the right copy for a raw_media_start_failed report's "detail" text. +inline std::string zoom_privilege_notice_text(const std::string &detail) +{ + return zoom_privilege_already_requested(detail) + ? zoom_privilege_notice_still_pending() + : zoom_privilege_notice_first_request(); +} diff --git a/tests/zoom-privilege-notice-test.cpp b/tests/zoom-privilege-notice-test.cpp new file mode 100644 index 00000000..3a64feb2 --- /dev/null +++ b/tests/zoom-privilege-notice-test.cpp @@ -0,0 +1,106 @@ +// Unit test for the record-privilege handshake notice (src/zoom-privilege- +// notice.h), the fix for the 2026-09-05 live defect: starting the engine +// popped a "Zoom Join" error modal for the NORMAL first half of Zoom's +// record-privilege handshake (canStartRawRecording -> NoPermission -> the +// engine asks the host -> raw_media_ready once granted). Pure C++, no Qt/ +// OBS/Zoom SDK dependency -- the actual notice-vs-error plumbing lives in +// ZoomEngineClient and cannot be host-tested the same way (see this +// project's CLAUDE.md on that class), so this file pins the one part of the +// fix that CAN be driven without it: which copy the operator sees, and for +// which report shape. +#include "zoom-privilege-notice.h" + +#include +#include + +static int g_failures = 0; + +static void check(bool cond, const std::string &name) +{ + if (!cond) { + std::cerr << "FAIL: " << name << "\n"; + ++g_failures; + } +} + +int main() +{ + // ── The two exact wire strings the engine actually emits ──────────────── + // Copied verbatim from engine/src/main-macos.mm's handle_start_media() so + // a change to either engine string that stops matching the classifier's + // substring check fails HERE, not silently in the field. + const std::string first_request_detail = + "Raw recording needs local-recording permission. The meeting host " + "must allow this participant to record."; + const std::string still_pending_detail = + "Local-recording permission was already requested and has not been " + "granted."; + + // ── Classification routes each shape correctly ─────────────────────────── + check(!zoom_privilege_already_requested(first_request_detail), + "first request: not classified as already-requested"); + check(zoom_privilege_already_requested(still_pending_detail), + "still pending: classified as already-requested"); + + check(zoom_privilege_notice_text(first_request_detail) == + zoom_privilege_notice_first_request(), + "first request: routes to first-request copy"); + check(zoom_privilege_notice_text(still_pending_detail) == + zoom_privilege_notice_still_pending(), + "still pending: routes to still-pending copy"); + + // ── Absent/unrecognized detail fails toward the FIRST-request copy ────── + // Same tolerance rule this codebase applies elsewhere (e.g. talkback's + // "ABSENT MEANS NOT BLOCKED" for the "mic" field): a missing or unknown + // detail must not be read as "the host already ignored a request", or an + // engine that omits/changes this field would show the firmer, wrong copy + // on every very first attempt. + check(!zoom_privilege_already_requested(""), + "empty detail: not classified as already-requested"); + check(zoom_privilege_notice_text("") == zoom_privilege_notice_first_request(), + "empty detail: routes to first-request copy"); + check(!zoom_privilege_already_requested("some unrelated future detail text"), + "unrecognized detail: not classified as already-requested"); + + // ── The two notices are distinct copy, not the same string twice ──────── + // If a future edit collapsed both branches to identical text, the "handle + // both, and the second warrants firmer copy" requirement this fix was + // written against would silently stop being true. + const std::string first_copy = zoom_privilege_notice_first_request(); + const std::string still_copy = zoom_privilege_notice_still_pending(); + check(!first_copy.empty(), "first-request copy non-empty"); + check(!still_copy.empty(), "still-pending copy non-empty"); + check(first_copy != still_copy, "the two notices are distinct copy"); + + // ── Copy names the ACTION, not the error code ──────────────────────────── + // The owner's ask, verbatim: "capture this error and not display an + // error... you just hit it again". Neither string may leak a raw SDK + // error code digit, and both must name the actual button the operator + // has to press. + auto has_digit = [](const std::string &s) { + return s.find_first_of("0123456789") != std::string::npos; + }; + check(!has_digit(first_copy), "first-request copy: no error code digits"); + check(!has_digit(still_copy), "still-pending copy: no error code digits"); + check(first_copy.find("Start Engine") != std::string::npos, + "first-request copy: names the Start Engine action"); + check(still_copy.find("Start Engine") != std::string::npos, + "still-pending copy: names the Start Engine action"); + + // ── The still-pending copy is the firmer one ───────────────────────────── + // A repeat report means the host has not acted yet; the copy should say + // so rather than reading like a brand-new ask. Pinned narrowly (not by + // wording, which is free to change) as: it says the host has not granted + // it / nothing will start, which the first-request copy does not. + check(still_copy.find("Still waiting") != std::string::npos || + still_copy.find("nothing will") != std::string::npos, + "still-pending copy: reads as a repeat wait, not a fresh ask"); + + if (g_failures == 0) { + std::cout << "zoom-privilege-notice-test: all checks passed\n"; + return 0; + } + std::cerr << "zoom-privilege-notice-test: " << g_failures + << " check(s) failed\n"; + return 1; +}