Hi all,
I recently built a small wallpaper “factory” in my NixOS flake and wanted to share the pattern (I can’t share the repo, since it’s something I did for my company).
The goal is simple: drop PNG files into a directory, and let Nix generate the GNOME wallpaper package and metadata automatically.
How it works
The layout looks like this:
pkgs/wallpapers/
package.nix
mk-background.nix
mk-dynamic-background.nix
walls/
simple-background.png
dynamic-background/
light.png
dark.png
The convention is:
walls/simple-background.png
-> wallpapers.simple-background
walls/dynamic-background/light.png
-> wallpapers.dynamic-background_light
walls/dynamic-background/dark.png
-> wallpapers.dynamic-background_dark
walls/dynamic-background/{light,dark}.png
-> wallpapers.dynamic-background_dynamic
Each generated wallpaper installs:
share/backgrounds/<name>.png
share/gnome-background-properties/<name>.xml
For light/dark pairs, the generated GNOME XML uses both:
<filename>...</filename>
<filename-dark>...</filename-dark>
So GNOME can switch wallpapers based on the active light/dark theme.
The aggregate package is built with:
nix build '.#wallpapers'
and can be installed system-wide like any other package:
{ pkgs, ... }:
{
environment.systemPackages = [
pkgs.wallpapers
];
}
The part I like most is that adding a new wallpaper does not require touching Nix code. For a static wallpaper, add:
walls/foo.png
For a dynamic GNOME wallpaper, add:
walls/bar/
light.png
dark.png
The factory discovers it with builtins.readDir, creates the derivations, and exposes them individually through passthru.
This is not a big framework, just a small convention over files. But it has been useful for keeping branding assets declarative, reproducible, and easy to update.
What about the code?
I don’t really like saying that, but here it’s mostly true: the process is quite simple. I have two packages to handle the generation of GNOME XML background properties files:
mk-background.nix
{
lib,
stdenv,
name,
src,
}:
let
bgOutdir = "share/backgrounds";
bgOutfile = "${bgOutdir}/${name}.png";
in
stdenv.mkDerivation (finalAttrs: {
inherit name src;
dontUnpack = true;
dontPatch = true;
dontConfigure = true;
dontBuild = true;
dontFixup = true;
installPhase = ''
runHook preInstall
mkdir -p $out/${bgOutdir}
cp $src $out/${bgOutfile}
mkdir -p $out/share/gnome-background-properties
cat <<EOF > $out/share/gnome-background-properties/${name}.xml
<?xml version="1.0"?>
<!DOCTYPE wallpapers SYSTEM "gnome-wp-list.dtd">
<wallpapers>
<wallpaper deleted="false">
<name>${name}</name>
<filename>$out/${bgOutfile}</filename>
<options>zoom</options>
<shade_type>solid</shade_type>
<pcolor>#ffffff</pcolor>
<scolor>#000000</scolor>
</wallpaper>
</wallpapers>
EOF
runHook postInstall
'';
passthru = {
gnomeFilePath = "${finalAttrs.finalPackage}/${bgOutfile}";
};
meta = {
license = lib.licenses.free;
platforms = lib.platforms.all;
};
})
mk-dynamic-background
{
writeTextFile,
name,
lightBg,
darkBg,
}:
writeTextFile {
name = "${name}-background-info";
destination = "/share/gnome-background-properties/${name}.xml";
text = ''
<?xml version="1.0"?>
<!DOCTYPE wallpapers SYSTEM "gnome-wp-list.dtd">
<wallpapers>
<wallpaper deleted="false">
<name>${name}</name>
<filename>${lightBg.gnomeFilePath}</filename>
<filename-dark>${darkBg.gnomeFilePath}</filename-dark>
<options>zoom</options>
<shade_type>solid</shade_type>
<pcolor>#ffffff</pcolor>
<scolor>#000000</scolor>
</wallpaper>
</wallpapers>
'';
}
And the main package which aggregates all wallpapers in a single derivation:
package.nix
{
lib,
callPackage,
runCommandLocal,
}:
let
mkBackground = callPackage ./mk-background.nix;
mkDynamicBackground = callPackage ./mk-dynamic-background.nix;
wallpapers = lib.filterAttrs (
_name: type: type == "directory" || type == "regular"
) (builtins.readDir ./walls);
sanitizeName = name: lib.replaceString "." "-" name;
wallpaperDrvs = lib.concatMapAttrs (
name: _:
let
sanitizedName = sanitizeName name;
nameWithoutSuffix = sanitizeName (lib.removeSuffix ".png" name);
singleBg =
if
(lib.pathIsDirectory ./walls/${name} == false) && lib.pathExists ./walls/${name}
then
mkBackground {
name = nameWithoutSuffix;
src = ./walls/${name};
}
else
null;
lightBg =
if lib.pathExists ./walls/${name}/light.png then
mkBackground {
name = "${name}-light";
src = ./walls/${name}/light.png;
}
else
null;
darkBg =
if lib.pathExists ./walls/${name}/dark.png then
mkBackground {
name = "${name}-dark";
src = ./walls/${name}/dark.png;
}
else
null;
in
lib.optionalAttrs (singleBg != null) { "${nameWithoutSuffix}" = singleBg; }
// (lib.optionalAttrs (lightBg != null) { "${sanitizedName}_light" = lightBg; })
// (lib.optionalAttrs (darkBg != null) { "${sanitizedName}_dark" = darkBg; })
// (lib.optionalAttrs (lightBg != null && darkBg != null) {
"${sanitizedName}_dynamic" = mkDynamicBackground {
inherit name lightBg darkBg;
};
})
) wallpapers;
in
runCommandLocal "wallpapers" { passthru = wallpaperDrvs; } ''
mkdir -p $out/share/{backgrounds,gnome-background-properties}
for wall in ${lib.concatStringsSep " " (lib.attrValues wallpaperDrvs)}; do
cp -r $wall/* $out
done
''
Small note about the
sanitizeNamehelper functionI created it because I want to be able to name my wallpapers like
foo.bar.pngorfoo.bar/, while still referencing them aswallpapers.foo-barinstead ofwallpapers."foo.bar". This is unnecessary if you don’t have dots in your wallpaper names.
Optional: Replacing the default NixOS GNOME wallpaper
I also use the generated package to set the default GNOME wallpaper through nixos-gsettings-overrides:
environment.sessionVariables.NIX_GSETTINGS_OVERRIDES_DIR = lib.mkForce "${
pkgs.gnome.nixos-gsettings-overrides.override {
nixos-background-light = pkgs.wallpapers.dynamic-background_light;
nixos-background-dark = pkgs.wallpapers.dynamic-background_dark;
}
}/share/gsettings-schemas/nixos-gsettings-overrides/glib-2.0/schemas";
Optional: Setting a custom GDM login screen
For GDM, I use the same generated wallpaper derivation and patch gnome-shell with the final store path exposed by the wallpaper package:
Overlay
{ pkgs, ... }:
{
nixpkgs.overlays = [
(
final: prev:
let
# This is exposed from creating a `gdm.png` file in `walls/`!
wallpaper = pkgs.wallpapers.gdm;
in
{
gnome-shell = prev.gnome-shell.overrideAttrs (oldAttrs: {
buildInputs = (oldAttrs.buildInputs or [ ]) ++ [ wallpaper ];
patches = (oldAttrs.patches or [ ]) ++ [
(final.replaceVars ./gdm-background.patch {
backgroundPath = wallpaper.gnomeFilePath;
})
];
});
}
)
];
}
Patch
diff --git a/data/theme/gnome-shell-sass/widgets/_login-lock.scss b/data/theme/gnome-shell-sass/widgets/_login-lock.scss
index b8f5ae9..9560976 100644
--- a/data/theme/gnome-shell-sass/widgets/_login-lock.scss
+++ b/data/theme/gnome-shell-sass/widgets/_login-lock.scss
@@ -231,7 +231,10 @@ $_gdm_dialog_width: 25em;
}
#lockDialogGroup {
- background-color: $_gdm_bg;
+ background: $_gdm_bg url(file://@backgroundPath@);
+ background-repeat: no-repeat;
+ background-size: cover;
+ background-position: center;
}
// Clock
The custom GDM login screen doesn’t work very well on multi-screen setups, since the image covers all monitors.
I didn’t find a solution (yet) about this issue. If anybody has pointers though, I’m all ears!
Cheers!