From 03c048e9584d471ab074258030b30d74a27d9cc2 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 1 Jul 2026 16:22:45 +0000 Subject: [PATCH 1/4] fix(kiosk): pass XDG_RUNTIME_DIR so Chromium can reach PipeWire-Pulse The launcher's sudo -u archipelago invocation set DISPLAY/HOME but not XDG_RUNTIME_DIR, so Chromium's audio backend couldn't find the PipeWire-Pulse socket at /run/user//pulse/native. It silently fell back to raw ALSA "default", which also failed ("Connection refused"), producing no HDMI audio at all with no visible error since --noerrdialogs suppresses it. Co-Authored-By: Claude Sonnet 5 --- image-recipe/configs/archipelago-kiosk-launcher.sh | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/image-recipe/configs/archipelago-kiosk-launcher.sh b/image-recipe/configs/archipelago-kiosk-launcher.sh index 2f34a33e..562d607c 100644 --- a/image-recipe/configs/archipelago-kiosk-launcher.sh +++ b/image-recipe/configs/archipelago-kiosk-launcher.sh @@ -93,8 +93,14 @@ else GPU_FLAGS="--disable-gpu --num-raster-threads=1" fi +ARCHIPELAGO_UID=$(id -u archipelago) + while true; do - sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago chromium --kiosk \ + # XDG_RUNTIME_DIR must be passed explicitly — without it Chromium's audio + # backend can't find PipeWire-Pulse's socket at /run/user//pulse/native, + # falls back to raw ALSA "default", fails to connect, and produces no audio + # at all with no visible error (--noerrdialogs suppresses it). + sudo -u archipelago env DISPLAY=:0 HOME=/home/archipelago XDG_RUNTIME_DIR=/run/user/$ARCHIPELAGO_UID chromium --kiosk \ --app=http://localhost/kiosk?safe_area_x=${KIOSK_SAFE_AREA_X_PX:-0}\&safe_area_y=${KIOSK_SAFE_AREA_Y_PX:-0} \ --noerrdialogs \ --disable-infobars \ From 23e04b1859e1da45d325cc6e256ec811c02a8564 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 1 Jul 2026 17:02:43 +0000 Subject: [PATCH 2/4] fix(kiosk): restore in-process-gpu + CPUQuota=200% to stop choppy HDMI audio MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both had regressed back to the pre-2026-06-28-incident state: GPU_FLAGS used --enable-gpu-rasterization on any node with a GPU, and CPUQuota was 75%. On archy-x250-exp (Intel HD 5500 under X11) this spins a dedicated GPU process at 55-92% CPU (falls back to software compositing anyway), and the 75% cgroup quota throttled the kiosk unit ~81% of the time (nr_throttled 4856/6005) — the exact CPU-starvation signature documented as the choppy- audio root cause. Restores the validated fix: --in-process-gpu, --num-raster-threads=1, GpuRasterization disabled via --disable-features, CPUQuota=200%. Verified on archy-x250-exp: no separate gpu-process, throttling dropped to ~3% (nr_throttled 14/503). Co-Authored-By: Claude Sonnet 5 --- image-recipe/configs/archipelago-kiosk-launcher.sh | 14 ++++++++------ image-recipe/configs/archipelago-kiosk.service | 5 ++++- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/image-recipe/configs/archipelago-kiosk-launcher.sh b/image-recipe/configs/archipelago-kiosk-launcher.sh index 562d607c..1e227760 100644 --- a/image-recipe/configs/archipelago-kiosk-launcher.sh +++ b/image-recipe/configs/archipelago-kiosk-launcher.sh @@ -83,12 +83,14 @@ xset s noblank 2>/dev/null || true pkill -u archipelago -f 'chromium.*localhost' 2>/dev/null || true sleep 1 -# GPU vs headless (#36). On a real kiosk display with a GPU, GPU rasterization is -# fast. On a GPU-less / headless server (no /dev/dri), --enable-gpu-rasterization -# forces GPU paths that fall back to software compositing and SPIN a full core at -# ~92% CPU, saturating the node. Detect the GPU and pick safe flags accordingly. +# GPU vs headless (#36, choppy-audio incident 2026-06-28). --enable-gpu-rasterization +# spins a dedicated GPU process at 55-92% CPU even on real GPU hardware (Intel HD 5500) +# because under X11 it falls back to software compositing anyway — that CPU +# starvation is what caused choppy HDMI audio. --in-process-gpu avoids the +# separate process; GpuRasterization is also disabled via --disable-features below. +# On a GPU-less / headless server (no /dev/dri), disable GPU entirely instead. if [ -e /dev/dri/card0 ] || [ -e /dev/dri/renderD128 ]; then - GPU_FLAGS="--enable-gpu-rasterization --num-raster-threads=2" + GPU_FLAGS="--in-process-gpu --num-raster-threads=1" else GPU_FLAGS="--disable-gpu --num-raster-threads=1" fi @@ -107,7 +109,7 @@ while true; do --disable-translate \ --no-first-run \ --check-for-update-interval=31536000 \ - --disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled \ + --disable-features=TranslateUI,MetricsReporting,AutofillServerCommunication,PasswordManagerEnabled,GpuRasterization \ --disable-session-crashed-bubble \ --disable-save-password-bubble \ --disable-suggestions-service \ diff --git a/image-recipe/configs/archipelago-kiosk.service b/image-recipe/configs/archipelago-kiosk.service index 5db41f3c..d05e425f 100644 --- a/image-recipe/configs/archipelago-kiosk.service +++ b/image-recipe/configs/archipelago-kiosk.service @@ -25,8 +25,11 @@ RestartSec=5 # backend (it caused the .198 receive timeout + deploy storms). Cap CPU + memory # so a runaway kiosk can never take the whole machine down; Delegate so the cap # also binds the chromium/Xorg children in this unit's cgroup. +# CPUQuota=75% (0.75 cores) was too tight even for normal playback — the kiosk +# was throttled ~40% of the time, which is what caused choppy HDMI audio on +# archy-x250-exp (2026-06-28 incident). 200% (2 cores) gives enough headroom. Delegate=yes -CPUQuota=75% +CPUQuota=200% MemoryMax=1500M MemoryHigh=1200M From 81f1c7ed24a3cf7370c6fe6eea69076341ec6373 Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 1 Jul 2026 20:25:34 +0000 Subject: [PATCH 3/4] fix(kiosk): track /etc/asound.conf in image-recipe and sync it on deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Routes ALSA's "default" device through PulseAudio/PipeWire. Was a manual, untracked fix living only on archy-x250-exp — a reprovision or fresh image would silently lose it, same failure mode that already bit the GPU-flags and CPUQuota fixes. Wired into deploy-to-target.sh (both the primary --live path and the .198/.253 secondary-copy path) and deploy-tailscale.sh, mirroring the existing 99-mesh-radio.rules udev sync pattern (diff-check, copy if changed). Co-Authored-By: Claude Sonnet 5 --- image-recipe/configs/asound.conf | 7 +++++++ scripts/deploy-tailscale.sh | 16 ++++++++++++++++ scripts/deploy-to-target.sh | 33 ++++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+) create mode 100644 image-recipe/configs/asound.conf diff --git a/image-recipe/configs/asound.conf b/image-recipe/configs/asound.conf new file mode 100644 index 00000000..8a1f1f07 --- /dev/null +++ b/image-recipe/configs/asound.conf @@ -0,0 +1,7 @@ +pcm.!default { + type pulse + hint.description "Default ALSA Device (via PulseAudio)" +} +ctl.!default { + type pulse +} diff --git a/scripts/deploy-tailscale.sh b/scripts/deploy-tailscale.sh index cc85eb77..bfb88346 100755 --- a/scripts/deploy-tailscale.sh +++ b/scripts/deploy-tailscale.sh @@ -425,6 +425,22 @@ deploy_node() { ' 2>/dev/null || true fi + # ── Deploy ALSA default-device config ──────────────────────────── + ASOUND_CONF="$PROJECT_DIR/image-recipe/configs/asound.conf" + if [ -f "$ASOUND_CONF" ]; then + step "Deploying ALSA default-device config" + scp $SSH_OPTS "$ASOUND_CONF" "$TARGET:/tmp/asound.conf" 2>/dev/null || true + ssh $SSH_OPTS "$TARGET" ' + if ! diff -q /tmp/asound.conf /etc/asound.conf >/dev/null 2>&1; then + sudo cp /tmp/asound.conf /etc/asound.conf + echo " Installed" + else + echo " Unchanged" + fi + rm -f /tmp/asound.conf + ' 2>/dev/null || true + fi + # ── Step 18: NTP + swap ────────────────────────────────────────── step "Ensuring NTP + swap" ssh $SSH_OPTS "$TARGET" ' diff --git a/scripts/deploy-to-target.sh b/scripts/deploy-to-target.sh index c7533c89..7b133fda 100755 --- a/scripts/deploy-to-target.sh +++ b/scripts/deploy-to-target.sh @@ -456,6 +456,22 @@ deploy_secondary() { ' 2>/dev/null || true fi + # Deploy ALSA default-device config (routes ALSA "default" through PulseAudio/PipeWire) + ASOUND_CONF="$PROJECT_DIR/image-recipe/configs/asound.conf" + if [ -f "$ASOUND_CONF" ]; then + echo " Syncing ALSA default-device config to .$SEC_LABEL..." + scp $SSH_OPTS "$ASOUND_CONF" "$SEC_TARGET:/tmp/asound.conf" 2>/dev/null || true + ssh $SSH_OPTS "$SEC_TARGET" ' + if ! diff -q /tmp/asound.conf /etc/asound.conf >/dev/null 2>&1; then + sudo cp /tmp/asound.conf /etc/asound.conf + echo " ALSA default-device config installed" + else + echo " ALSA default-device config unchanged" + fi + rm -f /tmp/asound.conf + ' 2>/dev/null || true + fi + # Dev mode + FileBrowser ssh $SSH_OPTS "$SEC_TARGET" ' # Dev mode @@ -762,6 +778,23 @@ if [ "$LIVE" = true ]; then ' 2>/dev/null || true fi + # Deploy ALSA default-device config (routes ALSA "default" through + # PulseAudio/PipeWire — without it Chromium's raw ALSA fallback can't + # reach the HDMI sink and kiosk HDMI audio is silent). + ASOUND_CONF="$PROJECT_DIR/image-recipe/configs/asound.conf" + if [ -f "$ASOUND_CONF" ]; then + scp $SSH_OPTS "$ASOUND_CONF" "$TARGET_HOST:/tmp/asound.conf" 2>/dev/null || true + ssh $SSH_OPTS "$TARGET_HOST" ' + if ! diff -q /tmp/asound.conf /etc/asound.conf >/dev/null 2>&1; then + sudo cp /tmp/asound.conf /etc/asound.conf + echo " ALSA default-device config installed" + else + echo " ALSA default-device config unchanged" + fi + rm -f /tmp/asound.conf + ' 2>/dev/null || true + fi + # Deploy Claude API proxy (auto-install if missing) progress "Setting up Claude API proxy" ssh $SSH_OPTS "$TARGET_HOST" ' From 95d6bf5dace35730c58068c7b2b02fcff95cb1db Mon Sep 17 00:00:00 2001 From: ssmithx Date: Wed, 1 Jul 2026 20:50:19 +0000 Subject: [PATCH 4/4] not sure --- core/Cargo.lock | 1 + core/archipelago/Cargo.toml | 1 + core/archipelago/src/wallet/cashu.rs | 178 +++++++++++++++++++++++---- 3 files changed, 159 insertions(+), 21 deletions(-) diff --git a/core/Cargo.lock b/core/Cargo.lock index 4596ff06..957a8cfd 100644 --- a/core/Cargo.lock +++ b/core/Cargo.lock @@ -139,6 +139,7 @@ dependencies = [ "reqwest 0.11.27", "sd-notify", "serde", + "serde_bytes", "serde_json", "serde_yaml", "serial2-tokio", diff --git a/core/archipelago/Cargo.toml b/core/archipelago/Cargo.toml index 157c1d00..ddbf0964 100644 --- a/core/archipelago/Cargo.toml +++ b/core/archipelago/Cargo.toml @@ -109,6 +109,7 @@ hkdf = "0.12.4" # Transport abstraction (Phase 2: mesh as federation transport) ciborium = "0.2.2" +serde_bytes = "0.11" reed-solomon-erasure = "6.0" mdns-sd = "0.18" diff --git a/core/archipelago/src/wallet/cashu.rs b/core/archipelago/src/wallet/cashu.rs index 17865853..5a4fc8ed 100644 --- a/core/archipelago/src/wallet/cashu.rs +++ b/core/archipelago/src/wallet/cashu.rs @@ -1,6 +1,6 @@ //! Cashu token format (NUT-00) — serialization and deserialization. //! -//! Supports the cashuA (V3) token format: +//! Emits the cashuA (V3) token format: //! cashuA //! //! Token JSON structure: @@ -8,13 +8,54 @@ //! "token": [{ "mint": "", "proofs": [{ "amount": u64, "id": "", "secret": "", "C": "" }] }], //! "memo": "" //! } +//! +//! Also accepts (decode-only) the cashuB (V4) CBOR format many wallets emit +//! by default now: +//! cashuB +//! CBOR map keys are the spec's single-letter names (t/i/p/a/s/c/m/u/d/w), +//! not the JSON names above. `i` (keyset id) and `c` (signature) are raw +//! bytes on the wire; we hex-encode them into `Proof` to match the V3 +//! convention so the rest of the wallet doesn't need to know which version +//! a token arrived in. use anyhow::{Context, Result}; use bitcoin::secp256k1::PublicKey; use serde::{Deserialize, Serialize}; -/// Prefix for V3 tokens. +/// Prefix for V3 (JSON) tokens. const CASHU_A_PREFIX: &str = "cashuA"; +/// Prefix for V4 (CBOR) tokens. +const CASHU_B_PREFIX: &str = "cashuB"; + +/// Raw CBOR shape of a V4 proof — field names are the spec's map keys. +#[derive(Debug, Deserialize)] +struct ProofV4 { + a: u64, + s: String, + #[serde(with = "serde_bytes")] + c: Vec, + // DLEQ proof ("d") and witness ("w") aren't verified or stored by this + // wallet; accept and discard them rather than fail on the field. +} + +/// Raw CBOR shape of a V4 token entry (one keyset's worth of proofs). +#[derive(Debug, Deserialize)] +struct TokenEntryV4 { + #[serde(with = "serde_bytes")] + i: Vec, + p: Vec, +} + +/// Raw CBOR shape of a full V4 token — single mint per token, unlike V3. +#[derive(Debug, Deserialize)] +struct TokenV4 { + t: Vec, + m: String, + #[serde(default)] + u: Option, + #[serde(default, rename = "d")] + memo: Option, +} /// A single Cashu proof (a signed token for a specific denomination). #[derive(Debug, Clone, Serialize, Deserialize)] @@ -100,31 +141,65 @@ impl CashuToken { Ok(format!("{}{}", CASHU_A_PREFIX, encoded)) } - /// Decode a cashuA token string. + /// Decode a cashuA (V3 JSON) or cashuB (V4 CBOR) token string. pub fn deserialize(token_str: &str) -> Result { - let payload = token_str - .strip_prefix(CASHU_A_PREFIX) - .ok_or_else(|| anyhow::anyhow!("Token must start with '{}'", CASHU_A_PREFIX))?; + if let Some(payload) = token_str.strip_prefix(CASHU_B_PREFIX) { + return Self::deserialize_v4(payload); + } - use base64::Engine; - let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(payload) - .or_else(|_| { - // Try standard base64 as fallback (some implementations use it) - base64::engine::general_purpose::URL_SAFE.decode(payload) - }) - .or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload)) - .context("Invalid base64 in cashuA token")?; + let payload = token_str.strip_prefix(CASHU_A_PREFIX).ok_or_else(|| { + anyhow::anyhow!( + "Token must start with '{}' or '{}'", + CASHU_A_PREFIX, + CASHU_B_PREFIX + ) + })?; + let decoded = decode_token_base64(payload).context("Invalid base64 in cashuA token")?; let json_str = String::from_utf8(decoded).context("Invalid UTF-8 in decoded token")?; let token: CashuToken = serde_json::from_str(&json_str).context("Invalid JSON in cashuA token")?; - // Structural validation - if token.token.is_empty() { + token.validate()?; + Ok(token) + } + + /// Decode a cashuB (V4 CBOR) token payload (prefix already stripped). + fn deserialize_v4(payload: &str) -> Result { + let decoded = decode_token_base64(payload).context("Invalid base64 in cashuB token")?; + + let v4: TokenV4 = + ciborium::from_reader(decoded.as_slice()).context("Invalid CBOR in cashuB token")?; + + let proofs = v4 + .t + .into_iter() + .flat_map(|entry| { + let keyset_id = hex::encode(&entry.i); + entry.p.into_iter().map(move |p| Proof { + amount: p.a, + id: keyset_id.clone(), + secret: p.s, + c: hex::encode(&p.c), + }) + }) + .collect(); + + let token = CashuToken { + token: vec![TokenEntry { mint: v4.m, proofs }], + memo: v4.memo, + unit: v4.u, + }; + token.validate()?; + Ok(token) + } + + /// Structural validation shared by both token versions. + fn validate(&self) -> Result<()> { + if self.token.is_empty() { anyhow::bail!("Token has no entries"); } - for entry in &token.token { + for entry in &self.token { if entry.mint.is_empty() { anyhow::bail!("Token entry has empty mint URL"); } @@ -143,11 +218,20 @@ impl CashuToken { } } } - - Ok(token) + Ok(()) } } +/// Decode a token's base64 payload, trying URL-safe-no-pad first (the spec +/// default) and falling back to other alphabets some implementations use. +fn decode_token_base64(payload: &str) -> Result, base64::DecodeError> { + use base64::Engine; + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(payload) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload)) + .or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload)) +} + /// Keyset info returned by a mint. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct KeysetInfo { @@ -303,11 +387,63 @@ mod tests { } #[test] - fn test_deserialize_rejects_invalid_prefix() { + fn test_deserialize_rejects_unknown_prefix() { + let result = CashuToken::deserialize("cashuZabc123"); + assert!(result.is_err()); + } + + #[test] + fn test_deserialize_rejects_malformed_v4_cbor() { let result = CashuToken::deserialize("cashuBabc123"); assert!(result.is_err()); } + #[test] + fn test_deserialize_v4_cbor_token() { + // Hand-built (not via our own encoder) to verify we actually match + // the NUT-00 V4 wire format: single-letter CBOR map keys, raw-byte + // keyset id ("i") and signature ("c"). + use base64::Engine; + use ciborium::value::Value; + + let keyset_id = vec![0x00u8, 0x9a, 0x1f, 0x29, 0x32, 0x53, 0xe4, 0x1e]; + let sig = hex::decode( + "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24", + ) + .unwrap(); + + let proof = Value::Map(vec![ + (Value::from("a"), Value::from(8u64)), + (Value::from("s"), Value::from("abcdef1234567890")), + (Value::from("c"), Value::from(sig.clone())), + ]); + let entry = Value::Map(vec![ + (Value::from("i"), Value::from(keyset_id.clone())), + (Value::from("p"), Value::Array(vec![proof])), + ]); + let token = Value::Map(vec![ + (Value::from("t"), Value::Array(vec![entry])), + (Value::from("m"), Value::from("http://127.0.0.1:8175")), + (Value::from("u"), Value::from("sat")), + (Value::from("d"), Value::from("test token")), + ]); + + let mut buf = Vec::new(); + ciborium::into_writer(&token, &mut buf).unwrap(); + let encoded = format!( + "cashuB{}", + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&buf) + ); + + let decoded = CashuToken::deserialize(&encoded).unwrap(); + assert_eq!(decoded.total_amount(), 8); + assert_eq!(decoded.token[0].mint, "http://127.0.0.1:8175"); + assert_eq!(decoded.token[0].proofs[0].secret, "abcdef1234567890"); + assert_eq!(decoded.token[0].proofs[0].id, hex::encode(&keyset_id)); + assert_eq!(decoded.token[0].proofs[0].c, hex::encode(&sig)); + assert_eq!(decoded.memo, Some("test token".to_string())); + } + #[test] fn test_amount_to_denominations() { assert_eq!(amount_to_denominations(0), Vec::::new());