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
29 changes: 29 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,35 @@ DIFFERENT clocks on macOS (see the struct comment) — `audio_latency_us` is
untrustworthy there until that is reconciled. Kill a mid-meeting engine only
after checking `{"cmd":"status"}` — it IS the meeting session.

**Talkback does not exist on macOS**, and the dock says so rather than failing
quietly. `engine-talkback.cpp` is in `ENGINE_SOURCES`, which only the Windows
engine target uses; the macOS engine is `main-macos.mm` and never compiles it.
The dock is cross-platform and builds either way, so without a gate it is a
panel whose every control sends a command nothing answers. One constant,
`kTalkbackPlatformSupported` (`src/zoom-talkback-panel.cpp`), feeds
`TalkbackDockSessionView::platform_supported`,
`TalkbackDockKeyContext::platform_supported`, and the Assign/probe buttons'
own `setEnabled`. Three rulings worth keeping. The `#if defined(__APPLE__)`
lives at that ONE call site and the decision crosses into
`talkback-dock-state.h` as a plain bool, because that header is Qt/OBS-free and
compiles everywhere — so the macOS rendering is pinned by a Windows or Linux CI
run, which is the only way a macOS-only branch is tested by anything this
project runs. `TalkbackDockBannerState::Unavailable` is checked FIRST in
`talkback_dock_banner()` and RETURNS, which is what keeps the "coming to macOS"
wording out of the ON AIR strip structurally — with no talkback engine nothing
can key, so nothing can be live, and Unavailable and Live are unreachable
together by construction rather than by promise. In the key chain it sits
directly below `held_here` and nowhere else: never disabling a button the
operator is holding is the stronger law, and honouring it here costs nothing,
because there is no key to hold. The layout instrument
(`COREVIDEO_TALKBACK_LAYOUT_TEST`) is deliberately NOT gated — its job is to
render every state including the tallest live banner, and gating it would
collapse it to the Unavailable strip on the very platform a developer is most
likely running it on. Mutation-proved in
`tests/talkback-dock-state-test.cpp`: disabling either half of the gate fails
its own assertions, and a default-constructed context must stay supported or
the gate turns the feature off on Windows.

## Build, test, install

Full toolchain setup is in `README.md` (§Building). Day-to-day, an existing
Expand Down
39 changes: 39 additions & 0 deletions src/talkback-dock-state.h
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ struct TalkbackDockOpenKey {

// The state the dock has about keying at the moment it rebuilds its buttons.
struct TalkbackDockKeyContext {
// See TalkbackDockSessionView::platform_supported. Refused AHEAD of
// engine/meeting/source, all three of which are also false on a macOS box
// -- each would be a true statement that sends the operator to debug
// something that is not the problem.
bool platform_supported = true;
bool engine_running = false;
bool in_meeting = false;
// An OBS audio source is selected. Without one key_on() cannot open a tap
Expand Down Expand Up @@ -122,6 +127,14 @@ inline std::vector<TalkbackDockKeyButton> talkback_dock_key_buttons(
ctx.open.target == target;
if (held_here) {
b.enabled = true;
} else if (!ctx.platform_supported) {
// Below held_here and nowhere else. Never disabling a button the
// operator is holding is the stronger law (a disabled QPushButton
// drops `down` without emitting released(), which strands the key)
// and it costs nothing to honour here: with no talkback engine
// there is no key to hold, so this ordering is unreachable rather
// than a trade.
b.reason = "talkback is not available on macOS yet";
} else if (ctx.open.open && !ctx.open.dock_owned) {
// m3: not "another talkback key is open" -- naming the surface is
// what tells the operator that pressing here cannot help and that
Expand Down Expand Up @@ -851,6 +864,16 @@ enum class TalkbackDockBannerState {
LiveMicBlocked,
// A key was refused, or the last one that closed had failed.
Refused,
// This build's engine has no talkback at all (macOS: engine-talkback.cpp
// is only in ENGINE_SOURCES, which the main-macos.mm target does not use).
// The dock is cross-platform and compiles anyway, so without this it is a
// panel of controls that send commands nothing on the other end answers.
//
// It is checked FIRST and returns, which is what keeps the roadmap wording
// out of the ON AIR strip STRUCTURALLY rather than by promise: on a build
// with no talkback engine nothing can key, so nothing can be live, and
// Unavailable and Live are unreachable together by construction.
Unavailable,
};

struct TalkbackDockBanner {
Expand All @@ -862,6 +885,12 @@ struct TalkbackDockBanner {
};

struct TalkbackDockSessionView {
// False on a build whose engine has no talkback (macOS). A FIELD, not an
// #ifdef in this header: this file is Qt/OBS-free and compiles on every
// platform, so the macOS rendering is pinned by a Windows or Linux CI run
// -- otherwise a macOS-only branch is tested by nothing this project runs.
// The caller sets it (see zoom-talkback-panel.cpp).
bool platform_supported = true;
bool key_open = false;
std::string target;
bool engine_live = false;
Expand Down Expand Up @@ -920,6 +949,16 @@ inline std::string talkback_dock_recovery_label(const std::string &recover)
inline TalkbackDockBanner talkback_dock_banner(const TalkbackDockSessionView &s)
{
TalkbackDockBanner b;
// FIRST, and it returns. Everything below describes a key on an engine
// that can carry one; this build's cannot. See TalkbackDockBannerState::
// Unavailable for why the precedence is the safety property and not a
// style choice.
if (!s.platform_supported) {
b.state = TalkbackDockBannerState::Unavailable;
b.headline = "Talkback is coming to macOS";
b.detail = "Talkback is Windows-only in this release.";
return b;
}
if (!s.key_open) {
b.headline = "Off air";
if (!s.engine_reason.empty() && !s.engine_live) {
Expand Down
43 changes: 41 additions & 2 deletions src/zoom-talkback-panel.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,22 @@
// zoom-iso-panel.cpp both open with 8 px and space at 6-8). kGroupPad is gone
// with the group boxes it padded: the intercom grid IS the panel, and wrapping
// it in a captioned frame only spent height saying so.
// Does THIS BUILD's engine carry talkback at all? engine-talkback.cpp is in
// ENGINE_SOURCES, which only the Windows engine target uses; the macOS engine
// is main-macos.mm and never compiles it. This panel is cross-platform and
// builds either way, so without this every control here would send a command
// nothing on the other end answers.
//
// The #if lives HERE and not in talkback-dock-state.h on purpose: that header
// is Qt/OBS-free and compiles everywhere, so keeping the decision a plain bool
// field lets a Windows or Linux CI run pin the macOS rendering. A macOS-only
// #ifdef branch would be tested by nothing this project runs.
#if defined(__APPLE__)
static constexpr bool kTalkbackPlatformSupported = false;
#else
static constexpr bool kTalkbackPlatformSupported = true;
#endif

static constexpr int kDockMargin = 10; // panel edge to content
static constexpr int kSectionGap = 14; // between the panel's own sections
static constexpr int kInnerGap = 8; // between controls in one section
Expand Down Expand Up @@ -182,6 +198,13 @@ static const char *banner_state_name(TalkbackDockBannerState state)
case TalkbackDockBannerState::LiveMicBlocked: return "livemuted";
case TalkbackDockBannerState::Waiting: return "waiting";
case TalkbackDockBannerState::Refused: return "refused";
// This build's engine has no talkback, so nothing is keyed and nothing is
// pending: it is an idle strip and takes the idle styling. Named rather
// than left to fall through, both to match every other value here and
// because a new style state with no rule in cv_stylesheet() would render
// unstyled -- the headline carries the meaning, the colour must not
// suggest something is happening.
case TalkbackDockBannerState::Unavailable: return "off";
case TalkbackDockBannerState::Off: break;
}
return "off";
Expand Down Expand Up @@ -721,7 +744,7 @@ void ZoomTalkbackPanel::refresh_probe()
// placeholder item carries an invalid/empty data(), which
// currentData().toString() reports as empty.
m_probe_btn->setEnabled(
in_meeting &&
kTalkbackPlatformSupported && in_meeting &&
!m_probe_participant_combo->currentData().toString().isEmpty());
}
if (m_probe_status_label) {
Expand Down Expand Up @@ -895,7 +918,11 @@ void ZoomTalkbackPanel::refresh()
// Both conditions, not just InMeeting: talkback_nominate() is a silent
// no-op when the engine pipe is not up, which is why the control API
// acks "engine_not_running" separately from "not_in_meeting".
m_nominate_btn->setEnabled(engine_running && in_meeting);
// ...and the platform, ahead of both: on a build whose engine has no
// talkback there is nothing to assign channels on, and offering the
// press would be the same empty gesture as an enabled key.
m_nominate_btn->setEnabled(kTalkbackPlatformSupported &&
engine_running && in_meeting);
// An empty nomination is a deliberate denominate (the engine's
// nominate() documents it as such), not a mistake to block -- but it
// must not be labelled as setting channels up.
Expand Down Expand Up @@ -994,6 +1021,7 @@ void ZoomTalkbackPanel::refresh()
// control under it would come to disagree.
const auto session = engine.talkback_session_status();
TalkbackDockSessionView view;
view.platform_supported = kTalkbackPlatformSupported;
view.key_open = key_open;
view.target = open_target;
view.engine_live = session.live;
Expand All @@ -1011,6 +1039,7 @@ void ZoomTalkbackPanel::refresh()

// -- The grid --------------------------------------------------------------
TalkbackDockKeyContext ctx;
ctx.platform_supported = kTalkbackPlatformSupported;
ctx.engine_running = engine_running;
ctx.in_meeting = in_meeting;
ctx.source_chosen = !source_name.isEmpty();
Expand Down Expand Up @@ -1289,6 +1318,16 @@ void ZoomTalkbackPanel::populate_layout_test()

// The banner in its LIVE state, naming the person whose cell is painted ON
// AIR, with the member tally -- the tallest thing the strip ever renders.
//
// DELIBERATELY NOT GATED on kTalkbackPlatformSupported, unlike the two
// product call sites above. This is the layout instrument
// (COREVIDEO_TALKBACK_LAYOUT_TEST), whose whole job is to render every
// state at once so the real panel can be eyeballed in the real OBS with no
// meeting; taking the gate here would collapse it to the Unavailable strip
// on the one platform where a developer is most likely to be running it,
// and the tallest banner -- the thing the layout is actually verified
// against -- would stop being reachable. Nothing here can reach the engine
// regardless: the env-var mode refuses all three paths to it.
TalkbackDockSessionView view;
view.key_open = true;
view.target = talkback_layout_test_live_target(cells);
Expand Down
86 changes: 86 additions & 0 deletions tests/talkback-dock-state-test.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,92 @@ int main()
"the backstop fired with no key open at all");
}

// ── TALKBACK IS NOT ON THE macOS ENGINE (2026-09-04) ───────────────────
//
// engine-talkback.cpp is in ENGINE_SOURCES, which only the Windows engine
// target uses; the macOS engine is main-macos.mm and never compiles it.
// The DOCK is cross-platform and compiles either way, so on macOS it is a
// panel whose every control would send a command nothing answers.
//
// The gate is a field rather than an #ifdef in this header on purpose:
// this file is Qt/OBS-free and builds everywhere, so the macOS behaviour
// is pinned by a Windows or Linux CI run -- which is the only way a
// macOS-only code path gets tested at all on this project's CI.
{
// The banner. Unsupported wins over EVERY other state, including a key
// the engine has confirmed live: it is checked first and returns, so
// Live and Unavailable cannot both be reachable. That is what makes
// "the roadmap line can never sit in the ON AIR strip" structural
// rather than a promise -- on macOS nothing can key, so nothing can be
// live, and the precedence proves it instead of relying on it.
TalkbackDockSessionView s;
s.platform_supported = false;
s.key_open = true;
s.target = "Sarah";
s.engine_live = true;
const auto b = talkback_dock_banner(s);
check(b.state == TalkbackDockBannerState::Unavailable,
"an unsupported platform did not say so in the banner");
check(b.state != TalkbackDockBannerState::Live,
"a platform with no talkback engine was shown ON AIR -- the "
"banner's one job is that red means the director is audible");
check(contains(b.headline, "macOS"),
"the unavailable banner does not name the platform");
check(!contains(b.headline, "ON AIR"),
"the unavailable banner kept the ON AIR wording");
}
{
// ...and it still says so with nothing keyed, which is the state a
// macOS operator actually sees.
TalkbackDockSessionView s;
s.platform_supported = false;
const auto b = talkback_dock_banner(s);
check(b.state == TalkbackDockBannerState::Unavailable,
"an idle unsupported panel fell back to plain Off air");
check(!b.detail.empty(),
"the unavailable banner gave no detail line to explain itself");
}
{
// The keys. Refused for the PLATFORM, ahead of engine/meeting/source
// -- all three of which are also false on a macOS box and would each
// send the operator to debug something that is not the problem.
TalkbackNominationPlan plan;
plan.requested = {"Sarah"};
TalkbackDockKeyContext ctx;
ctx.platform_supported = false;
ctx.engine_running = true;
ctx.in_meeting = true;
ctx.source_chosen = true;
const auto buttons = talkback_dock_key_buttons(plan, ctx);
check(!buttons.empty(), "the unsupported dock produced no buttons");
for (const auto &b : buttons) {
check(!b.enabled,
"a key was offered on a platform with no talkback engine");
check(contains(b.reason, "macOS"),
"the refusal does not name the platform, so the operator is "
"sent to debug the engine instead");
}
}
{
// The gate must not fire on the platform that HAS talkback: a default
// context is supported, or this whole feature turns itself off.
TalkbackNominationPlan plan;
plan.requested = {"Sarah"};
TalkbackDockKeyContext ctx;
ctx.engine_running = true;
ctx.in_meeting = true;
ctx.source_chosen = true;
const auto buttons = talkback_dock_key_buttons(plan, ctx);
bool any_enabled = false;
for (const auto &b : buttons) any_enabled = any_enabled || b.enabled;
check(any_enabled,
"the platform gate defaults to unsupported and disabled a "
"working Windows dock");
TalkbackDockSessionView s;
check(talkback_dock_banner(s).state == TalkbackDockBannerState::Off,
"the platform gate defaults to unsupported in the banner too");
}

if (failures == 0) std::cout << "talkback-dock-state-test: all checks passed\n";
return failures == 0 ? 0 : 1;
}
Loading