From 766991cf6ab7bffeb76279ed4017927162343e98 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Fri, 31 Jul 2026 22:35:48 +0200 Subject: [PATCH] feat(apple): the mic has an off switch you can reach mid-stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the only way to stop sending the room was to end the session and turn "Send microphone to the host" off in Settings — a per-app setting for something that is really a per-conversation act. The mic now mutes from inside the stream: a button on the HUD card, the Stream menu's ⌃⌥⇧A (macOS menu bar and iPad hardware keyboards), the same chord while input is captured (both platforms detect it in InputCapture, where the reserved ⌃⌥⇧Q/D/S already live — ⌃⌥⇧M is the mouse-model flip, cross-client, so the mic gets A), and on iPhone/iPad a mic disc beside the touch exit for the stats tiers whose HUD carries no buttons. Muting is local and instant: it gates capture on this device, the host is never asked and never told. The muted state gets its own badge over the stream — independent of the stats overlay, because "am I muted?" is not a statistic and the overlay is exactly what a player turns off. The badge is also the way back: tapping it unmutes, which is the guaranteed path for a touch user who muted with the overlay off. Mute is session state and is deliberately not persisted. Every stream starts live if the mic is enabled at all, rather than carrying a mute nobody remembers making into a call three days later. The mechanism is the one wave 1 built. `SessionAudio.setMicMuted` — which mutes the voice processor's input on the combined engine and pauses the capture engine on the split one — stays the single muting path; what changes is that it now takes an EFFECTIVE mute the session composes from its two reasons: the user's mute and the background keep-alive's privacy mute. Neither can clear the other, so a user who muted before pocketing their phone comes back still muted, and backgrounding no longer un-mutes anyone on return. It also latches the state, so a mute made while the microphone permission prompt is still open lands on the engine that grant creates instead of being lost. The control is offered only where there is something to mute: the session's resolved `micEnabled` (a profile can turn the mic on or off), a platform with an app-accessible input (never tvOS), and a TCC grant the OS hasn't refused. Absent rather than greyed on the HUD and the touch discs, greyed on the menu, and the macOS start-of-stream banner only teaches ⌃⌥⇧A when the session actually sends a microphone. Co-Authored-By: Claude Fable 5 --- .../Sources/PunktfunkClient/ContentView.swift | 124 ++++++++++++------ .../Session/SessionModel.swift | 65 ++++++++- .../Session/StreamCommands.swift | 20 ++- .../Session/StreamHUDView.swift | 61 +++++++++ .../PunktfunkKit/Audio/SessionAudio.swift | 41 ++++-- .../PunktfunkKit/Input/InputCapture.swift | 32 ++++- .../PunktfunkKit/Views/StreamView.swift | 6 + .../PunktfunkKit/Views/StreamViewIOS.swift | 6 + .../PunktfunkShared/DefaultsKeys.swift | 6 + 9 files changed, 303 insertions(+), 58 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index e1264200..c28d6add 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -315,7 +315,15 @@ struct ContentView: View { clipboardAvailable: model.connection?.hostSupportsClipboard == true, clipboardOn: model.clipboardEnabled, toggleClipboard: { model.toggleClipboardSync() }, + micAvailable: model.micAvailable, + micMuted: model.micMuted, + toggleMicMute: { model.toggleMicMute() }, disconnect: { model.disconnect() })) + // ⌃⌥⇧A fired while input was CAPTURED (InputCapture's chord path posts it — the menu's + // identical equivalent can't reach a captured stream). Same toggle either way. + .onReceive(NotificationCenter.default.publisher(for: .punktfunkToggleMicMute)) { _ in + model.toggleMicMute() + } #endif #if os(macOS) // Fullscreen only while a session is up (incl. the trust prompt over the blurred stream), @@ -724,31 +732,48 @@ struct ContentView: View { } .animation(.smooth(duration: 0.28), value: statsVerbosity) } - #if os(macOS) || os(tvOS) - // The start-of-stream shortcut banner (Windows-client parity): the platform's - // reserved controls on a glass pill, bottom-centre, for the first 6 seconds of - // every session — independent of the stats HUD, so the keys are discoverable - // even with statistics off. The banner's own task drops it (cancelled cleanly - // if the session view goes away first). On tvOS it carries the ONLY exits — - // Menu/B is swallowed during a session (the `.onExitCommand {}` in the tvOS - // session branch), so the hold gestures must be told to the user. + // The bottom-centre stack: the muted-microphone badge over the start-of-stream + // shortcut banner. ONE overlay for both, so the two can never land on top of each + // other in the seconds where they overlap. .overlay(alignment: .bottom) { - if captureEnabled && showShortcutHint { - Text(Self.shortcutHintText) - .font(.geist(Self.shortcutHintFont, relativeTo: .caption)) - .foregroundStyle(.secondary) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .glassBackground(Capsule()) - .padding(.bottom, 24) - .transition(.opacity) - .task { - try? await Task.sleep(for: .seconds(6)) - withAnimation(.easeOut(duration: 0.6)) { showShortcutHint = false } - } + VStack(spacing: 8) { + #if !os(tvOS) + // Shown for as long as the mic is muted, at every stats tier and with the + // overlay off — see MicMutedBadge. tvOS has no microphone to mute. + if captureEnabled && model.micMuted { + MicMutedBadge { model.setMicMuted(false) } + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + #endif + #if os(macOS) || os(tvOS) + // The start-of-stream shortcut banner (Windows-client parity): the + // platform's reserved controls on a glass pill for the first 6 seconds of + // every session — independent of the stats HUD, so the keys are + // discoverable even with statistics off. The banner's own task drops it + // (cancelled cleanly if the session view goes away first). On tvOS it + // carries the ONLY exits — Menu/B is swallowed during a session (the + // `.onExitCommand {}` in the tvOS session branch), so the hold gestures + // must be told to the user. + if captureEnabled && showShortcutHint { + Text(shortcutHintText) + .font(.geist(Self.shortcutHintFont, relativeTo: .caption)) + .foregroundStyle(.secondary) + .padding(.horizontal, 14) + .padding(.vertical, 8) + .glassBackground(Capsule()) + .transition(.opacity) + .task { + try? await Task.sleep(for: .seconds(6)) + withAnimation(.easeOut(duration: 0.6)) { + showShortcutHint = false + } + } + } + #endif } + .padding(.bottom, 24) + .animation(.easeOut(duration: 0.2), value: model.micMuted) } - #endif #if os(iOS) // Touch users have no menu / ⌘D, so when the HUD's Disconnect button isn't on // screen — the overlay off, or the compact pill (which carries no button) — @@ -766,21 +791,24 @@ struct ContentView: View { .overlay(alignment: .topLeading) { if captureEnabled, statsVerbosity == .compact || (statsVerbosity == .off && showTouchExit) { - Button { model.disconnect() } label: { - Image(systemName: "xmark") - .font(.headline.weight(.semibold)) - .frame(width: 36, height: 36) - // Floating glass disc over the frame (26+, material fallback). - // interactive: the disc IS the tap target, so the glass reacts - // to press. - .glassBackground(Circle(), interactive: true) - // Match the hit region to the visible disc so every tap also - // triggers the interactive-glass press highlight. - .contentShape(Circle()) + HStack(spacing: 10) { + Button { model.disconnect() } label: { touchDisc("xmark") } + .buttonStyle(.plain) + .accessibilityLabel("Disconnect") + // The mic toggle rides the same discs, for the same reason: in these + // tiers the HUD carries no buttons (compact is a stat pill, off is + // nothing), so this is a touch-only user's ONLY way to mute. Absent — + // not greyed — when the session sends no microphone at all. + if model.micAvailable { + Button { model.toggleMicMute() } label: { + touchDisc(model.micMuted ? "mic.slash.fill" : "mic.fill") + } + .buttonStyle(.plain) + .accessibilityLabel( + model.micMuted ? "Unmute microphone" : "Mute microphone") + } } - .buttonStyle(.plain) .padding(12) - .accessibilityLabel("Disconnect") .transition(.opacity) .task { guard statsVerbosity == .off else { return } @@ -794,14 +822,34 @@ struct ContentView: View { } } + #if os(iOS) + /// One touch-control disc: an SF Symbol on a floating glass disc over the frame (26+, + /// material fallback), sized as a comfortable tap target. `interactive`: the disc IS the tap + /// target, so the glass reacts to press, and the hit region is matched to the visible disc so + /// every tap triggers that press highlight. + private func touchDisc(_ symbol: String) -> some View { + Image(systemName: symbol) + .font(.headline.weight(.semibold)) + .frame(width: 36, height: 36) + .glassBackground(Circle(), interactive: true) + .contentShape(Circle()) + } + #endif + #if os(macOS) - private static let shortcutHintText = - "Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats" + /// The reserved combos, told once per session. The mute segment appears only when the session + /// actually sends a microphone — teaching a shortcut for a mic that isn't on would be a lie. + private var shortcutHintText: String { + let base = + "Click the stream to capture · ⌃⌥⇧Q releases the mouse · ⌃⌥⇧D disconnects · ⌃⌥⇧S stats" + return model.micAvailable ? base + " · ⌃⌥⇧A mutes the mic" : base + } private static let shortcutHintFont: CGFloat = 12 #elseif os(tvOS) - private static let shortcutHintText = + private var shortcutHintText: String { "Hold the remote's Back button — or L1+R1+Start+Select on a controller — to disconnect" + " · Touch surface moves the pointer · press clicks · Play/Pause right-clicks" + } private static let shortcutHintFont: CGFloat = 22 // read from the couch #endif diff --git a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift index e3c9a525..f1dab9ca 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/SessionModel.swift @@ -1,6 +1,9 @@ // Session state for the app shell: owns the connection, the input capture, the trust // handshake phase, and the pump-thread → main-actor stats relay. +// AVFoundation: AVCaptureDevice.authorizationStatus (the mic TCC grant behind `micAvailable`) +// and, on tvOS, AVPlayer.eligibleForHDRPlayback (the TV-capability HDR gate). +import AVFoundation import Foundation import os import PunktfunkKit @@ -11,9 +14,6 @@ import SwiftUI #elseif canImport(UIKit) import UIKit #endif -#if os(tvOS) - import AVFoundation // AVPlayer.eligibleForHDRPlayback — the TV-capability HDR gate -#endif /// 1 Hz latency-stage line mirrored to the unified log so the stages can be read WITHOUT the /// on-screen HUD (Console.app, wirelessly on an iPad/Apple TV). The HUD is not a neutral @@ -137,6 +137,14 @@ final class SessionModel: ObservableObject { /// Mirrors StreamView's capture state (it owns the input capture; this drives the /// HUD's "click to capture" / "⌘⎋ releases" hint). @Published var mouseCaptured = false + /// The USER's in-stream mic mute (the HUD button, the Stream menu's ⌃⌥⇧A, the captured-state + /// chord, the iOS mic disc) — session state, deliberately NOT persisted: a mute is for the + /// people in the room right now, so every new session starts live if the mic is on at all. + /// One of the two inputs to the effective mute; `isBackgrounded` is the other, and + /// `applyMicMute` composes them — a user mute survives a trip through the background, and the + /// background's privacy mute never clears the user's choice. Local and instant: it gates + /// capture on this device, nothing is sent to the host. + @Published private(set) var micMuted = false /// Resize overlay (design/midstream-resolution-resize.md — client resize UX): true from the /// instant a Match-window resize starts steering toward a new size until a frame at that size /// decodes (or a safety timeout). Drives the blur+spinner so the unavoidable host-rebuild delay @@ -440,7 +448,7 @@ final class SessionModel: ObservableObject { guard phase == .streaming, let conn = connection, !isBackgrounded else { return } isBackgrounded = true conn.setVideoDropped(true) - audio?.setMicMuted(true) + applyMicMute() // now muted for privacy — on top of the user's own mute, not instead of it // Non-deliberate on fire (keep the host linger) so a user who returns late reconnects fast, // exactly like today's network-drop path. min 1 minute guards a nonsense setting. let minutes = max(1, timeoutMinutes) @@ -465,13 +473,57 @@ final class SessionModel: ObservableObject { backgroundDeadline = nil backgroundTimer?.cancel() backgroundTimer = nil - audio?.setMicMuted(false) + applyMicMute() // back to the user's own choice — which may well still be "muted" if let conn = connection { conn.setVideoDropped(false) conn.requestKeyframe() } } + // MARK: - Microphone mute (in-stream, per session) + + /// Whether this session has a mic uplink there is any point in muting: the mic must be on in + /// the session's RESOLVED settings (a profile can turn it on or off), the platform must have + /// an app-accessible input at all, and the OS must not have refused us one. Drives whether the + /// mute control is offered — a live-looking mute button over a session that sends no + /// microphone would be a lie. Same three conditions `SessionAudio` starts an uplink on + /// (`.notDetermined` counts: the prompt is pending and a grant starts the uplink mid-session). + var micAvailable: Bool { + #if os(tvOS) + return false // no app-accessible microphone — SessionAudio never opens an uplink either + #else + guard settings.micEnabled else { return false } + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .authorized, .notDetermined: return true + default: return false // denied / restricted — there is no uplink to mute + } + #endif + } + + /// Flip the user's mute. The in-stream surfaces (HUD button, Stream menu, ⌃⌥⇧A while + /// captured, the iOS mic disc) all land here. + func toggleMicMute() { + setMicMuted(!micMuted) + } + + /// Set the user's mute directly (the badge's tap-to-unmute). Ignored when the session has no + /// microphone, so a stale surface can't leave a phantom "muted" badge over a session that was + /// never sending anything. + func setMicMuted(_ muted: Bool) { + guard micAvailable, micMuted != muted else { return } + micMuted = muted + applyMicMute() + } + + /// Push the EFFECTIVE mute — the user's choice OR the background keep-alive's privacy mute — + /// onto the audio engine. The two reasons are composed here and nowhere else: whichever one + /// changed, the other still holds, so returning from the background can't un-mute a user who + /// muted mid-stream, and a user unmuting while backgrounded (Live Activity, another window) + /// doesn't open the mic behind their back. + private func applyMicMute() { + audio?.setMicMuted(micMuted || isBackgrounded) + } + /// Follow a live stats-overlay cycle (⌃⌥⇧S, the three-finger tap, the Stream menu). Those /// surfaces write the GLOBAL setting as they always have; this moves the session's own tier /// with it, so cycling still works in a session a profile put on a different tier. @@ -509,6 +561,9 @@ final class SessionModel: ObservableObject { backgroundTimer = nil isBackgrounded = false backgroundDeadline = nil + // The mic mute is per-session and never persisted: the next stream starts live (if the + // mic is enabled), rather than silently carrying a mute nobody remembers making. + micMuted = false let audio = self.audio self.audio = nil // Gamepad capture is main-actor (releases held buttons on the wire while the diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift index a9085f1d..86f50069 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamCommands.swift @@ -1,7 +1,8 @@ // The app's "Stream" menu (macOS menu bar + iPad hardware-keyboard shortcuts). These live at // the Scene level so they keep working when the HUD overlay is hidden. The shortcuts are the // CROSS-CLIENT set every punktfunk client reserves — Ctrl+Alt+Shift+Q (release the captured -// mouse) / +D (disconnect) / +S (stats) — and the menu is their discoverable surface on macOS +// mouse) / +D (disconnect) / +S (stats), plus +A (mute the microphone), the Apple clients' +// addition to it — and the menu is their discoverable surface on macOS // (the Linux client has its GTK Shortcuts window, Windows its start-of-stream banner). While // input is CAPTURED these key equivalents never reach the menu (the stream view swallows // keys); InputCapture's monitor detects the same combos there and performs the same actions — @@ -27,6 +28,12 @@ struct SessionFocus { /// Clipboard sync is live (host-acked) — drives the item's Stop/Share title. var clipboardOn: Bool var toggleClipboard: () -> Void + /// The session has a mic uplink at all (its resolved `micEnabled`) — gates the mute item, so + /// it is never an enabled control over a session that sends no microphone. + var micAvailable: Bool + /// The user's mic mute is engaged — drives the item's Mute/Unmute title. + var micMuted: Bool + var toggleMicMute: () -> Void var disconnect: () -> Void } @@ -60,6 +67,17 @@ struct StreamCommands: Commands { } .keyboardShortcut("q", modifiers: [.control, .option, .shift]) .disabled(session?.isStreaming != true) + // Mic mute, local and instant (it gates capture on this device — the host is never + // asked). Per SESSION: it starts off every time, so this item is a live toggle, not a + // setting. Greyed when the session sends no microphone at all (Settings → mic off, or + // a profile that turns it off) rather than pretending there is something to mute. + // Captured, the combo is handled by InputCapture's chord path before menus see it; + // this item is the released-state path and the shortcut's documentation. + Button(session?.micMuted == true ? "Unmute Microphone" : "Mute Microphone") { + session?.toggleMicMute() + } + .keyboardShortcut("a", modifiers: [.control, .option, .shift]) + .disabled(session?.isStreaming != true || session?.micAvailable != true) #if os(macOS) // Mid-session clipboard flip (design/clipboard-and-file-transfer.md §5.3). Greyed // when the host doesn't advertise the cap (older host / operator policy off). diff --git a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift index 67a21962..41606725 100644 --- a/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift +++ b/clients/apple/Sources/PunktfunkClient/Session/StreamHUDView.swift @@ -179,6 +179,18 @@ struct StreamHUDView: View { .foregroundStyle(.secondary) } #endif + // Mic mute — the in-stream toggle, on the same card as the other in-overlay action. + // Absent (not greyed) when the session sends no microphone: the HUD is a status card, + // and a dead control on it would read as "there is a mic, and it is on". The muted + // STATE is not this button's job — the badge over the stream says that at every tier + // and with the overlay off entirely. tvOS gets no control: no microphone, and a + // focusable one would steal the controller's A press from the host. + #if !os(tvOS) + if model.micAvailable { + Button(micButtonTitle) { model.toggleMicMute() } + .font(.geist(12, relativeTo: .caption)) + } + #endif // ⌃⌥⇧D lives on the app's Stream menu (so it still works when the HUD is hidden) // and in InputCapture's monitor while captured; this button is the in-overlay, // click-to-disconnect affordance. tvOS deliberately gets NEITHER a button (a @@ -195,6 +207,19 @@ struct StreamHUDView: View { } } + #if !os(tvOS) + /// The mute button's wording. macOS names the chord, exactly as its Disconnect button does; + /// iOS/iPadOS spells the action out (the HUD's buttons there carry no shortcuts, even where a + /// hardware keyboard could fire one — the Stream menu is that keyboard's surface). + private var micButtonTitle: String { + #if os(macOS) + return model.micMuted ? "Unmute Mic (⌃⌥⇧A)" : "Mute Mic (⌃⌥⇧A)" + #else + return model.micMuted ? "Unmute Microphone" : "Mute Microphone" + #endif + } + #endif + // MARK: - Card metrics /// The card's inner content padding. Roomier on tvOS — the stat text auto-scales for the @@ -242,6 +267,42 @@ struct StreamHUDView: View { } } +#if !os(tvOS) +/// The muted-microphone badge — the mute STATE, as opposed to the buttons that flip it. It rides +/// over the stream whenever the mic is muted, INDEPENDENT of the stats overlay (which the user +/// may have cycled off, and which the compact tier reduces to a stat line): "am I muted?" is not a +/// statistic, and a mute you can't see is how people talk to nobody for a minute. Same glass +/// language as the HUD, sized like the start-of-stream banner it shares the bottom edge with. +/// +/// It is also a control: tapping it unmutes. That is the guaranteed way back for a touch user who +/// muted with the overlay off, and it costs the badge nothing (it is on screen either way). +struct MicMutedBadge: View { + let onUnmute: () -> Void + + var body: some View { + Button(action: onUnmute) { + HStack(spacing: 7) { + Image(systemName: "mic.slash.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.red) + Text("Microphone muted") + .font(.geist(12, .medium, relativeTo: .caption)) + .foregroundStyle(.white.opacity(0.9)) + } + .padding(.horizontal, 14) + .padding(.vertical, 8) + // interactive: the badge IS the tap target, so the glass reacts to press. + .glassBackground(Capsule(), interactive: true) + .contentShape(Capsule()) + } + .buttonStyle(.plain) + .environment(\.colorScheme, .dark) // reads over any frame, like the resize overlay + .accessibilityLabel("Microphone muted") + .accessibilityHint("Unmutes the microphone") + } +} +#endif + #if os(iOS) /// Device display geometry the overlay needs but UIKit doesn't expose publicly. enum DeviceMetrics { diff --git a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift index a5495671..6ff85813 100644 --- a/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift +++ b/clients/apple/Sources/PunktfunkKit/Audio/SessionAudio.swift @@ -51,6 +51,12 @@ public final class SessionAudio { /// `playbackEngine`/`captureEngine` stay nil while this is set. private var combinedEngine: AVAudioEngine? private var drainStarted = false + /// The mute LATCH: the effective mute the owner last asked for (see `setMicMuted`). Held + /// because the uplink engine can appear LATER than the request — the mic permission prompt + /// is answered at the user's leisure, and the engine is built on the grant — so a mute set + /// in the meantime must be waiting for it. Applied by whichever start path wins the race. + /// Guarded by `stateLock`, like the engines it applies to. + private var micMuted = false /// The playback jitter ring — created by whichever engine starts playback first and KEPT /// across an engine rebuild (the permission-grant upgrade in `startEngines` swaps engines, /// not the ring, so the drain thread never has to be re-pointed). Main-thread confined, @@ -259,19 +265,34 @@ public final class SessionAudio { } } - /// Background keep-alive: silence the mic uplink while backgrounded (privacy — no room audio - /// leaves the device) and restore it on return. Two-engine sessions pause/resume the capture - /// engine; a combined session instead mutes the voice processor's input (playback shares that - /// engine and must keep running, so the engine itself never pauses — the mute zeroes the mic - /// at the IO unit, and the tap encodes silence). A no-op when there's no uplink (playback-only - /// / tvOS / mic disabled). The audio SESSION stays active for background playback, so iOS may - /// keep showing the recording indicator until a full reconfigure — either path stops room - /// audio leaving the device, which is the privacy-relevant part. Main thread. + /// Silence the mic uplink (no room audio leaves the device) or restore it. THE one muting + /// mechanism: the owner composes its reasons — the user's in-stream mute and the background + /// keep-alive's privacy mute — into one effective state and passes that here, so neither can + /// clear the other (see `SessionModel.applyMicMute`). + /// + /// Two-engine sessions pause/resume the capture engine; a combined session instead mutes the + /// voice processor's input (playback shares that engine and must keep running, so the engine + /// itself never pauses — the mute zeroes the mic at the IO unit, and the tap encodes silence). + /// Local and instant either way: nothing is negotiated with the host, and the packets that do + /// leave carry silence. A no-op when there's no uplink (playback-only / tvOS / mic disabled), + /// except that the state is LATCHED for an uplink that starts later. The audio SESSION stays + /// active for background playback, so iOS may keep showing the recording indicator until a + /// full reconfigure — either path stops room audio leaving the device, which is the + /// privacy-relevant part. Main thread. public func setMicMuted(_ muted: Bool) { stateLock.lock() + micMuted = muted let capture = captureEngine let combined = combinedEngine stateLock.unlock() + apply(micMuted: muted, capture: capture, combined: combined) + } + + /// Push the latched mute onto whichever engine carries the uplink. Split out from + /// `setMicMuted` because the start paths call it too, with the engine they just started — + /// that's how a mute requested before the permission grant lands on the engine the grant + /// creates. Never resumes a stopped session's engine. + private func apply(micMuted muted: Bool, capture: AVAudioEngine?, combined: AVAudioEngine?) { if let combined { combined.inputNode.isVoiceProcessingInputMuted = muted return @@ -473,7 +494,9 @@ public final class SessionAudio { return } combinedEngine = engine + let muted = micMuted // latched before this engine existed (a mute during the prompt) stateLock.unlock() + apply(micMuted: muted, capture: nil, combined: engine) startDrain(into: ring) log.info("audio engines joined — voice processing (echo cancellation) active") } @@ -517,7 +540,9 @@ public final class SessionAudio { return } captureEngine = engine + let muted = micMuted // latched before this engine existed (a mute during the prompt) stateLock.unlock() + apply(micMuted: muted, capture: engine, combined: nil) log.info("mic uplink started (\(micUID.isEmpty ? "default input" : micUID))") } diff --git a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift index 7c1abf70..0434295c 100644 --- a/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift +++ b/clients/apple/Sources/PunktfunkKit/Input/InputCapture.swift @@ -128,11 +128,19 @@ public final class InputCapture { /// carries the same key equivalents for discoverability) can't see them, so the monitor is the /// captured-state delivery path; released, the events pass through and the menu handles them. /// ⌃⌥⇧Q releases the captured mouse/keyboard; ⌃⌥⇧D disconnects; ⌃⌥⇧S cycles the stats - /// overlay tier (off → compact → normal → detailed). Main queue. + /// overlay tier (off → compact → normal → detailed). ⌃⌥⇧A (`onToggleMicMute`, below) rides + /// the same path. Main queue. public var onReleaseCapture: (() -> Void)? public var onDisconnect: (() -> Void)? public var onCycleStats: (() -> Void)? + /// Fired on ⌃⌥⇧A — mute/unmute the microphone uplink, the one in-stream control a captured + /// session can't otherwise reach (the HUD's button is behind a grabbed cursor). Same delivery + /// rule as the combos above: only WHILE FORWARDING, because that's when the menu's identical + /// key equivalent can't fire. ⌃⌥⇧M — the obvious letter — is long since the mouse-model flip + /// (cross-client), so A ("audio in") is the mic's. Main queue. + public var onToggleMicMute: (() -> Void)? + /// Fired on ⌃⌘F (macOS) — toggle the streaming window in/out of fullscreen. Detected in the /// monitor only WHILE FORWARDING, for the same reason as the ⌃⌥⇧ combos: a captured stream view /// swallows keys, so the Stream menu's identical ⌃⌘F equivalent never reaches it; released, the @@ -140,13 +148,13 @@ public final class InputCapture { public var onToggleFullscreen: (() -> Void)? #if os(iOS) - /// Windows VKs of the three modifier classes in the ⌃⌥⇧Q release chord, both L/R sides: + /// Windows VKs of the three modifier classes in the ⌃⌥⇧ chords, both L/R sides: /// control (0xA2/0xA3), option (0xA4/0xA5), shift (0xA0/0xA1). Used to sift the HID key stream. private static let chordModifierVKs: Set = [0xA2, 0xA3, 0xA4, 0xA5, 0xA0, 0xA1] /// Whether Control AND Option AND Shift are all currently held (either side of each counts) — - /// the modifier precondition for the iPad ⌃⌥⇧Q release chord. - private var hasReleaseChordModifiers: Bool { + /// the modifier precondition for the iPad ⌃⌥⇧ chords (Q releases capture, A mutes the mic). + private var hasChordModifiers: Bool { let m = chordModifiersDown return (m.contains(0xA2) || m.contains(0xA3)) // control && (m.contains(0xA4) || m.contains(0xA5)) // option @@ -284,6 +292,10 @@ public final class InputCapture { self.suppressedVK = 0x53 self.onCycleStats?() return nil + case 0 /* A */: + self.suppressedVK = 0x41 + self.onToggleMicMute?() + return nil default: break } @@ -704,7 +716,7 @@ public final class InputCapture { } } #if os(iOS) - // Track Control/Option/Shift for the ⌃⌥⇧Q release chord below — in both forwarding + // Track Control/Option/Shift for the ⌃⌥⇧ chords below — in both forwarding // states (like `cmdKeysDown`) so a modifier held before capture engaged still counts. if Self.chordModifierVKs.contains(vk) { if pressed { self.chordModifiersDown.insert(vk) } else { self.chordModifiersDown.remove(vk) } @@ -732,11 +744,19 @@ public final class InputCapture { // otherwise). The Q is latched (`suppressedVK`) so its keyUp can't type into the host; // the ⌃⌥⇧ modifiers were forwarded as they went down and are flushed by the release // path (setCaptured(false) → releaseAll). VK 0x51 is layout-independent (physical Q). - if pressed, vk == 0x51, self.hasReleaseChordModifiers { + if pressed, vk == 0x51, self.hasChordModifiers { self.suppressedVK = 0x51 self.onReleaseCapture?() return } + // ⌃⌥⇧A mutes/unmutes the mic uplink — same detection, same latching, and needed here + // for the same reason as on macOS: a captured iPad swallows the Stream menu's + // identical key equivalent. VK 0x41 is layout-independent (physical A). + if pressed, vk == 0x41, self.hasChordModifiers { + self.suppressedVK = 0x41 + self.onToggleMicMute?() + return + } #endif // Release direction of the toggle: GC's Esc-down can beat the NSEvent // monitor — never type Esc into the host while ⌘ is held (⌘⎋ is reserved). diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift index c568b905..60061f66 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamView.swift @@ -881,6 +881,12 @@ public final class StreamLayerView: NSView { guard self?.window?.isKeyWindow == true else { return } NotificationCenter.default.post(name: .punktfunkToggleFullscreen, object: nil) } + capture.onToggleMicMute = { [weak self] in + // Session-level state the view doesn't own — post to the app (same routing as the + // fullscreen chord), so the captured and released paths end at one toggle. + guard self?.window?.isKeyWindow == true else { return } + NotificationCenter.default.post(name: .punktfunkToggleMicMute, object: nil) + } capture.onCycleStats = { [weak self] in guard self?.window?.isKeyWindow == true else { return } // Advance the shared tier setting directly — every @AppStorage reader (the HUD's diff --git a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift index 7673291c..fa3aa325 100644 --- a/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift +++ b/clients/apple/Sources/PunktfunkKit/Views/StreamViewIOS.swift @@ -424,6 +424,12 @@ public final class StreamViewController: StreamViewControllerBase { capture.onReleaseCapture = { [weak self] in self?.setCaptured(false) } + // ⌃⌥⇧A mutes/unmutes the mic uplink. Session state this controller doesn't own, so it + // posts to the app exactly as the macOS chord does — the Stream menu's identical + // equivalent (which a captured scene swallows) ends at the same toggle. + capture.onToggleMicMute = { + NotificationCenter.default.post(name: .punktfunkToggleMicMute, object: nil) + } capture.onPreempted = { [weak self] in self?.setCaptured(false) } diff --git a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift index 8ede87ab..7b68d740 100644 --- a/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift +++ b/clients/apple/Sources/PunktfunkShared/DefaultsKeys.swift @@ -200,6 +200,12 @@ extension Notification.Name { /// state. macOS only. public static let punktfunkToggleFullscreen = Notification.Name("io.unom.punktfunk.toggle-fullscreen") + /// Posted by InputCapture's chord path (⌃⌥⇧A) when the combo fires while input is CAPTURED — + /// the state in which the Stream menu's identical key equivalent never reaches the app. The + /// live session's owner (ContentView) flips the session's mic mute. Released, the menu item + /// handles the same combo directly; both end at `SessionModel.toggleMicMute`. + public static let punktfunkToggleMicMute = Notification.Name("io.unom.punktfunk.toggle-mic-mute") + /// Posted by the Live Activity's / Shortcuts' End-stream intent (`EndStreamIntent.perform`, /// which runs in the app's process): the app tears the active session down deliberately /// (quit-close the host). Same cross-process-signal pattern as `punktfunkReleaseCapture` —