Commit Graph
21 Commits
Author SHA1 Message Date
LGUG2Z e563988258 feat(wm): fullscreen is left alone
A fullscreen window covers its monitor on purpose. Moving it would take it out
of fullscreen, or be ignored, so Window::set_position does nothing for a
fullscreen window, and every move the window manager makes goes through it.

The bridge reports KWin's fullScreenChanged as a fullscreen event, and the table
keeps what it says, which is what the window manager asks. The window stays in
its container, and the others are tiled as if it were not fullscreen. When it
comes back from fullscreen, KWin gives it the frame it had before, which may not
be its tile any more, so the workspace is tiled again.

Section E of the features suite checks it in a real Plasma session: a fullscreen
window covers the monitor, a window that opens does not take it out of
fullscreen, and it is in a tile that overlaps no other when it comes back.
2026-10-28 07:20:57 -07:00
LGUG2Z d6ad3b4292 feat(wm): a window the user maximizes
KWin maximizes a window for the user as well as for komorebi: the title bar, a
double click and a shortcut do it, and nobody asks the window manager. A window
that stayed a tile would be asked to move at the next retile, so komorebi has to
know.

The bridge reports KWin's maximizedChanged as a maximized event, true only when
the window is maximized both ways. The table turns it into a MaximizeChange, and
adopt_maximize_change takes the window out of the layout as toggle-maximize
does, or, when the user restores it, puts it back in a container of its own at
the index it came from. A window that is already maximized when komorebi starts,
or that opens maximized, is the workspace's maximized window from the start.
komorebi's own maximize is reported too and finds the workspace as it left it,
so it changes nothing.

While a window is maximized, what is asked of the layout does nothing, by the
same rule as for toggle-maximize.

A maximized window of a monitor that is unplugged goes to the monitor that is
left and stays maximized: evacuate_monitor makes it the maximized window of the
workspace it goes to, and the maximized window that workspace had goes back to
its tile. Until now it came back as a tile.

vm-user-maximize maximizes windows with kdotool in a headless KWin, and checks
the daemon's state, the frames, that the layout commands do nothing, and that
toggle-maximize and the user's restore both put the window back in its tile.
2026-10-28 07:20:57 -07:00
LGUG2Z c8dc92a236 feat(wm): a monitor changes size
When an output gets another resolution or scale, or a panel starts, grows or
hides, the work area the layout fills is not the one komorebi tiled in, and
nothing moved the windows until something else tiled them again.

The bridge now sends the outputs, with their geometry and the work area of the
desktop each shows, when KWin says they changed, when a dock window opens,
closes or moves, and once a second, because KWin has no signal for the room a
panel leaves. The table turns a monitor whose size or work area changed into a
monitor notification. It is not a window event, since it is about no window: it
goes to the monitor reconciliator, a thread that takes the window manager's lock
as the threads of events and messages do, reads the size and work area of every
monitor again and tiles the workspace in front of the focused monitor. Another
monitor's windows keep their tiles until a retile. Every subscriber is notified
of it, whether or not the state changed.

vm-hotplug starts with the scale of one of two outputs changing under a running
daemon. The VM library gets kde.outputs, which lists, disables, enables and
scales outputs, for the commits that unplug and plug them.
2026-10-28 07:20:57 -07:00
LGUG2Z 9a0693f5d1 feat(kwin): focus follows mouse through KWin's focus policy
KWin has a focus policy that gives the focus to the window under the pointer, so
komorebi asks for it and follows where the focus goes, as it does for any change
of focus. Both implementations komorebi for Windows names are the same thing
here. The KWin backend sets the focus policy and no delay in the user's kwinrc
and has KWin read it, and puts the two keys back as the user had them when it is
turned off and, with every other setting, when komorebi exits. The state of the
daemon says whether it is on.

komorebic gets focus-follows-mouse and toggle-focus-follows-mouse. The session
node gets a pointer that a test drives from inside the guest: the program
komorebi-pointer makes an absolute device with uinput, and komorebi_vm.Pointer
sends it commands. vm-focus-follows-mouse moves it over each of two windows and
checks the focus, then the policy that is put back.

komorebi.json takes focus_follows_mouse, which is on with Windows or Komorebi,
and a file that leaves it out turns it off and gives the user's focus policy
back.

Loading a configuration asks the backend for the focus policy now, so the tests
that load one do it with a backend installed.
2026-10-28 07:20:49 -07:00
LGUG2Z abfa8c6a5c feat(kwin): easing curves for animations
An animation that moves a window at one pace all the way looks flat, and the
stock Geometry Change has one curve, an exponential ease out. The patched effect
reads a Curve setting, the name of one of Qt's easing curves. komorebi's styles
are those curves with Ease in front, so the look the backend is asked for names
the curve as Qt does, and the effect's settings have it next to the duration. A
cubic Bezier, which only a configuration can say, is not one of Qt's curves and
is an ease out. With the stock effect every style is that ease out.

komorebic gets animation-style, for every animation or the movement of windows
alone. vm-features puts two curves a few seconds into a 30 s move of the same
window and checks that they are in different places.

The animation key of komorebi.json takes a style too, for every kind of
animation or for one.
2026-10-28 07:20:49 -07:00
LGUG2Z 22c14ba5be feat(kwin): a border color for each kind of window
The stock KDE Rounded Corners has one color for the active window and one for
the others, and komorebi colors a window by what it is to the layout: the
focused window, the others, a locked container that does not have the focus, a
floating window that has it, a stack, a monocle. komorebi's build of the effect
takes a color for each window over D-Bus. After every event and message the
border manager works out the kind of each window and tells the backend, which
sends the effect what changed, and tells it that no window has a color when
borders are off. Without that build the borders are the look's two colors, and
the backend says so once.

komorebic gets border-colour. The fake desktop keeps the colors it was told, and
gets window_commands, which leaves the look out of the list of what the window
manager asked for. vm-features reads the colors of the kinds of window in a real
session.

The colors of a stack and of a monocled window are read from the fake and, in
vm-features, from the pixels. The border_colours key of komorebi.json is not
here: its colors are written as the Colour of komorebi-themes, which the themes
(49) bring, and 49 adds the key.

The maximize check of vm-features, which was the whole suite, reads a pixel a
quarter of the way into the window and not the middle one: komorebi puts the
pointer in the middle of the window it focuses, and a screenshot has the arrow
on it, white. It read the arrow on most runs once the border effect was loaded.

vm-features waits for the panel's room too, where it starts the daemon for its
sections B to E: the border colors of section C are read from the pixels around
a window, which the panel can cover.
2026-10-28 07:20:49 -07:00
LGUG2Z 2c35e4bb6f feat(kwin): borders drawn by the compositor
komorebi draws no border. KWin's KDE Rounded Corners effect does, outside each
window in the gap the layout leaves, and komorebi says how it looks. The window
manager describes a Look (a border with a width, a radius and two colors) and
asks the backend for it with Backend::apply_look after every event, message and
retile. The backend answers whether borders are being drawn, and the layout
leaves room around the windows only while they are: a border with no effect to
draw it takes none.

The KWin backend writes the effect's settings into the user's kwinrc, with every
inner outline off (the effect's default draws one over the title bar), loads the
effect when borders are on and has it read them again. It unloads the effect and
puts the user's settings back when the borders go or komorebi exits. The module
installs the effect (effects.borders), which is built to stay unloaded until
komorebi asks, and vm-look reads the pixels around two windows in a real
session.

The session library of the VM tests also waits for the panel of the Plasma shell
now: a window komorebi tiles before the panel is made is tiled under it, which
hides the bottom of its border from a screenshot.

Kde.wait_for_panel_room waits until the daemon's work area is not the whole of
its monitor, which is the daemon having learned that the panel takes room, and
vm-look calls it where the daemon starts, before the windows open. The shell
makes its panel and KWin takes room for it a while later, which the daemon
learns of up to a second after, so a daemon that tiles in between puts the
windows under the panel and tiles them again, and a screenshot taken between the
two shows frames as high as the screen. It is a wait and not a check, since a
session that is too slow, as TCG often is, may never take the room.

komorebic gets border, border-width and border-offset, and visible-windows.

komorebi.json takes the same settings as border, border_width and border_offset,
and border_radius, which has no command. The module warns when config asks for
borders and the effect is not installed.
2026-10-28 07:20:49 -07:00
LGUG2Z 05de20749b feat(wm): maximize
A window could be given the work area only as a tile of the layout, which has
padding around it. `komorebic toggle-maximize` has KWin maximize the focused
window instead: it gets the work area of its monitor with the panels left alone,
and it leaves the layout, so that it is in no container. Toggled again, the
window is restored, put in a container of its own at the index it was taken
from, and the workspace is tiled again.

The window is kept in the workspace as its maximized window, with the index it
goes back to, and the state shows both. While it is maximized the workspace asks
KWin to maximize it again on every tiling, and does not move the containers
behind it. The messages that are about the tiles (move, stack, resize, promote,
preselect, lock, float, monocle, and the ones that send a container to another
monitor or workspace) are dropped before they are handled, since there is no
tile to act on. Focusing in a direction finds no container on any side of it, so
the focus goes to the monitor on that side, where a maximized window or a
monocle is given the focus whole. Showing the workspace again restores the
maximized window and focuses it ahead of the containers' windows. A window in
monocle that is maximized leaves the monocle, and one that closes takes the
maximize with it. Backend gets a maximize command, which does nothing unless a
backend implements it; the fake desktop and the KWin backend do, and the bridge
carries it out with KWin's own `setMaximize`. A maximize also makes the bridge
forget the position it was last asked to put the window at, which it would
otherwise set again on the work area's size when the window's frame changed
within a second of a tiling.

komorebi/tests/maximize.rs and maximize_commands.rs drive it against the fake
desktop, with one monitor and with two. The bridge's tests and the backend's
tests read the maximize command from the same fixture, so that what one sends is
what the other carries out. vm-features is a new session suite that reads
screenshots: the first thing it checks is that the maximized window fills the
work area, is the one on top, and goes back to its tile; the vm-commands sweep
runs the new command.
2026-10-28 07:20:37 -07:00
LGUG2Z 0d21dd332c feat(wm): monocle
A workspace could show its windows only side by side. Monocle gives one
container the whole work area: the focused container is taken out of the
workspace's containers, so the layout gives no container a tile, and the next
tiling puts its window on the work area less the padding. Turning it off puts
the container back at the index it came from, and the tiles come back as they
were resized: the container keeps its resize adjustment, and the workspace does
not shorten the list of adjustments while a monocle is on.

The windows of the other containers are hidden on the parking desktop, as the
windows behind the front one of a stack are, and the front window of each is
shown again when the monocle is turned off or the window in monocle closes. A
stack in monocle stays a stack.

`komorebic toggle-monocle` turns it on and off. What focusing in a direction
does meanwhile is set with `monocle-focus-behaviour` or switched with
`toggle-monocle-focus-behaviour`: with `cycle` the monocle goes to the previous
or the next container, with `no-op`, the default, no container is on any side
and the focus falls through to the monitor on that side, where the monocle
window of its workspace, if it has one, is given the focus, and the command
fails where there is no monitor. A container that is moved to a monitor or sent
to a workspace in monocle turns that monocle off first. `cycle-focus` always
carries the monocle along. Giving the focus to another window gives it back to
the monocle window, so that it stays in front. `move` and `cycle-move` are
refused and `toggle-float` is ignored while a window is in monocle, since the
focused container is not the window in front. The monocle container is in the
state, with the index it goes back to, and a window in it that closes takes the
monocle with it.

komorebi/tests/monocle.rs drives it against the fake desktop, with one monitor
and with two. vm-core-e2e checks in a real KWin that the focused window fills
the work area, that the state has the monocle container, that toggling back
restores the tile, that the cycle behavior gives another window the work area,
and that the windows behind a monocle are parked, which a clean stop and the
next start after a crash put right; vm-stacks checks a monocle over a stack; the
vm-commands sweep runs the new commands.
2026-10-28 07:20:37 -07:00
LGUG2Z 331db681e2 feat(wm): stack out of sight on a parking desktop
A container has one tile, so the windows of a stack that are not in front have
to be out of sight. KWin has nothing a script can set to hide a window except a
virtual desktop nobody looks at, which the first spike measured to cost no focus
change where minimizing a window cost two. Backend::hide takes a window out of
sight and Backend::restore brings it back; the KWin backend parks the window on
a desktop named komorebi-park, which the table keeps out of the workspaces, and
remembers where each hidden window came from so that it is still on its
workspace.

KWin saves a desktop in kwinrc as soon as it is made, so komorebi removes the
parking desktop when it stops, after the windows are back, and the first
snapshot of the next run finds one a killed run left, sends its windows home and
removes it. The windows komorebi hid are recorded, so that a minimize the
compositor reports for one is not mistaken for the user's.

komorebic stack, unstack, stack-all and unstack-all stack and unstack windows.
The window container behavior and the float override decide whether a window
that opens joins the focused container or floats, for every workspace or for
one. vm-stacks stacks windows in a headless KWin, shows a workspace and goes
back, stops, and kills the daemon with a window parked.
2026-10-28 07:20:37 -07:00
LGUG2Z 62e37e44cf feat(kwin): workspaces are KWin's own desktops
komorebi's workspaces exist on Windows and macOS because neither has a virtual
desktop API: switching hides every window of the workspace being left. KWin has
virtual desktops, per output since 6.8, so here workspace i is KWin's desktop i
on every monitor, and each monitor shows one. KWin shows, hides and focuses the
windows of a desktop; komorebi asks for a switch, puts a window on a desktop,
and follows what anything else does.

The Backend gets the workspace methods (ensure_workspaces, switch_workspace,
set_window_workspace, window_workspace, current_workspaces), and the fake its
desktops. The bridge reports the desktops, the desktop each output shows and a
window's desktops, and carries out output_desktop and desktop commands, typed
and tested with the fixtures like the rest. The table turns a switch or a move
komorebi did not make into a WorkspaceChange or a WorkspaceMove, and records its
own when it sends them, so that KWin's report of those raises nothing. The
window manager follows a switch with no command in reply
(adopt_workspace_change), puts windows on their desktops before a desktop is
shown (focusing a window first would take the monitor back), and loads the
windows open at startup onto the workspace of their desktop.

Per-output desktops are a kwinrc setting that is off by default, and
kwriteconfig6 was not on the PATH of the user service in a live session, so
komorebi edits kwinrc itself (kwin::kwinrc) and puts the user's value back when
it stops, also after a crash.

focus-workspace, move-to-workspace and send-to-workspace come with this commit,
since the tests of switching and moving, and vm-workspace-switch, need them; the
named and cycling workspace commands follow. vm-workspace-switch runs the daemon
against a real KWin; vm-kwin-contract checks what komorebi relies on from KWin's
per-output desktops, on a headless KWin with two outputs and no komorebi.
2026-10-28 07:20:37 -07:00
LGUG2Z 062fe57fac feat(wm): float
Some windows are better left out of the layout. `komorebic toggle-float` takes
the window that has the focus out of its container into the floating windows of
the workspace, which komorebi gives no tile and does not move, and the other
windows share the work area; on a floating window it puts it back in a container
at the focused place.

A floating window is in the index of managed windows like any other, so its
events find it, and it is in the state as the workspace's `floating_windows`.
Where a floated window goes is the `toggle_float_placement` of the window
management behaviour: by default it stays where it is, since the spike's
centering of floated windows waited for a configuration file. Centering and
resizing to the floating aspect ratio come with it.

Opening windows floating (the float override and the float rules), the floating
layer and its own focus and move commands come later.

The vm-commands sweep floats a window and puts it back, and its health check
counts the floating windows.
2026-10-28 07:20:37 -07:00
LGUG2Z 41c32aad8e feat(wm): layouts
Every workspace started in BSP and stayed there. `komorebic change-layout` gives
the focused workspace one of the layouts of komorebi-layouts, `cycle-layout
next|previous` steps through them, and `flip-layout` mirrors the tiles along an
axis, on top of the flip there is. A flip mirrors the tiles, not the order of
the containers, and the directions of focus and move are flipped with it, so
`focus left` still goes to the tile on the left.

`toggle-tiling` turns the tiling of the focused workspace off and on: while it
is off, no window of the workspace is moved, and the windows that open are taken
in and tiled once it is on again.

`preselect-direction` reserves the place on one side of the focused window for
the next window: an empty container holds it, the layout gives it a tile, and
the next window takes it. `cancel-preselect` gives it back.

A new VM suite, vm-commands, after the spike's m8 script, runs every layout with
five, three, two and one windows against a real KWin with two outputs, and then
every komorebic command there is, checking after each that the daemon is alive,
answers, and has every window KWin has, and that KWin has the windows where the
daemon asked. Every command that comes later adds its line to that sweep.
2026-10-28 07:20:37 -07:00
LGUG2Z a86e2bdc63 build(nix): a NixOS module for komorebi
On NixOS, running komorebi should take one flake input and one module line. The
flake exports `services.komorebi` as nixosModules.default (and .komorebi). It
adds the flake's overlay, which gains komorebi-full, installs komorebi and
komorebic, and runs the daemon in every Plasma session as a systemd user unit,
wanted by plasma-workspace.target and ordered after plasma-core.target, which
Plasma orders after KWin. systemd starts a daemon that fails again; the exit
status of a SIGTERM (130) counts as success, so a stopped unit is not failed.
The unit's PATH is the one a terminal in the session has, instead of the few
store paths NixOS gives a unit.

The options are enable, package, logLevel and a read-only execStart, the command
line the unit runs. There is no configFile: the daemon reads no configuration
file yet, and an option that linked one would do nothing.

nixos-module-eval evaluates seven scenarios as real NixOS systems and checks
their assertions, the unit and execStart, with builtins.tryEval for the two that
must not evaluate. Nothing in it builds a Linux package, so it runs on a Mac as
well.

The VM nodes import the module, so every suite runs komorebi as the module
installs it. The headless node's komorebi@NAME runs execStart, which has
--log-level, so the nodes set logLevel to debug and the suites stop passing it;
latency, which times the daemon, keeps the default level. The session node
starts the module's unit at login; render-smoke turns it off, since a tiled
terminal would cover the desktop it looks at. A new fast suite, vm-module, logs
in to Plasma and checks that the unit runs the daemon with the module's log
level and PATH, that a window opened in the session is tiled, that a killed
daemon is started again, and that a stop is not a failure.

docs/installation.md covers NixOS first, and building from source on other
distributions. There is no binary cache: the Komorebi license does not allow
third parties to redistribute binaries.
2026-10-28 07:20:37 -07:00
LGUG2Z 6f657e53e3 feat(cli): komorebic
The daemon answers on its socket, but a person has no way to write a message on
it. komorebic is that way: each subcommand that asks something of the daemon is
one message, sent with komorebi-client, and a question's answer is printed.
komorebic is a separate program because the daemon takes no input but its
socket, so a key binding, a script and a terminal all drive it the same way.

It has what the daemon can answer so far: state, monitor-information, query,
retile and stop. Around them, start runs the komorebi on the PATH in the
background and passes it what follows a --, such as --fake-backend to try it
without a compositor; log follows the daemon's log, which the daemon now writes
to a file a day in LOG_DIR as well as to stdout; data-directory shows where the
socket is; completions prints a shell completion script. The hidden docgen
writes each subcommand's help to docs/cli, in fences that name their language
and wrapped at 100 columns whatever the width of the terminal, so that the pages
are the same wherever they are generated; just docgen runs it.

The user guide starts here: docs/index.md for KDE Plasma, and a page on running
komorebi. tests/cli.rs runs each subcommand against a socket the test serves in
place of the daemon. In the VM tests the guests get komorebic next to the
daemon, and vm-core-e2e reads the daemon's state with it (the windows it
manages, the one it focuses, after KWin restarts too) and stops it with
komorebic stop, which unloads the bridge and removes the socket.

In the VM test library, Kde.state asks again, a second (times the scale) after
an empty answer or a failed command, up to 10 times. The daemon drops a query
when it cannot take hold of the window manager within a second, and komorebic
then prints an empty line and succeeds; komorebic itself gives the daemon a
second to answer and fails when that is not enough. A daemon busy with the first
windows does either on a machine that is loaded or slow, and a suite that polled
the state died on JSONDecodeError. An answer that is not JSON still fails at
once.
2026-10-28 07:20:37 -07:00
LGUG2Z 4386003e30 feat(kwin): survive KWin restarting
KWin can go away under a running daemon while the session bus stays: it crashed,
or kwin_wayland --replace took its place. Its windows go with it, and so does
the bridge, which would have reported them closing; in the spikes the old
windows vanished without a single removed event. What the daemon does see is the
bus, so the thread that serves the connection watches NameOwnerChanged for
org.kde.KWin. When the name loses its owner, Table::compositor_lost marks every
window closed and returns a Destroy for each, so the window manager takes them
out of its layout, and their ids are not given out again. When the name has an
owner again, the daemon loads the bridge into the new KWin, trying for up to 15
seconds while KWin is not ready for it, and the bridge reports the new desktop
in a snapshot.

zbus does not end a held call when its caller leaves the bus, so the Poll of the
old bridge is still waiting when the new bridge calls, and it was woken first:
it took the commands for the new KWin and answered them to nobody.
vm-kwin-restart lost the tile of a window opened in the third KWin that way in
one run of three, and a recording of the bus showed the batch going to the old
bridge. A Poll held since before KWin last left the bus is answered with nothing
and passes its wake-up on to the next call, which a unit test holds.

The VM suite vm-kwin-restart (from the spike's spike7-resync) restarts the
headless KWin twice under a running daemon, through Kde.restart_kwin in the VM
library, and checks that the daemon sees KWin go, takes the old windows out of
its layout, loads the bridge into the new KWin, and tiles the windows that open
there.
2026-10-27 14:50:53 -07:00
LGUG2Z b463191d00 feat(kwin): move and focus windows through a held call
With the commands of the Backend trait carried out, the daemon tiles a real
KWin. A command looks up the UUID KWin knows the window by, is queued as JSON
for the bridge, and returns; the held Poll call is answered with the queue, one
millisecond after the first command, so that a retile reaches KWin as one batch.
The bridge sets the frame, activates, raises, minimizes, restores or closes the
window, and reports what it could not do. set_rect records the frame it asked
for in the table, so the window manager sees it at once, and the geometry event
that follows says what the client made of it.

A KWin script cannot move the pointer, so the pointer follows the focus through
KWin's own "Move Mouse to Focus" action, invoked over kglobalaccel once KWin
reports the window active.

The commands are typed in bridge.js, and the declarations of KWin's API let the
bridge set a window's frame, its minimized state and the active window, raise
and close it, and nothing else. The session the tests of both sides share grows
a second batch: the backend's tests check that set_rect, focus, raise, minimize,
unminimize and close send it, and the bridge's tests answer the bridge with it
and check what KWin is asked to do, down to the command for a window that has
closed, which comes back as failed. The bridge's tests also cover findWindow's
fallback to KWin's list, a frame set as a plain object, and a command KWin
throws on.

Three VM suites check it against a real KWin. vm-core-e2e (from the spike's
m2-e2e, the sections that need no komorebic) compares what the daemon asked for,
read from its debug log, with what KWin has, read with kdotool: three windows
inside the screen, not overlapping, sharing it, the same focused window on both
sides, and the same again after a minimize, a restore and a close.
vm-client-geometry (slow) has eight real clients answer the frames komorebi asks
for: Qt programs and xterm take them exactly, foot sizes itself to whole cells,
GTK 4 programs and Firefox keep to their minimum sizes. vm-latency (slow) times
retiles from a recording of the session bus: on HVF, from the daemon's answer to
the last frame KWin reports, p50 17.3 ms, p95 27.7 ms. vm-kwin-events no longer
checks that nothing moves. The VM library gives session commands the test user's
home and data directories rather than root's, which a GTK client was refused.

The bridge's second request for a position after a resize, which matters for
windows off the screen, comes with the scrolling layout.
2026-10-27 14:48:56 -07:00
LGUG2Z 15ec3c1274 feat(kwin): read the desktop through a script in KWin
KWin has no protocol that lets another process read or set the geometry of a
window, but its scripting API has both, and a signal for every change the window
manager follows. So the KWin backend is in two parts: bridge.js, a script the
daemon loads into KWin, which reports what KWin sees and holds no window manager
logic, and a D-Bus service in the daemon, org.komorebi.Bridge, which the script
calls with every event as a JSON string.

Every decision about what an event means is made by the table in kwin::state,
which is pure and is unit-tested with the JSON the script sends: which windows
may be managed, the ids komorebi gives KWin's windows, and the Show, Destroy,
FocusChange, Minimize and Restore events of the window manager. Events reach the
window manager in the order KWin raised them: the Event interface handles its
calls inline (zbus reordered them otherwise), and one channel and one thread,
window_manager_event_listener and listen_for_events, carry them on.

Commands go to the script as the answer to a Poll call that the script always
has open and the daemon holds until it has something to send: in the spikes this
was as fast as any other channel, and it makes one call every 10 seconds when
nothing happens. Here the only command is the cursor query. Every command of the
Backend trait is refused with "the KWin backend only reads the desktop", so the
daemon follows KWin and moves nothing.

The bridge is JavaScript that KWin runs as it is, and it gets types and tests
without a JavaScript build chain. tsc checks it as strict TypeScript through
JSDoc types of every message, against kwin-api.d.ts, a declaration of the part
of KWin's scripting API it uses, in which every property is read-only and
callDBus takes one string. bridge.test.js runs it under node --test against a
fake KWin in a node:vm context. The messages of a session are JSON fixtures that
both sides' tests read: the bridge's tests check that it sends those events, and
the table's and the backend's tests read the same events and commands, so a
message cannot change on one side alone. just check-js and the flake's
komorebi-bridge-js check run both, with nodejs and typescript from nixpkgs;
there is no package.json.

The daemon now connects to KWin by default, and --fake-backend is hidden. The VM
suite vm-kwin-events starts it against a headless KWin and checks, from its
debug log, that the script loads and reports what was open, that KWin's signals
arrive as events and in order, and that stopping the daemon unloads the script.
docs/dev/PLATFORM.md starts with what komorebi needs from Plasma.

zbus 5's zvariant, zbus_names and zvariant_derive are flagged unmaintained,
since their code moved into zbus 6; deny.toml ignores the three advisories.
2026-10-27 14:46:35 -07:00
LGUG2Z 0725705298 ci(github): run the VM tests on KVM
`discover` asks the flake for the suites of each tier
(`legacyPackages.x86_64-linux.vmTiers`) and hands them to two matrices, so a new
directory under `nix/tests/vm/suites` is run without an edit to the workflow.
`vm-fast` runs the fast tier on every pull request, one machine per suite, as a
sandboxed `nix build` of the check. `vm-slow` runs on master, every week, and on
a pull request labeled `ci:vm-slow`; it builds the driver and runs it by hand,
outside the sandbox, so that the screenshots and journals of a failed run are
uploaded with `if: always()`.

A composite action installs nix with the store cached and, for the VM jobs,
frees disk space (a Plasma guest is a closure of about 10 GB), writes the udev
rule that makes /dev/kvm writable for the runner's user, checks it with `test
-w`, and adds `kvm` to nix's system features. KVM on hosted runners is
documented for public repositories only. Whether this private repository has it
is not known: the first run of these jobs says, and if it does not, they need a
different runner.

`lint` no longer runs `nix flake check`: on Linux that now boots every VM suite,
which a runner with no KVM cannot do. It builds the checks that need no virtual
machine, and `vm-lib`, the library's own tests.

CONTRIBUTING.md and docs/dev/vm-tests.md say what runs where.
2026-10-27 14:06:52 -07:00
LGUG2Z fec9c2773a build(nix): a Plasma session node for tests
A virtual KWin has no output to draw on, so every test about pixels, and later
about the pointer, needs a real session. The `session` node is the headless node
plus a login: plasma-login-manager logs the test user in by itself, the way
nixpkgs' own `plasma6` test does, and Plasma draws on the machine's display. The
choice of login manager is made in `nodes/session.nix` and nowhere else, and the
file says how to switch to sddm.

The node adds no display device. The test driver gives every aarch64 guest one
virtio-gpu and takes screenshots of it; a second device is a second KWin output
that screenshots never show, which is what a first attempt did (a blank 1280x800
console).

`Kde.session` attaches to the session once the desktop is up: the shell is
running, the user manager knows the Wayland display, and the splash screen has
gone. The display is read from the user manager because plasma-login-manager's
greeter has its own KWin and takes `wayland-0`, and the user's session gets
`wayland-1`. The splash condition is there because KSplash has over a thousand
colors on it, so a "not blank" check passed on the splash screen. NixOS wraps
programs, which is why processes are found by their command lines: `pgrep -x
plasmashell` never matches `.plasmashell-wrapped`, and an exact match for the
splash would have passed because nothing matched. `Kde.shot` returns a
screenshot as a Pillow image.

`vm-render-smoke` is the first slow suite: it starts a session, opens a
terminal, and checks the middle of the window is dark and that the desktop
behind it has thousands of colors. On aarch64 under HVF five runs gave the same
screenshot, with the desktop up after 34 seconds. docs/dev/vm-tests.md describes
the node and the two traps.
2026-10-27 14:06:52 -07:00
LGUG2Z 3c51b9b4bf build(nix): a NixOS test framework for headless KWin
The unit tests run against a fake desktop and cannot say whether KWin does what
the daemon asks. This adds the other half: NixOS machines with Plasma in them, a
real KWin, and a Python library to drive it.

Every directory under `nix/tests/vm/suites` is a test. The generator lists the
directory, so a new suite appears as `checks.<system>.vm-NAME` on Linux and as
`legacyPackages.<system>.vm.NAME` everywhere, with no edit to the flake. A suite
names its tier and its node and has a `test.py`;
`legacyPackages.<system>.vmTiers` lists the names of each tier as JSON for the
CI matrix, `vmGuests` the guests to build on a Linux builder, and `vmTcg` the
same suites for a machine with no KVM. `scale` multiplies every wait: 1 for KVM,
1.5 for HVF, 8 for TCG.

The headless node runs `kwin_wayland --virtual` as user units of a lingering
user, one pair of units per session (`session-bus@NAME`, `kwin-virtual@NAME`),
so a test can run several sessions with their own bus and socket. A virtual KWin
has no output to draw on, so anything that needs pixels or a pointer needs the
session node.

The library, `komorebi_vm`, has `Checks` (counted checks that fail once, at the
end) and `Kde` (start a session, run clients and tools inside it, read windows
through kdotool, poll with `wait_for`). It depends on a small `Machine` protocol
rather than on the test driver. The `vm-lib` check runs its unit tests against a
fake machine, with ruff and ty. The driver type checks each `test.py` with ty,
which is how a protocol that did not match `QemuMachine.execute` was caught.

`vm-kwin` starts a KWin with two outputs, spawns a foot window, reads it back,
starts a second session and checks that it cannot see the first.

On a Mac the guest is built on a Linux builder and the driver run directly,
because the macOS nix sandbox cannot give QEMU the hypervisor: `scripts/vm-mac`,
`just vm NAME`. docs/dev/vm-tests.md covers this, adding a test, and the
builder.
2026-10-27 14:06:19 -07:00