Commit Graph
12 Commits
Author SHA1 Message Date
LGUG2Z e2d757bbb0 feat(wm): subscribe to notifications
A program such as a status bar has to follow the window manager, and so far it
could only ask: komorebi on Windows and on macOS sends notifications for that.
The daemon now keeps subscribers, each a Unix socket in its data directory that
a program listens on, and after each event and each message it handles, it
connects to every subscriber and writes the event or the message with the state
after it.

A subscriber can ask to be sent only what changed the state; a window that is
shown and a new subscription are always sent, so a subscriber has the state as
soon as it subscribes. A subscriber that cannot be connected to is dropped with
its socket file and not tried again, and a stopping daemon closes an empty
connection to each one.

komorebic gains subscribe-socket and unsubscribe-socket, and komorebi-client
subscribe and subscribe_with_options, which bind the socket and subscribe. The
tests subscribe through real sockets against the fake desktop: a shown window is
sent with the state that has it, a subscriber that filters is not sent a
question that changed nothing, and one that is gone is dropped.
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 88aa78b874 feat(wm): a command socket and the first messages
Until now the daemon tiled what KWin reported and nothing else could reach it.
komorebi takes its orders on a socket, as it does on Windows and on macOS: it
handles no key itself, and whatever binds keys runs a command that writes a
message.

The daemon binds komorebi.sock in $XDG_RUNTIME_DIR/komorebi (DATA_DIR), reads
one JSON SocketMessage per line on a thread per connection, and handles each
message with the window manager locked, as the thread that handles events does.
The first messages are the ones the first komorebic needs: State (the window
manager as JSON, from state.rs), MonitorInformation, Query with a StateQuery,
Retile and Stop. A question is answered on the same connection, and the answer
ends when the daemon closes it. Stop unloads the bridge from KWin, removes the
socket and exits; so does a stop signal.

The socket is bound once the daemon is connected to KWin, so a komorebi that
cannot connect, such as a second one in the same session, which the bus refuses,
leaves the socket of the one that runs alone. With --fake-backend the daemon
answers on its socket with no compositor at all, which is how the tests run it.

komorebi-client is the other end: send_message, send_batch and send_query, and
the types the messages are made of. Its doctests serve the socket themselves,
tests/daemon.rs runs it against a window manager over the fake desktop, and
komorebi/tests/socket.rs runs the daemon binary from its start to a Stop.
2026-10-27 15:17:30 -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 02231d232c feat(wm): tile what the backend reports
With the model in place, the window manager can do its job: load the desktop
from the backend, tile it, and keep it tiled as the compositor reports what
happens to windows. The events are the Windows implementation's, each about one
window and carrying it: a window is shown, destroyed, given the focus, minimized
or restored. A new window gets a container of its own after the focused one, a
closed one leaves whichever workspace has it, a window the user minimizes is out
of the layout until it is restored, and every event ends with the workspace in
front tiled again.

At startup every monitor is loaded, and every open window that is normal, alive
and not minimized goes on the monitor its middle is on. An index from each
managed window to its monitor and workspace is rebuilt around every event, so
that the first event komorebi sees can be the close of a window it picked up at
startup.

Whether a window is managed is, for now, only whether the compositor calls it a
normal window and it is alive; the rules that ignore, manage or float
applications come later. When a window closes, komorebi leaves the choice of the
next focus to KWin unless KWin focuses nothing, so the two do not fight.

The daemon runs this against a backend chosen on the command line.
--fake-backend, an in-memory desktop with one monitor, is the only one, and
without it komorebi exits with a usage error instead of pretending to manage a
desktop it cannot reach.
2026-10-27 14:07:28 -07:00
LGUG2Z c0345eb0e9 feat(wm): windows, containers, workspaces and monitors
The window manager tiles a tree of four levels: monitors, each with a ring of
workspaces, each with a ring of containers, each with a ring of windows. It is
the model komorebi has on Windows and macOS, and the first thing the daemon
keeps that the backend does not give it.

A window is a Copy handle that carries the compositor's id and asks the backend
for everything else, so moving one is a call on the handle and needs no context
passed to it. A workspace tiles itself: the layout gives each container a tile
of the work area its monitor hands it, less the offset and the padding, and
every window of the container is asked to move there. A locked container keeps
its index because the workspace inserts, removes and swaps its containers
through LockableSequence.

This is tiling only. Stacks, monocle, maximize, the floating layer, resize
adjustments, placement rules and the link between workspaces and KWin's desktops
come with the commits that use them. impl_ring_elements!, which gives every
holder of a ring the same accessors, lands here with its first users.
2026-10-27 14:07:28 -07:00
LGUG2Z ed6a8e96c3 feat(backend): reach the compositor through one trait
The window manager decides where every window goes, and asks a compositor to put
it there. If it asked KWin directly, every test of its logic would have to boot
a virtual machine, so everything it needs from the desktop goes through one
trait, `Backend`: queries for windows, monitors, the focus and the pointer, and
commands to move, focus, raise, minimize and close a window and to put the
pointer somewhere. A real backend answers from a table that compositor events
keep up to date, so a query never waits for the compositor; a command returns
once it has been accepted, not once the client has acted.

`FakeBackend` is the other implementation: an in-memory desktop that does what
it is told, at once and exactly, and records every command so that a test can
assert on what the window manager asked for. A test can also do what a user does
without the window manager asking: move a window, close it, change the focus,
turn a window into a dialog. It is strict where KWin is (a command about a
window that is gone is an error) and clamps where a GTK4 client does (a minimum
size). `with_backend` overrides the process-wide backend for the current thread
only and puts the previous one back on drop, also on a panic, which is what
keeps one test's desktop out of the next.

The module is the first to have `#![deny(missing_docs)]`. The contract (what
must be implemented, that a backend is `Send + Sync`) is proved by two
`compile_fail` examples with their error codes, and the rules nothing checks are
listed as such.

docs/dev/ARCHITECTURE.md describes the trait, its roles and the layer that
enforces each rule, and docs/dev/DESIGN.md has the first decisions with their
reasons. Both grow with the code.
2026-10-27 14:07:28 -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