From 4edae715b78cfa3e33e821b0c075b903fe9815d2 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 8 Dec 2025 10:04:10 +0100 Subject: [PATCH 1/9] chore: add systemd service file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add systemd user service file with proper dependencies on aw-server. Includes port readiness check to ensure server is listening before starting the watcher. Note: This is an alternative to running aw-qt (recommended approach). Disclaimer: QA and testing done utilizing real stupidity. Except for that, everything is stiched together using artificial intelligence. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- Makefile | 38 +++++++++++++++++++++---- aw-watcher-window-wayland.service | 46 +++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 aw-watcher-window-wayland.service diff --git a/Makefile b/Makefile index 9a4cfbf..6fa3aee 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,41 @@ -.PHONY: all build package +.PHONY: all build install clean -PREFIX=/usr/local/bin +# Determine PREFIX based on whether we're using sudo or not +DESTDIR := +ifeq ($(SUDO_USER),) + PREFIX := $(HOME)/.local +else + PREFIX := /usr/local +endif -CARGO_FLAGS=--release +# Build in release mode by default, unless RELEASE=false +ifeq ($(RELEASE), false) + CARGO_FLAGS := + TARGET_DIR := debug +else + CARGO_FLAGS := --release + TARGET_DIR := release +endif all: build build: cargo build $(CARGO_FLAGS) -install: - install target/release/aw-watcher-window-wayland $(PREFIX)/aw-watcher-window-wayland +install: build + # Install aw-watcher-window-wayland executable + mkdir -p $(DESTDIR)$(PREFIX)/bin/ + install -m 755 target/$(TARGET_DIR)/aw-watcher-window-wayland $(DESTDIR)$(PREFIX)/bin/aw-watcher-window-wayland + # Install systemd user service +ifeq ($(SUDO_USER),) + mkdir -p $(HOME)/.config/systemd/user + install -m 644 aw-watcher-window-wayland.service $(HOME)/.config/systemd/user/aw-watcher-window-wayland.service + systemctl --user daemon-reload || true +else + mkdir -p $(DESTDIR)$(PREFIX)/lib/systemd/user + install -m 644 aw-watcher-window-wayland.service $(DESTDIR)$(PREFIX)/lib/systemd/user/aw-watcher-window-wayland.service + systemctl daemon-reload || true +endif + +clean: + cargo clean diff --git a/aw-watcher-window-wayland.service b/aw-watcher-window-wayland.service new file mode 100644 index 0000000..405a2c4 --- /dev/null +++ b/aw-watcher-window-wayland.service @@ -0,0 +1,46 @@ +######################################## +# aw-watcher-window-wayland.service # +######################################## +# +# This service file provides an alternative way to run ActivityWatch watchers +# without using aw-qt. The recommended approach is to use aw-qt, which manages +# both the server and watchers automatically. +# +# Prerequisites: +# - aw-server-rust must be installed and running +# - This watcher must be built and installed +# - Running a Wayland compositor that supports required protocols +# +# Installation: +# 1. Build and install with: make install +# This installs both the binary and service file to ~/.local/ +# 2. Reload systemd: systemctl --user daemon-reload +# 3. Enable the service: systemctl --user enable aw-watcher-window-wayland.service +# 4. Start the service: systemctl --user start aw-watcher-window-wayland.service +# +# For system-wide installation (not recommended for user services): +# sudo make install +# +# The watcher will automatically start when aw-server is ready and listening on port 5600. +# + +[Unit] +Description=ActivityWatch Window Watcher for Wayland +Documentation=https://github.com/ActivityWatch/aw-watcher-window-wayland +After=aw-server.service +Requires=aw-server.service +# Wait for aw-server to be fully ready and listening on port 5600 +After=network.target + +[Service] +Type=simple +ExecStart=aw-watcher-window-wayland +Restart=on-failure +RestartSec=10 +# Ensure aw-server is actually listening before starting +ExecStartPre=/bin/sh -c 'until nc -z localhost 5600; do sleep 1; done' +# Wayland watchers need access to Wayland display +Environment="WAYLAND_DISPLAY=%E/wayland-0" + +[Install] +WantedBy=default.target From 0e8c4487982ba15a2b880b127971957bb6c2235a Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 8 Dec 2025 13:35:28 +0100 Subject: [PATCH 2/9] fix: improve service file reliability and correctness MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 60-second timeout to server readiness check to prevent infinite wait - Fix WAYLAND_DISPLAY to use %t (XDG_RUNTIME_DIR) instead of %E (XDG_CONFIG_HOME) Wayland sockets are in $XDG_RUNTIME_DIR, not config directory 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- aw-watcher-window-wayland.service | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/aw-watcher-window-wayland.service b/aw-watcher-window-wayland.service index 405a2c4..c0e3019 100644 --- a/aw-watcher-window-wayland.service +++ b/aw-watcher-window-wayland.service @@ -37,10 +37,10 @@ Type=simple ExecStart=aw-watcher-window-wayland Restart=on-failure RestartSec=10 -# Ensure aw-server is actually listening before starting -ExecStartPre=/bin/sh -c 'until nc -z localhost 5600; do sleep 1; done' +# Ensure aw-server is actually listening before starting (timeout after 60 seconds) +ExecStartPre=/bin/sh -c 'i=0; until nc -z localhost 5600 || [ $i -eq 60 ]; do sleep 1; i=$((i+1)); done; nc -z localhost 5600' # Wayland watchers need access to Wayland display -Environment="WAYLAND_DISPLAY=%E/wayland-0" +Environment="WAYLAND_DISPLAY=%t/wayland-0" [Install] WantedBy=default.target From 6a1019ae0d94ab33f51010909475358463110604 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 8 Dec 2025 20:10:32 +0100 Subject: [PATCH 3/9] Removing hard-coded enviornment from the systemd file --- aw-watcher-window-wayland.service | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/aw-watcher-window-wayland.service b/aw-watcher-window-wayland.service index c0e3019..0fbac51 100644 --- a/aw-watcher-window-wayland.service +++ b/aw-watcher-window-wayland.service @@ -14,9 +14,11 @@ # Installation: # 1. Build and install with: make install # This installs both the binary and service file to ~/.local/ -# 2. Reload systemd: systemctl --user daemon-reload -# 3. Enable the service: systemctl --user enable aw-watcher-window-wayland.service -# 4. Start the service: systemctl --user start aw-watcher-window-wayland.service +# 2. Import Wayland environment: systemctl --user import-environment WAYLAND_DISPLAY +# (Add this to your session startup script, e.g., ~/.config/sway/config or ~/.xinitrc) +# 3. Reload systemd: systemctl --user daemon-reload +# 4. Enable the service: systemctl --user enable aw-watcher-window-wayland.service +# 5. Start the service: systemctl --user start aw-watcher-window-wayland.service # # For system-wide installation (not recommended for user services): # sudo make install @@ -31,6 +33,8 @@ After=aw-server.service Requires=aw-server.service # Wait for aw-server to be fully ready and listening on port 5600 After=network.target +# Ensure we run within a graphical session that has Wayland environment +After=graphical-session.target [Service] Type=simple @@ -39,8 +43,9 @@ Restart=on-failure RestartSec=10 # Ensure aw-server is actually listening before starting (timeout after 60 seconds) ExecStartPre=/bin/sh -c 'i=0; until nc -z localhost 5600 || [ $i -eq 60 ]; do sleep 1; i=$((i+1)); done; nc -z localhost 5600' -# Wayland watchers need access to Wayland display -Environment="WAYLAND_DISPLAY=%t/wayland-0" +# Note: WAYLAND_DISPLAY must be imported from your graphical session +# Run: systemctl --user import-environment WAYLAND_DISPLAY +# Add this command to your compositor/session startup script [Install] WantedBy=default.target From 09f77d48eccc3cb726ecb72b560564cd0d38a633 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 12 Jan 2026 11:04:05 +0100 Subject: [PATCH 4/9] feat: Add setup-wayland and service management targets to Makefile - Add help target showing all available make targets - Add enable-service target to enable and start systemd service - Add disable-service target to stop and disable service - Add setup-wayland target that auto-detects Sway/Hyprland configs and adds WAYLAND_DISPLAY environment import for systemd services Co-Authored-By: Claude Opus 4.5 --- Makefile | 79 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 78 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index 6fa3aee..1836313 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,14 @@ -.PHONY: all build install clean +.PHONY: all build install clean enable-service disable-service setup-wayland help + +help: + @echo "Available targets:" + @echo " all - Build the project (default)" + @echo " build - Build the project with cargo" + @echo " install - Install binary and systemd service" + @echo " clean - Remove build artifacts" + @echo " enable-service - Enable and start the systemd service" + @echo " disable-service - Disable and stop the systemd service" + @echo " setup-wayland - Configure Wayland environment import" # Determine PREFIX based on whether we're using sudo or not DESTDIR := @@ -39,3 +49,70 @@ endif clean: cargo clean + +enable-service: + @echo "Enabling and starting service..." +ifeq ($(SUDO_USER),) + systemctl --user enable aw-watcher-window-wayland + systemctl --user start aw-watcher-window-wayland + @echo "Service status:" + @systemctl --user status aw-watcher-window-wayland --no-pager +else + @echo "Note: For user service, run without sudo" + systemctl --user enable aw-watcher-window-wayland + systemctl --user start aw-watcher-window-wayland +endif + +disable-service: + @echo "Disabling and stopping service..." + systemctl --user stop aw-watcher-window-wayland + systemctl --user disable aw-watcher-window-wayland + @echo "Service disabled." + +setup-wayland: enable-service + @echo "Configuring Wayland environment import..." + @echo "" + @echo "Detecting compositor configuration files..." + @if [ -f ~/.config/sway/config ]; then \ + echo "Found Sway config at ~/.config/sway/config"; \ + if grep -q "systemctl --user import-environment WAYLAND_DISPLAY" ~/.config/sway/config; then \ + echo "✓ Environment import already configured"; \ + else \ + echo "" >> ~/.config/sway/config; \ + echo "# Import WAYLAND_DISPLAY for systemd services" >> ~/.config/sway/config; \ + echo "exec systemctl --user import-environment WAYLAND_DISPLAY" >> ~/.config/sway/config; \ + echo "✓ Added environment import to Sway config"; \ + echo " Please reload Sway config or log out and back in"; \ + fi; \ + elif [ -f ~/.config/hypr/hyprland.conf ]; then \ + echo "Found Hyprland config at ~/.config/hypr/hyprland.conf"; \ + if grep -q "systemctl --user import-environment WAYLAND_DISPLAY" ~/.config/hypr/hyprland.conf; then \ + echo "✓ Environment import already configured"; \ + else \ + echo "" >> ~/.config/hypr/hyprland.conf; \ + echo "# Import WAYLAND_DISPLAY for systemd services" >> ~/.config/hypr/hyprland.conf; \ + echo "exec-once = systemctl --user import-environment WAYLAND_DISPLAY" >> ~/.config/hypr/hyprland.conf; \ + echo "✓ Added environment import to Hyprland config"; \ + echo " Please reload Hyprland config or log out and back in"; \ + fi; \ + else \ + echo "Could not detect compositor config file."; \ + echo ""; \ + echo "Please manually add this line to your compositor startup:"; \ + echo " exec systemctl --user import-environment WAYLAND_DISPLAY"; \ + echo ""; \ + echo "Common locations:"; \ + echo " - Sway: ~/.config/sway/config"; \ + echo " - Hyprland: ~/.config/hypr/hyprland.conf"; \ + echo " - Others: check your compositor documentation"; \ + fi + @echo "" + @echo "Restarting service to pick up environment changes..." + @systemctl --user restart aw-watcher-window-wayland 2>/dev/null || echo "Note: Service restart will happen after compositor reload" + @echo "" + @echo "⚠ IMPORTANT: The environment variable will only be available after:" + @echo " 1. Reloading your compositor config, OR" + @echo " 2. Logging out and back in" + @echo "" + @echo "After that, verify the service is working:" + @echo " systemctl --user status aw-watcher-window-wayland" From a2cc46c8fe32ed262514511bc127a144f8a2b779 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 23 Feb 2026 12:03:52 +0100 Subject: [PATCH 5/9] fix: use Restart=always to survive aw-server restarts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With Restart=on-failure, the watcher would not restart after being stopped cleanly (exit 0). This happened when aw-server stopped and pulled the watcher down via Requires=, then aw-server came back up but the watcher stayed dead — causing multi-hour gaps in recording. Restart=always ensures the watcher comes back regardless of whether it exited cleanly or with an error. The ExecStartPre check already handles waiting for aw-server to be ready. Co-Authored-By: Claude --- aw-watcher-window-wayland.service | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aw-watcher-window-wayland.service b/aw-watcher-window-wayland.service index 0fbac51..96b6140 100644 --- a/aw-watcher-window-wayland.service +++ b/aw-watcher-window-wayland.service @@ -39,7 +39,7 @@ After=graphical-session.target [Service] Type=simple ExecStart=aw-watcher-window-wayland -Restart=on-failure +Restart=always RestartSec=10 # Ensure aw-server is actually listening before starting (timeout after 60 seconds) ExecStartPre=/bin/sh -c 'i=0; until nc -z localhost 5600 || [ $i -eq 60 ]; do sleep 1; i=$((i+1)); done; nc -z localhost 5600' From d88acf1dc6b46de7bd664f52c37244f59c20c5ca Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Mon, 23 Feb 2026 14:20:06 +0100 Subject: [PATCH 6/9] ci: upgrade actions/checkout and upload-artifact to v4 v3 of both actions is deprecated and now auto-fails on GitHub. Co-Authored-By: Claude --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b737d04..9e73c87 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Build run: cargo build --verbose --release - name: Run tests From 0f12b8e515530967034495df5c245b58ad555c0e Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Wed, 15 Apr 2026 16:11:07 +0200 Subject: [PATCH 7/9] fix: retry for up to 2 hours when aw-server is unavailable When aw-server is temporarily down, the watcher is stopped by systemd (via Requires=) and then restarted. Previously there was no start-limit configuration, so the default burst limit could cause systemd to stop retrying. Now set StartLimitIntervalSec=7200 / StartLimitBurst=60 (up to 60 attempts in 2 hours) and RestartSec=120 (2-minute gap between attempts) to survive multi-hour aw-server outages. prompt: The window watcher, run from systemd, stops whenever the aw-server is down (even if it's just down for a short while). Can we set up systemd to do auto-restarts? A couple of minutes between each retry is OK, but it should retry restarts for at least an hour or two. Co-Authored-By: Claude Sonnet 4.6 --- aw-watcher-window-wayland.service | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/aw-watcher-window-wayland.service b/aw-watcher-window-wayland.service index 96b6140..0ece260 100644 --- a/aw-watcher-window-wayland.service +++ b/aw-watcher-window-wayland.service @@ -35,12 +35,16 @@ Requires=aw-server.service After=network.target # Ensure we run within a graphical session that has Wayland environment After=graphical-session.target +# Allow up to 60 restart attempts within a 2-hour window before giving up +StartLimitIntervalSec=7200 +StartLimitBurst=60 [Service] Type=simple ExecStart=aw-watcher-window-wayland Restart=always -RestartSec=10 +# Wait 2 minutes between restart attempts so aw-server has time to come back up +RestartSec=120 # Ensure aw-server is actually listening before starting (timeout after 60 seconds) ExecStartPre=/bin/sh -c 'i=0; until nc -z localhost 5600 || [ $i -eq 60 ]; do sleep 1; i=$((i+1)); done; nc -z localhost 5600' # Note: WAYLAND_DISPLAY must be imported from your graphical session From ff08a8435e9d0572bd1ecaf4606554c82b0daff0 Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Sat, 23 May 2026 20:37:13 +0200 Subject: [PATCH 8/9] fix: use Wants= instead of Requires= to survive aw-server downtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Requires=aw-server.service caused systemd to stop the watcher whenever aw-server went down, even briefly. With RestartSec=120 this meant every aw-server hiccup produced a minimum 2-minute tracking gap. Switch to Wants= so aw-server going down does not stop the watcher — the watcher's existing heartbeat retry loop (outer loop retries on every timer tick, ~5 s) handles runtime outages transparently. Also: - Remove the 60-iteration cap from ExecStartPre so it waits indefinitely for aw-server at startup (add TimeoutStartSec=infinity to match). - Drop RestartSec from 120 s to 10 s, since fast restart is now safe (the watcher only restarts for Wayland/crash reasons, not aw-server). prompt: There seems to be at least one local commit here 0f12b8e to deal with cases where the aw-server is down for a shorter period, but still I have problems that the watcher stops working when the aw server is temporarily down Co-Authored-By: Claude Sonnet 4.6 Reviewed-by: Tobias Brox --- aw-watcher-window-wayland.service | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/aw-watcher-window-wayland.service b/aw-watcher-window-wayland.service index 0ece260..378dcb1 100644 --- a/aw-watcher-window-wayland.service +++ b/aw-watcher-window-wayland.service @@ -30,12 +30,13 @@ Description=ActivityWatch Window Watcher for Wayland Documentation=https://github.com/ActivityWatch/aw-watcher-window-wayland After=aw-server.service -Requires=aw-server.service +# Wants= (not Requires=) so aw-server going down doesn't stop the watcher; +# the watcher handles aw-server outages internally via heartbeat retries. +Wants=aw-server.service # Wait for aw-server to be fully ready and listening on port 5600 After=network.target # Ensure we run within a graphical session that has Wayland environment After=graphical-session.target -# Allow up to 60 restart attempts within a 2-hour window before giving up StartLimitIntervalSec=7200 StartLimitBurst=60 @@ -43,10 +44,10 @@ StartLimitBurst=60 Type=simple ExecStart=aw-watcher-window-wayland Restart=always -# Wait 2 minutes between restart attempts so aw-server has time to come back up -RestartSec=120 -# Ensure aw-server is actually listening before starting (timeout after 60 seconds) -ExecStartPre=/bin/sh -c 'i=0; until nc -z localhost 5600 || [ $i -eq 60 ]; do sleep 1; i=$((i+1)); done; nc -z localhost 5600' +RestartSec=10 +# Wait indefinitely for aw-server to be listening before starting +ExecStartPre=/bin/sh -c 'until nc -z localhost 5600; do sleep 5; done' +TimeoutStartSec=infinity # Note: WAYLAND_DISPLAY must be imported from your graphical session # Run: systemctl --user import-environment WAYLAND_DISPLAY # Add this command to your compositor/session startup script From 6f487a9887a4fd59755e60df85fef5d0aa58332b Mon Sep 17 00:00:00 2001 From: Tobias Brox Date: Tue, 25 Aug 2026 10:21:24 +0200 Subject: [PATCH 9/9] fix: make the unit wait for the compositor On my laptop I'm starting the compositor by hand. Yesterday it was started relatively long after booting up and logging in. Without a compositor, the watcher doesn't want to start. The RestartSec=10/StartLimitBurst=60 was exhausted roughly ten minutes into the session - causing a watcher outage for 21 hours (I should set up some monitoring on it). A second ExecStartPre has been added, it blocks until the compositor is usable. Other approaches considered: * Waiting for the socket alone would still race: a compositor creates its socket and only then runs `systemctl --user import-environment WAYLAND_DISPLAY`, and starting in that window leaves connect_to_env() with nothing (the hardcoded `wayland-0` fallback does not help on a machine whose display is `wayland-1`). The gate therefore requires both the imported WAYLAND_DISPLAY and the socket it names. * Tuning RestartSec/StartLimitBurst upward was the alternative, but any number is a guess at how long the user takes to start sway, and a unit reporting `activating (start-pre)` describes the situation more honestly than one cycling between `activating (auto-restart)` and `failed`. `$$` is systemd's documented escape for a literal `$`. It is not strictly required here -- systemd expands `${VAR}` inside a quoted word, but bare `$VAR` only when it forms a whole argv element, so the bare forms used in this line survive unescaped -- but writing the escape keeps the line correct if a `${...}` is ever added. Because the unit now waits, a plain `systemctl --user start` blocks until a compositor exists, so give the Makefile's start and restart calls --no-block. That matters most for setup-wayland, which depends on enable-service and exists to write the very import-environment line the service is waiting for: without --no-block it deadlocks before writing it, on exactly the machine that needs it. Also correct the comment above After=graphical-session.target, which claimed to ensure a graphical session. The target is inactive on setups whose compositor never activates it, making the ordering a no-op there. Adds tests/wait_for_wayland_test.sh, which extracts the wait expression from the shipped unit and runs it against a faked user environment and a fake `systemctl` that refuses anything but `--user show-environment`. It must block with nothing present, with the display imported but no socket, and -- the case that tells this gate apart from one that merely waits for a socket -- with the socket present but the display not yet imported; then return once both are there, and again when a compositor appears late. Verified by mutation: a socket-only gate passes every other case and fails that third one. The test cannot catch a missing `$$`, since escaped and unescaped forms reach /bin/sh identically; its header says so. This is the first test in the repository, so it also adds a `test:` target. A test exits 77 when its prerequisites are missing, which make treats as failure, so the target maps 77 back to success. Not addressed: after a SIGKILLed compositor, WAYLAND_DISPLAY stays in the manager environment and the socket inode can survive, so the gate would pass against a dead socket and the watcher would fail-loop as before. Reaching that needs a compositor killed without cleanup, which is not what happened here. prompt: Why did not the service run? [+ pasted `systemctl --user status aw-watcher-window-wayland.service` output showing Result: start-limit-hit and ExecStart status=101] followup-prompt: Sway is started manually on this computer, and this morning I spent some time before sway was started. I think it's better that the unit appears to be failed or not started than that it appears to be running while it's constantly retrying connecting to the compositor, so I believe the fix should go into the systemd unit. I think it should probably retry with longer intervals and many more times. Or maybe it should wait for the socket. Please fix something. followup-prompts: [Claude trying to ask if I wanted to merge it into existing pull requests or create a new one] Co-authored-by: Claude Opus 5 (via Claude Code) Reviewed-by: Tobias Brox --- Makefile | 19 +++- aw-watcher-window-wayland.service | 28 +++++- tests/wait_for_wayland_test.sh | 140 ++++++++++++++++++++++++++++++ 3 files changed, 179 insertions(+), 8 deletions(-) create mode 100755 tests/wait_for_wayland_test.sh diff --git a/Makefile b/Makefile index 1836313..120b692 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,10 @@ -.PHONY: all build install clean enable-service disable-service setup-wayland help +.PHONY: all build test install clean enable-service disable-service setup-wayland help help: @echo "Available targets:" @echo " all - Build the project (default)" @echo " build - Build the project with cargo" + @echo " test - Run the systemd unit tests" @echo " install - Install binary and systemd service" @echo " clean - Remove build artifacts" @echo " enable-service - Enable and start the systemd service" @@ -32,6 +33,11 @@ all: build build: cargo build $(CARGO_FLAGS) +# A test exits 77 when its prerequisites are missing (here: python3). make +# aborts a recipe on any non-zero status, so map that back to success. +test: + ./tests/wait_for_wayland_test.sh || [ $$? -eq 77 ] + install: build # Install aw-watcher-window-wayland executable mkdir -p $(DESTDIR)$(PREFIX)/bin/ @@ -50,17 +56,22 @@ endif clean: cargo clean +# --no-block on start/restart: the unit waits for aw-server and for a Wayland +# compositor, so a plain start blocks until both exist. That matters most for +# setup-wayland below, which depends on this target and exists to write the +# import-environment line the service is waiting for. Expect the status output +# to read "activating (start-pre)" when run before the compositor is up. enable-service: @echo "Enabling and starting service..." ifeq ($(SUDO_USER),) systemctl --user enable aw-watcher-window-wayland - systemctl --user start aw-watcher-window-wayland + systemctl --user start --no-block aw-watcher-window-wayland @echo "Service status:" @systemctl --user status aw-watcher-window-wayland --no-pager else @echo "Note: For user service, run without sudo" systemctl --user enable aw-watcher-window-wayland - systemctl --user start aw-watcher-window-wayland + systemctl --user start --no-block aw-watcher-window-wayland endif disable-service: @@ -108,7 +119,7 @@ setup-wayland: enable-service fi @echo "" @echo "Restarting service to pick up environment changes..." - @systemctl --user restart aw-watcher-window-wayland 2>/dev/null || echo "Note: Service restart will happen after compositor reload" + @systemctl --user restart --no-block aw-watcher-window-wayland 2>/dev/null || echo "Note: Service restart will happen after compositor reload" @echo "" @echo "⚠ IMPORTANT: The environment variable will only be available after:" @echo " 1. Reloading your compositor config, OR" diff --git a/aw-watcher-window-wayland.service b/aw-watcher-window-wayland.service index 378dcb1..bd23178 100644 --- a/aw-watcher-window-wayland.service +++ b/aw-watcher-window-wayland.service @@ -23,7 +23,17 @@ # For system-wide installation (not recommended for user services): # sudo make install # -# The watcher will automatically start when aw-server is ready and listening on port 5600. +# The watcher waits for two things before it starts: +# 1. aw-server listening on port 5600 +# 2. a usable Wayland compositor -- WAYLAND_DISPLAY imported into the systemd +# user environment (installation step 2 above) AND the socket it names +# existing +# +# Both waits are unbounded (TimeoutStartSec=infinity), so on a machine where the +# compositor is started manually the unit simply sits in "activating (start-pre)" +# until you start it, instead of failing. That also means an interactive +# "systemctl --user start" blocks until the compositor is up; use --no-block if +# you do not want to wait. # [Unit] @@ -35,7 +45,10 @@ After=aw-server.service Wants=aw-server.service # Wait for aw-server to be fully ready and listening on port 5600 After=network.target -# Ensure we run within a graphical session that has Wayland environment +# Advisory only: After= orders against graphical-session.target just for +# compositors that actually activate it. Many (e.g. a hand-started sway) never +# do, leaving the target inactive and this line a no-op -- which is why the +# compositor wait below, not this ordering, is what makes startup reliable. After=graphical-session.target StartLimitIntervalSec=7200 StartLimitBurst=60 @@ -47,10 +60,17 @@ Restart=always RestartSec=10 # Wait indefinitely for aw-server to be listening before starting ExecStartPre=/bin/sh -c 'until nc -z localhost 5600; do sleep 5; done' +# Wait indefinitely for a compositor. Checking the socket alone would race: a +# compositor creates its socket and only then imports WAYLAND_DISPLAY, and +# starting in that window leaves the watcher unable to find the display. +ExecStartPre=/bin/sh -c 'until wd=$$(systemctl --user show-environment | sed -n "s/^WAYLAND_DISPLAY=//p"); [ -n "$$wd" ] && { [ -S "$$wd" ] || [ -S "$$XDG_RUNTIME_DIR/$$wd" ]; }; do sleep 5; done' TimeoutStartSec=infinity -# Note: WAYLAND_DISPLAY must be imported from your graphical session +# Note: WAYLAND_DISPLAY must be imported from your graphical session -- the +# second ExecStartPre above waits for exactly that, so without it the unit +# stays in start-pre forever rather than starting and failing. # Run: systemctl --user import-environment WAYLAND_DISPLAY -# Add this command to your compositor/session startup script +# Add this command to your compositor/session startup script (make setup-wayland +# does it for sway and Hyprland) [Install] WantedBy=default.target diff --git a/tests/wait_for_wayland_test.sh b/tests/wait_for_wayland_test.sh new file mode 100755 index 0000000..4153cde --- /dev/null +++ b/tests/wait_for_wayland_test.sh @@ -0,0 +1,140 @@ +#!/usr/bin/env bash +# Regression test: the systemd unit must not start the watcher before the +# Wayland compositor is actually usable. +# +# Background: the service is WantedBy=default.target, so on a machine where +# the compositor is started manually it is launched at login, minutes before +# any compositor exists. get_wl_display() then panics (exit 101) and the unit +# burns through StartLimitBurst long before the compositor shows up, leaving +# the watcher dead for the rest of the session. +# +# Waiting for the socket alone is not enough: a compositor creates its socket +# and only then runs `systemctl --user import-environment WAYLAND_DISPLAY`. +# Starting in that window means connect_to_env() still fails. The gate must +# therefore require BOTH the imported WAYLAND_DISPLAY and its socket -- which +# is what case C below pins down, since it is the only case that tells the +# shipped gate apart from one that merely waits for a socket to appear. +# +# This test extracts the ExecStartPre wait expression from the shipped unit +# file and exercises it against a faked systemd user environment, so it tests +# what actually ships rather than a copy of it. Note that it cannot catch a +# missing '$$' escape: systemd leaves a bare '$wd' inside a quoted word alone, +# so escaped and unescaped forms reach /bin/sh identically. + +set -u +cd "$(dirname "$0")/.." + +unit=aw-watcher-window-wayland.service +tmpdir=$(mktemp -d) +sock_pids="$tmpdir/sock.pids" +: > "$sock_pids" + +kill_sockets() { + while read -r p; do kill "$p" 2>/dev/null; done < "$sock_pids" + : > "$sock_pids" +} +cleanup() { + kill_sockets + wait 2>/dev/null + rm -rf "$tmpdir" +} +trap cleanup EXIT + +fail() { echo "FAIL: $*" >&2; exit 1; } +ok() { echo "ok - $*"; } + +# 77 is the automake "skip" convention, but make(1) aborts the whole recipe on +# any non-zero status, so the Makefile maps it back to 0. +command -v python3 >/dev/null || { echo "SKIP: python3 not installed"; exit 77; } +command -v timeout >/dev/null || { echo "SKIP: timeout not installed"; exit 77; } + +# --- extract the wait expression from the unit ----------------------------- +# systemd unescapes '$$' to a literal '$' before handing the line to /bin/sh; +# mirror that here so we run exactly what sh will receive. +line=$(grep '^ExecStartPre=' "$unit" | grep WAYLAND_DISPLAY) +[ -n "$line" ] || fail "no ExecStartPre in $unit waits for WAYLAND_DISPLAY" + +expr=${line#ExecStartPre=/bin/sh -c } +case "$expr" in + "'"*"'") expr=${expr#\'}; expr=${expr%\'} ;; + *) fail "WAYLAND_DISPLAY ExecStartPre is not a /bin/sh -c '...' command: $line" ;; +esac +expr=${expr//\$\$/\$} +ok "extracted wait expression from $unit" + +# --- fake systemd user environment ----------------------------------------- +mkdir -p "$tmpdir/bin" +cat > "$tmpdir/bin/systemctl" <<'EOF' +#!/bin/sh +# The gate must ask the *user* manager for its environment. Anything else -- +# the system manager, or a different subcommand -- is a regression, so refuse +# loudly instead of quietly serving the fixture. +if [ "$1" != "--user" ] || [ "$2" != "show-environment" ]; then + echo "fake systemctl: unexpected invocation: $*" >&2 + exit 64 +fi +cat "$FAKE_ENV" +EOF +chmod +x "$tmpdir/bin/systemctl" + +export PATH="$tmpdir/bin:$PATH" +export XDG_RUNTIME_DIR="$tmpdir/run" +export FAKE_ENV="$tmpdir/env" +mkdir -p "$XDG_RUNTIME_DIR" + +set_env() { printf 'LANG=C\n' > "$FAKE_ENV"; [ $# -eq 0 ] || printf 'WAYLAND_DISPLAY=%s\n' "$1" >> "$FAKE_ENV"; } + +make_socket() { + python3 -c 'import socket,sys,time; s=socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen(1); time.sleep(120)' "$1" & + # Record the pid in a file rather than a variable: make_socket is also + # called from a subshell, whose variables never reach the EXIT trap. + echo $! >> "$sock_pids" + for _ in $(seq 50); do [ -S "$1" ] && return; sleep 0.1; done + fail "could not create test socket $1" +} + +# The gate polls every 5s, so a "must block" timeout has to exceed that or it +# only proves the predicate was false once, not that the loop keeps polling. +run_wait() { timeout "$1" /bin/sh -c "$expr"; } +BLOCK=7 +RETURN=25 + +# --- case A: no compositor, nothing imported -> must block ----------------- +set_env +run_wait $BLOCK; [ $? -eq 124 ] || fail "wait returned with no WAYLAND_DISPLAY imported and no socket" +ok "blocks when neither WAYLAND_DISPLAY nor a socket is present" + +# --- case B: imported, but socket not there yet -> must block -------------- +set_env wayland-9 +run_wait $BLOCK; [ $? -eq 124 ] || fail "wait returned while the socket was missing" +ok "blocks when WAYLAND_DISPLAY is set but the socket is missing" + +# --- case C: socket present, but not imported yet -> must block ------------ +# The discriminating case. A gate that merely waits for a socket to appear +# passes every other case in this file and fails only this one. +make_socket "$XDG_RUNTIME_DIR/wayland-9" +set_env +run_wait $BLOCK; [ $? -eq 124 ] || fail "wait returned on a socket alone, before WAYLAND_DISPLAY was imported" +ok "blocks when the socket exists but WAYLAND_DISPLAY is not imported" + +# --- case D: both present -> must return promptly -------------------------- +set_env wayland-9 +run_wait $RETURN || fail "wait did not return once display and socket were both present" +ok "returns once WAYLAND_DISPLAY and its socket are both present" + +# --- case E: appears late (the real boot ordering) ------------------------- +kill_sockets +rm -f "$XDG_RUNTIME_DIR/wayland-9" +set_env +( + sleep 2 + make_socket "$XDG_RUNTIME_DIR/wayland-9" + set_env wayland-9 + sleep 60 +) & +late_pid=$! +run_wait $RETURN || { kill $late_pid 2>/dev/null; fail "wait did not pick up a compositor that started later"; } +kill $late_pid 2>/dev/null +ok "returns when the compositor starts after the unit" + +echo "PASS"