3 Commits
Author SHA1 Message Date
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
14 changed files with 1088 additions and 140 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
Generated
+87 -1
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"
@@ -1666,6 +1686,16 @@ dependencies = [
"percent-encoding",
]
[[package]]
name = "fs2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213"
dependencies = [
"libc",
"winapi",
]
[[package]]
name = "furumi-library"
version = "0.1.0"
@@ -1685,7 +1715,7 @@ dependencies = [
[[package]]
name = "furumi_tui"
version = "0.2.8"
version = "0.3.0"
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]]
@@ -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"
+12 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "furumi_tui"
version = "0.2.8"
version = "0.3.0"
edition = "2024"
rust-version = "1.97"
description = "A federated P2P player for personal music libraries"
@@ -11,6 +11,16 @@ 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"
@@ -24,7 +34,7 @@ lofty = "0.22"
# peers (same protocol as furumi-fd).
music-dht = "0.4.1"
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"
+22
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
+3
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>),
+68
View File
@@ -431,6 +431,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;
@@ -1415,6 +1422,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 +1967,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);
}
@@ -3098,6 +3135,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 {
+31 -15
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
}
@@ -1571,6 +1583,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 +1595,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)
+1
View File
@@ -12,6 +12,7 @@ mod similarity;
mod status;
mod streaming;
mod ui;
mod updater;
mod visualizer;
use std::io;
+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());
}
}