6 Commits
Author SHA1 Message Date
ab b07bcd4ddd Added fix 2026-09-10 22:56:06 +03:00
ab 966cf4b437 Fix connected devices 2026-09-10 18:05:00 +03:00
ab 532d6ee1e9 Integrate shared playback coordination and release 0.3.1 2026-09-10 17:07:41 +03:00
ab 5a193d839f Reworked settings menu 2026-09-07 13:06:46 +03:00
ab 7d6af59e97 fix lock 2026-09-07 12:41:27 +03:00
ab 32c976beb8 Added self updater 2026-09-07 12:36:48 +03:00
22 changed files with 2209 additions and 365 deletions
+6 -1
View File
@@ -98,7 +98,12 @@ jobs:
run: |
set -euo pipefail
gh release create "$RELEASE_TAG" artifacts/*/* \
# Hash archive bytes; manifest entries use the release asset basenames.
for archive in artifacts/*/*; do
(cd "$(dirname "$archive")" && sha256sum "$(basename "$archive")")
done > SHA256SUMS
gh release create "$RELEASE_TAG" artifacts/*/* SHA256SUMS \
--title "furumi ${RELEASE_TAG}" \
--notes "Release ${RELEASE_TAG}" \
--verify-tag
+7
View File
@@ -190,6 +190,13 @@ must propagate and converge, not an ephemeral server-side session flag.
## Playback across devices
A persisted device identity has exactly one live coordinator. The TUI holds
an OS file lock beside its device-sync database for the entire runtime; two
installations using the same user data directory cannot open it concurrently.
Network polling is independent per trusted peer, with one in-flight exchange
per peer and a bounded deadline. A stalled peer must not delay the next poll
of a healthy output. Poll tasks are cancelled when federation shuts down.
Playback has one logical state but remains physically local to the device
producing audio.
Generated
+92 -6
View File
@@ -124,6 +124,15 @@ dependencies = [
"num-traits",
]
[[package]]
name = "arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1"
dependencies = [
"derive_arbitrary",
]
[[package]]
name = "arc-swap"
version = "1.9.2"
@@ -1182,6 +1191,17 @@ dependencies = [
"syn 2.0.119",
]
[[package]]
name = "derive_arbitrary"
version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "derive_more"
version = "2.1.1"
@@ -1667,10 +1687,20 @@ dependencies = [
]
[[package]]
name = "furumi-library"
version = "0.1.0"
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f15ab98c65d89ea18e55ed8852abd2f029f63876d357270c4647d73601d0a2"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "furumi-library"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f78466b6345cb90d7bb37db5d9dd67476ab6c2c3b2202876d24f448c314a9a08"
dependencies = [
"anyhow",
"blake3",
@@ -1685,7 +1715,7 @@ dependencies = [
[[package]]
name = "furumi_tui"
version = "0.2.8"
version = "0.3.1"
dependencies = [
"anyhow",
"base64",
@@ -1694,24 +1724,32 @@ dependencies = [
"crokey",
"crossterm",
"directories",
"flate2",
"fs2",
"furumi-library",
"futures-util",
"image",
"libc",
"lofty",
"music-dht",
"object",
"ratatui",
"reqwest 0.12.28",
"rhai",
"rodio",
"rusqlite",
"rustfft",
"rustls",
"rusty-opus",
"self-replace",
"semver",
"serde",
"serde_json",
"sha2 0.10.9",
"souvlaki",
"symphonia",
"tar",
"tempfile",
"thiserror 2.0.20",
"tokio",
"toml",
@@ -1720,6 +1758,7 @@ dependencies = [
"tract-onnx",
"unicode-width",
"windows-sys 0.61.2",
"zip",
]
[[package]]
@@ -3200,9 +3239,9 @@ dependencies = [
[[package]]
name = "music-dht"
version = "0.4.1"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7bfe5cee89fa891b00e84738f947c924c6845fd84fedb4697cb073e3fe63bb81"
checksum = "4c3f75d49d3a742a6777a1c4cc53f397399dfc9ddb70034c57aeb44d75d2ee4f"
dependencies = [
"async-trait",
"blake3",
@@ -3862,6 +3901,15 @@ dependencies = [
"objc2-security",
]
[[package]]
name = "object"
version = "0.36.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62948e14d923ea95ea2c7c86c71013138b66525b86bdc08d2dcc262bdb497b87"
dependencies = [
"memchr",
]
[[package]]
name = "ogg_pager"
version = "0.7.2"
@@ -4792,6 +4840,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
@@ -5181,6 +5230,17 @@ version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b55fb86dfd3a2f5f76ea78310a88f96c4ea21a3031f8d212443d56123fd0521"
[[package]]
name = "self-replace"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03ec815b5eab420ab893f63393878d89c90fdd94c0bcc44c07abb8ad95552fb7"
dependencies = [
"fastrand 2.5.0",
"tempfile",
"windows-sys 0.52.0",
]
[[package]]
name = "semver"
version = "1.0.28"
@@ -7613,6 +7673,20 @@ dependencies = [
"syn 3.0.4",
]
[[package]]
name = "zip"
version = "4.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caa8cd6af31c3b31c6631b8f483848b91589021b28fffe50adada48d4f4d2ed1"
dependencies = [
"arbitrary",
"crc32fast",
"flate2",
"indexmap",
"memchr",
"zopfli",
]
[[package]]
name = "zlib-rs"
version = "0.6.7"
@@ -7625,6 +7699,18 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
[[package]]
name = "zopfli"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f05cd8797d63865425ff89b5c4a48804f35ba0ce8d125800027ad6017d2b5249"
dependencies = [
"bumpalo",
"crc32fast",
"log",
"simd-adler32",
]
[[package]]
name = "zune-core"
version = "0.5.3"
+14 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "furumi_tui"
version = "0.2.8"
version = "0.3.1"
edition = "2024"
rust-version = "1.97"
description = "A federated P2P player for personal music libraries"
@@ -11,20 +11,30 @@ name = "furumi"
path = "src/main.rs"
[dependencies]
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
semver = "1"
self-replace = "1.5"
tempfile = "3"
fs2 = "0.4"
flate2 = "1"
tar = "0.4"
zip = { version = "4", default-features = false, features = ["deflate"] }
object = { version = "0.36", default-features = false, features = ["read", "std"] }
anyhow = "1.0.102"
blake3 = "1"
crokey = "1.4.0"
crossterm = { version = "0.29.0", features = ["event-stream"] }
directories = "6.0.0"
futures-util = "0.3.32"
furumi-library = "0.1.0"
furumi-library = "0.2.0"
image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "webp", "gif", "bmp"] }
lofty = "0.22"
# P2P federation: library index in a shared DHT + audio streaming between
# peers (same protocol as furumi-fd).
music-dht = "0.4.1"
music-dht = "0.5.0"
ratatui = "0.30.1"
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream"] }
reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls", "stream", "blocking"] }
rhai = { version = "1", features = ["sync"] }
rodio = { version = "0.22.2", default-features = false, features = ["playback", "mp3", "flac", "vorbis", "wav", "symphonia-aac", "symphonia-isomp4", "symphonia-alac"] }
rusty-opus = "0.9.1"
+71
View File
@@ -124,6 +124,28 @@ Import a music directory from Furumi's command line:
Federation, trusted-device pairing, and key bindings are configured directly
inside the player.
### Manual updates
In **Settings → Additional settings → Updates**, select **Check for updates**, then **Install update**
when a newer stable GitHub release is available. Downloads run in the background.
After installation, restart `furumi` to use the new version; playback is not
restarted automatically. Wait for an active update operation to finish before
quitting.
Updates replace the running executable in its installation directory, which
must be writable by your user. Release archives must include a matching entry
in the release's `SHA256SUMS` asset. Older releases without it cannot be installed
through this feature. The updater checks SHA-256 and the executable's format
and architecture before replacing it. Checksums provide integrity checking,
not publisher signatures. Settings and the local library are preserved.
There are no automatic startup checks. Only the existing release asset naming
scheme is supported; missing or incompatible platform builds are rejected.
The **Additional settings** window also contains the music save directory and
visualization controls. Use Up/Down (or j/k) to navigate, Enter to select, and
Esc to return to Settings. Long lists scroll with the selection.
### Now playing in tmux
While Furumi is running, a second invocation can print a cheap, single-line
@@ -173,6 +195,55 @@ cargo check --all-targets
cargo test --all-targets
```
## Playback coordination
Connected Devices uses the shared `music_dht::playback` engine from frid.
Newly started players take an idle/paused output after discovery and become
controllers when another device is playing. Missing output reports trigger
automatic failover; concurrent claims converge to one owner. The web gateway
uses a passive server profile and reports actual browser activity.
Only one TUI process may use a device identity. Installed and locally built
binaries use the same user data directory: close the installed player before
starting a development build. An OS file lock prevents duplicate coordinators
and is released automatically on exit or a crash. Older builds do not take
this lock, so close those explicitly when upgrading.
Connected-device polling runs independently per peer, so an offline or stalled
device does not postpone updates to live players.
The existing device menu handles manual transfers. Advanced policy can be set
in `settings.toml` without changing the UI:
```toml
[playback]
claim_on_startup = true
automatic_failover = true
take_paused_on_startup = true
discovery_ms = 3000
owner_timeout_ms = 120000
```
All clients must support the new coordination envelope for eventual single
output ownership. frid's `PLAYBACK_PROTOCOL.md` describes the protocol, adapter
contract and rollout. Local Cargo patches are only for development; publish
frid and bump the client dependency versions before releasing these changes.
Run the local protocol checks from the TUI repository:
```bash
cargo test localhost_devices_exchange_state_and_handoff
python scripts/test_device_interop.py ../furumusic
```
The first test uses real iroh streams, isolated SQLite databases, ownership
handoff, and a stalled peer. The second builds both player test binaries and
exchanges their actual JSON messages over `127.0.0.1`: queue metadata,
bidirectional handoff, pause/seek and duplicate command fencing. It exercises
the TUI command adapter and web player hub, but does not start a browser or
PostgreSQL and does not cover web database migrations. Both tests use temporary
identities and data; no running player or user library is used.
## License
Furumi is released under the
+65
View File
@@ -0,0 +1,65 @@
"""Build and run the real TUI and web protocol adapters against each other.
Usage: python scripts/test_device_interop.py [path/to/furumusic]
No user databases, accounts, audio outputs, or external servers are used.
"""
import json
import os
from pathlib import Path
import subprocess
import sys
import tempfile
def test_binary(repo):
result = subprocess.run(
["cargo", "test", "--locked", "--no-run", "--message-format=json"],
cwd=repo, text=True, encoding="utf-8", stdout=subprocess.PIPE,
)
result.check_returncode()
binaries = []
for line in result.stdout.splitlines():
event = json.loads(line)
if event.get("reason") == "compiler-artifact" and event.get("profile", {}).get("test") and event.get("executable"):
binaries.append(event["executable"])
if len(binaries) != 1:
raise RuntimeError(f"Expected one player test binary in {repo}, found {binaries}")
return binaries[0]
def main():
tui = Path(__file__).resolve().parents[1]
web = Path(sys.argv[1]).resolve() if len(sys.argv) > 1 else tui.parent / "furumusic"
tui_bin, web_bin = test_binary(tui), test_binary(web)
with tempfile.TemporaryDirectory(prefix="furumi-interop-") as directory:
env = dict(os.environ, FURUMI_INTEROP_DIR=directory)
processes = []
try:
for binary, test in [(web_bin, "federation::devices::interop_tests::localhost_tui_peer"),
(tui_bin, "devices::interop_tests::localhost_web_peer")]:
listing = subprocess.check_output([binary, "--list"], text=True, encoding="utf-8")
if f"{test}: test" not in listing:
raise RuntimeError(f"Required test {test} is missing from {binary}")
processes.append(subprocess.Popen(
[binary, "--ignored", "--exact", test, "--nocapture"], env=env,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8",
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
))
codes = []
for process in processes:
output, _ = process.communicate(timeout=45)
print(output, end="")
codes.append(process.returncode)
if any(codes):
raise RuntimeError(f"Cross-player test failed: exit codes {codes}")
finally:
for process in processes:
if process.poll() is None:
process.kill()
process.wait()
print("PASS: TUI <-> WEB state, bidirectional handoff, pause/seek, duplicate fencing")
if __name__ == "__main__":
main()
+8 -1
View File
@@ -10,6 +10,9 @@ use crate::library::models::{
/// the playback engine, imports). Tasks never touch AppState directly.
#[derive(Debug)]
pub enum AppEvent {
UpdateChecked(Result<Option<crate::updater::Update>, String>),
UpdateProgress(String),
UpdateInstalled(Result<(), String>),
StatusMessage(String),
/// A page of the artists list arrived (or failed).
ArtistsLoaded(Result<ArtistsPage, String>),
@@ -182,7 +185,11 @@ pub enum AppEvent {
/// Trusted device playback state, delivered by personal-device sync.
DevicePlayback(crate::devices::PlaybackSnapshot),
/// Playback command addressed to this device.
PlaybackCommand(crate::devices::PlaybackCommand),
PlaybackCommand {
command: crate::devices::PlaybackCommand,
authority: music_dht::playback::CommandStamp,
origin: String,
},
/// Current lifecycle/status of the federation Jam.
JamStatus(crate::jam::JamStatus),
/// Host playback snapshot received by a Jam participant.
+163 -140
View File
@@ -32,7 +32,6 @@ use update::{Effect, update};
const TICK_INTERVAL: Duration = Duration::from_millis(250);
const VISUALIZER_TICK_INTERVAL: Duration = Duration::from_millis(50);
const ACTIVE_IDLE_LEASE_MS: i64 = 5 * 60 * 1000;
/// Handles shared by background tasks; AppState stays pure UI data.
pub struct Runtime {
@@ -297,6 +296,8 @@ pub async fn run(
}
let devices = crate::devices::DeviceSync::new(Arc::clone(&library))?;
devices.configure_playback(settings.playback.clone())?;
state.device_playback.config = settings.playback.clone();
devices.set_event_tx(event_tx.clone());
let jam = crate::jam::JamManager::new(event_tx.clone());
let similarity = crate::similarity::Manager::new(
@@ -321,7 +322,6 @@ pub async fn run(
state.device_playback.self_device_name = device_name.clone();
state.device_playback.active_device_id = Some(device_id);
state.device_playback.active_device_name = Some(device_name);
state.device_playback.startup_takeover_pending = true;
}
let player_events = event_tx.clone();
let mut runtime = Runtime {
@@ -416,6 +416,7 @@ pub async fn run(
},
Some(app_event) = event_rx.recv() => handle_app_event(&mut state, &mut runtime, app_event),
_ = tick.tick() => {
reconcile_personal_playback(&mut state, &mut runtime);
state.advance_spinner();
expire_quit_confirmation(&mut state);
sync_player_shared(&mut state, &runtime);
@@ -431,6 +432,13 @@ pub async fn run(
}
if state.should_quit {
// A replacement must finish before the runtime/process is torn down.
if state.updater.busy {
state.should_quit = false;
state.status_message =
Some("Please wait for the update operation to finish".into());
continue;
}
if runtime.plain_text_mode {
leave_plain_text_mode()?;
runtime.plain_text_mode = false;
@@ -619,6 +627,7 @@ fn publish_playback_snapshot_with_active(state: &mut AppState, runtime: &Runtime
state.device_playback.active_device_name = Some(device_name.clone());
}
let snapshot = crate::devices::PlaybackSnapshot {
coordination: None,
device_id,
device_name,
active,
@@ -632,6 +641,7 @@ fn publish_playback_snapshot_with_active(state: &mut AppState, runtime: &Runtime
runtime
.jam
.publish_host_playback(crate::devices::PlaybackSnapshot {
coordination: None,
device_id: state.device_playback.self_device_id.clone(),
device_name: state.device_playback.self_device_name.clone(),
active: true,
@@ -651,36 +661,6 @@ fn update_local_idle_since(state: &mut AppState) {
}
}
fn active_snapshot_idle_since(snapshot: &crate::devices::PlaybackSnapshot) -> Option<i64> {
if snapshot.state.playing && !snapshot.state.paused {
None
} else {
snapshot
.state
.idle_since_ms
.or(Some(snapshot.updated_at_ms))
}
}
fn active_idle_lease_expired(snapshot: &crate::devices::PlaybackSnapshot, now: i64) -> bool {
active_snapshot_idle_since(snapshot)
.is_some_and(|idle_since| now.saturating_sub(idle_since) >= ACTIVE_IDLE_LEASE_MS)
}
fn local_active_lease_protected(state: &mut AppState, now: i64) -> bool {
if !state.device_playback.is_audio_owner() || !state.player.playing {
return false;
}
if !state.player.paused {
return true;
}
update_local_idle_since(state);
state
.device_playback
.local_idle_since_ms
.is_some_and(|idle_since| now.saturating_sub(idle_since) < ACTIVE_IDLE_LEASE_MS)
}
fn extrapolate_control_position(state: &mut AppState) {
let Some(snapshot) = state.device_playback.last_remote_snapshot.as_ref() else {
return;
@@ -712,6 +692,7 @@ pub(crate) fn become_control_device(
runtime: &Runtime,
snapshot: crate::devices::PlaybackSnapshot,
) {
runtime.player.set_playback_allowed(false);
if state.device_playback.is_audio_owner() {
runtime.player.stop();
publish_inactive_playback_snapshot(state, runtime);
@@ -730,6 +711,7 @@ pub(crate) fn become_control_device(
}
pub(crate) fn become_active_device(state: &mut AppState, runtime: &mut Runtime, start_audio: bool) {
runtime.player.set_playback_allowed(true);
let was_control = state.device_playback.role == state::DevicePlaybackRole::Control;
state.device_playback.role = state::DevicePlaybackRole::Active;
state.device_playback.jam_host = false;
@@ -754,6 +736,13 @@ pub(crate) fn become_active_device(state: &mut AppState, runtime: &mut Runtime,
}
pub(crate) fn transfer_active_to_this_device(state: &mut AppState, runtime: &mut Runtime) {
if let Err(error) = runtime
.devices
.claim_playback(&state.device_playback.self_device_id)
{
state.status_message = Some(format!("device handoff: {error}"));
return;
}
if state.device_playback.is_audio_owner() {
publish_playback_snapshot(state, runtime);
request_urgent_device_sync(runtime);
@@ -793,6 +782,10 @@ pub(crate) fn transfer_active_to_remote_device(
transfer_active_to_this_device(state, runtime);
return;
}
if let Err(error) = runtime.devices.claim_playback(&target_device_id) {
state.status_message = Some(format!("device handoff: {error}"));
return;
}
extrapolate_control_position(state);
let previous_active_id = state.device_playback.active_device_id.clone();
if state.player.current.is_none() && !state.player.queue.is_empty() {
@@ -817,6 +810,7 @@ pub(crate) fn transfer_active_to_remote_device(
record_playback_command_async(runtime, previous, command.clone(), "device handoff");
}
let snapshot = crate::devices::PlaybackSnapshot {
coordination: None,
device_id: target_device_id.clone(),
device_name: target_device_name.clone(),
active: true,
@@ -1415,6 +1409,31 @@ fn perform_effect(state: &mut AppState, runtime: &mut Runtime, effect: Effect) {
return;
}
match effect {
Effect::CheckUpdate => {
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let result = tokio::task::spawn_blocking(crate::updater::check)
.await
.map_err(|err| format!("update worker failed: {err}"))
.and_then(|result| result.map_err(|err| format!("{err:#}")));
let _ = tx.send(AppEvent::UpdateChecked(result));
});
}
Effect::InstallUpdate(update) => {
let tx = runtime.event_tx.clone();
tokio::spawn(async move {
let progress_tx = tx.clone();
let result = tokio::task::spawn_blocking(move || {
crate::updater::install(&update, |message| {
let _ = progress_tx.send(AppEvent::UpdateProgress(message));
})
})
.await
.map_err(|err| format!("update worker failed: {err}"))
.and_then(|result| result.map_err(|err| format!("{err:#}")));
let _ = tx.send(AppEvent::UpdateInstalled(result));
});
}
Effect::PlayCurrent => {
play_current(state, runtime);
push_media_metadata(state, runtime);
@@ -1935,6 +1954,11 @@ fn perform_control_playback_effect(state: &mut AppState, runtime: &mut Runtime,
}
fn clamp_settings_cursor(state: &mut AppState) {
state.additional_settings_cursor = state.additional_settings_cursor.min(
state::additional_settings_rows(state)
.len()
.saturating_sub(1),
);
let last = state::settings_rows(state).len().saturating_sub(1);
state.settings_cursor = state.settings_cursor.min(last);
}
@@ -2840,6 +2864,7 @@ fn refresh_artists(state: &mut AppState, runtime: &Runtime) {
fn save_app_settings(state: &AppState) {
let settings = crate::config::settings::AppSettings {
playback: state.device_playback.config.clone(),
volume: state.player.volume,
library: state.global.filters,
music_dir: state.music_dir.clone(),
@@ -2946,81 +2971,67 @@ fn apply_queue_refresh(
}
}
fn reconcile_personal_playback(state: &mut AppState, runtime: &mut Runtime) {
if state.device_playback.role == state::DevicePlaybackRole::Jam {
runtime
.player
.set_playback_allowed(state.device_playback.is_audio_owner());
let _ = runtime.devices.playback_tick(false, false);
return;
}
let playing =
state.device_playback.is_audio_owner() && state.player.playing && !state.player.paused;
let owner = match runtime.devices.playback_tick(true, playing) {
Ok(owner) => owner,
Err(error) => {
runtime.player.set_playback_allowed(false);
runtime.player.stop();
state.status_message = Some(format!("playback coordination: {error}"));
return;
}
};
let Some(owner) = owner else {
runtime.player.set_playback_allowed(false);
runtime.player.stop();
runtime.player_start_pending = false;
state.device_playback.role = state::DevicePlaybackRole::Control;
state.device_playback.active_device_id = None;
return;
};
if owner == state.device_playback.self_device_id {
runtime.player.set_playback_allowed(true);
if !state.device_playback.is_audio_owner() {
become_active_device(state, runtime, true);
}
} else if let Some(snapshot) = state.device_playback.remote.get(&owner).cloned() {
if state.device_playback.last_remote_snapshot.as_ref() != Some(&snapshot)
|| state.device_playback.is_audio_owner()
{
become_control_device(state, runtime, snapshot);
}
} else {
runtime.player.set_playback_allowed(false);
runtime.player.stop();
runtime.player_start_pending = false;
state.device_playback.role = state::DevicePlaybackRole::Control;
state.device_playback.active_device_id = Some(owner);
}
publish_playback_snapshot_with_active(state, runtime, state.device_playback.is_audio_owner());
}
fn handle_device_playback_snapshot(
state: &mut AppState,
runtime: &mut Runtime,
snapshot: crate::devices::PlaybackSnapshot,
) {
// Personal-device reconciliation must never change Jam ownership. Jam
// has its own authority and lifecycle even when the same TUI also belongs
// to a trusted-device group.
if state.device_playback.role == state::DevicePlaybackRole::Jam {
return;
}
if snapshot.device_id == state.device_playback.self_device_id {
return;
}
state
.device_playback
.remote
.insert(snapshot.device_id.clone(), snapshot.clone());
let now = unix_time_ms();
state.device_playback.online_devices = state
.device_playback
.remote
.values()
.filter(|snapshot| {
now.saturating_sub(snapshot.updated_at_ms) <= state::DEVICE_ONLINE_TTL_MS
})
.count()
+ 1;
if !snapshot.active {
return;
}
// Starting a player is an explicit claim of the active role. Import the
// current queue/position from the previously active peer, then announce a
// normal handoff so that the old owner becomes a control device. This is
// intentionally one-shot: subsequent snapshots use the regular lease and
// explicit-transfer rules.
if state.device_playback.startup_takeover_pending {
state.device_playback.startup_takeover_pending = false;
become_control_device(state, runtime, snapshot);
transfer_active_to_this_device(state, runtime);
state.status_message = Some("playback moved to this newly started player".to_string());
return;
}
if local_active_lease_protected(state, now) {
tracing::debug!(
remote = %snapshot.device_id,
"ignored remote active snapshot while local active playback is protected"
);
return;
}
let lease_expired = active_idle_lease_expired(&snapshot, now);
let already_controls_this_device = state.device_playback.is_personal_control()
&& state.device_playback.active_device_id.as_deref() == Some(snapshot.device_id.as_str());
if !lease_expired || already_controls_this_device {
let was_active = state.device_playback.is_audio_owner();
let was_paused = state.player.playing && state.player.paused;
become_control_device(state, runtime, snapshot.clone());
if was_active && was_paused {
state.popup = Some(state::Popup::FedText {
title: "Active device moved".to_string(),
text: format!("Playback is now controlled by {}.", snapshot.device_name),
});
}
return;
}
if state.device_playback.is_personal_control() {
return;
}
become_active_device(state, runtime, false);
state.status_message = Some(format!(
"active playback moved here; {} was idle for 5m",
snapshot.device_name
));
.insert(snapshot.device_id.clone(), snapshot);
reconcile_personal_playback(state, runtime);
}
fn handle_playback_command(
@@ -3075,10 +3086,19 @@ fn handle_playback_command(
state: wire,
} => {
if active_device_id == state.device_playback.self_device_id {
handle_playback_command(
state,
runtime,
crate::devices::PlaybackCommand::SetState {
state: wire,
seek: true,
},
);
return;
}
let was_active = state.device_playback.is_audio_owner();
let snapshot = crate::devices::PlaybackSnapshot {
coordination: None,
device_id: active_device_id,
device_name: active_device_name,
active: true,
@@ -3098,6 +3118,37 @@ fn handle_playback_command(
fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent) {
match event {
AppEvent::UpdateChecked(result) => {
state.updater.busy = false;
state.updater.message = match result {
Ok(Some(update)) => {
let message = format!("v{} available", update.version);
state.updater.available = Some(update);
message
}
Ok(None) => "No newer stable release".into(),
Err(error) => format!("Check failed: {error}"),
};
state.status_message = Some(state.updater.message.clone());
}
AppEvent::UpdateProgress(message) => state.updater.message = message,
AppEvent::UpdateInstalled(result) => {
state.updater.busy = false;
state.updater.message = match result {
Ok(()) => {
state.updater.installed = true;
let version = state
.updater
.available
.as_ref()
.map(|u| u.version.as_str())
.unwrap_or("new version");
format!("Installed {version}; restart furumi")
}
Err(error) => format!("Update failed: {error}"),
};
state.status_message = Some(state.updater.message.clone());
}
AppEvent::StatusMessage(message) => state.status_message = Some(message),
AppEvent::ListenHistoryLoaded(result) => {
state.listen_history = Some(match result {
@@ -3169,47 +3220,6 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
})
.unwrap_or(1)
.max(1);
let active_revoked = state.device_playback.is_personal_control()
&& state
.device_playback
.active_device_id
.as_ref()
.is_some_and(|active| {
state
.federation
.devices
.as_ref()
.and_then(|status| {
status
.devices
.iter()
.find(|device| device.device_id == *active)
})
.is_some_and(|device| device.revoked)
});
let active_missing = state.device_playback.is_personal_control()
&& state
.device_playback
.active_device_id
.as_ref()
.is_some_and(|active| {
active != &state.device_playback.self_device_id
&& !state.federation.devices.as_ref().is_some_and(|status| {
status
.devices
.iter()
.any(|device| device.device_id == *active)
})
});
if active_revoked || active_missing {
runtime.player.stop();
state.player.playing = false;
state.player.current = None;
state.player.paused = false;
state.player.position_secs = 0.0;
become_active_device(state, runtime, false);
state.status_message = Some("active playback moved to this device".into());
}
clamp_settings_cursor(state);
}
AppEvent::FedSyncFinished(message) => {
@@ -3250,18 +3260,28 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
AppEvent::DevicePlayback(snapshot) => {
handle_device_playback_snapshot(state, runtime, snapshot);
}
AppEvent::PlaybackCommand(_)
AppEvent::PlaybackCommand { .. }
if state.device_playback.role == state::DevicePlaybackRole::Jam =>
{
tracing::debug!("ignored personal-device playback command while Jam is active");
}
AppEvent::PlaybackCommand(command) => {
handle_playback_command(state, runtime, command);
AppEvent::PlaybackCommand {
command,
authority,
origin,
} => {
if runtime
.devices
.playback_command_is_current(&origin, &authority)
{
handle_playback_command(state, runtime, command);
}
}
AppEvent::JamStatus(status) => {
state.jam = status.clone();
match status.role {
crate::jam::JamRole::Host => {
runtime.player.set_playback_allowed(true);
state.device_playback.role = state::DevicePlaybackRole::Jam;
state.device_playback.jam_host = true;
publish_playback_snapshot(state, runtime);
@@ -3780,6 +3800,9 @@ fn handle_app_event(state: &mut AppState, runtime: &mut Runtime, event: AppEvent
}
AppEvent::Player(event) if state.device_playback.is_control() => {
runtime.player_start_pending = false;
// A background decoder may finish after ownership was handed off.
// Ignoring Started alone would leave its obsolete audio playing.
runtime.player.stop();
tracing::debug!(
?event,
"ignored local player event while controlling remote playback"
+32 -22
View File
@@ -926,6 +926,9 @@ impl FedRow {
/// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
AdditionalSettings,
CheckUpdate,
InstallUpdate,
MusicDirectory,
Similarity(SimilarityRow),
Federation(FedRow),
@@ -1064,20 +1067,13 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
indices
}
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = vec![SettingsRow::MusicDirectory];
rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity));
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::DeviceName);
rows.push(SettingsRow::DeviceInvite);
rows.push(SettingsRow::DeviceConnect);
rows.push(SettingsRow::DeviceSyncNow);
rows.push(SettingsRow::DeviceLeaveGroup);
rows.extend(
device_status_order(state)
.into_iter()
.map(SettingsRow::Device),
);
/// Secondary settings share actions but have independent navigation.
pub fn additional_settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = vec![
SettingsRow::MusicDirectory,
SettingsRow::CheckUpdate,
SettingsRow::InstallUpdate,
];
rows.push(SettingsRow::VisualizationClock);
rows.extend(
state
@@ -1091,7 +1087,23 @@ pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
if !state.visualizer.scripts.is_empty() {
rows.push(SettingsRow::VisualizationEdit);
}
rows.push(SettingsRow::StatusDetails);
rows
}
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = vec![SettingsRow::AdditionalSettings, SettingsRow::StatusDetails];
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::DeviceName);
rows.push(SettingsRow::DeviceInvite);
rows.push(SettingsRow::DeviceConnect);
rows.push(SettingsRow::DeviceSyncNow);
rows.push(SettingsRow::DeviceLeaveGroup);
rows.extend(
device_status_order(state)
.into_iter()
.map(SettingsRow::Device),
);
rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity));
rows
}
@@ -1529,6 +1541,7 @@ impl DevicePlaybackRole {
#[derive(Debug, Default)]
pub struct DevicePlaybackState {
pub config: music_dht::playback::Config,
pub role: DevicePlaybackRole,
pub self_device_id: String,
pub self_device_name: String,
@@ -1539,9 +1552,6 @@ pub struct DevicePlaybackState {
pub remote: BTreeMap<String, crate::devices::PlaybackSnapshot>,
pub last_remote_snapshot: Option<crate::devices::PlaybackSnapshot>,
pub jam_host: bool,
/// A freshly started TUI owns playback by protocol. The first active
/// snapshot discovered during startup is imported and handed off here.
pub startup_takeover_pending: bool,
}
impl DevicePlaybackState {
@@ -1550,10 +1560,6 @@ impl DevicePlaybackState {
|| (self.role == DevicePlaybackRole::Jam && !self.jam_host)
}
pub fn is_personal_control(&self) -> bool {
self.role == DevicePlaybackRole::Control
}
pub fn is_audio_owner(&self) -> bool {
self.role == DevicePlaybackRole::Active
|| (self.role == DevicePlaybackRole::Jam && self.jam_host)
@@ -1571,6 +1577,7 @@ impl DevicePlaybackState {
/// event handlers in the main loop; views render from `&AppState`.
#[derive(Debug, Default)]
pub struct AppState {
pub updater: crate::updater::State,
pub active_tab: Tab,
pub should_quit: bool,
pub shutting_down: bool,
@@ -1582,6 +1589,9 @@ pub struct AppState {
pub status_message: Option<String>,
pub spinner_frame: usize,
pub settings_cursor: usize,
/// Kept open underneath child dialogs and asynchronous confirmations.
pub additional_settings_open: bool,
pub additional_settings_cursor: usize,
/// Root for music permanently downloaded from federation peers.
pub music_dir: std::path::PathBuf,
pub music_dir_changing: bool,
+76 -1
View File
@@ -18,6 +18,8 @@ pub const QUIT_CONFIRM_HINT: &str = "press quit again to exit";
/// owns the Runtime (audio controller, API client). Keeps update() pure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Effect {
CheckUpdate,
InstallUpdate(crate::updater::Update),
/// (Re)start playback of `queue[queue_pos]`.
PlayCurrent,
TogglePause,
@@ -113,6 +115,22 @@ pub fn update(state: &mut AppState, action: Action) -> Option<Effect> {
.take()
.is_some_and(|deadline| Instant::now() <= deadline);
state.status_message = None;
if state.additional_settings_open
&& !matches!(
action,
Action::PlayPause
| Action::NextTrack
| Action::PrevTrack
| Action::SeekForward { .. }
| Action::SeekBackward { .. }
| Action::VolumeUp
| Action::VolumeDown
| Action::ToggleShuffle
| Action::CycleRepeat
)
{
return update_additional_settings(state, action);
}
match action {
// While the help window is open, quit/back just close it.
Action::Quit | Action::Back if state.help_visible => {
@@ -2812,9 +2830,66 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
/// Enter on Settings: toggle switches, open text inputs, run
/// one-shot operations. The heavy lifting happens in perform_effect().
fn update_additional_settings(state: &mut AppState, action: Action) -> Option<Effect> {
if state.help_visible {
if matches!(action, Action::Back | Action::Quit | Action::ToggleHelp) {
state.help_visible = false;
}
return None;
}
let rows = super::state::additional_settings_rows(state);
let last = rows.len().saturating_sub(1);
let cursor = state.additional_settings_cursor.min(last);
state.additional_settings_cursor = match action {
Action::Back | Action::Quit => {
state.additional_settings_open = false;
return None;
}
Action::MoveUp | Action::PrevTab => cursor.saturating_sub(1),
Action::MoveDown | Action::NextTab => (cursor + 1).min(last),
Action::PageUp => cursor.saturating_sub(5),
Action::PageDown => (cursor + 5).min(last),
Action::SelectFirst => 0,
Action::SelectLast => last,
Action::Select => return select_settings_row(state, *rows.get(cursor)?),
Action::ToggleHelp => {
state.help_visible = true;
cursor
}
_ => cursor,
};
None
}
fn federation_select(state: &mut AppState) -> Option<Effect> {
select_settings_row(state, *settings_rows(state).get(state.settings_cursor)?)
}
fn select_settings_row(state: &mut AppState, row: super::state::SettingsRow) -> Option<Effect> {
use super::state::{FedInputField, FedRow, Popup, SettingsRow, SimilarityRow};
match settings_rows(state).get(state.settings_cursor).copied()? {
match row {
SettingsRow::AdditionalSettings => {
state.additional_settings_open = true;
None
}
SettingsRow::CheckUpdate => {
if state.updater.busy || state.updater.installed {
return None;
}
state.updater.busy = true;
state.updater.available = None;
state.updater.message = "Checking GitHub Releases...".into();
Some(Effect::CheckUpdate)
}
SettingsRow::InstallUpdate => {
if state.updater.busy || state.updater.installed {
return None;
}
let update = state.updater.available.clone()?;
state.updater.busy = true;
state.updater.message = "Downloading update...".into();
Some(Effect::InstallUpdate(update))
}
SettingsRow::MusicDirectory => {
if state.music_dir_changing {
state.status_message = Some("music directory change is already running".into());
+73
View File
@@ -1,6 +1,79 @@
use super::*;
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem};
#[test]
fn additional_settings_preserve_main_navigation_and_child_dialog_parent() {
use crate::app::state::{FedInputField, Popup, SettingsRow, additional_settings_rows};
let mut state = AppState {
active_tab: Tab::Federation,
..AppState::default()
};
let main_rows = settings_rows(&state);
assert!(!main_rows.contains(&SettingsRow::MusicDirectory));
assert!(!main_rows.contains(&SettingsRow::CheckUpdate));
assert!(!main_rows.contains(&SettingsRow::VisualizationClock));
state.settings_cursor = main_rows
.iter()
.position(|r| *r == SettingsRow::AdditionalSettings)
.unwrap();
let main_cursor = state.settings_cursor;
update(&mut state, Action::Select);
assert!(state.additional_settings_open);
update(&mut state, Action::Select);
assert!(matches!(
state.popup,
Some(Popup::FedInput {
field: FedInputField::MusicDirectory,
..
})
));
// Child dialogs own their input; closing one leaves the parent window intact.
state.popup = None;
assert!(state.additional_settings_open);
update(&mut state, Action::SelectLast);
assert_eq!(
state.additional_settings_cursor,
additional_settings_rows(&state).len() - 1
);
update(&mut state, Action::MoveDown);
assert_eq!(
state.additional_settings_cursor,
additional_settings_rows(&state).len() - 1
);
update(&mut state, Action::ToggleHelp);
update(&mut state, Action::Back);
assert!(!state.help_visible);
assert!(state.additional_settings_open);
update(&mut state, Action::Back);
assert!(!state.additional_settings_open);
assert!(!state.should_quit);
assert_eq!(state.settings_cursor, main_cursor);
}
#[test]
fn manual_update_check_is_single_flight_and_disabled_after_install() {
let mut state = AppState::default();
state.additional_settings_cursor = crate::app::state::additional_settings_rows(&state)
.iter()
.position(|row| *row == crate::app::state::SettingsRow::CheckUpdate)
.unwrap();
assert_eq!(
update_additional_settings(&mut state, Action::Select),
Some(Effect::CheckUpdate)
);
assert!(state.updater.busy);
assert_eq!(update_additional_settings(&mut state, Action::Select), None);
state.updater.busy = false;
state.updater.installed = true;
assert_eq!(update_additional_settings(&mut state, Action::Select), None);
state.updater.installed = false;
state.additional_settings_cursor = crate::app::state::additional_settings_rows(&state)
.iter()
.position(|row| *row == crate::app::state::SettingsRow::InstallUpdate)
.unwrap();
assert_eq!(update_additional_settings(&mut state, Action::Select), None);
}
fn with_artists(n: usize) -> AppState {
let mut state = AppState::default();
state.global.artists = (0..n)
+3
View File
@@ -50,6 +50,8 @@ impl Default for SimilaritySettings {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default)]
pub playback: music_dht::playback::Config,
#[serde(default = "default_volume")]
pub volume: u8,
#[serde(default)]
@@ -64,6 +66,7 @@ pub struct AppSettings {
impl Default for AppSettings {
fn default() -> Self {
Self {
playback: music_dht::playback::Config::default(),
volume: default_volume(),
library: LibraryFilters::default(),
music_dir: default_music_dir(),
+231 -45
View File
@@ -5,6 +5,7 @@
//! materialized tables, so offline clients can merge likes, playlists and
//! membership changes deterministically.
use music_dht::playback::{Checkpoint, CommandStamp, Config as PlaybackConfig, Engine};
use std::collections::{BTreeMap, BTreeSet};
use std::path::PathBuf;
use std::str::FromStr;
@@ -24,7 +25,7 @@ use crate::library::models::{ArtistRef, TrackItem};
pub const SYNC_ALPN: &[u8] = b"furumi/sync/2";
const CLIENT_VERSION: &str = env!("CARGO_PKG_VERSION");
pub const PROTOCOL_VERSION: u16 = 2;
pub const PROTOCOL_VERSION: u16 = 3;
const INVITE_TTL_MS: i64 = 10 * 60 * 1000;
const PAIRING_WAIT_MS: i64 = 5 * 60 * 1000;
const PAIRING_RETRY_DELAY: Duration = Duration::from_secs(1);
@@ -81,6 +82,10 @@ pub struct DeviceSyncStatus {
#[derive(Clone)]
pub struct DeviceSync {
// A device identity has exactly one coordinator, including across binaries
// launched from different installation directories. The OS releases this
// lock on crashes; the file itself is deliberately never removed.
_identity_lock: Option<Arc<std::fs::File>>,
conn: Arc<std::sync::Mutex<Connection>>,
library: Arc<Library>,
event_tx: Arc<std::sync::Mutex<Option<tokio::sync::mpsc::UnboundedSender<AppEvent>>>>,
@@ -245,32 +250,13 @@ pub struct PlaybackStateWire {
pub repeat: PlaybackRepeat,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PlaybackSnapshot {
pub device_id: String,
pub device_name: String,
pub active: bool,
pub updated_at_ms: i64,
pub state: PlaybackStateWire,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum PlaybackCommand {
SetState {
state: PlaybackStateWire,
#[serde(default)]
seek: bool,
},
ActiveChanged {
active_device_id: String,
active_device_name: String,
state: PlaybackStateWire,
},
}
pub type PlaybackSnapshot = music_dht::playback::Snapshot<PlaybackStateWire>;
pub type PlaybackCommand = music_dht::playback::Command<PlaybackStateWire>;
#[derive(Debug, Clone, Default)]
struct PlaybackShared {
engine: Option<Engine>,
engine_group: String,
local: Option<PlaybackSnapshot>,
remote: BTreeMap<String, PlaybackSnapshot>,
}
@@ -369,6 +355,8 @@ pub enum SyncOpPayload {
PlaybackCommand {
target_device_id: String,
command: PlaybackCommand,
#[serde(default)]
authority: Option<CommandStamp>,
},
ListenRecorded {
event: ListenEvent,
@@ -615,16 +603,33 @@ fn default_db_path() -> PathBuf {
.unwrap_or_else(|| PathBuf::from("devices").join("sync.sqlite3"))
}
fn acquire_identity_lock(database: &std::path::Path) -> Result<std::fs::File> {
let path = database.with_extension("lock");
let file = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(&path)
.with_context(|| format!("opening device identity lock {}", path.display()))?;
file.try_lock().with_context(|| format!(
"Cannot acquire device identity lock {}. Another Furumi instance may already be using this device. Close it before launching another binary; two players must not share one device identity.", path.display()
))?;
Ok(file)
}
impl DeviceSync {
pub fn new(library: Arc<Library>) -> Result<Arc<Self>> {
let path = default_db_path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let identity_lock = acquire_identity_lock(&path)?;
let conn =
Connection::open(&path).with_context(|| format!("opening {}", path.display()))?;
init_schema(&conn)?;
let sync = Arc::new(Self {
_identity_lock: Some(Arc::new(identity_lock)),
conn: Arc::new(std::sync::Mutex::new(conn)),
library,
event_tx: Arc::new(std::sync::Mutex::new(None)),
@@ -648,10 +653,92 @@ impl DeviceSync {
format!("{}-{}", now_ms(), random_hex(12))
}
pub fn configure_playback(&self, config: PlaybackConfig) -> Result<()> {
set_meta(
&lock(&self.conn),
"playback_config_v1",
&serde_json::to_string(&config)?,
)?;
lock(&self.playback).engine = None;
Ok(())
}
fn with_playback_engine<R>(&self, f: impl FnOnce(&mut Engine) -> R) -> Result<R> {
let identity = self.ensure_identity()?;
let mut shared = lock(&self.playback);
if shared.engine.is_none() || shared.engine_group != identity.group_id {
shared.engine_group = identity.group_id.clone();
let conn = lock(&self.conn);
let durable = get_meta(&conn, "playback_coordination_v1")?
.map(|json| serde_json::from_str::<Checkpoint>(&json))
.transpose()?
.filter(|checkpoint| checkpoint.scope == identity.group_id)
.map(|checkpoint| checkpoint.state)
.unwrap_or_default();
let config = get_meta(&conn, "playback_config_v1")?
.map(|json| serde_json::from_str::<PlaybackConfig>(&json))
.transpose()?
.unwrap_or_default();
shared.engine = Some(Engine::new(
identity.device_id,
config,
durable,
playback_clock(),
));
}
let engine = shared.engine.as_mut().expect("initialized playback engine");
let previous = engine.clone();
let result = f(engine);
if previous.durable() != engine.durable() {
let saved = set_meta(
&lock(&self.conn),
"playback_coordination_v1",
&serde_json::to_string(&Checkpoint {
scope: identity.group_id,
state: engine.durable().clone(),
})?,
);
if let Err(error) = saved {
*engine = previous;
return Err(error);
}
}
Ok(result)
}
/// Evaluate shared ownership independently of the UI's online-device list.
pub fn playback_tick(&self, available: bool, playing: bool) -> Result<Option<String>> {
self.with_playback_engine(|engine| {
engine.set_output(available, playing);
engine.heartbeat(playback_clock());
engine.tick(playback_clock());
engine.owner().map(str::to_string)
})
}
pub fn playback_command_is_current(&self, origin: &str, stamp: &CommandStamp) -> bool {
self.with_playback_engine(|engine| engine.command_is_current(origin, stamp))
.unwrap_or(false)
}
pub fn claim_playback(&self, target: &str) -> Result<()> {
self.with_playback_engine(|engine| engine.transfer(target, playback_clock()))?
.then_some(())
.context("cannot allocate playback ownership term")
}
pub fn publish_playback(&self, mut snapshot: PlaybackSnapshot) {
if snapshot.updated_at_ms <= 0 {
snapshot.updated_at_ms = now_ms();
}
let Ok(coordination) = self.with_playback_engine(|engine| engine.announcement()) else {
return;
};
snapshot.active = coordination
.claim
.as_ref()
.is_some_and(|claim| claim.owner == snapshot.device_id);
snapshot.coordination = Some(coordination);
lock(&self.playback).local = Some(snapshot);
}
@@ -663,9 +750,22 @@ impl DeviceSync {
if target_device_id.trim().is_empty() {
return Ok(());
}
let authority = self
.with_playback_engine(|engine| {
if let PlaybackCommand::ActiveChanged {
active_device_id, ..
} = &command
&& engine.owner() != Some(active_device_id.as_str())
{
engine.transfer(active_device_id, playback_clock());
}
engine.stamp()
})?
.context("no playback owner; wait for discovery or select an output")?;
self.record_local_op(SyncOpPayload::PlaybackCommand {
target_device_id: target_device_id.to_string(),
command,
authority: Some(authority),
})
}
@@ -845,7 +945,7 @@ impl DeviceSync {
if let Some(profile) = profile {
self.apply_device_profile(&profile, true)?;
if let Some(playback) = playback {
self.apply_playback_snapshot(playback)?;
self.apply_playback_snapshot(&profile.device_id, playback)?;
}
}
self.apply_device_profiles(&devices)?;
@@ -1305,7 +1405,7 @@ impl DeviceSync {
} => {
self.apply_device_profiles(&devices)?;
if let Some(playback) = playback {
self.apply_playback_snapshot(playback)?;
self.apply_playback_snapshot(&device.device_id, playback)?;
}
self.apply_snapshot(snapshot)?;
self.apply_ops(ops)?;
@@ -1636,8 +1736,15 @@ impl DeviceSync {
SyncOpPayload::PlaybackCommand {
target_device_id,
command,
authority,
} => {
self.apply_playback_command(target_device_id, command, &op.op_id)?;
self.apply_playback_command(
target_device_id,
command,
authority.as_ref(),
&op.origin_device_id,
&op.op_id,
)?;
false
}
SyncOpPayload::ListenRecorded { event } => self
@@ -1651,10 +1758,32 @@ impl DeviceSync {
&self,
target_device_id: &str,
command: &PlaybackCommand,
authority: Option<&CommandStamp>,
origin: &str,
op_id: &str,
) -> Result<()> {
let identity = self.ensure_identity()?;
if target_device_id != identity.device_id {
// Legacy commands still replicate with the library log, but cannot
// override the versioned personal-playback protocol.
let Some(authority) = authority else {
return Ok(());
};
let handoff = matches!(command, PlaybackCommand::ActiveChanged { .. });
if !handoff && target_device_id != identity.device_id {
return Ok(());
}
if let PlaybackCommand::ActiveChanged {
active_device_id, ..
} = command
&& active_device_id != &authority.claim.owner
{
return Ok(());
}
let accepted = self.with_playback_engine(|engine| {
engine.accept_command(origin, authority, handoff, playback_clock())
&& (handoff || engine.is_owner())
})?;
if !accepted || target_device_id != identity.device_id {
return Ok(());
}
let inserted = {
@@ -1669,7 +1798,11 @@ impl DeviceSync {
return Ok(());
}
if let Some(tx) = lock(&self.event_tx).as_ref() {
let _ = tx.send(AppEvent::PlaybackCommand(command.clone()));
let _ = tx.send(AppEvent::PlaybackCommand {
command: command.clone(),
authority: authority.clone(),
origin: origin.to_string(),
});
}
Ok(())
}
@@ -2595,19 +2728,29 @@ impl DeviceSync {
lock(&self.playback).local.clone()
}
fn apply_playback_snapshot(&self, snapshot: PlaybackSnapshot) -> Result<()> {
fn apply_playback_snapshot(&self, sender: &str, snapshot: PlaybackSnapshot) -> Result<()> {
let identity = self.ensure_identity()?;
if snapshot.device_id == identity.device_id {
if snapshot.device_id != sender || snapshot.device_id == identity.device_id {
return Ok(());
}
let Some(coordination) = &snapshot.coordination else {
return Ok(());
};
if !self.with_playback_engine(|engine| {
engine.observe(&snapshot.device_id, coordination, playback_clock())
})? {
return Ok(());
}
let should_send = {
let mut playback = lock(&self.playback);
let changed = playback
.remote
.get(&snapshot.device_id)
.is_none_or(|current| snapshot.updated_at_ms > current.updated_at_ms);
let mut shared = lock(&self.playback);
let changed = shared.remote.get(&snapshot.device_id).is_none_or(|old| {
old.coordination.as_ref().is_none_or(|c| {
coordination.claim > c.claim
|| (coordination.claim == c.claim && coordination.heartbeat > c.heartbeat)
})
});
if changed {
playback
shared
.remote
.insert(snapshot.device_id.clone(), snapshot.clone());
}
@@ -2707,13 +2850,45 @@ pub async fn sync_loop(
transport_stats: Arc<crate::federation::TransportStats>,
) {
let mut interval = tokio::time::interval(SYNC_INTERVAL);
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
// Independent polls: an offline device must not hold up live outputs.
// JoinSet aborts outstanding IO when federation stops or restarts.
let mut polls = tokio::task::JoinSet::new();
let mut active = std::collections::HashMap::new();
loop {
interval.tick().await;
if let Err(err) = sync
.sync_once(Arc::clone(&service), Arc::clone(&transport_stats))
.await
{
tracing::debug!("personal sync tick failed: {err:#}");
tokio::select! {
_ = interval.tick() => {
match sync.active_remote_devices() {
Ok(devices) => for device in devices {
if device.endpoint_ticket.trim().is_empty()
|| active.values().any(|id| id == &device.device_id) { continue; }
let device_id = device.device_id.clone();
let sync = Arc::clone(&sync);
let service = Arc::clone(&service);
let stats = Arc::clone(&transport_stats);
let handle = polls.spawn(async move {
tokio::time::timeout(Duration::from_secs(30), sync.sync_device(service, &device, stats))
.await.context("device sync exchange timed out")?
});
active.insert(handle.id(), device_id);
},
Err(err) => { let _ = sync.set_last_error(Some(format!("{err:#}"))); }
}
if let Err(err) = sync.gc_tombstones() {
tracing::debug!("personal sync cleanup failed: {err:#}");
}
}
Some(completed) = polls.join_next_with_id(), if !polls.is_empty() => {
let (task, result) = match completed {
Ok((task, result)) => (task, result),
Err(error) => (error.id(), Err(anyhow::Error::from(error))),
};
if let Some(device_id) = active.remove(&task)
&& let Err(err) = result {
tracing::debug!(device = %device_id, "device sync failed: {err:#}");
let _ = sync.set_last_error(Some(format!("{}: {err:#}", short_id(&device_id))));
}
}
}
if let Some(tx) = lock(&sync.event_tx).as_ref() {
let _ = tx.send(AppEvent::DeviceSyncStatus(sync.status()));
@@ -2936,7 +3111,7 @@ async fn handle_pair_request(
}
sync.apply_device_profile(&profile, true)?;
if let Some(playback) = playback {
sync.apply_playback_snapshot(playback)?;
sync.apply_playback_snapshot(&profile.device_id, playback)?;
}
sync.apply_snapshot(snapshot)?;
sync.apply_ops(ops)?;
@@ -3026,7 +3201,7 @@ async fn handle_hello(
sync.apply_device_profile(&profile, false)?;
sync.apply_device_profiles(&devices)?;
if let Some(playback) = playback {
sync.apply_playback_snapshot(playback)?;
sync.apply_playback_snapshot(&profile.device_id, playback)?;
}
sync.apply_snapshot(snapshot)?;
sync.apply_ops(ops)?;
@@ -3635,3 +3810,14 @@ fn base64url_decode(value: &str) -> Result<Vec<u8>> {
#[cfg(test)]
#[path = "devices/tests.rs"]
mod tests;
#[cfg(test)]
mod interop_tests;
fn playback_clock() -> u64 {
static START: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
START
.get_or_init(std::time::Instant::now)
.elapsed()
.as_millis() as u64
}
+133
View File
@@ -0,0 +1,133 @@
//! Cross-binary wire test. Run with scripts/test_device_interop.py; the other
//! endpoint is compiled from furumusic's real protocol types and player hub.
use super::*;
use tokio::io::AsyncWriteExt;
#[tokio::test]
#[ignore = "run scripts/test_device_interop.py to start both player test binaries"]
async fn localhost_web_peer() {
tokio::time::timeout(Duration::from_secs(30), async {
let dir = PathBuf::from(std::env::var("FURUMI_INTEROP_DIR").expect("interop runner"));
let address = loop {
if let Ok(address) = std::fs::read_to_string(dir.join("web-address")) {
break address;
}
tokio::time::sleep(Duration::from_millis(20)).await;
};
let mut stream = tokio::net::TcpStream::connect(address.trim())
.await
.unwrap();
let conn = Connection::open_in_memory().unwrap();
init_schema(&conn).unwrap();
let sync = DeviceSync {
_identity_lock: None,
conn: Arc::new(std::sync::Mutex::new(conn)),
library: Arc::new(Library::open(&dir.join("interop-library.sqlite3")).unwrap()),
event_tx: Default::default(),
playback: Default::default(),
};
let id = sync.ensure_identity().unwrap();
let (tx, mut events) = tokio::sync::mpsc::unbounded_channel();
sync.set_event_tx(tx);
sync.claim_playback(&id.device_id).unwrap();
sync.playback_tick(true, true).unwrap();
let mut state: PlaybackStateWire = serde_json::from_value(serde_json::json!({
"queue": [{ "id": 7, "title": "Interop track", "duration_seconds": 123.5,
"release_id": 1, "release_title": "Interop album", "artist_names": ["Interop artist"],
"content_id": "b3:0000000000000000000000000000000000000000000000000000000000000000" }],
"queue_pos": 0, "playing": true, "paused": false,
"position_secs": 42.5, "volume": 73, "shuffle": true, "repeat": "all"
}))
.unwrap();
for phase in 0..3 {
sync.publish_playback(PlaybackSnapshot {
device_id: id.device_id.clone(),
device_name: "TUI interop".into(),
active: true,
updated_at_ms: now_ms(),
state: state.clone(),
coordination: None,
});
let hello = WireMessage::Hello {
group_id: id.group_id.clone(),
profile: sync.own_profile("").unwrap(),
devices: vec![],
vector: BTreeMap::new(),
ops: vec![],
snapshot: SyncSnapshot::default(),
playback: sync.local_playback_snapshot(),
};
let mut bytes = serde_json::to_vec(&hello).unwrap();
bytes.push(b'\n');
stream.write_all(&bytes).await.unwrap();
let response: WireMessage =
serde_json::from_slice(&read_line(&mut stream).await.unwrap()).unwrap();
let WireMessage::SyncResponse {
accepted: true,
playback: Some(snapshot),
ops,
..
} = response
else {
panic!("expected web response")
};
let web_id = snapshot.device_id.clone();
sync.apply_playback_snapshot(&web_id, snapshot).unwrap();
assert_eq!(ops.len(), 1);
let op = &ops[0];
// Production command adapter including durable fencing/deduplication.
sync.apply_op(op).unwrap();
sync.apply_op(op).unwrap();
let mut commands = Vec::new();
while let Ok(event) = events.try_recv() {
if let AppEvent::PlaybackCommand {
command,
authority,
origin,
} = event
{
assert!(sync.playback_command_is_current(&origin, &authority));
commands.push(command);
}
}
assert_eq!(commands.len(), 1, "one event even after duplicate delivery");
match commands.pop().unwrap() {
PlaybackCommand::ActiveChanged {
active_device_id,
state: next,
..
} => {
assert_eq!(
active_device_id,
if phase == 0 {
web_id.clone()
} else {
id.device_id.clone()
}
);
assert_eq!(next.position_secs, 42.5);
assert_eq!(next.queue, state.queue);
state = next;
}
PlaybackCommand::SetState { state: next, seek } => {
assert_eq!(phase, 2);
assert!(seek);
assert!(next.paused);
assert_eq!(next.position_secs, 87.0);
state = next;
}
}
assert_eq!(
sync.playback_tick(true, false).unwrap(),
Some(if phase == 0 {
web_id
} else {
id.device_id.clone()
})
);
}
stream.write_all(b"ok\n").await.unwrap();
})
.await
.expect("web/TUI exchange timed out");
}
+386 -6
View File
@@ -1,5 +1,329 @@
use super::*;
/// Exercises the production stream handlers and SQLite adapter, not just the
/// ownership reducer. Each peer has a fresh identity and an isolated library.
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn localhost_devices_exchange_state_and_handoff() {
tokio::time::timeout(Duration::from_secs(30), async {
let dirs = [tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap()];
let network = NetworkId::from_name(&format!("device-test-{}", random_hex(16)));
let mut peers = Vec::new();
let mut servers = Vec::new();
let mut receivers = Vec::new();
for dir in &dirs {
std::fs::create_dir_all(dir.path().join("network")).unwrap();
let config = music_dht::MusicDhtConfig::builder()
.data_dir(dir.path().join("network"))
.network_id(network)
.stream_protocol(SYNC_ALPN)
.build()
.unwrap();
let (service, events) = MusicDhtService::start(config).await.unwrap();
let service = Arc::new(service);
let conn = Connection::open_in_memory().unwrap();
init_schema(&conn).unwrap();
let sync = Arc::new(DeviceSync {
_identity_lock: Some(Arc::new(
acquire_identity_lock(&dir.path().join("sync.sqlite3")).unwrap(),
)),
conn: Arc::new(std::sync::Mutex::new(conn)),
library: Arc::new(Library::open(&dir.path().join("library.sqlite3")).unwrap()),
event_tx: Default::default(),
playback: Default::default(),
});
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
sync.set_event_tx(tx);
let stats = Arc::new(crate::federation::TransportStats::default());
servers.push(tokio::spawn(serve_peers(
service.stream_acceptor(SYNC_ALPN).unwrap(),
sync.clone(),
service.clone(),
stats.clone(),
)));
receivers.push(rx);
peers.push((sync, service, stats, events));
}
let (a, service_a, stats_a, _) = &peers[0];
let (b, service_b, stats_b, _) = &peers[1];
b.ensure_identity().unwrap();
b.set_group_id(&a.ensure_identity().unwrap().group_id)
.unwrap();
let profile_a = a
.own_profile(&service_a.ticket().await.unwrap().to_string())
.unwrap();
let profile_b = b
.own_profile(&service_b.ticket().await.unwrap().to_string())
.unwrap();
a.apply_device_profile(&profile_b, true).unwrap();
b.apply_device_profile(&profile_a, true).unwrap();
a.claim_playback(&profile_a.device_id).unwrap();
a.playback_tick(true, true).unwrap();
let mut state = empty_playback_state();
state.playing = true;
state.position_secs = 42.5;
a.publish_playback(PlaybackSnapshot {
device_id: profile_a.device_id.clone(),
device_name: "TUI".into(),
active: true,
updated_at_ms: now_ms(),
state: state.clone(),
coordination: None,
});
a.sync_device(
service_a.clone(),
&a.active_remote_devices().unwrap()[0],
stats_a.clone(),
)
.await
.unwrap();
assert_eq!(
b.with_playback_engine(|e| e.owner().map(str::to_owned))
.unwrap(),
Some(profile_a.device_id.clone())
);
assert_eq!(lock(&b.playback).remote[&profile_a.device_id].state, state);
assert!(
b.status()
.devices
.iter()
.any(|d| d.device_id == profile_a.device_id && d.last_seen_ms.is_some())
);
a.record_playback_command(
&profile_b.device_id,
PlaybackCommand::ActiveChanged {
active_device_id: profile_b.device_id.clone(),
active_device_name: "Second player".into(),
state: state.clone(),
},
)
.unwrap();
a.sync_device(
service_a.clone(),
&a.active_remote_devices().unwrap()[0],
stats_a.clone(),
)
.await
.unwrap();
let mut transferred = false;
while let Ok(event) = receivers[1].try_recv() {
if let AppEvent::PlaybackCommand {
command:
PlaybackCommand::ActiveChanged {
state: received, ..
},
authority,
origin,
} = event
{
assert_eq!(received, state);
assert!(b.playback_command_is_current(&origin, &authority));
transferred = true;
}
}
assert!(transferred, "handoff must reach the player's event loop");
assert_eq!(
b.playback_tick(true, true).unwrap(),
Some(profile_b.device_id.clone())
);
b.publish_playback(PlaybackSnapshot {
device_id: profile_b.device_id.clone(),
device_name: "Second player".into(),
active: true,
updated_at_ms: now_ms(),
state,
coordination: None,
});
b.sync_device(
service_b.clone(),
&b.active_remote_devices().unwrap()[0],
stats_b.clone(),
)
.await
.unwrap();
assert_eq!(
a.playback_tick(true, false).unwrap(),
Some(profile_b.device_id)
);
// A paired peer that accepts a connection but never answers must not
// serialize or stop subsequent polls to the responsive peer.
let silent_dir = tempfile::tempdir().unwrap();
let (silent, _events) = MusicDhtService::start(
music_dht::MusicDhtConfig::builder()
.data_dir(silent_dir.path())
.network_id(network)
.stream_protocol(SYNC_ALPN)
.build()
.unwrap(),
)
.await
.unwrap();
let _silent_acceptor = silent.stream_acceptor(SYNC_ALPN).unwrap();
let mut silent_profile = profile_a.clone();
silent_profile.device_id = "silent-peer".into();
silent_profile.endpoint_id = silent.endpoint_id().to_string();
silent_profile.endpoint_ticket = silent.ticket().await.unwrap().to_string();
a.apply_device_profile(&silent_profile, true).unwrap();
lock(&a.conn)
.execute(
"UPDATE sync_devices SET last_seen_ms = ?1 WHERE device_id = 'silent-peer'",
[now_ms() + 1_000],
)
.unwrap();
assert_eq!(
a.active_remote_devices().unwrap()[0].device_id,
"silent-peer"
);
a.claim_playback(&profile_a.device_id).unwrap();
let poller = tokio::spawn(sync_loop(a.clone(), service_a.clone(), stats_a.clone()));
for position in [99.0, 100.0] {
a.playback_tick(true, true).unwrap();
let mut next = empty_playback_state();
next.playing = true;
next.position_secs = position;
a.publish_playback(PlaybackSnapshot {
device_id: profile_a.device_id.clone(),
device_name: "TUI".into(),
active: true,
updated_at_ms: now_ms(),
state: next,
coordination: None,
});
tokio::time::timeout(Duration::from_secs(6), async {
loop {
if lock(&b.playback)
.remote
.get(&profile_a.device_id)
.is_some_and(|snapshot| snapshot.state.position_secs == position)
{
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
})
.await
.expect("a stalled peer must not delay live playback polls");
}
poller.abort();
let _ = poller.await;
silent.shutdown().await.unwrap();
for server in servers {
server.abort();
}
for (_, service, _, _) in &peers {
service.shutdown().await.unwrap();
}
})
.await
.expect("localhost sync must complete within 30 seconds");
}
fn empty_playback_state() -> PlaybackStateWire {
PlaybackStateWire {
queue: vec![],
queue_pos: 0,
playing: false,
paused: false,
idle_since_ms: None,
position_secs: 0.0,
volume: 80,
shuffle: false,
repeat: PlaybackRepeat::Off,
}
}
#[test]
fn coordination_ignores_legacy_commands_and_wrong_snapshot_sender() {
let sync = test_sync();
let identity = sync.ensure_identity().unwrap();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
sync.set_event_tx(tx);
let command = PlaybackCommand::SetState {
state: empty_playback_state(),
seek: false,
};
sync.apply_playback_command(&identity.device_id, &command, None, "remote", "legacy")
.unwrap();
assert!(rx.try_recv().is_err());
let mut remote = Engine::new(
"remote".into(),
PlaybackConfig::default(),
Default::default(),
0,
);
remote.transfer("remote", 0);
remote.set_output(true, true);
remote.heartbeat(1);
let snapshot = PlaybackSnapshot {
device_id: "remote".into(),
device_name: "Remote".into(),
active: true,
updated_at_ms: now_ms(),
state: empty_playback_state(),
coordination: Some(remote.announcement()),
};
sync.apply_playback_snapshot("different-sender", snapshot.clone())
.unwrap();
assert!(
sync.with_playback_engine(|engine| engine.owner().is_none())
.unwrap()
);
sync.apply_playback_snapshot("remote", snapshot).unwrap();
assert_eq!(
sync.playback_tick(true, false).unwrap().as_deref(),
Some("remote")
);
sync.publish_playback(PlaybackSnapshot {
device_id: identity.device_id,
device_name: identity.name,
active: false,
updated_at_ms: now_ms(),
state: empty_playback_state(),
coordination: None,
});
let gossip = sync
.local_playback_snapshot()
.unwrap()
.coordination
.unwrap();
assert_eq!(gossip.claim.unwrap().owner, "remote");
}
#[test]
fn checkpoint_is_not_reused_after_changing_trusted_group() {
let sync = test_sync();
sync.claim_playback("old-group-owner").unwrap();
sync.set_group_id("new-test-group").unwrap();
assert!(
sync.with_playback_engine(|engine| engine.owner().is_none())
.unwrap()
);
}
#[test]
fn queued_command_loses_its_fence_when_another_owner_wins() {
let sync = test_sync();
let identity = sync.ensure_identity().unwrap();
sync.claim_playback(&identity.device_id).unwrap();
let stamp = sync
.with_playback_engine(|engine| engine.stamp().unwrap())
.unwrap();
sync.apply_playback_command(
&identity.device_id,
&PlaybackCommand::SetState {
state: empty_playback_state(),
seek: false,
},
Some(&stamp),
&identity.device_id,
"queued",
)
.unwrap();
assert!(sync.playback_command_is_current(&identity.device_id, &stamp));
sync.claim_playback("new-owner").unwrap();
assert!(!sync.playback_command_is_current(&identity.device_id, &stamp));
}
static NEXT_TEST_DB: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
fn test_sync() -> DeviceSync {
@@ -13,6 +337,7 @@ fn test_sync() -> DeviceSync {
unique
));
let sync = DeviceSync {
_identity_lock: None,
conn: Arc::new(std::sync::Mutex::new(conn)),
library: Arc::new(Library::open(&library_path).unwrap()),
event_tx: Arc::new(std::sync::Mutex::new(None)),
@@ -22,6 +347,38 @@ fn test_sync() -> DeviceSync {
sync
}
#[test]
fn device_identity_has_one_coordinator_across_installation_paths() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sync.sqlite3");
let first = acquire_identity_lock(&path).unwrap();
assert!(acquire_identity_lock(&path).is_err());
let child = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"devices::tests::identity_lock_child_process",
"--nocapture",
])
.env("FURUMI_IDENTITY_LOCK_TEST_PATH", &path)
.output()
.unwrap();
assert!(
child.status.success(),
"{}",
String::from_utf8_lossy(&child.stderr)
);
drop(first);
// Reopening a leftover lock file after shutdown must succeed.
assert!(acquire_identity_lock(&path).is_ok());
}
#[test]
fn identity_lock_child_process() {
if let Some(path) = std::env::var_os("FURUMI_IDENTITY_LOCK_TEST_PATH") {
assert!(acquire_identity_lock(std::path::Path::new(&path)).is_err());
}
}
fn device_revoked(sync: &DeviceSync, device_id: &str) -> bool {
let conn = lock(&sync.conn);
conn.query_row(
@@ -212,19 +569,41 @@ fn playback_command_is_targeted_and_deduplicated() {
seek: false,
};
sync.apply_playback_command("dev_other", &command, "op_other")
sync.claim_playback(&identity.device_id).unwrap();
let stamp = sync
.with_playback_engine(|engine| engine.stamp().unwrap())
.unwrap();
sync.apply_playback_command(
"dev_other",
&command,
Some(&stamp),
&identity.device_id,
"op_other",
)
.unwrap();
assert!(rx.try_recv().is_err());
sync.apply_playback_command(&identity.device_id, &command, "op_1")
.unwrap();
sync.apply_playback_command(
&identity.device_id,
&command,
Some(&stamp),
&identity.device_id,
"op_1",
)
.unwrap();
assert!(matches!(
rx.try_recv().unwrap(),
crate::app::event::AppEvent::PlaybackCommand(_)
crate::app::event::AppEvent::PlaybackCommand { .. }
));
sync.apply_playback_command(&identity.device_id, &command, "op_1")
.unwrap();
sync.apply_playback_command(
&identity.device_id,
&command,
Some(&stamp),
&identity.device_id,
"op_1",
)
.unwrap();
assert!(rx.try_recv().is_err());
}
@@ -245,6 +624,7 @@ fn playback_commands_are_caught_up_only_by_their_target_while_fresh() {
},
seek: false,
};
sync.claim_playback("dev_target").unwrap();
sync.record_playback_command("dev_target", command.clone())
.unwrap();
sync.record_playback_command("dev_other", command).unwrap();
+1
View File
@@ -668,6 +668,7 @@ mod tests {
repeat: crate::devices::PlaybackRepeat::Off,
};
host.publish_host_playback(PlaybackSnapshot {
coordination: None,
device_id: "dev_host".into(),
device_name: "Host".into(),
active: true,
+108 -16
View File
@@ -12,6 +12,7 @@ mod similarity;
mod status;
mod streaming;
mod ui;
mod updater;
mod visualizer;
use std::io;
@@ -60,7 +61,9 @@ fn main() -> Result<()> {
if let Err(err) = config::logging::init() {
startup_warning = Some(format!("logging disabled: {err:#}"));
}
capture_stderr();
// Restore stderr before main returns: Rust prints a returned error only
// after local guards have been dropped and the terminal has been restored.
let _stderr_capture = capture_stderr();
let (keymap, keymap_warning) = config::keymap::Keymap::load();
let startup_warning = keymap_warning.or(startup_warning);
@@ -131,25 +134,46 @@ fn run_app(
/// every line into tracing — it lands in the Logs tab and the log file
/// instead of the screen.
#[cfg(unix)]
fn capture_stderr() {
struct StderrCapture {
original: std::os::fd::RawFd,
}
#[cfg(unix)]
impl Drop for StderrCapture {
fn drop(&mut self) {
// SAFETY: original is our owned duplicate, kept open for this scope.
unsafe {
libc::dup2(self.original, libc::STDERR_FILENO);
libc::close(self.original);
}
}
}
#[cfg(unix)]
fn capture_stderr() -> Option<StderrCapture> {
use std::io::BufRead as _;
use std::os::fd::FromRawFd as _;
let mut fds = [0i32; 2];
// SAFETY: plain pipe/dup2 syscalls on freshly created fds.
unsafe {
let original = libc::dup(libc::STDERR_FILENO);
if original == -1 {
return None;
}
let capture = StderrCapture { original };
if libc::pipe(fds.as_mut_ptr()) != 0 {
return;
return None;
}
let [read_fd, write_fd] = fds;
if libc::dup2(write_fd, libc::STDERR_FILENO) == -1 {
libc::close(read_fd);
libc::close(write_fd);
return;
return None;
}
libc::close(write_fd);
let reader = std::fs::File::from_raw_fd(read_fd);
std::thread::Builder::new()
let reader_thread = std::thread::Builder::new()
.name("stderr".to_string())
.spawn(move || {
for line in std::io::BufReader::new(reader).lines() {
@@ -158,33 +182,65 @@ fn capture_stderr() {
tracing::warn!(target: "stderr", "{line}");
}
}
})
.ok();
});
if reader_thread.is_err() {
return None;
}
Some(capture)
}
}
#[cfg(windows)]
fn capture_stderr() {
struct StderrCapture {
original: windows_sys::Win32::Foundation::HANDLE,
writer: windows_sys::Win32::Foundation::HANDLE,
}
#[cfg(windows)]
impl Drop for StderrCapture {
fn drop(&mut self) {
// SAFETY: original is borrowed from the process; writer is owned by
// this guard. Restoring the original also preserves shell redirection.
unsafe {
windows_sys::Win32::System::Console::SetStdHandle(
windows_sys::Win32::System::Console::STD_ERROR_HANDLE,
self.original,
);
windows_sys::Win32::Foundation::CloseHandle(self.writer);
}
}
}
#[cfg(windows)]
fn capture_stderr() -> Option<StderrCapture> {
use std::io::BufRead as _;
use std::os::windows::io::FromRawHandle as _;
use windows_sys::Win32::Foundation::CloseHandle;
use windows_sys::Win32::System::Console::{STD_ERROR_HANDLE, SetStdHandle};
use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE};
use windows_sys::Win32::System::Console::{GetStdHandle, STD_ERROR_HANDLE, SetStdHandle};
use windows_sys::Win32::System::Pipes::CreatePipe;
unsafe {
let original = GetStdHandle(STD_ERROR_HANDLE);
if original.is_null() || original == INVALID_HANDLE_VALUE {
return None;
}
let mut read = core::ptr::null_mut();
let mut write = core::ptr::null_mut();
if CreatePipe(&mut read, &mut write, core::ptr::null(), 0) == 0 {
return;
return None;
}
if SetStdHandle(STD_ERROR_HANDLE, write) == 0 {
CloseHandle(read);
CloseHandle(write);
return;
return None;
}
let capture = StderrCapture {
original,
writer: write,
};
let reader = std::fs::File::from_raw_handle(read);
std::thread::Builder::new()
let reader_thread = std::thread::Builder::new()
.name("stderr".to_string())
.spawn(move || {
for line in std::io::BufReader::new(reader).lines() {
@@ -193,13 +249,49 @@ fn capture_stderr() {
tracing::warn!(target: "stderr", "{line}");
}
}
})
.ok();
});
if reader_thread.is_err() {
return None;
}
Some(capture)
}
}
#[cfg(not(any(unix, windows)))]
fn capture_stderr() {}
fn capture_stderr() -> Option<()> {
None
}
#[cfg(test)]
mod stderr_tests {
#[test]
fn error_output_is_restored_after_capture() {
const CHILD: &str = "FURUMI_STDERR_CAPTURE_TEST";
if std::env::var_os(CHILD).is_some() {
{
let _capture = super::capture_stderr().expect("capture stderr");
}
eprintln!("furumi-test: visible error after terminal shutdown");
return;
}
// Run in another process: redirecting global stderr inside a parallel
// test suite would interfere with unrelated tests and panic reporting.
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args([
"--exact",
"stderr_tests::error_output_is_restored_after_capture",
"--nocapture",
])
.env(CHILD, "1")
.output()
.unwrap();
assert!(output.status.success());
assert!(
String::from_utf8_lossy(&output.stderr)
.contains("furumi-test: visible error after terminal shutdown")
);
}
}
/// Kitty keyboard protocol, where supported, disambiguates Esc from alt-keys
/// and modifier combos. The flags are popped on exit and on panic — leaving
+31 -3
View File
@@ -90,9 +90,18 @@ impl Shared {
pub struct Controller {
tx: Sender<Command>,
pub shared: Arc<Shared>,
playback_allowed: Arc<AtomicBool>,
}
impl Controller {
/// Revoke the output at the audio thread boundary, including work queued
/// by background decoders before an ownership transfer.
pub fn set_playback_allowed(&self, allowed: bool) {
if self.playback_allowed.swap(allowed, Ordering::AcqRel) && !allowed {
self.stop();
}
}
pub fn play(&self, reader: TrackReader, byte_len: Option<u64>, volume: f32) {
let _ = self.tx.send(Command::Play {
reader,
@@ -142,11 +151,17 @@ pub fn spawn(on_event: impl Fn(PlayerEvent) + Send + 'static) -> Controller {
let (tx, rx) = std::sync::mpsc::channel();
let shared = Arc::new(Shared::default());
let thread_shared = Arc::clone(&shared);
let playback_allowed = Arc::new(AtomicBool::new(true));
let thread_allowed = Arc::clone(&playback_allowed);
std::thread::Builder::new()
.name("audio".to_string())
.spawn(move || run(rx, thread_shared, on_event))
.spawn(move || run(rx, thread_shared, thread_allowed, on_event))
.expect("spawning the audio thread cannot fail");
Controller { tx, shared }
Controller {
tx,
shared,
playback_allowed,
}
}
struct Output {
@@ -155,7 +170,12 @@ struct Output {
player: Player,
}
fn run(rx: Receiver<Command>, shared: Arc<Shared>, on_event: impl Fn(PlayerEvent)) {
fn run(
rx: Receiver<Command>,
shared: Arc<Shared>,
playback_allowed: Arc<AtomicBool>,
on_event: impl Fn(PlayerEvent),
) {
let mut output: Option<Output> = None;
let mut track_loaded = false;
let mut last_len = 0usize;
@@ -163,6 +183,14 @@ fn run(rx: Receiver<Command>, shared: Arc<Shared>, on_event: impl Fn(PlayerEvent
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(command) => {
if !playback_allowed.load(Ordering::Acquire)
&& matches!(
command,
Command::Play { .. } | Command::Enqueue { .. } | Command::Resume
)
{
continue;
}
// Commands change the source queue legitimately; resync the
// length so the next tick doesn't read it as a track ending.
handle(command, &shared, &mut output, &mut track_loaded, &on_event);
+191
View File
@@ -0,0 +1,191 @@
//! Secondary settings window; child dialogs are rendered above it.
use ratatui::{
Frame,
layout::{Constraint, Layout, Rect},
text::Line,
widgets::{Block, Clear, List, ListItem, ListState, Paragraph, Wrap},
};
use super::theme;
use crate::app::state::{AppState, SettingsRow, additional_settings_rows};
pub fn draw(frame: &mut Frame, state: &AppState) {
let screen = frame.area();
let width = screen.width.saturating_sub(2).min(90);
let height = screen.height.saturating_sub(2).min(26);
let area = Rect::new(
screen.x + (screen.width - width) / 2,
screen.y + (screen.height - height) / 2,
width,
height,
);
let block = Block::bordered()
.title(" Additional settings ")
.title_style(theme::header_for(state))
.border_style(theme::strong_border_for(state));
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let [list_area, status_area, footer] = Layout::vertical([
Constraint::Min(1),
Constraint::Length(3),
Constraint::Length(1),
])
.areas(inner);
let rows = additional_settings_rows(state);
let mut items = Vec::new();
let mut selected = 0;
for (index, row) in rows.iter().enumerate() {
let section = match row {
SettingsRow::MusicDirectory => Some("Library"),
SettingsRow::CheckUpdate => Some("Updates"),
SettingsRow::VisualizationClock => Some("Visualizations"),
_ => None,
};
if let Some(section) = section {
items.push(ListItem::new(Line::styled(
section,
theme::header_for(state),
)));
}
if index
== state
.additional_settings_cursor
.min(rows.len().saturating_sub(1))
{
selected = items.len();
}
let (label, value, enabled) = match row {
SettingsRow::MusicDirectory => (
"Music save directory".into(),
if state.music_dir_changing {
"checking/changing...".into()
} else {
state.music_dir.to_string_lossy().into_owned()
},
!state.music_dir_changing,
),
SettingsRow::CheckUpdate => (
"Check for updates".into(),
format!("v{}", env!("CARGO_PKG_VERSION")),
!state.updater.busy && !state.updater.installed,
),
SettingsRow::InstallUpdate => (
"Install update".into(),
state
.updater
.available
.as_ref()
.map(|u| format!("v{}", u.version))
.unwrap_or_else(|| "check for updates first".into()),
state.updater.available.is_some()
&& !state.updater.busy
&& !state.updater.installed,
),
SettingsRow::VisualizationClock => (
"Show clock".into(),
if state.visualizer.config.show_clock {
"on"
} else {
"off"
}
.into(),
true,
),
SettingsRow::VisualizationScript(index) => {
let script = &state.visualizer.scripts[*index];
let mark = if state.visualizer.selected_script_index() == Some(*index) {
"* "
} else {
""
};
(
format!("{mark}{}", script.name),
script
.path
.file_name()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_default(),
true,
)
}
SettingsRow::VisualizationNew => {
("+ New visualization script".into(), "enter".into(), true)
}
SettingsRow::VisualizationEdit => {
("Edit selected visualization".into(), "enter".into(), true)
}
_ => continue,
};
let item = ListItem::new(format!("{label}: {value}"));
items.push(if enabled {
item
} else {
item.style(theme::dim())
});
}
frame.render_stateful_widget(
List::new(items)
.highlight_symbol("> ")
.highlight_style(theme::selection_for(state)),
list_area,
&mut ListState::default().with_selected(Some(selected)),
);
let message = state
.status_message
.as_deref()
.filter(|message| !message.is_empty())
.unwrap_or(&state.updater.message);
frame.render_widget(
Paragraph::new(message)
.wrap(Wrap { trim: true })
.style(theme::dim()),
status_area,
);
frame.render_widget(
Paragraph::new("Up/Down: navigate | Enter: select | Esc: back").style(theme::dim()),
footer,
);
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn window_renders_controls_and_scrolls_to_last_row_in_small_terminal() {
for (width, height) in [(80, 30), (45, 14)] {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, height)).unwrap();
let mut state = AppState::default();
state.updater.message = "No newer stable release".into();
terminal.draw(|frame| draw(frame, &state)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("Additional settings"));
assert!(text.contains("Music save directory"));
if height >= 30 {
assert!(text.contains("Check for updates"));
assert!(text.contains("Install update"));
assert!(text.contains("Visualizations"));
}
state.additional_settings_cursor = additional_settings_rows(&state).len() - 1;
terminal.draw(|frame| draw(frame, &state)).unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|c| c.symbol())
.collect();
assert!(text.contains("New visualization script"));
assert!(text.contains("No newer stable release"));
}
}
}
+81 -120
View File
@@ -33,7 +33,7 @@ pub fn draw(frame: &mut Frame, area: Rect, state: &AppState) {
}
let rows_height =
(settings_rows(state).len() + 10 + device_presence_sections(state).len()) as u16;
(settings_rows(state).len() + 6 + device_presence_sections(state).len()) as u16;
let [rows_area, _, status_area] = Layout::vertical([
Constraint::Length(rows_height.min(inner.height)),
Constraint::Length(1),
@@ -69,7 +69,6 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
let mut y = area.y;
let mut cursor = 0usize;
draw_section(frame, area, state, &mut y, "Library");
draw_row(
frame,
area,
@@ -77,56 +76,22 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
&mut y,
cursor,
state.settings_cursor,
"Music save directory",
if state.music_dir_changing {
format!("{} checking/changing…", state.spinner())
} else {
state.music_dir.to_string_lossy().into_owned()
},
"Additional settings",
"enter".into(),
);
cursor += 1;
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Full status details",
"enter".to_string(),
);
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Similarity Search");
let similarity = &state.similarity.settings;
for row in SimilarityRow::ALL {
let (label, value) = match row {
SimilarityRow::Toggle => ("Similarity search", on_off(similarity.enabled).to_string()),
SimilarityRow::Model => (
"Embedding model",
crate::similarity::model_by_id(&similarity.model)
.map(|model| format!("{} · {}", model.id, model.license))
.unwrap_or_else(|| similarity.model.clone()),
),
SimilarityRow::Profile => (
"Preprocessing profile",
format!("{} (enter for details)", similarity.profile),
),
SimilarityRow::MinimumScore => (
"Minimum similarity",
format!("{:.2}", similarity.minimum_score),
),
SimilarityRow::MaxTracksPerArtist => (
"Tracks per artist",
similarity.max_tracks_per_artist.to_string(),
),
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
SimilarityRow::Clear => ("Clear all stored embeddings", "".to_string()),
};
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
label,
value,
);
cursor += 1;
}
cursor += 1;
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Federation");
@@ -314,39 +279,32 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
}
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Visualizations");
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Show clock",
if state.visualizer.config.show_clock {
"[x]".to_string()
} else {
"[ ]".to_string()
},
);
cursor += 1;
for (index, script) in state.visualizer.scripts.iter().enumerate() {
let selected_script = state
.visualizer
.selected_script_index()
.is_some_and(|selected| selected == index);
let label = if selected_script {
format!("* {}", script.name)
} else {
format!(" {}", script.name)
draw_section(frame, area, state, &mut y, "Similarity Search");
let similarity = &state.similarity.settings;
for row in SimilarityRow::ALL {
let (label, value) = match row {
SimilarityRow::Toggle => ("Similarity search", on_off(similarity.enabled).to_string()),
SimilarityRow::Model => (
"Embedding model",
crate::similarity::model_by_id(&similarity.model)
.map(|model| format!("{} · {}", model.id, model.license))
.unwrap_or_else(|| similarity.model.clone()),
),
SimilarityRow::Profile => (
"Preprocessing profile",
format!("{} (enter for details)", similarity.profile),
),
SimilarityRow::MinimumScore => (
"Minimum similarity",
format!("{:.2}", similarity.minimum_score),
),
SimilarityRow::MaxTracksPerArtist => (
"Tracks per artist",
similarity.max_tracks_per_artist.to_string(),
),
SimilarityRow::Workers => ("Background workers", similarity.workers.to_string()),
SimilarityRow::Clear => ("Clear all stored embeddings", "".to_string()),
};
let value = script
.path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("")
.to_string();
draw_row(
frame,
area,
@@ -354,49 +312,11 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
&mut y,
cursor,
state.settings_cursor,
&label,
label,
value,
);
cursor += 1;
}
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"+ New visualization script",
"".to_string(),
);
cursor += 1;
if state.visualizer.selected_script().is_some() {
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Edit selected visualization",
"".to_string(),
);
cursor += 1;
}
y = y.saturating_add(1);
draw_row(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Full status details",
"enter".to_string(),
);
}
fn protocol_label(id: &str) -> &str {
@@ -1578,3 +1498,44 @@ fn relative_time_label(value_ms: Option<i64>, now_ms: i64) -> String {
format!("{}d ago", seconds / 60 / 60 / 24)
}
}
#[cfg(test)]
mod update_ui_tests {
use super::*;
#[test]
fn update_controls_and_existing_sections_render_in_both_layouts() {
for width in [80, 160] {
let mut terminal =
ratatui::Terminal::new(ratatui::backend::TestBackend::new(width, 55)).unwrap();
let mut state = AppState::default();
state.updater.message = "No newer stable release".into();
terminal
.draw(|frame| draw(frame, frame.area(), &state))
.unwrap();
let text: String = terminal
.backend()
.buffer()
.content
.iter()
.map(|cell| cell.symbol())
.collect();
let mut previous = 0;
for expected in [
"Additional settings",
"Full status details",
"Federation",
"Connected Devices",
"Similarity Search",
] {
assert!(
text.contains(expected),
"missing {expected} at width {width}"
);
let position = text.find(expected).unwrap();
assert!(position >= previous, "incorrect order for {expected}");
previous = position;
}
}
}
}
+5
View File
@@ -1,3 +1,4 @@
mod additional_settings;
pub mod art;
mod federation;
mod global;
@@ -75,6 +76,10 @@ pub fn draw(frame: &mut Frame, state: &AppState, keymap: &Keymap) {
}
draw_status(frame, status_area, state);
if state.additional_settings_open {
additional_settings::draw(frame, state);
}
if state.help_visible {
draw_help(frame, keymap, state);
}
+432
View File
@@ -0,0 +1,432 @@
//! Manual portable-binary updates. All network/disk work runs off the UI thread.
use std::{
fs::File,
io::{Read, Write},
path::Path,
time::Duration,
};
use anyhow::{Context, Result, bail, ensure};
use object::Object;
use reqwest::blocking::Client;
use semver::Version;
use serde::Deserialize;
use sha2::{Digest, Sha256};
const REPOSITORY: &str = "https://api.github.com/repos/house-of-vanity/furumi_tui";
const MAX_ARCHIVE: u64 = 512 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Update {
pub version: String,
asset: Asset,
checksums: Asset,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
struct Asset {
name: String,
browser_download_url: String,
}
#[derive(Deserialize)]
struct Release {
tag_name: String,
draft: bool,
prerelease: bool,
assets: Vec<Asset>,
}
#[derive(Debug, Default)]
pub struct State {
pub busy: bool,
pub installed: bool,
pub available: Option<Update>,
pub message: String,
}
fn client() -> Result<Client> {
// Reuse the ring backend already used by federation; respect an existing provider.
let _ = rustls::crypto::ring::default_provider().install_default();
Ok(Client::builder()
.user_agent(concat!("furumi/", env!("CARGO_PKG_VERSION")))
.https_only(true)
.connect_timeout(Duration::from_secs(10))
.timeout(Duration::from_secs(300))
.build()?)
}
fn read_limited(mut reader: impl Read, limit: u64) -> Result<Vec<u8>> {
let mut data = Vec::new();
reader.by_ref().take(limit + 1).read_to_end(&mut data)?;
ensure!(data.len() as u64 <= limit, "download exceeds size limit");
Ok(data)
}
fn select_release(release: Release, current: &str, os: &str, arch: &str) -> Result<Option<Update>> {
let version = release
.tag_name
.strip_prefix('v')
.unwrap_or(&release.tag_name);
let next = Version::parse(version).context("invalid release version")?;
if release.draft
|| release.prerelease
|| !next.pre.is_empty()
|| next <= Version::parse(current)?
{
return Ok(None);
}
let platform = match os {
"linux" => "linux",
"macos" => "macos",
"windows" => "windows",
_ => bail!("self-update is unsupported on {os}"),
};
let extension = if os == "windows" { "zip" } else { "tar.gz" };
let name = format!("furumi-{platform}-{arch}-{version}.{extension}");
let find = |name: &str| -> Result<Asset> {
let matches: Vec<_> = release
.assets
.iter()
.filter(|asset| asset.name == name)
.collect();
ensure!(matches.len() == 1, "release has no unique {name} asset");
Ok(matches[0].clone())
};
Ok(Some(Update {
version: version.to_owned(),
asset: find(&name)?,
checksums: find("SHA256SUMS")?,
}))
}
pub fn check() -> Result<Option<Update>> {
let response = client()?
.get(format!("{REPOSITORY}/releases/latest"))
.timeout(Duration::from_secs(20))
.send()?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(None);
}
let release = serde_json::from_slice(&read_limited(
response.error_for_status()?,
2 * 1024 * 1024,
)?)?;
select_release(
release,
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
)
}
fn checksum(text: &str, name: &str) -> Result<String> {
let mut found = None;
for line in text.lines() {
let Some((hash, filename)) = line.split_once(' ') else {
continue;
};
if filename.trim_start().trim_start_matches('*') != name {
continue;
}
ensure!(found.is_none(), "duplicate checksum for {name}");
ensure!(
hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()),
"invalid SHA-256 for {name}"
);
found = Some(hash.to_ascii_lowercase());
}
found.context("release does not contain a checksum for the selected archive")
}
fn extract(archive: &Path, name: &str, output: &mut File) -> Result<()> {
let binary = if cfg!(windows) {
"furumi.exe"
} else {
"furumi"
};
let root = name
.strip_suffix(".tar.gz")
.or_else(|| name.strip_suffix(".zip"))
.context("unsupported archive")?;
// Existing release archives omit the version in their inner directory.
let root = root.rsplit_once('-').context("invalid archive name")?.0;
let expected = format!("{root}/{binary}");
let mut count = 0;
if name.ends_with(".zip") {
let mut archive = zip::ZipArchive::new(File::open(archive)?)?;
for index in 0..archive.len() {
let mut entry = archive.by_index(index)?;
if entry.name() != expected {
continue;
}
ensure!(
entry.is_file() && !entry.is_symlink(),
"binary is not a regular file"
);
count += 1;
ensure!(count == 1, "duplicate binary in archive");
output.write_all(&read_limited(&mut entry, MAX_ARCHIVE)?)?;
}
} else {
let mut archive = tar::Archive::new(flate2::read::GzDecoder::new(File::open(archive)?));
for entry in archive.entries()? {
let mut entry = entry?;
if entry.path()?.as_ref() != Path::new(&expected) {
continue;
}
ensure!(
entry.header().entry_type().is_file(),
"binary is not a regular file"
);
count += 1;
ensure!(count == 1, "duplicate binary in archive");
output.write_all(&read_limited(&mut entry, MAX_ARCHIVE)?)?;
}
}
ensure!(count == 1, "archive does not contain {expected}");
output.sync_all()?;
Ok(())
}
fn validate_binary(current: &[u8], candidate: &[u8]) -> Result<()> {
let current = object::File::parse(current).context("cannot inspect installed binary")?;
let candidate = object::File::parse(candidate).context("invalid downloaded binary")?;
ensure!(
candidate.kind() == object::ObjectKind::Executable
|| candidate.kind() == object::ObjectKind::Dynamic,
"download is not an executable"
);
ensure!(
candidate.format() == current.format()
&& candidate.architecture() == current.architecture()
&& candidate.is_64() == current.is_64()
&& candidate.is_little_endian() == current.is_little_endian(),
"downloaded binary has incompatible platform or architecture"
);
Ok(())
}
pub fn install(update: &Update, mut progress: impl FnMut(String)) -> Result<()> {
let exe = std::env::current_exe()?.canonicalize()?;
let parent = exe.parent().context("executable has no parent directory")?;
let lock = File::options()
.read(true)
.write(true)
.create(true)
.truncate(false)
.open(parent.join(".furumi-update.lock"))
.context("cannot write to installation directory")?;
fs2::FileExt::try_lock_exclusive(&lock).context("another furumi instance is updating")?;
// Keep staging on the destination filesystem and never overwrite a running image.
let staging = tempfile::Builder::new()
.prefix(".furumi-update-")
.tempdir_in(parent)
.context("cannot create update staging directory")?;
let client = client()?;
let sums = read_limited(
client
.get(&update.checksums.browser_download_url)
.send()?
.error_for_status()?,
1024 * 1024,
)?;
let expected = checksum(std::str::from_utf8(&sums)?, &update.asset.name)?;
let mut response = client
.get(&update.asset.browser_download_url)
.send()?
.error_for_status()?;
let total = response.content_length();
ensure!(
total.is_none_or(|size| size <= MAX_ARCHIVE),
"archive exceeds size limit"
);
let archive = staging.path().join("download");
let mut file = File::create(&archive)?;
let mut hash = Sha256::new();
let mut buffer = [0u8; 64 * 1024];
let mut downloaded = 0u64;
let mut reported = u64::MAX;
loop {
let count = response.read(&mut buffer)?;
if count == 0 {
break;
}
downloaded += count as u64;
ensure!(downloaded <= MAX_ARCHIVE, "archive exceeds size limit");
file.write_all(&buffer[..count])?;
hash.update(&buffer[..count]);
let mb = downloaded / (1024 * 1024);
if mb != reported {
progress(format!("Downloading: {mb} MiB"));
reported = mb;
}
}
file.sync_all()?;
drop(file);
ensure!(
format!("{:x}", hash.finalize()) == expected,
"SHA-256 mismatch; update was not installed"
);
progress("Verifying binary...".into());
let binary = staging.path().join(if cfg!(windows) {
"furumi.exe"
} else {
"furumi"
});
let mut output = File::create(&binary)?;
extract(&archive, &update.asset.name, &mut output)?;
drop(output);
validate_binary(&std::fs::read(&exe)?, &std::fs::read(&binary)?)?;
std::fs::set_permissions(&binary, std::fs::metadata(&exe)?.permissions())?;
progress("Installing...".into());
self_replace::self_replace(&binary).context("could not replace executable")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn https_client_can_be_constructed() {
client().unwrap();
}
#[test]
fn extracts_only_expected_binary_from_release_archives() {
let temp = tempfile::tempdir().unwrap();
let binary = if cfg!(windows) {
"furumi.exe"
} else {
"furumi"
};
let expected = format!("furumi-windows-x86_64/{binary}");
let payload = b"test executable";
let zip_path = temp.path().join("test.zip");
let mut zip = zip::ZipWriter::new(File::create(&zip_path).unwrap());
zip.start_file("README.md", zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(b"readme").unwrap();
zip.start_file(&expected, zip::write::SimpleFileOptions::default())
.unwrap();
zip.write_all(payload).unwrap();
zip.finish().unwrap();
let output = temp.path().join("output");
extract(
&zip_path,
"furumi-windows-x86_64-0.1.6.zip",
&mut File::create(&output).unwrap(),
)
.unwrap();
assert_eq!(std::fs::read(&output).unwrap(), payload);
assert!(
extract(
&zip_path,
"furumi-linux-x86_64-0.1.6.zip",
&mut File::create(&output).unwrap()
)
.is_err()
);
let tar_path = temp.path().join("test.tar.gz");
let gzip = flate2::write::GzEncoder::new(
File::create(&tar_path).unwrap(),
flate2::Compression::default(),
);
let mut tar = tar::Builder::new(gzip);
let mut header = tar::Header::new_gnu();
header.set_size(payload.len() as u64);
header.set_mode(0o755);
header.set_cksum();
tar.append_data(&mut header, &expected, payload.as_slice())
.unwrap();
tar.into_inner().unwrap().finish().unwrap();
extract(
&tar_path,
"furumi-windows-x86_64-0.1.6.tar.gz",
&mut File::create(&output).unwrap(),
)
.unwrap();
assert_eq!(std::fs::read(&output).unwrap(), payload);
}
#[test]
fn binary_validation_rejects_wrong_architecture() {
let current = std::fs::read(std::env::current_exe().unwrap()).unwrap();
let mut other = current.clone();
// Change only the machine field, preserving an otherwise valid executable.
if current.starts_with(b"MZ") {
let pe = u32::from_le_bytes(current[0x3c..0x40].try_into().unwrap()) as usize;
let machine: u16 = if current[pe + 4..pe + 6] == [0x64, 0x86] {
0xaa64
} else {
0x8664
};
other[pe + 4..pe + 6].copy_from_slice(&machine.to_le_bytes());
} else if current.starts_with(b"\x7fELF") {
let machine: u16 = if current[18..20] == [62, 0] { 183 } else { 62 };
other[18..20].copy_from_slice(&machine.to_le_bytes());
} else if current.starts_with(&[0xcf, 0xfa, 0xed, 0xfe]) {
let cpu: u32 = if current[4] == 7 {
0x0100000c
} else {
0x01000007
};
other[4..8].copy_from_slice(&cpu.to_le_bytes());
} else {
panic!("unsupported test executable format");
}
assert!(validate_binary(&current, &other).is_err());
}
#[test]
fn checksums_require_exact_unique_valid_entry() {
let hash = "ab".repeat(32);
assert_eq!(
checksum(&format!("{hash} app.zip\n"), "app.zip").unwrap(),
hash
);
assert!(checksum(&format!("{hash} other.zip"), "app.zip").is_err());
assert!(checksum("broken app.zip", "app.zip").is_err());
assert!(checksum(&format!("{hash} app.zip\n{hash} app.zip"), "app.zip").is_err());
}
#[test]
fn release_selection_respects_semver_and_assets() {
let release = |version: &str| Release {
tag_name: version.into(),
draft: false,
prerelease: false,
assets: vec![
Asset {
name: "furumi-linux-x86_64-0.1.10.tar.gz".into(),
browser_download_url: String::new(),
},
Asset {
name: "SHA256SUMS".into(),
browser_download_url: String::new(),
},
],
};
assert!(
select_release(release("v0.1.10"), "0.1.9", "linux", "x86_64")
.unwrap()
.is_some()
);
assert!(
select_release(release("v0.1.8"), "0.1.9", "linux", "x86_64")
.unwrap()
.is_none()
);
assert!(
select_release(release("v0.2.0-beta.1"), "0.1.9", "linux", "x86_64")
.unwrap()
.is_none()
);
assert!(select_release(release("v0.1.10"), "0.1.9", "linux", "aarch64").is_err());
}
#[test]
fn binary_validation_rejects_corrupt_download() {
let current = std::fs::read(std::env::current_exe().unwrap()).unwrap();
validate_binary(&current, &current).unwrap();
assert!(validate_binary(&current, b"not a binary").is_err());
}
}