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/ |
| 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 ~/Applications/zen.AppImage (stable filename, no version suffix) |
❌ — re-run ansible task | same role | ~/Applications/zen.AppImage |
| Alacritty | cargo install alacritty |
❌ — just upgrade-cargo |
devtools role |
~/.cargo/bin/alacritty |
| 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 |
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 for Windows | tarball at ~/Documents/ClashForWindows/ ⚠️ |
❌ DEPRECATED upstream | extracted manually 2025 | Migrate — see below |
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:
- Stable filename: drop the file at
~/Applications/<app>.AppImage(no version or hash in the name). Re-runs of the ansible task just overwrite it. Files likezen-x86_64_fe71259e...AppImage(old hash suffix in filename) are the wrong shape — they accumulate forever. - 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 (optionally) moves the file to AppImageLauncher's canonical location.
For our managed AppImages we usually skip the modal by writing the
.desktop entry ourselves in the same ansible task — see Zen Browser.
For AppImages you drop manually (Frpc Desktop), let the modal handle it.
To check the daemon: systemctl --user status appimagelauncherd.
Manual integration: ail-cli integrate ~/Applications/foo.AppImage.
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).