Files
qbz/crates/qbz-qt/src/about_qt.rs
T

454 lines
16 KiB
Rust

//! About QBZ modal controller — the Qt port of `crates/qbz/src/about.rs`.
//!
//! Publishes ONE JSON document (`QbzAbout.aboutJson`) holding everything the
//! modal renders that is not a translated literal: the version, the platform
//! label, the build stamp, the release URL, the codename, the author chip and
//! the row-grouped contributor chips. The only async part is the GitHub
//! avatars, which are downloaded to the user cache dir and handed to QML as
//! `file://` paths — this port has NO precedent for an `https://` URL in
//! `Image.source` (every image goes through a Rust download-to-disk cache), so
//! the avatars follow that route rather than inventing a second one.
//!
//! # Divergences from the Slint reference, and why
//!
//! - **Platform label reads `"Linux (Qt)"`** (`about.rs:34-49` reads
//! `"Linux (Slint)"`). The reference brands the FRONTEND on purpose — the
//! Tauri build said `"(Tauri 2.0)"` — and this string ends up in bug
//! reports, where knowing which frontend produced them matters.
//! - **The avatars are fetched on the FIRST OPEN, not at install.** The
//! reference dispatches twelve unauthenticated `github.com/<handle>.png`
//! requests at shell setup (`about.rs:126`), i.e. on every app start whether
//! or not anyone ever opens the modal. There is no `install()` seam on this
//! side to hang them from anyway, and deferring them costs nothing: the
//! first open paints the blank circles the reference paints too, then fills
//! them in as they land. Cached files make every later open instant.
//! - **`CONTRIBUTORS_PER_ROW` is 5** (`about.rs:76`). Both Slint comments that
//! say "4 per row" (`state.slint:6091`, `AboutModal.slint:434`) are stale;
//! the code is what ships. The rows are kept in the JSON even though QML has
//! `Flow` and does not need pre-grouping, so both frontends read ONE document
//! shape; `AboutModal.qml` flattens them.
//!
//! # The codename is a hand-set literal — a SECOND place to bump per release
//!
//! [`CODENAME`] mirrors `AboutModal.slint:371`. Neither is derived from
//! anything, so a release now updates two literals, not one. That is recorded
//! in the delivery doc; the alternative (a tiny shared crate both frontends
//! read) is a larger change and the owner's call.
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, Mutex};
use cxx_qt_lib::QString;
use serde::{Deserialize, Serialize};
use crate::about_bridge::ui;
/// The real, displayed app version. `qbz-qt` inherits `version.workspace`, the
/// same source `crates/qbz` reads, so this is the REAL release version.
pub fn app_version() -> &'static str {
env!("CARGO_PKG_VERSION")
}
/// Build date (`YYYY-MM-DD`) embedded by `build.rs`; empty if unavailable.
fn build_date() -> &'static str {
env!("QBZ_BUILD_DATE")
}
/// Short git commit embedded by `build.rs`; empty in offline source builds.
fn build_commit() -> &'static str {
env!("QBZ_BUILD_COMMIT")
}
/// The release codename shown in the build-info grid.
///
/// HAND-SET, exactly like `AboutModal.slint:371`. Bump it with the version.
pub const CODENAME: &str = "Rebuild Q (You Can (Not) Redo)";
/// Platform label for the build-info grid. This is the Qt port, so the label
/// reads "(Qt)" rather than the Slint build's "(Slint)".
fn platform_label() -> &'static str {
#[cfg(target_os = "macos")]
{
"macOS (Qt)"
}
#[cfg(target_os = "windows")]
{
"Windows (Qt)"
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
{
"Linux (Qt)"
}
}
/// The app author's GitHub handle (the single Author chip).
const AUTHOR_HANDLE: &str = "vicrodh";
/// The contributor handles. Copied VERBATIM from `about.rs:58-70`, order
/// included: first the Tauri About modal's order, then the Slint-era
/// external-PR contributors — `hoyon` (classical "work" grouping, PR #536),
/// `mxnix` (Russian translation, PR #517), `TerminalTilt`, plus contributors
/// whose implementation was superseded by Qt while their solution shipped.
/// Do not reorder existing entries.
const CONTRIBUTORS: &[&str] = &[
"vorce",
"boxdot",
"arminfelder",
"afonsojramos",
"GwendalBeaumont",
"AdamArstall",
"Vudgekek",
"DoubleGate",
"hoyon",
"mxnix",
"TerminalTilt",
"Alexandre-Menigault",
"MarkusAbtion",
"fengalin",
"luukvanderduim",
// 2.1.0 sweep (QoL round). Same house rule as above: credited because
// their solution or idea SHIPPED, merged PR or not — pbaart (Dutch
// translation, #561), b0bbywan (ALSA buffer sizing, #662), stshow (the
// DLNA strict-renderer handling qbz-cast carries, #541/#547), Ronjar
// (deb822 APT docs, #530), eldios (nix libjack2, #563), herder
// (hotkeys + Vim preset, #724), PhilipVinc (daemon event hooks, #700),
// Mazipani (Chromecast X.509 v1 fix, #730), RayneGit (arboard
// wayland-data-control, #727), LuckyTheCoder (the macOS Liquid Glass
// icon, issue #712 — an issue, not a PR, and credited all the same).
"pbaart",
"b0bbywan",
"stshow",
"Ronjar",
"eldios",
"herder",
"PhilipVinc",
"Mazipani",
"RayneGit",
"LuckyTheCoder",
];
/// Release-time snapshot from `data/about-sponsors.json`. GitHub contains the
/// cumulative public sponsor history (`includePrivate: false`,
/// `activeOnly: false`); ending a recurring sponsorship does not retract its
/// thank-you. Ko-fi contains the public display name from its "From" field —
/// never emails or payment data. Anonymous defaults are omitted; the modal
/// thanks them as a group. This file is deliberately local and embedded: no
/// sponsor service is contacted by the running app.
#[derive(Deserialize)]
struct SponsorCredits {
github: Vec<String>,
ko_fi: Vec<String>,
}
static SPONSOR_CREDITS: LazyLock<SponsorCredits> = LazyLock::new(|| {
serde_json::from_str(include_str!("../data/about-sponsors.json"))
.expect("bundled About sponsor data must be valid JSON")
});
/// How many contributor chips per wrap row (`about.rs:76`). Slint has no
/// flex-wrap and needs the pre-grouping; QML uses `Flow` and flattens these
/// again. The grouping stays in the document so both frontends read one shape.
const CONTRIBUTORS_PER_ROW: usize = 5;
static OPEN: AtomicBool = AtomicBool::new(false);
/// Guards the one-shot avatar dispatch (see the module header — first open,
/// not install).
static AVATARS_STARTED: AtomicBool = AtomicBool::new(false);
/// handle -> `file://` path of its cached avatar. Absent = blank circle.
static AVATARS: LazyLock<Mutex<HashMap<String, String>>> =
LazyLock::new(|| Mutex::new(HashMap::new()));
#[derive(Serialize)]
struct Chip {
name: String,
url: String,
/// `file://` path, or "" while it has not landed (QML draws the blank
/// circle, exactly like `AboutModal.slint:98-99`).
avatar: String,
}
#[derive(Serialize)]
struct Doc {
open: bool,
version: &'static str,
#[serde(rename = "platformLabel")]
platform_label: &'static str,
#[serde(rename = "buildDate")]
build_date: &'static str,
#[serde(rename = "buildCommit")]
build_commit: &'static str,
#[serde(rename = "releaseUrl")]
release_url: String,
codename: &'static str,
#[serde(rename = "authorName")]
author_name: &'static str,
#[serde(rename = "authorUrl")]
author_url: String,
#[serde(rename = "authorAvatar")]
author_avatar: String,
#[serde(rename = "contributorRows")]
contributor_rows: Vec<Vec<Chip>>,
/// ADDITIVE (QoL round): the Sponsors section — GitHub sponsors first
/// (clickable, `url` set), then the Ko-fi display names (`url` empty).
/// FLAT, not row-grouped: only QML renders it, and `Flow` wraps.
sponsors: Vec<Chip>,
}
fn profile_url(handle: &str) -> String {
format!("https://github.com/{handle}")
}
fn avatar_of(handle: &str) -> String {
AVATARS
.lock()
.unwrap_or_else(|e| e.into_inner())
.get(handle)
.cloned()
.unwrap_or_default()
}
fn snapshot_doc() -> Doc {
let version = app_version();
let sponsors = &*SPONSOR_CREDITS;
Doc {
open: OPEN.load(Ordering::SeqCst),
version,
platform_label: platform_label(),
build_date: build_date(),
build_commit: build_commit(),
release_url: format!("https://github.com/vicrodh/qbz/releases/tag/v{version}"),
codename: CODENAME,
author_name: AUTHOR_HANDLE,
author_url: profile_url(AUTHOR_HANDLE),
author_avatar: avatar_of(AUTHOR_HANDLE),
contributor_rows: CONTRIBUTORS
.chunks(CONTRIBUTORS_PER_ROW)
.map(|chunk| {
chunk
.iter()
.map(|handle| Chip {
name: (*handle).to_string(),
url: profile_url(handle),
avatar: avatar_of(handle),
})
.collect()
})
.collect(),
sponsors: sponsors
.github
.iter()
.map(|handle| Chip {
name: handle.clone(),
url: profile_url(handle),
avatar: String::new(),
})
.chain(sponsors.ko_fi.iter().map(|name| Chip {
name: name.clone(),
url: String::new(),
avatar: String::new(),
}))
.collect(),
}
}
pub fn publish() {
let json = serde_json::to_string(&snapshot_doc()).unwrap_or_else(|_| "{}".into());
ui(move |mut b| b.as_mut().set_about_json(QString::from(json.as_str())));
}
pub fn open() {
OPEN.store(true, Ordering::SeqCst);
publish();
start_avatar_loads();
}
pub fn close() {
OPEN.store(false, Ordering::SeqCst);
publish();
}
// ---------------------------------------------------------------------------
// Avatars
// ---------------------------------------------------------------------------
/// `<cache>/qbz/gh-avatars/` — beside the artwork cache, not inside it: the
/// artwork cache is a SQLite-keyed store for catalog art with its own eviction,
/// and twelve fixed 64px PNGs have nothing to do with it.
fn avatar_dir() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("qbz").join("gh-avatars"))
}
/// The GitHub avatar URL for a handle (64px PNG, matching the reference).
fn avatar_url(handle: &str) -> String {
format!("https://github.com/{handle}.png?size=64")
}
/// Resolve one avatar to a `file://` path, downloading it if the cache misses.
/// `None` on any network / decode / IO failure — the caller leaves the chip's
/// blank circle in place, no crash and no retry.
async fn ensure_avatar(client: &reqwest::Client, handle: &str) -> Option<String> {
let dir = avatar_dir()?;
let out = dir.join(format!("{handle}.png"));
// A zero-byte file is a failed write that would otherwise count as a hit
// forever (the artwork cache's documented trap) — treat it as a miss.
if std::fs::metadata(&out)
.map(|m| m.len() > 0)
.unwrap_or(false)
{
return Some(crate::artwork_qt::file_url(&out.to_string_lossy()));
}
std::fs::create_dir_all(&dir).ok()?;
let resp = client.get(avatar_url(handle)).send().await.ok()?;
if !resp.status().is_success() {
return None;
}
let bytes = resp.bytes().await.ok()?;
let img = image::load_from_memory(&bytes).ok()?.thumbnail(64, 64);
// ATOMIC: encode into a unique sibling, then rename — a half-written PNG
// under the final name is a permanently broken chip (artwork_qt.rs:594).
let tmp = dir.join(format!(".{handle}.{}.png", std::process::id()));
if img.save_with_format(&tmp, image::ImageFormat::Png).is_err()
|| std::fs::rename(&tmp, &out).is_err()
{
let _ = std::fs::remove_file(&tmp);
return None;
}
Some(crate::artwork_qt::file_url(&out.to_string_lossy()))
}
/// Fetch every avatar (author + contributors) once, off the UI thread, and
/// republish the document as each one lands.
fn start_avatar_loads() {
if AVATARS_STARTED.swap(true, Ordering::SeqCst) {
return;
}
let client = match reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(8))
// GitHub rejects requests without one.
.user_agent("qbz")
.build()
{
Ok(c) => c,
Err(e) => {
log::warn!("[qbz-qt] about avatar client build failed: {e}");
return;
}
};
for handle in std::iter::once(&AUTHOR_HANDLE).chain(CONTRIBUTORS.iter()) {
let client = client.clone();
let handle = handle.to_string();
crate::spawn(async move {
if let Some(path) = ensure_avatar(&client, &handle).await {
// The guard is scoped EXPLICITLY: `publish()` re-locks
// AVATARS through `avatar_of`, and std's Mutex is not
// re-entrant — holding it across the call would hard-hang the
// download task on itself.
{
AVATARS
.lock()
.unwrap_or_else(|e| e.into_inner())
.insert(handle, path);
}
publish();
}
});
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn contributor_rows_are_chunks_of_five_in_source_order() {
let doc = snapshot_doc();
assert_eq!(
doc.contributor_rows.len(),
CONTRIBUTORS.len().div_ceil(CONTRIBUTORS_PER_ROW)
);
assert!(doc
.contributor_rows
.iter()
.all(|row| !row.is_empty() && row.len() <= CONTRIBUTORS_PER_ROW));
let names: Vec<&str> = doc
.contributor_rows
.iter()
.flatten()
.map(|chip| chip.name.as_str())
.collect();
assert_eq!(names, CONTRIBUTORS);
let fengalin = doc
.contributor_rows
.iter()
.flatten()
.find(|chip| chip.name == "fengalin")
.unwrap();
assert_eq!(fengalin.url, "https://github.com/fengalin");
}
#[test]
fn sponsors_are_github_then_kofi_and_only_github_links() {
let doc = snapshot_doc();
let sponsors = &*SPONSOR_CREDITS;
assert_eq!(
doc.sponsors.len(),
sponsors.github.len() + sponsors.ko_fi.len()
);
for (chip, handle) in doc.sponsors.iter().zip(&sponsors.github) {
assert_eq!(chip.name, *handle);
assert_eq!(chip.url, profile_url(handle));
}
assert!(doc.sponsors[sponsors.github.len()..]
.iter()
.all(|chip| chip.url.is_empty()));
}
#[test]
fn bundled_sponsor_data_is_public_display_data_without_duplicates() {
use std::collections::HashSet;
let sponsors = &*SPONSOR_CREDITS;
assert!(!sponsors.github.is_empty());
assert!(!sponsors.ko_fi.is_empty());
assert_eq!(
sponsors.github.iter().collect::<HashSet<_>>().len(),
sponsors.github.len()
);
assert_eq!(
sponsors.ko_fi.iter().collect::<HashSet<_>>().len(),
sponsors.ko_fi.len()
);
assert!(sponsors.github.iter().all(|name| {
!name.is_empty()
&& name.len() <= 39
&& name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}));
assert!(sponsors.ko_fi.iter().all(|name| {
!name.trim().is_empty()
&& name.chars().count() <= 80
&& !name.chars().any(char::is_control)
}));
}
#[test]
fn release_url_is_the_v_prefixed_tag_for_the_running_version() {
let doc = snapshot_doc();
assert_eq!(
doc.release_url,
format!(
"https://github.com/vicrodh/qbz/releases/tag/v{}",
app_version()
)
);
}
#[test]
fn platform_label_brands_the_frontend() {
assert!(platform_label().ends_with("(Qt)"), "{}", platform_label());
}
}