UI-Branding of NixOS guests on HyperV host with RDP access

Hi everyone,
I’m new working with NixOS , so please be kind to newbies… :wink:

We are currently working on a scientific test setup using and
connecting some llm and AI services. To ensure a declarative
approach, we are going to implement a declarative approach based on
NixOS 25.11 guests running on a Windows Server 2019 HyperV host
in order to establish a small mesh of internal VMs behind an edge-VM
to the internet and leave the maintenance to admins/users used to
Microsoft infrastructure. To ease setups and operations, we have
used a NixOS hybrid configuration so that the base configuration is
defined and we use flake.nix on each of the VMs to customize services
and modules.

In order to ensure reliabity and security on the host and the guests,
we’ve tried to change the UI of the Guest-VMs according to a role scheme

  • red → infrastructure,
  • yellow → services,
  • green → static machines (mainly data)

Some (standard) VMs for users and user agents should stay with the
Breeze scheme of sddm standard/KDE without changes.

Thus we have tried to implement ‘UI-branding’ by a small ‘screenlayout.nix’
which is loaded by flake.nix, having the hope that within the HyperV
console we can use color codes and in addition hostname + ipv4-address
in order to ease life of the admins. The same should work with
wallpapers within RDP session (the users are used to Terminalservices
on their MS machines…), i.e. we want to realize two aspects:

  1. Hyper‑V console on the server (the built‑in VM console) with a
    colorized UI/theme and additional info (hostname + ipv4) on the
    logon screen
  2. RDP sessions (Plasma/X11) wallpapers + hostname + ipv4 in the top
    ‘row’ of the window

Below is a summary of what we have achieved so far, what does not work,
and where we are stuck. We would greatly appreciate any technical insights
from people who know the internals of SDDM, Plasma, Hyper‑V, or
NixOS boot/session ordering better than we do.

============================================================

GOAL 1 – Hyper‑V console branding

Intended:

  • Show a role‑specific theme (with colorized logon screen, hostname,
    ipv4 and after being logged in the wllpaper/info like in RDP)
  • Display hostname + IPv4 address directly in the console

Reality:

  • whatever we did we ended up with MAUI fallback in the console window
  • It does not display SDDM, X11, Plasma, QML, images, or panels
  • It cannot show colored backgrounds (or themes?)
  • It cannot show IP address early in boot (network not up yet)
  • It currently also does NOT respect console.keyMap = "de"; (still US layout)

Status:

  • Hyper‑V console branding appears technically impossible with the
    mechanisms we’ve tried so far
  • Keyboard layout in Hyper‑V console remains US (open issue)

============================================================

GOAL 2 – RDP session branding (Plasma/X11)

Intended:

  • Role‑specific wallpaper (muted color palette)
  • Hostname + IPv4 displayed at the top of the wallpaper
  • Consistent behavior in every RDP session dependedn on
    the role pattern

What we implemented:

  • A Nix derivation that generates a wallpaper per role
  • A render script that overlays host information from
    /run/vm-hostinfo
  • An autostart script that applies the wallpaper via
    plasma-apply-wallpaperimage

Status:

  • Works reliably in local Plasma sessions
  • Works inconsistently in RDP sessions because:
    • RDP starts its own Plasma session
    • Autostart runs before plasmashell is fully ready
    • /run/vm-hostinfo may not exist yet
    • Timing is fragile

============================================================

SDDM THEMING (for completeness)

  • For non‑legacy roles we generate a custom SDDM theme
    (color per role, hostinfo text)
  • For the “legacy” role we intentionally fall back to
    the built‑in Breeze theme (i.e. we do not want to change
    standards)
  • On NixOS 25.11, Breeze is automatically available when
    Plasma6 + SDDM are enabled
  • SDDM theming works as expected

============================================================

OPEN QUESTIONS

  1. Is there any supported way to influence the Hyper‑V console
    appearance beyond plain TTY text?
  2. Is there a reliable way to ensure /run/vm-hostinfo exists
    before SDDM and before RDP sessions start?
  3. Is there a robust pattern for Plasma autostart that guarantees
    plasmashell is ready (especially in RDP sessions)?
  4. Is there a recommended NixOS idiom for per‑session overlays
    (hostname/IP) that works across local + RDP Plasma sessions?

We need some expert advice to simplify the NixOS UI branding and
have some insight into the competing mechanisms…!
Thankx in advance
Rolf

============================================================

As with respect to the UI branding, we have loaded the
following screenlayout.nix:

{ config, pkgs, lib, … }:

let

-----------------------------------------

VM roles

-----------------------------------------

role = “gateway”; # gatewayVM / serviceVM / databaseVM / legacy

-----------------------------------------

color code per role

-----------------------------------------

roleColors = {
gateway = { sddm = “#CC0000”; wall = “#5A0000”; };
service = { sddm = “#F57C00”; wall = “#7A3F00”; };
datab = { sddm = “#107C41”; wall = “#08331F”; };
legacy = null;
};

colors = roleColors.${role};

-----------------------------------------

SDDM-Theme as Nix-derivation

-----------------------------------------

mkSddmTheme = { name, bgColor }:
pkgs.stdenv.mkDerivation {
name = “sddm-theme-${name}”;
src = null;
dontUnpack = true;

  installPhase = ''
    mkdir -p $out/${name}

    # mandatory: metadata.desktop
    cat > $out/${name}/metadata.desktop <<EOF

[Desktop Entry]
Name=${name}
Comment=Mesh Theme (${name})
Type=Theme
Version=1.0
MainScript=Main.qml
ConfigFile=theme.conf
EOF

    # mandatory: theme.conf
    cat > $out/${name}/theme.conf <<EOF

[General]
type=sddm-theme
name=${name}
version=1.0
backgroundColor=${bgColor}
EOF

    # main-QML
    cat > $out/${name}/Main.qml <<EOF

import QtQuick 2.15
import QtQuick.Controls 2.15
import SddmComponents 2.0
import Qt.labs.qmlmodels 1.0 # file access

Rectangle {
width: Screen.width
height: Screen.height
color: “${bgColor}”

Column {
    anchors.horizontalCenter: parent.horizontalCenter
    anchors.top: parent.top
    anchors.topMargin: 40
    spacing: 10

    Text {
        id: hostinfo
        color: "white"
        font.pointSize: 28

        Component.onCompleted: {
            var f = File.open("/run/vm-hostinfo", File.ReadOnly);
            if (f) {
                var content = f.readAll();
                f.close();

                var lines = content.split("\\n");
                var disp = "DISPLAY unavailable";
                var ip = "IP unavailable";

                for (var i = 0; i < lines.length; i++) {
                    if (lines[i].startsWith("DISPLAY="))
                        disp = lines[i].substring(8);
                    if (lines[i].startsWith("IPV4="))
                        ip = lines[i].substring(5);
                }

                text = disp + "  (" + ip + ")";
            } else {
                text = "Hostinfo unavailable";
            }
        }
    }
}

Column {
    anchors.horizontalCenter: parent.horizontalCenter
    anchors.verticalCenter: parent.verticalCenter
    spacing: 20

    UserList { width: 300 }
    PasswordField {
        id: password
        width: 300
        placeholderText: "Password"
        onAccepted: sddm.login(userList.currentUser, password.text)
    }
    Button {
        text: "Login"
        width: 300
        onClicked: sddm.login(userList.currentUser, password.text)
    }
}

}
EOF
‘’;
};

-----------------------------------------

dynamic wallpaper → plasma (hostname + ipv4)

-----------------------------------------

mkWallpaper = { name, baseColor }:
pkgs.stdenv.mkDerivation {
name = “wallpaper-${name}”;
buildInputs = [ pkgs.imagemagick ];
src = null;
dontUnpack = true;

  installPhase = ''
    mkdir -p $out

    # generate base picture
    magick -size 3840x2160 xc:'${baseColor}' \
      \( -size 3840x2160 xc: +noise Random \) \
      -compose overlay -define compose:args=7 -composite \
      $out/base.png

    # render-script -> dynamic data
    cat > $out/render.sh <<EOF

#!/bin/sh
. /run/vm-hostinfo
magick $out/base.png
-gravity north
-pointsize 64 -fill white
-annotate +0+80 “$DISPLAY ($IPV4)”
$out/wallpaper.png
EOF
chmod +x $out/render.sh
‘’;
};

sddmTheme =
if role == “legacy” then null
else mkSddmTheme { name = “role-${role}”; bgColor = colors.sddm; };

wallpaper =
if role == “legacy” then null
else mkWallpaper { name = “role-${role}”; baseColor = colors.wall; };

in
{

-----------------------------------------

activate X11

-----------------------------------------

services.xserver.enable = true;

keymap

services.xserver.xkb = {
layout = “de”;
variant = “”;
};

plasma6 desktop

services.desktopManager.plasma6.enable = true;

force SDDM via old X11-module

services.xserver.displayManager.sddm = {
enable = true;
wayland.enable = false;
};

set SDDM-theme

services.displayManager.sddm.theme = “role-${role}”;

services.displayManager.sddm.settings = {
General = {
InputMethod = “”;
EnableVirtualKeyboard = false;
};
X11 = {
Layout = “de”;
Variant = “”;
};
};

-----------------------------------------

install theme to SDDM-path

-----------------------------------------

environment.systemPackages = [
(pkgs.runCommand “install-sddm-theme” {} ‘’
mkdir -p $out/share/sddm/themes/role-${role}
cp -r ${sddmTheme}/role-${role}/* $out/share/sddm/themes/role-${role}/
‘’)
];

-----------------------------------------

dynamic wallpaper → plasma

-----------------------------------------

system.activationScripts.wallpaper = ‘’
mkdir -p /home/vmadmin/.config/autostart
mkdir -p /home/vmadmin/.local/bin

cat > /home/vmadmin/.local/bin/set-wallpaper.sh <<EOF

#!/bin/sh
${wallpaper}/render.sh
plasma-apply-wallpaperimage ${wallpaper}/wallpaper.png
EOF

chmod +x /home/vmadmin/.local/bin/set-wallpaper.sh
chown vmadmin:users /home/vmadmin/.local/bin/set-wallpaper.sh

cat > /home/vmadmin/.config/autostart/set-wallpaper.desktop <<EOF

[Desktop Entry]
Type=Application
Exec=/home/vmadmin/.local/bin/set-wallpaper.sh
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
Name=Set Dynamic Wallpaper
EOF

chown vmadmin:users /home/vmadmin/.config/autostart/set-wallpaper.desktop

‘’;

-----------------------------------------

overlay-panel → hostinfo

-----------------------------------------

system.activationScripts.overlayPanel = ‘’
mkdir -p /home/vmadmin/.local/share/plasma/layout-templates
mkdir -p /home/vmadmin/.local/bin
mkdir -p /home/vmadmin/.config/autostart

cat > /home/vmadmin/.local/bin/create-overlay-panel.sh <<EOF

#!/bin/bash

---------------------------------------------------------

1. wait for plasma (max. 10 Sekunden)

---------------------------------------------------------

for i in {1..20}; do
if qdbus org.kde.plasmashell /PlasmaShell >/dev/null 2>&1; then
break
fi
sleep 0.5
done

quit if Plasma doesn’t appear

if ! qdbus org.kde.plasmashell /PlasmaShell >/dev/null 2>&1; then
exit 0
fi

---------------------------------------------------------

2. check for panel

---------------------------------------------------------

if qdbus org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell.dumpCurrentLayout | grep -q “vmhostinfo-panel”; then
exit 0
fi

---------------------------------------------------------

3. generate panel

---------------------------------------------------------

panel=$(qdbus org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell.createPanel “bottom”)
qdbus org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell.setPanelVisibility “$panel” “alwaysvisible”
qdbus org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell.setPanelHeight “$panel” 40
qdbus org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell.setPanelOpacity “$panel” 0
qdbus org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell.setPanelName “$panel” “vmhostinfo-panel”

---------------------------------------------------------

4. enhance by text-widget

---------------------------------------------------------

widget=$(qdbus org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell.addWidget “$panel” “org.kde.plasma.notes”)

---------------------------------------------------------

5. Inhalt setzen

---------------------------------------------------------

DISPLAY_VALUE=$(grep DISPLAY= /run/vm-hostinfo | cut -d= -f2)
IP_VALUE=$(grep IPV4= /run/vm-hostinfo | cut -d= -f2)
qdbus org.kde.plasmashell /PlasmaShell org.kde.PlasmaShell.setWidgetText “$widget” “$DISPLAY_VALUE ($IP_VALUE)”
EOF

chmod +x /home/vmadmin/.local/bin/create-overlay-panel.sh
chown vmadmin:users /home/vmadmin/.local/bin/create-overlay-panel.sh

cat > /home/vmadmin/.config/autostart/overlay-panel.desktop <<EOF

[Desktop Entry]
Type=Application
Exec=/home/vmadmin/.local/bin/create-overlay-panel.sh
Hidden=false
NoDisplay=false
X-GNOME-Autostart-enabled=true
Name=Overlay Panel
EOF

chown vmadmin:users /home/vmadmin/.config/autostart/overlay-panel.desktop

‘’;
}

This is way too much information. I bet there will be rarely somebody who has the time to read all that information, digest it and give an answer. I would highly recommend to ask a specific question.