workspace skeleton and IPC protocol
Authored by
mfwolffe <wolffemf@dukes.jmu.edu>
- SHA
9d71c5e4a3d45bd8a8a2fa4bcd64e7f5ab195902- Parents
-
ab309fd - Tree
c2b6aa7
9d71c5e
9d71c5e4a3d45bd8a8a2fa4bcd64e7f5ab195902ab309fd
c2b6aa7| Status | File | + | - |
|---|---|---|---|
| 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 | +} | |