Force specific X11 windows onto the primary monitor
Some Linux applications open splash screens or authentication dialogs on the
wrong monitor even though the desktop settings and xrandr agree on which
display is primary. This can happen when an application ignores the RandR
primary-output flag and centers a window relative to X11 coordinate (0, 0).
This article describes a per-application workaround tested with Cinnamon on X11. It targets:
- the 300×350 pixel splash screen of the Flatpak version of Vesktop;
- Git Credential Manager dialogs.
Unlike rearranging the complete X11 desktop, these rules do not reverse the logical direction between monitors.
Confirm the primary monitor
Check the RandR and Cinnamon view of the display layout:
xrandr --listmonitors
The primary output has an asterisk:
Monitors: 2
0: +*DisplayPort-1 2560/597x1440/336+1920+0 DisplayPort-1
1: +DisplayPort-0 1920/531x1080/299+0+0 DisplayPort-0
Setting it again is harmless, but does not help applications that ignore the flag:
xrandr --output DisplayPort-1 --primary
Replace DisplayPort-1 with the output marked as primary on your machine.
Identify the affected window
Open the misplaced window, then inspect managed windows:
wmctrl -lxG
For more detail, select the window interactively:
xprop WM_CLASS _NET_WM_NAME
The relevant classes in this example are:
dev.vencord.vesktop
git-credential-manager
Vesktop uses the same class for its main window and splash screen. Its splash can instead be distinguished by its fixed client size of 300×350 pixels.
Install dependencies
On Debian and Ubuntu based distributions:
sudo apt install python3-xlib x11-xserver-utils wmctrl xdotool
Add the window rule
Create ~/.local/bin/primary-monitor-window-rules:
#!/usr/bin/env python3
"""Move selected application windows to the XRandR primary monitor."""
import re
import subprocess
import threading
import time
from Xlib import X, Xatom, display, error
def primary_geometry():
output = subprocess.check_output(
["xrandr", "--listmonitors"], text=True, stderr=subprocess.DEVNULL
)
for line in output.splitlines():
if "*" not in line:
continue
match = re.search(r"\s(\d+)/\d+x(\d+)/\d+\+(-?\d+)\+(-?\d+)", line)
if match:
width, height, x, y = map(int, match.groups())
return x, y, width, height
raise RuntimeError("XRandR primary monitor not found")
connection = display.Display()
root = connection.screen().root
root.change_attributes(
event_mask=X.SubstructureNotifyMask | X.PropertyChangeMask
)
connection.flush()
def position_if_matched(window_id):
local_connection = display.Display()
local_root = local_connection.screen().root
window = local_connection.create_resource_object("window", window_id)
# A window may be mapped just before WM_CLASS becomes available.
for _ in range(10):
try:
wm_class = tuple(
value.lower() for value in (window.get_wm_class() or ())
)
geometry = window.get_geometry()
vesktop_splash = (
any("vesktop" in value for value in wm_class)
and geometry.width == 300
and geometry.height == 350
)
credential_dialog = "git-credential-manager" in wm_class
if vesktop_splash or credential_dialog:
monitor_x, monitor_y, monitor_w, monitor_h = primary_geometry()
target_x = monitor_x + (monitor_w - geometry.width) // 2
target_y = monitor_y + (monitor_h - geometry.height) // 2
if credential_dialog:
# Ask the window manager to move the Avalonia dialog and
# the frame that it adds around the client window.
subprocess.run(
[
"xdotool",
"windowmove",
hex(window_id),
str(target_x),
str(target_y),
],
check=False,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
else:
# Vesktop's frameless Electron splash can be moved
# directly through its outermost X11 window.
top_level = window
while True:
parent = top_level.query_tree().parent
if parent.id == local_root.id:
break
top_level = parent
top_level.configure(x=target_x, y=target_y)
local_connection.flush()
local_connection.close()
return
except (error.XError, error.DisplayConnectionError):
local_connection.close()
return
time.sleep(0.025)
local_connection.close()
client_list_atom = connection.intern_atom("_NET_CLIENT_LIST")
def inspect_managed_clients():
clients = root.get_full_property(client_list_atom, Xatom.WINDOW)
if clients:
for window_id in clients.value:
threading.Thread(
target=position_if_matched,
args=(int(window_id),),
daemon=True,
).start()
# Also handle a matching dialog that was open before the rule started.
inspect_managed_clients()
while True:
event = connection.next_event()
if event.type == X.MapNotify:
threading.Thread(
target=position_if_matched,
args=(event.window.id,),
daemon=True,
).start()
elif event.type == X.PropertyNotify and event.atom == client_list_atom:
inspect_managed_clients()
Make it executable:
chmod +x ~/.local/bin/primary-monitor-window-rules
The _NET_CLIENT_LIST listener is important for Git Credential Manager. Its
toolkit creates an intermediate frame before the actual client window is
registered, so listening only for X11 map events may miss it.
Start the rule automatically
Create ~/.config/autostart/primary-monitor-window-rules.desktop:
[Desktop Entry]
Type=Application
Name=Primary monitor window rules
Comment=Move selected application windows to the primary monitor
Exec=/bin/sh -c "$HOME/.local/bin/primary-monitor-window-rules"
OnlyShowIn=X-Cinnamon;
X-GNOME-Autostart-enabled=true
NoDisplay=true
Start it immediately without logging out:
setsid ~/.local/bin/primary-monitor-window-rules \
>"${XDG_RUNTIME_DIR:-/tmp}/primary-monitor-window-rules.log" 2>&1 \
< /dev/null &
Fully quit and reopen Vesktop to test its splash screen. Git Credential Manager will be repositioned whenever its next authentication dialog appears.
Troubleshooting
Confirm that the rule is running:
pgrep -af primary-monitor-window-rules
Inspect a window's actual X11 position:
xwininfo -id WINDOW_ID
wmctrl -lxG is useful for finding the window ID, although some applications
report confusing geometry there. xwininfo is more reliable for confirming
the final position.
This solution is specific to X11. Wayland compositors use different APIs and security rules, so an X11 event listener is not a general Wayland window-rule mechanism.