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 appliedbackground-colorfine, 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.jsdoesrads[Math.floor(value)]overrads = [0,16,18,20,22,24,28,32], and anif (r)guard silently dropsundefined. The shipped30.0hitrads[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.0Generic 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=7is 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
dnfinstalls in build scripts cascade. Symptom:scxctl/scx-schedsvanished from built images, twice, after unrelated "cleanup" changes. Root cause: the script diddnf5 install unzip jq…dnf5 remove jq; dnf5 autoremove.autoremovereaped scx-scheds; after removing the autoremove,dnf5 remove jqstill cascaded:scx-tools-gitdeclaresRequires: 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 removeof 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 (as45-wsfdoes 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()callsremove_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);
grepfor 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 inenvironment.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-userwsf enable), and/etcdrop-ins beat/usrones —ujust wsf-preload offwrites/etc/systemd/user/org.gnome.Shell@.service.d/99-margine-wsf-off.confwithUnsetEnvironment=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 idempotentconfigure-*chain (home layout, keybindings, appearance, default apps, app folders, Zen browser) and drops~/.config/margine/bootstrappedso 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 perbootc 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/etcdrop-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 thecom.jeffser.Alpaca.Plugins.AMDROCm extension for AMD, points APUs at Ollama's Vulkan backend (with anHSA_OVERRIDE_GFX_VERSION=11.0.0note forgfx110x), 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 enableauto-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 thetpm2slot, confirms before mutating, and post-verifies;status/disableround it out.margine-autologin on|off|statusedits/etc/gdm/custom.confidempotently (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/statuspage (§9.9):margine-statuscompares the booted deployment to the latest:stableand prints the Fedora → Bluefin → Margine chain plus the running kernel (uname -r— the only place the real booted kernel is knowable);margine-updatestages 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
scripttheme has NO built-in LUKS prompt (SetDisplayPasswordFunctionis 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 viaPlymouth.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 withImage.Textand 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.Textneedslabel-freetype.so+ a font;plymouth-populate-initrdpacks 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-releaseLOGO=margine-logoresolved via icon theme → install/usr/share/icons/hicolor/scalable/apps/margine-logo.svg(+/usr/share/pixmaps/margine-logo.pngfallback), thengtk-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 otherfedora-*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/gdm → system-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 updateand 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/extensionsat 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-logospackage — openSUSE (branding-openSUSEpackages 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.