gardesk/gardm / 9d71c5e

Browse files

workspace skeleton and IPC protocol

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
9d71c5e4a3d45bd8a8a2fa4bcd64e7f5ab195902
Parents
ab309fd
Tree
c2b6aa7

23 changed files

StatusFile+-
A .fackr/workspace.json 53 0
M .gitignore 1 3
A Cargo.lock 659 0
A Cargo.toml 41 0
A docs/architecture.md 219 0
A docs/checklist.md 157 0
A docs/gardm.md 14 0
A docs/sprints/sprint-0-setup.md 246 0
A docs/sprints/sprint-1-pam.md 356 0
A docs/sprints/sprint-2-x11.md 487 0
A docs/sprints/sprint-3-greeter-ui.md 586 0
A docs/sprints/sprint-4-garbg-integration.md 453 0
A etc/config.toml 34 0
A etc/gardm.service 25 0
A gardm-greeter/Cargo.toml 18 0
A gardm-greeter/src/main.rs 35 0
A gardm-ipc/Cargo.toml 12 0
A gardm-ipc/src/lib.rs 199 0
A gardmd/Cargo.toml 23 0
A gardmd/src/config.rs 155 0
A gardmd/src/ipc.rs 99 0
A gardmd/src/lib.rs 9 0
A gardmd/src/main.rs 174 0
.fackr/workspace.jsonadded
@@ -0,0 +1,53 @@
1
+{
2
+  "active_tab": 1,
3
+  "tabs": [
4
+    {
5
+      "files": [
6
+        {
7
+          "path": ".gitignore",
8
+          "is_orphan": false
9
+        }
10
+      ],
11
+      "active_pane": 0,
12
+      "panes": [
13
+        {
14
+          "buffer_idx": 0,
15
+          "cursor_line": 3,
16
+          "cursor_col": 0,
17
+          "viewport_line": 0,
18
+          "viewport_col": 0,
19
+          "bounds": {
20
+            "x_start": 0.0,
21
+            "y_start": 0.0,
22
+            "x_end": 1.0,
23
+            "y_end": 1.0
24
+          }
25
+        }
26
+      ]
27
+    },
28
+    {
29
+      "files": [
30
+        {
31
+          "path": "docs/gardm.md",
32
+          "is_orphan": false
33
+        }
34
+      ],
35
+      "active_pane": 0,
36
+      "panes": [
37
+        {
38
+          "buffer_idx": 0,
39
+          "cursor_line": 13,
40
+          "cursor_col": 109,
41
+          "viewport_line": 0,
42
+          "viewport_col": 9,
43
+          "bounds": {
44
+            "x_start": 0.0,
45
+            "y_start": 0.0,
46
+            "x_end": 1.0,
47
+            "y_end": 1.0
48
+          }
49
+        }
50
+      ]
51
+    }
52
+  ]
53
+}
.gitignoremodified
@@ -1,3 +1,1 @@
1
-docs/
2
-.fackr/
3
-CLAUDE.md
1
+/target
Cargo.lockadded
@@ -0,0 +1,659 @@
1
+# This file is automatically @generated by Cargo.
2
+# It is not intended for manual editing.
3
+version = 4
4
+
5
+[[package]]
6
+name = "aho-corasick"
7
+version = "1.1.4"
8
+source = "registry+https://github.com/rust-lang/crates.io-index"
9
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
10
+dependencies = [
11
+ "memchr",
12
+]
13
+
14
+[[package]]
15
+name = "anyhow"
16
+version = "1.0.100"
17
+source = "registry+https://github.com/rust-lang/crates.io-index"
18
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
19
+
20
+[[package]]
21
+name = "bitflags"
22
+version = "2.10.0"
23
+source = "registry+https://github.com/rust-lang/crates.io-index"
24
+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
25
+
26
+[[package]]
27
+name = "bytes"
28
+version = "1.11.0"
29
+source = "registry+https://github.com/rust-lang/crates.io-index"
30
+checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
31
+
32
+[[package]]
33
+name = "cfg-if"
34
+version = "1.0.4"
35
+source = "registry+https://github.com/rust-lang/crates.io-index"
36
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
37
+
38
+[[package]]
39
+name = "equivalent"
40
+version = "1.0.2"
41
+source = "registry+https://github.com/rust-lang/crates.io-index"
42
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
43
+
44
+[[package]]
45
+name = "errno"
46
+version = "0.3.14"
47
+source = "registry+https://github.com/rust-lang/crates.io-index"
48
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
49
+dependencies = [
50
+ "libc",
51
+ "windows-sys 0.61.2",
52
+]
53
+
54
+[[package]]
55
+name = "gardm-greeter"
56
+version = "0.1.0"
57
+dependencies = [
58
+ "anyhow",
59
+ "gardm-ipc",
60
+ "thiserror",
61
+ "tokio",
62
+ "tracing",
63
+ "tracing-subscriber",
64
+]
65
+
66
+[[package]]
67
+name = "gardm-ipc"
68
+version = "0.1.0"
69
+dependencies = [
70
+ "serde",
71
+ "serde_json",
72
+ "thiserror",
73
+ "tokio",
74
+]
75
+
76
+[[package]]
77
+name = "gardmd"
78
+version = "0.1.0"
79
+dependencies = [
80
+ "anyhow",
81
+ "gardm-ipc",
82
+ "nix",
83
+ "sd-notify",
84
+ "serde",
85
+ "serde_json",
86
+ "thiserror",
87
+ "tokio",
88
+ "toml",
89
+ "tracing",
90
+ "tracing-subscriber",
91
+]
92
+
93
+[[package]]
94
+name = "hashbrown"
95
+version = "0.16.1"
96
+source = "registry+https://github.com/rust-lang/crates.io-index"
97
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
98
+
99
+[[package]]
100
+name = "indexmap"
101
+version = "2.13.0"
102
+source = "registry+https://github.com/rust-lang/crates.io-index"
103
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
104
+dependencies = [
105
+ "equivalent",
106
+ "hashbrown",
107
+]
108
+
109
+[[package]]
110
+name = "itoa"
111
+version = "1.0.17"
112
+source = "registry+https://github.com/rust-lang/crates.io-index"
113
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
114
+
115
+[[package]]
116
+name = "lazy_static"
117
+version = "1.5.0"
118
+source = "registry+https://github.com/rust-lang/crates.io-index"
119
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
120
+
121
+[[package]]
122
+name = "libc"
123
+version = "0.2.180"
124
+source = "registry+https://github.com/rust-lang/crates.io-index"
125
+checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
126
+
127
+[[package]]
128
+name = "lock_api"
129
+version = "0.4.14"
130
+source = "registry+https://github.com/rust-lang/crates.io-index"
131
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
132
+dependencies = [
133
+ "scopeguard",
134
+]
135
+
136
+[[package]]
137
+name = "log"
138
+version = "0.4.29"
139
+source = "registry+https://github.com/rust-lang/crates.io-index"
140
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
141
+
142
+[[package]]
143
+name = "matchers"
144
+version = "0.2.0"
145
+source = "registry+https://github.com/rust-lang/crates.io-index"
146
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
147
+dependencies = [
148
+ "regex-automata",
149
+]
150
+
151
+[[package]]
152
+name = "memchr"
153
+version = "2.7.6"
154
+source = "registry+https://github.com/rust-lang/crates.io-index"
155
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
156
+
157
+[[package]]
158
+name = "mio"
159
+version = "1.1.1"
160
+source = "registry+https://github.com/rust-lang/crates.io-index"
161
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
162
+dependencies = [
163
+ "libc",
164
+ "wasi",
165
+ "windows-sys 0.61.2",
166
+]
167
+
168
+[[package]]
169
+name = "nix"
170
+version = "0.27.1"
171
+source = "registry+https://github.com/rust-lang/crates.io-index"
172
+checksum = "2eb04e9c688eff1c89d72b407f168cf79bb9e867a9d3323ed6c01519eb9cc053"
173
+dependencies = [
174
+ "bitflags",
175
+ "cfg-if",
176
+ "libc",
177
+]
178
+
179
+[[package]]
180
+name = "nu-ansi-term"
181
+version = "0.50.3"
182
+source = "registry+https://github.com/rust-lang/crates.io-index"
183
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
184
+dependencies = [
185
+ "windows-sys 0.61.2",
186
+]
187
+
188
+[[package]]
189
+name = "once_cell"
190
+version = "1.21.3"
191
+source = "registry+https://github.com/rust-lang/crates.io-index"
192
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
193
+
194
+[[package]]
195
+name = "parking_lot"
196
+version = "0.12.5"
197
+source = "registry+https://github.com/rust-lang/crates.io-index"
198
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
199
+dependencies = [
200
+ "lock_api",
201
+ "parking_lot_core",
202
+]
203
+
204
+[[package]]
205
+name = "parking_lot_core"
206
+version = "0.9.12"
207
+source = "registry+https://github.com/rust-lang/crates.io-index"
208
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
209
+dependencies = [
210
+ "cfg-if",
211
+ "libc",
212
+ "redox_syscall",
213
+ "smallvec",
214
+ "windows-link",
215
+]
216
+
217
+[[package]]
218
+name = "pin-project-lite"
219
+version = "0.2.16"
220
+source = "registry+https://github.com/rust-lang/crates.io-index"
221
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
222
+
223
+[[package]]
224
+name = "proc-macro2"
225
+version = "1.0.105"
226
+source = "registry+https://github.com/rust-lang/crates.io-index"
227
+checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
228
+dependencies = [
229
+ "unicode-ident",
230
+]
231
+
232
+[[package]]
233
+name = "quote"
234
+version = "1.0.43"
235
+source = "registry+https://github.com/rust-lang/crates.io-index"
236
+checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
237
+dependencies = [
238
+ "proc-macro2",
239
+]
240
+
241
+[[package]]
242
+name = "redox_syscall"
243
+version = "0.5.18"
244
+source = "registry+https://github.com/rust-lang/crates.io-index"
245
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
246
+dependencies = [
247
+ "bitflags",
248
+]
249
+
250
+[[package]]
251
+name = "regex-automata"
252
+version = "0.4.13"
253
+source = "registry+https://github.com/rust-lang/crates.io-index"
254
+checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
255
+dependencies = [
256
+ "aho-corasick",
257
+ "memchr",
258
+ "regex-syntax",
259
+]
260
+
261
+[[package]]
262
+name = "regex-syntax"
263
+version = "0.8.8"
264
+source = "registry+https://github.com/rust-lang/crates.io-index"
265
+checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
266
+
267
+[[package]]
268
+name = "scopeguard"
269
+version = "1.2.0"
270
+source = "registry+https://github.com/rust-lang/crates.io-index"
271
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
272
+
273
+[[package]]
274
+name = "sd-notify"
275
+version = "0.4.5"
276
+source = "registry+https://github.com/rust-lang/crates.io-index"
277
+checksum = "b943eadf71d8b69e661330cb0e2656e31040acf21ee7708e2c238a0ec6af2bf4"
278
+dependencies = [
279
+ "libc",
280
+]
281
+
282
+[[package]]
283
+name = "serde"
284
+version = "1.0.228"
285
+source = "registry+https://github.com/rust-lang/crates.io-index"
286
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
287
+dependencies = [
288
+ "serde_core",
289
+ "serde_derive",
290
+]
291
+
292
+[[package]]
293
+name = "serde_core"
294
+version = "1.0.228"
295
+source = "registry+https://github.com/rust-lang/crates.io-index"
296
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
297
+dependencies = [
298
+ "serde_derive",
299
+]
300
+
301
+[[package]]
302
+name = "serde_derive"
303
+version = "1.0.228"
304
+source = "registry+https://github.com/rust-lang/crates.io-index"
305
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
306
+dependencies = [
307
+ "proc-macro2",
308
+ "quote",
309
+ "syn",
310
+]
311
+
312
+[[package]]
313
+name = "serde_json"
314
+version = "1.0.149"
315
+source = "registry+https://github.com/rust-lang/crates.io-index"
316
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
317
+dependencies = [
318
+ "itoa",
319
+ "memchr",
320
+ "serde",
321
+ "serde_core",
322
+ "zmij",
323
+]
324
+
325
+[[package]]
326
+name = "serde_spanned"
327
+version = "0.6.9"
328
+source = "registry+https://github.com/rust-lang/crates.io-index"
329
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
330
+dependencies = [
331
+ "serde",
332
+]
333
+
334
+[[package]]
335
+name = "sharded-slab"
336
+version = "0.1.7"
337
+source = "registry+https://github.com/rust-lang/crates.io-index"
338
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
339
+dependencies = [
340
+ "lazy_static",
341
+]
342
+
343
+[[package]]
344
+name = "signal-hook-registry"
345
+version = "1.4.8"
346
+source = "registry+https://github.com/rust-lang/crates.io-index"
347
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
348
+dependencies = [
349
+ "errno",
350
+ "libc",
351
+]
352
+
353
+[[package]]
354
+name = "smallvec"
355
+version = "1.15.1"
356
+source = "registry+https://github.com/rust-lang/crates.io-index"
357
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
358
+
359
+[[package]]
360
+name = "socket2"
361
+version = "0.6.1"
362
+source = "registry+https://github.com/rust-lang/crates.io-index"
363
+checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
364
+dependencies = [
365
+ "libc",
366
+ "windows-sys 0.60.2",
367
+]
368
+
369
+[[package]]
370
+name = "syn"
371
+version = "2.0.114"
372
+source = "registry+https://github.com/rust-lang/crates.io-index"
373
+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
374
+dependencies = [
375
+ "proc-macro2",
376
+ "quote",
377
+ "unicode-ident",
378
+]
379
+
380
+[[package]]
381
+name = "thiserror"
382
+version = "1.0.69"
383
+source = "registry+https://github.com/rust-lang/crates.io-index"
384
+checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
385
+dependencies = [
386
+ "thiserror-impl",
387
+]
388
+
389
+[[package]]
390
+name = "thiserror-impl"
391
+version = "1.0.69"
392
+source = "registry+https://github.com/rust-lang/crates.io-index"
393
+checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
394
+dependencies = [
395
+ "proc-macro2",
396
+ "quote",
397
+ "syn",
398
+]
399
+
400
+[[package]]
401
+name = "thread_local"
402
+version = "1.1.9"
403
+source = "registry+https://github.com/rust-lang/crates.io-index"
404
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
405
+dependencies = [
406
+ "cfg-if",
407
+]
408
+
409
+[[package]]
410
+name = "tokio"
411
+version = "1.49.0"
412
+source = "registry+https://github.com/rust-lang/crates.io-index"
413
+checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
414
+dependencies = [
415
+ "bytes",
416
+ "libc",
417
+ "mio",
418
+ "parking_lot",
419
+ "pin-project-lite",
420
+ "signal-hook-registry",
421
+ "socket2",
422
+ "tokio-macros",
423
+ "windows-sys 0.61.2",
424
+]
425
+
426
+[[package]]
427
+name = "tokio-macros"
428
+version = "2.6.0"
429
+source = "registry+https://github.com/rust-lang/crates.io-index"
430
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
431
+dependencies = [
432
+ "proc-macro2",
433
+ "quote",
434
+ "syn",
435
+]
436
+
437
+[[package]]
438
+name = "toml"
439
+version = "0.8.23"
440
+source = "registry+https://github.com/rust-lang/crates.io-index"
441
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
442
+dependencies = [
443
+ "serde",
444
+ "serde_spanned",
445
+ "toml_datetime",
446
+ "toml_edit",
447
+]
448
+
449
+[[package]]
450
+name = "toml_datetime"
451
+version = "0.6.11"
452
+source = "registry+https://github.com/rust-lang/crates.io-index"
453
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
454
+dependencies = [
455
+ "serde",
456
+]
457
+
458
+[[package]]
459
+name = "toml_edit"
460
+version = "0.22.27"
461
+source = "registry+https://github.com/rust-lang/crates.io-index"
462
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
463
+dependencies = [
464
+ "indexmap",
465
+ "serde",
466
+ "serde_spanned",
467
+ "toml_datetime",
468
+ "toml_write",
469
+ "winnow",
470
+]
471
+
472
+[[package]]
473
+name = "toml_write"
474
+version = "0.1.2"
475
+source = "registry+https://github.com/rust-lang/crates.io-index"
476
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
477
+
478
+[[package]]
479
+name = "tracing"
480
+version = "0.1.44"
481
+source = "registry+https://github.com/rust-lang/crates.io-index"
482
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
483
+dependencies = [
484
+ "pin-project-lite",
485
+ "tracing-attributes",
486
+ "tracing-core",
487
+]
488
+
489
+[[package]]
490
+name = "tracing-attributes"
491
+version = "0.1.31"
492
+source = "registry+https://github.com/rust-lang/crates.io-index"
493
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
494
+dependencies = [
495
+ "proc-macro2",
496
+ "quote",
497
+ "syn",
498
+]
499
+
500
+[[package]]
501
+name = "tracing-core"
502
+version = "0.1.36"
503
+source = "registry+https://github.com/rust-lang/crates.io-index"
504
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
505
+dependencies = [
506
+ "once_cell",
507
+ "valuable",
508
+]
509
+
510
+[[package]]
511
+name = "tracing-log"
512
+version = "0.2.0"
513
+source = "registry+https://github.com/rust-lang/crates.io-index"
514
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
515
+dependencies = [
516
+ "log",
517
+ "once_cell",
518
+ "tracing-core",
519
+]
520
+
521
+[[package]]
522
+name = "tracing-subscriber"
523
+version = "0.3.22"
524
+source = "registry+https://github.com/rust-lang/crates.io-index"
525
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
526
+dependencies = [
527
+ "matchers",
528
+ "nu-ansi-term",
529
+ "once_cell",
530
+ "regex-automata",
531
+ "sharded-slab",
532
+ "smallvec",
533
+ "thread_local",
534
+ "tracing",
535
+ "tracing-core",
536
+ "tracing-log",
537
+]
538
+
539
+[[package]]
540
+name = "unicode-ident"
541
+version = "1.0.22"
542
+source = "registry+https://github.com/rust-lang/crates.io-index"
543
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
544
+
545
+[[package]]
546
+name = "valuable"
547
+version = "0.1.1"
548
+source = "registry+https://github.com/rust-lang/crates.io-index"
549
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
550
+
551
+[[package]]
552
+name = "wasi"
553
+version = "0.11.1+wasi-snapshot-preview1"
554
+source = "registry+https://github.com/rust-lang/crates.io-index"
555
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
556
+
557
+[[package]]
558
+name = "windows-link"
559
+version = "0.2.1"
560
+source = "registry+https://github.com/rust-lang/crates.io-index"
561
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
562
+
563
+[[package]]
564
+name = "windows-sys"
565
+version = "0.60.2"
566
+source = "registry+https://github.com/rust-lang/crates.io-index"
567
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
568
+dependencies = [
569
+ "windows-targets",
570
+]
571
+
572
+[[package]]
573
+name = "windows-sys"
574
+version = "0.61.2"
575
+source = "registry+https://github.com/rust-lang/crates.io-index"
576
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
577
+dependencies = [
578
+ "windows-link",
579
+]
580
+
581
+[[package]]
582
+name = "windows-targets"
583
+version = "0.53.5"
584
+source = "registry+https://github.com/rust-lang/crates.io-index"
585
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
586
+dependencies = [
587
+ "windows-link",
588
+ "windows_aarch64_gnullvm",
589
+ "windows_aarch64_msvc",
590
+ "windows_i686_gnu",
591
+ "windows_i686_gnullvm",
592
+ "windows_i686_msvc",
593
+ "windows_x86_64_gnu",
594
+ "windows_x86_64_gnullvm",
595
+ "windows_x86_64_msvc",
596
+]
597
+
598
+[[package]]
599
+name = "windows_aarch64_gnullvm"
600
+version = "0.53.1"
601
+source = "registry+https://github.com/rust-lang/crates.io-index"
602
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
603
+
604
+[[package]]
605
+name = "windows_aarch64_msvc"
606
+version = "0.53.1"
607
+source = "registry+https://github.com/rust-lang/crates.io-index"
608
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
609
+
610
+[[package]]
611
+name = "windows_i686_gnu"
612
+version = "0.53.1"
613
+source = "registry+https://github.com/rust-lang/crates.io-index"
614
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
615
+
616
+[[package]]
617
+name = "windows_i686_gnullvm"
618
+version = "0.53.1"
619
+source = "registry+https://github.com/rust-lang/crates.io-index"
620
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
621
+
622
+[[package]]
623
+name = "windows_i686_msvc"
624
+version = "0.53.1"
625
+source = "registry+https://github.com/rust-lang/crates.io-index"
626
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
627
+
628
+[[package]]
629
+name = "windows_x86_64_gnu"
630
+version = "0.53.1"
631
+source = "registry+https://github.com/rust-lang/crates.io-index"
632
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
633
+
634
+[[package]]
635
+name = "windows_x86_64_gnullvm"
636
+version = "0.53.1"
637
+source = "registry+https://github.com/rust-lang/crates.io-index"
638
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
639
+
640
+[[package]]
641
+name = "windows_x86_64_msvc"
642
+version = "0.53.1"
643
+source = "registry+https://github.com/rust-lang/crates.io-index"
644
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
645
+
646
+[[package]]
647
+name = "winnow"
648
+version = "0.7.14"
649
+source = "registry+https://github.com/rust-lang/crates.io-index"
650
+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
651
+dependencies = [
652
+ "memchr",
653
+]
654
+
655
+[[package]]
656
+name = "zmij"
657
+version = "1.0.14"
658
+source = "registry+https://github.com/rust-lang/crates.io-index"
659
+checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea"
Cargo.tomladded
@@ -0,0 +1,41 @@
1
+[workspace]
2
+members = ["gardmd", "gardm-greeter", "gardm-ipc"]
3
+resolver = "2"
4
+
5
+[workspace.package]
6
+version = "0.1.0"
7
+edition = "2021"
8
+license = "MIT"
9
+repository = "https://github.com/mfwolffe/gardesk"
10
+
11
+[workspace.dependencies]
12
+# Serialization
13
+serde = { version = "1.0", features = ["derive"] }
14
+serde_json = "1.0"
15
+toml = "0.8"
16
+
17
+# Async runtime
18
+tokio = { version = "1", features = ["full", "signal"] }
19
+
20
+# Logging
21
+tracing = "0.1"
22
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
23
+
24
+# Error handling
25
+anyhow = "1.0"
26
+thiserror = "1.0"
27
+
28
+# Unix/Linux
29
+nix = { version = "0.27", features = ["user", "process", "signal"] }
30
+
31
+# X11
32
+x11rb = { version = "0.13", features = ["allow-unsafe-code"] }
33
+
34
+# Graphics
35
+cairo-rs = { version = "0.18", features = ["png"] }
36
+pango = "0.18"
37
+pangocairo = "0.18"
38
+image = "0.24"
39
+
40
+# IPC
41
+gardm-ipc = { path = "gardm-ipc" }
docs/architecture.mdadded
@@ -0,0 +1,219 @@
1
+# gardm Architecture
2
+
3
+## Overview
4
+
5
+gardm is the display manager for the gar desktop suite. It follows a **daemon + greeter** architecture similar to [greetd](https://github.com/kennylevinsen/greetd), providing clean separation between authentication/session management and the user interface.
6
+
7
+## Components
8
+
9
+```
10
+┌─────────────────────────────────────────────────────────────┐
11
+│                         gardmd                               │
12
+│                   (Display Manager Daemon)                   │
13
+│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
14
+│  │    PAM      │  │   X11/Xorg  │  │  Session Manager    │  │
15
+│  │ Integration │  │   Control   │  │  (systemd-logind)   │  │
16
+│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
17
+│                          │                                   │
18
+│                    Unix Socket IPC                           │
19
+│                          │                                   │
20
+└──────────────────────────┼──────────────────────────────────┘
21
+                           │
22
+┌──────────────────────────┼──────────────────────────────────┐
23
+│                          ▼                                   │
24
+│                   gardm-greeter                              │
25
+│                   (Graphical UI)                             │
26
+│  ┌─────────────┐  ┌─────────────┐  ┌─────────────────────┐  │
27
+│  │   Cairo/    │  │   garbg     │  │    User Input       │  │
28
+│  │   Pango     │  │ Integration │  │   (login form)      │  │
29
+│  └─────────────┘  └─────────────┘  └─────────────────────┘  │
30
+└─────────────────────────────────────────────────────────────┘
31
+```
32
+
33
+### gardmd (Daemon)
34
+
35
+The privileged daemon that runs as root. Responsibilities:
36
+
37
+1. **X11 Server Management**: Start/stop Xorg server on appropriate VT
38
+2. **PAM Authentication**: Verify user credentials via `/etc/pam.d/gardm`
39
+3. **Session Launching**: Start selected session (gar, other WMs/DEs)
40
+4. **systemd-logind Integration**: Register sessions, handle multi-seat
41
+5. **IPC Server**: Listen on Unix socket for greeter commands
42
+
43
+### gardm-greeter (Frontend)
44
+
45
+The unprivileged graphical interface. Runs as dedicated `gardm` user. Responsibilities:
46
+
47
+1. **User Interface**: Render login form, session selector, power buttons
48
+2. **garbg Integration**: Display same wallpaper that gar session will use
49
+3. **Visual Effects**: Blur background, animations, theming
50
+4. **IPC Client**: Send auth requests to daemon, receive responses
51
+
52
+## IPC Protocol
53
+
54
+JSON-based protocol over Unix socket at `/run/gardm.sock`:
55
+
56
+### Requests (Greeter → Daemon)
57
+
58
+```json
59
+// Create authentication session
60
+{ "type": "create_session", "username": "user" }
61
+
62
+// Attempt authentication
63
+{ "type": "authenticate", "response": "password123" }
64
+
65
+// Start session after successful auth
66
+{ "type": "start_session", "cmd": ["gar-session.sh"], "env": [] }
67
+
68
+// Cancel current auth attempt
69
+{ "type": "cancel_session" }
70
+
71
+// System actions
72
+{ "type": "shutdown" }
73
+{ "type": "reboot" }
74
+{ "type": "suspend" }
75
+```
76
+
77
+### Responses (Daemon → Greeter)
78
+
79
+```json
80
+// Success
81
+{ "type": "success" }
82
+
83
+// Auth prompt (PAM asking for input)
84
+{ "type": "auth_prompt", "prompt": "Password:", "echo": false }
85
+
86
+// Auth info message
87
+{ "type": "auth_info", "message": "..." }
88
+
89
+// Auth error
90
+{ "type": "auth_error", "message": "Authentication failed" }
91
+
92
+// General error
93
+{ "type": "error", "message": "..." }
94
+```
95
+
96
+## Session Discovery
97
+
98
+Sessions are discovered from standard locations:
99
+
100
+- `/usr/share/xsessions/*.desktop` - X11 sessions
101
+- `/usr/share/wayland-sessions/*.desktop` - Wayland sessions (future)
102
+
103
+gar session file example (`/usr/share/xsessions/gar.desktop`):
104
+```ini
105
+[Desktop Entry]
106
+Name=gar
107
+Comment=Tiling window manager with smart splits
108
+Exec=/usr/local/bin/gar-session.sh
109
+Type=XSession
110
+DesktopNames=gar
111
+```
112
+
113
+## User Discovery
114
+
115
+Users are enumerated from:
116
+
117
+- `/etc/passwd` (filter by UID range, shell, home directory existence)
118
+- AccountsService D-Bus interface (if available)
119
+
120
+Hidden users (UID < 1000, nologin shell, system accounts) are filtered out.
121
+
122
+## garbg Integration
123
+
124
+The greeter reads garbg's config to display the same wallpaper:
125
+
126
+1. Read `~/.config/garbg/config.toml` for default wallpaper source
127
+2. Or read playlist state from `$XDG_RUNTIME_DIR/garbg-state.json`
128
+3. Apply blur effect for greeter aesthetic
129
+4. On successful login, gar session starts garbg which shows same image (seamless transition)
130
+
131
+## Security Model
132
+
133
+- **gardmd**: Runs as root, handles PAM, minimal attack surface
134
+- **gardm-greeter**: Runs as unprivileged `gardm` user
135
+- **IPC**: Socket permissions restrict access to gardm user
136
+- **X11**: Greeter runs on isolated X server started by daemon
137
+
138
+## Configuration
139
+
140
+`/etc/gardm/config.toml`:
141
+
142
+```toml
143
+[general]
144
+# Default session if user hasn't selected one
145
+default_session = "gar"
146
+
147
+# Greeter command
148
+greeter = "/usr/bin/gardm-greeter"
149
+
150
+# VT to use (0 = auto-select)
151
+vt = 0
152
+
153
+[greeter]
154
+# Theme settings
155
+blur_radius = 20
156
+blur_brightness = 0.7
157
+
158
+# Show/hide elements
159
+show_power_buttons = true
160
+show_session_selector = true
161
+
162
+# garbg integration
163
+use_garbg_wallpaper = true
164
+fallback_wallpaper = "/usr/share/gardm/backgrounds/default.jpg"
165
+
166
+[security]
167
+# Allow empty passwords
168
+allow_empty_password = false
169
+
170
+# Lock after N failed attempts (0 = disabled)
171
+lockout_attempts = 5
172
+lockout_duration = 300
173
+```
174
+
175
+## PAM Configuration
176
+
177
+`/etc/pam.d/gardm`:
178
+
179
+```
180
+#%PAM-1.0
181
+auth       required     pam_securetty.so
182
+auth       requisite    pam_nologin.so
183
+auth       include      system-local-login
184
+account    include      system-local-login
185
+session    include      system-local-login
186
+password   include      system-local-login
187
+```
188
+
189
+## systemd Integration
190
+
191
+`/usr/lib/systemd/system/gardm.service`:
192
+
193
+```ini
194
+[Unit]
195
+Description=gar Display Manager
196
+After=systemd-user-sessions.service getty@tty1.service plymouth-quit.service
197
+Conflicts=getty@tty1.service
198
+
199
+[Service]
200
+ExecStart=/usr/bin/gardmd
201
+Restart=always
202
+
203
+[Install]
204
+Alias=display-manager.service
205
+```
206
+
207
+## Key Dependencies
208
+
209
+| Component | Crates |
210
+|-----------|--------|
211
+| gardmd | pam, nix, sd-notify, serde, tokio |
212
+| gardm-greeter | x11rb, cairo-rs, pango, image, serde |
213
+
214
+## References
215
+
216
+- [freedesktop.org: Writing Display Managers](https://systemd.io/WRITING_DISPLAY_MANAGERS/)
217
+- [greetd](https://github.com/kennylevinsen/greetd) - Architecture inspiration
218
+- [SDDM](https://github.com/sddm/sddm) - Feature reference
219
+- [LightDM](https://github.com/canonical/lightdm) - Protocol reference
docs/checklist.mdadded
@@ -0,0 +1,157 @@
1
+# gardm Feature Checklist
2
+
3
+## Core Functionality
4
+
5
+### Authentication
6
+- [ ] PAM integration for password authentication
7
+- [ ] Multi-factor auth support (PAM handles this transparently)
8
+- [ ] Account lockout after failed attempts
9
+- [ ] "Remember last user" functionality
10
+- [ ] Guest session support (optional)
11
+
12
+### Session Management
13
+- [ ] Discover X11 sessions from `/usr/share/xsessions/`
14
+- [ ] Parse .desktop files for session metadata
15
+- [ ] Remember user's last session choice
16
+- [ ] Support custom session commands
17
+- [ ] Proper session environment setup (DISPLAY, XDG_*, etc.)
18
+- [ ] systemd-logind session registration
19
+
20
+### X11 Server Management
21
+- [ ] Start Xorg on appropriate VT
22
+- [ ] Handle X server crashes gracefully
23
+- [ ] Support multi-seat (seat0 initially)
24
+- [ ] Pass correct display/VT to sessions
25
+- [ ] Clean X server shutdown on logout
26
+
27
+### Power Management
28
+- [ ] Shutdown button (requires polkit or root)
29
+- [ ] Reboot button
30
+- [ ] Suspend button
31
+- [ ] Hibernate button (if available)
32
+- [ ] Scheduled actions (shutdown in 5 min, etc.) - optional
33
+
34
+## User Interface
35
+
36
+### Login Form
37
+- [ ] Username input (with dropdown of available users)
38
+- [ ] Password input (masked)
39
+- [ ] Session selector dropdown
40
+- [ ] Login button
41
+- [ ] Clear error messages on auth failure
42
+- [ ] Loading/spinner during authentication
43
+
44
+### Visual Design
45
+- [ ] Centered login form
46
+- [ ] Blurred background image
47
+- [ ] Configurable blur radius/brightness
48
+- [ ] Smooth fade-in on greeter start
49
+- [ ] Smooth transition to session (fade-out)
50
+- [ ] Clock display
51
+- [ ] Current date display
52
+
53
+### User List
54
+- [ ] Show available users with avatars
55
+- [ ] Filter system accounts (UID < 1000)
56
+- [ ] Filter accounts with nologin shell
57
+- [ ] Support user icons from AccountsService
58
+- [ ] Keyboard navigation through user list
59
+
60
+### Theming
61
+- [ ] Configurable colors/fonts via config file
62
+- [ ] Support custom background images
63
+- [ ] garbg wallpaper integration
64
+- [ ] Logo/branding support
65
+- [ ] Dark/light theme variants
66
+
67
+### Accessibility
68
+- [ ] Keyboard-only navigation (Tab, Enter, Escape)
69
+- [ ] High contrast mode (optional)
70
+- [ ] Screen reader support (optional, complex)
71
+- [ ] On-screen keyboard (optional, complex)
72
+
73
+## gar Integration
74
+
75
+### garbg Wallpaper Sync
76
+- [ ] Read garbg config for default wallpaper
77
+- [ ] Read garbg playlist state for current wallpaper
78
+- [ ] Apply consistent blur to match gar lock screen
79
+- [ ] Seamless visual transition to gar session
80
+
81
+### Shared Configuration
82
+- [ ] Read gar color scheme for consistent theming
83
+- [ ] Use same fonts as garbar
84
+- [ ] Respect gar's corner radius settings
85
+
86
+## System Integration
87
+
88
+### systemd
89
+- [ ] Proper service file
90
+- [ ] Socket activation (optional)
91
+- [ ] sd_notify for readiness
92
+- [ ] Proper shutdown handling (SIGTERM)
93
+- [ ] Alias as display-manager.service
94
+
95
+### D-Bus
96
+- [ ] systemd-logind integration for session management
97
+- [ ] AccountsService for user info (optional)
98
+- [ ] polkit for power actions (optional)
99
+
100
+### Files and Paths
101
+- [ ] Config file: `/etc/gardm/config.toml`
102
+- [ ] PAM config: `/etc/pam.d/gardm`
103
+- [ ] Socket: `/run/gardm.sock`
104
+- [ ] PID file: `/run/gardm.pid`
105
+- [ ] Log file: via journald
106
+- [ ] State dir: `/var/lib/gardm/`
107
+
108
+## Security
109
+
110
+### Hardening
111
+- [ ] Greeter runs as unprivileged user
112
+- [ ] Minimal daemon attack surface
113
+- [ ] Socket permissions (0600, gardm:gardm)
114
+- [ ] No credentials in logs
115
+- [ ] Secure memory handling for passwords
116
+
117
+### Input Validation
118
+- [ ] Sanitize username input
119
+- [ ] Prevent path traversal in session selection
120
+- [ ] Rate limiting on auth attempts
121
+- [ ] Timeout for idle greeter
122
+
123
+## Error Handling
124
+
125
+- [ ] X server fail to start
126
+- [ ] PAM errors (account locked, expired, etc.)
127
+- [ ] Session fail to start
128
+- [ ] IPC communication errors
129
+- [ ] Missing configuration graceful fallback
130
+- [ ] Greeter crash recovery (restart greeter)
131
+
132
+## Testing
133
+
134
+- [ ] Unit tests for IPC protocol
135
+- [ ] Unit tests for session discovery
136
+- [ ] Integration tests with mock PAM
137
+- [ ] Manual testing checklist
138
+- [ ] Xephyr-based testing for greeter UI
139
+
140
+## Documentation
141
+
142
+- [ ] README with quick start
143
+- [ ] Configuration reference
144
+- [ ] Theming guide
145
+- [ ] Troubleshooting guide
146
+- [ ] Man pages (gardmd.8, gardm-greeter.1)
147
+
148
+## Nice-to-Have (Future)
149
+
150
+- [ ] Wayland greeter support
151
+- [ ] Network login (LDAP, etc.)
152
+- [ ] Fingerprint authentication
153
+- [ ] Auto-login configuration
154
+- [ ] Remote desktop support
155
+- [ ] Multi-monitor greeter
156
+- [ ] Animated backgrounds
157
+- [ ] Weather/calendar widgets
docs/gardm.mdadded
@@ -0,0 +1,14 @@
1
+# GARDM
2
+
3
+the next step in the gar desktop experience is the gar desktop manager. it should be oriented towards integration with other gar products. 
4
+it should set the same background as will be set by garbg on launching gar so that the only thing that changes during the transition is the animation to load in
5
+
6
+it should have users centered, and all the standard desktop manger buttons.
7
+
8
+it should have a sleek interface like my current sddm approach. background should be blurred. username/pass should be  centered. 
9
+
10
+first stage is planning. we need to reference existing foss greeters/desktop managers to see what ours needs to do, otherwise we're lost. 
11
+We'll make a checklist of targets to hit and generate sprint files in the .gitignored directory docs/sprints/. each sprint file should have a list of targets and pitfalls to avoid
12
+
13
+gardm should do everything that a modern greeter does, and look pretty while doing it. it should be gar-first, that is oriented towards the gar desktop environment and window manager, but it should be compatible with all linux machines that support x11. 
14
+we're going with x11 because of nvidia issues on wayland, but we can still make a pretty greeter in xorgland.
docs/sprints/sprint-0-setup.mdadded
@@ -0,0 +1,246 @@
1
+# Sprint 0: Project Setup
2
+
3
+**Goal:** Establish project structure, build system, and minimal daemon that can start and stop cleanly.
4
+
5
+## Objectives
6
+
7
+- Initialize Rust workspace with two crates (gardmd, gardm-greeter)
8
+- Set up shared library for IPC protocol
9
+- Create systemd service file
10
+- Establish logging infrastructure
11
+- Basic daemon lifecycle (start, signal handling, shutdown)
12
+
13
+## Tasks
14
+
15
+### 0.1 Initialize Cargo Workspace
16
+
17
+```
18
+gardm/
19
+├── Cargo.toml              # Workspace root
20
+├── gardmd/
21
+│   ├── Cargo.toml
22
+│   └── src/
23
+│       ├── main.rs         # Daemon entry point
24
+│       └── lib.rs
25
+├── gardm-greeter/
26
+│   ├── Cargo.toml
27
+│   └── src/
28
+│       ├── main.rs         # Greeter entry point
29
+│       └── lib.rs
30
+└── gardm-ipc/
31
+    ├── Cargo.toml
32
+    └── src/
33
+        └── lib.rs          # Shared IPC types
34
+```
35
+
36
+**Root Cargo.toml:**
37
+```toml
38
+[workspace]
39
+members = ["gardmd", "gardm-greeter", "gardm-ipc"]
40
+resolver = "2"
41
+
42
+[workspace.dependencies]
43
+serde = { version = "1.0", features = ["derive"] }
44
+serde_json = "1.0"
45
+tokio = { version = "1", features = ["full"] }
46
+tracing = "0.1"
47
+tracing-subscriber = "0.3"
48
+anyhow = "1.0"
49
+thiserror = "1.0"
50
+```
51
+
52
+### 0.2 Define IPC Protocol Types
53
+
54
+In `gardm-ipc/src/lib.rs`:
55
+
56
+```rust
57
+use serde::{Deserialize, Serialize};
58
+
59
+#[derive(Debug, Serialize, Deserialize)]
60
+#[serde(tag = "type", rename_all = "snake_case")]
61
+pub enum Request {
62
+    CreateSession { username: String },
63
+    Authenticate { response: String },
64
+    StartSession { cmd: Vec<String>, env: Vec<String> },
65
+    CancelSession,
66
+    Shutdown,
67
+    Reboot,
68
+    Suspend,
69
+}
70
+
71
+#[derive(Debug, Serialize, Deserialize)]
72
+#[serde(tag = "type", rename_all = "snake_case")]
73
+pub enum Response {
74
+    Success,
75
+    AuthPrompt { prompt: String, echo: bool },
76
+    AuthInfo { message: String },
77
+    AuthError { message: String },
78
+    Error { message: String },
79
+}
80
+```
81
+
82
+### 0.3 Basic Daemon Skeleton
83
+
84
+In `gardmd/src/main.rs`:
85
+
86
+```rust
87
+use tokio::signal::unix::{signal, SignalKind};
88
+use tracing_subscriber::{fmt, prelude::*, EnvFilter};
89
+
90
+#[tokio::main]
91
+async fn main() -> anyhow::Result<()> {
92
+    // Initialize logging to journald
93
+    tracing_subscriber::registry()
94
+        .with(fmt::layer())
95
+        .with(EnvFilter::from_default_env())
96
+        .init();
97
+
98
+    tracing::info!("gardmd starting");
99
+
100
+    // Signal handling
101
+    let mut sigterm = signal(SignalKind::terminate())?;
102
+    let mut sigint = signal(SignalKind::interrupt())?;
103
+
104
+    tokio::select! {
105
+        _ = sigterm.recv() => {
106
+            tracing::info!("Received SIGTERM, shutting down");
107
+        }
108
+        _ = sigint.recv() => {
109
+            tracing::info!("Received SIGINT, shutting down");
110
+        }
111
+    }
112
+
113
+    tracing::info!("gardmd stopped");
114
+    Ok(())
115
+}
116
+```
117
+
118
+### 0.4 systemd Service File
119
+
120
+Create `gardm.service`:
121
+
122
+```ini
123
+[Unit]
124
+Description=gar Display Manager
125
+Documentation=man:gardmd(8)
126
+After=systemd-user-sessions.service getty@tty1.service plymouth-quit.service
127
+Conflicts=getty@tty1.service
128
+
129
+[Service]
130
+Type=notify
131
+ExecStart=/usr/bin/gardmd
132
+Restart=always
133
+RestartSec=1
134
+
135
+# Security hardening
136
+ProtectSystem=strict
137
+ProtectHome=read-only
138
+PrivateTmp=true
139
+NoNewPrivileges=false  # Required for PAM
140
+ReadWritePaths=/run
141
+
142
+[Install]
143
+Alias=display-manager.service
144
+```
145
+
146
+### 0.5 Configuration Scaffolding
147
+
148
+Create `gardmd/src/config.rs`:
149
+
150
+```rust
151
+use serde::Deserialize;
152
+use std::path::PathBuf;
153
+
154
+#[derive(Debug, Deserialize)]
155
+pub struct Config {
156
+    #[serde(default)]
157
+    pub general: GeneralConfig,
158
+    #[serde(default)]
159
+    pub greeter: GreeterConfig,
160
+}
161
+
162
+#[derive(Debug, Deserialize, Default)]
163
+pub struct GeneralConfig {
164
+    #[serde(default = "default_session")]
165
+    pub default_session: String,
166
+    #[serde(default = "default_greeter")]
167
+    pub greeter: PathBuf,
168
+    #[serde(default)]
169
+    pub vt: u32,
170
+}
171
+
172
+#[derive(Debug, Deserialize, Default)]
173
+pub struct GreeterConfig {
174
+    #[serde(default = "default_blur_radius")]
175
+    pub blur_radius: u32,
176
+}
177
+
178
+fn default_session() -> String { "gar".to_string() }
179
+fn default_greeter() -> PathBuf { "/usr/bin/gardm-greeter".into() }
180
+fn default_blur_radius() -> u32 { 20 }
181
+
182
+impl Config {
183
+    pub fn load() -> anyhow::Result<Self> {
184
+        let path = PathBuf::from("/etc/gardm/config.toml");
185
+        if path.exists() {
186
+            let content = std::fs::read_to_string(&path)?;
187
+            Ok(toml::from_str(&content)?)
188
+        } else {
189
+            Ok(Config::default())
190
+        }
191
+    }
192
+}
193
+```
194
+
195
+## Acceptance Criteria
196
+
197
+1. `cargo build --release` succeeds for all workspace members
198
+2. `gardmd` starts, logs to stdout, and exits cleanly on SIGTERM
199
+3. IPC types serialize/deserialize correctly (unit test)
200
+4. Config loads from file or uses defaults
201
+5. Service file passes `systemd-analyze verify`
202
+
203
+## Pitfalls to Avoid
204
+
205
+1. **Don't start X11 yet** - that's Sprint 2. Keep this sprint minimal.
206
+2. **Don't implement PAM yet** - that's Sprint 1. Focus on structure.
207
+3. **Avoid premature optimization** - get it working first.
208
+4. **Don't forget `sd_notify`** - systemd needs to know when daemon is ready.
209
+5. **Test without root first** - use `RUST_LOG=debug cargo run` for development.
210
+
211
+## Testing
212
+
213
+```bash
214
+# Build
215
+cargo build --release
216
+
217
+# Run daemon in foreground (Ctrl+C to stop)
218
+RUST_LOG=debug ./target/release/gardmd
219
+
220
+# Verify signal handling
221
+kill -TERM $(pidof gardmd)
222
+
223
+# Test config loading
224
+mkdir -p /tmp/gardm-test
225
+echo '[general]' > /tmp/gardm-test/config.toml
226
+echo 'default_session = "test"' >> /tmp/gardm-test/config.toml
227
+```
228
+
229
+## Dependencies for This Sprint
230
+
231
+```toml
232
+# gardmd/Cargo.toml
233
+[dependencies]
234
+gardm-ipc = { path = "../gardm-ipc" }
235
+tokio = { workspace = true }
236
+tracing = { workspace = true }
237
+tracing-subscriber = { workspace = true }
238
+anyhow = { workspace = true }
239
+serde = { workspace = true }
240
+toml = "0.8"
241
+sd-notify = "0.4"
242
+```
243
+
244
+## Next Sprint
245
+
246
+Sprint 1 will add PAM authentication to the daemon.
docs/sprints/sprint-1-pam.mdadded
@@ -0,0 +1,356 @@
1
+# Sprint 1: PAM Authentication
2
+
3
+**Goal:** Implement PAM-based user authentication in the daemon with proper conversation handling.
4
+
5
+## Objectives
6
+
7
+- Integrate with PAM for user authentication
8
+- Handle PAM conversation (prompts, messages)
9
+- Create IPC server for greeter communication
10
+- Implement session state machine
11
+- Test authentication flow end-to-end
12
+
13
+## Background: How PAM Works
14
+
15
+PAM (Pluggable Authentication Modules) uses a "conversation" model:
16
+
17
+1. Application calls `pam_authenticate()`
18
+2. PAM modules may request information via conversation callback
19
+3. Callback returns user input (password, OTP, etc.)
20
+4. PAM returns success/failure
21
+
22
+For a display manager, we need to bridge this conversation to the greeter UI:
23
+
24
+```
25
+Greeter ←→ IPC ←→ Daemon ←→ PAM ←→ System
26
+```
27
+
28
+## Tasks
29
+
30
+### 1.1 Add PAM Dependency
31
+
32
+The `pam` crate provides safe Rust bindings:
33
+
34
+```toml
35
+# gardmd/Cargo.toml
36
+[dependencies]
37
+pam = "0.8"
38
+```
39
+
40
+### 1.2 Create PAM Configuration
41
+
42
+`/etc/pam.d/gardm`:
43
+
44
+```
45
+#%PAM-1.0
46
+auth       required     pam_securetty.so
47
+auth       requisite    pam_nologin.so
48
+auth       include      system-local-login
49
+account    include      system-local-login
50
+session    include      system-local-login
51
+password   include      system-local-login
52
+```
53
+
54
+### 1.3 Implement Authentication State Machine
55
+
56
+```rust
57
+// gardmd/src/auth.rs
58
+
59
+use pam::Authenticator;
60
+use std::ffi::CString;
61
+
62
+pub enum AuthState {
63
+    /// No active authentication
64
+    Idle,
65
+    /// Waiting for username
66
+    AwaitingUsername,
67
+    /// PAM conversation in progress
68
+    Authenticating {
69
+        username: String,
70
+        authenticator: Authenticator<'static, PasswordConv>,
71
+    },
72
+    /// Authentication succeeded, ready to start session
73
+    Authenticated {
74
+        username: String,
75
+    },
76
+}
77
+
78
+pub struct AuthSession {
79
+    state: AuthState,
80
+}
81
+
82
+impl AuthSession {
83
+    pub fn new() -> Self {
84
+        Self { state: AuthState::Idle }
85
+    }
86
+
87
+    pub fn create_session(&mut self, username: &str) -> Result<AuthResponse> {
88
+        let service = CString::new("gardm")?;
89
+        let username_c = CString::new(username)?;
90
+
91
+        // Create PAM authenticator
92
+        let mut auth = Authenticator::with_password(&service)?;
93
+        auth.get_handler().set_credentials(username_c, /* password later */);
94
+
95
+        self.state = AuthState::Authenticating {
96
+            username: username.to_string(),
97
+            authenticator: auth,
98
+        };
99
+
100
+        // PAM will ask for password
101
+        Ok(AuthResponse::Prompt {
102
+            prompt: "Password:".to_string(),
103
+            echo: false,
104
+        })
105
+    }
106
+
107
+    pub fn authenticate(&mut self, response: &str) -> Result<AuthResponse> {
108
+        match &mut self.state {
109
+            AuthState::Authenticating { username, authenticator } => {
110
+                // Provide password to PAM
111
+                authenticator.get_handler().set_credentials(
112
+                    CString::new(username.as_str())?,
113
+                    CString::new(response)?,
114
+                );
115
+
116
+                // Attempt authentication
117
+                match authenticator.authenticate() {
118
+                    Ok(()) => {
119
+                        let username = username.clone();
120
+                        self.state = AuthState::Authenticated { username };
121
+                        Ok(AuthResponse::Success)
122
+                    }
123
+                    Err(e) => {
124
+                        self.state = AuthState::Idle;
125
+                        Ok(AuthResponse::Error {
126
+                            message: format!("Authentication failed: {}", e),
127
+                        })
128
+                    }
129
+                }
130
+            }
131
+            _ => Ok(AuthResponse::Error {
132
+                message: "No authentication in progress".to_string(),
133
+            }),
134
+        }
135
+    }
136
+
137
+    pub fn cancel(&mut self) {
138
+        self.state = AuthState::Idle;
139
+    }
140
+}
141
+```
142
+
143
+### 1.4 Implement IPC Server
144
+
145
+```rust
146
+// gardmd/src/ipc.rs
147
+
148
+use gardm_ipc::{Request, Response};
149
+use tokio::net::{UnixListener, UnixStream};
150
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
151
+
152
+pub struct IpcServer {
153
+    listener: UnixListener,
154
+}
155
+
156
+impl IpcServer {
157
+    pub async fn new(path: &str) -> anyhow::Result<Self> {
158
+        // Remove stale socket
159
+        let _ = std::fs::remove_file(path);
160
+
161
+        let listener = UnixListener::bind(path)?;
162
+
163
+        // Set permissions (only gardm user can connect)
164
+        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
165
+
166
+        Ok(Self { listener })
167
+    }
168
+
169
+    pub async fn accept(&self) -> anyhow::Result<IpcClient> {
170
+        let (stream, _) = self.listener.accept().await?;
171
+        Ok(IpcClient::new(stream))
172
+    }
173
+}
174
+
175
+pub struct IpcClient {
176
+    reader: BufReader<tokio::net::unix::OwnedReadHalf>,
177
+    writer: tokio::net::unix::OwnedWriteHalf,
178
+}
179
+
180
+impl IpcClient {
181
+    pub fn new(stream: UnixStream) -> Self {
182
+        let (read, write) = stream.into_split();
183
+        Self {
184
+            reader: BufReader::new(read),
185
+            writer: write,
186
+        }
187
+    }
188
+
189
+    pub async fn read_request(&mut self) -> anyhow::Result<Option<Request>> {
190
+        let mut line = String::new();
191
+        let n = self.reader.read_line(&mut line).await?;
192
+        if n == 0 {
193
+            return Ok(None);
194
+        }
195
+        Ok(Some(serde_json::from_str(&line)?))
196
+    }
197
+
198
+    pub async fn send_response(&mut self, response: &Response) -> anyhow::Result<()> {
199
+        let json = serde_json::to_string(response)?;
200
+        self.writer.write_all(json.as_bytes()).await?;
201
+        self.writer.write_all(b"\n").await?;
202
+        self.writer.flush().await?;
203
+        Ok(())
204
+    }
205
+}
206
+```
207
+
208
+### 1.5 Wire Up Main Loop
209
+
210
+```rust
211
+// gardmd/src/main.rs
212
+
213
+async fn run() -> anyhow::Result<()> {
214
+    let config = Config::load()?;
215
+    let ipc = IpcServer::new("/run/gardm.sock").await?;
216
+    let mut auth = AuthSession::new();
217
+
218
+    // Notify systemd we're ready
219
+    sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?;
220
+
221
+    tracing::info!("gardmd ready, listening for connections");
222
+
223
+    loop {
224
+        let mut client = ipc.accept().await?;
225
+
226
+        // Handle single client (greeter)
227
+        while let Some(request) = client.read_request().await? {
228
+            let response = match request {
229
+                Request::CreateSession { username } => {
230
+                    auth.create_session(&username)?
231
+                }
232
+                Request::Authenticate { response } => {
233
+                    auth.authenticate(&response)?
234
+                }
235
+                Request::CancelSession => {
236
+                    auth.cancel();
237
+                    Response::Success
238
+                }
239
+                // Power actions handled in later sprint
240
+                _ => Response::Error {
241
+                    message: "Not implemented".to_string(),
242
+                },
243
+            };
244
+
245
+            client.send_response(&response).await?;
246
+        }
247
+    }
248
+}
249
+```
250
+
251
+### 1.6 Create Test Client
252
+
253
+For testing without the full greeter:
254
+
255
+```rust
256
+// gardm-greeter/src/bin/test-auth.rs
257
+
258
+use gardm_ipc::{Request, Response};
259
+use std::io::{self, BufRead, Write};
260
+use std::os::unix::net::UnixStream;
261
+
262
+fn main() -> anyhow::Result<()> {
263
+    let mut stream = UnixStream::connect("/run/gardm.sock")?;
264
+
265
+    // Get username
266
+    print!("Username: ");
267
+    io::stdout().flush()?;
268
+    let mut username = String::new();
269
+    io::stdin().lock().read_line(&mut username)?;
270
+
271
+    // Send create_session
272
+    let req = Request::CreateSession {
273
+        username: username.trim().to_string(),
274
+    };
275
+    writeln!(stream, "{}", serde_json::to_string(&req)?)?;
276
+
277
+    // Read response
278
+    let mut response = String::new();
279
+    let mut reader = io::BufReader::new(&stream);
280
+    reader.read_line(&mut response)?;
281
+    println!("Response: {}", response);
282
+
283
+    // Get password
284
+    print!("Password: ");
285
+    io::stdout().flush()?;
286
+    let password = rpassword::read_password()?;
287
+
288
+    // Send authenticate
289
+    let req = Request::Authenticate { response: password };
290
+    writeln!(stream, "{}", serde_json::to_string(&req)?)?;
291
+
292
+    // Read response
293
+    response.clear();
294
+    reader.read_line(&mut response)?;
295
+    println!("Response: {}", response);
296
+
297
+    Ok(())
298
+}
299
+```
300
+
301
+## Acceptance Criteria
302
+
303
+1. Daemon accepts IPC connections from greeter
304
+2. `CreateSession` initiates PAM conversation
305
+3. `Authenticate` with correct password returns `Success`
306
+4. `Authenticate` with wrong password returns `AuthError`
307
+5. `CancelSession` resets state cleanly
308
+6. Multiple auth attempts work without daemon restart
309
+7. PAM config file is properly loaded
310
+
311
+## Pitfalls to Avoid
312
+
313
+1. **PAM is synchronous** - don't block the async runtime. Use `spawn_blocking` for PAM calls.
314
+2. **Memory safety with passwords** - zero memory after use (pam crate should handle this).
315
+3. **Don't store passwords** - only pass through to PAM immediately.
316
+4. **Handle PAM errors gracefully** - account locked, expired password, etc. have specific error types.
317
+5. **Test with real PAM** - mock tests are useful but test against real system too.
318
+6. **Remember PAM is stateful** - can't call authenticate twice on same session.
319
+
320
+## Testing
321
+
322
+```bash
323
+# Build and run daemon as root (required for PAM)
324
+sudo RUST_LOG=debug ./target/release/gardmd &
325
+
326
+# Test with CLI client
327
+./target/release/test-auth
328
+
329
+# Test wrong password
330
+# Test account lockout (if configured)
331
+# Test non-existent user
332
+```
333
+
334
+## Security Considerations
335
+
336
+- Daemon must run as root for PAM access
337
+- IPC socket must be protected (0600 permissions)
338
+- Failed auth attempts should be rate-limited (PAM may do this)
339
+- Log auth attempts but NOT passwords
340
+
341
+## Dependencies for This Sprint
342
+
343
+```toml
344
+# gardmd/Cargo.toml
345
+[dependencies]
346
+pam = "0.8"
347
+nix = { version = "0.27", features = ["user"] }
348
+
349
+# gardm-greeter/Cargo.toml (for test client)
350
+[dependencies]
351
+rpassword = "7.0"
352
+```
353
+
354
+## Next Sprint
355
+
356
+Sprint 2 will add X11 server management - starting Xorg and the greeter.
docs/sprints/sprint-2-x11.mdadded
@@ -0,0 +1,487 @@
1
+# Sprint 2: X11 Server Management
2
+
3
+**Goal:** Start and manage Xorg server, launch greeter process, and handle session startup.
4
+
5
+## Objectives
6
+
7
+- Start Xorg on appropriate VT with correct permissions
8
+- Launch greeter as unprivileged user
9
+- Start user session after successful auth
10
+- Handle X server lifecycle (crash recovery, clean shutdown)
11
+- Integrate with systemd-logind for session registration
12
+
13
+## Background: X11 Display Manager Flow
14
+
15
+```
16
+1. gardmd starts
17
+2. gardmd spawns Xorg on VT (e.g., :0 on vt1)
18
+3. gardmd waits for X to be ready (connect test)
19
+4. gardmd spawns greeter as unprivileged user
20
+5. Greeter authenticates user via IPC
21
+6. gardmd kills greeter, starts user session
22
+7. User session runs until logout
23
+8. gardmd restarts greeter (loop back to 4)
24
+```
25
+
26
+## Tasks
27
+
28
+### 2.1 X Server Launcher
29
+
30
+```rust
31
+// gardmd/src/x11.rs
32
+
33
+use nix::unistd::{fork, ForkResult, setsid, Pid};
34
+use std::process::{Command, Child};
35
+use std::time::Duration;
36
+
37
+pub struct XServer {
38
+    process: Child,
39
+    display: String,
40
+    vt: u32,
41
+}
42
+
43
+impl XServer {
44
+    /// Start Xorg server on specified display and VT
45
+    pub fn start(display: &str, vt: u32) -> anyhow::Result<Self> {
46
+        // Xorg arguments
47
+        let mut cmd = Command::new("/usr/bin/Xorg");
48
+        cmd.arg(display)
49
+           .arg(&format!("vt{}", vt))
50
+           .arg("-keeptty")
51
+           .arg("-noreset")
52
+           .arg("-novtswitch")
53
+           .arg("-nolisten").arg("tcp");
54
+
55
+        // For multi-seat support (future):
56
+        // cmd.arg("-seat").arg("seat0");
57
+
58
+        // Capture output for debugging
59
+        cmd.stdout(std::process::Stdio::piped())
60
+           .stderr(std::process::Stdio::piped());
61
+
62
+        let process = cmd.spawn()?;
63
+
64
+        tracing::info!("Started Xorg on {} (vt{}, pid={})",
65
+            display, vt, process.id());
66
+
67
+        let server = Self {
68
+            process,
69
+            display: display.to_string(),
70
+            vt,
71
+        };
72
+
73
+        // Wait for X to be ready
74
+        server.wait_ready(Duration::from_secs(10))?;
75
+
76
+        Ok(server)
77
+    }
78
+
79
+    /// Wait for X server to be ready by attempting connection
80
+    fn wait_ready(&self, timeout: Duration) -> anyhow::Result<()> {
81
+        use std::time::Instant;
82
+
83
+        let start = Instant::now();
84
+        while start.elapsed() < timeout {
85
+            // Try to connect to X server
86
+            match x11rb::connect(Some(&self.display)) {
87
+                Ok(_) => {
88
+                    tracing::debug!("X server ready on {}", self.display);
89
+                    return Ok(());
90
+                }
91
+                Err(_) => {
92
+                    std::thread::sleep(Duration::from_millis(100));
93
+                }
94
+            }
95
+        }
96
+
97
+        anyhow::bail!("X server failed to become ready within {:?}", timeout);
98
+    }
99
+
100
+    /// Get the display string (e.g., ":0")
101
+    pub fn display(&self) -> &str {
102
+        &self.display
103
+    }
104
+
105
+    /// Get the VT number
106
+    pub fn vt(&self) -> u32 {
107
+        self.vt
108
+    }
109
+
110
+    /// Check if X server is still running
111
+    pub fn is_running(&mut self) -> bool {
112
+        match self.process.try_wait() {
113
+            Ok(None) => true,
114
+            _ => false,
115
+        }
116
+    }
117
+
118
+    /// Stop the X server
119
+    pub fn stop(&mut self) -> anyhow::Result<()> {
120
+        tracing::info!("Stopping X server");
121
+        self.process.kill()?;
122
+        self.process.wait()?;
123
+        Ok(())
124
+    }
125
+}
126
+
127
+impl Drop for XServer {
128
+    fn drop(&mut self) {
129
+        let _ = self.stop();
130
+    }
131
+}
132
+```
133
+
134
+### 2.2 VT Allocation
135
+
136
+```rust
137
+// gardmd/src/vt.rs
138
+
139
+use std::fs::OpenOptions;
140
+use std::os::unix::io::AsRawFd;
141
+use nix::ioctl_read_bad;
142
+
143
+// ioctl for getting/setting VT
144
+const VT_GETSTATE: u64 = 0x5603;
145
+const VT_ACTIVATE: u64 = 0x5606;
146
+const VT_WAITACTIVE: u64 = 0x5607;
147
+const VT_OPENQRY: u64 = 0x5600;
148
+
149
+#[repr(C)]
150
+pub struct VtStat {
151
+    pub v_active: u16,
152
+    pub v_signal: u16,
153
+    pub v_state: u16,
154
+}
155
+
156
+/// Find an unused VT
157
+pub fn find_unused_vt() -> anyhow::Result<u32> {
158
+    let console = OpenOptions::new()
159
+        .read(true)
160
+        .write(true)
161
+        .open("/dev/tty0")?;
162
+
163
+    let mut vt: i32 = 0;
164
+    unsafe {
165
+        if libc::ioctl(console.as_raw_fd(), VT_OPENQRY as _, &mut vt) < 0 {
166
+            anyhow::bail!("Failed to find unused VT");
167
+        }
168
+    }
169
+
170
+    if vt <= 0 {
171
+        anyhow::bail!("No unused VT available");
172
+    }
173
+
174
+    Ok(vt as u32)
175
+}
176
+
177
+/// Switch to a specific VT
178
+pub fn switch_to_vt(vt: u32) -> anyhow::Result<()> {
179
+    let console = OpenOptions::new()
180
+        .read(true)
181
+        .write(true)
182
+        .open("/dev/tty0")?;
183
+
184
+    unsafe {
185
+        if libc::ioctl(console.as_raw_fd(), VT_ACTIVATE as _, vt) < 0 {
186
+            anyhow::bail!("Failed to activate VT {}", vt);
187
+        }
188
+        if libc::ioctl(console.as_raw_fd(), VT_WAITACTIVE as _, vt) < 0 {
189
+            anyhow::bail!("Failed to wait for VT {}", vt);
190
+        }
191
+    }
192
+
193
+    Ok(())
194
+}
195
+```
196
+
197
+### 2.3 Greeter Process Manager
198
+
199
+```rust
200
+// gardmd/src/greeter.rs
201
+
202
+use nix::unistd::{Uid, Gid, setuid, setgid, User};
203
+use std::process::{Command, Child};
204
+
205
+pub struct GreeterProcess {
206
+    process: Child,
207
+}
208
+
209
+impl GreeterProcess {
210
+    /// Start the greeter as the gardm user
211
+    pub fn start(greeter_cmd: &str, display: &str) -> anyhow::Result<Self> {
212
+        // Get gardm user info
213
+        let user = User::from_name("gardm")?
214
+            .ok_or_else(|| anyhow::anyhow!("gardm user not found"))?;
215
+
216
+        let mut cmd = Command::new(greeter_cmd);
217
+        cmd.env("DISPLAY", display)
218
+           .env("XDG_SESSION_CLASS", "greeter")
219
+           .env("XDG_SESSION_TYPE", "x11");
220
+
221
+        // Run as gardm user
222
+        unsafe {
223
+            cmd.pre_exec(move || {
224
+                setgid(Gid::from_raw(user.gid.as_raw()))?;
225
+                setuid(Uid::from_raw(user.uid.as_raw()))?;
226
+                Ok(())
227
+            });
228
+        }
229
+
230
+        let process = cmd.spawn()?;
231
+        tracing::info!("Started greeter (pid={})", process.id());
232
+
233
+        Ok(Self { process })
234
+    }
235
+
236
+    /// Check if greeter is still running
237
+    pub fn is_running(&mut self) -> bool {
238
+        matches!(self.process.try_wait(), Ok(None))
239
+    }
240
+
241
+    /// Wait for greeter to exit
242
+    pub fn wait(&mut self) -> anyhow::Result<std::process::ExitStatus> {
243
+        Ok(self.process.wait()?)
244
+    }
245
+
246
+    /// Kill the greeter
247
+    pub fn kill(&mut self) -> anyhow::Result<()> {
248
+        self.process.kill()?;
249
+        self.process.wait()?;
250
+        Ok(())
251
+    }
252
+}
253
+```
254
+
255
+### 2.4 Session Launcher
256
+
257
+```rust
258
+// gardmd/src/session.rs
259
+
260
+use nix::unistd::{Uid, Gid, setuid, setgid, User, initgroups, chdir};
261
+use std::process::{Command, Child};
262
+use std::ffi::CString;
263
+
264
+pub struct UserSession {
265
+    process: Child,
266
+    username: String,
267
+}
268
+
269
+impl UserSession {
270
+    /// Start a user session
271
+    pub fn start(
272
+        username: &str,
273
+        session_cmd: &[String],
274
+        display: &str,
275
+        vt: u32,
276
+    ) -> anyhow::Result<Self> {
277
+        let user = User::from_name(username)?
278
+            .ok_or_else(|| anyhow::anyhow!("User {} not found", username))?;
279
+
280
+        let home = user.dir.to_string_lossy().to_string();
281
+        let shell = user.shell.to_string_lossy().to_string();
282
+        let uid = user.uid;
283
+        let gid = user.gid;
284
+        let username_c = CString::new(username)?;
285
+
286
+        // Build session command
287
+        let (cmd_path, cmd_args) = if session_cmd.is_empty() {
288
+            // Default to gar-session.sh
289
+            ("/usr/local/bin/gar-session.sh".to_string(), vec![])
290
+        } else {
291
+            (session_cmd[0].clone(), session_cmd[1..].to_vec())
292
+        };
293
+
294
+        let mut cmd = Command::new(&cmd_path);
295
+        cmd.args(&cmd_args)
296
+           .env("DISPLAY", display)
297
+           .env("HOME", &home)
298
+           .env("USER", username)
299
+           .env("LOGNAME", username)
300
+           .env("SHELL", &shell)
301
+           .env("XDG_SESSION_TYPE", "x11")
302
+           .env("XDG_VTNR", vt.to_string())
303
+           .env("XDG_SEAT", "seat0");
304
+
305
+        // Run as the user
306
+        unsafe {
307
+            cmd.pre_exec(move || {
308
+                // Set groups
309
+                initgroups(&username_c, gid)?;
310
+                setgid(gid)?;
311
+                setuid(uid)?;
312
+
313
+                // Change to home directory
314
+                let home_c = CString::new(home.as_str())?;
315
+                chdir(&home_c)?;
316
+
317
+                Ok(())
318
+            });
319
+        }
320
+
321
+        let process = cmd.spawn()?;
322
+        tracing::info!("Started session for {} (pid={})", username, process.id());
323
+
324
+        Ok(Self {
325
+            process,
326
+            username: username.to_string(),
327
+        })
328
+    }
329
+
330
+    /// Wait for session to end
331
+    pub fn wait(&mut self) -> anyhow::Result<std::process::ExitStatus> {
332
+        Ok(self.process.wait()?)
333
+    }
334
+}
335
+```
336
+
337
+### 2.5 Main Loop Integration
338
+
339
+```rust
340
+// gardmd/src/main.rs
341
+
342
+async fn run() -> anyhow::Result<()> {
343
+    let config = Config::load()?;
344
+
345
+    // Find VT to use
346
+    let vt = if config.general.vt > 0 {
347
+        config.general.vt
348
+    } else {
349
+        vt::find_unused_vt()?
350
+    };
351
+
352
+    // Start X server
353
+    let display = ":0";
354
+    let mut x_server = XServer::start(display, vt)?;
355
+
356
+    // Switch to our VT
357
+    vt::switch_to_vt(vt)?;
358
+
359
+    // Start IPC server
360
+    let ipc = IpcServer::new("/run/gardm.sock").await?;
361
+    let mut auth = AuthSession::new();
362
+
363
+    sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?;
364
+
365
+    loop {
366
+        // Start greeter
367
+        let mut greeter = GreeterProcess::start(&config.general.greeter, display)?;
368
+
369
+        // Handle greeter authentication
370
+        let session_info = handle_greeter_auth(&ipc, &mut auth).await?;
371
+
372
+        // Kill greeter before starting session
373
+        greeter.kill()?;
374
+
375
+        // Start user session
376
+        let mut session = UserSession::start(
377
+            &session_info.username,
378
+            &session_info.cmd,
379
+            display,
380
+            vt,
381
+        )?;
382
+
383
+        // Wait for session to end
384
+        let status = session.wait()?;
385
+        tracing::info!("Session ended with status: {:?}", status);
386
+
387
+        // Loop back to greeter
388
+    }
389
+}
390
+
391
+struct SessionInfo {
392
+    username: String,
393
+    cmd: Vec<String>,
394
+}
395
+
396
+async fn handle_greeter_auth(
397
+    ipc: &IpcServer,
398
+    auth: &mut AuthSession,
399
+) -> anyhow::Result<SessionInfo> {
400
+    let mut client = ipc.accept().await?;
401
+
402
+    loop {
403
+        let request = client.read_request().await?
404
+            .ok_or_else(|| anyhow::anyhow!("Greeter disconnected"))?;
405
+
406
+        match request {
407
+            Request::CreateSession { username } => {
408
+                let response = auth.create_session(&username)?;
409
+                client.send_response(&response.into()).await?;
410
+            }
411
+            Request::Authenticate { response } => {
412
+                let result = auth.authenticate(&response)?;
413
+                client.send_response(&result.clone().into()).await?;
414
+
415
+                if matches!(result, AuthResponse::Success) {
416
+                    // Read StartSession request
417
+                    if let Some(Request::StartSession { cmd, .. }) =
418
+                        client.read_request().await?
419
+                    {
420
+                        return Ok(SessionInfo {
421
+                            username: auth.username().unwrap().to_string(),
422
+                            cmd,
423
+                        });
424
+                    }
425
+                }
426
+            }
427
+            Request::CancelSession => {
428
+                auth.cancel();
429
+                client.send_response(&Response::Success).await?;
430
+            }
431
+            _ => {
432
+                client.send_response(&Response::Error {
433
+                    message: "Unexpected request".to_string(),
434
+                }).await?;
435
+            }
436
+        }
437
+    }
438
+}
439
+```
440
+
441
+## Acceptance Criteria
442
+
443
+1. Xorg starts on specified VT
444
+2. Greeter launches as unprivileged gardm user
445
+3. After auth success, greeter is killed and session starts
446
+4. Session runs as authenticated user with correct environment
447
+5. After session logout, greeter restarts
448
+6. X server crash triggers recovery (restart X + greeter)
449
+
450
+## Pitfalls to Avoid
451
+
452
+1. **Don't forget VT permissions** - Xorg needs access to /dev/tty*
453
+2. **X server takes time to start** - must wait for it to be ready
454
+3. **Environment inheritance** - don't leak root environment to session
455
+4. **Group membership** - user needs video, audio groups for hardware access
456
+5. **Home directory** - chdir to $HOME before starting session
457
+6. **Don't block on X** - use async/spawn for process management
458
+
459
+## Testing
460
+
461
+```bash
462
+# Test X server startup (need to be root, on real TTY)
463
+sudo ./target/release/gardmd
464
+
465
+# Alternative: Test in Xephyr (nested X)
466
+Xephyr -br -ac -noreset -screen 1280x720 :1 &
467
+DISPLAY=:1 ./target/release/gardm-greeter
468
+
469
+# Create gardm user for testing
470
+sudo useradd -r -s /usr/bin/nologin -d /var/lib/gardm gardm
471
+sudo mkdir -p /var/lib/gardm
472
+sudo chown gardm:gardm /var/lib/gardm
473
+```
474
+
475
+## Dependencies for This Sprint
476
+
477
+```toml
478
+# gardmd/Cargo.toml
479
+[dependencies]
480
+x11rb = "0.13"
481
+libc = "0.2"
482
+nix = { version = "0.27", features = ["user", "process", "ioctl"] }
483
+```
484
+
485
+## Next Sprint
486
+
487
+Sprint 3 will build the graphical greeter UI with Cairo/Pango.
docs/sprints/sprint-3-greeter-ui.mdadded
@@ -0,0 +1,586 @@
1
+# Sprint 3: Greeter UI
2
+
3
+**Goal:** Build a sleek, centered login interface with blurred background using Cairo/Pango rendering.
4
+
5
+## Objectives
6
+
7
+- Create X11 window that covers the full screen
8
+- Render blurred background image
9
+- Implement centered login form (username, password inputs)
10
+- Add session selector dropdown
11
+- Add power buttons (shutdown, reboot, suspend)
12
+- Handle keyboard input and focus
13
+- Connect UI to daemon via IPC
14
+
15
+## Design Reference
16
+
17
+```
18
+┌─────────────────────────────────────────────────────────────────────┐
19
+│                                                                     │
20
+│                         [Blurred Background]                        │
21
+│                                                                     │
22
+│                     ┌───────────────────────┐                       │
23
+│                     │       User Avatar     │                       │
24
+│                     │          👤           │                       │
25
+│                     ├───────────────────────┤                       │
26
+│                     │  Username: [       ]  │                       │
27
+│                     │  Password: [*******]  │                       │
28
+│                     │                       │                       │
29
+│                     │  Session:  [gar    ▼] │                       │
30
+│                     │                       │                       │
31
+│                     │      [ Login ]        │                       │
32
+│                     │                       │                       │
33
+│                     │   Error message here  │                       │
34
+│                     └───────────────────────┘                       │
35
+│                                                                     │
36
+│  12:34 PM                                          [⏻] [↻] [⏾]     │
37
+│  January 14, 2026                                                   │
38
+└─────────────────────────────────────────────────────────────────────┘
39
+```
40
+
41
+## Tasks
42
+
43
+### 3.1 Window Setup
44
+
45
+```rust
46
+// gardm-greeter/src/window.rs
47
+
48
+use x11rb::connection::Connection;
49
+use x11rb::protocol::xproto::*;
50
+use x11rb::wrapper::ConnectionExt;
51
+
52
+pub struct GreeterWindow {
53
+    conn: x11rb::rust_connection::RustConnection,
54
+    screen_num: usize,
55
+    window: Window,
56
+    width: u16,
57
+    height: u16,
58
+    gc: Gcontext,
59
+}
60
+
61
+impl GreeterWindow {
62
+    pub fn new() -> anyhow::Result<Self> {
63
+        let (conn, screen_num) = x11rb::connect(None)?;
64
+        let screen = &conn.setup().roots[screen_num];
65
+
66
+        let width = screen.width_in_pixels;
67
+        let height = screen.height_in_pixels;
68
+        let root = screen.root;
69
+        let depth = screen.root_depth;
70
+        let visual = screen.root_visual;
71
+
72
+        // Create window
73
+        let window = conn.generate_id()?;
74
+        conn.create_window(
75
+            depth,
76
+            window,
77
+            root,
78
+            0, 0,
79
+            width, height,
80
+            0,
81
+            WindowClass::INPUT_OUTPUT,
82
+            visual,
83
+            &CreateWindowAux::new()
84
+                .background_pixel(screen.black_pixel)
85
+                .event_mask(
86
+                    EventMask::EXPOSURE
87
+                        | EventMask::KEY_PRESS
88
+                        | EventMask::BUTTON_PRESS
89
+                        | EventMask::STRUCTURE_NOTIFY
90
+                ),
91
+        )?;
92
+
93
+        // Make it fullscreen (bypass WM)
94
+        let net_wm_state = conn.intern_atom(false, b"_NET_WM_STATE")?.reply()?.atom;
95
+        let fullscreen = conn.intern_atom(false, b"_NET_WM_STATE_FULLSCREEN")?.reply()?.atom;
96
+        conn.change_property32(
97
+            PropMode::REPLACE,
98
+            window,
99
+            net_wm_state,
100
+            AtomEnum::ATOM,
101
+            &[fullscreen],
102
+        )?;
103
+
104
+        // Override redirect for greeter (no WM decorations)
105
+        conn.change_window_attributes(
106
+            window,
107
+            &ChangeWindowAttributesAux::new().override_redirect(1),
108
+        )?;
109
+
110
+        // Create graphics context
111
+        let gc = conn.generate_id()?;
112
+        conn.create_gc(gc, window, &CreateGCAux::new())?;
113
+
114
+        conn.map_window(window)?;
115
+        conn.flush()?;
116
+
117
+        Ok(Self {
118
+            conn,
119
+            screen_num,
120
+            window,
121
+            width,
122
+            height,
123
+            gc,
124
+        })
125
+    }
126
+
127
+    pub fn width(&self) -> u16 { self.width }
128
+    pub fn height(&self) -> u16 { self.height }
129
+    pub fn window(&self) -> Window { self.window }
130
+    pub fn conn(&self) -> &x11rb::rust_connection::RustConnection { &self.conn }
131
+}
132
+```
133
+
134
+### 3.2 Cairo Rendering Surface
135
+
136
+```rust
137
+// gardm-greeter/src/render.rs
138
+
139
+use cairo::{Context, ImageSurface, Format};
140
+use x11rb::protocol::xproto::*;
141
+
142
+pub struct Renderer {
143
+    surface: ImageSurface,
144
+    width: i32,
145
+    height: i32,
146
+}
147
+
148
+impl Renderer {
149
+    pub fn new(width: u16, height: u16) -> anyhow::Result<Self> {
150
+        let surface = ImageSurface::create(Format::ARgb32, width as i32, height as i32)?;
151
+        Ok(Self {
152
+            surface,
153
+            width: width as i32,
154
+            height: height as i32,
155
+        })
156
+    }
157
+
158
+    pub fn context(&self) -> anyhow::Result<Context> {
159
+        Ok(Context::new(&self.surface)?)
160
+    }
161
+
162
+    /// Get raw pixel data for X11
163
+    pub fn data(&self) -> Vec<u8> {
164
+        let stride = self.surface.stride() as usize;
165
+        let height = self.height as usize;
166
+        let data = self.surface.data().unwrap();
167
+
168
+        // Cairo uses ARGB, X11 uses BGRA - but with same byte order on little-endian
169
+        data[..stride * height].to_vec()
170
+    }
171
+
172
+    pub fn width(&self) -> i32 { self.width }
173
+    pub fn height(&self) -> i32 { self.height }
174
+}
175
+```
176
+
177
+### 3.3 Background with Blur
178
+
179
+```rust
180
+// gardm-greeter/src/background.rs
181
+
182
+use image::{RgbaImage, imageops};
183
+
184
+/// Load and blur a background image
185
+pub fn load_blurred_background(
186
+    path: &str,
187
+    width: u32,
188
+    height: u32,
189
+    blur_radius: f32,
190
+    brightness: f32,
191
+) -> anyhow::Result<RgbaImage> {
192
+    // Load image
193
+    let img = image::open(path)?.to_rgba8();
194
+
195
+    // Scale to screen size (cover mode)
196
+    let scaled = scale_to_cover(&img, width, height);
197
+
198
+    // Apply gaussian blur
199
+    let blurred = imageops::blur(&scaled, blur_radius);
200
+
201
+    // Adjust brightness (darken for better text contrast)
202
+    let adjusted = adjust_brightness(&blurred, brightness);
203
+
204
+    Ok(adjusted)
205
+}
206
+
207
+fn scale_to_cover(img: &RgbaImage, target_w: u32, target_h: u32) -> RgbaImage {
208
+    let (src_w, src_h) = img.dimensions();
209
+    let scale = (target_w as f32 / src_w as f32)
210
+        .max(target_h as f32 / src_h as f32);
211
+
212
+    let new_w = (src_w as f32 * scale) as u32;
213
+    let new_h = (src_h as f32 * scale) as u32;
214
+
215
+    let resized = imageops::resize(img, new_w, new_h, imageops::FilterType::Lanczos3);
216
+
217
+    // Crop to center
218
+    let x = (new_w - target_w) / 2;
219
+    let y = (new_h - target_h) / 2;
220
+
221
+    imageops::crop_imm(&resized, x, y, target_w, target_h).to_image()
222
+}
223
+
224
+fn adjust_brightness(img: &RgbaImage, factor: f32) -> RgbaImage {
225
+    let mut result = img.clone();
226
+    for pixel in result.pixels_mut() {
227
+        pixel[0] = (pixel[0] as f32 * factor).min(255.0) as u8;
228
+        pixel[1] = (pixel[1] as f32 * factor).min(255.0) as u8;
229
+        pixel[2] = (pixel[2] as f32 * factor).min(255.0) as u8;
230
+    }
231
+    result
232
+}
233
+```
234
+
235
+### 3.4 Login Form Widget
236
+
237
+```rust
238
+// gardm-greeter/src/widgets/login_form.rs
239
+
240
+use cairo::Context;
241
+use pango::{FontDescription, Layout};
242
+
243
+pub struct LoginForm {
244
+    pub username: String,
245
+    pub password: String,
246
+    pub focused_field: FocusedField,
247
+    pub error_message: Option<String>,
248
+    pub is_loading: bool,
249
+
250
+    // Layout
251
+    x: f64,
252
+    y: f64,
253
+    width: f64,
254
+    height: f64,
255
+}
256
+
257
+#[derive(Clone, Copy, PartialEq)]
258
+pub enum FocusedField {
259
+    Username,
260
+    Password,
261
+}
262
+
263
+impl LoginForm {
264
+    pub fn new(screen_width: f64, screen_height: f64) -> Self {
265
+        let width = 400.0;
266
+        let height = 300.0;
267
+
268
+        Self {
269
+            username: String::new(),
270
+            password: String::new(),
271
+            focused_field: FocusedField::Username,
272
+            error_message: None,
273
+            is_loading: false,
274
+            x: (screen_width - width) / 2.0,
275
+            y: (screen_height - height) / 2.0,
276
+            width,
277
+            height,
278
+        }
279
+    }
280
+
281
+    pub fn render(&self, ctx: &Context, pango_ctx: &pango::Context) -> anyhow::Result<()> {
282
+        // Background panel (semi-transparent)
283
+        ctx.set_source_rgba(0.1, 0.1, 0.1, 0.85);
284
+        rounded_rectangle(ctx, self.x, self.y, self.width, self.height, 16.0);
285
+        ctx.fill()?;
286
+
287
+        // Title
288
+        let title_layout = Layout::new(pango_ctx);
289
+        let mut font = FontDescription::new();
290
+        font.set_family("Sans");
291
+        font.set_size(24 * pango::SCALE);
292
+        font.set_weight(pango::Weight::Bold);
293
+        title_layout.set_font_description(Some(&font));
294
+        title_layout.set_text("Welcome");
295
+
296
+        ctx.set_source_rgb(1.0, 1.0, 1.0);
297
+        ctx.move_to(self.x + self.width / 2.0 - 50.0, self.y + 30.0);
298
+        pangocairo::show_layout(ctx, &title_layout);
299
+
300
+        // Username field
301
+        self.render_input_field(
302
+            ctx, pango_ctx,
303
+            "Username",
304
+            &self.username,
305
+            self.y + 100.0,
306
+            self.focused_field == FocusedField::Username,
307
+            false,
308
+        )?;
309
+
310
+        // Password field
311
+        self.render_input_field(
312
+            ctx, pango_ctx,
313
+            "Password",
314
+            &"•".repeat(self.password.len()),
315
+            self.y + 160.0,
316
+            self.focused_field == FocusedField::Password,
317
+            true,
318
+        )?;
319
+
320
+        // Error message
321
+        if let Some(ref msg) = self.error_message {
322
+            ctx.set_source_rgb(1.0, 0.3, 0.3);
323
+            let err_layout = Layout::new(pango_ctx);
324
+            font.set_size(12 * pango::SCALE);
325
+            font.set_weight(pango::Weight::Normal);
326
+            err_layout.set_font_description(Some(&font));
327
+            err_layout.set_text(msg);
328
+            ctx.move_to(self.x + 30.0, self.y + 230.0);
329
+            pangocairo::show_layout(ctx, &err_layout);
330
+        }
331
+
332
+        // Login button
333
+        self.render_button(ctx, pango_ctx, "Login", self.y + 260.0)?;
334
+
335
+        Ok(())
336
+    }
337
+
338
+    fn render_input_field(
339
+        &self,
340
+        ctx: &Context,
341
+        pango_ctx: &pango::Context,
342
+        label: &str,
343
+        value: &str,
344
+        y: f64,
345
+        focused: bool,
346
+        _is_password: bool,
347
+    ) -> anyhow::Result<()> {
348
+        let field_x = self.x + 30.0;
349
+        let field_width = self.width - 60.0;
350
+        let field_height = 40.0;
351
+
352
+        // Label
353
+        let mut font = FontDescription::new();
354
+        font.set_family("Sans");
355
+        font.set_size(11 * pango::SCALE);
356
+
357
+        let label_layout = Layout::new(pango_ctx);
358
+        label_layout.set_font_description(Some(&font));
359
+        label_layout.set_text(label);
360
+
361
+        ctx.set_source_rgba(0.8, 0.8, 0.8, 1.0);
362
+        ctx.move_to(field_x, y - 18.0);
363
+        pangocairo::show_layout(ctx, &label_layout);
364
+
365
+        // Input box
366
+        if focused {
367
+            ctx.set_source_rgba(0.3, 0.5, 0.8, 1.0);
368
+        } else {
369
+            ctx.set_source_rgba(0.3, 0.3, 0.3, 1.0);
370
+        }
371
+        rounded_rectangle(ctx, field_x, y, field_width, field_height, 8.0);
372
+        ctx.fill()?;
373
+
374
+        // Text value
375
+        ctx.set_source_rgb(1.0, 1.0, 1.0);
376
+        font.set_size(14 * pango::SCALE);
377
+        let value_layout = Layout::new(pango_ctx);
378
+        value_layout.set_font_description(Some(&font));
379
+        value_layout.set_text(if value.is_empty() { " " } else { value });
380
+        ctx.move_to(field_x + 10.0, y + 10.0);
381
+        pangocairo::show_layout(ctx, &value_layout);
382
+
383
+        // Cursor
384
+        if focused {
385
+            let (text_width, _) = value_layout.pixel_size();
386
+            ctx.set_source_rgb(1.0, 1.0, 1.0);
387
+            ctx.rectangle(field_x + 10.0 + text_width as f64, y + 8.0, 2.0, 24.0);
388
+            ctx.fill()?;
389
+        }
390
+
391
+        Ok(())
392
+    }
393
+
394
+    fn render_button(
395
+        &self,
396
+        ctx: &Context,
397
+        pango_ctx: &pango::Context,
398
+        text: &str,
399
+        y: f64,
400
+    ) -> anyhow::Result<()> {
401
+        let btn_width = 120.0;
402
+        let btn_height = 36.0;
403
+        let btn_x = self.x + (self.width - btn_width) / 2.0;
404
+
405
+        // Button background
406
+        ctx.set_source_rgba(0.2, 0.5, 0.8, 1.0);
407
+        rounded_rectangle(ctx, btn_x, y, btn_width, btn_height, 8.0);
408
+        ctx.fill()?;
409
+
410
+        // Button text
411
+        ctx.set_source_rgb(1.0, 1.0, 1.0);
412
+        let mut font = FontDescription::new();
413
+        font.set_family("Sans");
414
+        font.set_size(14 * pango::SCALE);
415
+        font.set_weight(pango::Weight::Bold);
416
+
417
+        let layout = Layout::new(pango_ctx);
418
+        layout.set_font_description(Some(&font));
419
+        layout.set_text(text);
420
+
421
+        let (text_w, _) = layout.pixel_size();
422
+        ctx.move_to(btn_x + (btn_width - text_w as f64) / 2.0, y + 8.0);
423
+        pangocairo::show_layout(ctx, &layout);
424
+
425
+        Ok(())
426
+    }
427
+
428
+    pub fn handle_key(&mut self, key: char) {
429
+        match self.focused_field {
430
+            FocusedField::Username => self.username.push(key),
431
+            FocusedField::Password => self.password.push(key),
432
+        }
433
+    }
434
+
435
+    pub fn handle_backspace(&mut self) {
436
+        match self.focused_field {
437
+            FocusedField::Username => { self.username.pop(); }
438
+            FocusedField::Password => { self.password.pop(); }
439
+        }
440
+    }
441
+
442
+    pub fn handle_tab(&mut self) {
443
+        self.focused_field = match self.focused_field {
444
+            FocusedField::Username => FocusedField::Password,
445
+            FocusedField::Password => FocusedField::Username,
446
+        };
447
+    }
448
+}
449
+
450
+fn rounded_rectangle(ctx: &Context, x: f64, y: f64, w: f64, h: f64, r: f64) {
451
+    let degrees = std::f64::consts::PI / 180.0;
452
+    ctx.new_sub_path();
453
+    ctx.arc(x + w - r, y + r, r, -90.0 * degrees, 0.0 * degrees);
454
+    ctx.arc(x + w - r, y + h - r, r, 0.0 * degrees, 90.0 * degrees);
455
+    ctx.arc(x + r, y + h - r, r, 90.0 * degrees, 180.0 * degrees);
456
+    ctx.arc(x + r, y + r, r, 180.0 * degrees, 270.0 * degrees);
457
+    ctx.close_path();
458
+}
459
+```
460
+
461
+### 3.5 Main Event Loop
462
+
463
+```rust
464
+// gardm-greeter/src/main.rs
465
+
466
+use x11rb::protocol::Event;
467
+
468
+fn main() -> anyhow::Result<()> {
469
+    let window = GreeterWindow::new()?;
470
+    let renderer = Renderer::new(window.width(), window.height())?;
471
+    let mut form = LoginForm::new(window.width() as f64, window.height() as f64);
472
+
473
+    // Load background
474
+    let background = load_blurred_background(
475
+        "/usr/share/gardm/backgrounds/default.jpg",
476
+        window.width() as u32,
477
+        window.height() as u32,
478
+        20.0,
479
+        0.7,
480
+    )?;
481
+
482
+    // Connect to daemon
483
+    let mut daemon = DaemonClient::connect()?;
484
+
485
+    loop {
486
+        // Render frame
487
+        {
488
+            let ctx = renderer.context()?;
489
+            render_background(&ctx, &background)?;
490
+            form.render(&ctx, &pango_ctx)?;
491
+        }
492
+
493
+        // Copy to X11
494
+        window.put_image(&renderer.data())?;
495
+
496
+        // Handle events
497
+        let event = window.conn().wait_for_event()?;
498
+        match event {
499
+            Event::Expose(_) => {
500
+                // Redraw handled above
501
+            }
502
+            Event::KeyPress(e) => {
503
+                match e.detail {
504
+                    9 => break,  // Escape - exit (for testing)
505
+                    36 => {      // Enter - submit
506
+                        if form.focused_field == FocusedField::Password {
507
+                            // Authenticate
508
+                            daemon.create_session(&form.username)?;
509
+                            match daemon.authenticate(&form.password)? {
510
+                                Response::Success => {
511
+                                    daemon.start_session(&["gar-session.sh"])?;
512
+                                    break;
513
+                                }
514
+                                Response::AuthError { message } => {
515
+                                    form.error_message = Some(message);
516
+                                }
517
+                                _ => {}
518
+                            }
519
+                        } else {
520
+                            form.handle_tab();
521
+                        }
522
+                    }
523
+                    23 => form.handle_tab(),  // Tab
524
+                    22 => form.handle_backspace(),  // Backspace
525
+                    _ => {
526
+                        // Regular key
527
+                        if let Some(c) = keycode_to_char(e.detail, e.state) {
528
+                            form.handle_key(c);
529
+                        }
530
+                    }
531
+                }
532
+            }
533
+            _ => {}
534
+        }
535
+    }
536
+
537
+    Ok(())
538
+}
539
+```
540
+
541
+## Acceptance Criteria
542
+
543
+1. Greeter displays fullscreen with blurred background
544
+2. Login form is centered and styled
545
+3. Keyboard input works for username/password
546
+4. Tab switches between fields
547
+5. Enter submits form
548
+6. Error messages display correctly
549
+7. Successful auth triggers session start
550
+
551
+## Pitfalls to Avoid
552
+
553
+1. **X11 keycodes vary by layout** - use XKB for proper key mapping
554
+2. **Cairo surface format** - ensure ARGB matches X11 expectations
555
+3. **Font rendering** - initialize Pango context correctly
556
+4. **Fullscreen on all monitors** - may need per-monitor handling
557
+5. **Input focus** - greeter window must grab keyboard
558
+6. **Memory leaks** - Cairo contexts need proper cleanup
559
+
560
+## Testing
561
+
562
+```bash
563
+# Test in Xephyr
564
+Xephyr -br -ac -noreset -screen 1280x720 :1 &
565
+DISPLAY=:1 ./target/release/gardm-greeter
566
+
567
+# Test keyboard input
568
+# Test form submission
569
+# Test error display
570
+```
571
+
572
+## Dependencies for This Sprint
573
+
574
+```toml
575
+# gardm-greeter/Cargo.toml
576
+[dependencies]
577
+x11rb = "0.13"
578
+cairo-rs = { version = "0.18", features = ["png"] }
579
+pango = "0.18"
580
+pangocairo = "0.18"
581
+image = "0.24"
582
+```
583
+
584
+## Next Sprint
585
+
586
+Sprint 4 will add garbg integration for seamless wallpaper sync.
docs/sprints/sprint-4-garbg-integration.mdadded
@@ -0,0 +1,453 @@
1
+# Sprint 4: garbg Integration
2
+
3
+**Goal:** Integrate with garbg to display the same wallpaper that will be shown after login, creating a seamless visual transition.
4
+
5
+## Objectives
6
+
7
+- Read garbg configuration to determine wallpaper source
8
+- Read garbg playlist state for current wallpaper
9
+- Apply consistent blur settings
10
+- Ensure seamless transition from greeter to gar session
11
+- Handle fallback when garbg config not available
12
+
13
+## Background: Seamless Transition
14
+
15
+The goal is that when a user logs in, the transition from greeter to desktop should feel smooth:
16
+
17
+```
18
+┌─────────────────────────────────────────────────────────────────┐
19
+│ Greeter                                                         │
20
+│ - Shows blurred version of current garbg wallpaper              │
21
+│ - User enters credentials                                       │
22
+│ - Login succeeds                                                │
23
+└─────────────────────────────────────────────────────────────────┘
24
+                              │
25
+                              ▼ (fade out greeter)
26
+┌─────────────────────────────────────────────────────────────────┐
27
+│ gar Session                                                     │
28
+│ - garbg starts and sets SAME wallpaper (unblurred)              │
29
+│ - Only visible change: blur fades away, UI elements appear      │
30
+└─────────────────────────────────────────────────────────────────┘
31
+```
32
+
33
+## Tasks
34
+
35
+### 4.1 Read garbg Configuration
36
+
37
+```rust
38
+// gardm-greeter/src/garbg.rs
39
+
40
+use serde::Deserialize;
41
+use std::path::PathBuf;
42
+
43
+#[derive(Debug, Deserialize)]
44
+pub struct GarbgConfig {
45
+    #[serde(default)]
46
+    pub general: GeneralConfig,
47
+    #[serde(default)]
48
+    pub default: DefaultConfig,
49
+}
50
+
51
+#[derive(Debug, Deserialize, Default)]
52
+pub struct GeneralConfig {
53
+    #[serde(default = "default_mode")]
54
+    pub mode: String,
55
+}
56
+
57
+#[derive(Debug, Deserialize, Default)]
58
+pub struct DefaultConfig {
59
+    #[serde(default)]
60
+    pub source: String,
61
+}
62
+
63
+fn default_mode() -> String { "fill".to_string() }
64
+
65
+impl GarbgConfig {
66
+    /// Load garbg config from user or system location
67
+    pub fn load(username: Option<&str>) -> Option<Self> {
68
+        // Try user config first (if we know the username)
69
+        if let Some(user) = username {
70
+            if let Some(home) = get_user_home(user) {
71
+                let user_config = home.join(".config/garbg/config.toml");
72
+                if let Ok(content) = std::fs::read_to_string(&user_config) {
73
+                    if let Ok(config) = toml::from_str(&content) {
74
+                        return Some(config);
75
+                    }
76
+                }
77
+            }
78
+        }
79
+
80
+        // Try system-wide default
81
+        let system_config = PathBuf::from("/etc/garbg/config.toml");
82
+        if let Ok(content) = std::fs::read_to_string(&system_config) {
83
+            if let Ok(config) = toml::from_str(&content) {
84
+                return Some(config);
85
+            }
86
+        }
87
+
88
+        None
89
+    }
90
+
91
+    /// Get the default wallpaper path
92
+    pub fn default_wallpaper(&self) -> Option<String> {
93
+        if self.default.source.is_empty() {
94
+            None
95
+        } else {
96
+            Some(expand_path(&self.default.source))
97
+        }
98
+    }
99
+}
100
+
101
+fn get_user_home(username: &str) -> Option<PathBuf> {
102
+    use nix::unistd::User;
103
+    User::from_name(username).ok()?
104
+        .map(|u| PathBuf::from(u.dir))
105
+}
106
+
107
+fn expand_path(path: &str) -> String {
108
+    shellexpand::tilde(path).to_string()
109
+}
110
+```
111
+
112
+### 4.2 Read garbg Playlist State
113
+
114
+```rust
115
+// gardm-greeter/src/garbg.rs (continued)
116
+
117
+#[derive(Debug, Deserialize)]
118
+pub struct PlaylistState {
119
+    pub source: String,
120
+    pub images: Vec<String>,
121
+    pub current_index: usize,
122
+    #[serde(default)]
123
+    pub shuffled: bool,
124
+}
125
+
126
+impl PlaylistState {
127
+    /// Load current playlist state from runtime directory
128
+    pub fn load(username: Option<&str>) -> Option<Self> {
129
+        // Playlist state is stored in XDG_RUNTIME_DIR
130
+        let runtime_dir = std::env::var("XDG_RUNTIME_DIR").ok()?;
131
+        let state_file = PathBuf::from(&runtime_dir).join("garbg-state.json");
132
+
133
+        // If that doesn't exist, try user-specific location
134
+        if !state_file.exists() {
135
+            if let Some(user) = username {
136
+                // Try /run/user/<uid>/garbg-state.json
137
+                if let Some(uid) = get_user_uid(user) {
138
+                    let user_runtime = format!("/run/user/{}/garbg-state.json", uid);
139
+                    if let Ok(content) = std::fs::read_to_string(&user_runtime) {
140
+                        if let Ok(state) = serde_json::from_str(&content) {
141
+                            return Some(state);
142
+                        }
143
+                    }
144
+                }
145
+            }
146
+        }
147
+
148
+        let content = std::fs::read_to_string(&state_file).ok()?;
149
+        serde_json::from_str(&content).ok()
150
+    }
151
+
152
+    /// Get the current wallpaper path
153
+    pub fn current_wallpaper(&self) -> Option<&str> {
154
+        self.images.get(self.current_index).map(|s| s.as_str())
155
+    }
156
+}
157
+
158
+fn get_user_uid(username: &str) -> Option<u32> {
159
+    use nix::unistd::User;
160
+    User::from_name(username).ok()?
161
+        .map(|u| u.uid.as_raw())
162
+}
163
+```
164
+
165
+### 4.3 Wallpaper Resolution Logic
166
+
167
+```rust
168
+// gardm-greeter/src/garbg.rs (continued)
169
+
170
+/// Resolve which wallpaper to use for the greeter
171
+pub struct WallpaperResolver {
172
+    fallback_path: String,
173
+}
174
+
175
+impl WallpaperResolver {
176
+    pub fn new(fallback: &str) -> Self {
177
+        Self {
178
+            fallback_path: fallback.to_string(),
179
+        }
180
+    }
181
+
182
+    /// Resolve wallpaper path, trying multiple sources
183
+    pub fn resolve(&self, username: Option<&str>) -> String {
184
+        // Priority 1: Current playlist state (what user was last seeing)
185
+        if let Some(state) = PlaylistState::load(username) {
186
+            if let Some(current) = state.current_wallpaper() {
187
+                let expanded = expand_path(current);
188
+                if std::path::Path::new(&expanded).exists() {
189
+                    tracing::info!("Using wallpaper from playlist: {}", expanded);
190
+                    return expanded;
191
+                }
192
+            }
193
+        }
194
+
195
+        // Priority 2: garbg config default
196
+        if let Some(config) = GarbgConfig::load(username) {
197
+            if let Some(default) = config.default_wallpaper() {
198
+                // If it's a directory, pick first image
199
+                let path = std::path::Path::new(&default);
200
+                if path.is_dir() {
201
+                    if let Some(first) = first_image_in_dir(&default) {
202
+                        tracing::info!("Using first image from garbg source dir: {}", first);
203
+                        return first;
204
+                    }
205
+                } else if path.exists() {
206
+                    tracing::info!("Using wallpaper from garbg config: {}", default);
207
+                    return default;
208
+                }
209
+            }
210
+        }
211
+
212
+        // Priority 3: Fallback
213
+        tracing::info!("Using fallback wallpaper: {}", self.fallback_path);
214
+        self.fallback_path.clone()
215
+    }
216
+}
217
+
218
+/// Get first image file in a directory
219
+fn first_image_in_dir(dir: &str) -> Option<String> {
220
+    let extensions = ["jpg", "jpeg", "png", "webp"];
221
+
222
+    let mut entries: Vec<_> = std::fs::read_dir(dir).ok()?
223
+        .filter_map(|e| e.ok())
224
+        .filter(|e| {
225
+            let path = e.path();
226
+            if let Some(ext) = path.extension() {
227
+                extensions.contains(&ext.to_string_lossy().to_lowercase().as_str())
228
+            } else {
229
+                false
230
+            }
231
+        })
232
+        .collect();
233
+
234
+    entries.sort_by_key(|e| e.path());
235
+    entries.first().map(|e| e.path().to_string_lossy().to_string())
236
+}
237
+```
238
+
239
+### 4.4 Shared Visual Settings
240
+
241
+```rust
242
+// gardm-greeter/src/config.rs
243
+
244
+use serde::Deserialize;
245
+
246
+#[derive(Debug, Deserialize)]
247
+pub struct GreeterConfig {
248
+    #[serde(default)]
249
+    pub visual: VisualConfig,
250
+    #[serde(default)]
251
+    pub garbg: GarbgIntegration,
252
+}
253
+
254
+#[derive(Debug, Deserialize, Default)]
255
+pub struct VisualConfig {
256
+    /// Blur radius for background (should match gar lock screen)
257
+    #[serde(default = "default_blur_radius")]
258
+    pub blur_radius: f32,
259
+
260
+    /// Background brightness (0.0-1.0, lower = darker)
261
+    #[serde(default = "default_brightness")]
262
+    pub blur_brightness: f32,
263
+
264
+    /// Corner radius for UI elements (match gar's corner_radius)
265
+    #[serde(default = "default_corner_radius")]
266
+    pub corner_radius: f64,
267
+}
268
+
269
+#[derive(Debug, Deserialize, Default)]
270
+pub struct GarbgIntegration {
271
+    /// Whether to use garbg wallpaper
272
+    #[serde(default = "default_true")]
273
+    pub enabled: bool,
274
+
275
+    /// Fallback wallpaper if garbg not configured
276
+    #[serde(default = "default_fallback")]
277
+    pub fallback: String,
278
+}
279
+
280
+fn default_blur_radius() -> f32 { 20.0 }
281
+fn default_brightness() -> f32 { 0.7 }
282
+fn default_corner_radius() -> f64 { 18.0 }
283
+fn default_true() -> bool { true }
284
+fn default_fallback() -> String { "/usr/share/gardm/backgrounds/default.jpg".to_string() }
285
+```
286
+
287
+### 4.5 Integration with Greeter Main Loop
288
+
289
+```rust
290
+// gardm-greeter/src/main.rs (updated)
291
+
292
+fn main() -> anyhow::Result<()> {
293
+    let config = GreeterConfig::load()?;
294
+    let window = GreeterWindow::new()?;
295
+
296
+    // Resolve wallpaper using garbg integration
297
+    let wallpaper_path = if config.garbg.enabled {
298
+        let resolver = WallpaperResolver::new(&config.garbg.fallback);
299
+        // Initially no username known, use system defaults
300
+        resolver.resolve(None)
301
+    } else {
302
+        config.garbg.fallback.clone()
303
+    };
304
+
305
+    // Load and blur background
306
+    let background = load_blurred_background(
307
+        &wallpaper_path,
308
+        window.width() as u32,
309
+        window.height() as u32,
310
+        config.visual.blur_radius,
311
+        config.visual.blur_brightness,
312
+    )?;
313
+
314
+    let mut form = LoginForm::new(
315
+        window.width() as f64,
316
+        window.height() as f64,
317
+        config.visual.corner_radius,
318
+    );
319
+
320
+    // When username is entered, potentially update wallpaper
321
+    // to user-specific one (optional enhancement)
322
+
323
+    // ... rest of event loop ...
324
+}
325
+```
326
+
327
+### 4.6 Session Transition Effect
328
+
329
+```rust
330
+// gardm-greeter/src/transition.rs
331
+
332
+use std::time::{Duration, Instant};
333
+
334
+/// Fade out transition before starting session
335
+pub struct FadeOutTransition {
336
+    start_time: Instant,
337
+    duration: Duration,
338
+}
339
+
340
+impl FadeOutTransition {
341
+    pub fn new(duration_ms: u64) -> Self {
342
+        Self {
343
+            start_time: Instant::now(),
344
+            duration: Duration::from_millis(duration_ms),
345
+        }
346
+    }
347
+
348
+    /// Get current opacity (1.0 -> 0.0)
349
+    pub fn opacity(&self) -> f64 {
350
+        let elapsed = self.start_time.elapsed();
351
+        if elapsed >= self.duration {
352
+            0.0
353
+        } else {
354
+            1.0 - (elapsed.as_secs_f64() / self.duration.as_secs_f64())
355
+        }
356
+    }
357
+
358
+    pub fn is_complete(&self) -> bool {
359
+        self.start_time.elapsed() >= self.duration
360
+    }
361
+}
362
+
363
+/// Apply fade during session start
364
+pub fn render_with_fade(
365
+    ctx: &cairo::Context,
366
+    background: &image::RgbaImage,
367
+    form: &LoginForm,
368
+    fade: Option<&FadeOutTransition>,
369
+) -> anyhow::Result<()> {
370
+    // Render background (always full opacity)
371
+    render_background(ctx, background)?;
372
+
373
+    // Render UI with fade
374
+    if let Some(fade) = fade {
375
+        ctx.push_group();
376
+        form.render(ctx, &pango_ctx)?;
377
+        ctx.pop_group_to_source()?;
378
+        ctx.paint_with_alpha(fade.opacity())?;
379
+    } else {
380
+        form.render(ctx, &pango_ctx)?;
381
+    }
382
+
383
+    Ok(())
384
+}
385
+```
386
+
387
+## Acceptance Criteria
388
+
389
+1. Greeter shows same wallpaper as garbg will after login
390
+2. Blur settings are consistent with gar lock screen
391
+3. Fallback works when garbg is not configured
392
+4. User-specific wallpaper is used when username is known
393
+5. Smooth fade transition when starting session
394
+6. No flash of different wallpaper during login
395
+
396
+## Pitfalls to Avoid
397
+
398
+1. **File permissions** - greeter runs as gardm user, may not access user home
399
+2. **Race conditions** - garbg state file might be stale
400
+3. **Directory vs file** - garbg source might be a directory
401
+4. **Missing files** - wallpaper might have been deleted
402
+5. **Large images** - blur on 4K images is slow, cache if needed
403
+
404
+## Testing
405
+
406
+```bash
407
+# Test wallpaper resolution
408
+# 1. With garbg configured and running
409
+garbg set ~/Pictures/wallpapers --random
410
+DISPLAY=:1 ./target/release/gardm-greeter
411
+
412
+# 2. With garbg config but no playlist
413
+rm ~/.local/state/garbg-state.json
414
+DISPLAY=:1 ./target/release/gardm-greeter
415
+
416
+# 3. Without any garbg config (should use fallback)
417
+mv ~/.config/garbg/config.toml ~/.config/garbg/config.toml.bak
418
+DISPLAY=:1 ./target/release/gardm-greeter
419
+```
420
+
421
+## Dependencies for This Sprint
422
+
423
+```toml
424
+# gardm-greeter/Cargo.toml
425
+[dependencies]
426
+shellexpand = "3.0"
427
+toml = "0.8"
428
+serde_json = "1.0"
429
+```
430
+
431
+## Integration Checklist
432
+
433
+- [ ] Greeter reads `~/.config/garbg/config.toml`
434
+- [ ] Greeter reads `$XDG_RUNTIME_DIR/garbg-state.json`
435
+- [ ] Blur radius matches gar lock screen (if implemented)
436
+- [ ] Corner radius matches gar's `corner_radius` setting
437
+- [ ] Fallback image is bundled with gardm package
438
+- [ ] Fade transition timing feels smooth (150-200ms)
439
+
440
+## Future Enhancements
441
+
442
+- [ ] Per-user wallpaper preview before login
443
+- [ ] Animated wallpaper support (match garbg animation)
444
+- [ ] Workspace-specific wallpaper from garbg config
445
+- [ ] Live wallpaper update if garbg changes during greeter
446
+
447
+## Next Steps
448
+
449
+After Sprint 4, consider:
450
+- Sprint 5: Power buttons and session selector
451
+- Sprint 6: User list with avatars
452
+- Sprint 7: Accessibility and theming
453
+- Sprint 8: Multi-monitor support
etc/config.tomladded
@@ -0,0 +1,34 @@
1
+# gardm configuration
2
+# Place this file at /etc/gardm/config.toml
3
+
4
+[general]
5
+# Default session to launch (without .desktop extension)
6
+default_session = "gar"
7
+# Path to greeter executable
8
+greeter = "/usr/bin/gardm-greeter"
9
+# VT to use (0 = auto-select)
10
+vt = 0
11
+# X11 display
12
+display = ":0"
13
+
14
+[greeter]
15
+# Background blur radius
16
+blur_radius = 20
17
+# Background brightness (0.0-1.0)
18
+blur_brightness = 0.7
19
+# Show power buttons (shutdown, reboot, suspend)
20
+show_power_buttons = true
21
+# Show session selector dropdown
22
+show_session_selector = true
23
+# Use garbg's current wallpaper
24
+use_garbg_wallpaper = true
25
+# Fallback wallpaper if garbg not available
26
+fallback_wallpaper = "/usr/share/gardm/backgrounds/default.jpg"
27
+
28
+[security]
29
+# Allow empty passwords (NOT recommended)
30
+allow_empty_password = false
31
+# Lock after N failed attempts (0 = disabled)
32
+lockout_attempts = 5
33
+# Lockout duration in seconds
34
+lockout_duration = 300
etc/gardm.serviceadded
@@ -0,0 +1,25 @@
1
+[Unit]
2
+Description=gar Display Manager
3
+Documentation=https://github.com/mfwolffe/gardesk
4
+After=systemd-user-sessions.service getty@tty1.service plymouth-quit.service
5
+Conflicts=getty@tty1.service
6
+
7
+[Service]
8
+Type=notify
9
+ExecStart=/usr/bin/gardmd
10
+ExecReload=/bin/kill -HUP $MAINPID
11
+Restart=always
12
+RestartSec=1
13
+
14
+# Security hardening
15
+NoNewPrivileges=no
16
+ProtectSystem=strict
17
+ProtectHome=read-only
18
+PrivateTmp=yes
19
+ReadWritePaths=/run
20
+
21
+# PAM needs access to various system files
22
+ReadOnlyPaths=/etc/passwd /etc/shadow /etc/group /etc/pam.d
23
+
24
+[Install]
25
+Alias=display-manager.service
gardm-greeter/Cargo.tomladded
@@ -0,0 +1,18 @@
1
+[package]
2
+name = "gardm-greeter"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+description = "gar display manager greeter"
7
+
8
+[[bin]]
9
+name = "gardm-greeter"
10
+path = "src/main.rs"
11
+
12
+[dependencies]
13
+gardm-ipc = { workspace = true }
14
+tokio = { workspace = true }
15
+tracing = { workspace = true }
16
+tracing-subscriber = { workspace = true }
17
+anyhow = { workspace = true }
18
+thiserror = { workspace = true }
gardm-greeter/src/main.rsadded
@@ -0,0 +1,35 @@
1
+//! gardm-greeter - gar display manager greeter
2
+//!
3
+//! Graphical login UI that communicates with gardmd.
4
+
5
+use anyhow::Result;
6
+use gardm_ipc::{Client, Request};
7
+use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
8
+
9
+#[tokio::main]
10
+async fn main() -> Result<()> {
11
+    // Initialize logging
12
+    tracing_subscriber::registry()
13
+        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
14
+        .with(tracing_subscriber::fmt::layer())
15
+        .init();
16
+
17
+    tracing::info!("gardm-greeter starting");
18
+
19
+    // Connect to daemon
20
+    let mut client = Client::connect().await?;
21
+    tracing::info!("Connected to gardmd");
22
+
23
+    // TODO: Initialize X11/rendering
24
+    // TODO: Main UI loop
25
+
26
+    // For now, just test the connection
27
+    let response = client.request(&Request::ListSessions).await?;
28
+    tracing::info!(?response, "Got sessions");
29
+
30
+    let response = client.request(&Request::ListUsers).await?;
31
+    tracing::info!(?response, "Got users");
32
+
33
+    tracing::info!("gardm-greeter exiting (UI not yet implemented)");
34
+    Ok(())
35
+}
gardm-ipc/Cargo.tomladded
@@ -0,0 +1,12 @@
1
+[package]
2
+name = "gardm-ipc"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+description = "IPC protocol types for gardm display manager"
7
+
8
+[dependencies]
9
+serde = { workspace = true }
10
+serde_json = { workspace = true }
11
+thiserror = { workspace = true }
12
+tokio = { workspace = true, features = ["net", "io-util"] }
gardm-ipc/src/lib.rsadded
@@ -0,0 +1,199 @@
1
+//! IPC protocol for gardm display manager
2
+//!
3
+//! Defines the JSON-based protocol between gardmd (daemon) and gardm-greeter (UI).
4
+
5
+use serde::{Deserialize, Serialize};
6
+use std::path::PathBuf;
7
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
8
+use tokio::net::UnixStream;
9
+
10
+/// Socket path for gardm IPC
11
+pub const SOCKET_PATH: &str = "/run/gardm.sock";
12
+
13
+/// Requests from greeter to daemon
14
+#[derive(Debug, Clone, Serialize, Deserialize)]
15
+#[serde(tag = "type", rename_all = "snake_case")]
16
+pub enum Request {
17
+    /// Create a new authentication session for a user
18
+    CreateSession { username: String },
19
+
20
+    /// Provide authentication response (password, OTP, etc.)
21
+    Authenticate { response: String },
22
+
23
+    /// Start the user's session after successful auth
24
+    StartSession {
25
+        /// Session command (e.g., ["gar-session.sh"])
26
+        cmd: Vec<String>,
27
+        /// Additional environment variables
28
+        #[serde(default)]
29
+        env: Vec<String>,
30
+    },
31
+
32
+    /// Cancel the current authentication attempt
33
+    CancelSession,
34
+
35
+    /// Request system shutdown
36
+    Shutdown,
37
+
38
+    /// Request system reboot
39
+    Reboot,
40
+
41
+    /// Request system suspend
42
+    Suspend,
43
+
44
+    /// Get list of available sessions
45
+    ListSessions,
46
+
47
+    /// Get list of available users
48
+    ListUsers,
49
+}
50
+
51
+/// Responses from daemon to greeter
52
+#[derive(Debug, Clone, Serialize, Deserialize)]
53
+#[serde(tag = "type", rename_all = "snake_case")]
54
+pub enum Response {
55
+    /// Operation completed successfully
56
+    Success,
57
+
58
+    /// PAM is requesting user input
59
+    AuthPrompt {
60
+        /// Prompt message (e.g., "Password:")
61
+        prompt: String,
62
+        /// Whether to echo input (false for passwords)
63
+        echo: bool,
64
+    },
65
+
66
+    /// PAM informational message
67
+    AuthInfo { message: String },
68
+
69
+    /// Authentication failed
70
+    AuthError { message: String },
71
+
72
+    /// General error
73
+    Error { message: String },
74
+
75
+    /// List of available sessions
76
+    Sessions { sessions: Vec<SessionInfo> },
77
+
78
+    /// List of available users
79
+    Users { users: Vec<UserInfo> },
80
+}
81
+
82
+/// Information about an available session
83
+#[derive(Debug, Clone, Serialize, Deserialize)]
84
+pub struct SessionInfo {
85
+    /// Session identifier (desktop file name without .desktop)
86
+    pub id: String,
87
+    /// Display name
88
+    pub name: String,
89
+    /// Optional comment/description
90
+    pub comment: Option<String>,
91
+    /// Exec command
92
+    pub exec: String,
93
+    /// Session type (x11, wayland)
94
+    pub session_type: String,
95
+}
96
+
97
+/// Information about a user
98
+#[derive(Debug, Clone, Serialize, Deserialize)]
99
+pub struct UserInfo {
100
+    /// Username
101
+    pub name: String,
102
+    /// Full name (GECOS field)
103
+    pub full_name: Option<String>,
104
+    /// Home directory
105
+    pub home: PathBuf,
106
+    /// Path to user avatar (if available)
107
+    pub avatar: Option<PathBuf>,
108
+}
109
+
110
+/// IPC client for connecting to gardmd
111
+pub struct Client {
112
+    reader: BufReader<tokio::net::unix::OwnedReadHalf>,
113
+    writer: tokio::net::unix::OwnedWriteHalf,
114
+}
115
+
116
+impl Client {
117
+    /// Connect to the daemon
118
+    pub async fn connect() -> Result<Self, std::io::Error> {
119
+        Self::connect_to(SOCKET_PATH).await
120
+    }
121
+
122
+    /// Connect to a specific socket path
123
+    pub async fn connect_to(path: &str) -> Result<Self, std::io::Error> {
124
+        let stream = UnixStream::connect(path).await?;
125
+        let (read, write) = stream.into_split();
126
+        Ok(Self {
127
+            reader: BufReader::new(read),
128
+            writer: write,
129
+        })
130
+    }
131
+
132
+    /// Send a request to the daemon
133
+    pub async fn send(&mut self, request: &Request) -> Result<(), std::io::Error> {
134
+        let json = serde_json::to_string(request).map_err(|e| {
135
+            std::io::Error::new(std::io::ErrorKind::InvalidData, e)
136
+        })?;
137
+        self.writer.write_all(json.as_bytes()).await?;
138
+        self.writer.write_all(b"\n").await?;
139
+        self.writer.flush().await?;
140
+        Ok(())
141
+    }
142
+
143
+    /// Receive a response from the daemon
144
+    pub async fn recv(&mut self) -> Result<Response, std::io::Error> {
145
+        let mut line = String::new();
146
+        let n = self.reader.read_line(&mut line).await?;
147
+        if n == 0 {
148
+            return Err(std::io::Error::new(
149
+                std::io::ErrorKind::UnexpectedEof,
150
+                "daemon closed connection",
151
+            ));
152
+        }
153
+        serde_json::from_str(&line).map_err(|e| {
154
+            std::io::Error::new(std::io::ErrorKind::InvalidData, e)
155
+        })
156
+    }
157
+
158
+    /// Send request and wait for response
159
+    pub async fn request(&mut self, request: &Request) -> Result<Response, std::io::Error> {
160
+        self.send(request).await?;
161
+        self.recv().await
162
+    }
163
+}
164
+
165
+#[cfg(test)]
166
+mod tests {
167
+    use super::*;
168
+
169
+    #[test]
170
+    fn test_request_serialization() {
171
+        let req = Request::CreateSession {
172
+            username: "testuser".to_string(),
173
+        };
174
+        let json = serde_json::to_string(&req).unwrap();
175
+        assert!(json.contains("create_session"));
176
+        assert!(json.contains("testuser"));
177
+    }
178
+
179
+    #[test]
180
+    fn test_response_serialization() {
181
+        let resp = Response::AuthPrompt {
182
+            prompt: "Password:".to_string(),
183
+            echo: false,
184
+        };
185
+        let json = serde_json::to_string(&resp).unwrap();
186
+        assert!(json.contains("auth_prompt"));
187
+        assert!(json.contains("Password:"));
188
+    }
189
+
190
+    #[test]
191
+    fn test_request_deserialization() {
192
+        let json = r#"{"type":"authenticate","response":"secret123"}"#;
193
+        let req: Request = serde_json::from_str(json).unwrap();
194
+        match req {
195
+            Request::Authenticate { response } => assert_eq!(response, "secret123"),
196
+            _ => panic!("Wrong variant"),
197
+        }
198
+    }
199
+}
gardmd/Cargo.tomladded
@@ -0,0 +1,23 @@
1
+[package]
2
+name = "gardmd"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+description = "gar display manager daemon"
7
+
8
+[[bin]]
9
+name = "gardmd"
10
+path = "src/main.rs"
11
+
12
+[dependencies]
13
+gardm-ipc = { workspace = true }
14
+tokio = { workspace = true }
15
+tracing = { workspace = true }
16
+tracing-subscriber = { workspace = true }
17
+anyhow = { workspace = true }
18
+thiserror = { workspace = true }
19
+serde = { workspace = true }
20
+serde_json = { workspace = true }
21
+toml = { workspace = true }
22
+nix = { workspace = true }
23
+sd-notify = "0.4"
gardmd/src/config.rsadded
@@ -0,0 +1,155 @@
1
+//! Configuration for gardmd
2
+
3
+use serde::Deserialize;
4
+use std::path::PathBuf;
5
+
6
+/// Main configuration structure
7
+#[derive(Debug, Clone, Deserialize)]
8
+pub struct Config {
9
+    #[serde(default)]
10
+    pub general: GeneralConfig,
11
+    #[serde(default)]
12
+    pub greeter: GreeterConfig,
13
+    #[serde(default)]
14
+    pub security: SecurityConfig,
15
+}
16
+
17
+impl Default for Config {
18
+    fn default() -> Self {
19
+        Self {
20
+            general: GeneralConfig::default(),
21
+            greeter: GreeterConfig::default(),
22
+            security: SecurityConfig::default(),
23
+        }
24
+    }
25
+}
26
+
27
+/// General daemon settings
28
+#[derive(Debug, Clone, Deserialize)]
29
+pub struct GeneralConfig {
30
+    /// Default session if user hasn't selected one
31
+    #[serde(default = "default_session")]
32
+    pub default_session: String,
33
+
34
+    /// Path to greeter executable
35
+    #[serde(default = "default_greeter")]
36
+    pub greeter: PathBuf,
37
+
38
+    /// VT to use (0 = auto-select)
39
+    #[serde(default)]
40
+    pub vt: u32,
41
+
42
+    /// X11 display to use
43
+    #[serde(default = "default_display")]
44
+    pub display: String,
45
+}
46
+
47
+impl Default for GeneralConfig {
48
+    fn default() -> Self {
49
+        Self {
50
+            default_session: default_session(),
51
+            greeter: default_greeter(),
52
+            vt: 0,
53
+            display: default_display(),
54
+        }
55
+    }
56
+}
57
+
58
+/// Greeter visual settings
59
+#[derive(Debug, Clone, Deserialize)]
60
+pub struct GreeterConfig {
61
+    /// Blur radius for background
62
+    #[serde(default = "default_blur_radius")]
63
+    pub blur_radius: u32,
64
+
65
+    /// Background brightness (0.0-1.0)
66
+    #[serde(default = "default_blur_brightness")]
67
+    pub blur_brightness: f32,
68
+
69
+    /// Show power buttons
70
+    #[serde(default = "default_true")]
71
+    pub show_power_buttons: bool,
72
+
73
+    /// Show session selector
74
+    #[serde(default = "default_true")]
75
+    pub show_session_selector: bool,
76
+
77
+    /// Use garbg wallpaper
78
+    #[serde(default = "default_true")]
79
+    pub use_garbg_wallpaper: bool,
80
+
81
+    /// Fallback wallpaper path
82
+    #[serde(default = "default_fallback_wallpaper")]
83
+    pub fallback_wallpaper: PathBuf,
84
+}
85
+
86
+impl Default for GreeterConfig {
87
+    fn default() -> Self {
88
+        Self {
89
+            blur_radius: default_blur_radius(),
90
+            blur_brightness: default_blur_brightness(),
91
+            show_power_buttons: true,
92
+            show_session_selector: true,
93
+            use_garbg_wallpaper: true,
94
+            fallback_wallpaper: default_fallback_wallpaper(),
95
+        }
96
+    }
97
+}
98
+
99
+/// Security settings
100
+#[derive(Debug, Clone, Deserialize)]
101
+pub struct SecurityConfig {
102
+    /// Allow empty passwords
103
+    #[serde(default)]
104
+    pub allow_empty_password: bool,
105
+
106
+    /// Lock after N failed attempts (0 = disabled)
107
+    #[serde(default = "default_lockout_attempts")]
108
+    pub lockout_attempts: u32,
109
+
110
+    /// Lockout duration in seconds
111
+    #[serde(default = "default_lockout_duration")]
112
+    pub lockout_duration: u64,
113
+}
114
+
115
+impl Default for SecurityConfig {
116
+    fn default() -> Self {
117
+        Self {
118
+            allow_empty_password: false,
119
+            lockout_attempts: default_lockout_attempts(),
120
+            lockout_duration: default_lockout_duration(),
121
+        }
122
+    }
123
+}
124
+
125
+// Default value functions
126
+fn default_session() -> String { "gar".to_string() }
127
+fn default_greeter() -> PathBuf { PathBuf::from("/usr/bin/gardm-greeter") }
128
+fn default_display() -> String { ":0".to_string() }
129
+fn default_blur_radius() -> u32 { 20 }
130
+fn default_blur_brightness() -> f32 { 0.7 }
131
+fn default_true() -> bool { true }
132
+fn default_fallback_wallpaper() -> PathBuf {
133
+    PathBuf::from("/usr/share/gardm/backgrounds/default.jpg")
134
+}
135
+fn default_lockout_attempts() -> u32 { 5 }
136
+fn default_lockout_duration() -> u64 { 300 }
137
+
138
+impl Config {
139
+    /// Load configuration from default path
140
+    pub fn load() -> anyhow::Result<Self> {
141
+        Self::load_from("/etc/gardm/config.toml")
142
+    }
143
+
144
+    /// Load configuration from specified path
145
+    pub fn load_from(path: &str) -> anyhow::Result<Self> {
146
+        let path = PathBuf::from(path);
147
+        if path.exists() {
148
+            let content = std::fs::read_to_string(&path)?;
149
+            Ok(toml::from_str(&content)?)
150
+        } else {
151
+            tracing::info!("Config file not found at {}, using defaults", path.display());
152
+            Ok(Config::default())
153
+        }
154
+    }
155
+}
gardmd/src/ipc.rsadded
@@ -0,0 +1,99 @@
1
+//! IPC server for gardmd
2
+//!
3
+//! Handles connections from the greeter and processes requests.
4
+
5
+use anyhow::Result;
6
+use gardm_ipc::{Request, Response, SOCKET_PATH};
7
+use std::path::Path;
8
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
9
+use tokio::net::{UnixListener, UnixStream};
10
+use tokio::sync::mpsc;
11
+
12
+/// IPC server for handling greeter connections
13
+pub struct Server {
14
+    listener: UnixListener,
15
+}
16
+
17
+impl Server {
18
+    /// Create a new IPC server
19
+    pub async fn new() -> Result<Self> {
20
+        Self::bind(SOCKET_PATH).await
21
+    }
22
+
23
+    /// Bind to a specific socket path
24
+    pub async fn bind(path: &str) -> Result<Self> {
25
+        let path = Path::new(path);
26
+
27
+        // Remove stale socket if it exists
28
+        if path.exists() {
29
+            std::fs::remove_file(path)?;
30
+        }
31
+
32
+        // Ensure parent directory exists
33
+        if let Some(parent) = path.parent() {
34
+            std::fs::create_dir_all(parent)?;
35
+        }
36
+
37
+        let listener = UnixListener::bind(path)?;
38
+        tracing::info!("IPC server listening on {}", path.display());
39
+
40
+        Ok(Self { listener })
41
+    }
42
+
43
+    /// Accept a new connection
44
+    pub async fn accept(&self) -> Result<ClientConnection> {
45
+        let (stream, _addr) = self.listener.accept().await?;
46
+        tracing::debug!("New greeter connection");
47
+        Ok(ClientConnection::new(stream))
48
+    }
49
+}
50
+
51
+/// A connected greeter client
52
+pub struct ClientConnection {
53
+    reader: BufReader<tokio::net::unix::OwnedReadHalf>,
54
+    writer: tokio::net::unix::OwnedWriteHalf,
55
+}
56
+
57
+impl ClientConnection {
58
+    fn new(stream: UnixStream) -> Self {
59
+        let (read, write) = stream.into_split();
60
+        Self {
61
+            reader: BufReader::new(read),
62
+            writer: write,
63
+        }
64
+    }
65
+
66
+    /// Receive a request from the greeter
67
+    pub async fn recv(&mut self) -> Result<Option<Request>> {
68
+        let mut line = String::new();
69
+        let n = self.reader.read_line(&mut line).await?;
70
+        if n == 0 {
71
+            return Ok(None);
72
+        }
73
+        let request: Request = serde_json::from_str(&line)?;
74
+        tracing::debug!(?request, "Received request");
75
+        Ok(Some(request))
76
+    }
77
+
78
+    /// Send a response to the greeter
79
+    pub async fn send(&mut self, response: &Response) -> Result<()> {
80
+        let json = serde_json::to_string(response)?;
81
+        self.writer.write_all(json.as_bytes()).await?;
82
+        self.writer.write_all(b"\n").await?;
83
+        self.writer.flush().await?;
84
+        tracing::debug!(?response, "Sent response");
85
+        Ok(())
86
+    }
87
+}
88
+
89
+/// Commands from external sources (e.g., signal handlers)
90
+#[derive(Debug)]
91
+pub enum DaemonCommand {
92
+    Shutdown,
93
+    Reload,
94
+}
95
+
96
+/// Create a channel for daemon commands
97
+pub fn command_channel() -> (mpsc::Sender<DaemonCommand>, mpsc::Receiver<DaemonCommand>) {
98
+    mpsc::channel(16)
99
+}
gardmd/src/lib.rsadded
@@ -0,0 +1,9 @@
1
+//! gardmd - gar display manager daemon
2
+//!
3
+//! Handles PAM authentication, X11 server management, and session launching.
4
+
5
+pub mod config;
6
+pub mod ipc;
7
+
8
+pub use config::Config;
9
+pub use ipc::{ClientConnection, Server};
gardmd/src/main.rsadded
@@ -0,0 +1,174 @@
1
+//! gardmd - gar display manager daemon
2
+//!
3
+//! Main entry point with signal handling and systemd integration.
4
+
5
+use anyhow::Result;
6
+use gardmd::{config::Config, ipc};
7
+use tokio::signal::unix::{signal, SignalKind};
8
+use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt, EnvFilter};
9
+
10
+#[tokio::main]
11
+async fn main() -> Result<()> {
12
+    // Initialize logging
13
+    tracing_subscriber::registry()
14
+        .with(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
15
+        .with(tracing_subscriber::fmt::layer())
16
+        .init();
17
+
18
+    tracing::info!("gardmd starting");
19
+
20
+    // Load configuration
21
+    let config = Config::load()?;
22
+    tracing::debug!(?config, "Loaded configuration");
23
+
24
+    // Create IPC server
25
+    let server = ipc::Server::new().await?;
26
+
27
+    // Set up signal handlers
28
+    let mut sigterm = signal(SignalKind::terminate())?;
29
+    let mut sigint = signal(SignalKind::interrupt())?;
30
+    let mut sighup = signal(SignalKind::hangup())?;
31
+
32
+    // Notify systemd we're ready
33
+    if let Err(e) = sd_notify::notify(true, &[sd_notify::NotifyState::Ready]) {
34
+        tracing::warn!("Failed to notify systemd: {}", e);
35
+    }
36
+
37
+    tracing::info!("gardmd ready");
38
+
39
+    // Main event loop
40
+    loop {
41
+        tokio::select! {
42
+            // Handle new greeter connections
43
+            result = server.accept() => {
44
+                match result {
45
+                    Ok(conn) => {
46
+                        tokio::spawn(handle_client(conn, config.clone()));
47
+                    }
48
+                    Err(e) => {
49
+                        tracing::error!("Failed to accept connection: {}", e);
50
+                    }
51
+                }
52
+            }
53
+
54
+            // Handle SIGTERM
55
+            _ = sigterm.recv() => {
56
+                tracing::info!("Received SIGTERM, shutting down");
57
+                break;
58
+            }
59
+
60
+            // Handle SIGINT
61
+            _ = sigint.recv() => {
62
+                tracing::info!("Received SIGINT, shutting down");
63
+                break;
64
+            }
65
+
66
+            // Handle SIGHUP (reload config)
67
+            _ = sighup.recv() => {
68
+                tracing::info!("Received SIGHUP, reloading config");
69
+                // TODO: Implement config reload
70
+            }
71
+        }
72
+    }
73
+
74
+    // Notify systemd we're stopping
75
+    let _ = sd_notify::notify(true, &[sd_notify::NotifyState::Stopping]);
76
+
77
+    tracing::info!("gardmd stopped");
78
+    Ok(())
79
+}
80
+
81
+/// Handle a connected greeter client
82
+async fn handle_client(mut conn: ipc::ClientConnection, _config: Config) {
83
+    loop {
84
+        match conn.recv().await {
85
+            Ok(Some(request)) => {
86
+                let response = handle_request(request).await;
87
+                if let Err(e) = conn.send(&response).await {
88
+                    tracing::error!("Failed to send response: {}", e);
89
+                    break;
90
+                }
91
+            }
92
+            Ok(None) => {
93
+                tracing::debug!("Greeter disconnected");
94
+                break;
95
+            }
96
+            Err(e) => {
97
+                tracing::error!("Error receiving request: {}", e);
98
+                break;
99
+            }
100
+        }
101
+    }
102
+}
103
+
104
+/// Process a request and return a response
105
+async fn handle_request(request: gardm_ipc::Request) -> gardm_ipc::Response {
106
+    use gardm_ipc::{Request, Response};
107
+
108
+    match request {
109
+        Request::CreateSession { username } => {
110
+            tracing::info!("Creating session for user: {}", username);
111
+            // TODO: Implement PAM session creation
112
+            Response::AuthPrompt {
113
+                prompt: "Password:".to_string(),
114
+                echo: false,
115
+            }
116
+        }
117
+
118
+        Request::Authenticate { response: _ } => {
119
+            // TODO: Implement PAM authentication
120
+            Response::Error {
121
+                message: "PAM not yet implemented".to_string(),
122
+            }
123
+        }
124
+
125
+        Request::StartSession { cmd, env } => {
126
+            tracing::info!("Start session request: {:?} env={:?}", cmd, env);
127
+            // TODO: Implement session start
128
+            Response::Error {
129
+                message: "Session start not yet implemented".to_string(),
130
+            }
131
+        }
132
+
133
+        Request::CancelSession => {
134
+            tracing::info!("Session cancelled");
135
+            Response::Success
136
+        }
137
+
138
+        Request::Shutdown => {
139
+            tracing::info!("Shutdown requested");
140
+            // TODO: Implement via logind
141
+            Response::Error {
142
+                message: "Shutdown not yet implemented".to_string(),
143
+            }
144
+        }
145
+
146
+        Request::Reboot => {
147
+            tracing::info!("Reboot requested");
148
+            // TODO: Implement via logind
149
+            Response::Error {
150
+                message: "Reboot not yet implemented".to_string(),
151
+            }
152
+        }
153
+
154
+        Request::Suspend => {
155
+            tracing::info!("Suspend requested");
156
+            // TODO: Implement via logind
157
+            Response::Error {
158
+                message: "Suspend not yet implemented".to_string(),
159
+            }
160
+        }
161
+
162
+        Request::ListSessions => {
163
+            tracing::debug!("Listing sessions");
164
+            // TODO: Enumerate /usr/share/xsessions and /usr/share/wayland-sessions
165
+            Response::Sessions { sessions: vec![] }
166
+        }
167
+
168
+        Request::ListUsers => {
169
+            tracing::debug!("Listing users");
170
+            // TODO: Enumerate users from /etc/passwd
171
+            Response::Users { users: vec![] }
172
+        }
173
+    }
174
+}