(Sorry for the multi-post, but it’s better if I write as much info as possible before loosing interest and potentially abandoning my setup
)
Overall there are a lot of moving pieces to get this right.
What needs to be done:
- install Niri and Wayland utilities
- need to start
qvm-start-daemon, with the following flags: --all --watch --kde
- need to start
xwayland-satellite, with the right PATH to use the wrapped Xwayland
- need a custom script to update the Niri configuration (wip, need to rewrite it since there are no color files in
4.3.0-rc3 from what I can tell, we need to extract everything from the qubes.xml)
- need to disable X11 authentication with
xhost +
Important: in my experience, there should be only one process for each.
I’ll explain in details each of those in this post.
Installing niri and Wayland utilities
Here is what I installed:
qubes-dom0-update niri
qubes-dom0-update qubes-desktop-linux-common-wayland
Maybe Wayland utilities are not necessary, YMMV
qvm-start-daemon
This is supposed to be started by an XDG autostart.
The two relevant files are:
/etc/xdg/autostart/qvm-start-daemon.desktop
/etc/xdg/autostart/qvm-start-daemon-kde.desktop
I edited them so that the KDE variant starts on Niri, but it’s not working (it doesn’t get started on Niri anymore)
I’m a bit tired and just want something to work, so I just added a line in my Niri configuration to start the daemon:
spawn-at-startup "bash" "-c" "sleep 1 && xhost + && qvm-start-daemon --all --watch --kde"
Disabling X11 authentication
X11 authentication is not handled super well.
I’m not sure how it fits into the whole threat model, but what I did was to disable it with xhost +
See the line at the end of the section above for how I plugged it into Niri.
Note that there is a delay of 1 second because we need to wait for xwayland-satellite to be started in order for xhost to edit the right “X context”
Starting xwayland-satellite
This one is SUPER tricky.
The issue will be that there will be many xwayland-satellite running and that they will conflict (I assume).
The solution was to disable as much of them as I could.
There are three:
- The Niri xwayland-satellite
- The SystemD xwayland-satellite
- The mystery xwayland-satellite
1. Niri’s xwayland-satellite
This is the xwayland-satellite that is auto-started by Niri.
To disable this, I put this in my config:
xwayland-satellite {
off
}
And here, I start my own xwayland-satellite with the right PATH:
spawn-at-startpu "bash" "-c" "env PATH=\"/usr/libexec/qubes/wrappers:/usr/bin\" xwayland-satellite"
2. SystemD’s xwayland-satellite
Not sure why this one got installed
sudo systemctl --global disable xwayland-satellite.service
I will put some tips here if someone (or me) attempts to do this again in the future:
- Make sure that there is one instance of each process running. If there are more than 1, there is a problem
- Niri autostarts xwayland-satellite which can be disabled with a config option
- SystemD starts xwayland-satellite as well
- XDG autostarts start xwayland-satellite as well
xhost + should be ran after xwayland-satellite has started
- I think but not sure that
qvm-start- daemon needs to start after xhost + has been added but not 100% sure
- There are no color files anymore apparently anymore in
4.3.0-rc3 which is something to take into account when writing the script that automatically writes the Niri configuration
- If a window appears but it’s blank, it means that the
LD_PRELOAD wasn’t added (said another way, that xwayland-satellite is not using the right Xwayland; it should always use /usr/libexec/qubes/wrappers/Xwayland which sets the right LD_PRELOAD)
- If a window doesn’t appear, it’s possible that there are duplicate or missing processes. List of processes that should be started below:
- qvm-start-daemon
- xwayland-satellite
- Xwayland
- If a window doesn’t appear, it’s also possible that you didn’t run
xhost + or that you ran it before xwayland-satellite started and thus it failed. Make sure to wait 1s after xwayland-satellite started before running xhost +
- Starting
xwayland-satellite can fail because there could be another instance running. The xwayland-satellite you see in ps -aux | grep xwayland-satellite could be another one that the one that is supposed to be running (make sure to confirm that the parent process is right using htop; also, you can check the env of the process by looking at /proc/<PID>/environ)
Putting more stuff here since Discourse disallows 3 replies in a row
Rewrote the script for 4.3.0-rc3
@antlers rewrote your script since there is no $HOME/.local/share/qubes-kde/$LABEL.colors anymore even when starting with the --kde flag.
Not sure if it’s a real change or if I’m just doing something wrong.
In my solution, I compute the active and inactive colors manually in Python using the base label color.
Otherwise it’s the same spirit as your script, and runs every 5 seconds.
It could definitely be simplified, some functions are not even used, but I’m happy with it.
My only concern is battery usage, not sure how much reading/writing files every 5 seconds could force the CPU to wakeup somehow and cause issues?
~/Documents/niri_borders_daemon.py
#!/usr/bin/env python3
#
# The goal of this script is to automatically apply
# QubesOS borders in Niri
#
# This works by checking the qubes.xml file every 5 seconds
# and then editing our Niri configuration file.
#
# For it to work, make sure to add these two lines in the middle of
# your Niri configuration:
#
# // #+START_NIRI_QUBES_RULES
# // #+END_NIRI_QUBES_RULES
#
# And of course, start it in Niri automatically, with something
# like this:
#
# spawn-at-startup "bash" "-c" "~/Documents/niri_borders_daemon.py"
import json
import subprocess
import os
# import xml.sax as sax
# import xml.parsers.expat as expat
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from time import sleep
def get_niri_windows():
"""
Call 'niri msg -j windows' to get the list
of windows that are currently displayed.
This returns the raw output parsed from JSON.
"""
out = subprocess.run(['niri', 'msg', '-j', 'windows'], capture_output=True)
windows_str = out.stdout.decode('utf-8')
windows = json.loads(windows_str)
return windows
# XXX: not used, but I want to keep it for later since it might
# become useful
def get_niri_qube_windows():
"""
Return sugared Niri windows list, including
the Qube name
"""
ret = []
for window in get_niri_windows():
app_id = window['app_id']
if ":" in app_id:
qube_name = app_id.split(":", maxsplit=1)[0]
app_name = app_id.split(":", maxsplit=1)[1]
else:
qube_name = "dom0"
app_name = app_id
ret.append({
"qube_name": qube_name,
"app_name": app_name,
"window_id": window["id"]
})
return ret
#### qubes.xml reading and parsing
# The path to the qubes.xml path
# I think maybe it requires passing the --kde file for
# it to appear? It's not super super clear.
qubes_xml_path = "/var/lib/qubes/qubes.xml"
def get_qube_domains():
"""
Get the list of Qube domains with their label
"""
# Here, we open the qubes.xml file and read it.
# There is probably an easier way to do this but well,
# I am no Python expert and I don't have Internet
# so I'll use raw syscalls lol.
qubes_xml_fd = os.open(qubes_xml_path, flags=0)
qubes_xml_stat = os.stat(qubes_xml_path)
qubes_xml_str = os.read(qubes_xml_fd, qubes_xml_stat.st_size).decode('utf-8')
# Note: it's really hard to get XML parsing to work without Internet
# so I'll just use terrible "split" to get the data that I want...
#
# Here, I want to access the list of qubes (their names)
# and find their colours, so I can build a Map<QubeName, Colour>
# that I will then be able to apply in Niri.
# This pattern finds all the "domains"
pattern = re.compile("(?ms)<domain id=\".+?\" class=\".+?\">.+?</domain>")
# Gets us a list of domains in XML str format
domains_str_list = pattern.findall(qubes_xml_str)
ret = []
for domain in domains_str_list:
# Get the properties into a nice dictionnary
properties = re.findall("<property name=\"(.+?)\">(.+?)</property>", domain)
# Available properties:
# - label
properties_dict = {}
for prop in properties:
properties_dict[prop[0]] = prop[1]
ret.append(properties_dict)
return ret
def add_to_color(input_color: str, amount: int) -> str:
"""Add (or remove) a given integer to each word of a color."""
ret = ""
for i in [0, 2, 4]:
color_str = input_color[i:i+2]
color_int = int(color_str, 16)
color_int = min(max(color_int + amount, 0), 255) # clamp into hex range
ret+= "{:02x}".format(color_int)
return ret
def get_qube_labels():
"""
Get the list of QubesOS labels.
We are renaming the fields to make it
clearer to the end user + adding an inactive
colour.
"""
tree = ET.parse(qubes_xml_path)
root = tree.getroot()
ret = []
for label in root.findall("./labels/label"):
label_color = label.get("color").replace("0x", "")
# color_inactive = ""
# for i in [0, 2, 4]:
# color_str = color_active[i:i+2]
# color_int = int(color_str, 16)
# color_int = max(color_int - 20, 0) # clamp
# color_inactive += "{:02x}".format(color_int)
ret.append({
"id": label.get("id"),
"color_active": "#" + add_to_color(label_color, 30),
"color_inactive": "#" + add_to_color(label_color, -30),
"name": label.text
})
return ret
# Here is where we craft the Niri configuration
# for each domain!
def generate_niri_config():
config = ""
labels = get_qube_labels()
for domain in get_qube_domains():
# Skip malformed domains
if "name" not in domain or "label" not in domain:
continue
label_data = None
for label in labels:
if label["name"] == domain["label"]:
label_data = label
break
if not label_data: # skip if we don't have the label
continue
config += '''window-rule {PARENTHESIS_STARTING}
match app-id="{domain_name}:*"
border {PARENTHESIS_STARTING}
active-color "{color_active}"
inactive-color "{color_inactive}"
{PARENTHESIS_ENDING}
{PARENTHESIS_ENDING}
'''.format(**{
"domain_name": domain["name"],
"color_active": label_data["color_active"],
"color_inactive": label_data["color_inactive"],
"PARENTHESIS_STARTING": "{",
"PARENTHESIS_ENDING": "}"
})
return config.strip()
while True:
# Generate the Niri blocks to insert
niri_blocks = generate_niri_config()
# Actually insert them into the config file.
delim_start = "\n// #+START_NIRI_QUBES_RULES\n"
delim_end = "\n// #+END_NIRI_QUBES_RULES\n"
niri_config_path = str(Path.home()) + "/.config/niri/config.kdl"
with open(str(Path.home()) + "/.config/niri/config.kdl", "r+") as f:
config = f.read()
if delim_start not in config or delim_end not in config:
print("ERROR: Missing start or/and end delimiters in configuration.")
print("Make sure to add the following in your config:")
print(delim_start.strip())
print(delim_end.strip())
exit(1)
# save config for later, when checking if there
# is a difference or not
old_config = config
# Parse and reassemble config with the generated Niri blocks
(before, middle) = config.split(delim_start)
after = config.split(delim_end)[1]
config = before + delim_start + niri_blocks + delim_end + after
config = config.strip() + '\n' # for good measure
print("done generating config")
# Writing to the disk only if there is a difference
# because maybe it'll minimize SSD tear?
if config != old_config:
with open(niri_config_path + ".bak", "w") as bak:
bak.write(config)
f.seek(0)
f.write(config)
f.truncate()
print("INFO: Niri config ")
sleep(5)
~/.config/niri/config.kdl
Finally, we add our script as a startup command in Niri + add delimiters:
// NOTE: the blocks below are autogenerated
// #+START_NIRI_QUBES_RULES
// #+END_NIRI_QUBES_RULES
// Line below auto-starts the Niri borders daemon
spawn-at-startup "bash" "-c" "~/Documents/niri_borders_daemon.py"
Next steps
- adding window title (probably going to use Waybar for this)
- getting a background
- fixing screen brightness keys
- fixing waybar indicators being wrong: Wifi, CPU, RAM
- maybe documenting / reimplementing the most useful tray icons from Xfce?
- writing documentation for new users
Getting windows title to show in Waybar
First create the .config/waybar directory and seed it:
mkdir ~/.config/waybar
cp /etc/xdg/waybar/config.json ~/.config/waybar
cp /etc/xdg/waybar/style.css ~/.config/waybar
Add this in the .config/waybar/config.jsonc
"niri/window": {}
This will display the currently focused window’s title in the
top left corner of the screen.
This is taking a lot of space so definitely not perfect but at least it works…
I also removed Wifi + CPU + Memory information by commenting them out because it takes space and provides no value.