From 85b12a26c91de71e6f00270a4d72dfcbe5d78f74 Mon Sep 17 00:00:00 2001 From: Mollusk Date: Thu, 18 Jun 2026 16:47:13 -0400 Subject: [PATCH] Windows port Phase 0: platform-select the audio backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Make the tree compile for Windows without touching core logic, by confining all Linux/PipeWire assumptions behind cfg gates and a single platform-selected backend alias. No new dependencies — the cpal/WASAPI backend lands in Phase 1; this ships a no-op stub. - Cargo.toml: move pipewire + rfd(xdg-portal) under cfg(unix); add a cfg(windows) rfd using the Win32 dialog backend. - audio: gate pipewire_impl to unix, add a cpal_impl stub for windows, and select between them via the new PlatformAudioBackend alias. - core: use PlatformAudioBackend instead of the concrete PipeWireBackend. - lib: gate the unix-only 0o600 log-file mode code (+ its test); Windows logs inherit the directory ACL. - audio_probe: gate this PipeWire diagnostic to unix with a stub main. - app: open URLs via rundll32 on windows, xdg-open on unix (shell-free). - ci: add .gitea/workflows/windows-build.yml (M1) — build + lib tests for x86_64-pc-windows-msvc, with CMAKE_POLICY_VERSION_MINIMUM=3.5 for the vendored libopus build. Needs a windows act_runner to actually run. Linux build/clippy/tests green (316/316). The Windows path is verified by inspection only (no local Windows toolchain); CI is the real gate. Co-Authored-By: Claude Opus 4.8 --- .gitea/workflows/windows-build.yml | 85 ++++++++++++ Cargo.toml | 30 ++++- src/app/mod.rs | 28 +++- src/audio/cpal_impl.rs | 67 ++++++++++ src/audio/mod.rs | 18 +++ src/bin/audio_probe.rs | 206 ++++++++++++++++------------- src/core/mod.rs | 8 +- src/lib.rs | 32 +++-- 8 files changed, 354 insertions(+), 120 deletions(-) create mode 100644 .gitea/workflows/windows-build.yml create mode 100644 src/audio/cpal_impl.rs diff --git a/.gitea/workflows/windows-build.yml b/.gitea/workflows/windows-build.yml new file mode 100644 index 0000000..59dc481 --- /dev/null +++ b/.gitea/workflows/windows-build.yml @@ -0,0 +1,85 @@ +name: windows-build + +# Milestone M1 of the Windows port (see docs/handoff windows-migration-plan): +# prove the tree compiles for `x86_64-pc-windows-msvc` and the unit tests pass. +# The audio backend is the Phase 0 `CpalBackend` stub for now — this job guards +# the *compile* boundary (cfg gating, platform deps, the PlatformAudioBackend +# alias) so a Unix-only assumption can't sneak back in and break Windows. +# +# RUNNER REQUIREMENT: this needs a Windows act_runner registered with the +# `windows-latest` label (the Linux `cargo-deny` job's container approach does +# NOT apply here — Windows jobs run on the host, not a Linux container). If your +# runner advertises a different label, change `runs-on` below. Until a Windows +# runner exists this workflow is simply skipped/queued, not a failure of the +# Linux CI. +# +# BUILD-HOST REQUIREMENTS (validated by the opus spike, see +# peerspeak-windows-opus-spike.md): +# - MSVC C toolchain (Visual Studio Build Tools) — to compile vendored libopus. +# - CMake on PATH — `audiopus_sys` builds libopus from source via cmake. +# - CMAKE_POLICY_VERSION_MINIMUM=3.5 (set below) — the vendored libopus declares +# an ancient `cmake_minimum_required` that CMake >= 4.0 refuses without it. +# GitHub-hosted `windows-latest` images ship MSVC + CMake; a self-hosted runner +# must provide both. + +on: + push: + # `main` plus the in-progress port branches, so the Windows path is exercised + # before merge rather than only after. + branches: [main, "windows-port-**"] + pull_request: + # Allow manual runs from the Gitea Actions UI. + workflow_dispatch: + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + # The vendored libopus (audiopus_sys -> cmake) uses cmake_minimum_required < 3.5, + # which CMake 4.x rejects unless this is set. See the opus spike report. + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + +jobs: + windows-build: + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust (MSVC, pinned to repo toolchain if present) + uses: dtolnay/rust-toolchain@stable + with: + targets: x86_64-pc-windows-msvc + components: clippy + + - name: Show toolchain + build prerequisites + shell: bash + run: | + set -euo pipefail + rustc --version + cargo --version + # libopus is built from source via cmake; fail early with a clear + # message if the runner lacks it rather than deep in the opus build. + if ! command -v cmake >/dev/null 2>&1; then + echo "::error::cmake not found on PATH. The opus crate builds libopus from source via cmake; install CMake on this runner." + exit 1 + fi + cmake --version + + # Build on a *locked* tree so the pinned, vetted Cargo.lock versions are what + # get compiled — same supply-chain stance as the cargo-deny job. + - name: Build (all targets, msvc) + run: cargo build --all-targets --locked --target x86_64-pc-windows-msvc + + # Unit (lib) tests only: the `transport_loopback` integration tests stand up + # real iroh/QUIC endpoints and need working loopback networking, which isn't + # guaranteed on a CI runner. Add `--tests` here once a networked Windows + # runner is confirmed. + - name: Unit tests (lib, msvc) + run: cargo test --lib --locked --target x86_64-pc-windows-msvc + + # Informational for now (not `-D warnings`): the Windows tree may surface + # platform-specific lints we haven't triaged. Tighten to deny-warnings once + # it's clean. + - name: Clippy (msvc) + run: cargo clippy --all-targets --locked --target x86_64-pc-windows-msvc diff --git a/Cargo.toml b/Cargo.toml index 4cbfedc..54421d2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,17 +30,12 @@ bytes = "1.11.1" dirs = "6.0.0" iced = { version = "0.14.0", features = ["canvas", "image"] } # W4 custom avatars: decode/resize an arbitrary user image (png/jpeg only to keep -# the codec surface small) and a native file picker (xdg-portal backend, no GTK). +# the codec surface small). The matching native file picker (`rfd`) is platform- +# gated below — its backend differs per OS (xdg-portal on Linux, Win32 on Windows). image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } -rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } iroh = "1.0.0-rc.0" iroh-gossip = "0.99.0" opus = "0.3.1" -# v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle quantum), used by -# the playback RT callback to fill exactly what the device asks for instead of -# pinning the buffer to a hard-coded 1024-frame quantum (crackle on non-1024 -# hardware). The field has existed in libpipewire since 0.3.49 (2022). -pipewire = { version = "0.9", features = ["v0_3_49"] } rand = "0.10.1" ringbuf = "0.5.0" serde = { version = "1.0.228", features = ["derive"] } @@ -48,3 +43,24 @@ serde_json = "1.0.150" thiserror = "2.0.18" tokio = { version = "1.52.3", features = ["full"] } tokio-stream = "0.1.18" + +# --- Platform-specific dependencies ----------------------------------------- +# Audio and the native file-picker backends differ per OS. Everything else in the +# app talks to the `AudioBackend` trait and the `PlatformAudioBackend` alias (see +# `src/audio/mod.rs`), so platform selection is confined to these few lines. + +[target.'cfg(unix)'.dependencies] +# Linux audio backend. v0_3_49 exposes `Buffer::requested()` (the graph's per-cycle +# quantum), used by the playback RT callback to fill exactly what the device asks +# for instead of a hard-coded 1024-frame quantum (crackle on non-1024 hardware). +# The field has existed in libpipewire since 0.3.49 (2022). +pipewire = { version = "0.9", features = ["v0_3_49"] } +# Native file picker via the XDG desktop portal (no GTK) on Linux. +rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] } + +[target.'cfg(windows)'.dependencies] +# Native file picker using the built-in Win32 dialog backend on Windows. +rfd = { version = "0.17", default-features = false } +# NOTE: the Windows audio backend (cpal/WASAPI) lands in Phase 1. Until then the +# Windows build uses the no-op `CpalBackend` stub in `src/audio/cpal_impl.rs`, +# which needs no extra dependency. diff --git a/src/app/mod.rs b/src/app/mod.rs index 8352bfc..928157e 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -1350,12 +1350,28 @@ fn update(state: &mut AppState, message: AppMessage) -> Task { // Defence in depth: only ever hand http(s) URLs to the opener. The // link span's href came from `linkify`, which only emits http/https, // but re-check here so this can't be widened into launching arbitrary - // schemes/args. `xdg-open` receives the URL as a single argv entry - // (no shell), so there's no injection surface. - if (url.starts_with("http://") || url.starts_with("https://")) - && let Err(e) = std::process::Command::new("xdg-open").arg(&url).spawn() - { - crate::log_msg(&format!("Failed to open URL {url:?}: {e}")); + // schemes/args. Each opener receives the URL as a single argv entry + // (no shell), so there's no injection surface: + // - Unix: `xdg-open `. + // - Windows: `rundll32 url.dll,FileProtocolHandler ` — opens the + // default browser without going through `cmd`/`start`, which would + // otherwise re-parse `&` in query strings. + if url.starts_with("http://") || url.starts_with("https://") { + let spawned = { + #[cfg(unix)] + { + std::process::Command::new("xdg-open").arg(&url).spawn() + } + #[cfg(windows)] + { + std::process::Command::new("rundll32") + .args(["url.dll,FileProtocolHandler", &url]) + .spawn() + } + }; + if let Err(e) = spawned { + crate::log_msg(&format!("Failed to open URL {url:?}: {e}")); + } } } AppMessage::ToggleMicTest(enabled) => { diff --git a/src/audio/cpal_impl.rs b/src/audio/cpal_impl.rs new file mode 100644 index 0000000..783a814 --- /dev/null +++ b/src/audio/cpal_impl.rs @@ -0,0 +1,67 @@ +//! Windows audio backend (cpal/WASAPI) — **Phase 0 stub**. +//! +//! This is a compile-and-run placeholder so the Windows build links and the app +//! starts up (networking, UI, and text chat all functional) while the real +//! capture/playback implementation lands in Phase 1. Every method satisfies the +//! [`AudioBackend`] contract as a no-op: no microphone is captured and nothing is +//! played. It deliberately pulls in no extra dependency — `cpal` is added only +//! when the real implementation arrives. +//! +//! Phase 1 will replace this with cpal streams on the WASAPI host, mapping: +//! - `start_capture` → input stream, f32→i16, mono 48 kHz, into `tx`; +//! - `start_playback` → output stream draining a `ringbuf`, keeping `ring_fill` +//! updated so the existing hardware-clock pacing in the mixer keeps working; +//! - `stop` → drop the streams. + +use std::sync::Arc; +use std::sync::atomic::AtomicUsize; +use std::sync::mpsc::{Receiver, Sender}; + +use super::{AudioBackend, AudioError}; + +/// No-op Windows audio backend (Phase 0). See module docs. +pub struct CpalBackend; + +impl CpalBackend { + pub fn new() -> Self { + crate::log_msg("CpalBackend: Phase 0 stub active (no audio I/O yet)"); + CpalBackend + } +} + +impl Default for CpalBackend { + fn default() -> Self { + Self::new() + } +} + +impl AudioBackend for CpalBackend { + fn start_capture( + &self, + _tx: Sender>, + _target_node: Option, + ) -> Result<(), AudioError> { + // No capture stream yet: dropping `_tx` simply means no samples are ever + // produced (silent mic), which is the intended Phase 0 behaviour. + crate::log_msg("CpalBackend::start_capture: not yet implemented (Phase 1) — capturing silence"); + Ok(()) + } + + fn start_playback( + &self, + rx: Receiver>, + _target_node: Option, + _ring_fill: Arc, + ) -> Result<(), AudioError> { + // Drain and discard incoming audio on a detached thread so the mixer's + // producer never blocks or sees a closed channel. This keeps the rest of + // the pipeline running normally while output is silent. + std::thread::spawn(move || while rx.recv().is_ok() {}); + crate::log_msg("CpalBackend::start_playback: not yet implemented (Phase 1) — discarding output"); + Ok(()) + } + + fn stop(&self) -> Result<(), AudioError> { + Ok(()) + } +} diff --git a/src/audio/mod.rs b/src/audio/mod.rs index 4a83742..c601a36 100644 --- a/src/audio/mod.rs +++ b/src/audio/mod.rs @@ -62,6 +62,24 @@ pub mod gate; pub mod limiter; pub mod multitrack; pub mod pan; +#[cfg(unix)] pub mod pipewire_impl; +#[cfg(windows)] +pub mod cpal_impl; pub mod pw_cli; pub mod recorder; + +/// The audio backend implementation for the current platform. +/// +/// The whole app constructs and threads this alias (via +/// `PlatformAudioBackend::new()`) rather than any concrete backend type, so +/// platform selection lives entirely here. Both implementations satisfy the +/// [`AudioBackend`] trait, which is the only interface the core talks to. +/// +/// - Linux/Unix → PipeWire ([`pipewire_impl::PipeWireBackend`]). +/// - Windows → cpal/WASAPI ([`cpal_impl::CpalBackend`]); a no-op stub until the +/// Phase 1 capture/playback implementation lands. +#[cfg(unix)] +pub type PlatformAudioBackend = pipewire_impl::PipeWireBackend; +#[cfg(windows)] +pub type PlatformAudioBackend = cpal_impl::CpalBackend; diff --git a/src/bin/audio_probe.rs b/src/bin/audio_probe.rs index e655622..c3fcee3 100644 --- a/src/bin/audio_probe.rs +++ b/src/bin/audio_probe.rs @@ -17,105 +17,121 @@ //! //! Run: cargo run --bin audio_probe -- [freq_hz] [seconds] [target_node] //! e.g. cargo run --release --bin audio_probe -- 440 30 +//! +//! This probe exercises the PipeWire backend directly, so it is a Unix-only tool. +//! On non-Unix targets `main` is a stub that explains the limitation. -use std::io::{BufRead, BufReader, Seek, SeekFrom}; -use std::sync::Arc; -use std::sync::atomic::AtomicUsize; -use std::sync::mpsc; -use std::time::Duration; - -use peerspeak::audio::AudioBackend; -use peerspeak::audio::pipewire_impl::PipeWireBackend; -use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz - -const SAMPLE_RATE: f32 = 48_000.0; - -#[tokio::main] -async fn main() { - let mut args = std::env::args().skip(1); - let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0); - let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30); - let target_node: Option = args.next(); - - // The playout-health logger is quiet in normal operation (it only logs - // glitches); ask it for the full once-per-second heartbeat so the probe can - // show the steady-state numbers. - // SAFETY: set before any playback thread starts, so no concurrent env read. - unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") }; - - println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path."); - println!("Listen for clicks/pops; watch the playout-health lines below.\n"); - - // Tail the app log (where playout-health lines land) to stdout in the - // background so it's all in one terminal. - spawn_log_tailer(); - - let backend = PipeWireBackend::new(); - let (tx, rx) = mpsc::channel::>(); - let ring_fill = Arc::new(AtomicUsize::new(0)); - if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) { - eprintln!("failed to start playback: {e}"); - return; - } - - // Phase-continuous sine, generated one 20ms frame at a time, fill-paced - // exactly like the production mixer: only produce while the ring is below - // target, so production tracks the PipeWire hardware clock. - use std::sync::atomic::Ordering; - let deadline = tokio::time::Instant::now() + Duration::from_secs(secs); - let mut n: u64 = 0; // running sample index keeps phase continuous across frames - while tokio::time::Instant::now() < deadline { - if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES { - tokio::time::sleep(Duration::from_millis(2)).await; - continue; - } - let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS); - for _ in 0..FRAME_SAMPLES { - let t = n as f32 / SAMPLE_RATE; - // 0.25 amplitude: clearly audible but not harsh. - let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16; - // Stereo playback bus: duplicate the probe tone to L/R. - frame.push(sample); - frame.push(sample); - n += 1; - } - if tx.send(frame).is_err() { - eprintln!("playback channel closed early"); - break; - } - } - - // Let the ring drain, then stop. - tokio::time::sleep(Duration::from_millis(300)).await; - let _ = backend.stop(); - println!("\naudio_probe: done."); +#[cfg(unix)] +fn main() { + unix_probe::run(); } -/// Open the app log, seek to the end, and echo new lines (the `playout-health:` -/// reports) to stdout once they appear. -fn spawn_log_tailer() { - let path = peerspeak::log_file_path(); - std::thread::spawn(move || { - // Wait for the file to exist (first log_msg creates it). - let file = loop { - if let Ok(f) = std::fs::File::open(&path) { - break f; +#[cfg(not(unix))] +fn main() { + eprintln!("audio_probe is only supported on Unix builds (it drives the PipeWire backend directly)."); +} + +#[cfg(unix)] +mod unix_probe { + use std::io::{BufRead, BufReader, Seek, SeekFrom}; + use std::sync::Arc; + use std::sync::atomic::AtomicUsize; + use std::sync::mpsc; + use std::time::Duration; + + use peerspeak::audio::AudioBackend; + use peerspeak::audio::pipewire_impl::PipeWireBackend; + use peerspeak::core::jitter::FRAME_SAMPLES; // 960 mono frames = 20ms @ 48kHz + + const SAMPLE_RATE: f32 = 48_000.0; + + #[tokio::main] + pub async fn run() { + let mut args = std::env::args().skip(1); + let freq: f32 = args.next().and_then(|s| s.parse().ok()).unwrap_or(440.0); + let secs: u64 = args.next().and_then(|s| s.parse().ok()).unwrap_or(30); + let target_node: Option = args.next(); + + // The playout-health logger is quiet in normal operation (it only logs + // glitches); ask it for the full once-per-second heartbeat so the probe can + // show the steady-state numbers. + // SAFETY: set before any playback thread starts, so no concurrent env read. + unsafe { std::env::set_var("PEERSPEAK_AUDIO_VERBOSE", "1") }; + + println!("audio_probe: {freq} Hz tone for {secs}s through the real playback path."); + println!("Listen for clicks/pops; watch the playout-health lines below.\n"); + + // Tail the app log (where playout-health lines land) to stdout in the + // background so it's all in one terminal. + spawn_log_tailer(); + + let backend = PipeWireBackend::new(); + let (tx, rx) = mpsc::channel::>(); + let ring_fill = Arc::new(AtomicUsize::new(0)); + if let Err(e) = backend.start_playback(rx, target_node, ring_fill.clone()) { + eprintln!("failed to start playback: {e}"); + return; + } + + // Phase-continuous sine, generated one 20ms frame at a time, fill-paced + // exactly like the production mixer: only produce while the ring is below + // target, so production tracks the PipeWire hardware clock. + use std::sync::atomic::Ordering; + let deadline = tokio::time::Instant::now() + Duration::from_secs(secs); + let mut n: u64 = 0; // running sample index keeps phase continuous across frames + while tokio::time::Instant::now() < deadline { + if ring_fill.load(Ordering::Relaxed) >= peerspeak::audio::PLAYBACK_TARGET_SAMPLES { + tokio::time::sleep(Duration::from_millis(2)).await; + continue; } - std::thread::sleep(Duration::from_millis(100)); - }; - let mut reader = BufReader::new(file); - let _ = reader.seek(SeekFrom::End(0)); - loop { - let mut line = String::new(); - match reader.read_line(&mut line) { - Ok(0) => std::thread::sleep(Duration::from_millis(150)), - Ok(_) => { - if line.contains("playout-health:") { - print!("{line}"); - } + let mut frame = Vec::with_capacity(FRAME_SAMPLES * peerspeak::audio::PLAYBACK_CHANNELS); + for _ in 0..FRAME_SAMPLES { + let t = n as f32 / SAMPLE_RATE; + // 0.25 amplitude: clearly audible but not harsh. + let sample = (0.25 * i16::MAX as f32 * (2.0 * std::f32::consts::PI * freq * t).sin()) as i16; + // Stereo playback bus: duplicate the probe tone to L/R. + frame.push(sample); + frame.push(sample); + n += 1; + } + if tx.send(frame).is_err() { + eprintln!("playback channel closed early"); + break; + } + } + + // Let the ring drain, then stop. + tokio::time::sleep(Duration::from_millis(300)).await; + let _ = backend.stop(); + println!("\naudio_probe: done."); + } + + /// Open the app log, seek to the end, and echo new lines (the `playout-health:` + /// reports) to stdout once they appear. + fn spawn_log_tailer() { + let path = peerspeak::log_file_path(); + std::thread::spawn(move || { + // Wait for the file to exist (first log_msg creates it). + let file = loop { + if let Ok(f) = std::fs::File::open(&path) { + break f; + } + std::thread::sleep(Duration::from_millis(100)); + }; + let mut reader = BufReader::new(file); + let _ = reader.seek(SeekFrom::End(0)); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => std::thread::sleep(Duration::from_millis(150)), + Ok(_) => { + if line.contains("playout-health:") { + print!("{line}"); + } + } + Err(_) => std::thread::sleep(Duration::from_millis(150)), } - Err(_) => std::thread::sleep(Duration::from_millis(150)), } - } - }); + }); + } } diff --git a/src/core/mod.rs b/src/core/mod.rs index 2a6b58d..41869b1 100644 --- a/src/core/mod.rs +++ b/src/core/mod.rs @@ -1,7 +1,7 @@ pub mod messages; pub mod jitter; -use crate::audio::{AudioBackend, pipewire_impl::PipeWireBackend}; +use crate::audio::{AudioBackend, PlatformAudioBackend}; use crate::audio::eq::{Eq, EqSettings}; use crate::codec::{AudioEncoder, opus_impl::OpusEncoder}; use crate::core::jitter::{JitterBuffer, FRAME_SAMPLES}; @@ -237,7 +237,7 @@ fn run_mic_monitor( /// Stops a standalone mic monitor if one is running. MUST NOT be called while a /// room session is active — `backend.stop()` would also tear down the call's /// capture/playback. Monitor and session are mutually exclusive by construction. -fn stop_mic_monitor(backend: &PipeWireBackend, monitor: Option) { +fn stop_mic_monitor(backend: &PlatformAudioBackend, monitor: Option) { if let Some(m) = monitor { let _ = backend.stop(); let _ = m.thread.join(); @@ -400,7 +400,7 @@ struct ActiveSession { } impl ActiveSession { - async fn shutdown(mut self, audio_backend: Arc) { + async fn shutdown(mut self, audio_backend: Arc) { crate::log_msg("ActiveSession::shutdown started"); // Tear down any screen-share children first so the host stops streaming // promptly (kill_on_drop is the backstop, but kill explicitly so viewers @@ -731,7 +731,7 @@ async fn run_core_loop( let known_peers: Arc>>> = Arc::new(std::sync::Mutex::new(HashMap::new())); - let audio_backend = Arc::new(PipeWireBackend::new()); + let audio_backend = Arc::new(PlatformAudioBackend::new()); let is_muted = Arc::new(AtomicBool::new(false)); let is_deafened = Arc::new(AtomicBool::new(false)); diff --git a/src/lib.rs b/src/lib.rs index e6369ed..b110029 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,9 @@ use std::path::{Path, PathBuf}; use std::sync::OnceLock; const LOG_MAX_BYTES: u64 = 5 * 1024 * 1024; +// Owner-only log permissions are a Unix concept (mode bits); on Windows the log +// inherits the directory's default ACL. Only referenced under `cfg(unix)`. +#[cfg(unix)] const LOG_MODE: u32 = 0o600; /// Resolves the log file path once: `$XDG_STATE_HOME/peerspeak/peerspeak.log` @@ -84,8 +87,6 @@ fn prepare_log_file(path: &Path) -> std::io::Result { } fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result { - use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; - if let Some(parent) = path.parent() { let _ = std::fs::create_dir_all(parent); } @@ -98,12 +99,23 @@ fn prepare_log_file_with_limit(path: &Path, max_bytes: u64) -> std::io::Result PathBuf { @@ -145,6 +158,9 @@ mod tests { assert_eq!(redact_for_log(" "), ""); } + // Owner-only log perms are a Unix concept; on Windows the file inherits the + // directory ACL and there's no mode to assert. + #[cfg(unix)] #[test] fn log_file_is_created_private() { let dir = temp_log_dir();