Repository navigation
Expand file tree
/
Copy pathJustfile
More file actions
656 lines (603 loc) · 30.8 KB
/
Copy pathJustfile
File metadata and controls
656 lines (603 loc) · 30.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
# deckd — common commands
# Load a gitignored ./.env if one exists. `just worktree-adopt` writes one per
# worktree holding that checkout's port assignment, so every recipe below picks
# up the right ports with no env-var juggling. Nothing else uses it, the
# primary checkout doesn't need one, and an explicit env var still wins:
# `DECKD_PORT=9000 just dev`.
set dotenv-load := true
# `just` (no args) lists available recipes.
default:
@just --list
# Per-platform setup recipes. `setup` auto-picks the right one; use the
# explicit recipe when you want to override (e.g. cross-checking on a CI box).
# Linux/GNOME/KDE dev: [dev,uinput,dbus] gives the evdev-backed uinput sink
# used for key injection (browser buttons etc.) plus dbus-fast for the `dbus:`
# action primitive and MPRIS now-playing (issue #27 — both Linux-only, so
# setup-macos skips the [dbus] extra). On x86_64 uinput is the prebuilt
# evdev-binary wheel; on aarch64 (no evdev-binary wheel) we install [dev,dbus]
# then source-build python-evdev via scripts/install_evdev_source.sh, which
# needs a C compiler (the `gcc` flox package). If that build is skipped the
# uinput backend degrades gracefully (input.py imports evdev lazily, no-ops).
setup-linux:
#!/usr/bin/env bash
set -euo pipefail
# See setup-macos: skip venv creation when flox (or any activation)
# already owns one, or the empty ./.venv shadows it everywhere.
if [ -z "${VIRTUAL_ENV:-}" ]; then
uv venv --python 3.11 --allow-existing
fi
if [ "$(uname -m)" = x86_64 ]; then
uv pip install -e ".[dev,uinput,dbus]"
else
echo "note: $(uname -m) has no evdev-binary wheel; source-building python-evdev." >&2
uv pip install -e ".[dev,dbus]"
PYTHON="${VIRTUAL_ENV:-.venv}/bin/python" bash scripts/install_evdev_source.sh \
|| echo "warn: evdev source build failed; uinput key injection will no-op." >&2
fi
cd client && npm install
# macOS dev: [dev] + [macos] (PyObjC Quartz covers scroll, pointer, click,
# and held-button drag for the trackpad). No [dbus] extra: macOS has no
# session bus and the default deck's D-Bus/MPRIS targets don't exist there
# (issue #27), so __main__ wires a null bus factory and the daemon serves
# without those primitives.
setup-macos:
#!/usr/bin/env bash
set -euo pipefail
# Under flox the env already exists ($FLOX_ENV_CACHE/python) and
# `uv pip install` targets it. Creating ./.venv anyway leaves an empty
# venv that shadows the real one: it lands first on test-all's PATH,
# pyright picks it over --pythonpath, and playwright boots
# .venv/bin/deckd. So only create one when nothing is active.
if [ -z "${VIRTUAL_ENV:-}" ]; then
uv venv --python 3.11 --allow-existing
fi
uv pip install -e ".[dev,macos]"
cd client && npm install
# Dispatch: picks setup-linux on Linux, setup-macos on macOS. flox users
# don't need this -- flox activate handles its own venv.
setup:
@if [ "$(uname)" = Darwin ]; then \
just setup-macos; \
else \
just setup-linux; \
fi
# Run the daemon against the decks directory, serving the built client.
run-daemon:
VLC_HTTP_PASSWORD=dummy deckd --decks-dir decks --client-dist client/dist --verbose
# Run the daemon on the LAN without a built client (use dev-client-lan for HMR).
# Binds to 0.0.0.0 so a phone on the LAN (or Tailscale) can reach it
# (issue #66). Token auth still gates every non-localhost connection.
run-daemon-lan:
VLC_HTTP_PASSWORD=dummy deckd --bind 0.0.0.0 --decks-dir decks --verbose
# Default ports, and the knobs that let worktrees coexist. `just worktree-adopt`
# writes all four into a per-checkout ./.env (loaded above); set them by hand
# for a one-off. DECKD_E2E_PORT and DECKD_SMOKE_PORT move the two throwaway
# test daemons, so two worktrees can run `just test-all` at the same time.
DECKD_PORT := env_var_or_default("DECKD_PORT", "8765")
VITE_PORT := env_var_or_default("VITE_PORT", "5173")
DECKD_E2E_PORT := env_var_or_default("DECKD_E2E_PORT", "8975")
DECKD_SMOKE_PORT := env_var_or_default("DECKD_SMOKE_PORT", "18765")
# Kill whatever is bound to the two ports we use: the daemon (default :8765)
# and the Vite dev server (default :5173). Handy when a stale daemon still
# holds the port (deckd now fails fast on that) or a dev server outlived its
# terminal, leaving the client with no backend. Honours DECKD_PORT /
# VITE_PORT so multi-worktree `just kill` only tears down the current
# worktree's processes, not every deckd on the box.
kill:
#!/usr/bin/env bash
set -uo pipefail
for port in {{DECKD_PORT}} {{VITE_PORT}}; do
pids=$(lsof -ti "tcp:$port" 2>/dev/null || true)
if [ -z "$pids" ]; then
echo ":$port already free"
continue
fi
echo "killing :$port -> $pids"
kill $pids 2>/dev/null || true
sleep 0.3
pids=$(lsof -ti "tcp:$port" 2>/dev/null || true)
if [ -n "$pids" ]; then
echo " still alive, SIGKILL -> $pids"
kill -9 $pids 2>/dev/null || true
fi
done
# Run the whole dev stack with one command: the daemon (LAN, restart-on-edit)
# plus the Vite client (tailscale HTTPS). Ctrl+C — or either process dying —
# stops both. This replaces the old Procfile
# (daemon: dev-daemon-lan / client: dev-client-tailscale). For a plain
# LAN/HTTP client with no cert, run `just dev-lan` instead. Honours
# DECKD_PORT / VITE_PORT for multi-worktree setups; the recipe prints which
# ports it's using so you know where to point the phone.
dev:
#!/usr/bin/env bash
set -uo pipefail
echo "deckd on :{{DECKD_PORT}}, vite on :{{VITE_PORT}}"
# kill 0 targets this script's process group, so Ctrl+C tears down both
# `just` children AND their grandchildren (deckd, node/vite) — no orphans
# left holding :{{DECKD_PORT}} / :{{VITE_PORT}}.
trap 'kill 0' EXIT
just dev-daemon-lan &
just dev-client-tailscale &
# Fall through (and via the trap, stop the sibling) the moment either exits.
wait -n
# Same as `just dev` but with the plain-HTTP LAN client (no tailscale cert,
# no sudo). Reachable at http://<host>:5173/ on the LAN; no PWA install
# prompt (that needs the HTTPS secure context `just dev` provides).
# TODO: consider replacing this with overmind/hivemind
dev-lan:
#!/usr/bin/env bash
set -uo pipefail
echo "deckd on :{{DECKD_PORT}}, vite on :{{VITE_PORT}}"
trap 'kill 0' EXIT
just dev-daemon-lan &
just dev-client-lan &
wait -n
# Run the daemon under a supervisor that restarts it when daemon/**/*.py
# changes. Deck YAML hot-reload is built into the daemon itself; this is
# only useful when editing Python.
dev-daemon:
VLC_HTTP_PASSWORD=dummy deckd-dev --port {{DECKD_PORT}} --verbose
# Same, but bind the daemon to all interfaces so a phone on the LAN
# (or Tailscale) can reach it. deckd-dev forwards unknown args to the
# child, so --bind, --port, and --verbose end up on the deckd process.
# Issue #66. --port honours DECKD_PORT so multiple worktrees can coexist.
dev-daemon-lan:
VLC_HTTP_PASSWORD=dummy deckd-dev --bind 0.0.0.0 --port {{DECKD_PORT}} --verbose
# Vite dev server on the LAN. Vite proxies /ws and /health to the local
# daemon (see vite.config.ts), so the client is same-origin at the vite
# origin. Honours VITE_PORT for multi-worktree setups; --strictPort is
# dropped when VITE_PORT is overridden so Vite can fall through to the
# next free port if a sibling worktree grabbed the override.
dev-client-lan:
#!/usr/bin/env bash
set -euo pipefail
port_flag="--port {{VITE_PORT}} --strictPort"
if [ "{{VITE_PORT}}" != "5173" ]; then
echo "vite on :{{VITE_PORT}} (DECKD_UPSTREAM=http://127.0.0.1:{{DECKD_PORT}})"
port_flag="--port {{VITE_PORT}}"
else
echo "vite on :{{VITE_PORT}}"
fi
cd client && DECKD_UPSTREAM="http://127.0.0.1:{{DECKD_PORT}}" npm run dev -- --host 0.0.0.0 $port_flag
# Vite dev server with HTTPS via a tailscale-provisioned cert. Required
# for Chrome's PWA install prompt (secure-context gate). Provisions the
# cert lazily on first run; caches it under client/.tls (gitignored).
# Phone opens https://<host>.<tailnet>.ts.net:5173/ (or the override of
# VITE_PORT). Honours VITE_PORT for multi-worktree setups; --strictPort
# is dropped when overridden so Vite can fall through if the port is busy.
dev-client-tailscale:
#!/usr/bin/env bash
set -euo pipefail
ts() { local cmd="tailscale"; for c in tailscale /Applications/Tailscale.app/Contents/MacOS/Tailscale; do if command -v "$c" &>/dev/null; then cmd="$c"; break; fi; done; if ! command -v "$cmd" &>/dev/null; then echo "tailscale CLI not found. Install Tailscale or symlink it:" >&2; echo " sudo ln -s /Applications/Tailscale.app/Contents/MacOS/Tailscale /usr/local/bin/tailscale" >&2; exit 1; fi; echo "$cmd"; }
ts="$(ts)"
host="$("$ts" status --self --json | jq -r .Self.DNSName | sed 's:\.$::')"
tls="client/.tls"
mkdir -p "$tls"
if [ ! -f "$tls/$host.crt" ] || [ ! -f "$tls/$host.key" ]; then
echo "Provisioning tailscale cert for $host in $tls/ (requires sudo)..."
(cd "$tls" && sudo "$ts" cert "$host" && sudo chown "$USER" "$host.crt" "$host.key")
fi
echo "-> https://$host:{{VITE_PORT}}/"
port_flag="--port {{VITE_PORT}} --strictPort"
if [ "{{VITE_PORT}}" != "5173" ]; then
port_flag="--port {{VITE_PORT}}"
fi
cd client && DECKD_TLS_DIR="./.tls" DECKD_TLS_HOST="$host" DECKD_UPSTREAM="http://127.0.0.1:{{DECKD_PORT}}" npm run dev -- --host 0.0.0.0 $port_flag
# Build the client (output: client/dist/).
build-client:
cd client && npm run build
# Build the client and Ladle with the GitHub Pages base paths
# (jonocodes.github.io/deckd/) so the deploy-pages workflow can be
# repro'd locally. Output: client/dist/ (index.html, gallery.html,
# ladle/). Use `npx serve client/dist` to browse before pushing.
build-pages:
#!/usr/bin/env bash
set -euo pipefail
cd client
VITE_BASE_PATH=/deckd/ npm run build
mkdir -p dist/ladle
npm run ladle:build -- --base /deckd/ladle/ --outDir dist/ladle
ladle:
cd client && npm run ladle
# Take phone-framed screenshots of the demo decks. Starts a Vite dev
# server, opens /screenshots.html in Chromium (Playwright, reuses the
# nix-store binary from e2e), snaps each configured shot, and saves them
# to docs/screenshots/. Edit client/src/Screenshots.tsx to curate the list.
screenshots:
cd client && node screenshots.mjs
# Rasterises client/public/icon.svg (the source of truth) into the PWA any +
# maskable PNGs, the apple-touch-icon, and packaging/macos/deckd.icns. Uses
# the e2e Chromium, so client/node_modules must be installed; the .icns step
# is macOS-only (iconutil) and is skipped elsewhere.
#
# Regenerate all app icons from the brand SVG
icons:
#!/usr/bin/env bash
set -euo pipefail
cd client && node render-icons.mjs
cd ..
if command -v iconutil >/dev/null 2>&1; then
iconutil -c icns build/deckd.iconset -o packaging/macos/deckd.icns
echo "Wrote packaging/macos/deckd.icns"
else
echo "iconutil not found (not macOS); skipped .icns, PNGs are up to date."
fi
# Run the full verification ladder (docs/ONBOARDING.md) in order:
# typechecks first (cheap gates), then Python unit/integration + the
# producer/parser shape contracts, then TypeScript compile, client unit
# tests, Playwright e2e, the daemon smoke test, and finally the lint
# sweep. Each step must pass before the next. Skips nothing; anything
# that needs human-on-hardware verification lives above this ladder
# (see docs/TESTING.md).
#
# This mirrors the ubuntu `test` job in `.github/workflows/ci.yml` 1:1 —
# same steps, same order, same numbering — so a failure there reproduces
# exactly here. (CI's macOS, Nix, and 3.12 jobs have no local twin; run
# `just nix-check` for the Nix one.) On failure the ERR trap names the
# failed subsystem and how to re-run; see #77.
test-all:
#!/usr/bin/env bash
set -euo pipefail
# Only prepend ./.venv when it's the real env — under flox it either
# doesn't exist or (historically) is an empty shell that shadows the
# active interpreter and breaks every step below.
if [ -x .venv/bin/python ]; then
export PATH="$PWD/.venv/bin:$PATH"
fi
# On any failure, say *which* subsystem broke and how to reproduce it
# (#77 AC: failures name the subsystem and remediation, not just a
# bare nonzero exit). STEP is reassigned before every step below.
STEP=""
trap 'code=$?; if [ -n "${STEP:-}" ]; then echo "" >&2; echo "FAILED: $STEP (exit $code)" >&2; echo "Fix the failure above, then re-run: just test-all" >&2; fi; exit $code' ERR
STEP="1/8 pyright daemon"; echo "== $STEP =="
# --pythonpath resolves imports against whichever env is active
# (flox cache or ./.venv); see [tool.pyright] in pyproject.toml.
pyright --pythonpath "$(command -v python)" daemon
STEP="2/8 pytest"; echo "== $STEP =="
pytest
# The GNOME extension and KWin script are the upstream half of the
# focus wire protocol; these pure-node contracts guard the producer
# shape and parser reconciliation without a compositor (#130). They
# run under `just test` too — keep the ladder a superset of it.
STEP="3/8 focus-wire contracts"; echo "== $STEP =="
node scripts/test_focus_wire_shape.mjs
node scripts/test_kwin_focus_bridge.mjs
STEP="4/8 tsc --noEmit"; echo "== $STEP =="
(cd client && npx tsc --noEmit)
STEP="5/8 vitest unit"; echo "== $STEP =="
(cd client && npm run test:unit)
STEP="6/8 playwright e2e"; echo "== $STEP =="
# Build the client before e2e — playwright serves client/dist, so
# any TypeScript change in client/src must be bundled for the
# browser to pick it up. The build lives in this step (not the vitest
# one) so a bundling failure is attributed to e2e, matching CI.
(cd client && npm run build)
(cd client && npm run test:e2e)
STEP="7/8 smoke"; echo "== $STEP =="
just smoke
STEP="8/8 eslint"; echo "== $STEP =="
(cd client && npm run lint)
# Run the test suite.
test:
pytest
node scripts/test_focus_wire_shape.mjs
node scripts/test_kwin_focus_bridge.mjs
# Protocol drift guard (#76): regenerate the TypeScript wire types from
# daemon/deckd/protocol.py and fail if the checked-in
# client/src/protocol.generated.ts has drifted. Edit protocol.py and
# run `just check-protocol` (or
# `python scripts/codegen_protocol_ts.py --out client/src/protocol.generated.ts`
# to regenerate). The companion drift test in
# tests/test_protocol_ts_drift.py runs the same check in pytest so CI
# catches drift without needing the Justfile recipe.
check-protocol:
python scripts/codegen_protocol_ts.py --check --out client/src/protocol.generated.ts
# Regenerate the TypeScript wire types from daemon/deckd/protocol.py (#76).
# Run after editing the protocol; check-protocol (and CI) will fail until
# the generated file is in sync.
gen-protocol:
python scripts/codegen_protocol_ts.py --out client/src/protocol.generated.ts
# Run the GNOME + KDE focus JSON producer contracts independently.
test-focus-wire:
node scripts/test_focus_wire_shape.mjs
node scripts/test_kwin_focus_bridge.mjs
# Live-bus MPRIS smoke test — NOT part of `test` / CI. Publishes a real
# MPRIS player on the session bus and asserts the production
# DbusMprisBackend enumerates it with correct metadata. Needs a desktop
# session bus; skips (exit 0) on a headless box. See
# scripts/smoke_mpris_live.py.
smoke-mpris:
"${VIRTUAL_ENV:-.venv}/bin/python" scripts/smoke_mpris_live.py
# Live-bus GNOME focus smoke test (#129) — NOT part of `test` / CI.
# Drives the production GnomeShellFocusBackend over the live session bus
# and asserts GetActiveWindow / ListWindows / RaiseWindow are well-formed
# — catches extension↔compositor drift daemon-side mocks can't (the
# e166242 empty-list bug). Needs a GNOME session with deckd-focus@local
# enabled and a window open; skips (exit 0) when org.deckd.Focus isn't on
# the bus. See scripts/smoke_focus_live.py.
smoke-focus:
"${VIRTUAL_ENV:-.venv}/bin/python" scripts/smoke_focus_live.py
# Run the client test suite (Vitest unit tests + Playwright e2e). The e2e
# half boots the daemon with PYTHONPATH=scripts/no-evdev so its uinput sink
# is shadowed to LoggingKeySink — keystrokes are logged, not injected into
# the host desktop. See client/e2e/kbd-mode.spec.ts for the suite.
test-client:
cd client && npm run test:unit
cd client && npm run test:e2e
# End-to-end smoke test (boots daemon in-process, fires every action
# primitive). Uses a stable fixture deck (scripts/smoke_fixtures/)
# so shipping-deck edits can't break CI (#77). Pass --decks-dir to
# point at shipping decks (or anything else) instead. Binds DECKD_SMOKE_PORT
# (default :18765, well away from any live daemon) so two worktrees can run
# it concurrently.
smoke:
DECKD_SMOKE_PORT={{DECKD_SMOKE_PORT}} python -u scripts/smoke.py
# Check whether this shell can create a uinput scroll device.
check-uinput:
python -u scripts/check_uinput.py
# Install and enable the local GNOME Shell focus extension for Spike #2.
install-focus-extension:
tmpdir="$(mktemp -d)"; gnome-extensions pack -f -o "$tmpdir" packaging/gnome-shell/deckd-focus@local; gnome-extensions install --force --print-uuid "$tmpdir/deckd-focus@local.shell-extension.zip"; rm -rf "$tmpdir"; if gnome-extensions list | grep -qx deckd-focus@local; then gnome-extensions enable deckd-focus@local; else echo "Installed deckd-focus@local. Log out/in, then run: gnome-extensions enable deckd-focus@local"; fi
# Install and enable the deckd-focus KWin script for KDE Plasma Wayland (#31).
#
# Mirrors install-focus-extension: installs the script package into
# ~/.local/share/kwin/scripts/, persists the kwinrc enable flag so it
# survives relogin, applies the change with reconfigure, and hot-starts
# the script via org.kde.kwin.Scripting.loadScript so focus events flow
# immediately without a relogin. Re-run anytime to reload the in-process
# script (e.g. after editing main.js, or after the daemon restarts later
# than the script's initial push).
#
# Requires: kpackagetool6, kwriteconfig6, qdbus6 (qdbus) on $PATH —
# stock Plasma 6 dev packages.
install-focus-kwin:
#!/usr/bin/env bash
set -euo pipefail
pkg="packaging/kwin-script/deckd-focus"
script_id="deckd-focus"
script_path="$HOME/.local/share/kwin/scripts/${script_id}/contents/code/main.js"
# 1. Install (or upgrade) the KWin script package into the user dir.
# -u fails on a first run ("Plugin deckd-focus is not installed"),
# so install first and fall back to upgrade when it already exists.
kpackagetool6 --type=KWin/Script -i "$pkg" 2>/dev/null \
|| kpackagetool6 --type=KWin/Script -u "$pkg"
# 2. Persist enable across relogins (kwinrc [Plugins] deckd-focusEnabled=true).
kwriteconfig6 --file kwinrc --group Plugins --key "${script_id}Enabled" true
# 3. Apply kwinrc changes so a KWin restart picks the script up automatically.
qdbus org.kde.KWin /KWin org.kde.KWin.reconfigure >/dev/null
# 4. Hot-start: unload any in-process copy so we never get duplicate
# handlers, then loadScript fires the script's initial
# push(workspace.activeWindow) against the running daemon's
# org.deckd.Focus cache.
qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.unloadScript "${script_id}" >/dev/null 2>&1 || true
qdbus org.kde.KWin /Scripting org.kde.kwin.Scripting.loadScript "${script_path}" "${script_id}" >/dev/null
echo "deckd-focus KWin script installed, enabled, and hot-started."
echo "Run 'just watch-focus' to confirm focus events land."
# Install deckd as a per-user session service so it starts with your desktop.
#
# OS-aware (like `just setup`): on Linux installs the systemd *user* unit
# (packaging/systemd/deckd.service) to ~/.config/systemd/user and enables it;
# on macOS installs the launchd LaunchAgent (packaging/launchd/...) to
# ~/Library/LaunchAgents and loads it. Both substitute this checkout's path in.
# deckd is a per-user desktop-session daemon, hence a *user* service, not a
# system one. Run `just setup && just build-client` and set up the focus watcher
# (`just install-focus-extension` / `install-focus-kwin`) first. Re-run anytime.
install-service:
#!/usr/bin/env bash
set -euo pipefail
project_dir="$(pwd)"
if [ "$(uname)" = "Darwin" ]; then
dest="$HOME/Library/LaunchAgents/com.deckd.daemon.plist"
sed "s|@PROJECT_DIR@|${project_dir}|g" packaging/launchd/com.deckd.daemon.plist > "$dest"
launchctl unload "$dest" 2>/dev/null || true
launchctl load "$dest"
echo "com.deckd.daemon LaunchAgent installed and loaded. Logs: tail -f ${project_dir}/deckd.log"
else
dest_dir="${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user"
mkdir -p "$dest_dir"
sed "s|@PROJECT_DIR@|${project_dir}|g" packaging/systemd/deckd.service > "$dest_dir/deckd.service"
systemctl --user daemon-reload
systemctl --user enable --now deckd.service
echo "deckd.service installed and started. Logs: journalctl --user -u deckd -f"
echo "Optional: 'sudo loginctl enable-linger $USER' keeps it running when you're logged out."
fi
# Print active app/window changes for Spike #2.
watch-focus:
python -u scripts/watch_focus.py
# Single snapshot of the active app/window.
watch-focus-once:
python -u scripts/watch_focus.py --once
# Hit /health.
#
# These four all target DECKD_PORT — i.e. *this* checkout's daemon. Without
# that, deckctl's own default (:8765) would answer from whatever holds the
# default port, which on a machine running an installed deckd service is the
# prod daemon rather than the dev one you just started. To aim at another
# instance deliberately: `DECKD_PORT=8765 just status`.
status:
deckctl --port {{DECKD_PORT}} status
# Hit /diag (issue #70): one-shot machine-readable snapshot of the
# daemon's focus, input, decks, sessions, and MPRIS state. Open-auth,
# so it works without the password. Same shape ``deckctl status``
# uses, just on a richer endpoint.
diag:
#!/usr/bin/env bash
set -euo pipefail
deckctl --port {{DECKD_PORT}} diag
# Hit /decks (issue #70): enumeration of loaded decks and safe
# widget summaries (no action bodies).
decks:
#!/usr/bin/env bash
set -euo pipefail
deckctl --port {{DECKD_PORT}} decks
# Hit /metrics (issue #71): Prometheus text-format scrape. Open-auth
# and stdlib-only on the server side; pipe into ``head`` or
# ``grep deckd_`` for a quick check.
metrics:
#!/usr/bin/env bash
set -euo pipefail
deckctl --port {{DECKD_PORT}} metrics
# Print the version from pyproject.toml. The single source for artifact
# names (the DMG) and the release tag guard, so they can't drift.
version:
@sed -n 's/^version = "\(.*\)"/\1/p' pyproject.toml | head -n1
# Build the self-contained macOS app bundle (dist/deckd.app, issue #165).
# macOS only: a .app needs Apple tooling, so this refuses to run elsewhere.
# Installs the [packaging] extra (PyInstaller) on demand and builds the
# client first if it's missing. Output is ad-hoc signed, not notarized.
build-macos-app:
#!/usr/bin/env bash
set -euo pipefail
if [ "$(uname)" != "Darwin" ]; then
echo "build-macos-app needs macOS; a .app can't be built on $(uname)." >&2
exit 1
fi
if ! command -v pyinstaller >/dev/null 2>&1; then
echo "installing PyInstaller ([packaging] extra)..."
uv pip install -e ".[packaging]"
fi
if [ ! -f client/dist/index.html ]; then
echo "client/dist missing; building client..."
just build-client
fi
pyinstaller --noconfirm --clean packaging/macos/deckd.spec
echo "Built dist/deckd.app (ad-hoc signed, not notarized)."
# Wrap dist/deckd.app in a distributable DMG for a GitHub release (#165).
# ``DECKD_VERSION`` names the artifact (the release workflow sets it from the
# git tag); otherwise it falls back to pyproject's version.
build-macos-dmg: build-macos-app
#!/usr/bin/env bash
set -euo pipefail
version="${DECKD_VERSION:-$(just version)}"
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
cp -R dist/deckd.app "$stage/"
ln -s /Applications "$stage/Applications"
hdiutil create -volname "deckd ${version}" -srcfolder "$stage" \
-ov -format UDZO "dist/deckd-${version}.dmg"
echo "Built dist/deckd-${version}.dmg"
# Build the self-contained Linux AppImage (dist/deckd-<version>-<arch>.AppImage,
# issue #168). Linux only: appimagetool wraps a PyInstaller onedir tree in a
# squashfs. Installs the [uinput,dbus,packaging] extras on demand and builds
# the client first if it's missing. The udev rule and focus-watcher sources ride
# along under usr/share/deckd/integration for the install helper.
build-linux-appimage:
#!/usr/bin/env bash
set -euo pipefail
if [ "$(uname)" != "Linux" ]; then
echo "build-linux-appimage needs Linux; an AppImage can't be built on $(uname)." >&2
exit 1
fi
arch="$(uname -m)"
if ! command -v pyinstaller >/dev/null 2>&1; then
echo "installing packaging deps..."
uv pip install -e ".[uinput,dbus,packaging]"
fi
if [ "$arch" != "x86_64" ]; then
echo "note: $arch has no evdev-binary wheel; ensuring a source build." >&2
PYTHON="$(command -v python)" bash scripts/install_evdev_source.sh \
|| echo "warn: evdev source build failed; key injection will no-op." >&2
fi
if [ ! -f client/dist/index.html ]; then
echo "client/dist missing; building client..."
just build-client
fi
pyinstaller --noconfirm --clean packaging/linux/deckd.spec
version="${DECKD_VERSION:-$(just version)}"
work="$(mktemp -d)"
trap 'rm -rf "$work"' EXIT
appdir="$work/deckd.AppDir"
mkdir -p "$appdir/usr/bin" "$appdir/usr/share/deckd/integration/gnome-shell" \
"$appdir/usr/share/deckd/integration/kwin-script"
cp -R dist/deckd/. "$appdir/usr/bin/"
cp packaging/udev/70-deckd-uinput.rules "$appdir/usr/share/deckd/integration/"
cp -R packaging/gnome-shell/deckd-focus@local "$appdir/usr/share/deckd/integration/gnome-shell/"
cp -R packaging/kwin-script/deckd-focus "$appdir/usr/share/deckd/integration/kwin-script/"
cp packaging/linux/install-system-integration.sh "$appdir/usr/share/deckd/integration/"
cp packaging/linux/appimage/AppRun "$appdir/AppRun"
chmod +x "$appdir/AppRun"
cp packaging/linux/appimage/deckd.desktop "$appdir/"
# Icons: the committed PNGs are the single source of truth (regenerate with
# `just icons`). appimagetool turns the root deckd.png into .DirIcon, and the
# hicolor copies let desktop integration resolve the .desktop's Icon=deckd.
[ -f client/public/icon-512.png ] \
|| { echo "missing client/public/icon-512.png; run: just icons" >&2; exit 1; }
cp client/public/icon-512.png "$appdir/deckd.png"
for size in 192 512; do
mkdir -p "$appdir/usr/share/icons/hicolor/${size}x${size}/apps"
cp "client/public/icon-${size}.png" \
"$appdir/usr/share/icons/hicolor/${size}x${size}/apps/deckd.png"
done
# The install helper copies the icon into the user's theme, so it rides
# along in the integration tree it extracts.
cp client/public/icon-512.png "$appdir/usr/share/deckd/integration/deckd.png"
tooling="${XDG_CACHE_HOME:-$HOME/.cache}/deckd/appimagetool-${arch}.AppImage"
if [ ! -x "$tooling" ]; then
echo "fetching appimagetool (${arch})..."
mkdir -p "$(dirname "$tooling")"
curl -fsSL -o "$tooling" \
"https://github.com/AppImage/appimagetool/releases/download/continuous/appimagetool-${arch}.AppImage"
chmod +x "$tooling"
fi
export ARCH="$arch"
APPIMAGE_EXTRACT_AND_RUN=1 "$tooling" "$appdir" "dist/deckd-${version}-${arch}.AppImage"
cp packaging/linux/install-system-integration.sh dist/deckd-install-system-integration.sh
echo "Built dist/deckd-${version}-${arch}.AppImage (+ dist/deckd-install-system-integration.sh)"
# Stage the integration assets from a checkout and run the privileged helper
# for the current user (issue #168): udev rule + input group (root), plus the
# focus watcher and an XDG autostart entry (user). Prompts for sudo. Useful for
# a source install and for testing the helper without building an AppImage.
# Extra args pass through (e.g. `--desktop gnome`, `--uninstall`).
install-system-integration *args:
#!/usr/bin/env bash
set -euo pipefail
stage="$(mktemp -d)"
trap 'rm -rf "$stage"' EXIT
mkdir -p "$stage/gnome-shell" "$stage/kwin-script"
cp packaging/udev/70-deckd-uinput.rules "$stage/"
cp -R packaging/gnome-shell/deckd-focus@local "$stage/gnome-shell/"
cp -R packaging/kwin-script/deckd-focus "$stage/kwin-script/"
cp client/public/icon-512.png "$stage/deckd.png"
sudo packaging/linux/install-system-integration.sh --assets "$stage" {{args}}
# Run the Nix flake checks: builds packages.deckd and the focus-watcher
# bundles, evaluates the NixOS + home-manager modules, unit-tests the
# activation scripts in a sandbox, and boots the packaged daemon on
# loopback (health, client dist, bundled decks). Needs Nix with flakes.
# See docs/GUIDE.md "Nix flake, NixOS, and home-manager".
nix-check:
nix flake check -L
# --- Worktrees ------------------------------------------------------------
# Code needs nothing for `git worktree` (every path resolves from the file's
# own location). What a fresh checkout lacks is the gitignored scaffolding —
# .envrc, .venv, client/node_modules, TLS certs — plus a port assignment that
# doesn't collide with its siblings. See docs/ONBOARDING.md#worktrees-git-worktree.
# "Adopt" because the worktree usually already exists: an agent harness
# (Paseo, Cursor) or a plain `git worktree add` made it, and this claims it
# afterwards. Assigns free ports -> ./.env, wires .envrc to the primary
# checkout's flox env, copies over gitignored bits worth sharing (TLS certs),
# then runs `just setup`. Idempotent, so it's also the repair command. Pass
# --no-install to skip the slow dependency step, --force to reassign ports.
#
# Make THIS worktree dev-ready: ports, env, dependencies. [--no-install] [--force]
worktree-adopt *args:
@bash scripts/worktree.sh adopt {{args}}
# Checks port assignment (including collisions with siblings), .envrc,
# toolchain on PATH, venv, and client deps. Every failure prints its fix;
# exits non-zero if the checkout isn't ready.
#
# Why isn't this worktree working?
worktree-doctor:
@bash scripts/worktree.sh doctor
# Every worktree with its assigned ports and readiness. `*` marks this one.
worktree-list:
@bash scripts/worktree.sh list
# For harness-created worktrees, run `worktree-adopt` inside the checkout
# instead — this is only for the from-scratch case.
#
# Add a worktree at ../deckd-<branch> on a new branch off HEAD, then adopt it.
worktree-create branch *args:
#!/usr/bin/env bash
set -euo pipefail
dir="$(cd "$(git rev-parse --show-toplevel)/.." && pwd)/deckd-{{branch}}"
git worktree add -b "{{branch}}" "$dir"
cd "$dir" && just worktree-adopt {{args}}