Desktops¶
Technical reference for the desktop submodule of armbian-config (the configng repository), under tools/modules/desktops/. This guide is aimed at developers who want to add a new desktop environment, modify the install/remove pipeline, or integrate the YAML-driven desktop API from another tool.
End-user instructions for installing a desktop with armbian-config live in the Armbian Config section.
Overview¶
The desktop submodule replaces hand-rolled per-distro install scripts with a single YAML-driven pipeline. Each desktop environment is described by one YAML file in tools/modules/desktops/yaml/. A Python helper parses the YAML and emits bash-compatible variables that the rest of the module evaluates and acts on.
The submodule provides:
- Tiered install — every desktop ships at one of three sizes (
minimal,mid,full), and users can move between tiers after install viaupgrade/downgrade/set-tier. - Per-install manifest — every install records exactly which packages it added so removal and downgrades only undo what they themselves did.
- Custom APT repositories, branding, group memberships, and skel sync.
- Auto-login management for
gdm3,sddm, andlightdm, with non-destructive in-place edits of the underlying config files. - Per-release / per-arch package overrides so the same YAML works across Debian bookworm/trixie/forky/sid and Ubuntu jammy/noble/resolute on amd64/arm64/armhf/riscv64/loong64 with different package availability.
- Browser virtual token that resolves per-release-per-arch (google-chrome-stable on amd64, chromium on Debian/Ubuntu arm arches, firefox-esr on Debian riscv64, epiphany-browser on Ubuntu riscv64, …).
- Container/CI awareness so the same code path can be used inside Docker without trying to start a display manager.
Tier model¶
Every desktop install is run at one of three tiers, in order of inclusion: minimal -> mid -> full. Each tier is the union of itself plus all lower tiers, so installing full implies mid implies minimal. Tiers are mandatory; there is no flat “install everything from this YAML” mode.
| Tier | Contents | Approximate size |
|---|---|---|
minimal |
DE itself + display manager + base utilities. No browser, no office, no user-facing apps beyond a terminal and a file manager. | ~500 MB |
mid |
minimal + browser + everyday user apps (text editor, calculator, image/PDF viewer, media player, archive tool, torrent client). |
~1 GB |
full |
mid + office suite + creative tools (LibreOffice, GIMP, Inkscape, Thunderbird, Audacity). |
~2.5 GB |
The per-tier package lists for mid and full live in common.yaml so every DE inherits them. Per-DE YAMLs override only what they need (e.g. KDE Plasma swaps gnome-text-editor for kate at the mid tier).
The currently-installed tier is recorded in /etc/armbian/desktop/<de>.tier. The full set of packages installed for a given DE is recorded in /etc/armbian/desktop/<de>.packages.
Component map¶
Every shell file is loaded by configng’s module loader, which exposes them as bash functions in the running shell. desktops_dir points at the desktops directory and is used to resolve paths from any module function.
Data flow¶
The Python helper is the single source of truth for what packages get installed for a given (desktop, release, arch, tier) combination. The bash side never reads YAML directly.
YAML schema¶
Each desktop is defined in a single YAML file under tools/modules/desktops/yaml/. Filename without .yaml is the canonical desktop name (de_name).
Top-level fields¶
| Field | Type | Required | Description |
|---|---|---|---|
name |
string | informational | Human-readable name. |
description |
string | informational | One-line summary, exposed via DESKTOP_DESC. |
display_manager |
string | yes | Greeter package: gdm3, sddm, lightdm, or none. |
status |
string | yes | Editorial label — one of supported, community, unsupported. Reported via DESKTOP_STATUS. Affects only labelling and catalog filtering — does not block install. community is used for DEs that work but are maintained on a best-effort basis; unsupported for DEs that are known-broken or not vetted. |
tiers |
mapping | yes | Per-tier package lists, keyed by minimal, mid, full. See Tier blocks. |
tier_overrides |
mapping | optional | Per-arch and/or per-release-per-arch package removals (and additions) for tier holes. See tier_overrides. |
releases |
mapping | yes | Per-release overrides keyed by release codename (bookworm, trixie, forky, sid, jammy, noble, resolute, …). |
repo |
mapping | optional | Custom APT repository, see below. |
Tier blocks¶
| Field | Type | Description |
|---|---|---|
packages |
list | Packages added at this tier. Combined with common.yaml’s same-tier packages and any earlier tiers in the walk. |
packages_remove |
list | Packages dropped from the accumulated list at this tier. Use this to remove a common.yaml entry that doesn’t fit the DE (e.g. KDE Plasma drops gnome-text-editor and inserts kate at the mid tier). |
packages_uninstall |
list | (minimal tier only) Packages purged after install. Used for orthogonal junk that the metapackage pulls in but we want gone (e.g. apport, python3-apport). Important: never list a package that is a hard Depends: of any meta package the install ships, or apt’s autoremove will cascade and yank a chunk of the desktop. |
The first DE-specific package that survives all filters becomes DESKTOP_PRIMARY_PKG, used by module_desktops status for dpkg -l checks. It must come from the DE’s own tiers.minimal.packages block, not from common.yaml, otherwise every DE would share the same primary package.
Per-release block¶
The release block is orthogonal to the tier walk: it applies to whatever tier is being installed. Use it for things that vary by release rather than by user choice (e.g. trixie’s pulseaudio→pipewire swap, bookworm’s gnome-calculator addition).
| Field | Type | Description |
|---|---|---|
architectures |
list | Architectures supported on this release. Used to compute DESKTOP_AVAILABLE (the “does this YAML declare the requested release+arch combo?” bool — distinct from the editorial status above). |
packages |
list | Extra packages added on top of the tier-resolved set. |
packages_remove |
list | Packages filtered out of the merged install list. |
packages_uninstall |
list | Packages purged after install on this release only. |
tier_overrides¶
tier_overrides is for package availability holes: a tier package that exists on most arches/releases but is missing on one specific combination. The schema has two layers:
| YAML | |
|---|---|
Use the per-arch layer for permanent arch-wide holes (e.g. blender always missing on armhf). Use the per-release-per-arch layer for transient holes (e.g. loupe missing on bookworm because GNOME 43 didn’t have it). The parser walks tier_overrides at every tier step in its walk, so a hole declared at the mid tier is honoured for both mid and full installs.
tier_overrides can live in common.yaml (applies to every DE) or in a per-DE YAML (applies only to that DE). The parser merges common first, then per-DE.
Custom repository block¶
| Field | Type | Description |
|---|---|---|
url |
string | Base URL for deb [signed-by=...] <url> <suite> <components>. |
key_url |
string | URL to the GPG key (ASCII-armored). |
keyring |
string | Path to the dearmored keyring file, e.g. /usr/share/keyrings/neon.gpg. |
suite |
string or list of strings (optional) | Suite path(s) that follow the URL. A list emits one deb [...] line per entry — all sharing url/keyring/components — for vendors whose archive spans multiple parallel suites (base, -security, -updates, -porting, -customization, …). Defaults to the release codename. Regex-validated to ^[A-Za-z0-9._/-]+$. Per-release override: releases.<release>.repo_suite. |
components |
list (optional) | Components that follow the suite. Defaults to [main]. Each entry regex-validated to ^[A-Za-z0-9._-]+$; invalid entries are dropped with a warning. Per-release override: releases.<release>.repo_components. |
preferences |
list (optional) | APT pin preferences written to /etc/apt/preferences.d/<de_name>. Each entry needs origin, suite, and priority (positive integer). Removed on uninstall. |
suite and components exist for vendor archives whose layout doesn’t match the default <codename> main convention. For example, SpacemiT’s K1 RISC-V archive pins a frozen snapshot per Ubuntu release (noble/snapshots/v2.2, resolute/snapshots/v3.0) and mirrors all four Ubuntu components, so bianbu.yaml sets components: [main, universe, restricted, multiverse] at the repo: level and overrides repo_suite in each release block.
preferences is rarely needed — only when a vendor archive must outrank the distro for a given (origin, suite) pair. Each list entry becomes one stanza:
Priorities above 1000 let apt downgrade a package from the distro to the pinned archive’s version; below 1000 only allows upgrades. Entries missing any required field are skipped with a warning from parse_desktop_yaml.py.
Example¶
common.yaml¶
common.yaml carries the per-tier defaults that apply to every desktop, the browser substitution table, and any cross-DE tier_overrides. Per-DE YAMLs only declare a tiers block when they want to add packages on top of common or override common-tier entries.
| yaml/common.yaml | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 | |
Browser virtual token¶
The literal string browser inside any tier block resolves to a real package name from the browser: map at parse time. Lookup order:
browser.<release>.<arch>— most specificbrowser.<arch>— per-arch fallback if no per-release entry exists- drop the token entirely (silent — install proceeds without a browser rather than failing on a literal
browserapt name)
The per-release layer is needed because the same arch can resolve differently across releases:
- Debian has
firefox-esrbut nofirefoxpackage. - Ubuntu’s
chromium/firefoxdebs are snap-shim wrappers that requiresnapd. Armbian doesn’t ship snapd, so the shims are broken at runtime — apt.armbian.com hosts realchromium/firefox/google-chrome-stable.debs used instead. - amd64 always gets
google-chrome-stable(Google publishes no arm/riscv builds, so this is amd64-only). chromiumisn’t built for riscv64 in either Debian or Ubuntu.- Ubuntu doesn’t publish
firefoxorfirefox-esrfor riscv64 (Mozilla has no riscv64 binaries, andfirefox-esris a Debian-only package name). Fall back toepiphany-browser(GNOME Web) there — native GTK, small, and available on every Ubuntu arch. - Debian riscv64 gets
firefox-esrbecause the Debian archive does publish it for riscv64. loong64is only declared forsidin the inventory;chromiumisn’t built there yet either, so it usesfirefox-esr.
Python helper: parse_desktop_yaml.py¶
Single-purpose CLI that bash modules invoke via python3. All YAML parsing and validation happens here so the bash side stays free of YAML logic.
Usage¶
The two filter flags on --list / --list-json select on two orthogonal axes, both default to permissive (backwards-compatible with pre-filter callers):
--filterselects on the computedDESKTOP_AVAILABLEaxis (does the YAML declare this release+arch combo?). Values:available(default — hides DEs without an entry for this combo),unavailable(only the non-declared DEs), orall(no filtering on this axis).--statusselects on the editorialDESKTOP_STATUSaxis. Takes a comma-separated keep-list of status values to retain. Omit the flag to keep all statuses. Example:--status supported,communitydropsunsupportedDEs from the output.
Variables emitted (per-desktop mode)¶
All values are double-quoted and shell-escaped via shell_escape() (escapes \, ", $, and `), so the bash caller can safely eval the output.
| Variable | Source | Notes |
|---|---|---|
DESKTOP_PACKAGES |
full tier walk: common minimal/mid/full + DE minimal/mid/full + release packages − every layer’s packages_remove and tier_overrides removals. The browser virtual token is resolved here. |
Space-separated, ready to feed to apt install. |
DESKTOP_PACKAGES_UNINSTALL |
minimal-tier packages_uninstall from common + DE + release |
Space-separated. |
DESKTOP_PRIMARY_PKG |
first DE-specific package (not from common) that survives all filters | Used by module_desktops status for dpkg -l checks. |
DESKTOP_DM |
display_manager, default lightdm |
|
DESKTOP_STATUS |
editorial status from the YAML, default unsupported. One of supported / community / unsupported. |
Orthogonal to DESKTOP_AVAILABLE — a community DE may be available on a combo (its YAML declares the release+arch) or not. |
DESKTOP_AVAILABLE |
yes if arch is in the release’s architectures and release is a key in releases, else no |
Computed axis — whether the YAML declares this release+arch combo. Named DESKTOP_SUPPORTED before 2026-04 (the rename disambiguates this from the editorial status field). |
DESKTOP_DESC |
description, default de_name |
|
DESKTOP_TIER |
the requested tier name | Set verbatim from the --tier arg. |
DESKTOP_REPO_URL |
repo.url |
Only emitted when repo: exists. |
DESKTOP_REPO_KEY_URL |
repo.key_url |
Only emitted when repo: exists. |
DESKTOP_REPO_KEYRING |
repo.keyring |
Only emitted when repo: exists. |
Resolution algorithm¶
For a given (de_name, release, arch, tier):
- Start with empty
packagesandremoveslists. - Walk tiers from
minimalup to the target tier. At each step: - Mergecommon.tiers.<tier>.packages, thende.tiers.<tier>.packages, applying each layer’spackages_removeto filter. - Applycommon.tier_overrides.<tier>for the (release, arch). - Applyde.tier_overrides.<tier>for the (release, arch). - Resolve the
browsertoken to a real package viacommon.browser.<release>.<arch>(with fallback tocommon.browser.<arch>, or drop the token). - Apply the release block: filter
release.<release>.packages_remove, then addrelease.<release>.packages. - Compute
packages_uninstallby unioning the minimal-tierpackages_uninstallfrom common, DE, and the release block. - Compute
DESKTOP_PRIMARY_PKGas the first DE-specific tier-walk package that survived release and per-arch removals. - Emit all
DESKTOP_*variables.
Error handling and validation¶
The parser is strict about top-level structure but tolerant of malformed sub-nodes:
- Mandatory
--tierarg. Calling without it prints usage and exits 1. Invalid tier values (ultra, etc.) error out with a clear message. - Path traversal guard —
de_nameis resolved againstyaml_dirviaos.path.realpath/commonpath. Anything outside the directory (../..., absolute paths, symlink escapes) is rejected withError: invalid desktop name '<name>'and exit 1. - Tolerant normalization —
tiers,releases,architectures,tier_overrides,repo, every list field passes through_as_dict/_as_listhelpers. Wrong-typed nodes coerce to safe empty defaults ({}or[]) instead of raisingAttributeErroror doing surprising substring matches likearch in "arm64".
List and JSON list modes¶
Iterates every *.yaml (excluding common.yaml), parses each one’s release block, and emits one row per DE. By default only entries with DESKTOP_AVAILABLE=yes for the requested (release, arch) are printed — pass --filter unavailable or --filter all to override. Pass --status <csv> to additionally narrow by the editorial status field. Used by module_desktops install to show available desktops on error and by module_desktops supported to expose a machine-readable catalog. These modes do not require --tier.
Each JSON entry has this shape (two orthogonal status axes):
| JSON | |
|---|---|
Bash module API¶
All functions are loaded by configng’s module loader. They share global state (DESKTOP_* variables, desktops_dir, DISTROID) — call sites must follow the documented order.
module_desktops¶
| Text Only | |
|---|---|
Top-level dispatcher. The de=, tier=, arch=, release=, mode= arguments are parsed positionally from $@.
| Command | Behavior | Required args |
|---|---|---|
install |
Full install pipeline (see Lifecycle). Bails out cleanly on pkg_install failure without changing system state. With mode=build: skips user detection, group membership, skel propagation, and DM start/autologin — intended for image-build time when no real user exists. |
de=, tier= (optional: mode=build) |
remove |
Disables auto-login, stops the display manager, purges every package recorded in <de>.packages, runs pkg_clean, switches default.target back to multi-user, isolates to multi-user.target so the running session also drops to console. |
de= |
upgrade |
Move an installed desktop to a higher tier. Refuses if the target is the same or lower (use downgrade). |
de=, tier= |
downgrade |
Move an installed desktop to a lower tier. Removable set is intersected with the install manifest so user-installed packages are never touched. | de=, tier= |
set-tier |
Direction-agnostic tier change — auto-detects upgrade vs downgrade from the current marker. Same arg shape as upgrade/downgrade. Refuses with a friendly message if not installed or already at the target tier. Used by the dialog menu’s “Change to |
de=, tier= |
tier |
Print the installed tier name (minimal/mid/full) on stdout, or not installed. Returns 0 if installed, 1 if not. Use this from the CLI when you want the actual tier value. |
de= |
at-tier |
Silent gate: exit 0 if the DE is installed AND its current tier marker matches the given target. Used by dialog menu condition gates. | de=, tier= |
status |
Silent exit-code query. Returns 0 if DESKTOP_PRIMARY_PKG is dpkg -l installed, 1 if not. Prints nothing on either path so it can be used safely from menu condition gates that fire dozens of times per render. |
de= |
disable |
systemctl stop && disable display-manager. |
— |
enable |
systemctl enable && start display-manager. |
— |
auto |
Configures auto-login for DESKTOP_DM (gdm3/sddm/lightdm). Edits the gdm config in place — never overwrites the file — so user customization is preserved. |
de= |
manual |
Reverts auto-login. Idempotent. | de= |
login |
Returns 0 if auto-login is currently configured. Anchored regex; safely ignores commented sample lines in the stock noble custom.conf. |
de= |
supported |
With de=: prints true/false based on DESKTOP_AVAILABLE for the DE on arch=/release=. Without de=: prints a JSON catalog. Two optional filter knobs: filter=available\|unavailable\|all (computed-availability axis, default available) and status=<csv> (editorial-status keep-list — e.g. status=supported,community hides editorially unsupported DEs). |
optional de=, arch=, release=, filter=, status= |
installed |
Returns 0 if any desktop is installed (uses cached --primaries lookup). |
— |
help |
Shows help and exits. | — |
Manifest files¶
Two files per installed desktop, both under /etc/armbian/desktop/:
| File | Format | Purpose |
|---|---|---|
<de>.packages |
newline-separated package names | The exact set of packages newly installed by module_desktops install (captured from apt-get -s install dry-run via pkg_install’s ACTUALLY_INSTALLED array). The remove path passes this to pkg_remove; the downgrade path uses it to constrain what may be removed. |
<de>.tier |
one line: minimal, mid, or full |
Source of truth for the currently-installed tier. Read by status, tier, at-tier, upgrade, downgrade, set-tier. Written by install and the tier-change commands. |
Auto-login files written¶
| Display manager | File |
|---|---|
gdm3 |
/etc/gdm3/custom.conf on Ubuntu, /etc/gdm3/daemon.conf on Debian. Branched on ID= from /etc/os-release (not on release codename — both bookworm and trixie use daemon.conf). The file is edited in place via sed, NOT overwritten — any user customization (WaylandEnable=false, etc.) is preserved. |
sddm |
/etc/sddm.conf.d/autologin.conf (drop-in, non-destructive) |
lightdm |
/etc/lightdm/lightdm.conf.d/22-armbian-autologin.conf (drop-in, non-destructive) |
module_desktop_yamlparse¶
| Text Only | |
|---|---|
Wraps parse_desktop_yaml.py. Resets all DESKTOP_* globals, runs the helper, and evals its stdout. Returns 1 on parse failure (with the parser’s stderr surfaced).
Defaults:
- arch → dpkg --print-architecture
- release → $DISTROID
- tier → minimal — passed through to the parser’s --tier arg, so callers that only need DESKTOP_DM / DESKTOP_PRIMARY_PKG (status checks, autologin paths) don’t need to know the actual installed tier.
| Bash | |
|---|---|
module_desktop_yamlparse_list¶
| Text Only | |
|---|---|
Calls the parser with --list and prints TSV to stdout. Used to assemble the “Available: …” hint shown when install is invoked without de=.
module_desktop_supported¶
| Text Only | |
|---|---|
Convenience wrapper around module_desktop_yamlparse that returns 0/1 based on DESKTOP_AVAILABLE (the computed-availability axis). Suppresses parser stderr — meant for predicates and CI gates. Note: this function does not consider the editorial DESKTOP_STATUS axis — a DE with status: unsupported can still return 0 here if its YAML declares the requested release+arch. Filter on DESKTOP_STATUS separately if you need to exclude unsupported DEs.
module_desktop_repo¶
| Text Only | |
|---|---|
Sets up a custom APT source. Must be called after module_desktop_yamlparse because it consumes DESKTOP_REPO_URL, DESKTOP_REPO_KEY_URL, DESKTOP_REPO_KEYRING.
Behavior:
- Validates
de_nameagainst^[a-zA-Z0-9._-]+$(defense in depth — the YAML parser already blocks traversal). curl --retry 3 --connect-timeout 10 --max-time 30 ... | gpg --dearmorwrites the keyring. Pipefail is set so a curl failure is surfaced.- Verifies the keyring is non-empty before proceeding (catches HTML error pages dearmoring to a zero-byte file).
- Writes
/etc/apt/sources.list.d/<de_name>.listwithdeb [signed-by=<keyring>] <url> $DISTROID main.
A no-op if the YAML has no repo: block.
module_desktop_branding¶
| Text Only | |
|---|---|
Copies branding assets and runs the optional postinst hook. Idempotent — every step is guarded with [[ -d ... ]].
Source (under tools/modules/desktops/) |
Destination |
|---|---|
greeters/lightdm/ |
/etc/armbian/lightdm/ and mirrored to /etc/lightdm/ |
skel/ |
/etc/skel/ |
branding/wallpapers/*.jpg |
/usr/share/backgrounds/armbian/ |
branding/wallpapers-lightdm/*.jpg |
/usr/share/backgrounds/armbian-lightdm/ |
branding/icons/* |
/usr/share/icons/armbian/ |
branding/pixmaps/* |
/usr/share/pixmaps/armbian/ |
branding/armbian.xml |
/usr/share/gnome-background-properties/ |
greeters/sddm/themes/* |
/usr/share/sddm/themes/ (only when DESKTOP_DM=sddm) |
postinst/<de_name>.sh |
Executed via bash (skipped inside containers/CI) |
The distributor logo for GNOME Settings → About / KDE Info Center / etc. is not installed from here — that file ships from armbian-base-files so it stays in sync with the LOGO= line in /etc/os-release.
module_desktop_getuser¶
| Text Only | |
|---|---|
Returns the first non-root, non-system user with a real login shell. Prefers $SUDO_USER if set and not root, otherwise scans /etc/passwd for the first entry with 1000 ≤ uid < 65534 and a shell that does not match nologin|false. Exits 1 if none is found.
module_update_skel¶
| Text Only | |
|---|---|
Walks getent passwd, and for every regular user (1000 ≤ uid < 65534, home directory exists, not root):
- Walks
/etc/skelwithfind -mindepth 1. For each entry: - Directory: create at the destination if missing. - File: copy if the destination doesn’t exist; never overwrite. - Runs
chown -R "$uid:$gid" "$home/"as a safety net.
The recursive chown is critical: other package postinst scripts (caja, nemo, gnome-keyring, …) routinely leak root-owned files into the user’s ~/.config directory on first install. Without the recursive chown, those tools refuse to start on first login because they can’t write their own config dirs.
module_appimage¶
| Text Only | |
|---|---|
Standalone AppImage helper. The internal APPIMAGE_REPO registry maps logical app names (e.g. armbian-imager) to GitHub owner/repo slugs and downloads the appropriate architecture-suffixed AppImage from the latest release. module_appimage install also installs libfuse2, fuse3, and the libgles2/libegl1/libgl1/libgl1-mesa-dri runtime so the AppImage can launch.
Not called from the desktop install path by default. The armbian-imager AppImage is available via armbian-config --api module_appimage install app=armbian-imager for users who explicitly want it.
Lifecycle: install¶
The install pipeline in module_desktops install is intentionally linear and idempotent-friendly. Every step that touches system state is gated on the previous step’s success.
Steps marked with [R] are runtime-only — skipped when mode=build is passed (image build time, no real user exists). Steps marked with [B] run in both modes.
1. [B] Validate args de= and tier= both required; tier must be minimal|mid|full
2. [R] Resolve target user module_desktop_getuser (skipped in mode=build)
3. [B] Parse YAML at target tier module_desktop_yamlparse $de $arch $release $tier
4. [B] Validate package list exit if DESKTOP_PACKAGES / DESKTOP_PRIMARY_PKG empty
5. [B] Warn on unavailable DESKTOP_AVAILABLE != yes → stderr warning, continue
6. [B] Suppress interactive debconf-set-selections + DEBIAN_FRONTEND=noninteractive
7. [B] Configure custom repo module_desktop_repo $de (no-op if no repo: block)
8. [B] Write apt pin _module_desktops_write_apt_pin (force apt.armbian.com .debs)
9. [B] apt update pkg_update
10. [B] Reset ACTUALLY_INSTALLED array used by pkg_install to record new packages
11. [B] apt install desktop pkgs pkg_install $DESKTOP_PACKAGES ← bail on failure
12. [B] apt install + register DM pkg_install $DESKTOP_DM ← bail on failure
/etc/X11/default-display-manager
13. [B] (Armbian) install plymouth if /etc/apt/sources.list.d/armbian.{list,sources} present
14. [B] Save install manifest /etc/armbian/desktop/<de>.packages and <de>.tier
15. [B] Purge unwanted packages apt-get remove --purge $DESKTOP_PACKAGES_UNINSTALL
16. [B] Install branding module_desktop_branding $de (browser policies, VPU flags, etc.)
17. [R] Add user to groups sudo netdev audio video dialout plugdev input bluetooth systemd-journal ssh
18. [R] Profile sync daemon (psd) touch ~/.activate_psd, sudoers entry
19. [R] Sync skel to existing users module_update_skel install (with chown -R safety net)
20. [R] Stop other DMs gdm3/lightdm/sddm one by one
21. [R] Start display manager systemctl start display-manager ← container path also skips
22. [R] Switch default.target systemctl set-default graphical.target ONLY if step 21 succeeded
23. [R] Enable auto-login module_desktops auto de=$de
mode=build is used by the Armbian build framework at image-creation time. At that point the rootfs has no regular user (armbian-firstrun creates the first user on first boot), and DM/systemd operations make no sense inside a chroot. The packages, branding, manifests, and /etc/skel all land correctly; the first user inherits skel at useradd time and armbian-firstrun manages graphical.target.
If step 11 or 12 fails, the function returns 1 with no further state changes — the manifest is not written, default.target stays at multi-user, no DM is started. The system is in the same state as if the install had never run.
Lifecycle: remove¶
1. Validate args de= required
2. Read installed tier marker /etc/armbian/desktop/<de>.tier (default: minimal)
3. Parse YAML at the installed module_desktop_yamlparse $de $arch $release $installed_tier
tier
4. Disable auto-login module_desktops manual de=$de
5. Stop display manager systemctl stop display-manager
6. Switch default.target systemctl set-default multi-user.target
7. Isolate to multi-user systemctl isolate multi-user.target (drops running session
to console immediately, no reboot needed)
8. Compute removable set from /etc/armbian/desktop/<de>.packages
fallback: walk DESKTOP_PACKAGES through dpkg-query
9. Filter out essentials apt-get -s -y purge <list> (simulation, stderr parsed)
every package apt flags under "WARNING: The following
essential packages will be removed" is dropped from
the removable set — see note below
10. Purge remaining set apt-get -y purge <filtered list> ← bail on failure,
manifest preserved for retry
11. Delete manifest files rm /etc/armbian/desktop/<de>.{packages,tier} (only after 10 succeeds)
12. pkg_clean apt-get clean — reclaim downloaded .deb cache
The set-default and isolate calls together ensure the user gets a console login on tty1 immediately after the uninstall, without needing to reboot. Without them, the system stays pinned to graphical.target with no DM behind it and the local console is blank.
Why the essential filter (step 9). The remove path calls apt-get -y purge directly — not pkg_remove (which wraps apt-get autopurge). autopurge adds an orphan-cleanup cascade on top of the removal, and on fresh post-t64 images (trixie+, noble+) several shared libs pulled in alongside e2fsprogs (libext2fs2t64, libss2, logsave) are marked auto-installed. Once the DE is gone nothing manual depends on them, autopurge proposes to orphan-remove the whole chain, and apt 2.9+ / solver 3.0 vetoes the transaction with E: Essential packages were removed and -y was used without --allow-remove-essential — nothing actually gets removed. A plain apt-get purge avoids the cascade, and the manifest is already the complete list, so no cascade is needed.
A separate case the filter catches: some base images (notably armbian/repository-update:*-armhf tags rebuilt from debian-slim) ship without e2fsprogs pre-installed. When a DE’s install pulls in dracut-install or gnome-disk-utility transitively, those pull e2fsprogs, it lands in the manifest, and purging it would touch an Essential package. Step 9 simulates the purge, parses apt’s essential-warning block (stripping (due to X) annotations), and drops every flagged name from the list before the real purge runs.
On failure of step 10 the function returns 1 with the manifest left in place, so the next remove retries against the same list rather than falling into the less-precise YAML-walk path.
Lifecycle: upgrade and downgrade¶
upgrade and downgrade are the two halves of _module_desktops_change_tier:
1. Validate args de= and tier= required; tier must be minimal|mid|full
2. Read current tier marker /etc/armbian/desktop/<de>.tier (must exist)
3. Validate direction upgrade refuses target <= current
downgrade refuses target >= current
same tier → no-op message, exit 0
4. Parse YAML twice once at current tier, once at target tier
store the package lists in two bash arrays
5. Compute set difference (awk on per-line printf input)
upgrade: to_install = target - current
downgrade: removable = current - target
6. (downgrade only) intersect removable ∩ <de>.packages — never touch
packages the user installed manually
outside the desktop install path
7. Apply pkg_install (upgrade) or pkg_remove (downgrade)
8. Update manifest append new packages, or remove drained ones
9. Update tier marker /etc/armbian/desktop/<de>.tier
set-tier is a thin front-end over the same helper that auto-detects direction from the current tier vs the target. It’s the entry point used by the dialog menu’s “Change
Container and CI awareness¶
_desktop_in_container returns true when any of the following holds:
/.dockerenvexists/run/.containerenvexists$CIis set$GITHUB_ACTIONSis set
Inside a container the install pipeline still does packages, branding, and skel work, but skips:
- Stopping or starting any display manager
- The
set-default graphical.targetswitch - Restarting the display manager after auto-login changes
- The
systemctl isolatecall on remove - Running per-desktop
postinst/<de_name>.shhooks
This makes the same code path usable for image preseeding inside Docker without needing parallel “container mode” branches.
Adding a new desktop¶
- Create the YAML. Drop a new file at
tools/modules/desktops/yaml/<de_name>.yamlfollowing the schema. Minimum required fields:display_manager,status,tiers.minimal.packages, and at least one entry underreleases.<codename>with anarchitectureslist. - (Optional) Per-DE tier overrides. Add a
tiers.midand/ortiers.fullblock only if you need to override common defaults. Most DEs inherit common’s mid/full unchanged. - (Optional)
tier_overrides. Add per-arch or per-release-per-arch removals only when there’s a known package availability hole specific to this DE. Cross-DE holes belong incommon.yaml. - (Optional) Custom repo. Add a
repo:block if the DE is not in the distro’s default repositories. Pin the keyring path under/usr/share/keyrings/. - (Optional) Postinst hook. Drop
tools/modules/desktops/postinst/<de_name>.shfor any per-DE configuration that has to run afterapt install. Container/CI runs are skipped automatically. - (Optional) Branding overrides. Branding lives in shared directories, so most desktops do not need any per-DE assets — only add files when the DE needs something different.
-
Smoke test the parser at every tier:
Bash All
DESKTOP_*variables should print,DESKTOP_AVAILABLE="yes"for any (release, arch) pair you listed in the YAML, andDESKTOP_TIERshould match the requested tier. -
List-mode sanity check:
Bash Your new desktop should appear in the TSV output for the (release, arch) combinations you declared.
-
End-to-end test in a disposable VM or container:
-
Add menu entries in
tools/json/config.system.jsonif you want the DE to appear in the dialog menu. Existing desktops use this slot allocation per DE:ID slot Action *01install minimal *02uninstall *03enable autologin *04disable autologin *05install mid *06install full *07change to minimal *08change to mid *09change to full The
*07-*09change-tier entries usemodule_desktops set-tierand gate visibility withmodule_desktops status de=<X> && ! module_desktops at-tier de=<X> tier=<target>.status: communityDEs (the[CSC]tier) follow a shorter allocation — only*01(install minimal),*02(uninstall),*03(autologin),*04(manual-login) — matching thekde-neonprecedent. No 3-tier install, no set-tier. Description andshortcarry a trailing[CSC]marker so the UI can distinguish community DEs from first-class supported ones. Do NOT add menu entries forstatus: unsupportedDEs — they’re intentionally kept out of the dialog so users never land on a broken install path from the menu.
Matrix audit automation¶
The desktop matrix covers several DEs × several releases × several architectures, and two kinds of drift tend to accumulate silently:
- Missing releases —
armbian/buildadds a new release toconfig/distributions/(e.g. Ubunturesolute) but no DE YAML grows a release block for it, so the desktop can’t be installed on that release at all. - Package holes — an entry in the resolved
DESKTOP_PACKAGESset is no longer published for some(release, arch)pair (archive removed it, or it was never built for that arch), soaptfails at install time withE: Unable to locate package.
A weekly GitHub Actions workflow detects both, hands the findings to Claude Code to propose YAML edits, and opens a draft PR for a maintainer to review.
Components¶
Only the scanner talks to the network; the LLM never fetches package metadata itself. That keeps the “what is broken” signal reproducible and cache-friendly, and confines all non-determinism to the “how should we fix it” step.
audit.py¶
Walks tools/modules/desktops/yaml/ against:
armbian/build’sconfig/distributions/<release>.conf(loaded from a sibling checkout passed via--build-repo) to get the set of releases and their support statuses (supported,csc,eos, …). Anythingeosis skipped.packages.debian.organdpackages.ubuntu.com— oneurllibrequest per(release, arch, package)tuple, parallelised withThreadPoolExecutor. Responses are cached in-process for the run.
Report shape (audit-report.json):
Desktops with status: unsupported in their YAML are listed in skipped_desktops and not audited — drift in an unsupported DE isn’t actionable. status: community DEs are audited (drift in a community-tier DE is still worth reporting, even if a maintainer may choose not to act on it immediately).
Flags: --tier {minimal,mid,full} narrows the scope; --release <codename> audits a single release; --skip-network is a dry-run that only reports missing_releases.
audit_prompt.py¶
Renders the JSON report into a single text prompt (no markdown-in-markdown gymnastics; the report JSON is embedded in fenced blocks). The prompt pins Claude to:
- touch only YAML files under
tools/modules/desktops/yaml/ - address every finding, not just the first
- prefer edits to
common.yaml’stier_overridesblock for package holes (one place, applies to every DE) over duplicatingpackages_removeentries in per-DE YAMLs - for missing releases, add a release block to each
status: supportedDE YAML, copying the shape from an existing block and adjusting per-release deltas only where needed - always add an inline comment explaining why a hole exists, so future readers can distinguish a transient archive gap from a permanent upstream-port limitation
- preserve the existing 2-space indentation
- if the report is empty, say so and make no edits
maintenance-desktop-audit.yml¶
Triggers:
schedule: '0 6 * * 1'— Mondays 06:00 UTC. Release and package availability change slowly, so weekly is enough and cheap.workflow_dispatch— with optionaltier,release, anddry_runinputs.dry_run: truestops after the deterministic audit and attachesaudit-report.jsonwithout calling Claude or opening a PR.
Concurrency: group: desktop-audit, cancel-in-progress: false — two scheduled runs will never race, and a manual dispatch queues behind the scheduled run rather than killing it.
Job steps, in order:
- Checkout configng at the workspace root (no
path:) soclaude-code-actionfinds.git. - Checkout
armbian/buildintoarmbian-build/withfetch-depth: 1— the audit only readsconfig/distributions/, so shallow is fine. - Set up Python 3.12 and
pip install pyyaml. - Run
audit.py— writesaudit-report.json, appends a markdown summary table to$GITHUB_STEP_SUMMARY, and setssteps.audit.outputs.actionabletotrueiffmissing_releasesorpackage_holesis non-empty. - Prepare Claude prompt (
audit_prompt.py) — only ifactionableand not a dry run. - Upload
audit-reportartifact (always, 30-day retention) — useful even on zero-hole runs as historical record. anthropics/claude-code-action@v1with: -claude_code_oauth_token: secrets.CLAUDE_CODE_OAUTH_TOKEN(Max subscription token — no per-run API charges). -claude_args: --max-turns 30 --permission-mode acceptEdits --allowed-tools Edit,Write,Read,Glob,Grep,Bash(git:*).acceptEditsplus the explicit allow-list is required: without them the action’s default tool gate denies Edit/Write and the branch stays empty.Bash(git:*)only permits read-only git inspection; no shell execution surface.- Stash Claude execution log — copies
${RUNNER_TEMP}/claude-execution-output.jsoninto the workspace; uploaded as theclaude-execution-outputartifact withif: always()so a failed or zero-edit run is debuggable from the transcript without a re-run. - Clean up temp files — removes
armbian-build/,audit-report.json,claude-prompt.txt, andclaude-execution-output.jsonfrom the working tree sopeter-evans/create-pull-requestsees only Claude’s YAML edits. peter-evans/create-pull-request@v6— branchbot/desktop-matrix-audit, basemain,add-paths: tools/modules/desktops/yaml/*,delete-branch: true,draft: true, labelsbot,desktops,documentation. PR body issteps.claude.outputs.structured_output(Claude’s own summary of what it changed and why). If Claude produced no diff, the branch is not ahead of main and no PR is opened — the workflow finishes green with only the audit artifact.
Permissions¶
| YAML | |
|---|---|
Reviewing a bot PR¶
Bot PRs open as draft on purpose. A human check before merge:
- Read Claude’s PR body — it should list every file it changed and the reason.
- Confirm the diff is scoped to
tools/modules/desktops/yaml/. Any out-of-scope file is a red flag (the workflow’sadd-pathsshould already prevent this, but verify). - For each missing-release addition: spot-check that the new release block is a sensible copy of an existing one (e.g. a
resoluteblock forxfce.yamlshould look like thetrixieornobleblock, not a half-written stub). - For each package-hole edit: confirm it lives in
common.yaml’stier_overrideswhere it belongs, not duplicated per-DE. - For each WHY comment: confirm it’s accurate. “not yet in trixie” ages out; “no upstream riscv64 port” doesn’t.
- Mark ready for review and merge normally.
delete-branch: truecleans up on merge.
If Claude judged the report non-actionable (e.g. the only finding is a csc-tier release a maintainer wants to hold off on), the run ends with the audit-report artifact present and no PR — inspect the artifact and the claude-execution-output log to confirm.
Common pitfalls¶
packages_uninstall cascade¶
Listing a package in tiers.minimal.packages_uninstall runs apt-get remove --purge on it after the install. If that package is a hard Depends: of any meta package the DE install pulled in, apt’s autoremove cascade will yank the meta package along with it — and on systems with APT::Get::AutomaticRemove "true" (Ubuntu noble/resolute), the cascade keeps going and rips out a chunk of the desktop. Real examples that bit us:
- Listing any
xfce4-goodiesplugin (e.g.xfce4-clipman-plugin) yanksxfce4-goodiesitself, then half the desktop. - Listing
language-selector-gnomeyanksgnome-control-center(which has it as a hard Depends on Ubuntu), so the user loses Settings. - Listing
kdeconnectorkhelpcenteryanksneon-desktop.
Rule: never put a Depends: of a metapackage you ship into packages_uninstall. Verify with apt-cache rdepends --installed <pkg> before adding anything.
Gnome daemon.conf vs custom.conf¶
Both Debian and Ubuntu ship a gdm3 package, but they read different config files:
- Debian (any release):
/etc/gdm3/daemon.conf - Ubuntu (any release):
/etc/gdm3/custom.conf
module_desktops auto branches on ID=ubuntu from /etc/os-release, not on the release codename. Earlier versions of the code branched on codename and wrote to the wrong file on Debian bookworm.
The auto path also edits the file in place via sed (preserving any user customization like WaylandEnable=false) rather than overwriting it with a fresh cat > $file.
login regex anchoring¶
The stock Ubuntu noble /etc/gdm3/custom.conf template ships with a commented sample line:
| Text Only | |
|---|---|
An unanchored grep for AutomaticLoginEnable\s*=\s*true matches this comment, and module_desktops login returns 0 (autologin enabled) on every fresh install where the user has never touched autologin. The fix is ^AutomaticLoginEnable[[:space:]]*=[[:space:]]*true — anchored at line start so the comment doesn’t match.
Security notes¶
- Path traversal:
de_nameflows from CLI input intoos.path.join(yaml_dir, f"{de_name}.yaml"). The Python helper resolves both sides viaos.path.realpathand rejects anything outsideyaml_dir(handles.., absolute paths, and symlink escapes).module_desktop_repoadditionally validatesde_nameagainst^[a-zA-Z0-9._-]+$before writing/etc/apt/sources.list.d/<de_name>.list. - Shell injection: all values emitted by the Python helper pass through
shell_escape()(escapes\,",$,`) so the bash caller canevalthe output safely even when YAML strings contain shell metacharacters. - GPG keyring fetch: the
curl | gpg --dearmorpipeline runs underset -o pipefail, with--retry 3 --connect-timeout 10 --max-time 30, and a non-empty file check after dearmor. A failed download or an HTML error page does not silently produce an empty keyring. - APT sources are written with
[signed-by=<keyring>], never viaapt-key. Each desktop’s source list lives in its own file (/etc/apt/sources.list.d/<de_name>.list) so removal is a singlerm.
See also¶
- Extensions — the Armbian build framework’s extension system, used by board configs to inject build-time hooks.
- Armbian Config — end-user docs for
armbian-config. - configng repository — source for everything described here.