Handbook · Chapter 5 of 12 · 16 min read

Shipping desktop opinion as data

An atomic image is more than packages: most of what makes a distro feel like a distro is configuration — default settings, extensions, boot splash, logos, one-command workflows. The rule in this chapter: ship opinion as data in /usr and /etc, baked at image build, never as imperative first-boot scripts mutating user state behind the user's back. Margine's payload splits into five mechanisms: gschema overrides + dconf databases, extensions installed (and patched) at build, systemd drop-ins, ujust recipes, and Plymouth/branding assets.

5.1 Defaults: gschema overrides vs the dconf distro database

GNOME has two layered "vendor default" systems, and you need both.

gschema overrides patch the compiled schema defaults. They are read by anything that resolves a key through the global schema source. Files load in lexicographic order — Margine names its file zz1-* so it sorts after Bluefin's zz0-bluefin-modifications:

# margine-image/build_files/30-gnome-defaults/install.sh (heredoc, trimmed)
# /usr/share/glib-2.0/schemas/zz1-margine.gschema.override
[org.gnome.shell]
enabled-extensions=['appindicatorsupport@rgcjonas.gmail.com', 'bazaar-integration@kolunmi.github.io', 'blur-my-shell@aunetx', 'dash-to-dock@micxgx.gmail.com', 'gradia-integration@alexandervanhee.github.io', 'gsconnect@andyholmes.github.io', 'search-light@icedman.github.com', 'o-tiling@oliwebd.github.com', 'hide-cursor@elcste.com', 'caffeine@patapon.info']
favorite-apps=['app.zen_browser.zen.desktop', 'org.mozilla.Thunderbird.desktop', 'org.gnome.Nautilus.desktop', 'io.github.kolunmi.Bazaar.desktop', 'org.gnome.Ptyxis.desktop']

[org.gnome.desktop.interface]
accent-color='yellow'

[org.gnome.desktop.wm.preferences]
num-workspaces=10
focus-mode='sloppy'
auto-raise=false

Practical effect: these are defaults, not settings — the user's dconf still wins, and gsettings reset returns to your value, not GNOME's. Overrides only take effect after glib-compile-schemas /usr/share/glib-2.0/schemas (the install script runs it at the end).

dconf system databases sit below the user's database in the read path. Configured by a profile plus keyfile directories:

# margine-image/build_files/30-gnome-defaults/install.sh:94-107
mkdir -p /etc/dconf/db/distro.d/locks /etc/dconf/profile
install -m 0644 /ctx/30-gnome-defaults/dconf/* /etc/dconf/db/distro.d/

if [[ ! -f /etc/dconf/profile/user ]]; then
  cat > /etc/dconf/profile/user <<'PROFILE'
user-db:user
system-db:local
system-db:site
system-db:distro
PROFILE
elif ! grep -qxF 'system-db:distro' /etc/dconf/profile/user; then
  printf '\nsystem-db:distro\n' >> /etc/dconf/profile/user
fi
dconf update

The profile is a read stack, top wins: user > local > site > distro. local/site are reserved for the machine admin and site policy (the Fedora convention), distro is yours. dconf update compiles distro.d/* keyfiles into the binary /etc/dconf/db/distro — without it nothing applies. The locks/ subdirectory (created above, currently unused for distro) is where you list key paths that the user database may NOT override — Margine uses a lock in the GDM database (§5.8).

Why extensions get dconf keyfiles, not gschema overrides

# margine-image/build_files/30-gnome-defaults/install.sh:88-93
# Extension preferences use dconf keyfiles rather than gschema
# overrides. GNOME Shell Extension.getSettings() loads an extension's
# local schemas/ directory ahead of the global schema source, so global
# gschema override defaults for org.gnome.shell.extensions.* can be
# shadowed at runtime. dconf defaults are keyed by path and apply to
# the actual settings backend the extension reads.

Practical effect: a zz1 override for org.gnome.shell.extensions.dash-to-dock may simply never be consulted, because the extension compiles and loads its own copy of the schema from its schemas/ directory. dconf keyfiles are keyed by path ([org/gnome/shell/extensions/dash-to-dock]), so they hit the backend no matter which schema object the extension instantiated. Example keyfile:

# margine-image/build_files/30-gnome-defaults/dconf/01-margine-dash-to-dock (trimmed)
[org/gnome/shell/extensions/dash-to-dock]
# Anti-collision with Margine's Super+1..0 workspace binds.
hot-keys=false
dash-max-icon-size=36
dock-fixed=true
running-indicator-style='DOTS'
transparency-mode='DYNAMIC'

Even ad-hoc user keybindings ship this way — relocatable schemas (custom-keybindings/...) have no fixed schema to override, but a dconf path always works:

# margine-image/build_files/30-gnome-defaults/dconf/07-margine-custom-keybindings
[org/gnome/settings-daemon/plugins/media-keys]
custom-keybindings=['/org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/margine-smile/']

[org/gnome/settings-daemon/plugins/media-keys/custom-keybindings/margine-smile]
name='Smile emoji picker'
binding='<Super>period'
command='flatpak run it.mijorus.smile'

Lesson — the index-vs-pixels bug (search-light border-radius). Symptom: the dconf default for search-light applied background-color fine, but the corner rounding never appeared, with no error anywhere. Root cause: the key is named like a pixel value but the extension treats it as an array index: extension.js does rads[Math.floor(value)] over rads = [0,16,18,20,22,24,28,32], and an if (r) guard silently drops undefined. The shipped 30.0 hit rads[30]. Fix: read the extension source before writing "obvious" values; the keyfile now documents the encoding:

# margine-image/build_files/30-gnome-defaults/dconf/02-margine-search-light:8-15
# border-radius is NOT pixels: the extension uses it as an INDEX into
# rads = [0, 16, 18, 20, 22, 24, 28, 32] px — extension.js does
# rads[Math.floor(value)] and the `if (r)` guard silently drops
# out-of-range values (the old 30.0 hit rads[30] = undefined, ...)
# The prefs UI slider is 0..7. 7.0 = 32 px = maximum rounding.
border-radius=7.0

Generic rule: extension settings have no validation layer — dconf accepts any value of the right GVariant type, and bad values fail silently at render time. CI sentinel checks (Margine's build validator asserts border-radius=7 is present in the image) catch regressions of the file, not of the semantics.

A second silent trap, documented inline in zz1: clearing keys to @as [] expecting a later script to restore them. Margine once cleared switch-applications/switch-windows in the override, assuming configure-gnome-keybindings would re-bind them — it intentionally doesn't, so Alt+Tab was dead on fresh installs. Defaults files are append-only opinion; don't use them to "make room" for scripts.

5.2 GNOME extensions: build-time install, downstream patches

Margine originally installed extensions per-user at first login. That failed three ways (race with flatpak-preinstall.service network priority; a user-side copy shadowing Bluefin's newer system copy of search-light; silent whole-extension disable on shell-version mismatch). The fix is the Bluefin/Bazzite pattern: bake every extension into /usr/share/gnome-shell/extensions/ at image build, enable via the gschema override above, and never touch ~/.local.

# margine-image/build_files/build-margine-extensions.sh:56-57,102-117 (trimmed)
OTILING_VERSION="v2.8.8"
OTILING_URL="https://github.com/oliwebd/o-tiling/releases/download/${OTILING_VERSION}/o-tiling@oliwebd.github.com-${OTILING_VERSION}.zip"

install_otiling() {
  local target="${EXT_DIR}/o-tiling@oliwebd.github.com"
  rm -rf "${target}"; mkdir -p "${target}"
  curl -fL --retry 5 --retry-delay 10 -o /tmp/otiling.zip "${OTILING_URL}"
  extract_zip /tmp/otiling.zip "${target}"
  if [[ -d "${target}/schemas" ]] && compgen -G "${target}/schemas/*.xml" > /dev/null; then
    glib-compile-schemas --strict "${target}/schemas"
  fi
}

Pinned release zips make bumps reviewable PRs. For EGO-only extensions, resolve the compatible version against the shell actually in the image:

# margine-image/build_files/build-margine-extensions.sh:90,124-127
GNOME_SHELL_MAJOR="$(gnome-shell --version | awk '{print $3}' | cut -d. -f1)"
version_tag="$(curl -fsSL --retry 5 --retry-delay 10 \
  "https://extensions.gnome.org/extension-info/?uuid=${HIDECURSOR_UUID}&shell_version=${GNOME_SHELL_MAJOR}" \
  | python3 -c 'import json,sys; d=json.loads(sys.stdin.read()); print(d.get("version_tag",""))')"

Querying EGO's extension-info endpoint with the image's shell major version avoids the classic "extension disabled after rebase" mismatch. Note also what the script deliberately does not install: search-light, because Bluefin already bakes it system-wide from git master — a second copy re-creates the shadow bug.

Lesson — transient dnf installs in build scripts cascade. Symptom: scxctl/scx-scheds vanished from built images, twice, after unrelated "cleanup" changes. Root cause: the script did dnf5 install unzip jqdnf5 remove jq; dnf5 autoremove. autoremove reaped scx-scheds; after removing the autoremove, dnf5 remove jq still cascaded: scx-tools-git declares Requires: jq, so removing jq pulled 16 packages including scx-scheds. Fix: zero dnf operations — Python stdlib does JSON and zip:

# margine-image/build_files/build-margine-extensions.sh:93-100
extract_zip() {
  local zipfile="$1" target="$2"
  python3 -c "
import zipfile, sys
zipfile.ZipFile(sys.argv[1]).extractall(sys.argv[2])
" "$zipfile" "$target"
}

Generic rule: in a derived image you don't own the dependency graph; dnf remove of a "build tool" can take base features with it. Prefer tools already in the base, or contain installs to a single stage that removes exactly what it added (as 45-wsf does with meson/ninja).

Patching an upstream extension downstream

Owning the bytes in /usr means you can carry mitigations the upstream hasn't merged.

Lesson — search-light unrealize-while-mapped shell crash. Symptom: launching an app from the search overlay SIGABRTs the entire GNOME Shell on Wayland (session dies), and GNOME's crash protection then sets disable-user-extensions=true — one crash silently turns off all extensions. Root cause: Clutter:ERROR:clutter-actor.c:1989 ... assertion failed: (!clutter_actor_is_mapped (self)) — search-light's _release_ui() calls remove_child() on the entry while the overlay is still mapped; Clutter 18's stricter unrealize asserts. Reproduced via coredump + journal; upstream issues #82/#133 open, no fix in v101. Fix: one-line build-time patch — unmap before detaching — applied idempotently and soft-failing:

# margine-image/build_files/build-margine-extensions.sh:203-220 (embedded Python)
old = """  _release_ui() {
    if (this._entry) {
      if (this._entry.get_parent()) {
        this._entry.get_parent().remove_child(this._entry);"""
new = """  _release_ui() {
    if (this._entry) {
      if (this._entry.get_parent()) {
        this._entry.hide(); // margine: unmap before detach (Clutter 18 unrealize assert)
        this._entry.get_parent().remove_child(this._entry);"""
if old not in s:
    sys.exit(1)
open(p, "w").write(s.replace(old, new, 1))

Design points worth copying: match the exact surrounding context so the patch targets only the crash site (not the show-path occurrence of the same call); grep for the marker comment first so re-runs are no-ops; and if the pattern is gone (base image bumped the extension), log and continue rather than fail the build — a mitigation must not become load-bearing.

5.3 systemd user drop-ins as integration glue

Margine ships wayland-scroll-factor (WSF), an LD_PRELOAD interposer on libinput getters that scales touchpad scroll/pinch in GNOME Wayland. It's built from a pinned, checksummed tarball at image build (meson setup build --prefix=/usr --libdir=lib64, with CCACHE_DISABLE=1 because the base image's ccache PATH shim breaks in the build container). The interesting part is activation: instead of upstream's per-user ~/.config/environment.d/ + logout, the image injects the preload into the gnome-shell unit itself via a template drop-in:

# margine-image/build_files/45-wsf/install.sh:58-66 (trimmed)
# Pre-enable the preload for gnome-shell system-wide. ... inject
# LD_PRELOAD only into the gnome-shell unit (template drop-in covers
# every org.gnome.Shell@<instance>.service, including the GDM greeter,
# where it is a no-op). The library scrubs itself from LD_PRELOAD after
# loading, so gnome-shell's children do not inherit it.
install -Dm0644 /ctx/45-wsf/margine-wsf-preload.conf \
  /usr/lib/systemd/user/org.gnome.Shell@.service.d/50-margine-wsf.conf
# margine-image/build_files/45-wsf/margine-wsf-preload.conf
[Service]
Environment=LD_PRELOAD=/usr/lib64/wayland-scroll-factor/libwsf_preload.so

Why this pattern is good glue:

  • Template drop-in (org.gnome.Shell@.service.d/) applies to every instance — session and GDM greeter — with zero per-user state.
  • Scoped: only gnome-shell gets the preload (the library is additionally process-guarded and self-scrubs from LD_PRELOAD, so children don't inherit it). Compare with setting it in environment.d, which leaks into every user process.
  • Safe default: factor 1.0 is a mathematical no-op, so baking it on is inert until the user runs wsf set.
  • Layered opt-out: unit-level Environment= beats the user-manager environment (no double-load from a per-user wsf enable), and /etc drop-ins beat /usr ones — ujust wsf-preload off writes /etc/systemd/user/org.gnome.Shell@.service.d/99-margine-wsf-off.conf with UnsetEnvironment=LD_PRELOAD, never touching the image file.

This /usr ships policy, /etc overrides policy split is the systemd-native way to make image opinion user-reversible without mutating the image.

5.4 ujust recipes: the user-facing API

Anything opt-in gets a ujust recipe (Universal Blue's just wrapper). One non-obvious constraint when deriving from Bluefin:

# margine-image/build_files/60-ujust-services/install.sh:11-23 (trimmed)
# Bluefin's /usr/share/ublue-os/just/00-entry.just hardcodes the list
# of imported recipe files. The ONLY one declared as optional is
# 60-custom.just (via `import?`) — that's the documented extension
# point for downstream distros. Files dropped under any other name
# (e.g. 99-margine.just) are simply ignored by `ujust --list`.
install -Dm0644 /ctx/60-custom.just /usr/share/ublue-os/just/60-custom.just

Margine's recipe set defines the supported surface:

  • margine-bootstrap [MODE] — runs the idempotent configure-* chain (home layout, keybindings, appearance, default apps, app folders, Zen browser) and drops ~/.config/margine/bootstrapped so the XDG-autostart first-login trigger doesn't refire. User dconf/HOME state is the one thing the image can't bake — the recipe is the explicit, re-runnable bridge.
  • margine-gaming / margine-gaming-native (+ -remove) — opt-in layers; the recipe text honestly states the rpm-ostree trade-off ("+30-60s per bootc upgrade, occasional file conflicts") before the prompt. Each install recipe has a symmetric remove recipe.
  • margine-scheduler [MODE] — the sched_ext switcher (next section).
  • wsf-preload on|off|status — the /etc drop-in toggle from §5.3.
# margine-image/build_files/60-custom.just:394-419 (trimmed)
      default|stop|off)
        echo "Stopping scx_loader.service; kernel default (BORE on Margine) takes over."
        stop_loader
        ;;
      *)
        if ! scheduler_supported "$MODE"; then
          echo "Unknown or unsupported scheduler: $MODE"
          list_schedulers | sed 's/^/  - /'
          exit 1
        fi
        ensure_loader
        if scheduler_running; then
          scxctl switch --sched "$MODE"
        else
          scxctl start --sched "$MODE"
        fi

The recipe validates against scxctl list (whatever the shipped scx-scheds actually supports) instead of a hardcoded scheduler list — so a package bump can't desync the CLI from reality.

The recipe surface, mid-2026

60-custom.just is the whole user-facing API, and it has grown a few opt-in layers and safety helpers since the first cut:

  • AI layer — ujust margine-ai / margine-ai-remove. Installs Alpaca (com.jeffser.Alpaca), a Flatpak GUI that bundles its own Ollama backend — the AI layer is 100% Flatpak and lays nothing native on the host (deliberate: the base stays lean, AI is sandboxed and fully removable). GPU acceleration is wired Flatpak-side, not by layering: the recipe detects the GPU and offers the com.jeffser.Alpaca.Plugins.AMD ROCm extension for AMD, points APUs at Ollama's Vulkan backend (with an HSA_OVERRIDE_GFX_VERSION=11.0.0 note for gfx110x), and explains the NVIDIA/CPU paths. (The base already ships AMD ROCm + Mesa Vulkan inherited from Bluefin, but a Flatpak sandbox can't reach the host ROCm — hence the in-sandbox plugin.) The installed Flatpak refs are CI-validated against Flathub (§9.11).
  • Safe disk/login helpers — ujust margine-tpm-unlock / margine-autologin. Both are designed to be unfootgunnable. margine-tpm-unlock enable auto-detects the LUKS device backing root, refuses to enroll unless a passphrase/recovery keyslot will survive (the TPM can never become the sole key), only ever wipes the tpm2 slot, confirms before mutating, and post-verifies; status/disable round it out. margine-autologin on|off|status edits /etc/gdm/custom.conf idempotently (preserves other keys, BOM- and multi-[daemon]-safe, timestamped backup, SELinux relabel) and never selects root or a system account. Both were authored and hardened through an adversarial review loop before shipping.
  • Freshness from the machine — ujust margine-status / margine-update. The on-host counterpart to the /status page (§9.9): margine-status compares the booted deployment to the latest :stable and prints the Fedora → Bluefin → Margine chain plus the running kernel (uname -r — the only place the real booted kernel is knowable); margine-update stages the latest image and reboots.

5.5 tuned profiles + the scheduler picker

Margine ships the CachyOS kernel (BORE as default CPU scheduler) plus scx-scheds, with scx_loader.service disabled — sched_ext is opt-in. Two integration layers sit on top.

tuned profiles wrap the stock Fedora profiles and add a hook that nudges scx only if the user already opted in (Bazzite's pattern):

# margine-image/build_files/system_files/usr/lib/tuned/profiles/balanced-margine/tuned.conf
[main]
include=balanced
summary=Optimize balanced, flip scx scheduler mode via scxctl when scx is opt-in

[script]
script=script.sh
# .../balanced-margine/script.sh
case "$1" in
  start)
    if systemctl is-active --quiet scx_loader.service 2>/dev/null; then
      scxctl switch -m auto >/dev/null 2>&1 || true
    fi
    ;;
esac
exit 0

include= means you inherit all of Fedora's tuning and only add the delta; the is-active guard keeps the profile a strict no-op for users who never enabled scx. Same pair exists for powersave-margine and throughput-performance-margine.

The GUI picker is a zenity radiolist over scxctl list, pre-selecting the currently running scheduler, then delegating to the ujust recipe in a visible terminal:

# margine-image/build_files/system_files/usr/libexec/margine/scheduler-picker:105-122 (trimmed)
choice="$(
  zenity --list --radiolist \
    --title="$APP_NAME" \
    --text="Choose the active sched_ext scheduler." \
    --column="" --column="Scheduler" --column="Notes" \
    --print-column=2 \
    "${rows[@]}"
)" || exit 0

if command -v ptyxis >/dev/null 2>&1; then
  printf -v quoted_choice '%q' "$choice"
  ptyxis -- bash -lc "ujust margine-scheduler $quoted_choice; echo; read -rp 'Premi Invio per chiudere... '"
else
  ujust margine-scheduler "$choice"
fi

GUI and CLI funnel into the same recipe — one code path to test. The launcher additionally exposes every scheduler as a desktop Action (right-click quick picks on the dock/grid icon):

# margine-image/build_files/system_files/usr/share/applications/margine-scheduler.desktop (trimmed)
[Desktop Entry]
Name=Margine CPU Scheduler
Exec=/usr/libexec/margine/scheduler-picker
Actions=lavd;bpfland;rusty;flash;cosmos;rustland;off;status;

[Desktop Action lavd]
Name=Switch to scx_lavd (low-latency, gaming-tuned)
Exec=ptyxis -- bash -c "ujust margine-scheduler lavd; read -rp \"Premi Invio per chiudere... \""

5.6 Plymouth: a script theme with a working LUKS prompt

The theme is three files plus one plugin package:

# margine-image/build_files/50-branding/install.sh:78-97 (trimmed)
# Bluefin DX ships Plymouth core but not the script plugin.
dnf -y install plymouth-plugin-script

mkdir -p /usr/share/plymouth/themes/margine
for f in margine.plymouth margine.script watermark.png ; do
  retry_curl "${MARGINE_REPO}/${MARGINE_REF}/assets/branding/plymouth/${f}" "/usr/share/plymouth/themes/margine/${f}"
done
plymouth-set-default-theme margine
# margine-fedora-atomic/assets/branding/plymouth/margine.plymouth
[Plymouth Theme]
Name=Margine
ModuleName=script

[script]
ImageDir=/usr/share/plymouth/themes/margine
ScriptFile=/usr/share/plymouth/themes/margine/margine.script

Plymouth lives in the initramfs, so after plymouth-set-default-theme the build regenerates initramfs for every kernel with dracut --force --no-hostonly --add ostree --kver "$kver" .../initramfs.img--no-hostonly because the image must boot any hardware, --add ostree because without that module switch-root fails on bootc systems, and the output path /usr/lib/modules/<kver>/initramfs.img is what bootc expects.

Lesson — a script theme has NO built-in LUKS prompt (SetDisplayPasswordFunction is REQUIRED). Symptom: on encrypted installs, boot appears to hang on the splash. Pressing Esc (details view) reveals a passphrase prompt that was there all along. Root cause: verified in Plymouth 24.004.60 source — the script plugin advertises the display-password hook unconditionally (src/plugins/splash/script/plugin.c:497) but only runs whatever the theme registered via Plymouth.SetDisplayPasswordFunction; the slot starts null and calling a null object is a silent no-op (script-lib-plymouth.c:128, :362). The "built-in default prompt" only exists for the two-step (spinner/bgrt) and details plugins. Fix: the theme renders its own dialog with Image.Text and registers all three callbacks:

// margine-fedora-atomic/assets/branding/plymouth/margine.script:132-158 (trimmed)
fun display_password_callback (prompt, bullets)
  {
    dialog_show(prompt);
    if (bullets > ENTRY_COLUMNS) bullets = ENTRY_COLUMNS;
    bullet_text = "";
    for (i = 0; i < bullets; i++) bullet_text += "* ";
    dialog_set_input(bullet_text);
  }
Plymouth.SetDisplayPasswordFunction(display_password_callback);
Plymouth.SetDisplayQuestionFunction(display_question_callback);
Plymouth.SetDisplayNormalFunction(display_normal_callback);

Image.Text needs label-freetype.so + a font; plymouth-populate-initrd packs both unconditionally, so no extra assets.

Lesson — the initramfs runs in the C locale; multi-byte UTF-8 breaks text rendering. Symptom: the password bullets rendered as mangled â characters on a real encrypted boot (fine in casual testing). Root cause / fix, from the theme itself:

// margine-fedora-atomic/assets/branding/plymouth/margine.script:58-61
// NB: the bullet is ASCII "*", NOT U+2022 "•": the initramfs runs in the
// C/POSIX locale (no locale data packed), label-freetype decodes glyphs
// with mbrtowc which fails on multi-byte UTF-8 there — U+2022 would
// render as a mangled "â" on the real encrypted boot.

Generic rule: anything that runs pre-pivot (Plymouth themes, dracut hooks, emergency shells) must assume ASCII-only.

5.7 Branding: the paths GNOME actually reads

Branding a derived image is mostly knowing which hardcoded filenames each component consumes — and that your base image already replaced some of them with its own art.

  • About panel system logo: os-release LOGO=margine-logo resolved via icon theme → install /usr/share/icons/hicolor/scalable/apps/margine-logo.svg (+ /usr/share/pixmaps/margine-logo.png fallback), then gtk-update-icon-cache.
  • About panel distributor wordmark: Fedora's gnome-control-center build hardcodes two pixmap filenames at compile time. Deleting them shows no logo; the move is to overwrite them in place:
# margine-image/build_files/50-branding/install.sh:182-190 (trimmed)
# fedora_logo_med.png is shown on LIGHT backgrounds (so a dark-text
# wordmark); fedora_whitelogo_med.png on DARK backgrounds (white-text
# wordmark). gnome-control-center scales these 1200×300 transparent PNGs
# to the About-panel logo slot.
retry_curl_strict ".../margine-wordmark-dark.png"  /usr/share/pixmaps/fedora_logo_med.png
retry_curl_strict ".../margine-wordmark-light.png" /usr/share/pixmaps/fedora_whitelogo_med.png

Note the asset choice: a wordmark (wide, transparent) for the wordmark slot — an earlier revision put the square logo there and it rendered badly. Also note the inverted naming: fedora_logo_med = light theme = dark-text art.

  • Leftover base-image art: Bluefin overlays /usr/share/icons/hicolor/scalable/places/fedora-logo-sprite.svg (unowned by any RPM) — Margine overwrites it with an empty 296×296 SVG so icon-name lookups render nothing, and deletes nine other fedora-* pixmaps wholesale.
  • GDM greeter logo: disabled rather than replaced (the available asset was a 2400×700 banner that GDM scaled to near-fullscreen). This is also Margine's one real dconf lock — the user database physically cannot re-set the key:
# margine-image/build_files/50-branding/install.sh:201-208
cat > /etc/dconf/db/gdm.d/02-margine-logo <<'EOF'
[org/gnome/login-screen]
logo=''
EOF
mkdir -p /etc/dconf/db/gdm.d/locks
cat > /etc/dconf/db/gdm.d/locks/02-margine-logo <<'EOF'
/org/gnome/login-screen/logo
EOF

GDM uses its own profile (/etc/dconf/profile/gdmsystem-db:gdm), so greeter background/logo overrides live in gdm.d, not distro.d. Everything in this section is asserted by the CI first-boot-asset validator (chapter on CI) — branding regressions fail the build, not the user.

Alternatives & other distros

Desktop defaults:

  • gschema overrides only (zz0-bluefin-modifications) — Bluefin/Aurora; simplest, but loses to extension-local schemas and can't lock keys.
  • dconf system DB + locks — RHEL/corporate GNOME standard; Margine's choice for extensions; heavier (needs dconf update and a profile).
  • Patch upstream defaults in the schema XML itself — some spins; survives nothing, don't.
  • Home-manager/NixOS dconf.settings — declarative per-user, but manages user state, not vendor defaults.
  • KDE distros (Bazzite-KDE, Aurora… via kreadconfig/look-and-feel packages) — entirely different mechanism; settings as INI files in /etc/xdg.

Extensions:

  • Bake into /usr/share/gnome-shell/extensions at build — Bluefin, Bazzite, Margine; robust, but you own update cadence and compat patches.
  • RPM-packaged extensions from Fedora repos — Silverblue stock; only covers a small curated set.
  • Per-user install at first login (EGO download) — Margine's abandoned v1; races, shadowing, silent shell-version failures.
  • No extensions at all — openSUSE Aeon ("just GNOME"); zero maintenance, zero opinion.

Opinion-as-recipes (user-facing API):

  • ujust — Universal Blue family (Bluefin, Bazzite, Aurora, Margine); discoverable ujust --list, recipes are plain shell.
  • GUI control center (Bazzite Portal / yafti first-boot picker) — friendlier, more code to maintain.
  • NixOS — options system replaces recipes entirely; "opt-in" = flip a module option and rebuild.
  • Vanilla OS — first-setup wizard + abroot/apx for layering equivalents.

Scheduler/power integration:

  • tuned + scxctl hook — Bazzite (originator of the pattern), Margine; scx opt-in.
  • ppd (power-profiles-daemon) only — Silverblue/Aeon stock; no sched_ext story.
  • Always-on scx with a default scheduler — CachyOS (the distro); great defaults, less conservative.
  • GameMode per-process governor flips — complementary; Margine ships it for the gaming layer.

Boot splash:

  • script-plugin custom theme — Margine; full control, you must implement the password dialog (see Lesson).
  • spinner/BGRT (firmware logo + spinner) — Fedora default, Silverblue, Bluefin; zero effort, built-in prompts, weak branding.
  • two-step themed (Bazzite's themed spinner) — middle ground; password rendering built in.
  • No Plymouth (console boot) — server images, ChimeraOS (boots straight to Steam Big Picture).

Branding:

  • Overwrite hardcoded pixmap paths + os-release LOGO — Margine, Bazzite; fights the base image's own branding layer (you must strip it too).
  • Fork the fedora-logos/system-logos package — openSUSE (branding-openSUSE packages do this properly); cleanest but you maintain an RPM.
  • Leave Fedora branding intact — many personal images; honest, but users can't tell what they're running.