ci / web (push) Successful in 1m1s
ci / rust-arm64 (push) Successful in 2m35s
ci / docs-site (push) Successful in 2m35s
docker / builders (--build-arg FEDORA_VERSION=44, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm, -f44) (push) Successful in 5s
docker / builders (ci/android-ci.Dockerfile, punktfunk-android-ci) (push) Successful in 7s
docker / builders (ci/arch-ci.Dockerfile, punktfunk-arch-ci) (push) Successful in 33s
docker / builders (ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 6s
docker / builders (ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 4s
deb / build-publish-client-arm64 (push) Successful in 1m16s
docker / builders (ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 7s
docker / apps (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
deb / build-publish (push) Successful in 3m52s
apple / swift (push) Successful in 1m18s
docker / apps (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 8s
deb / build-publish-host (push) Successful in 4m11s
windows-msix / package (arm64, C:\Users\Public\ffmpeg-arm64, --no-default-features, aarch64-pc-windows-msvc, C:\t-a64) (push) Successful in 2m28s
ci / rust (push) Successful in 7m1s
android / android (push) Successful in 7m5s
arch / build-publish (push) Successful in 8m17s
windows-msix / package (x64, C:\Users\Public\ffmpeg, , x86_64-pc-windows-msvc, C:\t) (push) Successful in 2m55s
windows / build (aarch64-pc-windows-msvc) (push) Successful in 1m0s
docker / builders-arm64cross (push) Successful in 8s
apple / screenshots (push) Successful in 5m42s
docker / deploy-docs (push) Successful in 26s
windows / build (x86_64-pc-windows-msvc) (push) Successful in 2m25s
flatpak / build-publish (push) Canceled after 9m13s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Canceled after 9m13s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Canceled after 9m11s
A field reporter's codec setting "changed by itself" between sessions. Nothing writes the negotiated codec back — what they saw was a stale snapshot. `AppCtx.settings` is loaded ONCE at process start and the page renders from it, but this process is not the file's only writer (the spawned session persists its match-window size, the console UI and Decky save too), so the page showed values another process had already replaced — until a row was touched and `commit`'s rebase pulled the file in, at which point the value visibly jumped. The 2026-07-31 rebase fix covered the whole-file writers and missed two spots: nothing re-based on page ENTRY, and the profile-scope commit arm cloned the snapshot without reloading, so overlay absorption diffed against stale globals. Both now re-base on the file. Two more ways a setting could vanish or cost time: * An older binary's whole-file save DROPPED a newer client's keys — `Settings` had no unknown-key passthrough, unlike `SettingsOverlay`, whose `extra` map already gives profiles exactly that contract. Extended to the globals: additive, empty on every existing store, and an empty map serializes to nothing so no file churns. (`save()` was already temp+rename, so the torn-file → silent-Default reset was closed.) * "Check the client log" never said WHERE. Settings ▸ About grows an Open log folder row (%LOCALAPPDATA%\punktfunk\logs, folder not file so the rotated .old generation is in reach), and the failed-spawn banner now names the path. The 4:4:4 caption said "HEVC only, and only where the host can encode it", which sends people hunting: the host gate is PyroWave or an NVENC backend. It says so now. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
100 lines
3.9 KiB
Rust
100 lines
3.9 KiB
Rust
//! Persistent client log file: `%LOCALAPPDATA%\punktfunk\logs\client.log`.
|
|
//!
|
|
//! The shell is a `windows_subsystem` binary and spawns `punktfunk-session` with
|
|
//! `CREATE_NO_WINDOW` — a normal GUI/MSIX launch has NO console, so before this module every
|
|
//! log line (the shell's and, worse, the session's whole receive/decode/present forensic
|
|
//! trail) evaporated exactly when a user hit a problem worth reporting. The 2026-07 PyroWave
|
|
//! latency-sawtooth field report had to be triaged from host logs alone because the client
|
|
//! side had nowhere to land.
|
|
//!
|
|
//! Mirrors the host's convention (`%ProgramData%\punktfunk\logs`, size-capped): a file over
|
|
//! 10 MB is rotated to `.old` at the next client start, one generation kept. Everything is
|
|
//! best-effort — a missing/locked directory degrades to plain stderr, never a startup failure.
|
|
|
|
use std::fs::{File, OpenOptions};
|
|
use std::io::{self, BufRead, Write};
|
|
use std::path::PathBuf;
|
|
use std::sync::{Arc, Mutex, OnceLock};
|
|
|
|
/// Rotate at the next start once the file exceeds this (the host's cap).
|
|
const ROTATE_BYTES: u64 = 10 * 1024 * 1024;
|
|
|
|
static SINK: OnceLock<Option<Arc<Mutex<File>>>> = OnceLock::new();
|
|
|
|
/// The log directory — Settings ▸ About's "Open log folder" opens it in Explorer.
|
|
pub(crate) fn log_dir() -> Option<PathBuf> {
|
|
Some(PathBuf::from(std::env::var_os("LOCALAPPDATA")?).join(r"punktfunk\logs"))
|
|
}
|
|
|
|
/// The log file's path, for the "logs land here" startup line and the failed-spawn banner.
|
|
pub(crate) fn path() -> Option<PathBuf> {
|
|
Some(log_dir()?.join("client.log"))
|
|
}
|
|
|
|
/// Open (rotating first) and cache the sink. Called once at startup, before the tracing
|
|
/// subscriber installs; every later [`tee`] shares the handle.
|
|
pub(crate) fn init() {
|
|
SINK.get_or_init(|| {
|
|
let dir = log_dir()?;
|
|
std::fs::create_dir_all(&dir).ok()?;
|
|
let path = dir.join("client.log");
|
|
if std::fs::metadata(&path).is_ok_and(|m| m.len() > ROTATE_BYTES) {
|
|
let old = dir.join("client.log.old");
|
|
// Windows `rename` refuses an existing destination — drop the old generation first.
|
|
let _ = std::fs::remove_file(&old);
|
|
let _ = std::fs::rename(&path, &old);
|
|
}
|
|
let file = OpenOptions::new()
|
|
.create(true)
|
|
.append(true)
|
|
.open(&path)
|
|
.ok()?;
|
|
Some(Arc::new(Mutex::new(file)))
|
|
});
|
|
}
|
|
|
|
/// A writer that duplicates onto stderr (dev runs from a terminal keep their interleaved
|
|
/// output) and the log file (GUI runs finally keep anything at all). The tracing subscriber's
|
|
/// `with_writer` factory and the session-stderr forwarder both use it.
|
|
pub(crate) struct Tee;
|
|
|
|
/// `with_writer` factory (`fn() -> Tee` satisfies `MakeWriter`).
|
|
pub(crate) fn tee() -> Tee {
|
|
Tee
|
|
}
|
|
|
|
impl Write for Tee {
|
|
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
|
|
let _ = io::stderr().write_all(buf);
|
|
if let Some(Some(f)) = SINK.get() {
|
|
let _ = f.lock().unwrap().write_all(buf);
|
|
}
|
|
Ok(buf.len())
|
|
}
|
|
|
|
fn flush(&mut self) -> io::Result<()> {
|
|
let _ = io::stderr().flush();
|
|
if let Some(Some(f)) = SINK.get() {
|
|
let _ = f.lock().unwrap().flush();
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Forward a spawned child's stderr into the [`Tee`], line-buffered so its lines never
|
|
/// interleave mid-line with the shell's own. Returns immediately; the thread dies with the
|
|
/// pipe (child exit).
|
|
pub(crate) fn forward_child_stderr(stderr: impl io::Read + Send + 'static) {
|
|
let _ = std::thread::Builder::new()
|
|
.name("punktfunk-session-log".into())
|
|
.spawn(move || {
|
|
let mut reader = io::BufReader::new(stderr);
|
|
let mut line = String::new();
|
|
let mut tee = Tee;
|
|
while matches!(reader.read_line(&mut line), Ok(n) if n > 0) {
|
|
let _ = tee.write_all(line.as_bytes());
|
|
line.clear();
|
|
}
|
|
});
|
|
}
|