Added self updater

This commit is contained in:
ab
2026-09-07 12:36:48 +03:00
parent 2607314da3
commit 32c976beb8
12 changed files with 752 additions and 6 deletions
+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>),
+63
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);
@@ -3098,6 +3130,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 {
+8 -1
View File
@@ -926,6 +926,8 @@ impl FedRow {
/// the config directory.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingsRow {
CheckUpdate,
InstallUpdate,
MusicDirectory,
Similarity(SimilarityRow),
Federation(FedRow),
@@ -1065,7 +1067,11 @@ pub fn device_status_order(state: &AppState) -> Vec<usize> {
}
pub fn settings_rows(state: &AppState) -> Vec<SettingsRow> {
let mut rows = vec![SettingsRow::MusicDirectory];
let mut rows = vec![
SettingsRow::MusicDirectory,
SettingsRow::CheckUpdate,
SettingsRow::InstallUpdate,
];
rows.extend(SimilarityRow::ALL.into_iter().map(SettingsRow::Similarity));
rows.extend(FedRow::ALL.into_iter().map(SettingsRow::Federation));
rows.push(SettingsRow::DeviceName);
@@ -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,
+20
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,
@@ -2815,6 +2817,24 @@ fn fed_card_featured_artist_names(track: &crate::federation::FedCardTrack) -> Ve
fn federation_select(state: &mut AppState) -> Option<Effect> {
use super::state::{FedInputField, FedRow, Popup, SettingsRow, SimilarityRow};
match settings_rows(state).get(state.settings_cursor).copied()? {
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());
+21
View File
@@ -1,6 +1,27 @@
use super::*;
use crate::library::models::{ArtistCard, ArtistDetail, TrackItem};
#[test]
fn manual_update_check_is_single_flight_and_disabled_after_install() {
let mut state = AppState::default();
state.settings_cursor = settings_rows(&state)
.iter()
.position(|row| *row == crate::app::state::SettingsRow::CheckUpdate)
.unwrap();
assert_eq!(federation_select(&mut state), Some(Effect::CheckUpdate));
assert!(state.updater.busy);
assert_eq!(federation_select(&mut state), None);
state.updater.busy = false;
state.updater.installed = true;
assert_eq!(federation_select(&mut state), None);
state.updater.installed = false;
state.settings_cursor = settings_rows(&state)
.iter()
.position(|row| *row == crate::app::state::SettingsRow::InstallUpdate)
.unwrap();
assert_eq!(federation_select(&mut state), 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;
+82 -2
View File
@@ -4,7 +4,7 @@ use ratatui::Frame;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Paragraph};
use ratatui::widgets::{Block, Paragraph, Wrap};
use super::theme;
use crate::app::state::{AppState, DevicePresenceSection, FedRow, SimilarityRow, settings_rows};
@@ -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() + 15 + device_presence_sections(state).len()) as u16;
let [rows_area, _, status_area] = Layout::vertical([
Constraint::Length(rows_height.min(inner.height)),
Constraint::Length(1),
@@ -88,6 +88,48 @@ fn draw_settings_rows(frame: &mut Frame, area: Rect, state: &AppState) {
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Updates");
draw_row_enabled(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Check for updates",
format!("v{} | enter", env!("CARGO_PKG_VERSION")),
!state.updater.busy && !state.updater.installed,
);
cursor += 1;
draw_row_enabled(
frame,
area,
state,
&mut y,
cursor,
state.settings_cursor,
"Install update",
state
.updater
.available
.as_ref()
.map(|update| format!("v{} | enter", update.version))
.unwrap_or_else(|| "check for updates first".into()),
state.updater.available.is_some() && !state.updater.busy && !state.updater.installed,
);
cursor += 1;
if y < area.bottom() {
let height = 3.min(area.bottom() - y);
frame.render_widget(
Paragraph::new(state.updater.message.as_str())
.style(theme::dim())
.wrap(Wrap { trim: true }),
Rect::new(area.x, y, area.width, height),
);
y += height;
}
y = y.saturating_add(1);
draw_section(frame, area, state, &mut y, "Similarity Search");
let similarity = &state.similarity.settings;
for row in SimilarityRow::ALL {
@@ -1578,3 +1620,41 @@ 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();
for expected in [
"Music save directory",
"Check for updates",
"Install update",
"No newer stable release",
"Similarity Search",
"Federation",
] {
assert!(
text.contains(expected),
"missing {expected} at width {width}"
);
}
}
}
}
+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());
}
}