Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
11 changes: 11 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions src/zoom-dock.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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());
Expand All @@ -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()
Expand All @@ -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();
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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");
Expand Down
5 changes: 5 additions & 0 deletions src/zoom-dock.h
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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;
Expand Down
96 changes: 83 additions & 13 deletions src/zoom-engine-client.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 <QJsonArray>
Expand Down Expand Up @@ -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<NoticeCallback> callbacks;
{
std::lock_guard<std::mutex> 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<NoticeCallback> callbacks;
{
std::lock_guard<std::mutex> 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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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<ErrorCallback> error_callbacks;
const std::string error_message = zoom_error_message(obj);
{
std::lock_guard<std::mutex> 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") {
Expand Down Expand Up @@ -1907,6 +1956,12 @@ std::string ZoomEngineClient::last_error() const
return m_last_error;
}

std::string ZoomEngineClient::pending_privilege_notice() const
{
std::lock_guard<std::mutex> lk(m_mtx);
return m_privilege_notice;
}

void ZoomEngineClient::clear_last_error()
{
std::vector<ErrorCallback> callbacks;
Expand Down Expand Up @@ -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<std::mutex> 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<std::mutex> lk(m_mtx);
m_notice_callbacks.erase(key);
}

bool ZoomEngineClient::write_json(const std::string &json)
{
std::lock_guard<std::mutex> lk(m_mtx);
Expand Down
35 changes: 35 additions & 0 deletions src/zoom-engine-client.h
Original file line number Diff line number Diff line change
Expand Up @@ -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<ParticipantInfo> roster() const;
Expand Down Expand Up @@ -321,6 +328,19 @@ class ZoomEngineClient {
using ErrorCallback = std::function<void(const std::string &message)>;
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(const std::string &message)>;
void add_notice_callback(void *key, NoticeCallback cb);
void remove_notice_callback(void *key);

private:
// Starts the two media dispatch lanes; see MediaDispatchLane below.
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -417,7 +447,12 @@ class ZoomEngineClient {
std::unordered_map<std::string, SourceCallbacks> m_sources;
std::unordered_map<void *, RosterCallback> m_roster_callbacks;
std::unordered_map<void *, ErrorCallback> m_error_callbacks;
std::unordered_map<void *, NoticeCallback> 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;
Expand Down
Loading
Loading