From 35822e8f99cbb64445b803f52c2be8db354b9103 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:22:58 +0000 Subject: [PATCH 01/24] Run the service well from an AppImage Issue #41 asks for AppImages. An AppImage installs nothing, so what an installed Weir finds in fixed places has to name the AppImage file: - weir_protocol::appimage::appimage() reads APPIMAGE and APPDIR, which the AppImage's starter sets, and trusts them only when Weir's own file is inside APPDIR: a terminal opened from another AppImage passes them on too. - Start at login: login::AppImageLogin writes its own ~/.config/systemd/user/weir.service from the installed unit's template, with ExecStart naming the AppImage (quoted for systemd: % and $ doubled, quotes and backslashes escaped) and a first line marking it as Weir's. Weir only ever changes or removes a unit with that line. At each start it points the unit at the AppImage's current path, so moving or renaming the AppImage, or replacing it with a newer one under another name, keeps working. - Restart starts the AppImage file rather than the mounted copy, so a newer AppImage put in its place is what runs. The AppImage's starter keeps the image mounted while any process holds a pipe it hands down, so restart first marks every inherited file to close, letting go of the old mount. - diagnostics::Install::AppImage: bug reports say "AppImage, PATH", and the update notifier says to put the new AppImage in place of this one, or let Gear Lever or AppImageUpdate update it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/controller/mod.rs | 2 +- crates/daemon/src/diagnostics.rs | 33 ++++- crates/daemon/src/login.rs | 202 ++++++++++++++++++++++++++++ crates/daemon/src/main.rs | 43 +++++- crates/protocol/src/appimage.rs | 79 +++++++++++ crates/protocol/src/lib.rs | 1 + 6 files changed, 354 insertions(+), 6 deletions(-) create mode 100644 crates/protocol/src/appimage.rs diff --git a/crates/daemon/src/controller/mod.rs b/crates/daemon/src/controller/mod.rs index ec713bb..8fe9eff 100644 --- a/crates/daemon/src/controller/mod.rs +++ b/crates/daemon/src/controller/mod.rs @@ -273,7 +273,7 @@ impl Controller { window_clients: AtomicUsize::new(0), window_tx: Mutex::new(None), spectrum_watchers: Mutex::new(BTreeMap::new()), - login: Box::new(login::Systemd), + login: login::here(weir_protocol::appimage::appimage().as_ref()), hotkey_runner: Mutex::new(None), shortcut_settings: tokio::sync::Notify::new(), popups: Mutex::new(None), diff --git a/crates/daemon/src/diagnostics.rs b/crates/daemon/src/diagnostics.rs index b2a4774..3248893 100644 --- a/crates/daemon/src/diagnostics.rs +++ b/crates/daemon/src/diagnostics.rs @@ -127,6 +127,8 @@ pub enum Install { Make(PathBuf), /// Run from where it was built. BuildFolder(PathBuf), + /// From an AppImage: the AppImage file. + AppImage(PathBuf), /// Somewhere else, or nobody knows. Elsewhere(Option), } @@ -136,9 +138,14 @@ impl Install { /// moment, so it is asked once. pub fn here() -> Install { static HERE: OnceLock = OnceLock::new(); - HERE.get_or_init(|| match weir_protocol::own_exe() { - Ok(exe) => Install::of(&exe, dirs::home_dir().as_deref(), package), - Err(_) => Install::Elsewhere(None), + HERE.get_or_init(|| { + if let Some(a) = weir_protocol::appimage::appimage() { + return Install::AppImage(a.file); + } + match weir_protocol::own_exe() { + Ok(exe) => Install::of(&exe, dirs::home_dir().as_deref(), package), + Err(_) => Install::Elsewhere(None), + } }) .clone() } @@ -171,6 +178,7 @@ impl Install { Install::Unpackaged(exe) => format!("{}, in no package", exe.display()), Install::Make(exe) => format!("make install, {}", exe.display()), Install::BuildFolder(exe) => format!("a build folder, {}", exe.display()), + Install::AppImage(file) => format!("AppImage, {}", file.display()), Install::Elsewhere(Some(exe)) => exe.display().to_string(), Install::Elsewhere(None) => "unknown".into(), } @@ -200,6 +208,14 @@ impl Install { release's source download) and run make install again" } Install::BuildFolder(_) => "Get the new source and build it again", + Install::AppImage(file) => { + return format!( + "Download the new AppImage for your computer from the release page and put \ + it in place of this one ({}), or let Gear Lever or AppImageUpdate update \ + it. Then choose Restart Weir in Weir's tray menu", + file.display() + ); + } Install::Unpackaged(_) | Install::Elsewhere(_) => "Download it from the release page", }; // Installing replaces the files, but the old version keeps running @@ -362,6 +378,17 @@ mod tests { at("/home/sam/src/Weir/target/release/weir-daemon", &copr).describe(), "a build folder, /home/sam/src/Weir/target/release/weir-daemon" ); + let appimage = Install::AppImage("/home/sam/Apps/Weir-1.5.0-x86_64.AppImage".into()); + assert_eq!( + appimage.describe(), + "AppImage, /home/sam/Apps/Weir-1.5.0-x86_64.AppImage" + ); + let how = appimage.how_to_update(); + assert!(how.contains("in place of this one (/home/sam/Apps/Weir-1.5.0-x86_64.AppImage)")); + assert!( + how.ends_with("Then choose Restart Weir in Weir's tray menu"), + "{how}" + ); } #[test] diff --git a/crates/daemon/src/login.rs b/crates/daemon/src/login.rs index 9db7c6f..add71a4 100644 --- a/crates/daemon/src/login.rs +++ b/crates/daemon/src/login.rs @@ -6,8 +6,13 @@ //! remembering an answer that a terminal command could make wrong. It //! enables the unit without `--now`: the daemon asking is already running, //! and a second one started by systemd would find its socket taken. +//! +//! An AppImage installs nothing, so [`AppImageLogin`] writes the unit +//! itself, naming the AppImage file, and keeps it naming the right one. +use std::path::{Path, PathBuf}; use std::process::Command; +use weir_protocol::appimage::AppImage; /// The unit `make install` and the RPM install. const UNIT: &str = "weir.service"; @@ -52,6 +57,175 @@ impl LoginStart for Systemd { } } +/// Where Weir is started at login, for this copy of it: a unit of its own +/// when it runs from an AppImage, the installed one otherwise. +pub fn here(appimage: Option<&AppImage>) -> Box { + match appimage.and_then(|a| AppImageLogin::new(a.file.clone())) { + Some(own) => { + own.follow(); + Box::new(own) + } + None => Box::new(Systemd), + } +} + +/// The first line of a unit Weir wrote for an AppImage. Weir only ever +/// changes or removes a unit that starts with it. +const MARK: &str = "# Written by Weir to start its AppImage at login."; + +/// The installed unit, the model for the one an AppImage writes. +const TEMPLATE: &str = include_str!("../../../packaging/weir.service.in"); + +/// Starting at login from an AppImage: `~/.config/systemd/user/weir.service`, +/// written by Weir, starting the AppImage file. It takes the place of an +/// installed unit, if there is one too. +pub struct AppImageLogin { + /// The AppImage file. + file: PathBuf, + /// Where the unit goes. + unit: PathBuf, +} + +impl AppImageLogin { + /// For the AppImage `file`, or `None` without a configuration folder. + pub fn new(file: PathBuf) -> Option { + let unit = dirs::config_dir()?.join("systemd/user").join(UNIT); + Some(Self { file, unit }) + } + + /// The unit there now, if Weir wrote it. + fn ours(&self) -> Option { + std::fs::read_to_string(&self.unit) + .ok() + .filter(|text| text.starts_with(MARK)) + } + + /// Point the unit Weir wrote at this AppImage, if it names another + /// file: the AppImage was moved, or a newer one with another name + /// replaced it. Run at startup; a unit Weir did not write is left + /// alone. + pub fn follow(&self) { + let Some(text) = self.ours() else { + return; + }; + match unit_for(&self.file) { + Ok(wanted) if wanted != text => match write_unit(&self.unit, &wanted) { + Ok(()) => tracing::info!("start at login now starts {}", self.file.display()), + Err(e) => tracing::warn!("{e}"), + }, + Ok(_) => {} + Err(e) => tracing::warn!("{e}"), + } + } +} + +impl LoginStart for AppImageLogin { + fn get(&self) -> Option { + // Without a unit of its own the box can still be ticked: ticking + // writes one. An installed unit that is enabled answers yes. + match Systemd.get() { + Some(on) => Some(on), + None if self.ours().is_none() => Some(false), + None => None, + } + } + + fn set(&self, on: bool) -> Result<(), String> { + if on { + let ours_or_none = self.ours().is_some() || !self.unit.exists(); + if ours_or_none { + write_unit(&self.unit, &unit_for(&self.file)?)?; + } + Systemd.set(true) + } else { + Systemd.set(false)?; + if self.ours().is_some() { + std::fs::remove_file(&self.unit) + .map_err(|e| format!("could not remove {}: {e}", self.unit.display()))?; + daemon_reload(); + } + Ok(()) + } + } +} + +/// The unit that starts the AppImage `file`, from the installed one's +/// sections. +fn unit_for(file: &Path) -> Result { + let exec = systemd_quote(file).ok_or_else(|| { + format!( + "{} has a line break in its name, which systemd cannot start; \ + rename the AppImage", + file.display() + ) + })?; + let body = TEMPLATE + .find("[Unit]") + .map_or(TEMPLATE, |at| &TEMPLATE[at..]); + let mut out = format!( + "{MARK}\n# It starts {}; Weir rewrites this line if the AppImage moves.\n\n", + file.display() + ); + for line in body.lines() { + if line.starts_with("ExecStart=") { + out.push_str(&format!("ExecStart={exec} weir-daemon\n")); + } else { + out.push_str(line); + out.push('\n'); + } + } + Ok(out) +} + +/// `path` as one word on a systemd command line: in double quotes, with +/// backslashes and quotes escaped, and `%` and `$` doubled so systemd +/// takes them as themselves. `None` for a path with a line break. +fn systemd_quote(path: &Path) -> Option { + let text = path.to_string_lossy(); + if text.contains(['\n', '\r']) { + return None; + } + let mut out = String::from("\""); + for c in text.chars() { + match c { + '\\' => out.push_str("\\\\"), + '"' => out.push_str("\\\""), + '%' => out.push_str("%%"), + '$' => out.push_str("$$"), + c => out.push(c), + } + } + out.push('"'); + Some(out) +} + +/// Write the unit and tell systemd it changed. +fn write_unit(unit: &Path, text: &str) -> Result<(), String> { + if let Some(dir) = unit.parent() { + std::fs::create_dir_all(dir) + .map_err(|e| format!("could not make {}: {e}", dir.display()))?; + } + std::fs::write(unit, text).map_err(|e| format!("could not write {}: {e}", unit.display()))?; + daemon_reload(); + Ok(()) +} + +/// Have systemd read its units again. A failure only delays it: systemd +/// also reads them at the next login. +fn daemon_reload() { + match Command::new("systemctl") + .args(["--user", "daemon-reload"]) + .output() + { + Ok(out) if out.status.success() => {} + Ok(out) => tracing::warn!( + "systemctl --user daemon-reload: {}", + String::from_utf8_lossy(&out.stderr).trim() + ), + Err(e) => tracing::warn!("could not run systemctl: {e}"), + } +} + /// What `systemctl is-enabled` printed, as an answer. Only a unit that can /// be switched either way counts: a missing unit prints nothing or /// `not-found`, and a masked one needs unmasking first, which is the @@ -78,4 +252,32 @@ mod tests { assert_eq!(parse_is_enabled("static\n"), None); assert_eq!(parse_is_enabled(""), None, "older systemd prints nothing"); } + + #[test] + fn an_appimage_path_is_one_word_to_systemd() { + assert_eq!( + systemd_quote(Path::new("/home/me/Apps/Weir.AppImage")).as_deref(), + Some(r#""/home/me/Apps/Weir.AppImage""#) + ); + assert_eq!( + systemd_quote(Path::new(r#"/home/me/My "Apps"/100% $HOME\Weir.AppImage"#)).as_deref(), + Some(r#""/home/me/My \"Apps\"/100%% $$HOME\\Weir.AppImage""#) + ); + assert_eq!(systemd_quote(Path::new("/home/me/a\nb.AppImage")), None); + } + + #[test] + fn the_appimage_unit_starts_the_appimage_and_says_who_wrote_it() { + let unit = unit_for(Path::new("/home/me/Apps/Weir-1.5.0-x86_64.AppImage")).unwrap(); + assert!(unit.starts_with(MARK)); + assert!( + unit.contains("\nExecStart=\"/home/me/Apps/Weir-1.5.0-x86_64.AppImage\" weir-daemon\n") + ); + assert!(!unit.contains("@BINDIR@"), "{unit}"); + assert!( + unit.contains("[Install]"), + "the template's sections are kept" + ); + assert_eq!(unit.matches("ExecStart=").count(), 1); + } } diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index e348d63..a41fd2d 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -631,11 +631,25 @@ async fn shut_down( /// one was started with `--safe`. Returns only if that fails. fn restart(window: bool, safe: bool) -> Result<()> { use std::os::unix::process::CommandExt; - let exe = weir_protocol::own_exe().context("finding our own binary to restart")?; let rest: Vec = std::env::args().skip(1).filter(|a| a != "--safe").collect(); + // From an AppImage, start the AppImage file again rather than the copy + // mounted from it, so that a newer AppImage put in its place is what + // runs, and let go of the old mount. + let appimage = weir_protocol::appimage::appimage().filter(|a| a.file.is_file()); + let (exe, mut args) = match appimage { + Some(a) => { + let_go_of_the_mount(); + (a.file, vec!["weir-daemon".to_string()]) + } + None => ( + weir_protocol::own_exe().context("finding our own binary to restart")?, + Vec::new(), + ), + }; + args.extend(rest); let mut command = std::process::Command::new(&exe); // A crash on purpose is for the run it was asked of. - command.args(&rest).env_remove(CRASH_TEST_ENV); + command.args(&args).env_remove(CRASH_TEST_ENV); for (env, on) in [(OPEN_WINDOW_ENV, window), (SAFE_MODE_ENV, safe)] { if on { command.env(env, "1"); @@ -647,6 +661,31 @@ fn restart(window: bool, safe: bool) -> Result<()> { anyhow::bail!("could not restart {}: {error}", exe.display()); } +/// Close, in the program this one becomes, every file it inherited past +/// standard input, output and error. An AppImage stays mounted while any +/// process holds the pipe its starter hands down; a restart that kept it +/// would keep the old version mounted beside the new one until Weir quits. +/// Weir's own files are already marked to close. +fn let_go_of_the_mount() { + let Ok(fds) = std::fs::read_dir("/proc/self/fd") else { + return; + }; + let fds: Vec = fds + .filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()) + .filter(|fd| *fd > 2) + .collect(); + for fd in fds { + // SAFETY: setting a flag on a descriptor number; one already + // closed (such as the one read_dir used) just fails. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC); + } + } + } +} + /// Open the window as the daemon starts, once there is a desktop to show it /// on: at login the daemon comes up before the desktop, and a window /// started then would find no screen and quit. By then the desktop may have diff --git a/crates/protocol/src/appimage.rs b/crates/protocol/src/appimage.rs new file mode 100644 index 0000000..7c47ec9 --- /dev/null +++ b/crates/protocol/src/appimage.rs @@ -0,0 +1,79 @@ +//! Running from an AppImage: one file holding Weir's programs, which an +//! AppImage's own starter mounts and runs. Nothing is installed, so the +//! things an installed Weir finds in fixed places (its login service, its +//! menu entry, the way to update it) have to name the AppImage file +//! instead, and the service and the window both need to know it. + +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// The AppImage this program runs from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppImage { + /// The AppImage file itself, such as `~/Applications/Weir-1.5.0-x86_64.AppImage`. + pub file: PathBuf, + /// Where its contents are mounted while it runs. + pub dir: PathBuf, +} + +/// The AppImage this program runs from, if it does. +/// +/// The AppImage's starter says so in `APPIMAGE` and `APPDIR`, but every +/// program started from it inherits them, including a terminal opened from +/// some other AppImage. So they count only when this program's own file is +/// inside `APPDIR`. +pub fn appimage() -> Option { + let exe = crate::own_exe().ok()?; + appimage_from( + std::env::var_os("APPIMAGE"), + std::env::var_os("APPDIR"), + &exe, + ) +} + +fn appimage_from(file: Option, dir: Option, exe: &Path) -> Option { + let file = PathBuf::from(file.filter(|f| !f.is_empty())?); + let dir = PathBuf::from(dir.filter(|d| !d.is_empty())?); + (file.is_absolute() && dir.is_absolute() && exe.starts_with(&dir)) + .then_some(AppImage { file, dir }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn os(s: &str) -> Option { + Some(OsString::from(s)) + } + + #[test] + fn only_a_program_inside_the_mount_runs_from_the_appimage() { + let exe = Path::new("/tmp/.mount_WeirAb12/usr/bin/weir-daemon"); + assert_eq!( + appimage_from( + os("/home/me/Apps/Weir-x86_64.AppImage"), + os("/tmp/.mount_WeirAb12"), + exe + ), + Some(AppImage { + file: "/home/me/Apps/Weir-x86_64.AppImage".into(), + dir: "/tmp/.mount_WeirAb12".into(), + }) + ); + // Started from a terminal that another AppImage opened. + assert_eq!( + appimage_from( + os("/home/me/Apps/Editor.AppImage"), + os("/tmp/.mount_EditXy"), + Path::new("/usr/bin/weir-daemon") + ), + None + ); + assert_eq!(appimage_from(None, os("/tmp/.mount_WeirAb12"), exe), None); + assert_eq!( + appimage_from(os("Weir.AppImage"), os("/tmp/.mount_WeirAb12"), exe), + None, + "a relative path is no use once the folder changes" + ); + } +} diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 7201a25..8b3c13f 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -29,6 +29,7 @@ #![warn(missing_docs)] +pub mod appimage; pub mod crash; pub mod fx; pub mod hotkeys; From 4be51b03df3b77b38e6adaf34db6653d3241da6f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:22:58 +0000 Subject: [PATCH 02/24] Offer to add an AppImage to the application menu Running from an AppImage, Weir is not in the application menu, and off Plasma the desktop portal's hotkeys need its menu entry too. The window asks once, under the top bar ("Add Weir to your application menu?"), unless the menu already has Weir from an installed copy. Adding writes ~/.local/share/applications/weir.desktop from the installed entry's template, with Exec naming the AppImage (quoted as the desktop entry specification asks, % doubled, backslashes doubled again for the string value), TryExec so a deleted AppImage hides it, and X-Weir-AppImage to mark it as Weir's; and the two icons. At each start the window points that entry at the AppImage's current path. Preferences > General gets an AppImage box: where Weir runs from, Add or Remove the menu entry (removing takes the icons too when nothing else in the menu needs them), and the command to link weirctl to the AppImage, with a Copy button. About's Licenses... opens the AppImage's own licenses folder. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/gui/src/app/about.rs | 7 + crates/gui/src/app/appimage.rs | 330 +++++++++++++++++++++++++++++++++ crates/gui/src/app/mod.rs | 16 ++ crates/gui/src/app/settings.rs | 3 + crates/gui/src/prefs.rs | 4 + 5 files changed, 360 insertions(+) create mode 100644 crates/gui/src/app/appimage.rs diff --git a/crates/gui/src/app/about.rs b/crates/gui/src/app/about.rs index 12e82f1..72916de 100644 --- a/crates/gui/src/app/about.rs +++ b/crates/gui/src/app/about.rs @@ -244,6 +244,13 @@ fn open_licenses() -> bool { /// `make install` in the home folder. The one beside this program first, /// should both be there. fn licenses_folder() -> Option { + // An AppImage carries its own, readable while it runs. + if let Some(a) = weir_protocol::appimage::appimage() { + let inside = a.dir.join("usr/share/licenses/weir"); + if inside.is_dir() { + return Some(inside); + } + } let system = PathBuf::from("/usr/share/licenses/weir"); let own = dirs::data_dir().map(|d| d.join("licenses").join("weir")); let packaged = weir_protocol::own_exe().is_ok_and(|e| e.starts_with("/usr")); diff --git a/crates/gui/src/app/appimage.rs b/crates/gui/src/app/appimage.rs new file mode 100644 index 0000000..5dab992 --- /dev/null +++ b/crates/gui/src/app/appimage.rs @@ -0,0 +1,330 @@ +//! Running from an AppImage. Nothing is installed, so Weir is not in the +//! application menu until something puts it there. The window offers once +//! to add it, and Preferences can add or remove it later. The entry is +//! also what the desktop's hotkeys service needs to know Weir by, off +//! Plasma. The entry names the AppImage file, and the window keeps it +//! naming the right one when the AppImage moves or a newer one replaces it. + +use super::App; +use crate::theme; +use egui::{Frame, RichText, Ui}; +use std::path::{Path, PathBuf}; +use weir_protocol::appimage::AppImage; + +/// The line that marks a menu entry as the one Weir wrote for an AppImage. +/// Weir only ever changes or removes an entry that has it. +const MARK: &str = "X-Weir-AppImage=true"; + +/// The installed entry, the model for this one. +const TEMPLATE: &str = include_str!("../../../../packaging/weir.desktop.in"); +const ICON: &[u8] = include_bytes!("../../../../packaging/weir.svg"); +const SYMBOLIC_ICON: &[u8] = include_bytes!("../../../../packaging/weir-symbolic.svg"); + +/// Whether the application menu has Weir. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum Menu { + /// It does not. + Missing, + /// It has the entry Weir wrote for an AppImage. + Ours, + /// It has another: an installed Weir's, or one made by hand or by an + /// AppImage manager. Weir leaves that alone. + Other, +} + +/// Where the entry and its icons go, under `~/.local/share`. +struct Places { + entry: PathBuf, + icon: PathBuf, + symbolic_icon: PathBuf, +} + +impl Places { + fn here() -> Option { + let data = dirs::data_dir()?; + let icons = data.join("icons/hicolor"); + Some(Places { + entry: data.join("applications/weir.desktop"), + icon: icons.join("scalable/apps/weir.svg"), + symbolic_icon: icons.join("symbolic/apps/weir-symbolic.svg"), + }) + } +} + +/// Every folder the desktop reads menu entries from, the person's own +/// first. +fn data_dirs() -> Vec { + let mut dirs: Vec = dirs::data_dir().into_iter().collect(); + let system = std::env::var("XDG_DATA_DIRS") + .ok() + .filter(|d| !d.is_empty()) + .unwrap_or_else(|| "/usr/local/share:/usr/share".into()); + dirs.extend( + system + .split(':') + .filter(|d| !d.is_empty()) + .map(PathBuf::from), + ); + dirs +} + +/// Whether the application menu has Weir now. +pub(super) fn menu_state() -> Menu { + let own = Places::here().map(|p| p.entry); + for dir in data_dirs() { + let entry = dir.join("applications/weir.desktop"); + let Ok(text) = std::fs::read_to_string(&entry) else { + continue; + }; + let ours = Some(&entry) == own.as_ref() && text.lines().any(|l| l.trim() == MARK); + return if ours { Menu::Ours } else { Menu::Other }; + } + Menu::Missing +} + +/// The menu entry that starts the AppImage `file`, or `None` for a file +/// name with a line break. +fn entry_for(file: &Path) -> Option { + let exec = exec_quote(file)?; + let try_exec = string_value(file)?; + let mut out = String::new(); + for line in TEMPLATE.lines() { + if line.starts_with("Exec=") { + out.push_str(&format!("Exec={exec}\n")); + // Hides the entry if the AppImage is deleted. + out.push_str(&format!("TryExec={try_exec}\n")); + } else { + out.push_str(line); + out.push('\n'); + } + } + out.push_str(MARK); + out.push('\n'); + Some(out) +} + +/// `path` as one word on a menu entry's `Exec` line: in double quotes, +/// with the characters the specification reserves inside them escaped, +/// `%` doubled, and then written as a string value, which doubles each +/// backslash again. +fn exec_quote(path: &Path) -> Option { + let text = path.to_str().filter(|t| !t.contains(['\n', '\r']))?; + let mut inner = String::from("\""); + for c in text.chars() { + match c { + '"' | '`' | '$' | '\\' => { + inner.push('\\'); + inner.push(c); + } + '%' => inner.push_str("%%"), + c => inner.push(c), + } + } + inner.push('"'); + Some(inner.replace('\\', "\\\\")) +} + +/// `path` as a plain string value in a menu entry. +fn string_value(path: &Path) -> Option { + let text = path.to_str().filter(|t| !t.contains(['\n', '\r']))?; + Some(text.replace('\\', "\\\\")) +} + +/// Put Weir in the application menu, starting the AppImage `file`. +fn add(file: &Path) -> Result<(), String> { + let places = Places::here().ok_or("there is no folder for menu entries")?; + let entry = entry_for(file).ok_or_else(|| { + format!( + "{} has a line break in its name; rename the AppImage first", + file.display() + ) + })?; + for (path, bytes) in [ + (&places.icon, ICON), + (&places.symbolic_icon, SYMBOLIC_ICON), + (&places.entry, entry.as_bytes()), + ] { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir) + .map_err(|e| format!("could not make {}: {e}", dir.display()))?; + } + std::fs::write(path, bytes) + .map_err(|e| format!("could not write {}: {e}", path.display()))?; + } + Ok(()) +} + +/// Take Weir out of the application menu: the entry Weir wrote, and its +/// icons when they are Weir's own and nothing else in the menu needs them. +fn remove() -> Result<(), String> { + let places = Places::here().ok_or("there is no folder for menu entries")?; + if menu_state() != Menu::Ours { + return Ok(()); + } + std::fs::remove_file(&places.entry) + .map_err(|e| format!("could not remove {}: {e}", places.entry.display()))?; + if menu_state() == Menu::Missing { + for (path, bytes) in [(&places.icon, ICON), (&places.symbolic_icon, SYMBOLIC_ICON)] { + if std::fs::read(path).is_ok_and(|b| b == bytes) { + let _ = std::fs::remove_file(path); + } + } + } + Ok(()) +} + +/// Point the entry Weir wrote at the AppImage `file`, if it names another: +/// the AppImage was moved, or a newer one with another name replaced it. +pub(super) fn follow(file: &Path) { + let Some(places) = Places::here() else { + return; + }; + if menu_state() != Menu::Ours { + return; + } + let current = std::fs::read_to_string(&places.entry).ok(); + if entry_for(file).is_some_and(|wanted| current.as_deref() != Some(wanted.as_str())) { + match add(file) { + Ok(()) => tracing::info!("the menu entry now starts {}", file.display()), + Err(e) => tracing::warn!("{e}"), + } + } +} + +impl App { + /// The question asked once, under the top bar, when Weir runs from an + /// AppImage that is not in the application menu. + pub(super) fn appimage_question(&mut self, root: &mut egui::Ui) { + let Some(appimage) = self.appimage.clone() else { + return; + }; + if self.menu != Menu::Missing || self.prefs.appimage_menu_asked { + return; + } + egui::Panel::top("appimage_question") + .frame( + Frame::new() + .fill(theme::p().section_fill) + .inner_margin(egui::Margin::symmetric(10, 6)), + ) + .show(root, |ui| { + ui.horizontal_wrapped(|ui| { + ui.label("Add Weir to your application menu?"); + ui.label( + RichText::new( + "It runs from an AppImage, so it isn't there yet. On desktops \ + other than Plasma, hotkeys need it too.", + ) + .color(theme::p().text_dim), + ); + if ui.button("Add to the menu").clicked() { + self.add_to_menu(&appimage); + self.answer_menu_question(); + } + if ui.button("No thanks").clicked() { + self.answer_menu_question(); + } + }); + }); + } + + fn answer_menu_question(&mut self) { + self.prefs.appimage_menu_asked = true; + self.prefs.save(); + } + + fn add_to_menu(&mut self, appimage: &AppImage) { + match add(&appimage.file) { + Ok(()) => self.show_toast("Weir is in your application menu", false), + Err(e) => self.show_toast(&format!("Weir could not be added to the menu: {e}"), false), + } + self.menu = menu_state(); + } + + /// Preferences' AppImage box: which file Weir runs from, the menu entry, + /// and weirctl. + pub(super) fn appimage_prefs(&mut self, ui: &mut Ui) { + let Some(appimage) = self.appimage.clone() else { + return; + }; + ui.label(format!("Weir runs from {}.", appimage.file.display())); + ui.add_space(4.0); + match self.menu { + Menu::Ours => { + ui.horizontal_wrapped(|ui| { + ui.label("It's in your application menu."); + if ui.button("Remove from the menu").clicked() { + match remove() { + Ok(()) => { + self.show_toast("Weir is out of your application menu", false) + } + Err(e) => self.show_toast(&format!("Could not remove it: {e}"), false), + } + self.menu = menu_state(); + } + }); + } + Menu::Missing => { + if ui.button("Add to the application menu").clicked() { + self.add_to_menu(&appimage); + } + } + Menu::Other => { + ui.label( + RichText::new( + "Your application menu already has Weir, from another copy, so Weir \ + leaves it as it is.", + ) + .color(theme::p().text_dim), + ); + } + } + ui.add_space(6.0); + ui.label("To use weirctl in a terminal, link it to the AppImage once:"); + let link = format!( + "mkdir -p ~/.local/bin && ln -sf '{}' ~/.local/bin/weirctl", + appimage.file.display().to_string().replace('\'', r"'\''") + ); + ui.horizontal_wrapped(|ui| { + ui.label(RichText::new(&link).monospace()); + if ui.small_button("Copy").clicked() { + ui.ctx().copy_text(link.clone()); + } + }); + ui.label( + RichText::new("If you move the AppImage, run it again and make the link again.") + .color(theme::p().text_dim), + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_appimage_path_is_one_word_on_the_exec_line() { + assert_eq!( + exec_quote(Path::new("/home/me/Apps/Weir.AppImage")).as_deref(), + Some(r#""/home/me/Apps/Weir.AppImage""#) + ); + // Inside the quotes: \" \` \$ \\ and %% ; then each backslash + // doubled for the string value. + assert_eq!( + exec_quote(Path::new(r#"/home/me/My "Apps"/100% $x`y`\Weir.AppImage"#)).as_deref(), + Some(r#""/home/me/My \\"Apps\\"/100%% \\$x\\`y\\`\\\\Weir.AppImage""#) + ); + assert_eq!(exec_quote(Path::new("/home/me/a\nb.AppImage")), None); + } + + #[test] + fn the_entry_starts_the_appimage_and_says_who_wrote_it() { + let entry = entry_for(Path::new("/home/me/Apps/Weir-1.5.0-x86_64.AppImage")).unwrap(); + assert!(entry.starts_with("[Desktop Entry]\n")); + assert!(entry.contains("\nExec=\"/home/me/Apps/Weir-1.5.0-x86_64.AppImage\"\n")); + assert!(entry.contains("\nTryExec=/home/me/Apps/Weir-1.5.0-x86_64.AppImage\n")); + assert!(entry.ends_with(&format!("\n{MARK}\n"))); + assert!(!entry.contains("@BINDIR@")); + assert_eq!(entry.matches("\nExec=").count(), 1); + } +} diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index edfb557..11f7d9b 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -34,6 +34,7 @@ //! * `status`: what shows while there is no daemon to talk to. mod about; +mod appimage; mod apps; mod bus; mod controls; @@ -167,6 +168,11 @@ pub struct App { last_recorder_seq: u64, /// What the person said about a new version, for this run. updates: updates::Updates, + /// The AppImage Weir runs from, if it does. + appimage: Option, + /// Whether the application menu has Weir, when it runs from an + /// AppImage. + menu: appimage::Menu, /// Preferences › Meters' meter colors, as being edited. meter_page: meter_colors::MeterColorsPage, show_prefs: bool, @@ -257,6 +263,7 @@ impl App { client.shared.locked().safe_start = safe_mode; let prefs = Prefs::load(); prefs.note_for_crashes(); + let running_from = weir_protocol::appimage::appimage(); Self { crash: crash::CrashUi::at_start(&cc.egui_ctx), safe: safe::SafeUi::at_start(safe_mode), @@ -281,6 +288,14 @@ impl App { recorder: recorder::RecorderWindow::opened(open_recorder), last_recorder_seq: 0, updates: updates::Updates::default(), + appimage: running_from.clone(), + menu: match &running_from { + Some(a) => { + appimage::follow(&a.file); + appimage::menu_state() + } + None => appimage::Menu::Other, + }, meter_page: meter_colors::MeterColorsPage::default(), show_prefs: false, raise_prefs: false, @@ -658,6 +673,7 @@ impl eframe::App for App { if connected { self.safe_bar(root, &state); self.update_question(root, &state); + self.appimage_question(root); self.mix_bar(root, &state); } egui::CentralPanel::default() diff --git a/crates/gui/src/app/settings.rs b/crates/gui/src/app/settings.rs index 4798f18..52dfa10 100644 --- a/crates/gui/src/app/settings.rs +++ b/crates/gui/src/app/settings.rs @@ -179,6 +179,9 @@ impl App { card(ui, "Tray icon", |ui| self.tray_prefs(ui, state)); } card(ui, "Updates", |ui| self.update_prefs(ui, state)); + if self.appimage.is_some() { + card(ui, "AppImage", |ui| self.appimage_prefs(ui)); + } } PrefsPage::Audio => { card(ui, "Sample rate and buffer size", |ui| { diff --git a/crates/gui/src/prefs.rs b/crates/gui/src/prefs.rs index 2daadf5..e2391f4 100644 --- a/crates/gui/src/prefs.rs +++ b/crates/gui/src/prefs.rs @@ -171,6 +171,9 @@ pub struct Prefs { /// only be shown by a long name such as /// `alsa_input.usb-Astro_Gaming_Astro_MixAmp_Pro-00-input-0`. pub device_names: BTreeMap, + /// Whether the window has asked to add Weir to the application menu, + /// which it does once when Weir runs from an AppImage. + pub appimage_menu_asked: bool, } impl Default for Prefs { @@ -189,6 +192,7 @@ impl Default for Prefs { strip_fx_size: None, bus_settings_size: None, device_names: BTreeMap::new(), + appimage_menu_asked: false, } } } From f99203febd903b7a34a7db0f488e390daa034112 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:22:58 +0000 Subject: [PATCH 03/24] Build AppImages for x86_64 and arm64 make appimage-dir lays out the AppImage's contents: what make install puts down under /usr, less the guides and the login unit, the programs stripped, plus packaging/appimage/AppRun, which starts the window, the service ("weir-daemon" as first word, as Weir's own login unit does) or weirctl (that word, or a link named weirctl). make appimage packs it with appimagetool, embedding update information for Gear Lever and AppImageUpdate (gh-releases-zsync, the latest release's .zsync for the same architecture), and writes the .zsync file with zsyncmake. Nothing is bundled but Weir: its programs need only the C library and PipeWire's library, and PipeWire's must be the system's, since it loads its plugins from the system's folders. Built on Ubuntu 24.04, it runs on systems from 2024 on, which leaves out only those whose PipeWire is too old for Weir anyway. The Packages workflow builds it on both architectures with appimagetool 1.9.1 and runtime 20251108 (pinned; their checksums are printed), checks the menu entry and AppStream file, runs weirctl and weir-daemon from it, and checks the embedded update information. A release attaches both AppImages and their .zsync files, with the AppImages in SHA256SUMS and the attestation. The README offers the AppImage for any other distribution, the user guide has a section on running from one, and CLI, ARCHITECTURE and CONTRIBUTING say what is different. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- .github/workflows/packages.yml | 101 +++++++++++++++++++++++++++++++-- CONTRIBUTING.md | 18 ++++-- Makefile | 60 +++++++++++++++++++- README.md | 6 ++ docs/ARCHITECTURE.md | 24 ++++++++ docs/CLI.md | 3 + docs/USER_GUIDE.md | 34 +++++++++++ packaging/appimage/AppRun | 32 +++++++++++ 8 files changed, 267 insertions(+), 11 deletions(-) create mode 100755 packaging/appimage/AppRun diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 3b7ce14..c4b369a 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -1,7 +1,8 @@ -# Builds Weir's packages: an RPM for Fedora and Nobara and a .deb for -# Ubuntu, Linux Mint, Pop!_OS and Debian, each for x86_64 and for 64-bit -# ARM, and a package for Arch Linux and the systems built on it (x86_64 -# only), whose recipe also goes to the AUR. +# Builds Weir's packages: an RPM for Fedora and Nobara, a .deb for +# Ubuntu, Linux Mint, Pop!_OS and Debian, and an AppImage for any other +# distribution, each for x86_64 and for 64-bit ARM, and a package for Arch +# Linux and the systems built on it (x86_64 only), whose recipe also goes +# to the AUR. # # * Publishing a release on GitHub builds them and attaches them to the # release, with a SHA256SUMS file. GitHub also signs a record that this @@ -156,6 +157,86 @@ jobs: if-no-files-found: error retention-days: 14 + appimage: + name: ${{ matrix.name }} + needs: version + strategy: + fail-fast: false + matrix: + include: + - name: AppImage + runner: ubuntu-24.04 + arch: x86_64 + - name: AppImage, arm64 + runner: ubuntu-24.04-arm + arch: aarch64 + # Built on the oldest system it is for, like the .deb: it needs the C + # library of Ubuntu 24.04 or newer, and PipeWire's from the system. + runs-on: ${{ matrix.runner }} + env: + # Pinned: appimagetool packs the AppImage, and the runtime is the + # small program at its front that mounts and starts it. + APPIMAGETOOL_VERSION: "1.9.1" + RUNTIME_VERSION: "20251108" + steps: + - uses: actions/checkout@v4 + - name: Install the build tools + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + clang cmake pkg-config libpipewire-0.3-dev desktop-file-utils appstream \ + squashfs-tools zsync + # The ARM image may come without rustup; install it if so. + - name: Install Rust + run: | + if ! command -v rustup >/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal + echo "$HOME/.cargo/bin" >> "$GITHUB_PATH" + export PATH="$HOME/.cargo/bin:$PATH" + fi + rustup toolchain install stable --profile minimal + rustup default stable + - name: Fetch appimagetool and the AppImage runtime + run: | + mkdir -p tools + base=https://github.com/AppImage + curl -fsSL -o tools/appimagetool \ + "$base/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-${{ matrix.arch }}.AppImage" + curl -fsSL -o tools/runtime \ + "$base/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-${{ matrix.arch }}" + chmod +x tools/appimagetool + sha256sum tools/appimagetool tools/runtime + # The runner may have no FUSE, so appimagetool unpacks itself to run. + - name: Build the AppImage + env: + APPIMAGE_EXTRACT_AND_RUN: 1 + run: make appimage APPIMAGETOOL="$PWD/tools/appimagetool" APPIMAGE_RUNTIME=tools/runtime + - name: Check the AppImage + env: + APPIMAGE_EXTRACT_AND_RUN: 1 + run: | + cd target/appimage + ls -la + desktop-file-validate AppDir/weir.desktop + appstreamcli validate --no-net AppDir/usr/share/metainfo/io.github.mourty.weir.metainfo.xml + image=$(ls Weir-*-${{ matrix.arch }}.AppImage) + test -s "$image.zsync" + # It starts, and runs the program asked for. + "./$image" weirctl --version + "./$image" weir-daemon --version + # Where updaters look for the next version. The runtime reads + # this without mounting, but only when not told to unpack itself. + env -u APPIMAGE_EXTRACT_AND_RUN "./$image" --appimage-updateinformation | tee update-info + grep -q "^gh-releases-zsync|Mourty|Weir|latest|Weir-\*-${{ matrix.arch }}.AppImage.zsync$" update-info + - uses: actions/upload-artifact@v4 + with: + name: appimage-${{ matrix.arch }} + path: | + target/appimage/Weir-*.AppImage + target/appimage/Weir-*.AppImage.zsync + if-no-files-found: error + retention-days: 14 + arch: name: Arch Linux needs: version @@ -269,7 +350,7 @@ jobs: publish: name: Attach to the release if: github.event_name == 'release' - needs: [rpm, deb, arch] + needs: [rpm, deb, appimage, arch] runs-on: ubuntu-24.04 permissions: contents: write # attach the files to the release @@ -287,12 +368,19 @@ jobs: pattern: deb-* merge-multiple: true path: dist + - uses: actions/download-artifact@v4 + with: + pattern: appimage-* + merge-multiple: true + path: dist - uses: actions/download-artifact@v4 with: name: arch path: dist + # The .zsync files are for updaters, which check what they download + # against them; they are attached but not listed. - name: Write checksums - run: cd dist && sha256sum -- *.rpm *.deb *.pkg.tar.zst > SHA256SUMS && cat SHA256SUMS + run: cd dist && sha256sum -- *.rpm *.deb *.AppImage *.pkg.tar.zst > SHA256SUMS && cat SHA256SUMS # Signed in the same run that built the packages, so it vouches for # exactly these files; a file swapped on the release later fails the # check. Attestations need a public repository (or GitHub Enterprise). @@ -302,6 +390,7 @@ jobs: subject-path: | dist/*.rpm dist/*.deb + dist/*.AppImage dist/*.pkg.tar.zst - name: Upload to the release env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 11c7218..7b3b85b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -234,11 +234,20 @@ Makefile. `make rpm-install` numbers its package by date and time, so dnf counts it as newer than a released package of the same version: run `sudo dnf remove weir` before installing a downloaded one. +`make appimage-dir` lays out an AppImage's contents in +`target/appimage/AppDir`: what `make install` puts down under `/usr`, +less the guides and the login unit, plus `packaging/appimage/AppRun`. +`make appimage` packs them with +[appimagetool](https://github.com/AppImage/appimagetool), which it does +not install (`make appimage APPIMAGETOOL=/path/to/appimagetool`), and +with `zsyncmake` (`sudo apt install zsync`) also writes the `.zsync` file +updaters use. It builds for the computer it runs on. + GitHub builds the packages whenever a pull request changes the packaging, and on demand from **Actions → Packages → Run workflow**. Each run keeps them as downloads on its page for two weeks, for trying before a -release. It builds the RPM and the `.deb` twice, for x86_64 and for -64-bit ARM (on GitHub's ARM runners, free for public repositories), and +release. It builds the RPM, the `.deb` and the AppImage twice, for x86_64 +and for 64-bit ARM (on GitHub's ARM runners, free for public repositories), and an Arch Linux package for x86_64 in an Arch container, from `packaging/aur/PKGBUILD`, which is also the recipe for the [AUR](https://aur.archlinux.org/packages/weir). The **Checks** workflow @@ -265,8 +274,9 @@ branch fails instead of publishing unreleased code as a release. tag**, type `v1.1.0` (the version with a `v` in front) and pick **Create new tag on publish**. Give it a title, write what changed (or press **Generate release notes**), and press **Publish release**. -4. The **Packages** workflow builds the RPMs, the `.deb` files and the - Arch package and attaches them to the release, with a `SHA256SUMS` file, in +4. The **Packages** workflow builds the RPMs, the `.deb` files, the + AppImages (with their `.zsync` files, for updaters) and the Arch + package and attaches them to the release, with a `SHA256SUMS` file, in about twenty minutes. It refuses to start if the tag and the version numbers disagree. It also signs an attestation for each package (listed on the diff --git a/Makefile b/Makefile index 8821303..920affa 100644 --- a/Makefile +++ b/Makefile @@ -52,7 +52,7 @@ CARGO ?= cargo CARGO_FLAGS ?= --release BINARIES := weir-daemon weir weirctl -.PHONY: all build test fuzz install uninstall clean help rpm rpm-install deb icons licenses +.PHONY: all build test fuzz install uninstall clean help rpm rpm-install deb appimage appimage-dir icons licenses all: build @@ -66,6 +66,8 @@ help: @echo "make rpm build an RPM package into $(RPMTOP)/RPMS" @echo "make rpm-install build the RPM and install it with dnf" @echo "make deb build a .deb package into target/deb" + @echo "make appimage build an AppImage into target/appimage (needs appimagetool)" + @echo "make appimage-dir lay out the AppImage's contents, without packing them" @echo "make icons redraw the window icon (needs rsvg-convert)" @echo "make licenses gather the libraries' license notices (needs cargo-about)" @@ -260,3 +262,59 @@ deb: dpkg-deb --root-owner-group -Zxz --build $(DEBROOT) $(DEB) @echo "" @echo "Package built: $(DEB)" + +# ---- AppImage ------------------------------------------------------------ +# +# One file holding Weir's programs, for any distribution with PipeWire, +# for the computer it is built on (x86_64 or aarch64). `make appimage-dir` +# lays out its contents in target/appimage/AppDir; `make appimage` packs +# them with appimagetool, which it does not install: put it on the PATH or +# point APPIMAGETOOL at it, and point APPIMAGE_RUNTIME at the AppImage +# runtime to put in front, or appimagetool downloads the newest. Both come +# from github.com/AppImage; the Packages workflow uses pinned versions. +# +# The programs need only the C library and PipeWire's library from the +# system, so nothing else is bundled. PipeWire's own library stays the +# system's: it loads its plugins from the system's folders. + +APPIMAGE_ARCH := $(shell uname -m) +APPIMAGE_DIR := target/appimage +APPIMAGE_ROOT := $(APPIMAGE_DIR)/AppDir +APPIMAGE_FILE := $(APPIMAGE_DIR)/Weir-$(VERSION)-$(APPIMAGE_ARCH).AppImage +APPIMAGETOOL ?= appimagetool +APPIMAGE_RUNTIME ?= +# Where updaters such as Gear Lever and AppImageUpdate look for the next +# version: the .zsync file beside it on GitHub's latest release. +APPIMAGE_UPDATE := gh-releases-zsync|Mourty|Weir|latest|Weir-*-$(APPIMAGE_ARCH).AppImage.zsync + +appimage-dir: + rm -rf $(APPIMAGE_DIR) + $(MAKE) install DESTDIR=$(CURDIR)/$(APPIMAGE_ROOT) PREFIX=/usr + @# Nothing an AppImage uses: the guides are online, and Weir writes its + @# own login unit, naming the AppImage, when asked to start at login. + rm -rf $(APPIMAGE_ROOT)/usr/share/doc $(APPIMAGE_ROOT)/usr/share/systemd + strip --strip-unneeded $(addprefix $(APPIMAGE_ROOT)/usr/bin/,$(BINARIES)) + install -m755 packaging/appimage/AppRun $(APPIMAGE_ROOT)/AppRun + @# Inside an AppImage the menu entry names the program, not a path. + sed -i 's|^Exec=.*|Exec=weir|' $(APPIMAGE_ROOT)/usr/share/applications/weir.desktop + cp $(APPIMAGE_ROOT)/usr/share/applications/weir.desktop $(APPIMAGE_ROOT)/weir.desktop + cp packaging/weir.svg $(APPIMAGE_ROOT)/weir.svg + cp crates/gui/assets/weir.png $(APPIMAGE_ROOT)/.DirIcon + @echo "" + @echo "Laid out in $(APPIMAGE_ROOT)" + +appimage: appimage-dir + @command -v $(APPIMAGETOOL) >/dev/null 2>&1 || { \ + echo "appimagetool not found. Get it from"; \ + echo " https://github.com/AppImage/appimagetool/releases"; \ + echo "and run make appimage APPIMAGETOOL=/path/to/appimagetool"; \ + exit 1; } + @command -v zsyncmake >/dev/null 2>&1 || \ + echo "zsyncmake not found: no .zsync file for updaters (sudo apt install zsync)" + @# From the AppImage's folder: zsyncmake writes the .zsync file where it runs. + tool=$$(realpath "$$(command -v $(APPIMAGETOOL))") && cd $(APPIMAGE_DIR) && \ + ARCH=$(APPIMAGE_ARCH) "$$tool" \ + $(if $(APPIMAGE_RUNTIME),--runtime-file $(abspath $(APPIMAGE_RUNTIME))) \ + -u '$(APPIMAGE_UPDATE)' AppDir $(notdir $(APPIMAGE_FILE)) + @echo "" + @echo "AppImage built: $(APPIMAGE_FILE)" diff --git a/README.md b/README.md index 575ca30..c067b4b 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,12 @@ On a 64-bit ARM computer, such as a Raspberry Pi 5, an Apple Silicon Mac running Fedora Asahi Remix, or an ARM laptop, take the `.aarch64.rpm` or the `_arm64.deb` instead. There is no ARM package for Arch. +**On any other distribution**, or to try Weir without installing it, take +the AppImage: `Weir-…-x86_64.AppImage`, or `Weir-…-aarch64.AppImage` on +ARM. Allow it to run (`chmod +x Weir-*.AppImage`) and open it. It needs +PipeWire with WirePlumber and a system from 2024 or newer; the +[user guide](docs/USER_GUIDE.md#running-weir-as-an-appimage) has the rest. + GitHub builds the packages from the code here and signs a record of it. To check that a download is exactly what GitHub built, use the [GitHub command-line tool](https://cli.github.com/), signed in with diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 90a99bb..b2dbe56 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -384,6 +384,30 @@ changing it with `enable` or `disable` (in `login.rs`), so the setting cannot drift from what systemd will actually do. It never uses `--now`: the daemon asking is already running. +**From an AppImage** (`weir_protocol::appimage`), nothing is installed. +The AppImage's starter mounts the image, sets `APPIMAGE` (the file) and +`APPDIR` (the mount), and runs `packaging/appimage/AppRun`, which starts +the window, the service (`weir-daemon` as first word) or `weirctl` (that +word, or a link of that name). The mount stays while any process holds +the read end of a pipe the starter hands down, so the service the window +starts outlives the window. Those variables count only when Weir's own +file is inside `APPDIR`, since a terminal opened from another AppImage +passes them on too. Then: + +* `login::AppImageLogin` writes its own `~/.config/systemd/user/weir.service` + from the installed one's template, with `ExecStart` naming the AppImage + (quoted for systemd) and a first line marking it as Weir's. At each + start it points that unit at the AppImage's current path. +* `restart` starts the AppImage file, not the mounted copy, so a newer + AppImage put in its place is what runs. It marks every inherited file + to close first, letting go of the old mount. +* The window offers once to write a menu entry (`app/appimage.rs`: + `~/.local/share/applications/weir.desktop`, marked `X-Weir-AppImage`, + and the icons), which the desktop portal's hotkeys need off Plasma. It + also keeps that entry pointing at the AppImage's current path. +* `diagnostics::Install::AppImage` gives bug reports and the update + notifier their words. + **Export and import** (`transfer.rs` for the files, `controller/transfer.rs` for the requests) is the daemon's, so `weirctl` and plugins get it as the window does. A `.zip` holds a manifest and one diff --git a/docs/CLI.md b/docs/CLI.md index 8f75a39..3784dec 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -3,6 +3,9 @@ `weirctl` controls Weir from a terminal, a script or a keyboard shortcut. It can do everything the mixer window can, and needs the daemon to be running, which it is whenever the window is open or the service is enabled. +Running Weir from an AppImage, start it as `./Weir-*.AppImage weirctl …`, +or make a link named `weirctl` to the AppImage once; Preferences › General +shows the command. ```sh weirctl state # everything at a glance diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 30a0611..2d1c58c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -23,6 +23,7 @@ look up when you need it. * [Undo](#undo) * [Preferences](#preferences) * [New versions](#new-versions) +* [Running Weir as an AppImage](#running-weir-as-an-appimage) * [Weir in the background](#weir-in-the-background) * [Mouse and keyboard](#mouse-and-keyboard) * [Where Weir keeps things](#where-weir-keeps-things) @@ -1028,6 +1029,39 @@ mixer window opened in between may not work with it. `weirctl update` says the same in a terminal, and `weirctl update --check` asks GitHub now. +## Running Weir as an AppImage + +The file ending in `.AppImage` on the release page is Weir in one file, +for any distribution with PipeWire: nothing is installed. Take the one for +your computer (`x86_64` for most PCs, `aarch64` for 64-bit ARM ones such as +a Raspberry Pi 5), allow it to run once (in the file manager, +**Properties › Permissions › Allow executing as program**, or `chmod +x +Weir-*.AppImage` in a terminal), and open it. + +* **The application menu.** Nothing is in it at first, so the first time a + line under the top bar asks whether to add Weir. That writes a menu + entry and Weir's icons into your home folder (`~/.local/share`). + **Preferences › General › AppImage** adds or removes it later. On + desktops other than KDE Plasma, hotkeys need the entry too. If an + installed Weir is in the menu already, nothing is asked. +* **Start at login** works as in an installed Weir: Weir writes its own + login service, `~/.config/systemd/user/weir.service`, naming the + AppImage. +* **Moving it.** If you move or rename the AppImage, open it once from its + new place: Weir points the menu entry and start at login there. +* **weirctl** runs as `./Weir-*.AppImage weirctl …`, or as plain `weirctl` + once you make a link to the AppImage. Preferences › General › AppImage + shows the command to copy. Make the link again if you move the AppImage. +* **Updating.** Download the new AppImage and put it in place of the old + one, or let an AppImage manager such as Gear Lever or AppImageUpdate do + it. Then choose **Restart Weir** in the tray menu. +* **What it needs:** PipeWire 0.3.77 or newer with WirePlumber, and a + system from 2024 or newer, such as Ubuntu 24.04, Fedora 40, Debian 13 or + Linux Mint 22. It needs FUSE to open, which desktops have. +* **With an installed Weir too,** both share your settings, and only one + service runs at a time: a window opened from either one shows the + service already running. Quit Weir from the tray before switching. + ## Weir in the background Weir is two parts: the **service**, which does the mixing and owns the diff --git a/packaging/appimage/AppRun b/packaging/appimage/AppRun new file mode 100755 index 0000000..c101b0b --- /dev/null +++ b/packaging/appimage/AppRun @@ -0,0 +1,32 @@ +#!/bin/sh +# The first thing the AppImage runs. The AppImage's starter mounts its +# contents, sets APPDIR to where, and ARGV0 to the name the AppImage was +# started by, then runs this. +# +# It runs one of Weir's three programs: +# - weirctl, when started through a link named weirctl, or as +# "Weir.AppImage weirctl ..."; +# - the service, as "Weir.AppImage weir-daemon ...", which the login +# unit Weir writes for an AppImage uses; +# - otherwise the mixer window, which starts the service itself. +# +# The mount stays as long as any program started from it runs, so the +# service can outlive the window that started it. + +here=${APPDIR:-$(dirname "$(readlink -f "$0")")} + +case $(basename "${ARGV0:-$0}") in + weirctl | weir-daemon) + exec "$here/usr/bin/$(basename "${ARGV0:-$0}")" "$@" + ;; +esac + +case $1 in + weirctl | weir-daemon) + program=$1 + shift + exec "$here/usr/bin/$program" "$@" + ;; +esac + +exec "$here/usr/bin/weir" "$@" From dd39629c9377fd4a4adb24b12cf7b45560936bf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:33:45 +0000 Subject: [PATCH 04/24] Clear an AppImage's login unit away from an installed Weir An AppImage turns on start at login by writing its own ~/.config/systemd/user/weir.service, which takes the place of the installed unit. Try the AppImage on a computer with Weir installed, turn start at login on there, delete the AppImage, and the next login starts nothing: the unit names a file that is gone, and the installed Weir cannot tell. An installed Weir now removes such a unit at startup once the AppImage it names is gone, switching the installed unit on in its place if it was on, and also when start at login is turned on in the installed Weir, which means that copy. Tried with a stand-in systemctl: a unit for a deleted AppImage goes and start at login stays on, one for an AppImage that is there stays, and ticking the box takes over. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/login.rs | 136 ++++++++++++++++++++++++++++++++----- docs/ARCHITECTURE.md | 6 +- docs/USER_GUIDE.md | 3 + 3 files changed, 128 insertions(+), 17 deletions(-) diff --git a/crates/daemon/src/login.rs b/crates/daemon/src/login.rs index add71a4..7d88c8e 100644 --- a/crates/daemon/src/login.rs +++ b/crates/daemon/src/login.rs @@ -9,6 +9,9 @@ //! //! An AppImage installs nothing, so [`AppImageLogin`] writes the unit //! itself, naming the AppImage file, and keeps it naming the right one. +//! That unit, in the person's own folder, takes the place of an installed +//! one, so an installed Weir ([`InstalledLogin`]) clears it away once its +//! AppImage is gone, or when the person turns start at login on there. use std::path::{Path, PathBuf}; use std::process::Command; @@ -60,19 +63,25 @@ impl LoginStart for Systemd { /// Where Weir is started at login, for this copy of it: a unit of its own /// when it runs from an AppImage, the installed one otherwise. pub fn here(appimage: Option<&AppImage>) -> Box { - match appimage.and_then(|a| AppImageLogin::new(a.file.clone())) { - Some(own) => { - own.follow(); - Box::new(own) - } - None => Box::new(Systemd), + if let Some(own) = appimage.and_then(|a| AppImageLogin::new(a.file.clone())) { + own.follow(); + return Box::new(own); } + let installed = InstalledLogin::new(); + installed.tidy(); + Box::new(installed) } /// The first line of a unit Weir wrote for an AppImage. Weir only ever /// changes or removes a unit that starts with it. const MARK: &str = "# Written by Weir to start its AppImage at login."; +/// The second line of such a unit names the AppImage between these. +const STARTS: (&str, &str) = ( + "# It starts ", + "; Weir rewrites this line if the AppImage moves.", +); + /// The installed unit, the model for the one an AppImage writes. const TEMPLATE: &str = include_str!("../../../packaging/weir.service.in"); @@ -95,9 +104,7 @@ impl AppImageLogin { /// The unit there now, if Weir wrote it. fn ours(&self) -> Option { - std::fs::read_to_string(&self.unit) - .ok() - .filter(|text| text.starts_with(MARK)) + appimage_unit(&self.unit) } /// Point the unit Weir wrote at this AppImage, if it names another @@ -140,15 +147,107 @@ impl LoginStart for AppImageLogin { } else { Systemd.set(false)?; if self.ours().is_some() { - std::fs::remove_file(&self.unit) - .map_err(|e| format!("could not remove {}: {e}", self.unit.display()))?; - daemon_reload(); + remove_unit(&self.unit)?; } Ok(()) } } } +/// Starting an installed Weir at login: the installed unit, through +/// systemd. An AppImage's unit in the person's folder hides it, so this +/// takes that one away when it can only get in the way. +pub struct InstalledLogin { + /// Where an AppImage's unit would be. + unit: Option, +} + +impl InstalledLogin { + fn new() -> Self { + Self { + unit: dirs::config_dir().map(|dir| dir.join("systemd/user").join(UNIT)), + } + } + + /// An AppImage's unit, if one is there. + fn appimage_unit(&self) -> Option<&Path> { + self.unit + .as_deref() + .filter(|unit| appimage_unit(unit).is_some()) + } + + /// Run at startup. An AppImage's unit whose AppImage is gone (deleted + /// after trying it, say) would start nothing at the next login, and + /// the installed Weir neither, so it goes, and if it was switched on, + /// the installed unit is switched on in its place. + fn tidy(&self) { + let Some(unit) = self.appimage_unit() else { + return; + }; + let names = appimage_unit(unit).and_then(|text| started_file(&text)); + if names.is_none_or(|file| file.exists()) { + return; + } + if let Err(e) = self.replace_appimage_unit(unit) { + tracing::warn!("{e}"); + return; + } + tracing::info!( + "removed {}: the AppImage it started is gone", + unit.display() + ); + } + + /// Take an AppImage's unit away, keeping start at login as it was. + fn replace_appimage_unit(&self, unit: &Path) -> Result<(), String> { + let was_on = Systemd.get() == Some(true); + if was_on { + // While the file is still there, so its links go with it. + Systemd.set(false)?; + } + remove_unit(unit)?; + if was_on { + Systemd.set(true)?; + } + Ok(()) + } +} + +impl LoginStart for InstalledLogin { + fn get(&self) -> Option { + Systemd.get() + } + + fn set(&self, on: bool) -> Result<(), String> { + // Turning it on here means this Weir, not an AppImage. + if let Some(unit) = self.appimage_unit().filter(|_| on) { + remove_unit(unit)?; + } + Systemd.set(on) + } +} + +/// The unit at `path`, if Weir wrote it for an AppImage. +fn appimage_unit(path: &Path) -> Option { + std::fs::read_to_string(path) + .ok() + .filter(|text| text.starts_with(MARK)) +} + +/// The AppImage an AppImage's unit starts, from its second line. +fn started_file(unit: &str) -> Option { + let line = unit.lines().nth(1)?; + let file = line.strip_prefix(STARTS.0)?.strip_suffix(STARTS.1)?; + Some(PathBuf::from(file)) +} + +/// Remove a unit and tell systemd. +fn remove_unit(unit: &Path) -> Result<(), String> { + std::fs::remove_file(unit).map_err(|e| format!("could not remove {}: {e}", unit.display()))?; + daemon_reload(); + Ok(()) +} + /// The unit that starts the AppImage `file`, from the installed one's /// sections. fn unit_for(file: &Path) -> Result { @@ -162,10 +261,7 @@ fn unit_for(file: &Path) -> Result { let body = TEMPLATE .find("[Unit]") .map_or(TEMPLATE, |at| &TEMPLATE[at..]); - let mut out = format!( - "{MARK}\n# It starts {}; Weir rewrites this line if the AppImage moves.\n\n", - file.display() - ); + let mut out = format!("{MARK}\n{}{}{}\n\n", STARTS.0, file.display(), STARTS.1); for line in body.lines() { if line.starts_with("ExecStart=") { out.push_str(&format!("ExecStart={exec} weir-daemon\n")); @@ -280,4 +376,12 @@ mod tests { ); assert_eq!(unit.matches("ExecStart=").count(), 1); } + + #[test] + fn an_installed_weir_can_tell_which_appimage_a_unit_starts() { + let file = Path::new("/home/me/My Apps/Weir; v2.AppImage"); + let unit = unit_for(file).unwrap(); + assert_eq!(started_file(&unit).as_deref(), Some(file)); + assert_eq!(started_file("[Unit]\nDescription=Weir\n"), None); + } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b2dbe56..82ff789 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -397,7 +397,11 @@ passes them on too. Then: * `login::AppImageLogin` writes its own `~/.config/systemd/user/weir.service` from the installed one's template, with `ExecStart` naming the AppImage (quoted for systemd) and a first line marking it as Weir's. At each - start it points that unit at the AppImage's current path. + start it points that unit at the AppImage's current path. Being in the + person's folder, that unit hides an installed one, so an installed + Weir's `login::InstalledLogin` removes it at startup once the AppImage + it names is gone (keeping start at login on, now with the installed + unit), and when start at login is turned on there. * `restart` starts the AppImage file, not the mounted copy, so a newer AppImage put in its place is what runs. It marks every inherited file to close first, letting go of the old mount. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 2d1c58c..775c89f 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1061,6 +1061,9 @@ Weir-*.AppImage` in a terminal), and open it. * **With an installed Weir too,** both share your settings, and only one service runs at a time: a window opened from either one shows the service already running. Quit Weir from the tray before switching. + Start at login starts the copy it was last turned on in; if that was + the AppImage and you delete it, the installed Weir takes over again + the next time it starts. ## Weir in the background From d9026829aa028cabe96a22170368dd9fe6a7dc85 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:33:45 +0000 Subject: [PATCH 05/24] Print the AppImage tools' checksums at the end of the job So they can be read from the end of the log and pinned. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- .github/workflows/packages.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index c4b369a..2fa03c4 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -205,7 +205,6 @@ jobs: curl -fsSL -o tools/runtime \ "$base/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-${{ matrix.arch }}" chmod +x tools/appimagetool - sha256sum tools/appimagetool tools/runtime # The runner may have no FUSE, so appimagetool unpacks itself to run. - name: Build the AppImage env: @@ -228,6 +227,8 @@ jobs: # this without mounting, but only when not told to unpack itself. env -u APPIMAGE_EXTRACT_AND_RUN "./$image" --appimage-updateinformation | tee update-info grep -q "^gh-releases-zsync|Mourty|Weir|latest|Weir-\*-${{ matrix.arch }}.AppImage.zsync$" update-info + # What went into it, to pin. + sha256sum ../../tools/appimagetool ../../tools/runtime - uses: actions/upload-artifact@v4 with: name: appimage-${{ matrix.arch }} From 9befc5a0fab568dddca2dad35acefe3bd5e8ae0e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:40:51 +0000 Subject: [PATCH 06/24] Find the one-color tray icon inside an AppImage The one-color tray icon goes to the panel by name, weir-symbolic, which packages install into the icon theme. An AppImage installs nothing (its menu entry adds the icons, but only when asked), so with the one-color icon chosen the panel had nothing to show. The tray now names the icon folder inside the AppImage as its icon theme path, which Plasma and GNOME's AppIndicator extension search; the AppImage stays mounted while the service runs. Checked over D-Bus with a stand-in AppImage: the item names the mounted folder, and weir-symbolic.svg is in it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/tray.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/crates/daemon/src/tray.rs b/crates/daemon/src/tray.rs index 36cc187..f605e06 100644 --- a/crates/daemon/src/tray.rs +++ b/crates/daemon/src/tray.rs @@ -106,6 +106,14 @@ impl Tray for MixerTray { } } + /// A folder the desktop searches for the icon named above, besides + /// its icon theme. An AppImage installs nothing (its menu entry adds + /// icons, but only if asked for), so it points inside the AppImage, + /// which stays mounted while the service runs. + fn icon_theme_path(&self) -> String { + appimage_icons().unwrap_or_default() + } + fn icon_pixmap(&self) -> Vec { match self.icon { TrayIcon::Color => color_pixmaps().to_vec(), @@ -331,6 +339,18 @@ fn red_dot(size: i32) -> Icon { } } +/// The icon folder inside the AppImage Weir runs from, if it does. +fn appimage_icons() -> Option { + static DIR: OnceLock> = OnceLock::new(); + DIR.get_or_init(|| { + let dir = weir_protocol::appimage::appimage()? + .dir + .join("usr/share/icons"); + dir.is_dir().then(|| dir.to_string_lossy().into_owned()) + }) + .clone() +} + /// Weir's color icon in the sizes panels use, made once from the 256 px /// one the window is built with; empty if that cannot be read. fn color_pixmaps() -> &'static [Icon] { From b690baa7876b767b8cd1060b42a2747ccca814d0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:46:37 +0000 Subject: [PATCH 07/24] Keep Weir running when a restart cannot start the new AppImage Restart Weir from an AppImage starts the AppImage file, so that a newer one put in its place runs. But a newly downloaded file is not allowed to run, and starting it failed: the old service had already shut down, so Weir was simply gone. The restart now allows the file to run first, as its owner would before opening it. If it still cannot be started (a folder that forbids running programs, a file owned by someone else), it holds on to the mount again and starts the running version from it, rather than stop. Tried with a stand-in AppImage: a replacement that was not allowed to run became allowed and ran, in the same process; on a mount that forbids running programs, the same process came back from the old mount, still answering. The guide's updating advice now covers both ways: in place under the old name, then Restart Weir; or quit, open the new file, delete the old one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/main.rs | 89 ++++++++++++++++++++++++++++++--------- docs/ARCHITECTURE.md | 8 +++- docs/USER_GUIDE.md | 8 ++-- 3 files changed, 80 insertions(+), 25 deletions(-) diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index a41fd2d..6369da2 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -635,21 +635,34 @@ fn restart(window: bool, safe: bool) -> Result<()> { // From an AppImage, start the AppImage file again rather than the copy // mounted from it, so that a newer AppImage put in its place is what // runs, and let go of the old mount. - let appimage = weir_protocol::appimage::appimage().filter(|a| a.file.is_file()); - let (exe, mut args) = match appimage { - Some(a) => { - let_go_of_the_mount(); - (a.file, vec!["weir-daemon".to_string()]) - } - None => ( - weir_protocol::own_exe().context("finding our own binary to restart")?, - Vec::new(), - ), - }; - args.extend(rest); - let mut command = std::process::Command::new(&exe); + if let Some(appimage) = weir_protocol::appimage::appimage().filter(|a| a.file.is_file()) { + allow_to_run(&appimage.file); + let held = let_go_of_the_mount(); + let error = restart_command(&appimage.file, &["weir-daemon"], &rest, window, safe).exec(); + // Rather than stop, start this version again from its mount, which + // has to stay then. + warn!( + "could not start {}: {error}; starting this version again", + appimage.file.display() + ); + hold_on_to(&held); + } + let exe = weir_protocol::own_exe().context("finding our own binary to restart")?; + let error = restart_command(&exe, &[], &rest, window, safe).exec(); + anyhow::bail!("could not restart {}: {error}", exe.display()); +} + +/// Weir started again as `exe` with `first` and then `rest` as arguments. +fn restart_command( + exe: &Path, + first: &[&str], + rest: &[String], + window: bool, + safe: bool, +) -> std::process::Command { + let mut command = std::process::Command::new(exe); // A crash on purpose is for the run it was asked of. - command.args(&args).env_remove(CRASH_TEST_ENV); + command.args(first).args(rest).env_remove(CRASH_TEST_ENV); for (env, on) in [(OPEN_WINDOW_ENV, window), (SAFE_MODE_ENV, safe)] { if on { command.env(env, "1"); @@ -657,30 +670,66 @@ fn restart(window: bool, safe: bool) -> Result<()> { command.env_remove(env); } } - let error = command.exec(); - anyhow::bail!("could not restart {}: {error}", exe.display()); + command +} + +/// Let `file` run, as its owner would before opening it: a newer AppImage +/// put in place of the old one has usually just been downloaded, and a +/// download is never allowed to run at first. +fn allow_to_run(file: &Path) { + use std::os::unix::fs::PermissionsExt; + let Ok(meta) = std::fs::metadata(file) else { + return; + }; + let mode = meta.permissions().mode(); + if mode & 0o100 != 0 { + return; + } + // Run for whoever may read it. + let wanted = mode | (mode & 0o444) >> 2; + match std::fs::set_permissions(file, std::fs::Permissions::from_mode(wanted)) { + Ok(()) => info!("allowed {} to run", file.display()), + Err(e) => warn!("could not allow {} to run: {e}", file.display()), + } } /// Close, in the program this one becomes, every file it inherited past /// standard input, output and error. An AppImage stays mounted while any /// process holds the pipe its starter hands down; a restart that kept it /// would keep the old version mounted beside the new one until Weir quits. -/// Weir's own files are already marked to close. -fn let_go_of_the_mount() { +/// Weir's own files are already marked to close. Gives the files it marked, +/// for [`hold_on_to`]. +fn let_go_of_the_mount() -> Vec { let Ok(fds) = std::fs::read_dir("/proc/self/fd") else { - return; + return Vec::new(); }; let fds: Vec = fds .filter_map(|e| e.ok()?.file_name().to_str()?.parse().ok()) .filter(|fd| *fd > 2) .collect(); + let mut marked = Vec::new(); for fd in fds { // SAFETY: setting a flag on a descriptor number; one already // closed (such as the one read_dir used) just fails. unsafe { let flags = libc::fcntl(fd, libc::F_GETFD); - if flags >= 0 { + if flags >= 0 && flags & libc::FD_CLOEXEC == 0 { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC); + marked.push(fd); + } + } + } + marked +} + +/// Undo [`let_go_of_the_mount`], for a restart from the mount after all. +fn hold_on_to(fds: &[i32]) { + for &fd in fds { + // SAFETY: as above. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFD); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC); } } } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 82ff789..c03cdd7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -403,8 +403,12 @@ passes them on too. Then: it names is gone (keeping start at login on, now with the installed unit), and when start at login is turned on there. * `restart` starts the AppImage file, not the mounted copy, so a newer - AppImage put in its place is what runs. It marks every inherited file - to close first, letting go of the old mount. + AppImage put in its place is what runs. It allows the file to run (a + download never is) and marks every inherited file to close first, + letting go of the old mount. If the file still cannot be started, it + holds on to the mount and starts the mounted copy again. +* The tray gives the folder of icons inside the AppImage as its icon + theme path, where the panel finds the one-color icon. * The window offers once to write a menu entry (`app/appimage.rs`: `~/.local/share/applications/weir.desktop`, marked `X-Weir-AppImage`, and the icons), which the desktop portal's hotkeys need off Plasma. It diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 775c89f..3b2afe1 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1052,9 +1052,11 @@ Weir-*.AppImage` in a terminal), and open it. * **weirctl** runs as `./Weir-*.AppImage weirctl …`, or as plain `weirctl` once you make a link to the AppImage. Preferences › General › AppImage shows the command to copy. Make the link again if you move the AppImage. -* **Updating.** Download the new AppImage and put it in place of the old - one, or let an AppImage manager such as Gear Lever or AppImageUpdate do - it. Then choose **Restart Weir** in the tray menu. +* **Updating.** Download the new AppImage. Either put it in place of the + old one, under the old name, and choose **Restart Weir** in the tray + menu (Weir allows the new file to run); or choose **Quit Weir** there, + open the new one, and delete the old one. An AppImage manager such as + Gear Lever or AppImageUpdate can do it for you. * **What it needs:** PipeWire 0.3.77 or newer with WirePlumber, and a system from 2024 or newer, such as Ubuntu 24.04, Fedora 40, Debian 13 or Linux Mint 22. It needs FUSE to open, which desktops have. From 587a5a846f4a47b650a4da614a07aa560690d7c8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 10 Nov 2026 18:53:58 +0000 Subject: [PATCH 08/24] Pin the AppImage tools by checksum The runtime at the front of every Weir AppImage, and appimagetool which packs it, are downloaded from the AppImage project's releases. The job now checks both against checksums kept in the workflow, so a file changed upstream stops the build instead of going into Weir's releases. The checksums are those of the files this pull request's builds used (1.9.1 and 20251108). A new version means new checksums, which the failing check prints. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- .github/workflows/packages.yml | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 2fa03c4..bc4f041 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -167,15 +167,21 @@ jobs: - name: AppImage runner: ubuntu-24.04 arch: x86_64 + appimagetool_sha256: ed4ce84f0d9caff66f50bcca6ff6f35aae54ce8135408b3fa33abfc3cb384eb0 + runtime_sha256: 2fca8b443c92510f1483a883f60061ad09b46b978b2631c807cd873a47ec260d - name: AppImage, arm64 runner: ubuntu-24.04-arm arch: aarch64 + appimagetool_sha256: f0837e7448a0c1e4e650a93bb3e85802546e60654ef287576f46c71c126a9158 + runtime_sha256: 00cbdfcf917cc6c0ff6d3347d59e0ca1f7f45a6df1a428a0d6d8a78664d87444 # Built on the oldest system it is for, like the .deb: it needs the C # library of Ubuntu 24.04 or newer, and PipeWire's from the system. runs-on: ${{ matrix.runner }} env: - # Pinned: appimagetool packs the AppImage, and the runtime is the - # small program at its front that mounts and starts it. + # Pinned, by version here and by checksum in the matrix: + # appimagetool packs the AppImage, and the runtime is the small + # program at its front that mounts and starts it, in every copy + # people download. A version changes both checksums. APPIMAGETOOL_VERSION: "1.9.1" RUNTIME_VERSION: "20251108" steps: @@ -204,6 +210,10 @@ jobs: "$base/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-${{ matrix.arch }}.AppImage" curl -fsSL -o tools/runtime \ "$base/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-${{ matrix.arch }}" + sha256sum -c < Date: Tue, 10 Nov 2026 18:54:15 +0000 Subject: [PATCH 09/24] Print the AppImage tools' checksums when they do not match So a new version's checksums can be read from the failed run. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- .github/workflows/packages.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index bc4f041..70c8721 100644 --- a/.github/workflows/packages.yml +++ b/.github/workflows/packages.yml @@ -210,7 +210,7 @@ jobs: "$base/appimagetool/releases/download/$APPIMAGETOOL_VERSION/appimagetool-${{ matrix.arch }}.AppImage" curl -fsSL -o tools/runtime \ "$base/type2-runtime/releases/download/$RUNTIME_VERSION/runtime-${{ matrix.arch }}" - sha256sum -c < Date: Wed, 11 Nov 2026 08:22:54 +0000 Subject: [PATCH 10/24] Keep the tray icon on GNOME after Restart Weir A restart keeps the process id, so the tray came back under the very bus name the old icon had just given up, about 60 ms later. GNOME's AppIndicator extension takes that as the old item re-registering and resets it, and the icon was gone until Weir was started afresh (the mixer and its window kept working). Seen on Ubuntu 26.04, and reproduced here on a headless GNOME Shell 46 with the extension: the panel had no Weir indicator after `weirctl restart`. After a restart (the restarted program has WEIR_RESTARTED set; the window Weir starts does not) the tray now registers under its connection's unique name, which is new each time. The extension treats it as a new icon and drops the old one. Plasma's, Cinnamon's and COSMIC's watchers take a unique name like any other (read in their sources); on GNOME the icon is there after each restart. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/main.rs | 21 +++++++++++++++++++-- docs/ARCHITECTURE.md | 7 ++++++- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 6369da2..fa042d6 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -71,6 +71,11 @@ const OPEN_WINDOW_ENV: &str = "WEIR_OPEN_WINDOW"; /// `--safe` does. const SAFE_MODE_ENV: &str = "WEIR_SAFE_MODE"; +/// Set on the program a restart starts. It keeps the process id, so the +/// tray's usual bus name would be the one the old icon just gave up, and +/// GNOME's tray, seeing it come back at once, lost the icon altogether. +const RESTARTED_ENV: &str = "WEIR_RESTARTED"; + /// The longest the daemon may take to shut down before it is ended by force. /// Comfortably above the sum of the limits of the individual steps. const SHUTDOWN_LIMIT: Duration = Duration::from_secs(5); @@ -369,7 +374,14 @@ async fn start_tray( (tray.strips, tray.buses) = (mixer.strips.len(), mixer.buses.len()); tray.can_record = controller.can_record(); tray.safe_mode = controller.safe_mode().on; - match tray.assume_sni_available(true).spawn().await { + // After a restart, the connection's own unique name, which is new. + let restarted = std::env::var_os(RESTARTED_ENV).is_some(); + match tray + .assume_sni_available(true) + .disable_dbus_name(restarted) + .spawn() + .await + { Ok(h) => { info!("tray icon started"); Some(h) @@ -662,7 +674,11 @@ fn restart_command( ) -> std::process::Command { let mut command = std::process::Command::new(exe); // A crash on purpose is for the run it was asked of. - command.args(first).args(rest).env_remove(CRASH_TEST_ENV); + command + .args(first) + .args(rest) + .env_remove(CRASH_TEST_ENV) + .env(RESTARTED_ENV, "1"); for (env, on) in [(OPEN_WINDOW_ENV, window), (SAFE_MODE_ENV, safe)] { if on { command.env(env, "1"); @@ -816,6 +832,7 @@ fn spawn_window(socket: &Path, opening: Opening, env: &[(String, String)]) -> bo } } cmd.envs(env.iter().map(|(key, value)| (key, value))); + cmd.env_remove(RESTARTED_ENV); match cmd .stdin(std::process::Stdio::null()) .kill_on_drop(false) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index c03cdd7..efd5089 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -283,7 +283,12 @@ and turns app rules' ids into names. **The tray icon** is in the daemon, because the daemon is what keeps running when the window is closed. It talks to the rest through a channel, -since its callbacks run on their own task. +since its callbacks run on their own task. Its bus name is normally +`org.kde.StatusNotifierItem-PID-1`, but a restart keeps the process id, and +GNOME's tray extension lost the icon when that name came back at once. So +after a restart (`WEIR_RESTARTED`) the tray registers under its +connection's unique name, which Plasma, GNOME, Cinnamon and COSMIC all +accept. **Hotkeys** (`hotkeys/`) are steps, each an ordinary request, so a hotkey can do whatever the protocol can and needs nothing of its own in the From 56bf8b78bca0f983d074c58342f86da5a4e652f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:22:54 +0000 Subject: [PATCH 11/24] Show the one-color tray icon from an AppImage on GNOME MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tray names the AppImage's usr/share/icons as its icon theme path. GNOME's AppIndicator extension then looks the icon up in a theme of that one folder, which finds hicolor's folders only through a theme index the AppImage does not have, or files right in the folder. So it found nothing, and the icon showed as "…" (Ubuntu 26.04). The AppImage now carries weir-symbolic.svg at the top of that folder too. Checked on a headless GNOME Shell 46 with the extension: the lookup failed with the old layout and finds the new file, and after a restart the panel shows the one-color icon. Switching styles while Weir runs still leaves the color picture under the new icon until a restart: the extension does not clear a picture when an icon gets a name. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- Makefile | 3 +++ docs/ARCHITECTURE.md | 4 +++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 920affa..da181ef 100644 --- a/Makefile +++ b/Makefile @@ -300,6 +300,9 @@ appimage-dir: cp $(APPIMAGE_ROOT)/usr/share/applications/weir.desktop $(APPIMAGE_ROOT)/weir.desktop cp packaging/weir.svg $(APPIMAGE_ROOT)/weir.svg cp crates/gui/assets/weir.png $(APPIMAGE_ROOT)/.DirIcon + @# The tray names usr/share/icons for the one-color icon. GNOME's tray + @# looks only at the files right in that folder, Plasma in hicolor. + cp packaging/weir-symbolic.svg $(APPIMAGE_ROOT)/usr/share/icons/weir-symbolic.svg @echo "" @echo "Laid out in $(APPIMAGE_ROOT)" diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index efd5089..049ce80 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -413,7 +413,9 @@ passes them on too. Then: letting go of the old mount. If the file still cannot be started, it holds on to the mount and starts the mounted copy again. * The tray gives the folder of icons inside the AppImage as its icon - theme path, where the panel finds the one-color icon. + theme path, where the panel finds the one-color icon. Plasma looks in + its `hicolor` folders; GNOME's tray extension only at files right in it + (it has no theme index), so `weir-symbolic.svg` is there as well. * The window offers once to write a menu entry (`app/appimage.rs`: `~/.local/share/applications/weir.desktop`, marked `X-Weir-AppImage`, and the icons), which the desktop portal's hotkeys need off Plasma. It From a7ec2ccf975667738f3b4f746c0cc48efff8f9db Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:22:54 +0000 Subject: [PATCH 12/24] Show hotkey notifications on GNOME Off Plasma, a hotkey says what it did in a notification marked transient and low urgency. GNOME Shell never shows a banner for a low urgency notification (messageTray.js: _onNotificationRequestBanner), and a transient one is not kept in its list, so on GNOME nothing was ever seen (Ubuntu 26.04). Normal urgency shows it; Cinnamon already showed them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/hotkeys/popup.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/daemon/src/hotkeys/popup.rs b/crates/daemon/src/hotkeys/popup.rs index d179f28..f9cd37c 100644 --- a/crates/daemon/src/hotkeys/popup.rs +++ b/crates/daemon/src/hotkeys/popup.rs @@ -364,7 +364,9 @@ async fn notify(connection: &zbus::Connection, popup: &Popup, replaces: u32) -> }; let mut hints: HashMap<&str, V> = HashMap::new(); hints.insert("transient", V::from(true)); - hints.insert("urgency", V::from(0u8)); + // Normal, not low: GNOME never pops up a low one, and a transient one + // is not kept in its list either, so it was never seen at all. + hints.insert("urgency", V::from(1u8)); hints.insert("desktop-entry", V::from("weir")); // Notification services that know it show one in place of the last. hints.insert("x-canonical-private-synchronous", V::from("weir")); From 6e102a2eeda5c00c0aed88352707441c69d60c92 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:22:54 +0000 Subject: [PATCH 13/24] Advise quitting Weir before putting a new AppImage in place While an AppImage runs, the program at its front keeps running to serve its files, so the file cannot be written over: Linux Mint's file manager said it was in use. Updating in place and then choosing Restart Weir only works where the file is replaced rather than rewritten, as AppImage managers do. The guide and the update notice now say: quit Weir, allow the new file to run, open it, and delete the old one; or let an AppImage manager do it and choose Restart Weir. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/diagnostics.rs | 14 +++++++++----- docs/USER_GUIDE.md | 12 +++++++----- 2 files changed, 16 insertions(+), 10 deletions(-) diff --git a/crates/daemon/src/diagnostics.rs b/crates/daemon/src/diagnostics.rs index 3248893..c85bc58 100644 --- a/crates/daemon/src/diagnostics.rs +++ b/crates/daemon/src/diagnostics.rs @@ -209,10 +209,13 @@ impl Install { } Install::BuildFolder(_) => "Get the new source and build it again", Install::AppImage(file) => { + // Quit first: while it runs, an AppImage cannot be written + // over (file managers say it is in use). return format!( - "Download the new AppImage for your computer from the release page and put \ - it in place of this one ({}), or let Gear Lever or AppImageUpdate update \ - it. Then choose Restart Weir in Weir's tray menu", + "Download the new AppImage for your computer from the release page. Choose \ + Quit Weir in Weir's tray menu, allow the new file to run and open it, then \ + delete this one ({}). Or let Gear Lever or AppImageUpdate update it, then \ + choose Restart Weir in Weir's tray menu", file.display() ); } @@ -384,9 +387,10 @@ mod tests { "AppImage, /home/sam/Apps/Weir-1.5.0-x86_64.AppImage" ); let how = appimage.how_to_update(); - assert!(how.contains("in place of this one (/home/sam/Apps/Weir-1.5.0-x86_64.AppImage)")); + assert!(how.contains("Choose Quit Weir"), "{how}"); + assert!(how.contains("delete this one (/home/sam/Apps/Weir-1.5.0-x86_64.AppImage)")); assert!( - how.ends_with("Then choose Restart Weir in Weir's tray menu"), + how.ends_with("then choose Restart Weir in Weir's tray menu"), "{how}" ); } diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 3b2afe1..81704c0 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1052,11 +1052,13 @@ Weir-*.AppImage` in a terminal), and open it. * **weirctl** runs as `./Weir-*.AppImage weirctl …`, or as plain `weirctl` once you make a link to the AppImage. Preferences › General › AppImage shows the command to copy. Make the link again if you move the AppImage. -* **Updating.** Download the new AppImage. Either put it in place of the - old one, under the old name, and choose **Restart Weir** in the tray - menu (Weir allows the new file to run); or choose **Quit Weir** there, - open the new one, and delete the old one. An AppImage manager such as - Gear Lever or AppImageUpdate can do it for you. +* **Updating.** Download the new AppImage and choose **Quit Weir** in the + tray menu. Put the new file where you keep the old one, allow it to run, + open it, and then delete the old one: Weir points its menu entry and + start at login at the new file. (While Weir runs, a file manager cannot + replace its AppImage; it says the file is in use.) An AppImage manager + such as Gear Lever or AppImageUpdate can update it for you instead; then + choose **Restart Weir** in the tray menu. * **What it needs:** PipeWire 0.3.77 or newer with WirePlumber, and a system from 2024 or newer, such as Ubuntu 24.04, Fedora 40, Debian 13 or Linux Mint 22. It needs FUSE to open, which desktops have. From f31a1bb34942b2a36c5f2d19a2dfe8d96fe84ce7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:22:54 +0000 Subject: [PATCH 14/24] Explain hotkeys where the desktop only runs commands On COSMIC (Pop!_OS 24.04) Weir cannot set keys itself, and a shortcut in the desktop's settings runs one command per press, so a hotkey that acts while held only works there as a tap. Say so, name COSMIC, and point AppImage users to linking weirctl first. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- docs/USER_GUIDE.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 81704c0..4d60646 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -828,9 +828,13 @@ lists them. A switched-off hotkey keeps its entry and keys there, and the desktop keeps those keys for it meanwhile. On an X11 desktop, Weir watches the keys itself, and a key another program -already uses is reported in the list. Anywhere else, hotkeys work only by -name: add a shortcut in your desktop's keyboard settings that runs -`weirctl hotkey run "NAME"`. +already uses is reported in the list. Anywhere else, such as COSMIC on +Pop!_OS, hotkeys work only by name: add a shortcut in your desktop's +keyboard settings that runs `weirctl hotkey run "NAME"`. Such a shortcut +runs once per press, so a hotkey that acts while its keys are held, such +as push to talk, works there only as a tap; use one that switches on one +press and off the next instead. From an AppImage, link `weirctl` first +(see [Running Weir as an AppImage](#running-weir-as-an-appimage)). ## Export and import From 458ddadeaf9e1a13d9e3a89b180e7718401a159a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 08:23:25 +0000 Subject: [PATCH 15/24] Say the ARM AppImage has not been tried on ARM hardware yet As the ARM packages: built and tested on GitHub's ARM machines only. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- README.md | 5 +++-- docs/USER_GUIDE.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 18c6004..e7a00d7 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,9 @@ on Debian 13) or newer. **On any other distribution**, or to try Weir without installing it, take the AppImage: `Weir-…-x86_64.AppImage`, or `Weir-…-aarch64.AppImage` on -ARM. Allow it to run (`chmod +x Weir-*.AppImage`) and open it. It needs -PipeWire with WirePlumber and a system from 2024 or newer; the +ARM (like the ARM packages, not yet tried on an ARM computer). Allow it +to run (`chmod +x Weir-*.AppImage`) and open it. It needs PipeWire with +WirePlumber and a system from 2024 or newer; the [user guide](docs/USER_GUIDE.md#running-weir-as-an-appimage) has the rest. GitHub builds the packages from the code here and signs a record of it. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 4d60646..8af271c 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1038,7 +1038,7 @@ asks GitHub now. The file ending in `.AppImage` on the release page is Weir in one file, for any distribution with PipeWire: nothing is installed. Take the one for your computer (`x86_64` for most PCs, `aarch64` for 64-bit ARM ones such as -a Raspberry Pi 5), allow it to run once (in the file manager, +a Raspberry Pi 5, not yet tried on one), allow it to run once (in the file manager, **Properties › Permissions › Allow executing as program**, or `chmod +x Weir-*.AppImage` in a terminal), and open it. From a91baf2b387a7caacc56cad26db8ab85d0007803 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:04:35 +0000 Subject: [PATCH 16/24] Bring the mixer back after Restart Weir Testers found that Restart Weir brought the tray icon back but never the mixer, on every desktop, and that Weir then ignored "When Weir starts". The window closes for a restart, and the tray asked for no window afterwards. A service the window started also carries --no-window, which the restart kept, so the new one never opened a window whatever the setting said. Only services started at login, without that flag, came back with their window. Now a window that was open is always asked back (tray, weirctl restart and SIGUSR1 alike), and the restart drops --no-window, since the window that passed it has closed. With no window open, the new service opens one as "When Weir starts" says, as at any other start. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/main.rs | 20 +++++++++++++++----- crates/daemon/src/tray.rs | 3 ++- docs/API.md | 9 +++++---- docs/ARCHITECTURE.md | 5 ++++- docs/CLI.md | 3 ++- docs/USER_GUIDE.md | 5 +++-- 6 files changed, 31 insertions(+), 14 deletions(-) diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index fa042d6..0ff54a2 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -467,7 +467,8 @@ async fn run( } _ = signals.usr1.recv() => { info!("restarting on SIGUSR1"); - return Outcome::Restart { window: false, safe: false }; + let window = controller.window_attached(); + return Outcome::Restart { window, safe: false }; } _ = &mut serve => { error!("server task ended"); @@ -524,6 +525,9 @@ async fn run( Some(TrayCommand::SetStartup(mode)) => set_startup(controller, mode), Some(TrayCommand::Restart { window, safe }) => { info!("restarting{}", if safe { " in safe mode" } else { "" }); + // The window closes for the restart, so an open one + // comes back with it. + let window = window || controller.window_attached(); return Outcome::Restart { window, safe }; } Some(TrayCommand::LeaveSafeMode) => controller.leave_safe_mode(), @@ -638,12 +642,18 @@ async fn shut_down( /// Replace this process with a fresh copy of itself, with the same /// arguments. Replacing rather than forking means whatever started us (a /// shell, or systemd) keeps supervising the same pid. With `window`, the -/// new one opens the mixer window whatever its startup setting says; with -/// `safe`, it starts in safe mode, and without, it does not, even if this -/// one was started with `--safe`. Returns only if that fails. +/// new one opens the mixer window whatever its startup setting says, and +/// without, as that setting says; with `safe`, it starts in safe mode, and +/// without, it does not, even if this one was started with `--safe`. +/// Returns only if that fails. fn restart(window: bool, safe: bool) -> Result<()> { use std::os::unix::process::CommandExt; - let rest: Vec = std::env::args().skip(1).filter(|a| a != "--safe").collect(); + // `--no-window` comes from a window that started the service, so as + // not to get a second one. That window has closed for the restart. + let rest: Vec = std::env::args() + .skip(1) + .filter(|a| a != "--safe" && a != "--no-window") + .collect(); // From an AppImage, start the AppImage file again rather than the copy // mounted from it, so that a newer AppImage put in its place is what // runs, and let go of the old mount. diff --git a/crates/daemon/src/tray.rs b/crates/daemon/src/tray.rs index f605e06..59027be 100644 --- a/crates/daemon/src/tray.rs +++ b/crates/daemon/src/tray.rs @@ -27,7 +27,8 @@ pub enum TrayCommand { /// Stop recording. StopRecording, /// Stop everything and start again: opening the mixer window then - /// (`window`), in safe mode (`safe`). + /// (`window`; one that is open comes back anyway), in safe mode + /// (`safe`). Restart { window: bool, safe: bool }, /// Bring back everything safe mode holds back, and leave it. LeaveSafeMode, diff --git a/docs/API.md b/docs/API.md index c2ae0d3..e954f04 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2260,13 +2260,14 @@ await mixer.call("show_window"); Restart the daemon, as the tray's **Restart Weir** does: windows get `quit`, and the daemon saves, stops, and starts again from its saved -settings. Restarting is the safest way to get everything working again -after part of the daemon stopped because of a bug (see `crashed` under -[Notifications](#notifications)). +settings. A window that was open is opened again once it is back; with +none, the daemon opens one as `startup` says. Restarting is the safest +way to get everything working again after part of the daemon stopped +because of a bug (see `crashed` under [Notifications](#notifications)). | Parameter | Type | | |---|---|---| -| `window` | boolean, *optional* | Open the mixer window once the daemon is back, whatever `startup` says. | +| `window` | boolean, *optional* | Open the mixer window once the daemon is back, whatever `startup` says, even if none was open. | | `safe` | boolean, *optional* | Start again in [safe mode](#safe-mode), with everything held back that can be. Without it, the daemon starts normally, even if it is in safe mode now. | Returns `null`, then the daemon restarts: the connection closes, and the diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 049ce80..59130fe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -516,7 +516,10 @@ mixer's state may be half updated. The daemon also waits on the windows it starts: one that ends with 101, a panic, gets a desktop notification saying so. `restart` (and the window's **Restart Weir**) restarts the daemon through its main loop, as the tray does, and can ask the new one -to open the window. `WEIR_CRASH_TEST` makes Weir crash on purpose: +to open the window. The window closes for a restart, so an open one is +always asked back; a restart also drops `--no-window` (the window that +started the service passes it), so with no window open the new one opens +it as **When Weir starts** says. `WEIR_CRASH_TEST` makes Weir crash on purpose: `service`, `part`, `audio` or `request` in the daemon, `window` or `window-part` in the window. diff --git a/docs/CLI.md b/docs/CLI.md index 3784dec..c55fca5 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -593,7 +593,8 @@ weirctl show Restarts Weir's service, as the tray's **Restart Weir** does: the safest way to get everything working again after part of Weir stopped because of -a bug. With `--window`, the mixer window opens once it is back. With +a bug. The mixer window comes back if it was open, and otherwise opens as +**When Weir starts** says; with `--window`, it opens either way. With `--safe`, it starts in [safe mode](#safe-mode); without, it starts normally, even if it is in safe mode now. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 8af271c..7c396f4 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -1084,8 +1084,9 @@ sound keeps flowing. Opening the window again picks up where it was. if needed. Its menu has **Show the mixer**, **Start recording** (once something is ticked to record) or, while recording, how long it has been and **Stop recording**, **Open the recorder**, **When Weir starts**, -**Restart Weir** and **Quit Weir**. While recording, the icon has a red -dot. GNOME shows tray icons only with the **AppIndicator and +**Restart Weir** and **Quit Weir**. **Restart Weir** brings the mixer back +if it was open, and otherwise does as **When Weir starts** says. While +recording, the icon has a red dot. GNOME shows tray icons only with the **AppIndicator and KStatusNotifierItem Support** extension, which Ubuntu has already; on other GNOME systems, install it from your software center or extensions.gnome.org. From 8aeec7b5fe72a78afb922290ac3c14b2b280e2ff Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:05:02 +0000 Subject: [PATCH 17/24] Show the tray icon afresh when its look changes On Ubuntu, switching the tray icon to one color drew the one-color mark over the color picture until Weir restarted. GNOME's AppIndicator extension, told that an icon shown as a picture now has a name, sets the named icon but never clears the picture. A new icon, as a restart gives, is drawn cleanly. So a change of look now takes the tray icon down and puts up a new one, with a new bus name. The tray's handle and its command sender live together in ShownTray for that. Tested in a headless GNOME Shell 46 with the extension: color, one color, color again, each clean. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/main.rs | 57 ++++++++++++++++++++++++++++++++------- docs/ARCHITECTURE.md | 3 ++- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 0ff54a2..92c6a1f 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -157,11 +157,13 @@ async fn main() -> Result<()> { // Lets a client ask for the window over the control socket, not just the // tray, so a hotkey or a stream deck can raise the mixer. controller.set_window_sender(tray_tx.clone()); - let tray = if !args.no_tray && controller.settings().tray { - start_tray(&controller, tray_tx).await - } else { - None + let mut tray = ShownTray { + handle: None, + tx: tray_tx.clone(), }; + if !args.no_tray && controller.settings().tray { + tray.handle = start_tray(&controller, tray_tx).await; + } if std::env::var_os(OPEN_WINDOW_ENV).is_some() { open_window_when_ready(&controller, socket_path.clone(), false); @@ -191,7 +193,7 @@ async fn main() -> Result<()> { &mut signals, serve, tray_rx, - tray.as_ref(), + &mut tray, &crashed, ) .await; @@ -200,7 +202,7 @@ async fn main() -> Result<()> { // anything that still manages to hang. The session manager waits for // this process before it can log out or power off. start_shutdown_watchdog(SHUTDOWN_LIMIT); - shut_down(&controller, tray, &socket_path).await; + shut_down(&controller, tray.handle, &socket_path).await; // A stop that arrived while shutting down for a restart cancels the // restart: coming back now would only hold up whoever asked us to stop. @@ -393,6 +395,32 @@ async fn start_tray( } } +/// The tray icon, if one is shown, and what its menu sends commands with. +struct ShownTray { + handle: Option>, + tx: UnboundedSender, +} + +impl ShownTray { + /// Put up a new tray icon in place of the old one, for a new look. + /// + /// GNOME's AppIndicator extension, told that an icon shown as a + /// picture now has a name, draws the named one over the picture rather + /// than instead of it, until the icon is shown afresh. A new icon is + /// that, and does no harm elsewhere. + async fn renew(&mut self, controller: &Controller) { + if let Some(old) = self.handle.take() { + if tokio::time::timeout(Duration::from_secs(1), old.shutdown()) + .await + .is_err() + { + warn!("the old tray icon did not go away in time"); + } + } + self.handle = start_tray(controller, self.tx.clone()).await; + } +} + /// The signals that stop the daemon, and the one that restarts it. /// /// SIGTERM, SIGINT and SIGHUP all mean "stop". SIGHUP in particular is what @@ -438,7 +466,7 @@ async fn run( signals: &mut StopSignals, mut serve: tokio::task::JoinHandle<()>, mut tray_rx: UnboundedReceiver, - tray: Option<&ksni::Handle>, + tray: &mut ShownTray, crashed: &tokio::sync::Notify, ) -> Outcome { // Keeps the tray's icon, menu and tooltip in step with the mixer. @@ -538,11 +566,20 @@ async fn run( None => return Outcome::Exit, }, - note = notes.recv(), if notes_open && tray.is_some() => { - let Some(handle) = tray else { continue }; + note = notes.recv(), if notes_open && tray.handle.is_some() => { + let Some(handle) = tray.handle.as_ref() else { continue }; match note { Ok(Notification::SettingsChanged(s)) => { - handle.update(|t| (t.startup, t.icon) = (s.startup, s.tray_icon)).await; + let new_look = handle + .update(|t| { + let new_look = t.icon != s.tray_icon; + (t.startup, t.icon) = (s.startup, s.tray_icon); + new_look + }) + .await; + if new_look == Some(true) { + tray.renew(controller).await; + } } Ok(Notification::EngineChanged(e)) => { handle.update(|t| t.engine_state = e.state).await; diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 59130fe..1c93dd5 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -288,7 +288,8 @@ since its callbacks run on their own task. Its bus name is normally GNOME's tray extension lost the icon when that name came back at once. So after a restart (`WEIR_RESTARTED`) the tray registers under its connection's unique name, which Plasma, GNOME, Cinnamon and COSMIC all -accept. +accept. A new icon look puts up a new icon (`ShownTray::renew`), since +GNOME's extension draws a named icon over the picture it showed before. **Hotkeys** (`hotkeys/`) are steps, each an ordinary request, so a hotkey can do whatever the protocol can and needs nothing of its own in the From 384eb0eb8c9919d0bfe34600692709e39ff383b6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:05:09 +0000 Subject: [PATCH 18/24] Give the tray menu icons COSMIC can draw On Pop!_OS, the tray menu showed a white square by Show the mixer and a white circle by Restart Weir. COSMIC draws every named menu icon in one color, so a color icon comes out as its own outline filled white: Weir's rounded square, and the theme's round refresh icon. Show the mixer now carries Weir's icon as a 32 px PNG made from the window's, which every desktop draws as it is, also from an AppImage whose icons are in no theme. The other entries name `-symbolic` icons; where a theme lacks one, the desktop falls back to the plain name. Checked in GNOME Shell's menu; COSMIC's source draws a picture as is. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/tray.rs | 57 ++++++++++++++++++++++++++++++++++----- docs/ARCHITECTURE.md | 2 ++ 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/crates/daemon/src/tray.rs b/crates/daemon/src/tray.rs index 59027be..d0e4506 100644 --- a/crates/daemon/src/tray.rs +++ b/crates/daemon/src/tray.rs @@ -179,7 +179,7 @@ impl Tray for MixerTray { vec![ StandardItem { label: "Show the mixer".into(), - icon_name: "weir".into(), + icon_data: menu_icon().to_vec(), activate: Box::new(|this: &mut Self| this.send(TrayCommand::Show)), ..Default::default() } @@ -214,7 +214,7 @@ impl Tray for MixerTray { MenuItem::Separator, StandardItem { label: "Restart Weir".into(), - icon_name: "view-refresh".into(), + icon_name: "view-refresh-symbolic".into(), activate: Box::new(|this: &mut Self| { this.send(TrayCommand::Restart { window: false, @@ -227,7 +227,7 @@ impl Tray for MixerTray { self.safe_mode_item(), StandardItem { label: "Quit Weir".into(), - icon_name: "application-exit".into(), + icon_name: "application-exit-symbolic".into(), activate: Box::new(|this: &mut Self| this.send(TrayCommand::Exit)), ..Default::default() } @@ -270,14 +270,14 @@ impl MixerTray { Some(r) => vec![ StandardItem { label: recording_words(r), - icon_name: "media-record".into(), + icon_name: "media-record-symbolic".into(), enabled: false, ..Default::default() } .into(), StandardItem { label: "Stop recording".into(), - icon_name: "media-playback-stop".into(), + icon_name: "media-playback-stop-symbolic".into(), activate: Box::new(|this: &mut Self| this.send(TrayCommand::StopRecording)), ..Default::default() } @@ -285,7 +285,7 @@ impl MixerTray { ], None if self.can_record => vec![StandardItem { label: "Start recording".into(), - icon_name: "media-record".into(), + icon_name: "media-record-symbolic".into(), activate: Box::new(|this: &mut Self| this.send(TrayCommand::StartRecording)), ..Default::default() } @@ -352,6 +352,43 @@ fn appimage_icons() -> Option { .clone() } +/// Weir's color icon for the menu, as a 32 px PNG; empty if it cannot be +/// made. +/// +/// Menus get pictures, not names: COSMIC draws every named menu icon in +/// one color, so a color icon by name comes out as a white shape (Weir's +/// as a white square), and an AppImage's icons are in no theme. The other +/// entries' icons are named `-symbolic`, as one color is how COSMIC draws +/// them, and where a theme lacks that name the desktop falls back to the +/// plain one. +fn menu_icon() -> &'static [u8] { + static PNG: OnceLock> = OnceLock::new(); + PNG.get_or_init(|| { + let Some((side, rgba)) = decode_square(include_bytes!("../../gui/assets/weir.png")) else { + return Vec::new(); + }; + let icon = shrink(&rgba, side, 32); + // ARGB back to RGBA. + let pixels: Vec = icon + .data + .chunks_exact(4) + .flat_map(|p| [p[1], p[2], p[3], p[0]]) + .collect(); + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, 32, 32); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let written = encoder.write_header().and_then(|mut writer| { + writer.write_image_data(&pixels)?; + writer.finish() + }); + if written.is_err() { + return Vec::new(); + } + out + }) +} + /// Weir's color icon in the sizes panels use, made once from the 256 px /// one the window is built with; empty if that cannot be read. fn color_pixmaps() -> &'static [Icon] { @@ -429,6 +466,14 @@ fn shrink(rgba: &[u8], side: usize, size: i32) -> Icon { mod tests { use super::*; + #[test] + fn the_menu_gets_weirs_icon_as_a_small_png() { + let (side, rgba) = decode_square(menu_icon()).expect("a square RGBA PNG"); + assert_eq!(side, 32); + let at = |x: usize, y: usize| &rgba[(y * 32 + x) * 4..][..4]; + assert!(at(16, 16)[3] > 200, "solid in the middle"); + } + #[test] fn the_recording_dot_fills_its_square_and_the_corners_are_clear() { let icon = red_dot(16); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1c93dd5..f14f3b4 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -290,6 +290,8 @@ after a restart (`WEIR_RESTARTED`) the tray registers under its connection's unique name, which Plasma, GNOME, Cinnamon and COSMIC all accept. A new icon look puts up a new icon (`ShownTray::renew`), since GNOME's extension draws a named icon over the picture it showed before. +Menu entries carry pictures or `-symbolic` names: COSMIC draws every named +menu icon in one color, so a color icon becomes a white shape. **Hotkeys** (`hotkeys/`) are steps, each an ordinary request, so a hotkey can do whatever the protocol can and needs nothing of its own in the From dfd1ef6579e97e118c67ad9e6e7e2531d92e4ce1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 16:05:09 +0000 Subject: [PATCH 19/24] Say off Plasma that both popup choices show a notification On Ubuntu, "The desktop's popup" and "A notification" did the same, which looked like a fault. Only Plasma lets other programs use its popup; elsewhere Weir shows a notification for either. That was said on hover only; now a line under the choices says it on other desktops. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/gui/src/app/settings.rs | 8 ++++++++ crates/gui/src/hotkeys/mod.rs | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/gui/src/app/settings.rs b/crates/gui/src/app/settings.rs index 52dfa10..84a84a5 100644 --- a/crates/gui/src/app/settings.rs +++ b/crates/gui/src/app/settings.rs @@ -449,6 +449,14 @@ impl App { } } }); + // Otherwise the first two look like the same choice, and are. + if !crate::hotkeys::on_kde() { + explain( + ui, + "This desktop has no such popup for other programs, so Weir shows \ + a notification for either.", + ); + } } /// Where and how loud hotkeys' sounds play. diff --git a/crates/gui/src/hotkeys/mod.rs b/crates/gui/src/hotkeys/mod.rs index ce80b7c..b0fbba1 100644 --- a/crates/gui/src/hotkeys/mod.rs +++ b/crates/gui/src/hotkeys/mod.rs @@ -771,7 +771,7 @@ fn status_line(ui: &mut Ui, keys: &KeysStatus) { } /// Whether the desktop is KDE Plasma, going by the session's variables. -fn on_kde() -> bool { +pub(crate) fn on_kde() -> bool { std::env::var("XDG_CURRENT_DESKTOP").is_ok_and(|d| d.to_ascii_uppercase().contains("KDE")) } From 17a4ff4479e76472b69f25fe037fd69ce0c71e45 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 05:57:51 +0000 Subject: [PATCH 20/24] Draw the recording dot into the tray icon The red dot that marks recording was an overlay picture, which Plasma lays over the icon itself. On a scaled display Plasma copies only the top-left quarter of it, so the user's own PC showed a quarter circle filling the corner, while a VM at 100% looked right. GNOME and Cinnamon draw no overlay at all, so Ubuntu and Mint showed no dot. Now the color icon's pictures have the dot drawn in while recording, which every desktop shows as it is. The one-color icon goes by name, so it gets the overlay by name instead (media-record), which Plasma draws right at any scale. The menu's recording entries get a red dot picture too: their media-record-symbolic icon came out as a plain white or black dot on COSMIC, GNOME and Cinnamon. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/tray.rs | 167 ++++++++++++++++++++++++++++++-------- docs/ARCHITECTURE.md | 7 +- 2 files changed, 137 insertions(+), 37 deletions(-) diff --git a/crates/daemon/src/tray.rs b/crates/daemon/src/tray.rs index d0e4506..30d7647 100644 --- a/crates/daemon/src/tray.rs +++ b/crates/daemon/src/tray.rs @@ -115,8 +115,10 @@ impl Tray for MixerTray { appimage_icons().unwrap_or_default() } + /// The color icon, with a red dot in its corner while recording. fn icon_pixmap(&self) -> Vec { match self.icon { + TrayIcon::Color if self.recording.is_some() => recording_pixmaps().to_vec(), TrayIcon::Color => color_pixmaps().to_vec(), TrayIcon::OneColor => Vec::new(), } @@ -126,13 +128,17 @@ impl Tray for MixerTray { Status::Active } - /// A red dot over the icon while recording. Desktops shrink it into a - /// corner of the icon themselves. - fn overlay_icon_pixmap(&self) -> Vec { - if self.recording.is_none() { - return Vec::new(); + /// While recording, a red dot over an icon that goes by name, which + /// Weir cannot draw on. Only Plasma shows these. It is given by name: + /// one given as a picture Plasma cuts to its top-left quarter on a + /// scaled display. The color icon has the dot drawn in instead + /// ([`Self::icon_pixmap`]), which every desktop shows. + fn overlay_icon_name(&self) -> String { + if self.recording.is_some() && !self.icon_name().is_empty() { + "media-record".into() + } else { + String::new() } - [16, 32].into_iter().map(red_dot).collect() } /// The desktop has no tray right now: at login before the panel is up, @@ -270,7 +276,7 @@ impl MixerTray { Some(r) => vec![ StandardItem { label: recording_words(r), - icon_name: "media-record-symbolic".into(), + icon_data: record_icon().to_vec(), enabled: false, ..Default::default() } @@ -285,7 +291,7 @@ impl MixerTray { ], None if self.can_record => vec![StandardItem { label: "Start recording".into(), - icon_name: "media-record-symbolic".into(), + icon_data: record_icon().to_vec(), activate: Box::new(|this: &mut Self| this.send(TrayCommand::StartRecording)), ..Default::default() } @@ -311,11 +317,11 @@ fn recording_words((seconds, tracks): (f64, usize)) -> String { format!("Recording · {} · {tracks} track{s}", clock(seconds)) } -/// A red dot filling a `size` square, with a thin dark ring so it shows on -/// a red icon or panel too: ARGB in network order, not premultiplied. -fn red_dot(size: i32) -> Icon { +/// A red dot of radius `r` in the middle of a `size` square, with a thin +/// dark ring a pixel further out so it shows on a red icon or panel too: +/// ARGB in network order, not premultiplied. +fn red_dot(size: i32, r: f32) -> Icon { let c = (size as f32 - 1.0) / 2.0; - let r = size as f32 / 2.0 - 1.0; let mut data = Vec::with_capacity((size * size * 4) as usize); for y in 0..size { for x in 0..size { @@ -363,30 +369,87 @@ fn appimage_icons() -> Option { /// plain one. fn menu_icon() -> &'static [u8] { static PNG: OnceLock> = OnceLock::new(); - PNG.get_or_init(|| { - let Some((side, rgba)) = decode_square(include_bytes!("../../gui/assets/weir.png")) else { - return Vec::new(); - }; - let icon = shrink(&rgba, side, 32); - // ARGB back to RGBA. - let pixels: Vec = icon - .data - .chunks_exact(4) - .flat_map(|p| [p[1], p[2], p[3], p[0]]) - .collect(); - let mut out = Vec::new(); - let mut encoder = png::Encoder::new(&mut out, 32, 32); - encoder.set_color(png::ColorType::Rgba); - encoder.set_depth(png::BitDepth::Eight); - let written = encoder.write_header().and_then(|mut writer| { - writer.write_image_data(&pixels)?; - writer.finish() - }); - if written.is_err() { - return Vec::new(); + PNG.get_or_init( + || match decode_square(include_bytes!("../../gui/assets/weir.png")) { + Some((side, rgba)) => png_of(&shrink(&rgba, side, 32)), + None => Vec::new(), + }, + ) +} + +/// A red dot for the menu's recording entries, as a 32 px PNG, the size +/// of a media-record icon. Every desktop's theme has one, but COSMIC, +/// GNOME and Cinnamon draw it in one color. +fn record_icon() -> &'static [u8] { + static PNG: OnceLock> = OnceLock::new(); + PNG.get_or_init(|| png_of(&red_dot(32, 9.0))) +} + +/// `icon` as a PNG; empty if it cannot be made. +fn png_of(icon: &Icon) -> Vec { + // ARGB back to RGBA. + let pixels: Vec = icon + .data + .chunks_exact(4) + .flat_map(|p| [p[1], p[2], p[3], p[0]]) + .collect(); + let (Ok(width), Ok(height)) = (u32::try_from(icon.width), u32::try_from(icon.height)) else { + return Vec::new(); + }; + let mut out = Vec::new(); + let mut encoder = png::Encoder::new(&mut out, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + let written = encoder.write_header().and_then(|mut writer| { + writer.write_image_data(&pixels)?; + writer.finish() + }); + if written.is_err() { + return Vec::new(); + } + out +} + +/// The color icon with a red dot in its bottom right quarter, as Plasma +/// draws a recording program's icon, made once. +fn recording_pixmaps() -> &'static [Icon] { + static ICONS: OnceLock> = OnceLock::new(); + ICONS.get_or_init(|| color_pixmaps().iter().map(with_dot).collect()) +} + +/// `icon` with a red dot laid over its bottom right quarter. +fn with_dot(icon: &Icon) -> Icon { + let size = icon.width; + let side = (size + 1) / 2; + let dot = red_dot(side, side as f32 / 2.0 - 1.0); + let mut out = icon.clone(); + let at = size - side; + for y in 0..side { + for x in 0..side { + let from = ((y * side + x) * 4) as usize; + let to = (((at + y) * size + at + x) * 4) as usize; + let (Some(top), Some(under)) = + (dot.data.get(from..from + 4), out.data.get_mut(to..to + 4)) + else { + continue; + }; + // The dot over the icon, with straight (not premultiplied) + // alpha. + let a = top[0] as f32 / 255.0; + let b = under[0] as f32 / 255.0; + let both = a + b * (1.0 - a); + if both <= 0.0 { + continue; + } + let mut mixed = [(both * 255.0).round() as u8, 0, 0, 0]; + for c in 1..4 { + let v = (top[c] as f32 * a + under[c] as f32 * b * (1.0 - a)) / both; + mixed[c] = v.round() as u8; + } + under.copy_from_slice(&mixed); } - out - }) + } + out } /// Weir's color icon in the sizes panels use, made once from the 256 px @@ -474,9 +537,41 @@ mod tests { assert!(at(16, 16)[3] > 200, "solid in the middle"); } + #[test] + fn recording_draws_a_dot_into_the_bottom_right_of_the_color_icon() { + let plain = color_pixmaps(); + let dotted = recording_pixmaps(); + assert_eq!(plain.len(), dotted.len()); + for (plain, dotted) in plain.iter().zip(dotted) { + let n = plain.width as usize; + assert_eq!((dotted.width, dotted.height), (plain.width, plain.height)); + let at = |icon: &Icon, x: usize, y: usize| icon.data[(y * n + x) * 4..][..4].to_vec(); + assert_eq!( + at(dotted, n / 4, n / 4), + at(plain, n / 4, n / 4), + "{n} px: top left as it was" + ); + let middle = n - n.div_ceil(2) / 2 - 1; + assert_eq!( + at(dotted, middle, middle), + [255, 235, 75, 65], + "{n} px: red in the dot" + ); + } + } + + #[test] + fn the_menus_recording_dot_is_a_red_png_with_room_around_it() { + let (side, rgba) = decode_square(record_icon()).expect("a square RGBA PNG"); + assert_eq!(side, 32); + let at = |x: usize, y: usize| &rgba[(y * 32 + x) * 4..][..4]; + assert_eq!(at(16, 16), [235, 75, 65, 255], "red in the middle"); + assert_eq!(at(2, 16)[3], 0, "clear at the sides"); + } + #[test] fn the_recording_dot_fills_its_square_and_the_corners_are_clear() { - let icon = red_dot(16); + let icon = red_dot(16, 7.0); assert_eq!(icon.data.len(), 16 * 16 * 4); let at = |x: usize, y: usize| &icon.data[(y * 16 + x) * 4..][..4]; assert_eq!(at(0, 0)[0], 0, "clear in the corners"); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index f14f3b4..4c25a8f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -291,7 +291,12 @@ connection's unique name, which Plasma, GNOME, Cinnamon and COSMIC all accept. A new icon look puts up a new icon (`ShownTray::renew`), since GNOME's extension draws a named icon over the picture it showed before. Menu entries carry pictures or `-symbolic` names: COSMIC draws every named -menu icon in one color, so a color icon becomes a white shape. +menu icon in one color, so a color icon becomes a white shape. While +recording, the color icon has the red dot drawn into its pictures: GNOME +and Cinnamon draw no overlay icon, and Plasma cuts an overlay picture to +its top-left quarter on a scaled display. The one-color icon, which goes +by name, gets an overlay by name (`media-record`), which Plasma draws +right. **Hotkeys** (`hotkeys/`) are steps, each an ordinary request, so a hotkey can do whatever the protocol can and needs nothing of its own in the From 6d6beba821c0312004fafd7ce7b76347e8304a06 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 06:11:42 +0000 Subject: [PATCH 21/24] Open the mixer at a new login the service lived through On the user's Nobara, logging out and straight back in left the AppImage's Weir in the tray without its window, although "When Weir starts" said to open it; restarting the computer opened it. The service had kept running through the logout: the systemd user manager outlives a logout when the next login follows within logind's UserStopDelaySec, 10 seconds by default. Then nothing starts at the new login, the login unit included, as the manager never left default.target, and the window had closed with the old desktop. With the RPM, Plasma reopened the window it had open at logout; it cannot for an AppImage, whose old mount is gone. The service now watches logind on the system bus for a new graphical login of its own user, here and not a login screen's. It waits until the new desktop's tray is back, or 10 seconds where there is no tray to wait for, gives the desktop a moment to reopen its own window, and then opens the window as "When Weir starts" says unless one is open. The window gets the user manager's display variables, which the new desktop has handed over by then, since the service's own may name the old desktop. Tested with a stand-in logind and tray watcher: other users' and console logins are passed over, the window opens once the tray is back (minimized when set so), not when one is open or with "Tray only", and after 12 seconds without a tray icon. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/display.rs | 8 ++ crates/daemon/src/lib.rs | 1 + crates/daemon/src/logins.rs | 206 +++++++++++++++++++++++++++++++++++ crates/daemon/src/main.rs | 46 +++++++- crates/daemon/src/tray.rs | 5 + docs/ARCHITECTURE.md | 13 +++ 6 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 crates/daemon/src/logins.rs diff --git a/crates/daemon/src/display.rs b/crates/daemon/src/display.rs index f938fb8..29fb8ff 100644 --- a/crates/daemon/src/display.rs +++ b/crates/daemon/src/display.rs @@ -34,6 +34,14 @@ pub fn window_env() -> Option> { session_from_manager(&manager_env()) } +/// The variables for a window on a desktop that began after the daemon +/// started: the manager's, which that desktop has just handed over, since +/// the daemon's own may name the desktop it started on, gone since. Where +/// the manager has none, as [`window_env`]. +pub fn new_desktop_env() -> Option> { + session_from_manager(&manager_env()).or_else(window_env) +} + /// The systemd user manager's environment, or nothing where there is no /// systemd or it cannot be asked. fn manager_env() -> String { diff --git a/crates/daemon/src/lib.rs b/crates/daemon/src/lib.rs index fe64fa2..86e3f5e 100644 --- a/crates/daemon/src/lib.rs +++ b/crates/daemon/src/lib.rs @@ -21,6 +21,7 @@ pub mod display; pub mod history; pub mod hotkeys; pub mod login; +pub mod logins; pub mod recorder; pub mod seen_apps; pub mod server; diff --git a/crates/daemon/src/logins.rs b/crates/daemon/src/logins.rs new file mode 100644 index 0000000..d992382 --- /dev/null +++ b/crates/daemon/src/logins.rs @@ -0,0 +1,206 @@ +//! Noticing a new login while the service keeps running. +//! +//! Logging out does not always stop the service. The systemd user manager, +//! and everything the session started, outlives a logout when the next +//! login comes within logind's `UserStopDelaySec` (10 seconds unless set +//! otherwise), or while anything of the old session is still closing. Then +//! nothing starts at the next login, start at login included, since the +//! manager never left `default.target`; the tray icon comes back with the +//! new panel, but the window closed with the old desktop and does not. So +//! the service watches logind for a new desktop login of its own user and +//! treats it as a start. + +use ashpd::zbus; +use futures_util::StreamExt; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; +use tokio::sync::Notify; +use tracing::{debug, info}; +use zbus::zvariant::OwnedObjectPath; + +/// How many times the desktop's tray has come back while the service ran, +/// with a way to wait for the next time. +static TRAYS_UP: AtomicU64 = AtomicU64::new(0); +static TRAY_UP: Notify = Notify::const_new(); + +/// The desktop's tray was there and has gone, as at a logout. +static TRAY_GONE: AtomicBool = AtomicBool::new(false); + +/// How long a new login's desktop may take to show its tray before the +/// window opens without waiting any longer. +const TRAY_WAIT: Duration = Duration::from_secs(90); + +/// How long to give a new login's desktop where there is no tray to wait +/// for. +const NO_TRAY_WAIT: Duration = Duration::from_secs(10); + +/// The desktop's tray has come back after it went away, or come up +/// after the service started before it. Called by the tray icon. +pub fn tray_appeared() { + TRAY_GONE.store(false, Ordering::Release); + TRAYS_UP.fetch_add(1, Ordering::AcqRel); + TRAY_UP.notify_waiters(); +} + +/// The desktop's tray was there and has gone: the desktop is ending, or +/// its tray restarting. Called by the tray icon. +pub fn tray_went_away() { + TRAY_GONE.store(true, Ordering::Release); +} + +/// A new desktop login of this user, as logind told of it. +#[derive(Debug, Clone, Copy)] +pub struct Login { + /// How many times the tray had come back when the login began. + trays_up: u64, + /// Whether the tray had gone with the old desktop, so the new one + /// will bring it back. + tray_gone: bool, +} + +impl Login { + /// Wait until this login's desktop is ready for a window: its tray + /// has come up, where the desktop showed one before, or a short while + /// has passed. The desktop hands the systemd user manager its display + /// before it starts its tray, so the manager's then belongs to it. + pub async fn desktop_ready(self) { + // No tray to wait for: Weir shows none, or the desktop has none + // (GNOME without its tray extension). + if !self.tray_gone { + tokio::time::sleep(NO_TRAY_WAIT).await; + return; + } + let deadline = tokio::time::Instant::now() + TRAY_WAIT; + loop { + let up = TRAY_UP.notified(); + tokio::pin!(up); + up.as_mut().enable(); + if TRAYS_UP.load(Ordering::Acquire) != self.trays_up { + return; + } + tokio::select! { + _ = &mut up => {} + _ = tokio::time::sleep_until(deadline) => { + debug!("the new desktop's tray did not come up in time"); + return; + } + } + } + } +} + +/// Call `on_login` for each new desktop login of this user from now on, +/// for as long as the service runs. Without a system bus or logind (no +/// systemd), logins go unnoticed. +pub async fn watch(mut on_login: impl FnMut(Login) + Send) { + if let Err(e) = watch_logind(&mut on_login).await { + debug!("new logins go unnoticed: {e}"); + } +} + +async fn watch_logind(on_login: &mut (impl FnMut(Login) + Send)) -> zbus::Result<()> { + let bus = zbus::Connection::system().await?; + let manager = zbus::Proxy::new( + &bus, + "org.freedesktop.login1", + "/org/freedesktop/login1", + "org.freedesktop.login1.Manager", + ) + .await?; + let mut news = manager.receive_signal("SessionNew").await?; + // SAFETY: getuid cannot fail and touches no memory. + let uid = unsafe { libc::getuid() }; + while let Some(message) = news.next().await { + // Before asking about the session, which takes a moment: the + // tray must come up after this to count as the new desktop's. + let trays_up = TRAYS_UP.load(Ordering::Acquire); + let tray_gone = TRAY_GONE.load(Ordering::Acquire); + let Ok((_, path)) = message.body().deserialize::<(String, OwnedObjectPath)>() else { + continue; + }; + match desktop_login_of(&bus, path, uid).await { + Ok(true) => { + info!("a new login while Weir kept running"); + on_login(Login { + trays_up, + tray_gone, + }); + } + Ok(false) => {} + Err(e) => debug!("could not tell whose login it was: {e}"), + } + } + Ok(()) +} + +/// Whether the session at `path` is a desktop login of user `uid` on this +/// computer: not a text console, ssh, a remote desktop or a login screen. +async fn desktop_login_of( + bus: &zbus::Connection, + path: OwnedObjectPath, + uid: u32, +) -> zbus::Result { + let session = zbus::Proxy::new( + bus, + "org.freedesktop.login1", + path, + "org.freedesktop.login1.Session", + ) + .await?; + let (user, _): (u32, OwnedObjectPath) = session.get_property("User").await?; + let kind: String = session.get_property("Type").await?; + let class: String = session.get_property("Class").await?; + let remote: bool = session.get_property("Remote").await?; + Ok(is_desktop_login(user == uid, &kind, &class, remote)) +} + +/// What makes a session one to open the window for: the user's own, a +/// graphical one, a person's (not a login screen's), and here. +fn is_desktop_login(own: bool, kind: &str, class: &str, remote: bool) -> bool { + own && matches!(kind, "wayland" | "x11") && class == "user" && !remote +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_the_users_own_desktop_logins_count() { + assert!(is_desktop_login(true, "wayland", "user", false)); + assert!(is_desktop_login(true, "x11", "user", false)); + assert!( + !is_desktop_login(false, "wayland", "user", false), + "someone else" + ); + assert!( + !is_desktop_login(true, "tty", "user", false), + "a text console or ssh" + ); + assert!( + !is_desktop_login(true, "x11", "user", true), + "a remote desktop" + ); + assert!( + !is_desktop_login(true, "wayland", "greeter", false), + "a login screen" + ); + assert!(!is_desktop_login(true, "unspecified", "user", false)); + } + + #[tokio::test] + async fn a_new_desktop_is_ready_once_its_tray_comes_up() { + tray_went_away(); + let login = Login { + trays_up: TRAYS_UP.load(Ordering::Acquire), + tray_gone: true, + }; + let ready = tokio::spawn(login.desktop_ready()); + tokio::time::sleep(Duration::from_millis(50)).await; + assert!(!ready.is_finished(), "waits for the tray"); + tray_appeared(); + tokio::time::timeout(Duration::from_secs(2), ready) + .await + .expect("ready once the tray is up") + .unwrap(); + } +} diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 92c6a1f..77550f2 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -4,7 +4,7 @@ //! them together and runs them. use weir_daemon::{ - config, controller, defaults, diagnostics, display, hotkeys, server, tray, updates, + config, controller, defaults, diagnostics, display, hotkeys, logins, server, tray, updates, }; use anyhow::{Context, Result}; @@ -177,6 +177,19 @@ async fn main() -> Result<()> { } } + // A login after a logout the service lived through is a start too. + { + let controller = controller.clone(); + let socket = socket_path.clone(); + tokio::spawn(logins::watch(move |login| { + tokio::spawn(open_window_at_login( + controller.clone(), + socket.clone(), + login, + )); + })); + } + // A part that stops because of a bug says so to the window at once. let crashed = Arc::new(tokio::sync::Notify::new()); let wake = crashed.clone(); @@ -839,6 +852,37 @@ fn open_window_when_ready(controller: &Arc, socket: PathBuf, minimiz }); } +/// A new login while the service kept running (see [`logins`]): open the +/// window as "When Weir starts" says, once the new desktop is ready, +/// unless one is open by then. Plasma may reopen the window it had open +/// at logout; it gets a moment to. +async fn open_window_at_login(controller: Arc, socket: PathBuf, login: logins::Login) { + let opening = match controller.settings().startup { + Startup::Window => Opening::Mixer, + Startup::Minimized => Opening::Minimized, + startup @ Startup::TrayOnly => { + info!("not opening the mixer window ({})", startup.label()); + return; + } + }; + login.desktop_ready().await; + tokio::time::sleep(Duration::from_secs(2)).await; + if controller.window_attached() { + info!("a mixer window is already open"); + return; + } + match tokio::task::spawn_blocking(display::new_desktop_env) + .await + .ok() + .flatten() + { + Some(env) => { + spawn_window(&socket, opening, &env); + } + None => open_window_when_ready(&controller, socket, opening == Opening::Minimized), + } +} + /// Start the mixer window, with `env` added to its environment. Returns /// false when the binary cannot be found, which is not fatal: the daemon is /// perfectly usable without it. diff --git a/crates/daemon/src/tray.rs b/crates/daemon/src/tray.rs index 30d7647..bb888eb 100644 --- a/crates/daemon/src/tray.rs +++ b/crates/daemon/src/tray.rs @@ -147,11 +147,16 @@ impl Tray for MixerTray { fn watcher_offline(&self, reason: OfflineReason) -> bool { info!("waiting for the desktop's tray to appear"); debug!("tray unavailable: {reason:?}"); + // It was there and has gone, rather than not there yet. + if matches!(reason, OfflineReason::No) { + crate::logins::tray_went_away(); + } true } fn watcher_online(&self) { info!("the desktop's tray is there; showing the tray icon"); + crate::logins::tray_appeared(); } fn tool_tip(&self) -> ToolTip { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4c25a8f..052bf6c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -397,6 +397,19 @@ changing it with `enable` or `disable` (in `login.rs`), so the setting cannot drift from what systemd will actually do. It never uses `--now`: the daemon asking is already running. +A logout does not always stop the daemon: the systemd user manager, and +everything the session started, outlives it when the next login comes +within logind's `UserStopDelaySec` (10 s by default). Then nothing starts +at that login, the unit included (the manager never left +`default.target`), and the window closed with the old desktop. So the +daemon watches logind for a new graphical login of its own user +(`logins.rs`, `SessionNew` on the system bus) and treats it as a start: +once the new desktop's tray is back (the desktop has handed the user +manager its display by then), or 10 s on where there is no tray to wait +for, it opens the window as `startup` says, unless one is open. It +spawns the window with the manager's display variables, not its own, +which belong to the old desktop. + **From an AppImage** (`weir_protocol::appimage`), nothing is installed. The AppImage's starter mounts the image, sets `APPIMAGE` (the file) and `APPDIR` (the mount), and runs `packaging/appimage/AppRun`, which starts From 9a22716c7a2f0f2a11994343543e08ba505d03de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 06:33:24 +0000 Subject: [PATCH 22/24] Let Arch Linux ARM users build Weir from the AUR The AUR recipe listed only x86_64, so makepkg on Arch Linux ARM would refuse it. Weir builds the same way there, so the recipe now lists aarch64 too, with a comment saying no one has tried it on an ARM computer yet and asking for reports. The README says how to build on Arch Linux ARM until Weir is in the AUR, and CONTRIBUTING that nothing here builds or tests that combination. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- CONTRIBUTING.md | 6 ++++-- README.md | 9 +++++---- packaging/aur/PKGBUILD | 6 +++++- 3 files changed, 14 insertions(+), 7 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b3b85b..0f41594 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -250,8 +250,10 @@ release. It builds the RPM, the `.deb` and the AppImage twice, for x86_64 and for 64-bit ARM (on GitHub's ARM runners, free for public repositories), and an Arch Linux package for x86_64 in an Arch container, from `packaging/aur/PKGBUILD`, which is also the recipe for the -[AUR](https://aur.archlinux.org/packages/weir). The **Checks** workflow -runs clippy and the tests on ARM too. +[AUR](https://aur.archlinux.org/packages/weir). The recipe also lists +`aarch64`, so Arch Linux ARM users can build it from the AUR, but nothing +here builds or tests it that way. The **Checks** workflow runs clippy and +the tests on ARM too. [COPR](https://copr.fedorainfracloud.org/coprs/mourt/Weir/), Fedora's build service, also builds the RPM for every current Fedora release and diff --git a/README.md b/README.md index e7a00d7..b5c32bb 100644 --- a/README.md +++ b/README.md @@ -87,10 +87,11 @@ you saved it to: On a 64-bit ARM computer, such as a Raspberry Pi 5, an Apple Silicon Mac running Fedora Asahi Remix, or an ARM laptop, take the `.aarch64.rpm` or -the `_arm64.deb` instead. There is no ARM package for Arch. The ARM -packages pass the same automatic tests as the others, but have not yet -been tried on an ARM computer, so if you try one, please report how it -went. On a Raspberry Pi, Weir needs Raspberry Pi OS from late 2025 (based +the `_arm64.deb` instead. There is no ready-made ARM package for Arch; +on Arch Linux ARM, build Weir with the Arch steps below. The ARM +packages pass the same automatic tests as the others, but neither they +nor an Arch Linux ARM build have been tried on an ARM computer yet, so if +you try one, please report how it went. On a Raspberry Pi, Weir needs Raspberry Pi OS from late 2025 (based on Debian 13) or newer. **On any other distribution**, or to try Weir without installing it, take diff --git a/packaging/aur/PKGBUILD b/packaging/aur/PKGBUILD index c72f6b5..3eab7dd 100644 --- a/packaging/aur/PKGBUILD +++ b/packaging/aur/PKGBUILD @@ -14,7 +14,11 @@ pkgname=weir pkgver=1.0.0 pkgrel=1 pkgdesc='Voicemeeter-style audio mixer for PipeWire' -arch=('x86_64') +# aarch64 is for Arch Linux ARM. Weir builds there the same way, and its +# other ARM packages pass the automatic tests on ARM, but no one has tried +# this one on an ARM computer yet: please report how it goes at +# https://github.com/Mourty/Weir/issues. +arch=('x86_64' 'aarch64') url='https://github.com/Mourty/Weir' license=('MIT') # namcap calls some of these unneeded: the window loads libglvnd's EGL and From ad0ab0c2ea49fd3657199ec3b750d763e1cedba0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 07:08:17 +0000 Subject: [PATCH 23/24] Keep room for the recording outline at the mixer's sides A strip or bus being recorded has a red outline drawn 2 px outside its frame, and the mixer's scroll area clips exactly at its sides, so the first strip's outline lost its left side (and the last bus's its right side once scrolled to the end). The scroll area now reaches 3 px into the window's margin on either side and the row starts and ends 3 px in, so the strips sit exactly where they did and the outline always has room. Nothing moves when a recording starts: the room is kept whether or not anything records. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/gui/src/app/mixer.rs | 27 +++++++++++++++++++++++---- crates/gui/src/app/recorder.rs | 14 ++++++++++++-- 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/gui/src/app/mixer.rs b/crates/gui/src/app/mixer.rs index ec20a02..97e57b9 100644 --- a/crates/gui/src/app/mixer.rs +++ b/crates/gui/src/app/mixer.rs @@ -124,11 +124,29 @@ impl App { self.prefs.meter_scale && meters, self.prefs.meter_peak && meters, ); + // The scroll area clips at its sides, and a strip being recorded + // has its outline outside its frame. So the scroll area reaches + // into the window's margin by the outline's room, and the row + // starts and ends that far in: the strips sit where they would + // without it, and starting a recording moves nothing. + let room = super::recorder::REC_OUTLINE_ROOM; + let area = ui + .available_rect_before_wrap() + .expand2(egui::vec2(room, 0.0)); + ui.scope_builder(egui::UiBuilder::new().max_rect(area), |ui| { + self.mixer_row(ui, state, &lay, room); + }); + } + + /// The strips and buses, in a row that scrolls sideways, `room` kept + /// free at either end. + fn mixer_row(&mut self, ui: &mut Ui, state: &FullState, lay: &Metrics, room: f32) { egui::ScrollArea::horizontal() .auto_shrink([false, false]) .show(ui, |ui| { ui.horizontal_top(|ui| { ui.set_min_height(lay.strip_h); + ui.add_space(room); let strips = &state.mixer.strips; if strips.is_empty() { ui.label( @@ -141,10 +159,10 @@ impl App { for (i, s) in strips.iter().enumerate() { let item = SafeItem::Strip(s.name.clone()); if self.held(&item) { - self.held_view(ui, item, &s.name, "STRIP", &lay); + self.held_view(ui, item, &s.name, "STRIP", lay); continue; } - let resp = self.strip_view(ui, state, s, &lay); + let resp = self.strip_view(ui, state, s, lay); self.drop_target(ui, &resp, i, &order); } ui.add_space(4.0); @@ -163,12 +181,13 @@ impl App { let item = SafeItem::Bus(b.name.clone()); if self.held(&item) { let caption = format!("BUS {}", super::bus_label(&state.mixer, b)); - self.held_view(ui, item, &b.name, &caption, &lay); + self.held_view(ui, item, &b.name, &caption, lay); continue; } - let resp = self.bus_view(ui, state, b, &lay); + let resp = self.bus_view(ui, state, b, lay); self.drop_target(ui, &resp, i, &order); } + ui.add_space(room); }); }); } diff --git a/crates/gui/src/app/recorder.rs b/crates/gui/src/app/recorder.rs index 6cc79b9..1799a81 100644 --- a/crates/gui/src/app/recorder.rs +++ b/crates/gui/src/app/recorder.rs @@ -11,6 +11,15 @@ use egui::{pos2, vec2, Color32, CornerRadius, Rect, RichText, Sense, Stroke, Ui} use std::path::{Path, PathBuf}; use weir_protocol::*; +/// How thick the red outline around a strip or bus being recorded is. +const REC_OUTLINE_W: f32 = 2.0; + +/// The room the outline takes outside a strip's frame, its soft edge +/// included: the mixer keeps this much at its sides all the time, so the +/// first strip's outline is not cut off and nothing moves when recording +/// starts. +pub(super) const REC_OUTLINE_ROOM: f32 = REC_OUTLINE_W + 1.0; + /// Ready-made file name patterns, with what they are called. const NAME_PRESETS: [(&str, &str); 3] = [ ("Date and time", RECORD_NAMES_DEFAULT), @@ -291,12 +300,13 @@ impl App { /// The red outline around a strip or bus being recorded, its frame at /// `rect`: outside it, so it leaves the color band along the top and a - /// bus's own outline, while its mix is on the faders, as they are. + /// bus's own outline, while its mix is on the faders, as they are. The + /// mixer keeps room for it at its edges ([`REC_OUTLINE_ROOM`]). pub(super) fn paint_recording_outline(ui: &Ui, rect: Rect) { ui.painter().rect_stroke( rect, CornerRadius::same(9), - Stroke::new(2.0_f32, theme::p().rec), + Stroke::new(REC_OUTLINE_W, theme::p().rec), egui::StrokeKind::Outside, ); } From b3025b9c879893868e63bfb9c0ad96b0b8618b51 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 07:32:40 +0000 Subject: [PATCH 24/24] Read the tray icon's pixels as fixed chunks Clippy 1.99 (the stable toolchain in CI since this week) adds chunks_exact_to_as_chunks, which fails the build on png_of's chunks_exact(4). as_chunks::<4>() reads each pixel as an array, so the channels are named instead of indexed. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PWaMWDrXpfpALWtEnsyueZ --- crates/daemon/src/tray.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/daemon/src/tray.rs b/crates/daemon/src/tray.rs index bb888eb..25fbbab 100644 --- a/crates/daemon/src/tray.rs +++ b/crates/daemon/src/tray.rs @@ -395,8 +395,10 @@ fn png_of(icon: &Icon) -> Vec { // ARGB back to RGBA. let pixels: Vec = icon .data - .chunks_exact(4) - .flat_map(|p| [p[1], p[2], p[3], p[0]]) + .as_chunks::<4>() + .0 + .iter() + .flat_map(|&[a, r, g, b]| [r, g, b, a]) .collect(); let (Ok(width), Ok(height)) = (u32::try_from(icon.width), u32::try_from(icon.height)) else { return Vec::new();