Forcing external display only on Ubuntu login

Published at "2026/09/26"

I've got the laptop plugged into an AOC 27" monitor and I wanted the built-in panel to just stay off after launch.

What I wanted

Every time I log in at the desk, I've wanted Ubuntu to detect that it is connected to external monitor and switch to external-only mode. AOC on, laptop panel off. If the HDMI cable isn't there, leave the laptop screen alone.

I've started with the obvious idea: a few xrandr lines, run them at startup. That fell apart in a few different places.

Boot is too early

A system service at boot can't do this. There is no display session yet, so there is nothing to reconfigure.

My session is GNOME on X11 (ubuntu-xorg, DISPLAY=:0). The display server shows up when I log in, not when the machine finishes booting. So this has to be a user autostart entry, and it has to wait a bit, because GNOME applies its own layout a few seconds after login and will overwrite whatever the script just did.

xrandr was almost right

This is what xrandr showed:

eDP-1 connected
HDMI-1-0 connected primary 1920x1080+0+0

eDP-1 had no * next to any mode. The panel is connected, but it isn't driving the picture. HDMI-1-0 was the only active output. On the desk, things already looked the way I wanted.

Then I've opened ~/.config/monitors.xml. The configuration for "both screens are plugged in" was one logical monitor with the AOC and the built-in panel together. That's mirror mode. The refresh rate saved there was 74.973. Mutter's preferred mode for that AOC is 1920x1080@60.000.

So the live layout and the saved layout disagreed. Next login, GNOME would put the mirror back. An autostart xrandr --output eDP-1 --off would race that restore and lose.

There's another trap in the output names. The AOC is HDMI-1-0, not HDMI-1, and xrandr also lists a bunch of disconnected DP-1-* ports. Hardcoding a connector name is a good way to aim at the wrong one.

Talking to Mutter instead

I've learned that GNOME owns this layout through org.gnome.Mutter.DisplayConfig on the session bus.

GetCurrentState returns a serial, the monitors, the logical monitors, and a few properties. Each monitor has is-builtin. My external AOC comes back with is-builtin false.

I've found out that the call that actually changes things is ApplyMonitorsConfig:

  • serial from GetCurrentState. A stale serial gets rejected.
  • method: 0 verify, 1 temporary, 2 persistent.
  • logical monitors, each one (x, y, scale, transform, primary, monitors).
  • each monitor inside that is (connector, mode_id, properties).
  • a properties dict. I've passed the current layout-mode back through it (it was 3 on this session). Dropping that felt like a good way to surprise myself with scaling later.

Mode id is a string like 1920x1080@60.000, from the mode flagged is-current, or is-preferred if the output is off.

gdbus prints this as one giant nested tuple. Fine if I want to look at it once. Bad if a script has to read it every login. Ubuntu already ships PyGObject, so the script uses gi.repository.Gio and unpacks the variant properly.

I've checked the call with method 0 first. Wrong signature, Mutter just refuses, screens don't blink. The one that worked:

properties = {
    "layout-mode": GLib.Variant("u", layout_mode),
}

variant = GLib.Variant(
    "(uua(iiduba(ssa{sv}))a{sv})",
    (serial, 2, logical_monitors, properties),
)

Per-monitor properties can be an empty {}. Method 2 is the persistent one, so the choice survives the next login.

monitors.xml shows up late

I've applied that, then immediately opened ~/.config/monitors.xml. The mirror block was still there. Mutter writes the file a moment later. A second run in the same second still thought the saved layout was wrong.

When the file did update, the HDMI mode wasn't the interesting part. This was:

<disabled>
  <monitorspec>
    <connector>eDP-1</connector>
    <vendor>SHP</vendor>
    <product>LQ156T1JW04</product>
    <serial>0x00000000</serial>
  </monitorspec>
</disabled>

GNOME picks a saved configuration by the set of monitors that are plugged in, not by the ones that are turned on. A config that only mentions the AOC means "only the AOC is connected". If both are connected and the laptop panel should stay off, that panel has to be listed under <disabled>.

The other configuration is still there: laptop alone, eDP-1 at 1920x1080. That's the one that should come back when I unplug the cable. I've matched those entries by vendor, product and serial. Connector names on this machine are already messy enough.

Don't blink if nothing changed

Applying the same layout again still goes through a modeset. I don't want that on every login.

The script exits without calling ApplyMonitorsConfig when both of these are true:

  • the live logical monitors are only the external ones (built-in panel isn't in any of them, and they aren't mirrored)
  • monitors.xml already has a configuration for this exact set of connected monitors, with the built-in panel under <disabled>

First persistent apply fixes the file. Logins after that should just note already external only and stop. If nothing external is connected, it stops even earlier and doesn't touch the laptop panel.

Autostart

I've added the script to ~/.local/bin/external-display-only. Desktop entry:

[Desktop Entry]
Type=Application
Name=External display only
Exec=/home/lukaszkups/.local/bin/external-display-only
Terminal=false
X-GNOME-Autostart-enabled=true
X-GNOME-Autostart-Delay=5

X-GNOME-Autostart-Delay=5 is so this runs after GNOME has applied monitors.xml. The script also waits up to 30 seconds for Mutter to show up on the session bus, in case autostart wins the race the other way.

--dry-run prints the layout and doesn't apply it. Handy while the variant packing is still wrong.

If you every wanted to have auto-switch functionality on your linux boot-up - feel free to use this knowledge for your needs 😉

Best,

-- Å‚.