Linux GUI Apps — packaging mechanisms, this repo's strategy, and how to add a new one¶
Ubuntu has five packaging mechanisms for desktop apps. They differ in
where binaries land, whether anything keeps them current, and how
desktop integration (icon, .desktop, MIME handlers) gets wired up.
This doc:
- lays out the five mechanisms and the trade-off matrix,
- inventories every GUI app this repo's
gui_apps_linuxansible role knows about (plus the snaps you probably installed via Ubuntu App Center), so you can tell at a glance which path each app is on, - gives a decision tree for adding a new app to the role.
TL;DR — five mechanisms, one trade-off matrix¶
| Mechanism | Auto-update? | Install scope | Desktop integration | Sandbox |
|---|---|---|---|---|
.deb w/ apt-source postinst |
✅ via apt upgrade |
system-wide (/usr/share/...) |
✅ baked in | ❌ |
.deb w/o apt-source postinst |
❌ manual re-deploy | system-wide | ✅ baked in | ❌ |
| Snap (Ubuntu App Center) | ✅ snapd background daemon |
/snap/<app>/<rev>/ |
✅ baked in | ✅ confined |
| Flatpak (Flathub) | ✅ flatpak update |
per-user ~/.var/app/... |
✅ baked in | ✅ confined |
| AppImage + AppImageLauncher | ❌ re-download | per-user ~/Applications/ |
✅ via AIL daemon | ❌ |
| Cargo / from source | ❌ via cargo install --force |
per-user ~/.cargo/bin/ |
❌ manual .desktop |
❌ |
Tarball / .tar.xz |
❌ pure manual | per-user (anywhere) | ❌ manual | ❌ |
The green-checkmark .deb row is the gold standard when upstream
provides it. Snap and Flatpak are the auto-update fallbacks for vendors
who don't ship .deb (or who want sandboxing). AppImage is the
last-mile bridge for everyone else.
Priority order on Ubuntu¶
The matrix above is what each mechanism does. The list below is which to pick when you have a choice — the explicit ordering this repo follows when a vendor ships in multiple formats:
.debw/ auto apt-source —apt upgradekeeps it current with zero sandbox overhead and native startup speed. The right answer for hot-path apps (editors, terminals, browsers, daily-driver tools). Examples: Cursor, VSCode, Chrome, Signal, 1Password, Slack.- Flatpak (Flathub) — clean sandbox, cross-distro friendly, faster
to launch than Snap, auto-update via
flatpak update. Right when.debis missing or vendor only publishes on Flathub. Default recommendation for community-packaged apps that need confinement. - Snap — set-and-forget auto-refresh via
snapd(4×/day, no user action). Acceptable for non-hot-path apps where the extra ~1-3s startup penalty is invisible (password manager, music player). Avoid for hot-path apps; the daily startup cost adds up. - AppImage + AppImageLauncher — when 1-3 are unavailable. No
auto-update unless the bundle ships
.zsyncAND AppImageLauncher's update check is configured. Re-running our ansible task is the typical refresh path. - Cargo / from-source / tarball — last resort. Manual everything, no apt/snap/flatpak update path. Only when upstream really only ships source.
Why not "always Snap"? Snap looks similar to Homebrew Cask on macOS but the comparison is misleading. Snap apps live in
/snap/<app>/<rev>/mounted as squashfs and bring up an AppArmor sandbox on every launch — Firefox snap takes ~2-3s to boot on SSD versus ~0.3s for the.deb. The sandbox also breaks subtle workflows by default (snap Bitwarden can't read~/.sshwithoutsnap connect bitwarden:password-manager-service; many snaps can't see external drives). For occasional-use apps these costs are invisible; for daily drivers (browser, IDE, terminal) they're a real annoyance. Snap is fine — it's just not the universal answer macOS users sometimes assume from their Cask experience. Pick by row 1-3 above.
Inventory of GUI apps on this machine¶
Ansible-managed (this repo)¶
| App | Mechanism | Auto-update? | Source-of-truth | Where it lives |
|---|---|---|---|---|
| Cursor | .deb (auto-adds /etc/apt/sources.list.d/cursor.sources) |
✅ apt upgrade cursor |
gui_apps_linux/tasks/main.yml "Install Cursor via .deb" |
/usr/share/cursor/ |
| VSCode | .deb (auto-adds vscode.sources) |
✅ apt upgrade code |
same role | /usr/share/code/ |
| Google Chrome | .deb from dl.google.com (postinst auto-adds google-chrome.list + /usr/share/keyrings/google-chrome.gpg); x86_64 only — Google publishes no arm64 Linux build |
✅ apt upgrade google-chrome-stable |
same role, "Install Google Chrome via .deb" | /opt/google/chrome/ |
| Discord | flatpak (default, recommended) OR .deb (no apt source) — picked by discordChannel chezmoi prompt |
✅ via flatpak update (default) / ❌ manual on .deb |
same role | ~/.local/share/flatpak/app/com.discordapp.Discord/ (flatpak) or /usr/share/discord/ (.deb) |
| Steam | Valve apt repo (steam-launcher), gated by installGamingApps=true and x86_64 |
✅ via apt for launcher/runtime packages; Steam client self-updates on launch | same role | /usr/lib/steam/ + /usr/share/applications/steam.desktop |
| Zen Browser | AppImage at ~/.local/opt/zen/zen.AppImage; one-time migration moves the newest legacy ~/Applications/zen*.AppImage there. The launcher sets APPIMAGELAUNCHER_DISABLE=1, so AIL neither renames it nor creates a parallel desktop entry |
❌ — replace the managed AppImage, or remove it and re-run the role | same role | fixed binary path plus stable zen-browser.desktop; profile in ~/.zen/ (shared across versions, upgrade is one-way) |
| Alacritty | cargo install alacritty |
❌ — just upgrade-cargo |
gui_apps_linux role |
~/.cargo/bin/alacritty; the desktop entry uses the absolute path because GNOME's login-session PATH omits Cargo by default |
| Ghostty | Native apt candidate → community PPA on Ubuntu 24.04 → classic Snap on other supported x64/arm64 desktops | apt maintenance / Snap auto-refresh; apply only installs missing copies | gui_apps_linux/tasks/ghostty.yml |
/usr/bin/ghostty or /snap/bin/ghostty; package supplies desktop integration. noRoot/32-bit skip. See Ghostty |
| Herdr UI | Verified stable upstream .deb; installBrewApps |
App Updates (PolicyKit), or official installer | gui_apps_linux/tasks/herdr-gui.yml |
x64/ARM64, glibc 2.39+, sudo; Herdr UI |
| AppImageLauncher | .deb (PPA on 22.04, GitHub release on 24.04) |
✅ via apt | same role | system + appimagelauncherd.service (user) |
Bitwarden CLI (bw) |
npm via mise (gated by installBitwarden=true) |
❌ — just upgrade-mise |
bitwarden role |
~/.local/share/mise/... |
| Bitwarden Desktop | Snap (bitwarden) → .deb fallback if snap unavailable; gated by installBitwarden=true AND profile=ubuntu_desktop |
✅ via snapd background refresh (or apt upgrade for fallback .deb) |
same role | /snap/bitwarden/current/ |
| CopyQ | .deb via stock apt (apt install copyq); gated by profile=ubuntu_desktop via tag selection in run_onchange_after_20_ansible_roles.sh.tmpl |
✅ via apt upgrade |
gui_apps_linux "Install CopyQ via apt" |
/usr/bin/copyq + /etc/xdg/autostart/com.github.hluk.copyq.desktop |
Clipboard CLIs (wl-clipboard, xclip, xsel) |
stock apt; ubuntu_desktop only (same tag gate as CopyQ). Not a GUI app — these are the backend binaries Neovim yank, lazygit Ctrl+O, and ~/.dotfiles/bin/x shell out to (CopyQ's daemon is not one of them). See clipboard.md |
✅ via apt upgrade |
gui_apps_linux "Install clipboard CLIs" |
/usr/bin/wl-copy, /usr/bin/xclip, /usr/bin/xsel |
Manually managed (you installed these yourself, outside ansible)¶
| App | Mechanism | Auto-update? | How it got there | How to upgrade |
|---|---|---|---|---|
| Spotify | Snap (publisher Spotify**) |
✅ background | Ubuntu App Center | snap refresh spotify |
| Firefox | Snap (canonical default since Ubuntu 22.04) | ✅ background | preinstalled with the OS | snap refresh firefox |
| btop | Snap (kz6fittycent) | ✅ background | manual | snap refresh btop |
| Frpc Desktop | AppImage at ~/Applications/Frpc-Desktop-1.2.1.AppImage |
❌ — manual re-download | manual drop-in | overwrite the file |
| Clash Verge Rev | .deb (clash-verge) |
❌ unless manually refreshed | installed manually from the upstream release | /usr/bin/clash-verge + /usr/share/applications/Clash Verge.desktop; the role removes stale user-level launchers that would shadow/duplicate it |
Clash for Windows is dead. The original developer (Fndroid) publicly stopped maintenance in 2023-11 and the GitHub repo is archived. No new releases, no CVE patches. Migrate to one of the maintained forks:
- Clash Verge Rev — active fork, ships .deb + AppImage
- Clash Nyanpasu — Tauri-based GUI, AppImage + .deb
- Mihomo Party — successor of CFW UX, AppImage + .deb
All three publish
.debpackages — drop the new app intogui_apps_linuxfollowing the.debpattern below and delete~/Documents/ClashForWindows/after the new one is wired up.
Why .deb auto-update works (the apt-source postinst trick)¶
When dpkg -i cursor_<v>_amd64.deb runs, Cursor's package contains a
DEBIAN/postinst script that, on success, writes:
/etc/apt/sources.list.d/cursor.sources— pointing athttps://downloads.cursor.com/aptrepo/usr/share/keyrings/anysphere.gpg— the signing key
After that, apt update pulls Cursor's release index alongside Ubuntu's
own, and apt upgrade cursor (or generic apt upgrade / just upgrade-system)
treats it like any other apt package. The user never needs to revisit
cursor.com.
You can verify this for any installed .deb:
dpkg -L cursor | grep -E 'sources|keyring'
# /etc/apt/sources.list.d/cursor.sources
# /usr/share/keyrings/anysphere.gpg
VSCode, Chrome, Signal, 1Password, Slack, Microsoft Edge, Brave all follow this pattern. Discord does not — see the next section.
Discord auto-update — why the screenshot keeps appearing¶
Discord's .deb does not ship a postinst that adds an apt source:
So apt upgrade is never aware of new Discord releases. Discord's
in-app updater detects the version mismatch, but on Linux it can't
actually invoke dpkg (no root) — so it pops the "Must be your lucky
day, there's a new update!" modal with a "Download" button that just
opens the vendor download page. You then have to manually dpkg -i the
new .deb.
Three ways out:
- Re-run the ansible task on demand:
chezmoi apply(the role re-fetches the latest.debURL via Discord's API every apply). This is what we do for Cursor too, so it's consistent. - Switch to Flatpak:
flatpak install flathub com.discordapp.Discord. Flatpak auto-updates viaflatpak update. Loses some native integration (notifications via XDG portal, screen-share via PipeWire) but the upgrade story is clean. - Switch to Vesktop / WebCord (community Discord clients with self-update). Drift from official Discord; not recommended unless you're already running modded Discord.
This repo's default is option 2 (Flatpak) — controlled by the
discordChannel chezmoi prompt (flatpak | deb | none, default flatpak).
The role's flatpak path:
- apt-installs the
flatpakpackage if missing (one-time, with sudo) - adds the Flathub remote at user scope (no sudo)
- installs
com.discordapp.Discordat user scope (no sudo)
To switch from .deb to flatpak on an existing machine: set
discordChannel = "flatpak" in ~/.config/chezmoi/chezmoi.toml and run
chezmoi apply --tags gui_apps. The two installs can coexist briefly
(different .desktop entries); once you've validated the Flatpak version,
sudo apt remove discord to clean up the old .deb.
Steam — why this uses Valve's apt repo, not Flatpak/Snap¶
Steam is gated by installGamingApps=true and currently only installed on
x86_64 Ubuntu Desktop. Valve publishes an official apt repository at
https://repo.steampowered.com/steam/; the role installs its signing key
from archive/stable/steam.gpg, enables i386, adds the repo as deb822,
and installs steam-launcher.
The i386 architecture is intentional: Steam still needs 32-bit runtime
libraries for parts of the launcher and many games. Do not hard-code old
Mesa package names from older guides (for example libgl1-mesa-glx) in the
role; Ubuntu 24.04 no longer ships that package. Let steam-launcher pull
the current dependencies and recommends.
Flathub's com.valvesoftware.Steam exists, but it is a community package
and explicitly not Valve's supported path. It also needs extra filesystem
overrides for libraries on non-default drives. If this repo ever adds a
steamChannel prompt, keep Valve apt as the default.
Snap (Ubuntu App Center) — what's actually happening¶
The Ubuntu App Center is a GTK frontend for snapd — Canonical's
universal package manager that ships .snap archives from the Snap
Store. When you install Bitwarden or Spotify from App Center:
- The
.snapfile is extracted to/snap/<app>/<rev>/(versioned, read-only) - A symlink
/snap/<app>/currentalways points at the active rev - A wrapper script at
/snap/bin/<app>is what$PATHactually picks up - A user-systemd service
snapd.refresh.servicechecks for updates ~4× per day and applies them in the background - Desktop integration (
/var/lib/snapd/desktop/applications/<app>.desktop) is auto-generated; the snap-store daemon updates these on refresh
This means Bitwarden Desktop and Spotify will keep themselves up to
date with no manual action — the same way apt upgrade doesn't run
automatically but apt-source .debs are kept current via your existing
just upgrade-system cadence, snaps are kept current via snapd's
always-on refresh daemon. Different paradigm, same end result.
To inspect: snap list, snap info <app>, snap refresh --list
(pending updates), snap refresh <app> (force-refresh now), snap
revert <app> (rollback to previous rev — neat property of the
versioned /snap/<app>/<rev>/ layout).
Caveat: snaps run in a confinement sandbox, which can break power-user workflows (no access to
~/.sshby default for example). Most desktop apps work fine; CLI tools sometimes don't. Prefer.debor apt when both options exist.
AppImage + AppImageLauncher — the catch-all¶
When upstream ships only an AppImage (Zen Browser, Cursor's old AppImage, many indie tools), our convention is:
- For AppImageLauncher-owned apps, download to a stable filename, but never depend on it: write the file
as
~/Applications/<app>.AppImage(no version or hash), and guard the install with a glob (<app>*.AppImage), not that exact path. AppImageLauncher renames integrated AppImages to<app>_<md5>.AppImage, so an exact-path guard turns every apply into a fresh multi-hundred-MB re-download. Files likezen-x86_64_fe71259e...AppImagealso accumulate — warn when the glob matches more than one instead of silently picking. - Executable bit:
mode: '0755'in the ansible task. - AppImageLauncher integration:
appimagelauncherd.service(user systemd unit, set up by our role) watches~/Applications/. New AppImages trigger a first-run modal: "Integrate or Run once?" — integrating extracts the icon, generates a.desktopentry under~/.local/share/applications/, and moves the file to AppImageLauncher's canonical name. Writing our own.desktopdoes not opt us out: AIL hooks execution itself throughbinfmt_misc, so any launch reaches it first.ask_to_move = falsesuppresses only the dialog, not the move (measured — see the pitfall below), and relocating the file outside~/Applicationsjust trades the rename for a move-prompt. - Re-assert desktop integration on every apply, not only on download.
Resolve the current AppImage path into a fact and rewrite the
.desktopfrom it each run, so a rename self-heals instead of leaving a danglingTryExec=— which the launcher hides rather than reporting. If you also delete AIL's parallelappimagekit_*.desktopto keep one authority, make both that andail-cli deintegratefailed_when: false:ail-clidoes not exist under AppImageLauncher Lite (noRoot).pitfalls/appimagelauncher-renames-managed-appimage.md - Gate the desktop-integration tasks on
is not skipped, not justis succeeded. A task skipped by itswhen:still passesis succeeded, so a failed/skipped download will happily go on to write an icon and a.desktopwhoseTryExec=dangles — which hides the launcher entry instead of erroring, and leavesfailed=0in the apply log. Pair the download with an explicit "no asset matched"debugwarning too; arescue:block cannot catch a no-op. Full story:pitfalls/ansible-folded-scalar-regex-empty-url-silent-skip.md
Zen Browser is the deliberate exception to that AppImageLauncher-owned pattern.
It lives at ~/.local/opt/zen/zen.AppImage, outside the daemon's watched
directories, and its desktop command exports APPIMAGELAUNCHER_DISABLE=1.
AppImageLauncher's own binfmt interpreter documents and honors that variable,
so launch-time integration is skipped too. This gives the role one stable
binary path and one stable desktop ID instead of an apply/login writer race.
For AppImages you drop manually (Frpc Desktop), continue to let AIL own the
integration.
To check the daemon: systemctl --user status appimagelauncherd.
Manual integration: ail-cli integrate ~/Applications/foo.AppImage.
Understanding and debugging duplicate launcher icons¶
GNOME does not deduplicate applications by displayed Name=. It primarily
sees a desktop ID, derived from the filename relative to an XDG application
directory. Two files named Clash Verge.desktop at user and system scope share
one ID, so the user copy shadows the system copy; clash-verge.desktop is a
different ID and therefore produces a second visible icon even when both say
Name=Clash Verge. NoDisplay=true entries are protocol handlers and should
not be counted as launcher icons.
Use this inspection loop before deleting anything:
for root in ~/.local/share/applications /usr/local/share/applications \
/usr/share/applications /var/lib/snapd/desktop/applications; do
find "$root" -maxdepth 1 -type f -name '*.desktop' -print0 2>/dev/null
done | while IFS= read -r -d '' file; do
printf '\n[%s]\n' "$file"
sed -n '/^\(Name\|Exec\|TryExec\|NoDisplay\|Hidden\|StartupWMClass\)=/p' "$file"
done
Then check the relevant layer:
desktop-file-validate ~/.local/share/applications/example.desktop
gtk-launch example # desktop ID without .desktop
update-desktop-database ~/.local/share/applications
gsettings get org.gnome.shell favorite-apps
tr '\0' '\n' </proc/$(pgrep -n gnome-shell)/environ | grep '^PATH='
journalctl --user -u appimagelauncherd --since today
The most useful interpretations are:
- missing
TryExecin the GNOME Shell process PATH hides the entry without an error (the Alacritty failure here); - same desktop ID at user/system scope means shadowing, not duplication;
- different IDs with the same
Name=means two icons (the Clash failure); - a new
appimagekit_*file immediately after login points to AppImageLauncher's daemon (the Zen failure); - a favorite that names a nonexistent desktop ID is stale state and should be replaced with the current stable ID.
Adding a new GUI app to gui_apps_linux¶
Does upstream publish a .deb?
├─ YES → Does the .deb add an apt source (check `dpkg -L <pkg> | grep sources` after install)?
│ ├─ YES → use the .deb. Ansible installs once, apt keeps current. ★ best path
│ └─ NO → use the .deb anyway, BUT add a refresh hook to
│ `scripts/upgrade_tools.sh` so `just upgrade-system`
│ re-fetches latest. (Discord pattern.)
│
├─ NO ── Does upstream publish on Snap or Flatpak with FIRST-PARTY support?
│ ├─ Official Snap → use snap (community.general.snap module).
│ │ Cleanest auto-update on Ubuntu, but confined.
│ ├─ Official Flathub → use flatpak (community.general.flatpak module).
│ │ Cross-distro friendly, also confined.
│ └─ Community-only → skip; AppImage probably more reliable.
│
└─ Only AppImage / tarball?
├─ AppImage → drop in ~/Applications/ as <app>.AppImage. AIL integrates.
└─ tarball → ~/Applications/<app>/ + wrapper script + manual .desktop.
⚠️ Last resort — no auto-update path, manual everything.
Concrete patterns¶
.deb pattern — see "Install Cursor via .deb" in
gui_apps_linux/tasks/main.yml.
Two-step:
ansible.builtin.uri— fetch vendor's API to resolve the latest.debURL (return JSON, parsedebUrlor similar field).ansible.builtin.get_url— download the.deb, withforce: true(overwrite stale files from prior apply) anduntil: cursor_dl is succeeded+retries: 4+delay: 5(GFW-region CDN drops).ansible.builtin.apt: deb=<path>— install. Postinst handles apt source if vendor ships one.- Wrap the whole block in
rescue:that prints a manual-fallback URL.
AppImage pattern — see "Install Zen Browser AppImage" in the same file. Three-step:
ansible.builtin.uri— GitHub releases API (or vendor equivalent) to resolve the asset URL.selectattr('name', 'match', '^<prefix>-<arch>\\.AppImage$')filter to pick the right asset fortarget_architecture(already normalized tox86_64/aarch64by theSet target_architecture from dpkgtask inlinux.yml).ansible.builtin.get_urlto~/Applications/<app>.AppImagewithmode: '0755'and a stable filename.- Write a
.desktopentry under~/.local/share/applications/usingansible.builtin.copywith acontent:literal — the file gets merged into the system menu viaupdate-desktop-database.
Snap pattern — none yet in this repo. Template:
- name: Install Bitwarden Desktop via Snap
community.general.snap:
name: bitwarden
state: present
classic: false # most apps are confined; set true only when vendor docs say so
become: true
Flatpak pattern — none yet. Template:
- name: Install Discord via Flatpak (instead of .deb)
community.general.flatpak:
name: com.discordapp.Discord
state: present
remote: flathub # add `community.general.flatpak_remote` first if missing
method: user # per-user install; `system` requires root
Always add a rescue: block that points the user at the manual
fallback URL (cursor.com / discord.com / GitHub release page) so a
failed automated install isn't a dead end.
Controlling installed apps from the shell¶
The role installs apps; dot_config/shell/56_linux_apps.sh.tmpl controls
them. Linux counterpart to the macOS app-* helpers in
dot_config/shell/54_macos_apps.sh.tmpl — same public function names
(appquit / applaunch / appactivate / apprestart / apprunning /
applist / appresponsive) so users get cross-platform muscle memory.
The tv linux-apps channel mirrors tv mac-apps.
Coverage on Wayland GNOME (no compositor extension)¶
| Verb | Linux backend | Fidelity |
|---|---|---|
applaunch NAME |
gtk-launch <desktop-id> (no DBusActivatable=true required) |
✓ works for any .desktop file |
apprunning NAME |
pgrep -f <pattern> (override or auto-derived) |
✓ silent boolean |
appquit NAME |
pkill -TERM -f <pattern> |
✓ Electron + Firefox quit gracefully on SIGTERM |
apprestart NAME |
quit + poll-gone (≤15s) + launch | ✓ launches even if not running |
appactivate NAME |
D-Bus → GNOME window-calls ext; fallback gtk-launch |
⚠ full focus only with extension installed |
applist [--pids\|--all] |
registry + .desktop scan, pgrep each |
⚠ "managed apps that are alive" — not every window |
appresponsive NAME [T] |
playerctl -p <mpris> for media apps; degrades to apprunning |
⚠ MPRIS-only; no Linux analog to macOS Apple Events |
Why gapplication alone doesn't work¶
The freedesktop org.freedesktop.Application D-Bus interface is the
semantic match to macOS Apple Events — apps expose quit / activate /
etc. and gapplication action <id> quit calls them. In practice, almost
no third-party app sets DBusActivatable=true in its .desktop file:
empirically, on a daily-driver Ubuntu 24.04 Wayland session,
gapplication list-apps returned 13 entries — all GNOME-core (Calendar,
Logs, Nautilus, …). Zero of the user's Zen / Cursor / Spotify / Discord /
Frpc-Desktop showed up. See
pitfalls/linux-app-control-gapplication-zero-coverage.md.
Hybrid pivot: launch via gtk-launch (works for any .desktop file
regardless of DBusActivatable); quit via pkill -TERM on a runtime
pattern; focus via the optional window-calls GNOME extension.
Override file: ~/.config/shell/linux-apps.conf¶
Not auto-stubbed (mirrors ~/.shellrc.secrets rule — empty-file footgun).
Helper sources it if present. Register apps with linux_app_register:
# Cursor — apt install at /usr/share/cursor/cursor (NOT the AppImage residue)
linux_app_register Cursor \
--desktop=cursor \
--pkill='^/usr/share/cursor/cursor( |$)' \
--wm-class=Cursor
# Discord — apt install; ignore the Flatpak duplicate
linux_app_register Discord \
--desktop=discord \
--pkill='^/usr/share/discord/Discord( |$)' \
--wm-class=discord
# Spotify — snap-confined, MPRIS-aware
linux_app_register Spotify \
--desktop=spotify_spotify \
--pkill='^/snap/spotify/[^/]+/usr/share/spotify/spotify' \
--wm-class=spotify \
--mpris=spotify
# Zen Browser — AppImage, runtime path != .desktop Exec
linux_app_register Zen \
--desktop=zen-browser \
--pkill='\.mount_.+/zen($| )' \
--wm-class=zen
# Frpc-Desktop — AppImage, runtime path drifts on re-download
linux_app_register Frpc-Desktop \
--desktop=appimagekit_557220393761292184db845cd26816c3-Frpc-Desktop \
--pkill='\.mount_.+/frpc-desktop($| )' \
--wm-class=Frpc-Desktop
# ClashForWindows — manually-placed Electron app, NO .desktop file.
# Use --exec= for the launch path; helper prefers --exec over --desktop.
linux_app_register CFW \
--exec='~/Documents/ClashForWindows/cfw' \
--pkill='^/home/.+/Documents/ClashForWindows/cfw' \
--wm-class='Clash for Windows'
The --exec= flag covers apps with no .desktop (manually-placed
Electron / hand-built binaries). The flag value is passed through eval
to expand ~ and $HOME, and is executed via setsid -f sh -c … so
the parent shell stays clean.
linux_app_register --list prints the current registry; --help prints
flag reference.
When to override vs let auto-derivation handle it:
- Always override for AppImage, Snap, and Flatpak apps. AppImage
runtime binary lives at
/tmp/.mount_<hash>/<inner>, not the.desktopExec=path —pkillon theExec=path only hits the launcher wrapper. Seepitfalls/linux-app-control-appimage-runtime-path.md. - Override when an app has multiple
.desktopentries (Zen with 4 AppImage residues, Cursor with AppImage residue + apt install, Discord with apt + Flatpak both installed). Auto-derivation picks latest mtime, which may not be the one that's actually running. - Skip override for clean apt-installed apps (their
Exec=matches the runtime path 1:1) — auto-derivation handles them correctly.
GNOME window-calls extension — manual install¶
The helper detects the extension at first appactivate call. Without it,
appactivate falls back to applaunch, which re-focuses for
single-instance apps like Electron + Firefox but isn't reliable for all
apps. Install once per host:
# Download from GitHub releases or extensions.gnome.org
# https://github.com/ickyicky/window-calls (or hseliger/window-calls-extended
# for GNOME 45+ if upstream is stale)
gnome-extensions install ~/Downloads/window-calls@domandoman.xyz.zip
gnome-extensions enable window-calls@domandoman.xyz
Wayland sessions need a logout + login for the extension to activate
(no Alt+F2 r reload like X11). The user-consent dialog on GNOME 46+
can't be suppressed, so this isn't ansible-automatable.
Known limits (mirror the backlog)¶
- No analog to macOS Apple Events'
with timeoutfor non-MPRIS apps.appresponsivefor Electron / Firefox / native GTK apps degrades toapprunningwith a stderr note. - No "hide windows" verb (mac-apps Alt+H has no Linux equivalent
without compositor IPC; deliberately omitted from
tv linux-apps). applistis "registered + auto-derived running apps", not "every GUI window". Auto-discovery filters to.desktopentries whoseExec=is an absolute path (skips Flatpak/AppImage wrappers that would match too loosely).- Compositor support is GNOME Wayland only. KDE / Sway / Hyprland
paths aren't wired — see
backlog/linux-desktop-app-control.mdfor the deferred matrix if a second compositor joins the fleet.
Cross-references¶
docs/tools/appimage.md— AppImageLauncher install paths,ail-cli, libfuse2 / AppArmor gotchas on Ubuntu 24.04.docs/this_repo/upgrades.md— the install-vs-upgrade split: whychezmoi applydoesn't bump versions, whatjust upgrade-*covers, where each package manager fits in.dot_ansible/roles/gui_apps_linux/tasks/main.yml— the actual ansible role, with the patterns you'd copy when adding a new app.backlog/linux-desktop-app-control.md— design history for theapp-*helpers, options considered, and deferred follow-ups (KDE / Sway / Hyprland).