feat(ios,p0): iOS P0 platform, audio fixes, channel UX
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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}")))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 (0–500 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 (0–500 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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!("");
|
||||
}
|
||||
|
||||
@@ -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}")),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user