diff --git a/.github/workflows/packages.yml b/.github/workflows/packages.yml index 3b7ce14..70c8721 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,95 @@ 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 + 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, 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: + - 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 }}" + sha256sum -c < 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 +399,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..0f41594 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -234,15 +234,26 @@ 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 -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 @@ -265,8 +276,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..da181ef 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,62 @@ 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 + @# 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)" + +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 26b8997..b5c32bb 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,20 @@ 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 +the AppImage: `Weir-…-x86_64.AppImage`, or `Weir-…-aarch64.AppImage` on +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. 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/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..c85bc58 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,17 @@ impl Install { release's source download) and run make install again" } 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. 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() + ); + } Install::Unpackaged(_) | Install::Elsewhere(_) => "Download it from the release page", }; // Installing replaces the files, but the old version keeps running @@ -362,6 +381,18 @@ 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("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}" + ); } #[test] 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/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")); 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/login.rs b/crates/daemon/src/login.rs index 9db7c6f..7d88c8e 100644 --- a/crates/daemon/src/login.rs +++ b/crates/daemon/src/login.rs @@ -6,8 +6,16 @@ //! 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. +//! 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; +use weir_protocol::appimage::AppImage; /// The unit `make install` and the RPM install. const UNIT: &str = "weir.service"; @@ -52,6 +60,268 @@ 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 { + 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"); + +/// 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 { + appimage_unit(&self.unit) + } + + /// 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() { + 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 { + 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{}{}{}\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")); + } 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 +348,40 @@ 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); + } + + #[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/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 e348d63..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}; @@ -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); @@ -152,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); @@ -170,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(); @@ -186,7 +206,7 @@ async fn main() -> Result<()> { &mut signals, serve, tray_rx, - tray.as_ref(), + &mut tray, &crashed, ) .await; @@ -195,7 +215,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. @@ -369,7 +389,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) @@ -381,6 +408,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 @@ -426,7 +479,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. @@ -455,7 +508,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"); @@ -512,6 +566,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(), @@ -522,11 +579,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; @@ -626,16 +692,53 @@ 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; + // `--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. + 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 rest: Vec = std::env::args().skip(1).filter(|a| a != "--safe").collect(); - let mut command = std::process::Command::new(&exe); + 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(&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"); @@ -643,8 +746,69 @@ 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. 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 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 && 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); + } + } + } } /// Open the window as the daemon starts, once there is a desktop to show it @@ -688,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. @@ -728,6 +923,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/crates/daemon/src/tray.rs b/crates/daemon/src/tray.rs index 36cc187..25fbbab 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, @@ -106,8 +107,18 @@ 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() + } + + /// 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(), } @@ -117,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, @@ -132,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 { @@ -170,7 +190,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() } @@ -205,7 +225,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, @@ -218,7 +238,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() } @@ -261,14 +281,14 @@ impl MixerTray { Some(r) => vec![ StandardItem { label: recording_words(r), - icon_name: "media-record".into(), + icon_data: record_icon().to_vec(), 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() } @@ -276,7 +296,7 @@ impl MixerTray { ], None if self.can_record => vec![StandardItem { label: "Start recording".into(), - icon_name: "media-record".into(), + icon_data: record_icon().to_vec(), activate: Box::new(|this: &mut Self| this.send(TrayCommand::StartRecording)), ..Default::default() } @@ -302,11 +322,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 { @@ -331,6 +351,114 @@ 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 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( + || 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 + .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(); + }; + 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 +} + /// 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] { @@ -408,9 +536,49 @@ 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 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/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/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/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/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, ); } diff --git a/crates/gui/src/app/settings.rs b/crates/gui/src/app/settings.rs index 4798f18..84a84a5 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| { @@ -446,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")) } 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, } } } 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; 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 90a99bb..052bf6c 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -283,7 +283,20 @@ 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. 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. 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 @@ -384,6 +397,53 @@ 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 +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. 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 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. 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 + 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 @@ -477,7 +537,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 8f75a39..c55fca5 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 @@ -590,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 30a0611..7c396f4 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) @@ -827,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 @@ -1028,6 +1033,46 @@ 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, 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. + +* **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 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. +* **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 Weir is two parts: the **service**, which does the mixing and owns the @@ -1039,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. 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" "$@" 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