launcher-menu: detect missing icon fallbacks on switch (iconFallbacks.enable/extra)

This commit is contained in:
Pierre Kisters committed 2026-09-27 21:16:51 +02:00
1 parent cfa9ca7e15
commit 8d7094916b
4 files changed
+229 -45

No files matched your search

+25 -13
View File
@@ -195,7 +195,8 @@ menu list every desktop entry, including terminals such as Konsole.
| `steamFrame.launcherMenu.grid.enable` | bool | `false` | Show the "+" menu's programs as a grid of tiles (large icon, name below) instead of a list. |
| `steamFrame.launcherMenu.grid.columns` | int, 1-8 | `4` | Tiles per row (popup is 300 px wide: 3 ≈ 92 px, 4 ≈ 68 px, 5 ≈ 53 px tiles). |
| `steamFrame.launcherMenu.grid.maxRows` | null or positive int | `null` | Rows visible at once, the rest scrolls. `null`: fill up to the menu's max height (600 px). |
| `steamFrame.launcherMenu.iconFallbacks` | list of str | `[ "utilities-terminal" "preferences-system" ]` | Icon names installed as hicolor copies of Breeze app icons, so the "+" menu shows them (Konsole, KDE System Settings). `[ ]`: none. Takes effect after a Steam restart. |
| `steamFrame.launcherMenu.iconFallbacks.enable` | bool | `true` | On every switch, link hicolor fallbacks (Breeze app icons) for desktop entries whose icon Steam can't find, so the "+" menu shows them (SteamOS: Konsole, KDE System Settings). `false` removes the links. See [Icon fallbacks](#icon-fallbacks-launchermenuiconfallbacks). |
| `steamFrame.launcherMenu.iconFallbacks.extra` | list of str | `[ ]` | Further icon names to provide even if no desktop entry the scan sees uses them. |
| `steamFrame.dashboard.windowMaxScale` | null or number | `null` | Largest resize-handle scale of SteamVR dashboard windows, relative to their default size. `null`: stock (2). See [Dashboard windows](#dashboard-windows-dashboard). |
| `steamFrame.dashboard.windowDistance.{world,theater,dashboard}.{min,max}` | null or number (m) | `null` | How close / far grabbed windows can be pulled in / pushed back. `null`: stock (world 0.25-5, theater 1-6, dashboard 0.3-4 m). |
| `steamFrame.dashboard.steamCloseButton.enable` | bool | `false` | Close (X) button on the dashboard's Steam window: switches to the previous window, or leaves just the dashboard bar. See [Steam close button](#steam-close-button-dashboardsteamclosebuttonenable). |
@@ -551,19 +552,30 @@ desktop's icon theme. Konsole (`Icon=utilities-terminal`) and KDE System
Settings (`Icon=preferences-system`), which SteamOS ships, have their icons
only in Breeze, so they show up without an icon in the "+" menu.
**Fix:** for each name in `iconFallbacks` (default: those two), the largest
Breeze app icon from nixpkgs' `kdePackages.breeze-icons` is installed as
`~/.nix-profile/share/icons/hicolor/scalable/apps/<name>.svg`
(`~/.nix-profile/share` is on Steam's `XDG_DATA_DIRS`); desktop entries are
left alone. Add names for other programs that lack an icon (the `Icon=`
value of their desktop entry), e.g.
`iconFallbacks = [ "utilities-terminal" "preferences-system" "system-file-manager" ];`;
`[ ]` installs nothing. A name Breeze has no app icon for fails the build
with an error naming it.
**Fix:** on every `home-manager switch`, a small activation script looks
through the desktop entries Steam sees (`applications/` in
`~/.local/share` and Steam's `XDG_DATA_DIRS`; an entry shadowed by one with
the same desktop-file ID, and `Hidden`/`NoDisplay` entries, are skipped).
For each `Icon=` name that no hicolor theme dir (or `pixmaps`) has but
nixpkgs' `kdePackages.breeze-icons` app icons do, it links the largest
Breeze SVG as `~/.local/share/icons/hicolor/scalable/apps/<name>.svg`.
Desktop entries are left alone. `iconFallbacks.extra` adds names to
provide even if no scanned entry uses them (a name Breeze lacks is
reported and skipped). The links made are listed in
`~/.local/state/steam-frame-nix/icon-fallbacks`; links no longer needed are
removed, and `iconFallbacks.enable = false` removes them all. Only those
links (still pointing into a breeze-icons store path) are ever removed;
other files in the icon dir are never touched. Programs installed later get
their fallback on the next switch.
**Caveat:** Steam caches the icon lookup (GTK icon cache) for its lifetime,
and files in the Nix store all have the same 1970 mtime, so new icons show
up only after Steam is restarted (e.g. a reboot), not on the next switch.
`iconFallbacks` used to be a list of icon names; setting a list now fails
evaluation with a message: use `iconFallbacks.extra` for additional names,
`iconFallbacks.enable = false` for what `[ ]` meant.
A running Steam picks up the change without a restart: it caches the icon
theme (GTK) and rescans it only when a theme directory's mtime changes, so
the script bumps the mtime of `~/.local/share/icons/hicolor` whenever it
adds or removes links.
### Dashboard windows (`dashboard.*`)
+76 -32
View File
@@ -23,32 +23,23 @@
# iconFallbacks is not a patch: Steam's scan of host programs resolves a
# desktop entry's Icon= name only in the hicolor icon theme (and pixmaps),
# so programs whose icon exists only in the desktop's Breeze theme (SteamOS'
# Konsole and KDE System Settings) have no icon in the menu. Hicolor copies
# from nixpkgs' Breeze are installed into the profile (~/.nix-profile/share
# is on Steam's XDG_DATA_DIRS).
# Konsole and KDE System Settings) have no icon in the menu. On every switch,
# launcher-menu/icon-fallbacks.sh looks through the desktop entries Steam
# sees and links the missing icons that nixpkgs' Breeze has into
# ~/.local/share/icons/hicolor (see the script). Until 2026-09 iconFallbacks
# was a list of names; setting a list now fails with a message pointing to
# enable/extra.
{ config, lib, pkgs, ... }:
let
cfg = config.steamFrame.launcherMenu;
inherit (import ./lib { inherit pkgs; }) mkPatch;
sharedJSContext = { title = "SharedJSContext"; };
# Largest Breeze app icon (all are SVG) as hicolor/scalable/apps/<name>.svg.
breezeApps = "${pkgs.kdePackages.breeze-icons}/share/icons/breeze/apps";
iconFallbacks = pkgs.runCommand "launcher-menu-icon-fallbacks" { } ''
dir=$out/share/icons/hicolor/scalable/apps
mkdir -p $dir
for name in ${lib.escapeShellArgs cfg.iconFallbacks}; do
src=
for size in 64 48 32 24 22 16; do
if [ -e "${breezeApps}/$size/$name.svg" ]; then src="${breezeApps}/$size/$name.svg"; break; fi
done
if [ -z "$src" ]; then
echo "steamFrame.launcherMenu.iconFallbacks: no Breeze app icon \"$name\" (${breezeApps}/<size>/$name.svg)" >&2
exit 1
fi
cp -L "$src" "$dir/$name.svg"
done
'';
iconFallbacks = pkgs.writeShellApplication {
name = "steam-frame-icon-fallbacks";
runtimeInputs = [ pkgs.coreutils pkgs.findutils pkgs.gawk ];
text = builtins.readFile ./launcher-menu/icon-fallbacks.sh;
};
in {
imports = [ ./steam-ui-patches.nix ];
@@ -122,23 +113,76 @@ in {
};
};
iconFallbacks = lib.mkOption {
type = lib.types.listOf (lib.types.strMatching "[A-Za-z0-9._+-]+");
default = [ "utilities-terminal" "preferences-system" ];
example = [ "utilities-terminal" "preferences-system" "system-file-manager" ];
default = { };
description = ''
Icon names (Icon= of desktop entries) installed as hicolor icons,
copied from nixpkgs' Breeze app icons, so the "+" menu shows them:
Steam resolves program icons only in the hicolor theme, not in the
desktop's Breeze theme. The default covers the entries SteamOS ships
whose icons exist only in Breeze: Konsole (utilities-terminal) and
KDE System Settings (preferences-system). A name Breeze has no app
icon for fails the build, naming it. [] installs nothing. Steam
picks up new icons only after a restart.
Hicolor fallbacks for program icons only the desktop's Breeze theme
has, so the "+" menu shows them: Steam resolves program icons only in
the hicolor theme.
'';
# The option used to be a list of icon names: keep a list from being
# silently misread and fail with a message instead (see the assertion).
type = lib.types.coercedTo (lib.types.listOf lib.types.str)
(names: { legacyList = names; })
(lib.types.submodule {
options = {
enable = lib.mkOption {
type = lib.types.bool;
default = true;
description = ''
On every switch, look through the desktop entries Steam sees
(XDG data dirs, shadowed and Hidden/NoDisplay entries skipped)
and, for each Icon= name no hicolor theme dir (or pixmaps)
has but nixpkgs' Breeze app icons do (SteamOS: Konsole's
utilities-terminal, KDE System Settings' preferences-system),
link the Breeze SVG as
~/.local/share/icons/hicolor/scalable/apps/<name>.svg. Links
no longer needed are removed; only links made by this option
(listed in ~/.local/state/steam-frame-nix/icon-fallbacks) are
ever touched. false removes them all.
'';
};
extra = lib.mkOption {
type = lib.types.listOf (lib.types.strMatching "[A-Za-z0-9._+-]+");
default = [ ];
example = [ "system-file-manager" ];
description = ''
Icon names to provide even if no desktop entry the scan sees
uses them (still only if hicolor lacks them). A name Breeze
has no app icon for is reported on switch and skipped.
'';
};
legacyList = lib.mkOption {
type = lib.types.nullOr (lib.types.listOf lib.types.str);
default = null;
internal = true;
visible = false;
};
};
});
};
};
config.home.packages = lib.optional (cfg.iconFallbacks != [ ]) iconFallbacks;
config.assertions = [ {
assertion = cfg.iconFallbacks.legacyList == null;
message = ''
steamFrame.launcherMenu.iconFallbacks is no longer a list of icon names
(set to ${builtins.toJSON cfg.iconFallbacks.legacyList}). Use
iconFallbacks.enable = true; # default: utilities-terminal, preferences-system
iconFallbacks.extra = [ ... ]; # further names
or iconFallbacks.enable = false; for none (what [ ] used to mean).
'';
} ];
# After installPackages, so the new profile's desktop entries and icons count.
# Disabled, the script removes its links (no BREEZE_APPS).
config.home.activation.steamFrameIconFallbacks =
lib.hm.dag.entryAfter [ "writeBoundary" "installPackages" ] (
if cfg.iconFallbacks.enable then ''
run env BREEZE_APPS=${pkgs.kdePackages.breeze-icons}/share/icons/breeze/apps \
${lib.getExe iconFallbacks} ${lib.escapeShellArgs cfg.iconFallbacks.extra}
'' else ''
run ${lib.getExe iconFallbacks}
'');
config.steamFrame.uiPatches.patches =
lib.optional cfg.sort {
+125
View File
@@ -0,0 +1,125 @@
# Hicolor fallbacks for desktop entry icons that only Breeze has.
#
# Usage: steam-frame-icon-fallbacks [EXTRA_NAME...]
# BREEZE_APPS=<breeze-icons>/share/icons/breeze/apps: link the Icon= names
# of the desktop entries Steam sees (plus EXTRA_NAMEs) that no hicolor
# theme dir has but Breeze does, as
# $XDG_DATA_HOME/icons/hicolor/scalable/apps/<name>.svg -> Breeze's SVG.
# BREEZE_APPS unset: remove all links made before (disabled).
# Links made are listed in a manifest; only those, and only while they still
# point into a breeze-icons store path, are ever removed. Nothing else in the
# icon dir is touched.
data_home=${XDG_DATA_HOME:-$HOME/.local/share}
dest=$data_home/icons/hicolor/scalable/apps
manifest=${XDG_STATE_HOME:-$HOME/.local/state}/steam-frame-nix/icon-fallbacks
# Steam's data dirs (XDG_DATA_HOME, then its XDG_DATA_DIRS on SteamOS), not
# the caller's: switches usually run from the nested desktop session.
data_dirs=(
"$data_home"
"$HOME/.local/share/flatpak/exports/share"
/var/lib/flatpak/exports/share
/usr/local/share
/usr/share
"$HOME/.nix-profile/share"
/nix/var/nix/profiles/default/share
)
shopt -s nullglob
ours() { [[ -L $1 && $(readlink "$1") == /nix/store/*-breeze-icons-*/* ]]; }
# Does Steam already find <name> (hicolor in any data dir, or pixmaps)?
# Our own links don't count.
resolves() {
local d f
for d in "$HOME/.icons" "${data_dirs[@]/%//icons}"; do
for f in "$d"/hicolor/*/*/"$1".{png,svg,xpm}; do
ours "$f" || return 0
done
done
for d in "${data_dirs[@]}"; do
for f in "$d"/pixmaps/"$1".{png,svg,xpm}; do [[ ! -e $f ]] || return 0; done
done
return 1
}
# Largest Breeze app icon for <name> (Breeze's app icons are all SVG).
breeze() {
local size
for size in 64 48 32 24 22 16; do
if [[ -e $BREEZE_APPS/$size/$1.svg ]]; then echo "$BREEZE_APPS/$size/$1.svg"; return; fi
done
return 1
}
# Icon= of a desktop entry, unless it is Hidden/NoDisplay or not a plain icon
# name (e.g. an absolute path).
entry_icon() {
awk -F= '
/^\[/ { group = ($0 ~ /^\[Desktop Entry\][[:space:]]*$/); next }
!group { next }
{ key = $1; sub(/[[:space:]]+$/, "", key); val = substr($0, index($0, "=") + 1)
gsub(/^[[:space:]]+|[[:space:]\r]+$/, "", val) }
(key == "Hidden" || key == "NoDisplay") && val == "true" { skip = 1 }
key == "Icon" { icon = val }
END { if (!skip && icon ~ /^[A-Za-z0-9._+-]+$/) print icon }
' "$1"
}
# Wanted: extra names, and the icons of the entries Steam sees (the first
# entry with a given desktop-file ID wins, like in the menu).
declare -A wanted=() seen=()
if [[ -n ${BREEZE_APPS:-} ]]; then
for name in "$@"; do wanted[$name]=extra; done
for d in "${data_dirs[@]}"; do
[[ -d $d/applications ]] || continue
while IFS= read -r -d '' f; do
id=${f#"$d/applications/"}; id=${id//\//-}
[[ -z ${seen[$id]:-} ]] || continue
seen[$id]=1
icon=$(entry_icon "$f")
[[ -z $icon ]] || wanted[$icon]=${wanted[$icon]:-$id}
done < <(find -L "$d/applications" -name '*.desktop' -print0 2>/dev/null)
done
fi
changed=
linked=()
for name in "${!wanted[@]}"; do
resolves "$name" && continue
if ! src=$(breeze "$name"); then
[[ ${wanted[$name]} != extra ]] || echo "icon fallbacks: Breeze has no app icon \"$name\" (extra)" >&2
continue
fi
link=$dest/$name.svg
if [[ -e $link || -L $link ]] && ! ours "$link"; then continue; fi # not ours
linked+=("$name")
[[ $(readlink "$link") == "$src" ]] && continue
mkdir -p "$dest"
ln -sfn "$src" "$link"
echo "icon fallbacks: linked $name (${wanted[$name]})"
changed=1
done
# Remove links from earlier runs that are no longer wanted.
if [[ -f $manifest ]]; then
while IFS= read -r name; do
[[ -n $name && " ${linked[*]} " != *" $name "* ]] || continue
if ours "$dest/$name.svg"; then
rm -f "$dest/$name.svg"
echo "icon fallbacks: removed $name"
changed=1
fi
done < "$manifest"
fi
if (( ${#linked[@]} )); then
mkdir -p "${manifest%/*}"
printf '%s\n' "${linked[@]}" | sort > "$manifest"
else
rm -f "$manifest"
fi
# GTK rescans an icon theme only when a theme dir's mtime changes; bump it
# so a running Steam sees the change.
if [[ -n $changed && -d $data_home/icons/hicolor ]]; then touch "$data_home/icons/hicolor"; fi
+3
View File
@@ -26,6 +26,9 @@
# closeOnLaunch = true;
# launchDebounce = 10;
# grid = { enable = true; columns = 4; maxRows = 4; };
# # Icon fallbacks for programs without an icon in the menu are on by
# # default; names to provide in addition to what the scan finds:
# # iconFallbacks.extra = [ "system-file-manager" ];
# };
# # SteamVR dashboard windows: resizable up to 4x (stock 2x), pushed
# # back up to 10 m in the world / 12 m in theater mode (stock 5 / 6 m):