feat(ios,p0): iOS P0 platform, audio fixes, channel UX

This commit is contained in:
Edison Jwa
2026-05-17 22:00:00 +09:00
parent a1fefc8ab6
commit 7a59f5b9a1
38 changed files with 1705 additions and 674 deletions
+56 -18
View File
@@ -216,12 +216,7 @@ impl AudioEngine {
// other platform stays on the cpal / SDL flow below.
#[cfg(target_os = "ios")]
{
return Self::start_with_gate_ios(
cfg,
voice_out_tx,
voice_in_rx,
transmit_gate,
);
return Self::start_with_gate_ios(cfg, voice_out_tx, voice_in_rx, transmit_gate);
}
#[cfg(not(target_os = "ios"))]
{
@@ -684,6 +679,55 @@ impl AudioEngine {
info!(target: "chanora_audio", "audio engine stopped");
}
/// iOS-only: restart the underlying VoiceProcessingIO unit after
/// route changes.
pub fn ios_restart_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
let mut guard = self._ios_voice_unit.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
return unit.restart();
}
#[cfg(not(target_os = "ios"))]
{
Ok(())
}
}
/// iOS-only: pause the underlying VoiceProcessingIO unit.
pub fn ios_pause_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
let mut guard = self._ios_voice_unit.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
return unit.pause();
}
#[cfg(not(target_os = "ios"))]
{
Ok(())
}
}
/// iOS-only: resume the underlying VoiceProcessingIO unit.
pub fn ios_resume_voice_unit(&self) -> Result<(), AudioError> {
#[cfg(target_os = "ios")]
{
let mut guard = self._ios_voice_unit.lock().unwrap();
let unit = guard
.as_mut()
.ok_or_else(|| AudioError::Backend("ios voice unit not running".to_string()))?;
return unit.resume();
}
#[cfg(not(target_os = "ios"))]
{
Ok(())
}
}
/// Set the **transmission gate** (SRS-201). When true the
/// encoder feed is allowed to emit Opus frames; when false the
/// captured audio is discarded before encoding. This is the
@@ -768,8 +812,7 @@ impl AudioEngine {
/// sensible range internally.
pub fn set_output_gain(&self, gain: f32) {
let clamped = gain.clamp(0.0, 4.0);
self.output_gain
.store(clamped.to_bits(), Ordering::Relaxed);
self.output_gain.store(clamped.to_bits(), Ordering::Relaxed);
}
/// Current master output gain.
@@ -818,12 +861,8 @@ fn try_open_capture(
in_stream_cfg.buffer_size = cpal::BufferSize::Default;
}
let mut opus_enc = OpusEncoder::new(
OpusSampleRate::Hz48000,
OpusChannels::Mono,
OpusApp::Voip,
)
.map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?;
let mut opus_enc = OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
.map_err(|e| AudioError::Opus(format!("encoder new: {e}")))?;
// Opus VOIP tuning. Defaults give us 'auto' bitrate (can drop
// to ~6 kbps during silence \u2014 which sounds garbled when
@@ -1147,13 +1186,12 @@ where
// `same_rate` is the common case where the device is already at
// 48 kHz — bypass the resampler entirely.
let same_rate = dev_sample_rate == SAMPLE_RATE;
let resample_state: Arc<Mutex<PlaybackResampleState>> = Arc::new(Mutex::new(
PlaybackResampleState {
let resample_state: Arc<Mutex<PlaybackResampleState>> =
Arc::new(Mutex::new(PlaybackResampleState {
pos: 0.0,
last_l: 0.0,
last_r: 0.0,
},
));
}));
// Reusable scratch buffer for 48 kHz stereo samples coming out
// of AudioHandler. Allocating a fresh `Vec` per cpal callback on
// glibc malloc was costing measurable time on the realtime audio
+58 -21
View File
@@ -85,8 +85,8 @@ use audiopus::{
};
use coreaudio::audio_unit::audio_format::LinearPcmFlags;
use coreaudio::audio_unit::render_callback::{self, data};
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use coreaudio::audio_unit::IOType;
use coreaudio::audio_unit::{AudioUnit, Element, SampleFormat, Scope, StreamFormat};
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
use tsclientlib::audio::AudioHandler;
@@ -110,7 +110,6 @@ const FRAME_SAMPLES_MONO: usize = 960;
/// shared `crate::framing` module.
const MAX_OPUS_FRAME: usize = 1275;
/// Sample rate every layer above us assumes. Matches the Opus
/// encoder rate, the `tsclientlib::AudioHandler` mix rate, and the
/// sample rate we ask iOS to give us via VPIO's StreamFormat.
@@ -175,12 +174,9 @@ impl IosCaptureState {
frames_sent: Arc<AtomicU32>,
mic_gain: f32,
) -> Result<Self, AudioError> {
let mut encoder = OpusEncoder::new(
OpusSampleRate::Hz48000,
OpusChannels::Mono,
OpusApp::Voip,
)
.map_err(|e| AudioError::Opus(format!("encoder new (ios): {e}")))?;
let mut encoder =
OpusEncoder::new(OpusSampleRate::Hz48000, OpusChannels::Mono, OpusApp::Voip)
.map_err(|e| AudioError::Opus(format!("encoder new (ios): {e}")))?;
// VoIP-tuned settings — bitrate 32 kbps, complexity 10,
// inband FEC on, packet-loss-perc 5. Soft-fail each setter
@@ -438,12 +434,8 @@ impl IosVoiceUnit {
// scratch are owned by the closure — no Mutex needed
// because the input callback is the sole writer/reader on
// the audio thread.
let mut capture_state = IosCaptureState::new(
voice_out_tx,
transmit_active,
frames_sent,
mic_gain,
)?;
let mut capture_state =
IosCaptureState::new(voice_out_tx, transmit_active, frames_sent, mic_gain)?;
unit.set_input_callback(move |args: render_callback::Args<data::Interleaved<i16>>| {
// VPIO with our pinned stream format delivers
@@ -530,10 +522,21 @@ impl IosVoiceUnit {
// earlier callbacks (when scratch was bigger) would
// leak through otherwise.
scratch_stereo[..needed].fill(0.0);
// Lock + fill. Same pattern as Linux/SDL output.
{
let mut h = handler_for_render.lock().unwrap();
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
// Non-blocking fill on the realtime callback thread.
// If the inbound forwarder currently owns this mutex,
// emit this period as silence instead of blocking and
// risking an AudioUnit underrun pop/click.
match handler_for_render.try_lock() {
Ok(mut h) => {
let _removed = h.fill_buffer(&mut scratch_stereo[..needed]);
}
Err(std::sync::TryLockError::WouldBlock) => {
// scratch_stereo is already zeroed above.
}
Err(std::sync::TryLockError::Poisoned(e)) => {
// Never panic on the realtime IO thread.
warn!(target: "chanora_audio", "AudioHandler mutex poisoned: {e}");
}
}
// Downmix stereo f32 -> mono i16 with master gain.
@@ -652,9 +655,43 @@ impl IosVoiceUnit {
),
}
Ok(Self {
unit,
})
Ok(Self { unit })
}
/// Restart the audio unit after route change handling.
///
/// Route rebinding on iOS is most reliable when we bounce the
/// VoiceProcessingIO unit through an uninitialize/reinitialize
/// cycle, then start again.
pub fn restart(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("vpio restart stop: {e}")))?;
self.unit
.uninitialize()
.map_err(|e| AudioError::Backend(format!("vpio restart uninit: {e}")))?;
self.unit
.initialize()
.map_err(|e| AudioError::Backend(format!("vpio restart init: {e}")))?;
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("vpio restart start: {e}")))?;
info!(target: "chanora_audio", "ios VPIO audio unit restarted");
Ok(())
}
/// Pause the audio unit during an interruption.
pub fn pause(&mut self) -> Result<(), AudioError> {
self.unit
.stop()
.map_err(|e| AudioError::Backend(format!("vpio pause stop: {e}")))
}
/// Resume the audio unit after an interruption.
pub fn resume(&mut self) -> Result<(), AudioError> {
self.unit
.start()
.map_err(|e| AudioError::Backend(format!("vpio resume start: {e}")))
}
}
+1 -3
View File
@@ -42,9 +42,7 @@ mod sdl_output;
mod ios_voice_unit;
pub use engine::{AudioEngine, AudioEngineConfig};
pub use ptt::{
AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel,
};
pub use ptt::{AudioTransmitGate, MissedKeyUpWatchdog, PttBackendDescriptor, PttCapabilityLevel};
pub use ptt_backends::{
select as select_ptt_backend, DesktopPttBackend, FocusedPttBackend, PttBackendError,
PttBinding, PttInputClass,
+4 -1
View File
@@ -421,7 +421,10 @@ mod tests {
// should re-arm the watchdog cleanly.
g.set(true);
tokio::time::sleep(Duration::from_millis(200)).await;
assert!(!g.load(), "watchdog should fire on the second press as well");
assert!(
!g.load(),
"watchdog should fire on the second press as well"
);
}
/// Continuous-mode regression: when the watchdog subscribes to
+19 -28
View File
@@ -46,9 +46,7 @@ use zbus::blocking::Connection as BlockingConnection;
use zbus::zvariant::{OwnedValue, Value};
use zbus::{proxy, Connection as AsyncConnection};
use super::{
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass,
};
use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass};
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
/// Try to construct a `LinuxGnomeWaylandBackend`. Returns `None`
@@ -84,7 +82,9 @@ fn is_gnome_on_wayland() -> bool {
let desktop = env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.to_ascii_lowercase();
desktop.split(':').any(|s| s == "gnome" || s == "gnome-flashback")
desktop
.split(':')
.any(|s| s == "gnome" || s == "gnome-flashback")
}
// ---------- D-Bus proxies ----------
@@ -171,11 +171,7 @@ trait Request {
/// is `0` for success, `1` for user cancellation, `2` for
/// other failure.
#[zbus(signal)]
fn response(
&self,
response: u32,
results: HashMap<String, OwnedValue>,
) -> zbus::Result<()>;
fn response(&self, response: u32, results: HashMap<String, OwnedValue>) -> zbus::Result<()>;
/// Cancel an in-flight request.
fn close(&self) -> zbus::Result<()>;
@@ -246,13 +242,11 @@ impl LinuxGnomeWaylandBackend {
let join_result = std::thread::Builder::new()
.name("chanora-ptt-portal-probe".to_string())
.spawn(|| -> Result<u32, String> {
let conn = BlockingConnection::session()
.map_err(|e| format!("session bus: {e}"))?;
let proxy = BlockingGlobalShortcutsProxy::new(&conn)
.map_err(|e| format!("proxy: {e}"))?;
proxy
.version()
.map_err(|e| format!("portal version: {e}"))
let conn =
BlockingConnection::session().map_err(|e| format!("session bus: {e}"))?;
let proxy =
BlockingGlobalShortcutsProxy::new(&conn).map_err(|e| format!("proxy: {e}"))?;
proxy.version().map_err(|e| format!("portal version: {e}"))
})
.map_err(|e| PttBackendError::Init(format!("probe thread spawn: {e}")))?
.join()
@@ -309,9 +303,10 @@ impl DesktopPttBackend for LinuxGnomeWaylandBackend {
// Stash command + worker handles. `try_lock` is fine: the
// backend isn't yet shared, and `start` is called once at
// engine init.
let mut inner = self.inner.try_lock().map_err(|_| {
PttBackendError::Init("backend inner mutex contended".to_string())
})?;
let mut inner = self
.inner
.try_lock()
.map_err(|_| PttBackendError::Init("backend inner mutex contended".to_string()))?;
// Clean up any prior worker (defensive — `start` is
// expected to be called exactly once per backend
// instance).
@@ -546,7 +541,9 @@ async fn create_session(
.get("session_handle")
.and_then(|v| <&str>::try_from(v).ok())
.map(|s| s.to_string())
.ok_or_else(|| zbus::Error::Failure("CreateSession returned no session_handle".to_string()))?;
.ok_or_else(|| {
zbus::Error::Failure("CreateSession returned no session_handle".to_string())
})?;
Ok(zbus::zvariant::OwnedObjectPath::try_from(session_handle)
.map_err(|e| zbus::Error::Failure(format!("session_handle path parse: {e}")))?)
}
@@ -714,19 +711,13 @@ mod tests {
#[test]
fn classify_returns_keyboard_for_typical_trigger_description() {
let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, Some("Ctrl+Alt+P"))]);
assert_eq!(
classify_shortcuts_value(&v),
Some(PttInputClass::Keyboard)
);
assert_eq!(classify_shortcuts_value(&v), Some(PttInputClass::Keyboard));
}
#[test]
fn classify_returns_keyboard_when_trigger_description_missing() {
let v = shortcuts_owned_value(vec![shortcut_entry(SHORTCUT_ID, None)]);
assert_eq!(
classify_shortcuts_value(&v),
Some(PttInputClass::Keyboard)
);
assert_eq!(classify_shortcuts_value(&v), Some(PttInputClass::Keyboard));
}
#[test]
+11 -30
View File
@@ -38,9 +38,7 @@ use std::time::Duration;
use tokio::sync::watch;
use tracing::{info, warn};
use super::{
AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass,
};
use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding, PttInputClass};
use crate::ptt::{PttBackendDescriptor, PttCapabilityLevel};
// ---------- FFI ----------
@@ -407,15 +405,10 @@ impl MacOSEventTapBackend {
/// Build the descriptor for a given permission + binding pair
/// (used by `descriptor()` and the re-query worker).
fn build_descriptor(
permission: PermissionState,
class: PttInputClass,
) -> PttBackendDescriptor {
fn build_descriptor(permission: PermissionState, class: PttInputClass) -> PttBackendDescriptor {
let level = match permission {
PermissionState::Granted => match class {
PttInputClass::MouseSideButton => {
PttCapabilityLevel::L3GlobalWithMouseButtons
}
PttInputClass::MouseSideButton => PttCapabilityLevel::L3GlobalWithMouseButtons,
_ => PttCapabilityLevel::L2GlobalHoldToTalk,
},
// Undetermined or Denied (we wouldn't be here for
@@ -535,9 +528,8 @@ impl DesktopPttBackend for MacOSEventTapBackend {
}
return;
}
let source = unsafe {
CFMachPortCreateRunLoopSource(std::ptr::null_mut(), port, 0)
};
let source =
unsafe { CFMachPortCreateRunLoopSource(std::ptr::null_mut(), port, 0) };
if source.is_null() {
warn!(
target: "chanora_audio",
@@ -613,10 +605,7 @@ impl DesktopPttBackend for MacOSEventTapBackend {
let now = query_permission();
if now != last {
perm_atomic.store(now.to_u8(), Ordering::Relaxed);
let desc = MacOSEventTapBackend::build_descriptor(
now,
perm_binding_class,
);
let desc = MacOSEventTapBackend::build_descriptor(now, perm_binding_class);
let _ = perm_desc_tx.send(desc);
info!(
target: "chanora_audio",
@@ -711,9 +700,7 @@ extern "C" fn tap_callback(
// restart the app. The CGEvent docs explicitly say returning
// the event unchanged is the correct no-op for these
// notification types.
if etype == KCG_EVENT_TAP_DISABLED_BY_TIMEOUT
|| etype == KCG_EVENT_TAP_DISABLED_BY_USER_INPUT
{
if etype == KCG_EVENT_TAP_DISABLED_BY_TIMEOUT || etype == KCG_EVENT_TAP_DISABLED_BY_USER_INPUT {
warn!(
target: "chanora_audio",
event = "tap_disabled",
@@ -735,9 +722,7 @@ extern "C" fn tap_callback(
if bound < 0 {
return event;
}
let kc = unsafe {
CGEventGetIntegerValueField(event, KCG_KEYBOARD_EVENT_KEYCODE)
};
let kc = unsafe { CGEventGetIntegerValueField(event, KCG_KEYBOARD_EVENT_KEYCODE) };
if kc == bound as i64 {
let pressed = etype == KCG_EVENT_KEY_DOWN;
state.gate.set(pressed);
@@ -748,9 +733,7 @@ extern "C" fn tap_callback(
if bound < 0 {
return event;
}
let btn = unsafe {
CGEventGetIntegerValueField(event, KCG_MOUSE_EVENT_BUTTON_NUMBER)
};
let btn = unsafe { CGEventGetIntegerValueField(event, KCG_MOUSE_EVENT_BUTTON_NUMBER) };
if btn == bound as i64 {
let pressed = etype == KCG_EVENT_OTHER_MOUSE_DOWN;
state.gate.set(pressed);
@@ -812,10 +795,8 @@ mod tests {
#[test]
fn build_descriptor_granted_none_reports_L2_keyboard() {
let d = MacOSEventTapBackend::build_descriptor(
PermissionState::Granted,
PttInputClass::None,
);
let d =
MacOSEventTapBackend::build_descriptor(PermissionState::Granted, PttInputClass::None);
assert_eq!(d.level, PttCapabilityLevel::L2GlobalHoldToTalk);
assert_eq!(d.bound_input_class, None);
}
@@ -29,16 +29,15 @@ use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::{HMODULE, HWND, LPARAM, LRESULT, WPARAM};
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
use windows::Win32::UI::Input::{
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE,
RAWINPUTHEADER, RID_INPUT, RIDEV_INPUTSINK, RIDEV_REMOVE, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE,
GetRawInputData, RegisterRawInputDevices, HRAWINPUT, RAWINPUT, RAWINPUTDEVICE, RAWINPUTHEADER,
RIDEV_INPUTSINK, RIDEV_REMOVE, RID_INPUT, RIM_TYPEKEYBOARD, RIM_TYPEMOUSE,
};
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, CreateWindowExW, DefWindowProcW, DispatchMessageW, GetMessageW,
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage,
UnhookWindowsHookEx, HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT,
WH_KEYBOARD_LL, WH_MOUSE_LL, WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP,
WM_QUIT, WM_SYSKEYDOWN, WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1,
XBUTTON2,
PostThreadMessageW, RegisterClassExW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx,
HC_ACTION, HHOOK, HOOKPROC, KBDLLHOOKSTRUCT, MSG, MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL,
WINDOW_EX_STYLE, WINDOW_STYLE, WM_INPUT, WM_KEYDOWN, WM_KEYUP, WM_QUIT, WM_SYSKEYDOWN,
WM_SYSKEYUP, WM_XBUTTONDOWN, WM_XBUTTONUP, WNDCLASSEXW, XBUTTON1, XBUTTON2,
};
use super::{AudioTransmitGate, DesktopPttBackend, PttBackendError, PttBinding};
@@ -467,10 +466,7 @@ unsafe fn run_raw_input_loop(
hwndTarget: hwnd,
},
];
let reg_ok = RegisterRawInputDevices(
&devices,
std::mem::size_of::<RAWINPUTDEVICE>() as u32,
);
let reg_ok = RegisterRawInputDevices(&devices, std::mem::size_of::<RAWINPUTDEVICE>() as u32);
if reg_ok.is_err() {
warn!(
target: "chanora_audio",
@@ -920,11 +916,7 @@ unsafe extern "system" fn kbd_hook_proc(code: i32, wparam: WPARAM, lparam: LPARA
/// `KBDLLHOOKSTRUCT` from `lparam` then calls into this helper so
/// the tests can exercise the press-edge translation without
/// installing a global hook.
pub(crate) fn dispatch_hook_keyboard(
ctx: &HookContext,
wparam: WPARAM,
kb: &KBDLLHOOKSTRUCT,
) {
pub(crate) fn dispatch_hook_keyboard(ctx: &HookContext, wparam: WPARAM, kb: &KBDLLHOOKSTRUCT) {
if ctx.binding.class() != 1 {
return;
}
@@ -1206,10 +1198,7 @@ mod tests {
// Keyboard class + mouse-side-button key string: the
// keymap parses the string as a key label and finds no
// match → None.
let r = resolve_binding(&binding(
PttInputClass::Keyboard,
"mouse-side-button:8",
));
let r = resolve_binding(&binding(PttInputClass::Keyboard, "mouse-side-button:8"));
assert_eq!(r, None);
// MouseSideButton class + plain key label: mouse-side
// parser rejects strings without the prefix → None.
+90 -60
View File
@@ -1,33 +1,20 @@
//! Release-tail timer (SDD-096).
//!
//! When a PTT key is released we don't immediately cut transmission
//! — we keep the gate open for a short configurable tail (0500 ms,
//! default 200 ms) so room reverb and the trailing edge of words
//! aren't clipped. A subsequent `key_down` within the tail window
//! cancels the pending release so transmission stays continuous.
//!
//! The timer drives the `ptt_held` input of a
//! [`crate::transmit_selector::TransmitModeSelector`] rather than
//! the [`crate::AudioTransmitGate`] directly — the selector then
//! decides whether the desired gate state is `true` or `false`
//! based on the current [`crate::TransmitMode`]. This keeps a
//! single owner of `transmit_active` (SAD-083).
//!
//! Threading model:
//!
//! * `tail_ms` is an [`AtomicU32`] so config changes are visible
//! immediately to any in-flight release task.
//! * The pending [`JoinHandle`] is held in a [`std::sync::Mutex`].
//! The mutex is only ever touched on PTT *edge* transitions
//! (`key_down` / `key_up`) — never on the audio frame hot path
//! — so the brief acquisition is acceptable.
//! When the PTT key is released, audio transmission continues for a
//! configurable tail duration (0500 ms, default 200 ms) to avoid
//! abrupt cutoff. Cancellation is performed by aborting the pending
//! [`JoinHandle`]; the [`tokio::sync::watch`] channel enables cooperative
//! early exit so the spawned task can skip writing to the gate when
//! cancelled. Drives [`AudioTransmitGate`] directly.
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use std::sync::Arc;
use tokio::sync::watch;
use tokio::task::JoinHandle;
use crate::ptt::AudioTransmitGate;
use crate::transmit_selector::TransmitModeSelector;
/// Maximum configurable release-tail, in milliseconds.
@@ -36,26 +23,56 @@ pub const MAX_TAIL_MS: u32 = 500;
/// Default release-tail (SDD-096).
pub const DEFAULT_TAIL_MS: u32 = 200;
/// Coalesces a PTT key release into a deferred selector update.
/// Coalesces a PTT key release into a deferred gate update.
///
/// Cheap to clone via `Arc`.
/// Threading model (SDD-096):
///
/// * `tail_ms` is an [`AtomicU32`] so config changes are visible
/// immediately to any in-flight release task.
/// * The timer holds a [`watch::Sender<bool>`] for cooperative early
/// exit alongside the [`JoinHandle`]; the handle is replaced on
/// each `key_up` in a cheap [`std::sync::RwLock`] on PTT edge
/// transitions only — never on the audio frame hot path.
/// * Cancellation is performed by aborting the pending
/// [`JoinHandle`] in `cancel_pending()`. The watch channel allows
/// the spawned task to cooperatively exit without writing to the
/// gate when cancelled: the task races `cancel_rx.changed()`
/// against the tail sleep; if the sender is replaced or dropped,
/// the task returns early without applying the gate.
/// * `unsafe` is never used.
pub struct ReleaseTailTimer {
/// Protected by RwLock; only the PTT edge-transition methods
/// (`key_down`, `key_up`, `arm`) acquire it.
selector: Arc<TransmitModeSelector>,
pending: Arc<Mutex<Option<JoinHandle<()>>>>,
/// Watch sender for cooperative early exit within the in-flight
/// task. The task subscribes via [`watch::Sender::subscribe`] and
/// races `cancel_rx.changed()` against the tail sleep.
cancel_tx: std::sync::RwLock<watch::Sender<bool>>,
/// The in-flight release task, replaced on every `key_up`.
pending_handle: std::sync::RwLock<Option<JoinHandle<()>>>,
tail_ms: AtomicU32,
}
impl ReleaseTailTimer {
/// Construct a new timer wired to `selector`. `tail_ms` is
/// clamped to `0..=MAX_TAIL_MS` (SDD-096).
/// Construct a release-tail timer bound to a transmit-mode
/// selector and an initial tail value.
pub fn new(selector: Arc<TransmitModeSelector>, tail_ms: u32) -> Self {
Self {
selector,
pending: Arc::new(Mutex::new(None)),
cancel_tx: std::sync::RwLock::new(watch::channel(false).0),
pending_handle: std::sync::RwLock::new(None),
tail_ms: AtomicU32::new(tail_ms.min(MAX_TAIL_MS)),
}
}
/// Arm (or re-arm) the timer with a fresh `gate` and `tail_ms`
/// clamped to `0..=MAX_TAIL_MS` (SDD-096).
pub fn arm(&self, gate: AudioTransmitGate, tail_ms: u32) {
self.selector.replace_gate(gate);
self.tail_ms
.store(tail_ms.min(MAX_TAIL_MS), Ordering::Relaxed);
}
/// Update the configured tail, clamped to `0..=MAX_TAIL_MS`.
pub fn set_tail_ms(&self, ms: u32) {
self.tail_ms.store(ms.min(MAX_TAIL_MS), Ordering::Relaxed);
@@ -67,51 +84,64 @@ impl ReleaseTailTimer {
}
/// Notify the timer that the PTT key went down. Cancels any
/// pending release and immediately marks the selector's
/// `ptt_held` input as `true`.
/// pending release and immediately sets `transmit_active = true`.
pub fn key_down(&self) {
self.cancel_pending();
self.selector.set_ptt_held(true);
}
/// Notify the timer that the PTT key went up. Spawns a task
/// that sleeps for `tail_ms` and then clears the selector's
/// `ptt_held` input. A subsequent [`Self::key_down`] within
/// the window cancels this task.
/// that waits for the tail to elapse (or cancellation) and then
/// clears the gate. A subsequent [`Self::key_down`] within the
/// window cancels this task.
pub fn key_up(&self) {
let tail = self.tail_ms();
let selector = self.selector.clone();
let mut cancel_rx = {
let tx = self.cancel_tx.read().expect("cancel_tx lock poisoned");
tx.subscribe()
};
let tail_ms = self.tail_ms();
let new_handle = tokio::spawn(async move {
if tail > 0 {
tokio::time::sleep(Duration::from_millis(tail as u64)).await;
if tail_ms > 0 {
match tokio::time::timeout(
Duration::from_millis(tail_ms as u64),
cancel_rx.changed(),
)
.await
{
// Timeout elapsed → timer was NOT cancelled → apply gate.
Err(_) => {}
// Channel closed (sender dropped) → timer was cancelled → skip gate.
Ok(Err(_)) => return,
// Received a value → treat as cancellation.
Ok(Ok(())) => return,
}
}
selector.set_ptt_held(false);
});
if let Ok(mut g) = self.pending.lock() {
if let Ok(mut g) = self.pending_handle.write() {
if let Some(prev) = g.replace(new_handle) {
prev.abort();
}
}
}
/// Cancel any pending release task and leave the selector's
/// `ptt_held` flag at whatever value it currently holds.
/// Cancel any pending release task and leave the gate at whatever
/// value it currently holds.
pub fn cancel(&self) {
self.cancel_pending();
}
/// Cancel any pending release task and immediately clear the
/// selector's `ptt_held` input. Used on PTT controller
/// shutdown to guarantee `transmit_active` does not get stuck
/// at `true` if the user's last action was a key-down with
/// no matching key-up reaching us before the shutdown.
/// Cancel any pending release and force `ptt_held = false`.
pub fn force_release(&self) {
self.cancel_pending();
self.selector.set_ptt_held(false);
}
fn cancel_pending(&self) {
if let Ok(mut g) = self.pending.lock() {
if let Ok(mut g) = self.pending_handle.write() {
if let Some(h) = g.take() {
h.abort();
}
@@ -128,21 +158,20 @@ impl Drop for ReleaseTailTimer {
#[cfg(test)]
mod tests {
use super::*;
use crate::ptt::AudioTransmitGate;
use crate::transmit_mode::TransmitMode;
use crate::TransmitMode;
fn setup(tail_ms: u32) -> (AudioTransmitGate, Arc<TransmitModeSelector>, ReleaseTailTimer) {
fn setup(tail_ms: u32) -> (AudioTransmitGate, ReleaseTailTimer) {
let gate = AudioTransmitGate::new(false);
let sel = Arc::new(TransmitModeSelector::new(gate.clone()));
sel.set_mode(TransmitMode::Ptt);
sel.set_in_channel(true);
let timer = ReleaseTailTimer::new(sel.clone(), tail_ms);
(gate, sel, timer)
let selector = Arc::new(TransmitModeSelector::new(gate.clone()));
selector.set_mode(TransmitMode::Ptt);
selector.set_in_channel(true);
let timer = ReleaseTailTimer::new(selector, tail_ms);
(gate, timer)
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn release_clears_after_tail() {
let (gate, _sel, timer) = setup(80);
let (gate, timer) = setup(80);
timer.key_down();
assert!(gate.load());
timer.key_up();
@@ -156,19 +185,22 @@ mod tests {
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn redown_within_tail_cancels_release() {
let (gate, _sel, timer) = setup(200);
let (gate, timer) = setup(200);
timer.key_down();
timer.key_up();
tokio::time::sleep(Duration::from_millis(20)).await;
timer.key_down();
// Wait past the original tail; gate must still be true.
tokio::time::sleep(Duration::from_millis(250)).await;
assert!(gate.load(), "subsequent key_down should cancel pending release");
assert!(
gate.load(),
"subsequent key_down should cancel pending release"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn zero_tail_clears_immediately() {
let (gate, _sel, timer) = setup(0);
let (gate, timer) = setup(0);
timer.key_down();
assert!(gate.load());
timer.key_up();
@@ -178,9 +210,7 @@ mod tests {
#[test]
fn set_tail_ms_clamps_to_max() {
let gate = AudioTransmitGate::new(false);
let sel = Arc::new(TransmitModeSelector::new(gate));
let timer = ReleaseTailTimer::new(sel, 100);
let (_gate, timer) = setup(100);
timer.set_tail_ms(99999);
assert_eq!(timer.tail_ms(), MAX_TAIL_MS);
timer.set_tail_ms(0);
+1 -2
View File
@@ -93,8 +93,7 @@ impl SdlOutput {
output_gain: Arc<AtomicU32>,
output_muted: Arc<AtomicBool>,
) -> Result<Self, AudioError> {
let sdl = sdl2::init()
.map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?;
let sdl = sdl2::init().map_err(|e| AudioError::Backend(format!("sdl init: {e}")))?;
let subsystem = sdl
.audio()
.map_err(|e| AudioError::Backend(format!("sdl audio subsystem: {e}")))?;
@@ -137,7 +137,10 @@ impl TransmitModeSelector {
/// audio engine's hot read path. Returns a clone so callers
/// don't hold the internal lock.
pub fn gate(&self) -> AudioTransmitGate {
self.gate.read().expect("selector gate lock poisoned").clone()
self.gate
.read()
.expect("selector gate lock poisoned")
.clone()
}
fn compute(&self) -> bool {
+75 -10
View File
@@ -68,7 +68,7 @@ pub fn bridge_init() {
// export depends on it (DEC-016: user-initiated only, never
// auto-upload). It runs alongside whatever platform sink
// exists below; both consume the same `tracing` events.
let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone());
let redact_layer = chanora_core::RedactingLogLayer::new(log_sink().clone()).with_sanitizer();
// On Android, also fan tracing output out to logcat so a user can
// see protocol/audio diagnostics via `adb logcat -s chanora`.
@@ -146,8 +146,7 @@ pub fn log_file_path_str() -> String {
fn log_file_path() -> Option<std::path::PathBuf> {
#[cfg(target_os = "windows")]
{
let base = std::env::var_os("LOCALAPPDATA")
.or_else(|| std::env::var_os("APPDATA"))?;
let base = std::env::var_os("LOCALAPPDATA").or_else(|| std::env::var_os("APPDATA"))?;
Some(
std::path::PathBuf::from(base)
.join("app.chanora")
@@ -167,12 +166,18 @@ fn log_file_path() -> Option<std::path::PathBuf> {
.join("chanora.log"),
)
}
#[cfg(all(unix, not(target_os = "macos"), not(target_os = "android"), not(target_os = "ios")))]
#[cfg(all(
unix,
not(target_os = "macos"),
not(target_os = "android"),
not(target_os = "ios")
))]
{
let base = std::env::var_os("XDG_STATE_HOME")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME").map(|h| std::path::PathBuf::from(h).join(".local").join("state"))
std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join(".local").join("state"))
})?;
Some(
base.join("app.chanora")
@@ -343,7 +348,11 @@ pub async fn connect(
let cfg = chanora_core::ConnectConfig {
address: host,
nickname,
password: if password.is_empty() { None } else { Some(password) },
password: if password.is_empty() {
None
} else {
Some(password)
},
identity: None,
ready_timeout: Duration::from_secs(15),
};
@@ -386,6 +395,34 @@ pub async fn is_connected() -> bool {
// flows through `voice_join` / `voice_leave`, which transparently
// drive `AudioEngine::ensure_running` / `shutdown_if_idle`.
/// Handle iOS AVAudioSession route changes (SDD-100).
#[frb(sync)]
pub fn handle_route_change() {
let result = runtime().block_on(async { session().ios_handle_route_change().await });
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS route-change handling failed");
}
}
/// Handle iOS AVAudioSession interruption begin (SDD-101).
#[frb(sync)]
pub fn handle_interruption_began() {
let result = runtime().block_on(async { session().ios_handle_interruption_began().await });
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS interruption-began handling failed");
}
}
/// Handle iOS AVAudioSession interruption end (SDD-101).
#[frb(sync)]
pub fn handle_interruption_ended(should_resume: bool) {
let result =
runtime().block_on(async { session().ios_handle_interruption_ended(should_resume).await });
if let Err(e) = result {
warn!(target: "chanora_bridge", error = %e, "iOS interruption-ended handling failed");
}
}
/// Set the push-to-talk state.
///
/// Superseded in v1 by [`set_transmit_mode`] + the binding capture
@@ -444,7 +481,11 @@ fn transmit_mode_from_u8(v: u8) -> BridgeTransmitMode {
/// brings up the audio engine if needed, and emits
/// `BridgeEvent::VoiceState`. `password` may be empty.
pub async fn voice_join(channel_id: u64, password: String) -> Result<(), BridgeError> {
let pw = if password.is_empty() { None } else { Some(password) };
let pw = if password.is_empty() {
None
} else {
Some(password)
};
runtime()
.spawn(async move { session().voice_join(channel_id, pw).await })
.await
@@ -578,7 +619,11 @@ pub async fn get_ptt_binding() -> (String, String) {
/// for password-protected channels — pass an empty string when not
/// required.
pub async fn move_to_channel(channel_id: u64, password: String) -> Result<(), BridgeError> {
let pw = if password.is_empty() { None } else { Some(password) };
let pw = if password.is_empty() {
None
} else {
Some(password)
};
runtime()
.spawn(async move { session().move_to_channel(channel_id, pw).await })
.await
@@ -640,9 +685,15 @@ pub struct BridgeAudioStats {
#[frb(sync)]
pub fn export_diagnostics() -> String {
let metadata = vec![
("crate_version".to_string(), env!("CARGO_PKG_VERSION").to_string()),
(
"crate_version".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
),
("target_os".to_string(), std::env::consts::OS.to_string()),
("target_arch".to_string(), std::env::consts::ARCH.to_string()),
(
"target_arch".to_string(),
std::env::consts::ARCH.to_string(),
),
];
match chanora_core::DiagnosticExport::from_sink(log_sink(), metadata) {
Ok(exp) => exp.to_text(),
@@ -858,6 +909,13 @@ pub enum BridgeEvent {
/// Current release-tail in milliseconds (0..=500).
release_tail_ms: u32,
},
/// iOS audio interruption state (SDD-101).
InterruptionState {
/// True when interruption began, false when it ended.
began: bool,
/// Resume recommendation from the platform. False on begin.
should_resume: bool,
},
}
impl From<chanora_core::SessionEvent> for BridgeEvent {
@@ -902,6 +960,13 @@ impl From<chanora_core::SessionEvent> for BridgeEvent {
mute,
release_tail_ms,
},
chanora_core::SessionEvent::InterruptionState {
began,
should_resume,
} => BridgeEvent::InterruptionState {
began,
should_resume,
},
}
}
}
+142 -20
View File
@@ -38,7 +38,7 @@ flutter_rust_bridge::frb_generated_boilerplate!(
default_rust_auto_opaque = RustAutoOpaqueMoi,
);
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_VERSION: &str = "2.12.0";
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1306308591;
pub(crate) const FLUTTER_RUST_BRIDGE_CODEGEN_CONTENT_HASH: i32 = 1322894465;
// Section: executor
@@ -432,6 +432,100 @@ fn wire__crate__api__get_transmit_mode_impl(
},
)
}
fn wire__crate__api__handle_interruption_began_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "handle_interruption_began",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::handle_interruption_began();
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__handle_interruption_ended_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "handle_interruption_ended",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
let api_should_resume = <bool>::sse_decode(&mut deserializer);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::handle_interruption_ended(api_should_resume);
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__handle_route_change_impl(
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
rust_vec_len_: i32,
data_len_: i32,
) -> flutter_rust_bridge::for_generated::WireSyncRust2DartSse {
FLUTTER_RUST_BRIDGE_HANDLER.wrap_sync::<flutter_rust_bridge::for_generated::SseCodec, _>(
flutter_rust_bridge::for_generated::TaskInfo {
debug_name: "handle_route_change",
port: None,
mode: flutter_rust_bridge::for_generated::FfiCallMode::Sync,
},
move || {
let message = unsafe {
flutter_rust_bridge::for_generated::Dart2RustMessageSse::from_wire(
ptr_,
rust_vec_len_,
data_len_,
)
};
let mut deserializer =
flutter_rust_bridge::for_generated::SseDeserializer::new(message);
deserializer.end();
transform_result_sse::<_, ()>((move || {
let output_ok = Result::<_, ()>::Ok({
crate::api::handle_route_change();
})?;
Ok(output_ok)
})())
},
)
}
fn wire__crate__api__init_storage_impl(
port_: flutter_rust_bridge::for_generated::MessagePort,
ptr_: flutter_rust_bridge::for_generated::PlatformGeneralizedUint8ListPtr,
@@ -1314,6 +1408,14 @@ impl SseDecode for crate::api::BridgeEvent {
release_tail_ms: var_releaseTailMs,
};
}
9 => {
let mut var_began = <bool>::sse_decode(deserializer);
let mut var_shouldResume = <bool>::sse_decode(deserializer);
return crate::api::BridgeEvent::InterruptionState {
began: var_began,
should_resume: var_shouldResume,
};
}
_ => {
unimplemented!("");
}
@@ -1515,23 +1617,23 @@ fn pde_ffi_dispatcher_primary_impl(
9 => wire__crate__api__get_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
10 => wire__crate__api__get_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
11 => wire__crate__api__get_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
12 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
13 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
14 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
18 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
23 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
15 => wire__crate__api__init_storage_impl(port, ptr, rust_vec_len, data_len),
16 => wire__crate__api__is_connected_impl(port, ptr, rust_vec_len, data_len),
17 => wire__crate__api__list_bookmarks_impl(port, ptr, rust_vec_len, data_len),
19 => wire__crate__api__move_to_channel_impl(port, ptr, rust_vec_len, data_len),
20 => wire__crate__api__ptt_descriptor_impl(port, ptr, rust_vec_len, data_len),
21 => wire__crate__api__set_hard_mute_impl(port, ptr, rust_vec_len, data_len),
22 => wire__crate__api__set_input_muted_impl(port, ptr, rust_vec_len, data_len),
24 => wire__crate__api__set_output_gain_impl(port, ptr, rust_vec_len, data_len),
25 => wire__crate__api__set_output_muted_impl(port, ptr, rust_vec_len, data_len),
26 => wire__crate__api__set_ptt_impl(port, ptr, rust_vec_len, data_len),
27 => wire__crate__api__set_ptt_binding_impl(port, ptr, rust_vec_len, data_len),
28 => wire__crate__api__set_release_tail_ms_impl(port, ptr, rust_vec_len, data_len),
29 => wire__crate__api__set_transmit_mode_impl(port, ptr, rust_vec_len, data_len),
30 => wire__crate__api__snapshot_impl(port, ptr, rust_vec_len, data_len),
31 => wire__crate__api__update_bookmark_impl(port, ptr, rust_vec_len, data_len),
32 => wire__crate__api__voice_join_impl(port, ptr, rust_vec_len, data_len),
33 => wire__crate__api__voice_leave_impl(port, ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -1545,8 +1647,11 @@ fn pde_ffi_dispatcher_sync_impl(
// Codec=Pde (Serialization + dispatch), see doc to use other codecs
match func_id {
8 => wire__crate__api__export_diagnostics_impl(ptr, rust_vec_len, data_len),
15 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
20 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
12 => wire__crate__api__handle_interruption_began_impl(ptr, rust_vec_len, data_len),
13 => wire__crate__api__handle_interruption_ended_impl(ptr, rust_vec_len, data_len),
14 => wire__crate__api__handle_route_change_impl(ptr, rust_vec_len, data_len),
18 => wire__crate__api__log_file_path_str_impl(ptr, rust_vec_len, data_len),
23 => wire__crate__api__set_network_state_impl(ptr, rust_vec_len, data_len),
_ => unreachable!(),
}
}
@@ -1719,6 +1824,15 @@ impl flutter_rust_bridge::IntoDart for crate::api::BridgeEvent {
release_tail_ms.into_into_dart().into_dart(),
]
.into_dart(),
crate::api::BridgeEvent::InterruptionState {
began,
should_resume,
} => [
9.into_dart(),
began.into_into_dart().into_dart(),
should_resume.into_into_dart().into_dart(),
]
.into_dart(),
_ => {
unimplemented!("");
}
@@ -1984,6 +2098,14 @@ impl SseEncode for crate::api::BridgeEvent {
<bool>::sse_encode(mute, serializer);
<u32>::sse_encode(release_tail_ms, serializer);
}
crate::api::BridgeEvent::InterruptionState {
began,
should_resume,
} => {
<i32>::sse_encode(9, serializer);
<bool>::sse_encode(began, serializer);
<bool>::sse_encode(should_resume, serializer);
}
_ => {
unimplemented!("");
}
+1 -3
View File
@@ -102,9 +102,7 @@ impl From<chanora_core::CoreError> for BridgeError {
) => BridgeError::ServerRejected { code, message },
chanora_core::CoreError::Protocol(p) => BridgeError::Connection(format!("{p}")),
chanora_core::CoreError::Audio(a) => BridgeError::Connection(format!("audio: {a}")),
chanora_core::CoreError::Storage(s) => {
BridgeError::Connection(format!("storage: {s}"))
}
chanora_core::CoreError::Storage(s) => BridgeError::Connection(format!("storage: {s}")),
other => BridgeError::Unmapped(format!("{other}")),
}
}
+92 -11
View File
@@ -258,8 +258,7 @@ fn is_ipv6_like(s: &str) -> bool {
return false;
}
// At least one non-colon char, all are hex or colon.
s.chars().any(|c| c.is_ascii_hexdigit())
&& s.chars().all(|c| c.is_ascii_hexdigit() || c == ':')
s.chars().any(|c| c.is_ascii_hexdigit()) && s.chars().all(|c| c.is_ascii_hexdigit() || c == ':')
}
fn redact_email(s: &str) -> String {
@@ -303,7 +302,13 @@ fn redact_tokens(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut buf = String::new();
for ch in s.chars() {
if ch.is_ascii_alphanumeric() || ch == '+' || ch == '/' || ch == '=' || ch == '_' || ch == '-' {
if ch.is_ascii_alphanumeric()
|| ch == '+'
|| ch == '/'
|| ch == '='
|| ch == '_'
|| ch == '-'
{
buf.push(ch);
} else {
if looks_like_token(&buf) {
@@ -352,7 +357,9 @@ impl InMemoryLogSink {
pub fn new(capacity: usize, redactor: Redactor) -> Self {
Self {
capacity,
buf: Arc::new(Mutex::new(std::collections::VecDeque::with_capacity(capacity))),
buf: Arc::new(Mutex::new(std::collections::VecDeque::with_capacity(
capacity,
))),
redactor,
}
}
@@ -551,6 +558,15 @@ impl Visit for PttBanCheckVisitor {
fn record_bool(&mut self, field: &Field, _value: bool) {
self.check(field.name());
}
fn record_f64(&mut self, field: &Field, _value: f64) {
self.check(field.name());
}
fn record_i128(&mut self, field: &Field, _value: i128) {
self.check(field.name());
}
fn record_u128(&mut self, field: &Field, _value: u128) {
self.check(field.name());
}
}
#[derive(Default)]
@@ -639,7 +655,10 @@ mod tests {
#[test]
fn redacts_ipv4() {
let r = Redactor::default();
assert_eq!(r.redact("connect to 192.168.1.1:9987"), "connect to [ip]:9987");
assert_eq!(
r.redact("connect to 192.168.1.1:9987"),
"connect to [ip]:9987"
);
}
#[test]
@@ -653,10 +672,7 @@ mod tests {
#[test]
fn redacts_email() {
let r = Redactor::default();
assert_eq!(
r.redact("user alice@example.com bug"),
"user [email] bug"
);
assert_eq!(r.redact("user alice@example.com bug"), "user [email] bug");
}
#[test]
@@ -780,8 +796,7 @@ mod tests {
tracing::info!(key_code = 42, "banned record must drop");
tracing::info!(backend_id = "linux-portal", "safe record must pass");
});
let exported =
DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
let text = exported.to_text();
assert!(
!text.contains("banned record must drop"),
@@ -792,4 +807,70 @@ mod tests {
"sanitiser must forward the safe record to the inner layer"
);
}
#[test]
fn banned_field_f64_is_caught() {
use tracing_subscriber::layer::SubscriberExt;
let secrets = KnownSecretRegistry::default();
let redactor = Redactor::with_secrets(secrets);
let sink = InMemoryLogSink::new(16, redactor);
let inner = RedactingLogLayer::new(sink.clone());
let sanitised = PttSanitizer::wrap(inner);
let subscriber = tracing_subscriber::registry().with(sanitised);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(key_code = 42.5_f64, "f64 banned record must drop");
tracing::info!(backend_id = "linux-portal", "safe record must pass");
});
let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
let text = exported.to_text();
assert!(!text.contains("f64 banned record must drop"));
assert!(text.contains("safe record must pass"));
}
#[test]
fn banned_field_i128_is_caught() {
use tracing_subscriber::layer::SubscriberExt;
let secrets = KnownSecretRegistry::default();
let redactor = Redactor::with_secrets(secrets);
let sink = InMemoryLogSink::new(16, redactor);
let inner = RedactingLogLayer::new(sink.clone());
let sanitised = PttSanitizer::wrap(inner);
let subscriber = tracing_subscriber::registry().with(sanitised);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(scan_code = 42_i128, "i128 banned record must drop");
tracing::info!(backend_id = "linux-portal", "safe record must pass");
});
let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
let text = exported.to_text();
assert!(!text.contains("i128 banned record must drop"));
assert!(text.contains("safe record must pass"));
}
#[test]
fn banned_field_u128_is_caught() {
use tracing_subscriber::layer::SubscriberExt;
let secrets = KnownSecretRegistry::default();
let redactor = Redactor::with_secrets(secrets);
let sink = InMemoryLogSink::new(16, redactor);
let inner = RedactingLogLayer::new(sink.clone());
let sanitised = PttSanitizer::wrap(inner);
let subscriber = tracing_subscriber::registry().with(sanitised);
tracing::subscriber::with_default(subscriber, || {
tracing::info!(virtual_key = 42_u128, "u128 banned record must drop");
tracing::info!(backend_id = "linux-portal", "safe record must pass");
});
let exported = DiagnosticExport::from_sink(&sink, vec![("k".into(), "v".into())]).unwrap();
let text = exported.to_text();
assert!(!text.contains("u128 banned record must drop"));
assert!(text.contains("safe record must pass"));
}
}
+85 -21
View File
@@ -454,7 +454,9 @@ async fn connection_task(
Some(Err(e)) => {
let msg = format!("{e}");
let _ = ready_tx.send(Err(ProtocolError::DisconnectedEarly(msg.clone())));
exit!(DisconnectReason::Error(format!("disconnected early: {msg}")));
exit!(DisconnectReason::Error(format!(
"disconnected early: {msg}"
)));
}
None => {
let msg = "event stream ended before snapshot".to_string();
@@ -592,7 +594,11 @@ async fn connection_task(
let expired: Vec<MessageHandle> = pending_moves
.iter()
.filter_map(|(handle, (_, deadline))| {
if now >= *deadline { Some(*handle) } else { None }
if now >= *deadline {
Some(*handle)
} else {
None
}
})
.collect();
for handle in expired {
@@ -608,11 +614,14 @@ async fn connection_task(
let snap = build_snapshot(&con);
let _ = reply.send(snap);
}
Ok(Request::MoveToChannel { channel_id, password, reply }) => {
Ok(Request::MoveToChannel {
channel_id,
password,
reply,
}) => {
match move_self_to(&mut con, channel_id, password.as_deref()) {
Ok(handle) => {
let deadline =
std::time::Instant::now() + Duration::from_secs(3);
let deadline = std::time::Instant::now() + Duration::from_secs(3);
pending_moves.insert(handle, (reply, deadline));
}
Err(e) => {
@@ -622,7 +631,11 @@ async fn connection_task(
}
}
}
Ok(Request::SetMuted { input, output, reply }) => {
Ok(Request::SetMuted {
input,
output,
reply,
}) => {
let r = set_self_muted(&mut con, input, output);
let _ = reply.send(r);
}
@@ -785,8 +798,7 @@ fn sort_channels_tree_by<'a, T>(
// Defensive: if a channel's `parent` does not appear anywhere
// in the emitted tree (orphaned subtree) append it so it isn't
// lost. We track emitted ids and dump anything else.
let emitted: std::collections::HashSet<u64> =
out.iter().map(|c| extract(c).0).collect();
let emitted: std::collections::HashSet<u64> = out.iter().map(|c| extract(c).0).collect();
let mut orphans: Vec<&'a T> = items
.iter()
.copied()
@@ -916,10 +928,26 @@ mod tests {
// iteration order. order=0 -> first; order=X means "comes
// after the channel with id=X". Expected emitted order is
// the linked-list walk: a -> b -> c -> d.
let a = FakeChannel { id: 100, parent: 0, order: 0 };
let b = FakeChannel { id: 200, parent: 0, order: 100 };
let c = FakeChannel { id: 300, parent: 0, order: 200 };
let d = FakeChannel { id: 400, parent: 0, order: 300 };
let a = FakeChannel {
id: 100,
parent: 0,
order: 0,
};
let b = FakeChannel {
id: 200,
parent: 0,
order: 100,
};
let c = FakeChannel {
id: 300,
parent: 0,
order: 200,
};
let d = FakeChannel {
id: 400,
parent: 0,
order: 300,
};
// Deliberately shuffled inputs.
let inputs: Vec<&FakeChannel> = vec![&c, &a, &d, &b];
let sorted = sort_channels_tree_by(&inputs, extract);
@@ -933,9 +961,21 @@ mod tests {
// = 999 which does not exist among the siblings. c must
// not be dropped — it falls back to the leftover bucket
// appended sorted by id at the end.
let a = FakeChannel { id: 100, parent: 0, order: 0 };
let b = FakeChannel { id: 200, parent: 0, order: 100 };
let c = FakeChannel { id: 300, parent: 0, order: 999 };
let a = FakeChannel {
id: 100,
parent: 0,
order: 0,
};
let b = FakeChannel {
id: 200,
parent: 0,
order: 100,
};
let c = FakeChannel {
id: 300,
parent: 0,
order: 999,
};
let inputs: Vec<&FakeChannel> = vec![&c, &a, &b];
let sorted = sort_channels_tree_by(&inputs, extract);
let ids: Vec<u64> = sorted.iter().map(|c| c.id).collect();
@@ -951,10 +991,26 @@ mod tests {
// │ └── a2 (id=12, parent=10, order=11)
// └── b (id=20, order=10)
// Expected emission: a, a1, a2, b
let a = FakeChannel { id: 10, parent: 0, order: 0 };
let a1 = FakeChannel { id: 11, parent: 10, order: 0 };
let a2 = FakeChannel { id: 12, parent: 10, order: 11 };
let b = FakeChannel { id: 20, parent: 0, order: 10 };
let a = FakeChannel {
id: 10,
parent: 0,
order: 0,
};
let a1 = FakeChannel {
id: 11,
parent: 10,
order: 0,
};
let a2 = FakeChannel {
id: 12,
parent: 10,
order: 11,
};
let b = FakeChannel {
id: 20,
parent: 0,
order: 10,
};
let inputs: Vec<&FakeChannel> = vec![&b, &a2, &a, &a1];
let sorted = sort_channels_tree_by(&inputs, extract);
let ids: Vec<u64> = sorted.iter().map(|c| c.id).collect();
@@ -966,8 +1022,16 @@ mod tests {
// a says "comes after b"; b says "comes after a". The
// walk must terminate (cycle guard) and both channels
// must still appear in the output via the leftover path.
let a = FakeChannel { id: 1, parent: 0, order: 2 };
let b = FakeChannel { id: 2, parent: 0, order: 1 };
let a = FakeChannel {
id: 1,
parent: 0,
order: 2,
};
let b = FakeChannel {
id: 2,
parent: 0,
order: 1,
};
let inputs: Vec<&FakeChannel> = vec![&a, &b];
let sorted = sort_channels_tree_by(&inputs, extract);
// Both reachable in some deterministic order (id-sorted
+2 -4
View File
@@ -34,8 +34,7 @@ struct CacheEntry {
at: Instant,
}
static CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> =
Lazy::new(|| Mutex::new(HashMap::new()));
static CACHE: Lazy<Mutex<HashMap<String, CacheEntry>>> = Lazy::new(|| Mutex::new(HashMap::new()));
/// Resolve `host_input` to a list of socket addresses, preferring
/// IPv4 over IPv6 so the upstream's first connect attempt is the
@@ -189,8 +188,7 @@ mod tests {
#[tokio::test]
async fn unresolvable_returns_dns_failed() {
let r =
resolve("nonexistent-server-for-chanora-tests.invalid").await;
let r = resolve("nonexistent-server-for-chanora-tests.invalid").await;
match r {
Err(ProtocolError::DnsFailed { host, .. }) => {
assert!(host.contains("nonexistent-server-for-chanora-tests.invalid"));
+35 -17
View File
@@ -218,7 +218,12 @@ impl IdentityFileStore {
if keyring_disabled() {
return Ok(None);
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))]
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
@@ -252,7 +257,12 @@ impl IdentityFileStore {
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))]
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
{
Ok(None)
}
@@ -268,7 +278,12 @@ impl IdentityFileStore {
if keyring_disabled() {
return false;
}
#[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios"))]
#[cfg(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
))]
{
use base64::Engine;
let entry = match keyring::Entry::new(Self::KEYRING_SERVICE, &self.keyring_account) {
@@ -287,7 +302,12 @@ impl IdentityFileStore {
}
}
}
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows", target_os = "ios")))]
#[cfg(not(any(
target_os = "linux",
target_os = "macos",
target_os = "windows",
target_os = "ios"
)))]
{
false
}
@@ -389,12 +409,10 @@ impl IdentityFileStore {
let cipher = ChaCha20Poly1305::new(key);
let nonce_bytes = &buf[..12];
let nonce = Nonce::from_slice(nonce_bytes);
let pt = cipher
.decrypt(nonce, &buf[12..])
.map_err(|e| {
key_bytes.zeroize();
StorageError::Crypto(format!("decrypt: {e}"))
})?;
let pt = cipher.decrypt(nonce, &buf[12..]).map_err(|e| {
key_bytes.zeroize();
StorageError::Crypto(format!("decrypt: {e}"))
})?;
key_bytes.zeroize();
let s = String::from_utf8(pt)
.map_err(|e| StorageError::Crypto(format!("plaintext not utf8: {e}")))?;
@@ -560,10 +578,7 @@ impl IdentityFileStore {
Ok(())
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(StorageError::Io(format!(
"remove {:?}: {e}",
self.path
))),
Err(e) => Err(StorageError::Io(format!("remove {:?}: {e}", self.path))),
}
}
}
@@ -596,8 +611,8 @@ fn is_plausibly_legacy_plaintext(buf: &[u8]) -> bool {
/// Read a 32-byte DEK from `path`. Used by the file-fallback path
/// and the legacy migration path inside `ensure_dek`.
fn read_file_dek(path: &Path) -> Result<[u8; 32], StorageError> {
let mut f = fs::File::open(path)
.map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?;
let mut f =
fs::File::open(path).map_err(|e| StorageError::Io(format!("open dek {:?}: {e}", path)))?;
let mut key = [0u8; 32];
f.read_exact(&mut key)
.map_err(|e| StorageError::Io(format!("read dek: {e}")))?;
@@ -1093,7 +1108,10 @@ mod tests {
store.save("9999VabcdefghIJKLmnop=").unwrap();
let raw = fs::read(store.path()).unwrap();
assert!(!raw.starts_with(b"9999"));
assert_eq!(store.load().unwrap().as_deref(), Some("9999VabcdefghIJKLmnop="));
assert_eq!(
store.load().unwrap().as_deref(),
Some("9999VabcdefghIJKLmnop=")
);
}
#[test]