Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/build.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
126 changes: 121 additions & 5 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,13 +1,129 @@
.PHONY: all build package
.PHONY: all build test install clean enable-service disable-service setup-wayland help

PREFIX=/usr/local/bin
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"
@echo " disable-service - Disable and stop the systemd service"
@echo " setup-wayland - Configure Wayland environment import"

CARGO_FLAGS=--release
# Determine PREFIX based on whether we're using sudo or not
DESTDIR :=
ifeq ($(SUDO_USER),)
PREFIX := $(HOME)/.local
else
PREFIX := /usr/local
endif

# 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
# 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/
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

# --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 --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 --no-block 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 --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"
@echo " 2. Logging out and back in"
@echo ""
@echo "After that, verify the service is working:"
@echo " systemctl --user status aw-watcher-window-wayland"
76 changes: 76 additions & 0 deletions aw-watcher-window-wayland.service
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
########################################
# 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. 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
#
# 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]
Description=ActivityWatch Window Watcher for Wayland
Documentation=https://github.com/ActivityWatch/aw-watcher-window-wayland
After=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
# 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

[Service]
Type=simple
ExecStart=aw-watcher-window-wayland
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 -- 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 (make setup-wayland
# does it for sway and Hyprland)

[Install]
WantedBy=default.target
140 changes: 140 additions & 0 deletions tests/wait_for_wayland_test.sh
Original file line number Diff line number Diff line change
@@ -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"