gardesk/garbg / 92945ac

Browse files

Implement garbg wallpaper daemon

- X11 wallpaper setting with image scaling
- Local, HTTP, and GitHub source providers
- Playlist state with shuffle/wrap-around
- Daemon mode with IPC and slideshow timer
- Optional gar workspace integration
- Systemd user service file
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
92945ac606a7c3d9a52d942f4bcabdb5d9f314d7
Parents
9157597
Tree
d16107d

38 changed files

StatusFile+-
M .gitignore 23 1
A Cargo.lock 2848 0
A Cargo.toml 70 0
A docs/phases/phase1-core-foundation.md 58 0
A docs/phases/phase2-animation-video.md 91 0
A docs/phases/phase3-remote-sources.md 114 0
A docs/phases/phase4-daemon-integration.md 116 0
A docs/phases/phase5-polish.md 157 0
A garbg.service 22 0
A garbg/Cargo.toml 47 0
A garbg/src/cache/disk.rs 189 0
A garbg/src/cache/memory.rs 82 0
A garbg/src/cache/mod.rs 9 0
A garbg/src/config/mod.rs 42 0
A garbg/src/config/types.rs 255 0
A garbg/src/daemon/mod.rs 7 0
A garbg/src/daemon/state.rs 606 0
A garbg/src/ipc/client.rs 63 0
A garbg/src/ipc/gar_client.rs 89 0
A garbg/src/ipc/mod.rs 13 0
A garbg/src/ipc/protocol.rs 152 0
A garbg/src/ipc/server.rs 135 0
A garbg/src/lib.rs 24 0
A garbg/src/main.rs 673 0
A garbg/src/media/loader.rs 58 0
A garbg/src/media/mod.rs 12 0
A garbg/src/media/scaler.rs 170 0
A garbg/src/sources/directory.rs 158 0
A garbg/src/sources/file.rs 115 0
A garbg/src/sources/github.rs 165 0
A garbg/src/sources/http.rs 98 0
A garbg/src/sources/mod.rs 20 0
A garbg/src/sources/provider.rs 110 0
A garbg/src/state.rs 191 0
A garbg/src/x11/connection.rs 227 0
A garbg/src/x11/mod.rs 12 0
A garbg/src/x11/monitors.rs 29 0
A garbg/src/x11/renderer.rs 29 0
.gitignoremodified
@@ -1,1 +1,23 @@
1
-docs/
1
+# Build artifacts
2
+/target/
3
+**/target/
4
+
5
+# Cargo lock for binaries is committed, but generated files are not
6
+# Cargo.lock
7
+
8
+# Editor files
9
+*.swp
10
+*.swo
11
+*~
12
+.idea/
13
+.vscode/
14
+
15
+# OS files
16
+.DS_Store
17
+Thumbs.db
18
+
19
+# Logs
20
+*.log
21
+
22
+# Local config overrides
23
+config.local.toml
Cargo.lockadded
2848 lines changed — click to load
@@ -0,0 +1,2848 @@
1
+# This file is automatically @generated by Cargo.
2
+# It is not intended for manual editing.
3
+version = 4
4
+
5
+[[package]]
6
+name = "adler2"
7
+version = "2.0.1"
8
+source = "registry+https://github.com/rust-lang/crates.io-index"
9
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
10
+
11
+[[package]]
12
+name = "ahash"
13
+version = "0.8.12"
14
+source = "registry+https://github.com/rust-lang/crates.io-index"
15
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
16
+dependencies = [
17
+ "cfg-if",
18
+ "getrandom 0.3.4",
19
+ "once_cell",
20
+ "version_check",
21
+ "zerocopy",
22
+]
23
+
24
+[[package]]
25
+name = "aho-corasick"
26
+version = "1.1.4"
27
+source = "registry+https://github.com/rust-lang/crates.io-index"
28
+checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
29
+dependencies = [
30
+ "memchr",
31
+]
32
+
33
+[[package]]
34
+name = "allocator-api2"
35
+version = "0.2.21"
36
+source = "registry+https://github.com/rust-lang/crates.io-index"
37
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
38
+
39
+[[package]]
40
+name = "android_system_properties"
41
+version = "0.1.5"
42
+source = "registry+https://github.com/rust-lang/crates.io-index"
43
+checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311"
44
+dependencies = [
45
+ "libc",
46
+]
47
+
48
+[[package]]
49
+name = "anstream"
50
+version = "0.6.21"
51
+source = "registry+https://github.com/rust-lang/crates.io-index"
52
+checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
53
+dependencies = [
54
+ "anstyle",
55
+ "anstyle-parse",
56
+ "anstyle-query",
57
+ "anstyle-wincon",
58
+ "colorchoice",
59
+ "is_terminal_polyfill",
60
+ "utf8parse",
61
+]
62
+
63
+[[package]]
64
+name = "anstyle"
65
+version = "1.0.13"
66
+source = "registry+https://github.com/rust-lang/crates.io-index"
67
+checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
68
+
69
+[[package]]
70
+name = "anstyle-parse"
71
+version = "0.2.7"
72
+source = "registry+https://github.com/rust-lang/crates.io-index"
73
+checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
74
+dependencies = [
75
+ "utf8parse",
76
+]
77
+
78
+[[package]]
79
+name = "anstyle-query"
80
+version = "1.1.5"
81
+source = "registry+https://github.com/rust-lang/crates.io-index"
82
+checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
83
+dependencies = [
84
+ "windows-sys 0.61.2",
85
+]
86
+
87
+[[package]]
88
+name = "anstyle-wincon"
89
+version = "3.0.11"
90
+source = "registry+https://github.com/rust-lang/crates.io-index"
91
+checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
92
+dependencies = [
93
+ "anstyle",
94
+ "once_cell_polyfill",
95
+ "windows-sys 0.61.2",
96
+]
97
+
98
+[[package]]
99
+name = "anyhow"
100
+version = "1.0.100"
101
+source = "registry+https://github.com/rust-lang/crates.io-index"
102
+checksum = "a23eb6b1614318a8071c9b2521f36b424b2c83db5eb3a0fead4a6c0809af6e61"
103
+
104
+[[package]]
105
+name = "arrayref"
106
+version = "0.3.9"
107
+source = "registry+https://github.com/rust-lang/crates.io-index"
108
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
109
+
110
+[[package]]
111
+name = "arrayvec"
112
+version = "0.7.6"
113
+source = "registry+https://github.com/rust-lang/crates.io-index"
114
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
115
+
116
+[[package]]
117
+name = "as-raw-xcb-connection"
118
+version = "1.0.1"
119
+source = "registry+https://github.com/rust-lang/crates.io-index"
120
+checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b"
121
+
122
+[[package]]
123
+name = "async-trait"
124
+version = "0.1.89"
125
+source = "registry+https://github.com/rust-lang/crates.io-index"
126
+checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb"
127
+dependencies = [
128
+ "proc-macro2",
129
+ "quote",
130
+ "syn",
131
+]
132
+
133
+[[package]]
134
+name = "atomic-waker"
135
+version = "1.1.2"
136
+source = "registry+https://github.com/rust-lang/crates.io-index"
137
+checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
138
+
139
+[[package]]
140
+name = "autocfg"
141
+version = "1.5.0"
142
+source = "registry+https://github.com/rust-lang/crates.io-index"
143
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
144
+
145
+[[package]]
146
+name = "base64"
147
+version = "0.22.1"
148
+source = "registry+https://github.com/rust-lang/crates.io-index"
149
+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
150
+
151
+[[package]]
152
+name = "bitflags"
153
+version = "2.10.0"
154
+source = "registry+https://github.com/rust-lang/crates.io-index"
155
+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
156
+
157
+[[package]]
158
+name = "blake3"
159
+version = "1.8.3"
160
+source = "registry+https://github.com/rust-lang/crates.io-index"
161
+checksum = "2468ef7d57b3fb7e16b576e8377cdbde2320c60e1491e961d11da40fc4f02a2d"
162
+dependencies = [
163
+ "arrayref",
164
+ "arrayvec",
165
+ "cc",
166
+ "cfg-if",
167
+ "constant_time_eq",
168
+ "cpufeatures",
169
+]
170
+
171
+[[package]]
172
+name = "bumpalo"
173
+version = "3.19.1"
174
+source = "registry+https://github.com/rust-lang/crates.io-index"
175
+checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
176
+
177
+[[package]]
178
+name = "bytemuck"
179
+version = "1.24.0"
180
+source = "registry+https://github.com/rust-lang/crates.io-index"
181
+checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
182
+
183
+[[package]]
184
+name = "byteorder"
185
+version = "1.5.0"
186
+source = "registry+https://github.com/rust-lang/crates.io-index"
187
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
188
+
189
+[[package]]
190
+name = "byteorder-lite"
191
+version = "0.1.0"
192
+source = "registry+https://github.com/rust-lang/crates.io-index"
193
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
194
+
195
+[[package]]
196
+name = "bytes"
197
+version = "1.11.0"
198
+source = "registry+https://github.com/rust-lang/crates.io-index"
199
+checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
200
+
201
+[[package]]
202
+name = "cc"
203
+version = "1.2.52"
204
+source = "registry+https://github.com/rust-lang/crates.io-index"
205
+checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
206
+dependencies = [
207
+ "find-msvc-tools",
208
+ "shlex",
209
+]
210
+
211
+[[package]]
212
+name = "cfg-if"
213
+version = "1.0.4"
214
+source = "registry+https://github.com/rust-lang/crates.io-index"
215
+checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
216
+
217
+[[package]]
218
+name = "chrono"
219
+version = "0.4.42"
220
+source = "registry+https://github.com/rust-lang/crates.io-index"
221
+checksum = "145052bdd345b87320e369255277e3fb5152762ad123a901ef5c262dd38fe8d2"
222
+dependencies = [
223
+ "iana-time-zone",
224
+ "js-sys",
225
+ "num-traits",
226
+ "serde",
227
+ "wasm-bindgen",
228
+ "windows-link",
229
+]
230
+
231
+[[package]]
232
+name = "clap"
233
+version = "4.5.54"
234
+source = "registry+https://github.com/rust-lang/crates.io-index"
235
+checksum = "c6e6ff9dcd79cff5cd969a17a545d79e84ab086e444102a591e288a8aa3ce394"
236
+dependencies = [
237
+ "clap_builder",
238
+ "clap_derive",
239
+]
240
+
241
+[[package]]
242
+name = "clap_builder"
243
+version = "4.5.54"
244
+source = "registry+https://github.com/rust-lang/crates.io-index"
245
+checksum = "fa42cf4d2b7a41bc8f663a7cab4031ebafa1bf3875705bfaf8466dc60ab52c00"
246
+dependencies = [
247
+ "anstream",
248
+ "anstyle",
249
+ "clap_lex",
250
+ "strsim",
251
+]
252
+
253
+[[package]]
254
+name = "clap_derive"
255
+version = "4.5.49"
256
+source = "registry+https://github.com/rust-lang/crates.io-index"
257
+checksum = "2a0b5487afeab2deb2ff4e03a807ad1a03ac532ff5a2cee5d86884440c7f7671"
258
+dependencies = [
259
+ "heck",
260
+ "proc-macro2",
261
+ "quote",
262
+ "syn",
263
+]
264
+
265
+[[package]]
266
+name = "clap_lex"
267
+version = "0.7.6"
268
+source = "registry+https://github.com/rust-lang/crates.io-index"
269
+checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d"
270
+
271
+[[package]]
272
+name = "color_quant"
273
+version = "1.1.0"
274
+source = "registry+https://github.com/rust-lang/crates.io-index"
275
+checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
276
+
277
+[[package]]
278
+name = "colorchoice"
279
+version = "1.0.4"
280
+source = "registry+https://github.com/rust-lang/crates.io-index"
281
+checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
282
+
283
+[[package]]
284
+name = "constant_time_eq"
285
+version = "0.4.2"
286
+source = "registry+https://github.com/rust-lang/crates.io-index"
287
+checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
288
+
289
+[[package]]
290
+name = "core-foundation"
291
+version = "0.9.4"
292
+source = "registry+https://github.com/rust-lang/crates.io-index"
293
+checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
294
+dependencies = [
295
+ "core-foundation-sys",
296
+ "libc",
297
+]
298
+
299
+[[package]]
300
+name = "core-foundation-sys"
301
+version = "0.8.7"
302
+source = "registry+https://github.com/rust-lang/crates.io-index"
303
+checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
304
+
305
+[[package]]
306
+name = "cpufeatures"
307
+version = "0.2.17"
308
+source = "registry+https://github.com/rust-lang/crates.io-index"
309
+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
310
+dependencies = [
311
+ "libc",
312
+]
313
+
314
+[[package]]
315
+name = "crc32fast"
316
+version = "1.5.0"
317
+source = "registry+https://github.com/rust-lang/crates.io-index"
318
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
319
+dependencies = [
320
+ "cfg-if",
321
+]
322
+
323
+[[package]]
324
+name = "crossbeam-channel"
325
+version = "0.5.15"
326
+source = "registry+https://github.com/rust-lang/crates.io-index"
327
+checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
328
+dependencies = [
329
+ "crossbeam-utils",
330
+]
331
+
332
+[[package]]
333
+name = "crossbeam-utils"
334
+version = "0.8.21"
335
+source = "registry+https://github.com/rust-lang/crates.io-index"
336
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
337
+
338
+[[package]]
339
+name = "cssparser"
340
+version = "0.31.2"
341
+source = "registry+https://github.com/rust-lang/crates.io-index"
342
+checksum = "5b3df4f93e5fbbe73ec01ec8d3f68bba73107993a5b1e7519273c32db9b0d5be"
343
+dependencies = [
344
+ "cssparser-macros",
345
+ "dtoa-short",
346
+ "itoa",
347
+ "phf 0.11.3",
348
+ "smallvec",
349
+]
350
+
351
+[[package]]
352
+name = "cssparser-macros"
353
+version = "0.6.1"
354
+source = "registry+https://github.com/rust-lang/crates.io-index"
355
+checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331"
356
+dependencies = [
357
+ "quote",
358
+ "syn",
359
+]
360
+
361
+[[package]]
362
+name = "derive_more"
363
+version = "0.99.20"
364
+source = "registry+https://github.com/rust-lang/crates.io-index"
365
+checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f"
366
+dependencies = [
367
+ "proc-macro2",
368
+ "quote",
369
+ "syn",
370
+]
371
+
372
+[[package]]
373
+name = "dirs"
374
+version = "6.0.0"
375
+source = "registry+https://github.com/rust-lang/crates.io-index"
376
+checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e"
377
+dependencies = [
378
+ "dirs-sys",
379
+]
380
+
381
+[[package]]
382
+name = "dirs-sys"
383
+version = "0.5.0"
384
+source = "registry+https://github.com/rust-lang/crates.io-index"
385
+checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab"
386
+dependencies = [
387
+ "libc",
388
+ "option-ext",
389
+ "redox_users",
390
+ "windows-sys 0.61.2",
391
+]
392
+
393
+[[package]]
394
+name = "displaydoc"
395
+version = "0.2.5"
396
+source = "registry+https://github.com/rust-lang/crates.io-index"
397
+checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
398
+dependencies = [
399
+ "proc-macro2",
400
+ "quote",
401
+ "syn",
402
+]
403
+
404
+[[package]]
405
+name = "dtoa"
406
+version = "1.0.11"
407
+source = "registry+https://github.com/rust-lang/crates.io-index"
408
+checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
409
+
410
+[[package]]
411
+name = "dtoa-short"
412
+version = "0.3.5"
413
+source = "registry+https://github.com/rust-lang/crates.io-index"
414
+checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
415
+dependencies = [
416
+ "dtoa",
417
+]
418
+
419
+[[package]]
420
+name = "ego-tree"
421
+version = "0.6.3"
422
+source = "registry+https://github.com/rust-lang/crates.io-index"
423
+checksum = "12a0bb14ac04a9fcf170d0bbbef949b44cc492f4452bd20c095636956f653642"
424
+
425
+[[package]]
426
+name = "encoding_rs"
427
+version = "0.8.35"
428
+source = "registry+https://github.com/rust-lang/crates.io-index"
429
+checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3"
430
+dependencies = [
431
+ "cfg-if",
432
+]
433
+
434
+[[package]]
435
+name = "equivalent"
436
+version = "1.0.2"
437
+source = "registry+https://github.com/rust-lang/crates.io-index"
438
+checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
439
+
440
+[[package]]
441
+name = "errno"
442
+version = "0.3.14"
443
+source = "registry+https://github.com/rust-lang/crates.io-index"
444
+checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
445
+dependencies = [
446
+ "libc",
447
+ "windows-sys 0.61.2",
448
+]
449
+
450
+[[package]]
451
+name = "fastrand"
452
+version = "2.3.0"
453
+source = "registry+https://github.com/rust-lang/crates.io-index"
454
+checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
455
+
456
+[[package]]
457
+name = "fdeflate"
458
+version = "0.3.7"
459
+source = "registry+https://github.com/rust-lang/crates.io-index"
460
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
461
+dependencies = [
462
+ "simd-adler32",
463
+]
464
+
465
+[[package]]
466
+name = "find-msvc-tools"
467
+version = "0.1.7"
468
+source = "registry+https://github.com/rust-lang/crates.io-index"
469
+checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
470
+
471
+[[package]]
472
+name = "flate2"
473
+version = "1.1.8"
474
+source = "registry+https://github.com/rust-lang/crates.io-index"
475
+checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
476
+dependencies = [
477
+ "crc32fast",
478
+ "miniz_oxide",
479
+]
480
+
481
+[[package]]
482
+name = "fnv"
483
+version = "1.0.7"
484
+source = "registry+https://github.com/rust-lang/crates.io-index"
485
+checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
486
+
487
+[[package]]
488
+name = "foldhash"
489
+version = "0.1.5"
490
+source = "registry+https://github.com/rust-lang/crates.io-index"
491
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
492
+
493
+[[package]]
494
+name = "foreign-types"
495
+version = "0.3.2"
496
+source = "registry+https://github.com/rust-lang/crates.io-index"
497
+checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
498
+dependencies = [
499
+ "foreign-types-shared",
500
+]
501
+
502
+[[package]]
503
+name = "foreign-types-shared"
504
+version = "0.1.1"
505
+source = "registry+https://github.com/rust-lang/crates.io-index"
506
+checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
507
+
508
+[[package]]
509
+name = "form_urlencoded"
510
+version = "1.2.2"
511
+source = "registry+https://github.com/rust-lang/crates.io-index"
512
+checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf"
513
+dependencies = [
514
+ "percent-encoding",
515
+]
516
+
517
+[[package]]
518
+name = "futf"
519
+version = "0.1.5"
520
+source = "registry+https://github.com/rust-lang/crates.io-index"
521
+checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
522
+dependencies = [
523
+ "mac",
524
+ "new_debug_unreachable",
525
+]
526
+
527
+[[package]]
528
+name = "futures-channel"
529
+version = "0.3.31"
530
+source = "registry+https://github.com/rust-lang/crates.io-index"
531
+checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10"
532
+dependencies = [
533
+ "futures-core",
534
+ "futures-sink",
535
+]
536
+
537
+[[package]]
538
+name = "futures-core"
539
+version = "0.3.31"
540
+source = "registry+https://github.com/rust-lang/crates.io-index"
541
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
542
+
543
+[[package]]
544
+name = "futures-io"
545
+version = "0.3.31"
546
+source = "registry+https://github.com/rust-lang/crates.io-index"
547
+checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6"
548
+
549
+[[package]]
550
+name = "futures-macro"
551
+version = "0.3.31"
552
+source = "registry+https://github.com/rust-lang/crates.io-index"
553
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
554
+dependencies = [
555
+ "proc-macro2",
556
+ "quote",
557
+ "syn",
558
+]
559
+
560
+[[package]]
561
+name = "futures-sink"
562
+version = "0.3.31"
563
+source = "registry+https://github.com/rust-lang/crates.io-index"
564
+checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7"
565
+
566
+[[package]]
567
+name = "futures-task"
568
+version = "0.3.31"
569
+source = "registry+https://github.com/rust-lang/crates.io-index"
570
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
571
+
572
+[[package]]
573
+name = "futures-util"
574
+version = "0.3.31"
575
+source = "registry+https://github.com/rust-lang/crates.io-index"
576
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
577
+dependencies = [
578
+ "futures-core",
579
+ "futures-io",
580
+ "futures-macro",
581
+ "futures-sink",
582
+ "futures-task",
583
+ "memchr",
584
+ "pin-project-lite",
585
+ "pin-utils",
586
+ "slab",
587
+]
588
+
589
+[[package]]
590
+name = "fxhash"
591
+version = "0.2.1"
592
+source = "registry+https://github.com/rust-lang/crates.io-index"
593
+checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c"
594
+dependencies = [
595
+ "byteorder",
596
+]
597
+
598
+[[package]]
599
+name = "garbg"
600
+version = "0.1.0"
601
+dependencies = [
602
+ "anyhow",
603
+ "async-trait",
604
+ "blake3",
605
+ "chrono",
606
+ "clap",
607
+ "crossbeam-channel",
608
+ "dirs",
609
+ "humantime",
610
+ "image",
611
+ "lru",
612
+ "rand",
613
+ "reqwest",
614
+ "scraper",
615
+ "serde",
616
+ "serde_json",
617
+ "shellexpand",
618
+ "thiserror",
619
+ "tokio",
620
+ "toml",
621
+ "tracing",
622
+ "tracing-subscriber",
623
+ "url",
624
+ "x11rb",
625
+]
626
+
627
+[[package]]
628
+name = "gethostname"
629
+version = "1.1.0"
630
+source = "registry+https://github.com/rust-lang/crates.io-index"
631
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
632
+dependencies = [
633
+ "rustix",
634
+ "windows-link",
635
+]
636
+
637
+[[package]]
638
+name = "getopts"
639
+version = "0.2.24"
640
+source = "registry+https://github.com/rust-lang/crates.io-index"
641
+checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df"
642
+dependencies = [
643
+ "unicode-width",
644
+]
645
+
646
+[[package]]
647
+name = "getrandom"
648
+version = "0.2.17"
649
+source = "registry+https://github.com/rust-lang/crates.io-index"
650
+checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
651
+dependencies = [
652
+ "cfg-if",
653
+ "libc",
654
+ "wasi",
655
+]
656
+
657
+[[package]]
658
+name = "getrandom"
659
+version = "0.3.4"
660
+source = "registry+https://github.com/rust-lang/crates.io-index"
661
+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
662
+dependencies = [
663
+ "cfg-if",
664
+ "libc",
665
+ "r-efi",
666
+ "wasip2",
667
+]
668
+
669
+[[package]]
670
+name = "gif"
671
+version = "0.14.1"
672
+source = "registry+https://github.com/rust-lang/crates.io-index"
673
+checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
674
+dependencies = [
675
+ "color_quant",
676
+ "weezl",
677
+]
678
+
679
+[[package]]
680
+name = "h2"
681
+version = "0.4.13"
682
+source = "registry+https://github.com/rust-lang/crates.io-index"
683
+checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54"
684
+dependencies = [
685
+ "atomic-waker",
686
+ "bytes",
687
+ "fnv",
688
+ "futures-core",
689
+ "futures-sink",
690
+ "http",
691
+ "indexmap",
692
+ "slab",
693
+ "tokio",
694
+ "tokio-util",
695
+ "tracing",
696
+]
697
+
698
+[[package]]
699
+name = "hashbrown"
700
+version = "0.15.5"
701
+source = "registry+https://github.com/rust-lang/crates.io-index"
702
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
703
+dependencies = [
704
+ "allocator-api2",
705
+ "equivalent",
706
+ "foldhash",
707
+]
708
+
709
+[[package]]
710
+name = "hashbrown"
711
+version = "0.16.1"
712
+source = "registry+https://github.com/rust-lang/crates.io-index"
713
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
714
+
715
+[[package]]
716
+name = "heck"
717
+version = "0.5.0"
718
+source = "registry+https://github.com/rust-lang/crates.io-index"
719
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
720
+
721
+[[package]]
722
+name = "html5ever"
723
+version = "0.27.0"
724
+source = "registry+https://github.com/rust-lang/crates.io-index"
725
+checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
726
+dependencies = [
727
+ "log",
728
+ "mac",
729
+ "markup5ever",
730
+ "proc-macro2",
731
+ "quote",
732
+ "syn",
733
+]
734
+
735
+[[package]]
736
+name = "http"
737
+version = "1.4.0"
738
+source = "registry+https://github.com/rust-lang/crates.io-index"
739
+checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
740
+dependencies = [
741
+ "bytes",
742
+ "itoa",
743
+]
744
+
745
+[[package]]
746
+name = "http-body"
747
+version = "1.0.1"
748
+source = "registry+https://github.com/rust-lang/crates.io-index"
749
+checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184"
750
+dependencies = [
751
+ "bytes",
752
+ "http",
753
+]
754
+
755
+[[package]]
756
+name = "http-body-util"
757
+version = "0.1.3"
758
+source = "registry+https://github.com/rust-lang/crates.io-index"
759
+checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a"
760
+dependencies = [
761
+ "bytes",
762
+ "futures-core",
763
+ "http",
764
+ "http-body",
765
+ "pin-project-lite",
766
+]
767
+
768
+[[package]]
769
+name = "httparse"
770
+version = "1.10.1"
771
+source = "registry+https://github.com/rust-lang/crates.io-index"
772
+checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
773
+
774
+[[package]]
775
+name = "humantime"
776
+version = "2.3.0"
777
+source = "registry+https://github.com/rust-lang/crates.io-index"
778
+checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424"
779
+
780
+[[package]]
781
+name = "hyper"
782
+version = "1.8.1"
783
+source = "registry+https://github.com/rust-lang/crates.io-index"
784
+checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11"
785
+dependencies = [
786
+ "atomic-waker",
787
+ "bytes",
788
+ "futures-channel",
789
+ "futures-core",
790
+ "h2",
791
+ "http",
792
+ "http-body",
793
+ "httparse",
794
+ "itoa",
795
+ "pin-project-lite",
796
+ "pin-utils",
797
+ "smallvec",
798
+ "tokio",
799
+ "want",
800
+]
801
+
802
+[[package]]
803
+name = "hyper-rustls"
804
+version = "0.27.7"
805
+source = "registry+https://github.com/rust-lang/crates.io-index"
806
+checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58"
807
+dependencies = [
808
+ "http",
809
+ "hyper",
810
+ "hyper-util",
811
+ "rustls",
812
+ "rustls-pki-types",
813
+ "tokio",
814
+ "tokio-rustls",
815
+ "tower-service",
816
+]
817
+
818
+[[package]]
819
+name = "hyper-tls"
820
+version = "0.6.0"
821
+source = "registry+https://github.com/rust-lang/crates.io-index"
822
+checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0"
823
+dependencies = [
824
+ "bytes",
825
+ "http-body-util",
826
+ "hyper",
827
+ "hyper-util",
828
+ "native-tls",
829
+ "tokio",
830
+ "tokio-native-tls",
831
+ "tower-service",
832
+]
833
+
834
+[[package]]
835
+name = "hyper-util"
836
+version = "0.1.19"
837
+source = "registry+https://github.com/rust-lang/crates.io-index"
838
+checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f"
839
+dependencies = [
840
+ "base64",
841
+ "bytes",
842
+ "futures-channel",
843
+ "futures-core",
844
+ "futures-util",
845
+ "http",
846
+ "http-body",
847
+ "hyper",
848
+ "ipnet",
849
+ "libc",
850
+ "percent-encoding",
851
+ "pin-project-lite",
852
+ "socket2",
853
+ "system-configuration",
854
+ "tokio",
855
+ "tower-service",
856
+ "tracing",
857
+ "windows-registry",
858
+]
859
+
860
+[[package]]
861
+name = "iana-time-zone"
862
+version = "0.1.64"
863
+source = "registry+https://github.com/rust-lang/crates.io-index"
864
+checksum = "33e57f83510bb73707521ebaffa789ec8caf86f9657cad665b092b581d40e9fb"
865
+dependencies = [
866
+ "android_system_properties",
867
+ "core-foundation-sys",
868
+ "iana-time-zone-haiku",
869
+ "js-sys",
870
+ "log",
871
+ "wasm-bindgen",
872
+ "windows-core",
873
+]
874
+
875
+[[package]]
876
+name = "iana-time-zone-haiku"
877
+version = "0.1.2"
878
+source = "registry+https://github.com/rust-lang/crates.io-index"
879
+checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
880
+dependencies = [
881
+ "cc",
882
+]
883
+
884
+[[package]]
885
+name = "icu_collections"
886
+version = "2.1.1"
887
+source = "registry+https://github.com/rust-lang/crates.io-index"
888
+checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43"
889
+dependencies = [
890
+ "displaydoc",
891
+ "potential_utf",
892
+ "yoke",
893
+ "zerofrom",
894
+ "zerovec",
895
+]
896
+
897
+[[package]]
898
+name = "icu_locale_core"
899
+version = "2.1.1"
900
+source = "registry+https://github.com/rust-lang/crates.io-index"
901
+checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6"
902
+dependencies = [
903
+ "displaydoc",
904
+ "litemap",
905
+ "tinystr",
906
+ "writeable",
907
+ "zerovec",
908
+]
909
+
910
+[[package]]
911
+name = "icu_normalizer"
912
+version = "2.1.1"
913
+source = "registry+https://github.com/rust-lang/crates.io-index"
914
+checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599"
915
+dependencies = [
916
+ "icu_collections",
917
+ "icu_normalizer_data",
918
+ "icu_properties",
919
+ "icu_provider",
920
+ "smallvec",
921
+ "zerovec",
922
+]
923
+
924
+[[package]]
925
+name = "icu_normalizer_data"
926
+version = "2.1.1"
927
+source = "registry+https://github.com/rust-lang/crates.io-index"
928
+checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a"
929
+
930
+[[package]]
931
+name = "icu_properties"
932
+version = "2.1.2"
933
+source = "registry+https://github.com/rust-lang/crates.io-index"
934
+checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec"
935
+dependencies = [
936
+ "icu_collections",
937
+ "icu_locale_core",
938
+ "icu_properties_data",
939
+ "icu_provider",
940
+ "zerotrie",
941
+ "zerovec",
942
+]
943
+
944
+[[package]]
945
+name = "icu_properties_data"
946
+version = "2.1.2"
947
+source = "registry+https://github.com/rust-lang/crates.io-index"
948
+checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af"
949
+
950
+[[package]]
951
+name = "icu_provider"
952
+version = "2.1.1"
953
+source = "registry+https://github.com/rust-lang/crates.io-index"
954
+checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614"
955
+dependencies = [
956
+ "displaydoc",
957
+ "icu_locale_core",
958
+ "writeable",
959
+ "yoke",
960
+ "zerofrom",
961
+ "zerotrie",
962
+ "zerovec",
963
+]
964
+
965
+[[package]]
966
+name = "idna"
967
+version = "1.1.0"
968
+source = "registry+https://github.com/rust-lang/crates.io-index"
969
+checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de"
970
+dependencies = [
971
+ "idna_adapter",
972
+ "smallvec",
973
+ "utf8_iter",
974
+]
975
+
976
+[[package]]
977
+name = "idna_adapter"
978
+version = "1.2.1"
979
+source = "registry+https://github.com/rust-lang/crates.io-index"
980
+checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344"
981
+dependencies = [
982
+ "icu_normalizer",
983
+ "icu_properties",
984
+]
985
+
986
+[[package]]
987
+name = "image"
988
+version = "0.25.9"
989
+source = "registry+https://github.com/rust-lang/crates.io-index"
990
+checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
991
+dependencies = [
992
+ "bytemuck",
993
+ "byteorder-lite",
994
+ "color_quant",
995
+ "gif",
996
+ "image-webp",
997
+ "moxcms",
998
+ "num-traits",
999
+ "png",
1000
+ "zune-core",
1001
+ "zune-jpeg",
1002
+]
1003
+
1004
+[[package]]
1005
+name = "image-webp"
1006
+version = "0.2.4"
1007
+source = "registry+https://github.com/rust-lang/crates.io-index"
1008
+checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
1009
+dependencies = [
1010
+ "byteorder-lite",
1011
+ "quick-error",
1012
+]
1013
+
1014
+[[package]]
1015
+name = "indexmap"
1016
+version = "2.13.0"
1017
+source = "registry+https://github.com/rust-lang/crates.io-index"
1018
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
1019
+dependencies = [
1020
+ "equivalent",
1021
+ "hashbrown 0.16.1",
1022
+]
1023
+
1024
+[[package]]
1025
+name = "ipnet"
1026
+version = "2.11.0"
1027
+source = "registry+https://github.com/rust-lang/crates.io-index"
1028
+checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
1029
+
1030
+[[package]]
1031
+name = "iri-string"
1032
+version = "0.7.10"
1033
+source = "registry+https://github.com/rust-lang/crates.io-index"
1034
+checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
1035
+dependencies = [
1036
+ "memchr",
1037
+ "serde",
1038
+]
1039
+
1040
+[[package]]
1041
+name = "is_terminal_polyfill"
1042
+version = "1.70.2"
1043
+source = "registry+https://github.com/rust-lang/crates.io-index"
1044
+checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
1045
+
1046
+[[package]]
1047
+name = "itoa"
1048
+version = "1.0.17"
1049
+source = "registry+https://github.com/rust-lang/crates.io-index"
1050
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
1051
+
1052
+[[package]]
1053
+name = "js-sys"
1054
+version = "0.3.83"
1055
+source = "registry+https://github.com/rust-lang/crates.io-index"
1056
+checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
1057
+dependencies = [
1058
+ "once_cell",
1059
+ "wasm-bindgen",
1060
+]
1061
+
1062
+[[package]]
1063
+name = "lazy_static"
1064
+version = "1.5.0"
1065
+source = "registry+https://github.com/rust-lang/crates.io-index"
1066
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
1067
+
1068
+[[package]]
1069
+name = "libc"
1070
+version = "0.2.180"
1071
+source = "registry+https://github.com/rust-lang/crates.io-index"
1072
+checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
1073
+
1074
+[[package]]
1075
+name = "libredox"
1076
+version = "0.1.12"
1077
+source = "registry+https://github.com/rust-lang/crates.io-index"
1078
+checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
1079
+dependencies = [
1080
+ "bitflags",
1081
+ "libc",
1082
+]
1083
+
1084
+[[package]]
1085
+name = "linux-raw-sys"
1086
+version = "0.11.0"
1087
+source = "registry+https://github.com/rust-lang/crates.io-index"
1088
+checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
1089
+
1090
+[[package]]
1091
+name = "litemap"
1092
+version = "0.8.1"
1093
+source = "registry+https://github.com/rust-lang/crates.io-index"
1094
+checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77"
1095
+
1096
+[[package]]
1097
+name = "lock_api"
1098
+version = "0.4.14"
1099
+source = "registry+https://github.com/rust-lang/crates.io-index"
1100
+checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965"
1101
+dependencies = [
1102
+ "scopeguard",
1103
+]
1104
+
1105
+[[package]]
1106
+name = "log"
1107
+version = "0.4.29"
1108
+source = "registry+https://github.com/rust-lang/crates.io-index"
1109
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
1110
+
1111
+[[package]]
1112
+name = "lru"
1113
+version = "0.12.5"
1114
+source = "registry+https://github.com/rust-lang/crates.io-index"
1115
+checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38"
1116
+dependencies = [
1117
+ "hashbrown 0.15.5",
1118
+]
1119
+
1120
+[[package]]
1121
+name = "mac"
1122
+version = "0.1.1"
1123
+source = "registry+https://github.com/rust-lang/crates.io-index"
1124
+checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
1125
+
1126
+[[package]]
1127
+name = "markup5ever"
1128
+version = "0.12.1"
1129
+source = "registry+https://github.com/rust-lang/crates.io-index"
1130
+checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
1131
+dependencies = [
1132
+ "log",
1133
+ "phf 0.11.3",
1134
+ "phf_codegen 0.11.3",
1135
+ "string_cache",
1136
+ "string_cache_codegen",
1137
+ "tendril",
1138
+]
1139
+
1140
+[[package]]
1141
+name = "matchers"
1142
+version = "0.2.0"
1143
+source = "registry+https://github.com/rust-lang/crates.io-index"
1144
+checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
1145
+dependencies = [
1146
+ "regex-automata",
1147
+]
1148
+
1149
+[[package]]
1150
+name = "memchr"
1151
+version = "2.7.6"
1152
+source = "registry+https://github.com/rust-lang/crates.io-index"
1153
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
1154
+
1155
+[[package]]
1156
+name = "mime"
1157
+version = "0.3.17"
1158
+source = "registry+https://github.com/rust-lang/crates.io-index"
1159
+checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
1160
+
1161
+[[package]]
1162
+name = "miniz_oxide"
1163
+version = "0.8.9"
1164
+source = "registry+https://github.com/rust-lang/crates.io-index"
1165
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
1166
+dependencies = [
1167
+ "adler2",
1168
+ "simd-adler32",
1169
+]
1170
+
1171
+[[package]]
1172
+name = "mio"
1173
+version = "1.1.1"
1174
+source = "registry+https://github.com/rust-lang/crates.io-index"
1175
+checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
1176
+dependencies = [
1177
+ "libc",
1178
+ "wasi",
1179
+ "windows-sys 0.61.2",
1180
+]
1181
+
1182
+[[package]]
1183
+name = "moxcms"
1184
+version = "0.7.11"
1185
+source = "registry+https://github.com/rust-lang/crates.io-index"
1186
+checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
1187
+dependencies = [
1188
+ "num-traits",
1189
+ "pxfm",
1190
+]
1191
+
1192
+[[package]]
1193
+name = "native-tls"
1194
+version = "0.2.14"
1195
+source = "registry+https://github.com/rust-lang/crates.io-index"
1196
+checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
1197
+dependencies = [
1198
+ "libc",
1199
+ "log",
1200
+ "openssl",
1201
+ "openssl-probe",
1202
+ "openssl-sys",
1203
+ "schannel",
1204
+ "security-framework",
1205
+ "security-framework-sys",
1206
+ "tempfile",
1207
+]
1208
+
1209
+[[package]]
1210
+name = "new_debug_unreachable"
1211
+version = "1.0.6"
1212
+source = "registry+https://github.com/rust-lang/crates.io-index"
1213
+checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
1214
+
1215
+[[package]]
1216
+name = "nu-ansi-term"
1217
+version = "0.50.3"
1218
+source = "registry+https://github.com/rust-lang/crates.io-index"
1219
+checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
1220
+dependencies = [
1221
+ "windows-sys 0.61.2",
1222
+]
1223
+
1224
+[[package]]
1225
+name = "num-traits"
1226
+version = "0.2.19"
1227
+source = "registry+https://github.com/rust-lang/crates.io-index"
1228
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
1229
+dependencies = [
1230
+ "autocfg",
1231
+]
1232
+
1233
+[[package]]
1234
+name = "once_cell"
1235
+version = "1.21.3"
1236
+source = "registry+https://github.com/rust-lang/crates.io-index"
1237
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
1238
+
1239
+[[package]]
1240
+name = "once_cell_polyfill"
1241
+version = "1.70.2"
1242
+source = "registry+https://github.com/rust-lang/crates.io-index"
1243
+checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
1244
+
1245
+[[package]]
1246
+name = "openssl"
1247
+version = "0.10.75"
1248
+source = "registry+https://github.com/rust-lang/crates.io-index"
1249
+checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
1250
+dependencies = [
1251
+ "bitflags",
1252
+ "cfg-if",
1253
+ "foreign-types",
1254
+ "libc",
1255
+ "once_cell",
1256
+ "openssl-macros",
1257
+ "openssl-sys",
1258
+]
1259
+
1260
+[[package]]
1261
+name = "openssl-macros"
1262
+version = "0.1.1"
1263
+source = "registry+https://github.com/rust-lang/crates.io-index"
1264
+checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
1265
+dependencies = [
1266
+ "proc-macro2",
1267
+ "quote",
1268
+ "syn",
1269
+]
1270
+
1271
+[[package]]
1272
+name = "openssl-probe"
1273
+version = "0.1.6"
1274
+source = "registry+https://github.com/rust-lang/crates.io-index"
1275
+checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
1276
+
1277
+[[package]]
1278
+name = "openssl-sys"
1279
+version = "0.9.111"
1280
+source = "registry+https://github.com/rust-lang/crates.io-index"
1281
+checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
1282
+dependencies = [
1283
+ "cc",
1284
+ "libc",
1285
+ "pkg-config",
1286
+ "vcpkg",
1287
+]
1288
+
1289
+[[package]]
1290
+name = "option-ext"
1291
+version = "0.2.0"
1292
+source = "registry+https://github.com/rust-lang/crates.io-index"
1293
+checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
1294
+
1295
+[[package]]
1296
+name = "parking_lot"
1297
+version = "0.12.5"
1298
+source = "registry+https://github.com/rust-lang/crates.io-index"
1299
+checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a"
1300
+dependencies = [
1301
+ "lock_api",
1302
+ "parking_lot_core",
1303
+]
1304
+
1305
+[[package]]
1306
+name = "parking_lot_core"
1307
+version = "0.9.12"
1308
+source = "registry+https://github.com/rust-lang/crates.io-index"
1309
+checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1"
1310
+dependencies = [
1311
+ "cfg-if",
1312
+ "libc",
1313
+ "redox_syscall",
1314
+ "smallvec",
1315
+ "windows-link",
1316
+]
1317
+
1318
+[[package]]
1319
+name = "percent-encoding"
1320
+version = "2.3.2"
1321
+source = "registry+https://github.com/rust-lang/crates.io-index"
1322
+checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1323
+
1324
+[[package]]
1325
+name = "phf"
1326
+version = "0.10.1"
1327
+source = "registry+https://github.com/rust-lang/crates.io-index"
1328
+checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259"
1329
+dependencies = [
1330
+ "phf_shared 0.10.0",
1331
+]
1332
+
1333
+[[package]]
1334
+name = "phf"
1335
+version = "0.11.3"
1336
+source = "registry+https://github.com/rust-lang/crates.io-index"
1337
+checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
1338
+dependencies = [
1339
+ "phf_macros",
1340
+ "phf_shared 0.11.3",
1341
+]
1342
+
1343
+[[package]]
1344
+name = "phf_codegen"
1345
+version = "0.10.0"
1346
+source = "registry+https://github.com/rust-lang/crates.io-index"
1347
+checksum = "4fb1c3a8bc4dd4e5cfce29b44ffc14bedd2ee294559a294e2a4d4c9e9a6a13cd"
1348
+dependencies = [
1349
+ "phf_generator 0.10.0",
1350
+ "phf_shared 0.10.0",
1351
+]
1352
+
1353
+[[package]]
1354
+name = "phf_codegen"
1355
+version = "0.11.3"
1356
+source = "registry+https://github.com/rust-lang/crates.io-index"
1357
+checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
1358
+dependencies = [
1359
+ "phf_generator 0.11.3",
1360
+ "phf_shared 0.11.3",
1361
+]
1362
+
1363
+[[package]]
1364
+name = "phf_generator"
1365
+version = "0.10.0"
1366
+source = "registry+https://github.com/rust-lang/crates.io-index"
1367
+checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
1368
+dependencies = [
1369
+ "phf_shared 0.10.0",
1370
+ "rand",
1371
+]
1372
+
1373
+[[package]]
1374
+name = "phf_generator"
1375
+version = "0.11.3"
1376
+source = "registry+https://github.com/rust-lang/crates.io-index"
1377
+checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
1378
+dependencies = [
1379
+ "phf_shared 0.11.3",
1380
+ "rand",
1381
+]
1382
+
1383
+[[package]]
1384
+name = "phf_macros"
1385
+version = "0.11.3"
1386
+source = "registry+https://github.com/rust-lang/crates.io-index"
1387
+checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
1388
+dependencies = [
1389
+ "phf_generator 0.11.3",
1390
+ "phf_shared 0.11.3",
1391
+ "proc-macro2",
1392
+ "quote",
1393
+ "syn",
1394
+]
1395
+
1396
+[[package]]
1397
+name = "phf_shared"
1398
+version = "0.10.0"
1399
+source = "registry+https://github.com/rust-lang/crates.io-index"
1400
+checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096"
1401
+dependencies = [
1402
+ "siphasher 0.3.11",
1403
+]
1404
+
1405
+[[package]]
1406
+name = "phf_shared"
1407
+version = "0.11.3"
1408
+source = "registry+https://github.com/rust-lang/crates.io-index"
1409
+checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
1410
+dependencies = [
1411
+ "siphasher 1.0.1",
1412
+]
1413
+
1414
+[[package]]
1415
+name = "pin-project-lite"
1416
+version = "0.2.16"
1417
+source = "registry+https://github.com/rust-lang/crates.io-index"
1418
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
1419
+
1420
+[[package]]
1421
+name = "pin-utils"
1422
+version = "0.1.0"
1423
+source = "registry+https://github.com/rust-lang/crates.io-index"
1424
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
1425
+
1426
+[[package]]
1427
+name = "pkg-config"
1428
+version = "0.3.32"
1429
+source = "registry+https://github.com/rust-lang/crates.io-index"
1430
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
1431
+
1432
+[[package]]
1433
+name = "png"
1434
+version = "0.18.0"
1435
+source = "registry+https://github.com/rust-lang/crates.io-index"
1436
+checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
1437
+dependencies = [
1438
+ "bitflags",
1439
+ "crc32fast",
1440
+ "fdeflate",
1441
+ "flate2",
1442
+ "miniz_oxide",
1443
+]
1444
+
1445
+[[package]]
1446
+name = "potential_utf"
1447
+version = "0.1.4"
1448
+source = "registry+https://github.com/rust-lang/crates.io-index"
1449
+checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77"
1450
+dependencies = [
1451
+ "zerovec",
1452
+]
1453
+
1454
+[[package]]
1455
+name = "ppv-lite86"
1456
+version = "0.2.21"
1457
+source = "registry+https://github.com/rust-lang/crates.io-index"
1458
+checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
1459
+dependencies = [
1460
+ "zerocopy",
1461
+]
1462
+
1463
+[[package]]
1464
+name = "precomputed-hash"
1465
+version = "0.1.1"
1466
+source = "registry+https://github.com/rust-lang/crates.io-index"
1467
+checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
1468
+
1469
+[[package]]
1470
+name = "proc-macro2"
1471
+version = "1.0.105"
1472
+source = "registry+https://github.com/rust-lang/crates.io-index"
1473
+checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
1474
+dependencies = [
1475
+ "unicode-ident",
1476
+]
1477
+
1478
+[[package]]
1479
+name = "pxfm"
1480
+version = "0.1.27"
1481
+source = "registry+https://github.com/rust-lang/crates.io-index"
1482
+checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
1483
+dependencies = [
1484
+ "num-traits",
1485
+]
1486
+
1487
+[[package]]
1488
+name = "quick-error"
1489
+version = "2.0.1"
1490
+source = "registry+https://github.com/rust-lang/crates.io-index"
1491
+checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
1492
+
1493
+[[package]]
1494
+name = "quote"
1495
+version = "1.0.43"
1496
+source = "registry+https://github.com/rust-lang/crates.io-index"
1497
+checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
1498
+dependencies = [
1499
+ "proc-macro2",
1500
+]
1501
+
1502
+[[package]]
1503
+name = "r-efi"
1504
+version = "5.3.0"
1505
+source = "registry+https://github.com/rust-lang/crates.io-index"
1506
+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
1507
+
1508
+[[package]]
1509
+name = "rand"
1510
+version = "0.8.5"
1511
+source = "registry+https://github.com/rust-lang/crates.io-index"
1512
+checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
1513
+dependencies = [
1514
+ "libc",
1515
+ "rand_chacha",
1516
+ "rand_core",
1517
+]
1518
+
1519
+[[package]]
1520
+name = "rand_chacha"
1521
+version = "0.3.1"
1522
+source = "registry+https://github.com/rust-lang/crates.io-index"
1523
+checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
1524
+dependencies = [
1525
+ "ppv-lite86",
1526
+ "rand_core",
1527
+]
1528
+
1529
+[[package]]
1530
+name = "rand_core"
1531
+version = "0.6.4"
1532
+source = "registry+https://github.com/rust-lang/crates.io-index"
1533
+checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
1534
+dependencies = [
1535
+ "getrandom 0.2.17",
1536
+]
1537
+
1538
+[[package]]
1539
+name = "redox_syscall"
1540
+version = "0.5.18"
1541
+source = "registry+https://github.com/rust-lang/crates.io-index"
1542
+checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
1543
+dependencies = [
1544
+ "bitflags",
1545
+]
1546
+
1547
+[[package]]
1548
+name = "redox_users"
1549
+version = "0.5.2"
1550
+source = "registry+https://github.com/rust-lang/crates.io-index"
1551
+checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac"
1552
+dependencies = [
1553
+ "getrandom 0.2.17",
1554
+ "libredox",
1555
+ "thiserror",
1556
+]
1557
+
1558
+[[package]]
1559
+name = "regex-automata"
1560
+version = "0.4.13"
1561
+source = "registry+https://github.com/rust-lang/crates.io-index"
1562
+checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c"
1563
+dependencies = [
1564
+ "aho-corasick",
1565
+ "memchr",
1566
+ "regex-syntax",
1567
+]
1568
+
1569
+[[package]]
1570
+name = "regex-syntax"
1571
+version = "0.8.8"
1572
+source = "registry+https://github.com/rust-lang/crates.io-index"
1573
+checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
1574
+
1575
+[[package]]
1576
+name = "reqwest"
1577
+version = "0.12.28"
1578
+source = "registry+https://github.com/rust-lang/crates.io-index"
1579
+checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
1580
+dependencies = [
1581
+ "base64",
1582
+ "bytes",
1583
+ "encoding_rs",
1584
+ "futures-channel",
1585
+ "futures-core",
1586
+ "futures-util",
1587
+ "h2",
1588
+ "http",
1589
+ "http-body",
1590
+ "http-body-util",
1591
+ "hyper",
1592
+ "hyper-rustls",
1593
+ "hyper-tls",
1594
+ "hyper-util",
1595
+ "js-sys",
1596
+ "log",
1597
+ "mime",
1598
+ "native-tls",
1599
+ "percent-encoding",
1600
+ "pin-project-lite",
1601
+ "rustls-pki-types",
1602
+ "serde",
1603
+ "serde_json",
1604
+ "serde_urlencoded",
1605
+ "sync_wrapper",
1606
+ "tokio",
1607
+ "tokio-native-tls",
1608
+ "tokio-util",
1609
+ "tower",
1610
+ "tower-http",
1611
+ "tower-service",
1612
+ "url",
1613
+ "wasm-bindgen",
1614
+ "wasm-bindgen-futures",
1615
+ "wasm-streams",
1616
+ "web-sys",
1617
+]
1618
+
1619
+[[package]]
1620
+name = "ring"
1621
+version = "0.17.14"
1622
+source = "registry+https://github.com/rust-lang/crates.io-index"
1623
+checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7"
1624
+dependencies = [
1625
+ "cc",
1626
+ "cfg-if",
1627
+ "getrandom 0.2.17",
1628
+ "libc",
1629
+ "untrusted",
1630
+ "windows-sys 0.52.0",
1631
+]
1632
+
1633
+[[package]]
1634
+name = "rustix"
1635
+version = "1.1.3"
1636
+source = "registry+https://github.com/rust-lang/crates.io-index"
1637
+checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
1638
+dependencies = [
1639
+ "bitflags",
1640
+ "errno",
1641
+ "libc",
1642
+ "linux-raw-sys",
1643
+ "windows-sys 0.61.2",
1644
+]
1645
+
1646
+[[package]]
1647
+name = "rustls"
1648
+version = "0.23.36"
1649
+source = "registry+https://github.com/rust-lang/crates.io-index"
1650
+checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
1651
+dependencies = [
1652
+ "once_cell",
1653
+ "rustls-pki-types",
1654
+ "rustls-webpki",
1655
+ "subtle",
1656
+ "zeroize",
1657
+]
1658
+
1659
+[[package]]
1660
+name = "rustls-pki-types"
1661
+version = "1.13.2"
1662
+source = "registry+https://github.com/rust-lang/crates.io-index"
1663
+checksum = "21e6f2ab2928ca4291b86736a8bd920a277a399bba1589409d72154ff87c1282"
1664
+dependencies = [
1665
+ "zeroize",
1666
+]
1667
+
1668
+[[package]]
1669
+name = "rustls-webpki"
1670
+version = "0.103.8"
1671
+source = "registry+https://github.com/rust-lang/crates.io-index"
1672
+checksum = "2ffdfa2f5286e2247234e03f680868ac2815974dc39e00ea15adc445d0aafe52"
1673
+dependencies = [
1674
+ "ring",
1675
+ "rustls-pki-types",
1676
+ "untrusted",
1677
+]
1678
+
1679
+[[package]]
1680
+name = "rustversion"
1681
+version = "1.0.22"
1682
+source = "registry+https://github.com/rust-lang/crates.io-index"
1683
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
1684
+
1685
+[[package]]
1686
+name = "ryu"
1687
+version = "1.0.22"
1688
+source = "registry+https://github.com/rust-lang/crates.io-index"
1689
+checksum = "a50f4cf475b65d88e057964e0e9bb1f0aa9bbb2036dc65c64596b42932536984"
1690
+
1691
+[[package]]
1692
+name = "schannel"
1693
+version = "0.1.28"
1694
+source = "registry+https://github.com/rust-lang/crates.io-index"
1695
+checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
1696
+dependencies = [
1697
+ "windows-sys 0.61.2",
1698
+]
1699
+
1700
+[[package]]
1701
+name = "scopeguard"
1702
+version = "1.2.0"
1703
+source = "registry+https://github.com/rust-lang/crates.io-index"
1704
+checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
1705
+
1706
+[[package]]
1707
+name = "scraper"
1708
+version = "0.20.0"
1709
+source = "registry+https://github.com/rust-lang/crates.io-index"
1710
+checksum = "b90460b31bfe1fc07be8262e42c665ad97118d4585869de9345a84d501a9eaf0"
1711
+dependencies = [
1712
+ "ahash",
1713
+ "cssparser",
1714
+ "ego-tree",
1715
+ "getopts",
1716
+ "html5ever",
1717
+ "once_cell",
1718
+ "selectors",
1719
+ "tendril",
1720
+]
1721
+
1722
+[[package]]
1723
+name = "security-framework"
1724
+version = "2.11.1"
1725
+source = "registry+https://github.com/rust-lang/crates.io-index"
1726
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
1727
+dependencies = [
1728
+ "bitflags",
1729
+ "core-foundation",
1730
+ "core-foundation-sys",
1731
+ "libc",
1732
+ "security-framework-sys",
1733
+]
1734
+
1735
+[[package]]
1736
+name = "security-framework-sys"
1737
+version = "2.15.0"
1738
+source = "registry+https://github.com/rust-lang/crates.io-index"
1739
+checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
1740
+dependencies = [
1741
+ "core-foundation-sys",
1742
+ "libc",
1743
+]
1744
+
1745
+[[package]]
1746
+name = "selectors"
1747
+version = "0.25.0"
1748
+source = "registry+https://github.com/rust-lang/crates.io-index"
1749
+checksum = "4eb30575f3638fc8f6815f448d50cb1a2e255b0897985c8c59f4d37b72a07b06"
1750
+dependencies = [
1751
+ "bitflags",
1752
+ "cssparser",
1753
+ "derive_more",
1754
+ "fxhash",
1755
+ "log",
1756
+ "new_debug_unreachable",
1757
+ "phf 0.10.1",
1758
+ "phf_codegen 0.10.0",
1759
+ "precomputed-hash",
1760
+ "servo_arc",
1761
+ "smallvec",
1762
+]
1763
+
1764
+[[package]]
1765
+name = "serde"
1766
+version = "1.0.228"
1767
+source = "registry+https://github.com/rust-lang/crates.io-index"
1768
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
1769
+dependencies = [
1770
+ "serde_core",
1771
+ "serde_derive",
1772
+]
1773
+
1774
+[[package]]
1775
+name = "serde_core"
1776
+version = "1.0.228"
1777
+source = "registry+https://github.com/rust-lang/crates.io-index"
1778
+checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad"
1779
+dependencies = [
1780
+ "serde_derive",
1781
+]
1782
+
1783
+[[package]]
1784
+name = "serde_derive"
1785
+version = "1.0.228"
1786
+source = "registry+https://github.com/rust-lang/crates.io-index"
1787
+checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79"
1788
+dependencies = [
1789
+ "proc-macro2",
1790
+ "quote",
1791
+ "syn",
1792
+]
1793
+
1794
+[[package]]
1795
+name = "serde_json"
1796
+version = "1.0.149"
1797
+source = "registry+https://github.com/rust-lang/crates.io-index"
1798
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
1799
+dependencies = [
1800
+ "itoa",
1801
+ "memchr",
1802
+ "serde",
1803
+ "serde_core",
1804
+ "zmij",
1805
+]
1806
+
1807
+[[package]]
1808
+name = "serde_spanned"
1809
+version = "0.6.9"
1810
+source = "registry+https://github.com/rust-lang/crates.io-index"
1811
+checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3"
1812
+dependencies = [
1813
+ "serde",
1814
+]
1815
+
1816
+[[package]]
1817
+name = "serde_urlencoded"
1818
+version = "0.7.1"
1819
+source = "registry+https://github.com/rust-lang/crates.io-index"
1820
+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
1821
+dependencies = [
1822
+ "form_urlencoded",
1823
+ "itoa",
1824
+ "ryu",
1825
+ "serde",
1826
+]
1827
+
1828
+[[package]]
1829
+name = "servo_arc"
1830
+version = "0.3.0"
1831
+source = "registry+https://github.com/rust-lang/crates.io-index"
1832
+checksum = "d036d71a959e00c77a63538b90a6c2390969f9772b096ea837205c6bd0491a44"
1833
+dependencies = [
1834
+ "stable_deref_trait",
1835
+]
1836
+
1837
+[[package]]
1838
+name = "sharded-slab"
1839
+version = "0.1.7"
1840
+source = "registry+https://github.com/rust-lang/crates.io-index"
1841
+checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
1842
+dependencies = [
1843
+ "lazy_static",
1844
+]
1845
+
1846
+[[package]]
1847
+name = "shellexpand"
1848
+version = "3.1.1"
1849
+source = "registry+https://github.com/rust-lang/crates.io-index"
1850
+checksum = "8b1fdf65dd6331831494dd616b30351c38e96e45921a27745cf98490458b90bb"
1851
+dependencies = [
1852
+ "dirs",
1853
+]
1854
+
1855
+[[package]]
1856
+name = "shlex"
1857
+version = "1.3.0"
1858
+source = "registry+https://github.com/rust-lang/crates.io-index"
1859
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
1860
+
1861
+[[package]]
1862
+name = "signal-hook-registry"
1863
+version = "1.4.8"
1864
+source = "registry+https://github.com/rust-lang/crates.io-index"
1865
+checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
1866
+dependencies = [
1867
+ "errno",
1868
+ "libc",
1869
+]
1870
+
1871
+[[package]]
1872
+name = "simd-adler32"
1873
+version = "0.3.8"
1874
+source = "registry+https://github.com/rust-lang/crates.io-index"
1875
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
1876
+
1877
+[[package]]
1878
+name = "siphasher"
1879
+version = "0.3.11"
1880
+source = "registry+https://github.com/rust-lang/crates.io-index"
1881
+checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d"
1882
+
1883
+[[package]]
1884
+name = "siphasher"
1885
+version = "1.0.1"
1886
+source = "registry+https://github.com/rust-lang/crates.io-index"
1887
+checksum = "56199f7ddabf13fe5074ce809e7d3f42b42ae711800501b5b16ea82ad029c39d"
1888
+
1889
+[[package]]
1890
+name = "slab"
1891
+version = "0.4.11"
1892
+source = "registry+https://github.com/rust-lang/crates.io-index"
1893
+checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
1894
+
1895
+[[package]]
1896
+name = "smallvec"
1897
+version = "1.15.1"
1898
+source = "registry+https://github.com/rust-lang/crates.io-index"
1899
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
1900
+
1901
+[[package]]
1902
+name = "socket2"
1903
+version = "0.6.1"
1904
+source = "registry+https://github.com/rust-lang/crates.io-index"
1905
+checksum = "17129e116933cf371d018bb80ae557e889637989d8638274fb25622827b03881"
1906
+dependencies = [
1907
+ "libc",
1908
+ "windows-sys 0.60.2",
1909
+]
1910
+
1911
+[[package]]
1912
+name = "stable_deref_trait"
1913
+version = "1.2.1"
1914
+source = "registry+https://github.com/rust-lang/crates.io-index"
1915
+checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
1916
+
1917
+[[package]]
1918
+name = "string_cache"
1919
+version = "0.8.9"
1920
+source = "registry+https://github.com/rust-lang/crates.io-index"
1921
+checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
1922
+dependencies = [
1923
+ "new_debug_unreachable",
1924
+ "parking_lot",
1925
+ "phf_shared 0.11.3",
1926
+ "precomputed-hash",
1927
+ "serde",
1928
+]
1929
+
1930
+[[package]]
1931
+name = "string_cache_codegen"
1932
+version = "0.5.4"
1933
+source = "registry+https://github.com/rust-lang/crates.io-index"
1934
+checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
1935
+dependencies = [
1936
+ "phf_generator 0.11.3",
1937
+ "phf_shared 0.11.3",
1938
+ "proc-macro2",
1939
+ "quote",
1940
+]
1941
+
1942
+[[package]]
1943
+name = "strsim"
1944
+version = "0.11.1"
1945
+source = "registry+https://github.com/rust-lang/crates.io-index"
1946
+checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
1947
+
1948
+[[package]]
1949
+name = "subtle"
1950
+version = "2.6.1"
1951
+source = "registry+https://github.com/rust-lang/crates.io-index"
1952
+checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
1953
+
1954
+[[package]]
1955
+name = "syn"
1956
+version = "2.0.114"
1957
+source = "registry+https://github.com/rust-lang/crates.io-index"
1958
+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
1959
+dependencies = [
1960
+ "proc-macro2",
1961
+ "quote",
1962
+ "unicode-ident",
1963
+]
1964
+
1965
+[[package]]
1966
+name = "sync_wrapper"
1967
+version = "1.0.2"
1968
+source = "registry+https://github.com/rust-lang/crates.io-index"
1969
+checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263"
1970
+dependencies = [
1971
+ "futures-core",
1972
+]
1973
+
1974
+[[package]]
1975
+name = "synstructure"
1976
+version = "0.13.2"
1977
+source = "registry+https://github.com/rust-lang/crates.io-index"
1978
+checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2"
1979
+dependencies = [
1980
+ "proc-macro2",
1981
+ "quote",
1982
+ "syn",
1983
+]
1984
+
1985
+[[package]]
1986
+name = "system-configuration"
1987
+version = "0.6.1"
1988
+source = "registry+https://github.com/rust-lang/crates.io-index"
1989
+checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b"
1990
+dependencies = [
1991
+ "bitflags",
1992
+ "core-foundation",
1993
+ "system-configuration-sys",
1994
+]
1995
+
1996
+[[package]]
1997
+name = "system-configuration-sys"
1998
+version = "0.6.0"
1999
+source = "registry+https://github.com/rust-lang/crates.io-index"
2000
+checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
2001
+dependencies = [
2002
+ "core-foundation-sys",
2003
+ "libc",
2004
+]
2005
+
2006
+[[package]]
2007
+name = "tempfile"
2008
+version = "3.24.0"
2009
+source = "registry+https://github.com/rust-lang/crates.io-index"
2010
+checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
2011
+dependencies = [
2012
+ "fastrand",
2013
+ "getrandom 0.3.4",
2014
+ "once_cell",
2015
+ "rustix",
2016
+ "windows-sys 0.61.2",
2017
+]
2018
+
2019
+[[package]]
2020
+name = "tendril"
2021
+version = "0.4.3"
2022
+source = "registry+https://github.com/rust-lang/crates.io-index"
2023
+checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
2024
+dependencies = [
2025
+ "futf",
2026
+ "mac",
2027
+ "utf-8",
2028
+]
2029
+
2030
+[[package]]
2031
+name = "thiserror"
2032
+version = "2.0.17"
2033
+source = "registry+https://github.com/rust-lang/crates.io-index"
2034
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
2035
+dependencies = [
2036
+ "thiserror-impl",
2037
+]
2038
+
2039
+[[package]]
2040
+name = "thiserror-impl"
2041
+version = "2.0.17"
2042
+source = "registry+https://github.com/rust-lang/crates.io-index"
2043
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
2044
+dependencies = [
2045
+ "proc-macro2",
2046
+ "quote",
2047
+ "syn",
2048
+]
2049
+
2050
+[[package]]
2051
+name = "thread_local"
2052
+version = "1.1.9"
2053
+source = "registry+https://github.com/rust-lang/crates.io-index"
2054
+checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
2055
+dependencies = [
2056
+ "cfg-if",
2057
+]
2058
+
2059
+[[package]]
2060
+name = "tinystr"
2061
+version = "0.8.2"
2062
+source = "registry+https://github.com/rust-lang/crates.io-index"
2063
+checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869"
2064
+dependencies = [
2065
+ "displaydoc",
2066
+ "zerovec",
2067
+]
2068
+
2069
+[[package]]
2070
+name = "tokio"
2071
+version = "1.49.0"
2072
+source = "registry+https://github.com/rust-lang/crates.io-index"
2073
+checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
2074
+dependencies = [
2075
+ "bytes",
2076
+ "libc",
2077
+ "mio",
2078
+ "parking_lot",
2079
+ "pin-project-lite",
2080
+ "signal-hook-registry",
2081
+ "socket2",
2082
+ "tokio-macros",
2083
+ "windows-sys 0.61.2",
2084
+]
2085
+
2086
+[[package]]
2087
+name = "tokio-macros"
2088
+version = "2.6.0"
2089
+source = "registry+https://github.com/rust-lang/crates.io-index"
2090
+checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
2091
+dependencies = [
2092
+ "proc-macro2",
2093
+ "quote",
2094
+ "syn",
2095
+]
2096
+
2097
+[[package]]
2098
+name = "tokio-native-tls"
2099
+version = "0.3.1"
2100
+source = "registry+https://github.com/rust-lang/crates.io-index"
2101
+checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
2102
+dependencies = [
2103
+ "native-tls",
2104
+ "tokio",
2105
+]
2106
+
2107
+[[package]]
2108
+name = "tokio-rustls"
2109
+version = "0.26.4"
2110
+source = "registry+https://github.com/rust-lang/crates.io-index"
2111
+checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61"
2112
+dependencies = [
2113
+ "rustls",
2114
+ "tokio",
2115
+]
2116
+
2117
+[[package]]
2118
+name = "tokio-util"
2119
+version = "0.7.18"
2120
+source = "registry+https://github.com/rust-lang/crates.io-index"
2121
+checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098"
2122
+dependencies = [
2123
+ "bytes",
2124
+ "futures-core",
2125
+ "futures-sink",
2126
+ "pin-project-lite",
2127
+ "tokio",
2128
+]
2129
+
2130
+[[package]]
2131
+name = "toml"
2132
+version = "0.8.23"
2133
+source = "registry+https://github.com/rust-lang/crates.io-index"
2134
+checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
2135
+dependencies = [
2136
+ "serde",
2137
+ "serde_spanned",
2138
+ "toml_datetime",
2139
+ "toml_edit",
2140
+]
2141
+
2142
+[[package]]
2143
+name = "toml_datetime"
2144
+version = "0.6.11"
2145
+source = "registry+https://github.com/rust-lang/crates.io-index"
2146
+checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c"
2147
+dependencies = [
2148
+ "serde",
2149
+]
2150
+
2151
+[[package]]
2152
+name = "toml_edit"
2153
+version = "0.22.27"
2154
+source = "registry+https://github.com/rust-lang/crates.io-index"
2155
+checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
2156
+dependencies = [
2157
+ "indexmap",
2158
+ "serde",
2159
+ "serde_spanned",
2160
+ "toml_datetime",
2161
+ "toml_write",
2162
+ "winnow",
2163
+]
2164
+
2165
+[[package]]
2166
+name = "toml_write"
2167
+version = "0.1.2"
2168
+source = "registry+https://github.com/rust-lang/crates.io-index"
2169
+checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
2170
+
2171
+[[package]]
2172
+name = "tower"
2173
+version = "0.5.2"
2174
+source = "registry+https://github.com/rust-lang/crates.io-index"
2175
+checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9"
2176
+dependencies = [
2177
+ "futures-core",
2178
+ "futures-util",
2179
+ "pin-project-lite",
2180
+ "sync_wrapper",
2181
+ "tokio",
2182
+ "tower-layer",
2183
+ "tower-service",
2184
+]
2185
+
2186
+[[package]]
2187
+name = "tower-http"
2188
+version = "0.6.8"
2189
+source = "registry+https://github.com/rust-lang/crates.io-index"
2190
+checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8"
2191
+dependencies = [
2192
+ "bitflags",
2193
+ "bytes",
2194
+ "futures-util",
2195
+ "http",
2196
+ "http-body",
2197
+ "iri-string",
2198
+ "pin-project-lite",
2199
+ "tower",
2200
+ "tower-layer",
2201
+ "tower-service",
2202
+]
2203
+
2204
+[[package]]
2205
+name = "tower-layer"
2206
+version = "0.3.3"
2207
+source = "registry+https://github.com/rust-lang/crates.io-index"
2208
+checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e"
2209
+
2210
+[[package]]
2211
+name = "tower-service"
2212
+version = "0.3.3"
2213
+source = "registry+https://github.com/rust-lang/crates.io-index"
2214
+checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
2215
+
2216
+[[package]]
2217
+name = "tracing"
2218
+version = "0.1.44"
2219
+source = "registry+https://github.com/rust-lang/crates.io-index"
2220
+checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2221
+dependencies = [
2222
+ "pin-project-lite",
2223
+ "tracing-attributes",
2224
+ "tracing-core",
2225
+]
2226
+
2227
+[[package]]
2228
+name = "tracing-attributes"
2229
+version = "0.1.31"
2230
+source = "registry+https://github.com/rust-lang/crates.io-index"
2231
+checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2232
+dependencies = [
2233
+ "proc-macro2",
2234
+ "quote",
2235
+ "syn",
2236
+]
2237
+
2238
+[[package]]
2239
+name = "tracing-core"
2240
+version = "0.1.36"
2241
+source = "registry+https://github.com/rust-lang/crates.io-index"
2242
+checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2243
+dependencies = [
2244
+ "once_cell",
2245
+ "valuable",
2246
+]
2247
+
2248
+[[package]]
2249
+name = "tracing-log"
2250
+version = "0.2.0"
2251
+source = "registry+https://github.com/rust-lang/crates.io-index"
2252
+checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
2253
+dependencies = [
2254
+ "log",
2255
+ "once_cell",
2256
+ "tracing-core",
2257
+]
2258
+
2259
+[[package]]
2260
+name = "tracing-subscriber"
2261
+version = "0.3.22"
2262
+source = "registry+https://github.com/rust-lang/crates.io-index"
2263
+checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e"
2264
+dependencies = [
2265
+ "matchers",
2266
+ "nu-ansi-term",
2267
+ "once_cell",
2268
+ "regex-automata",
2269
+ "sharded-slab",
2270
+ "smallvec",
2271
+ "thread_local",
2272
+ "tracing",
2273
+ "tracing-core",
2274
+ "tracing-log",
2275
+]
2276
+
2277
+[[package]]
2278
+name = "try-lock"
2279
+version = "0.2.5"
2280
+source = "registry+https://github.com/rust-lang/crates.io-index"
2281
+checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
2282
+
2283
+[[package]]
2284
+name = "unicode-ident"
2285
+version = "1.0.22"
2286
+source = "registry+https://github.com/rust-lang/crates.io-index"
2287
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
2288
+
2289
+[[package]]
2290
+name = "unicode-width"
2291
+version = "0.2.2"
2292
+source = "registry+https://github.com/rust-lang/crates.io-index"
2293
+checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
2294
+
2295
+[[package]]
2296
+name = "untrusted"
2297
+version = "0.9.0"
2298
+source = "registry+https://github.com/rust-lang/crates.io-index"
2299
+checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
2300
+
2301
+[[package]]
2302
+name = "url"
2303
+version = "2.5.8"
2304
+source = "registry+https://github.com/rust-lang/crates.io-index"
2305
+checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
2306
+dependencies = [
2307
+ "form_urlencoded",
2308
+ "idna",
2309
+ "percent-encoding",
2310
+ "serde",
2311
+]
2312
+
2313
+[[package]]
2314
+name = "utf-8"
2315
+version = "0.7.6"
2316
+source = "registry+https://github.com/rust-lang/crates.io-index"
2317
+checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9"
2318
+
2319
+[[package]]
2320
+name = "utf8_iter"
2321
+version = "1.0.4"
2322
+source = "registry+https://github.com/rust-lang/crates.io-index"
2323
+checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
2324
+
2325
+[[package]]
2326
+name = "utf8parse"
2327
+version = "0.2.2"
2328
+source = "registry+https://github.com/rust-lang/crates.io-index"
2329
+checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
2330
+
2331
+[[package]]
2332
+name = "valuable"
2333
+version = "0.1.1"
2334
+source = "registry+https://github.com/rust-lang/crates.io-index"
2335
+checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
2336
+
2337
+[[package]]
2338
+name = "vcpkg"
2339
+version = "0.2.15"
2340
+source = "registry+https://github.com/rust-lang/crates.io-index"
2341
+checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
2342
+
2343
+[[package]]
2344
+name = "version_check"
2345
+version = "0.9.5"
2346
+source = "registry+https://github.com/rust-lang/crates.io-index"
2347
+checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
2348
+
2349
+[[package]]
2350
+name = "want"
2351
+version = "0.3.1"
2352
+source = "registry+https://github.com/rust-lang/crates.io-index"
2353
+checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
2354
+dependencies = [
2355
+ "try-lock",
2356
+]
2357
+
2358
+[[package]]
2359
+name = "wasi"
2360
+version = "0.11.1+wasi-snapshot-preview1"
2361
+source = "registry+https://github.com/rust-lang/crates.io-index"
2362
+checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
2363
+
2364
+[[package]]
2365
+name = "wasip2"
2366
+version = "1.0.1+wasi-0.2.4"
2367
+source = "registry+https://github.com/rust-lang/crates.io-index"
2368
+checksum = "0562428422c63773dad2c345a1882263bbf4d65cf3f42e90921f787ef5ad58e7"
2369
+dependencies = [
2370
+ "wit-bindgen",
2371
+]
2372
+
2373
+[[package]]
2374
+name = "wasm-bindgen"
2375
+version = "0.2.106"
2376
+source = "registry+https://github.com/rust-lang/crates.io-index"
2377
+checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
2378
+dependencies = [
2379
+ "cfg-if",
2380
+ "once_cell",
2381
+ "rustversion",
2382
+ "wasm-bindgen-macro",
2383
+ "wasm-bindgen-shared",
2384
+]
2385
+
2386
+[[package]]
2387
+name = "wasm-bindgen-futures"
2388
+version = "0.4.56"
2389
+source = "registry+https://github.com/rust-lang/crates.io-index"
2390
+checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
2391
+dependencies = [
2392
+ "cfg-if",
2393
+ "js-sys",
2394
+ "once_cell",
2395
+ "wasm-bindgen",
2396
+ "web-sys",
2397
+]
2398
+
2399
+[[package]]
2400
+name = "wasm-bindgen-macro"
2401
+version = "0.2.106"
2402
+source = "registry+https://github.com/rust-lang/crates.io-index"
2403
+checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
2404
+dependencies = [
2405
+ "quote",
2406
+ "wasm-bindgen-macro-support",
2407
+]
2408
+
2409
+[[package]]
2410
+name = "wasm-bindgen-macro-support"
2411
+version = "0.2.106"
2412
+source = "registry+https://github.com/rust-lang/crates.io-index"
2413
+checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
2414
+dependencies = [
2415
+ "bumpalo",
2416
+ "proc-macro2",
2417
+ "quote",
2418
+ "syn",
2419
+ "wasm-bindgen-shared",
2420
+]
2421
+
2422
+[[package]]
2423
+name = "wasm-bindgen-shared"
2424
+version = "0.2.106"
2425
+source = "registry+https://github.com/rust-lang/crates.io-index"
2426
+checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
2427
+dependencies = [
2428
+ "unicode-ident",
2429
+]
2430
+
2431
+[[package]]
2432
+name = "wasm-streams"
2433
+version = "0.4.2"
2434
+source = "registry+https://github.com/rust-lang/crates.io-index"
2435
+checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65"
2436
+dependencies = [
2437
+ "futures-util",
2438
+ "js-sys",
2439
+ "wasm-bindgen",
2440
+ "wasm-bindgen-futures",
2441
+ "web-sys",
2442
+]
2443
+
2444
+[[package]]
2445
+name = "web-sys"
2446
+version = "0.3.83"
2447
+source = "registry+https://github.com/rust-lang/crates.io-index"
2448
+checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
2449
+dependencies = [
2450
+ "js-sys",
2451
+ "wasm-bindgen",
2452
+]
2453
+
2454
+[[package]]
2455
+name = "weezl"
2456
+version = "0.1.12"
2457
+source = "registry+https://github.com/rust-lang/crates.io-index"
2458
+checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
2459
+
2460
+[[package]]
2461
+name = "windows-core"
2462
+version = "0.62.2"
2463
+source = "registry+https://github.com/rust-lang/crates.io-index"
2464
+checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
2465
+dependencies = [
2466
+ "windows-implement",
2467
+ "windows-interface",
2468
+ "windows-link",
2469
+ "windows-result",
2470
+ "windows-strings",
2471
+]
2472
+
2473
+[[package]]
2474
+name = "windows-implement"
2475
+version = "0.60.2"
2476
+source = "registry+https://github.com/rust-lang/crates.io-index"
2477
+checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
2478
+dependencies = [
2479
+ "proc-macro2",
2480
+ "quote",
2481
+ "syn",
2482
+]
2483
+
2484
+[[package]]
2485
+name = "windows-interface"
2486
+version = "0.59.3"
2487
+source = "registry+https://github.com/rust-lang/crates.io-index"
2488
+checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
2489
+dependencies = [
2490
+ "proc-macro2",
2491
+ "quote",
2492
+ "syn",
2493
+]
2494
+
2495
+[[package]]
2496
+name = "windows-link"
2497
+version = "0.2.1"
2498
+source = "registry+https://github.com/rust-lang/crates.io-index"
2499
+checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
2500
+
2501
+[[package]]
2502
+name = "windows-registry"
2503
+version = "0.6.1"
2504
+source = "registry+https://github.com/rust-lang/crates.io-index"
2505
+checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
2506
+dependencies = [
2507
+ "windows-link",
2508
+ "windows-result",
2509
+ "windows-strings",
2510
+]
2511
+
2512
+[[package]]
2513
+name = "windows-result"
2514
+version = "0.4.1"
2515
+source = "registry+https://github.com/rust-lang/crates.io-index"
2516
+checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
2517
+dependencies = [
2518
+ "windows-link",
2519
+]
2520
+
2521
+[[package]]
2522
+name = "windows-strings"
2523
+version = "0.5.1"
2524
+source = "registry+https://github.com/rust-lang/crates.io-index"
2525
+checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
2526
+dependencies = [
2527
+ "windows-link",
2528
+]
2529
+
2530
+[[package]]
2531
+name = "windows-sys"
2532
+version = "0.52.0"
2533
+source = "registry+https://github.com/rust-lang/crates.io-index"
2534
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
2535
+dependencies = [
2536
+ "windows-targets 0.52.6",
2537
+]
2538
+
2539
+[[package]]
2540
+name = "windows-sys"
2541
+version = "0.60.2"
2542
+source = "registry+https://github.com/rust-lang/crates.io-index"
2543
+checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
2544
+dependencies = [
2545
+ "windows-targets 0.53.5",
2546
+]
2547
+
2548
+[[package]]
2549
+name = "windows-sys"
2550
+version = "0.61.2"
2551
+source = "registry+https://github.com/rust-lang/crates.io-index"
2552
+checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
2553
+dependencies = [
2554
+ "windows-link",
2555
+]
2556
+
2557
+[[package]]
2558
+name = "windows-targets"
2559
+version = "0.52.6"
2560
+source = "registry+https://github.com/rust-lang/crates.io-index"
2561
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
2562
+dependencies = [
2563
+ "windows_aarch64_gnullvm 0.52.6",
2564
+ "windows_aarch64_msvc 0.52.6",
2565
+ "windows_i686_gnu 0.52.6",
2566
+ "windows_i686_gnullvm 0.52.6",
2567
+ "windows_i686_msvc 0.52.6",
2568
+ "windows_x86_64_gnu 0.52.6",
2569
+ "windows_x86_64_gnullvm 0.52.6",
2570
+ "windows_x86_64_msvc 0.52.6",
2571
+]
2572
+
2573
+[[package]]
2574
+name = "windows-targets"
2575
+version = "0.53.5"
2576
+source = "registry+https://github.com/rust-lang/crates.io-index"
2577
+checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
2578
+dependencies = [
2579
+ "windows-link",
2580
+ "windows_aarch64_gnullvm 0.53.1",
2581
+ "windows_aarch64_msvc 0.53.1",
2582
+ "windows_i686_gnu 0.53.1",
2583
+ "windows_i686_gnullvm 0.53.1",
2584
+ "windows_i686_msvc 0.53.1",
2585
+ "windows_x86_64_gnu 0.53.1",
2586
+ "windows_x86_64_gnullvm 0.53.1",
2587
+ "windows_x86_64_msvc 0.53.1",
2588
+]
2589
+
2590
+[[package]]
2591
+name = "windows_aarch64_gnullvm"
2592
+version = "0.52.6"
2593
+source = "registry+https://github.com/rust-lang/crates.io-index"
2594
+checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
2595
+
2596
+[[package]]
2597
+name = "windows_aarch64_gnullvm"
2598
+version = "0.53.1"
2599
+source = "registry+https://github.com/rust-lang/crates.io-index"
2600
+checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
2601
+
2602
+[[package]]
2603
+name = "windows_aarch64_msvc"
2604
+version = "0.52.6"
2605
+source = "registry+https://github.com/rust-lang/crates.io-index"
2606
+checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
2607
+
2608
+[[package]]
2609
+name = "windows_aarch64_msvc"
2610
+version = "0.53.1"
2611
+source = "registry+https://github.com/rust-lang/crates.io-index"
2612
+checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
2613
+
2614
+[[package]]
2615
+name = "windows_i686_gnu"
2616
+version = "0.52.6"
2617
+source = "registry+https://github.com/rust-lang/crates.io-index"
2618
+checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
2619
+
2620
+[[package]]
2621
+name = "windows_i686_gnu"
2622
+version = "0.53.1"
2623
+source = "registry+https://github.com/rust-lang/crates.io-index"
2624
+checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
2625
+
2626
+[[package]]
2627
+name = "windows_i686_gnullvm"
2628
+version = "0.52.6"
2629
+source = "registry+https://github.com/rust-lang/crates.io-index"
2630
+checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
2631
+
2632
+[[package]]
2633
+name = "windows_i686_gnullvm"
2634
+version = "0.53.1"
2635
+source = "registry+https://github.com/rust-lang/crates.io-index"
2636
+checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
2637
+
2638
+[[package]]
2639
+name = "windows_i686_msvc"
2640
+version = "0.52.6"
2641
+source = "registry+https://github.com/rust-lang/crates.io-index"
2642
+checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
2643
+
2644
+[[package]]
2645
+name = "windows_i686_msvc"
2646
+version = "0.53.1"
2647
+source = "registry+https://github.com/rust-lang/crates.io-index"
2648
+checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
2649
+
2650
+[[package]]
2651
+name = "windows_x86_64_gnu"
2652
+version = "0.52.6"
2653
+source = "registry+https://github.com/rust-lang/crates.io-index"
2654
+checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
2655
+
2656
+[[package]]
2657
+name = "windows_x86_64_gnu"
2658
+version = "0.53.1"
2659
+source = "registry+https://github.com/rust-lang/crates.io-index"
2660
+checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
2661
+
2662
+[[package]]
2663
+name = "windows_x86_64_gnullvm"
2664
+version = "0.52.6"
2665
+source = "registry+https://github.com/rust-lang/crates.io-index"
2666
+checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
2667
+
2668
+[[package]]
2669
+name = "windows_x86_64_gnullvm"
2670
+version = "0.53.1"
2671
+source = "registry+https://github.com/rust-lang/crates.io-index"
2672
+checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
2673
+
2674
+[[package]]
2675
+name = "windows_x86_64_msvc"
2676
+version = "0.52.6"
2677
+source = "registry+https://github.com/rust-lang/crates.io-index"
2678
+checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
2679
+
2680
+[[package]]
2681
+name = "windows_x86_64_msvc"
2682
+version = "0.53.1"
2683
+source = "registry+https://github.com/rust-lang/crates.io-index"
2684
+checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
2685
+
2686
+[[package]]
2687
+name = "winnow"
2688
+version = "0.7.14"
2689
+source = "registry+https://github.com/rust-lang/crates.io-index"
2690
+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
2691
+dependencies = [
2692
+ "memchr",
2693
+]
2694
+
2695
+[[package]]
2696
+name = "wit-bindgen"
2697
+version = "0.46.0"
2698
+source = "registry+https://github.com/rust-lang/crates.io-index"
2699
+checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
2700
+
2701
+[[package]]
2702
+name = "writeable"
2703
+version = "0.6.2"
2704
+source = "registry+https://github.com/rust-lang/crates.io-index"
2705
+checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
2706
+
2707
+[[package]]
2708
+name = "x11rb"
2709
+version = "0.13.2"
2710
+source = "registry+https://github.com/rust-lang/crates.io-index"
2711
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
2712
+dependencies = [
2713
+ "as-raw-xcb-connection",
2714
+ "gethostname",
2715
+ "libc",
2716
+ "rustix",
2717
+ "x11rb-protocol",
2718
+]
2719
+
2720
+[[package]]
2721
+name = "x11rb-protocol"
2722
+version = "0.13.2"
2723
+source = "registry+https://github.com/rust-lang/crates.io-index"
2724
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
2725
+
2726
+[[package]]
2727
+name = "yoke"
2728
+version = "0.8.1"
2729
+source = "registry+https://github.com/rust-lang/crates.io-index"
2730
+checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954"
2731
+dependencies = [
2732
+ "stable_deref_trait",
2733
+ "yoke-derive",
2734
+ "zerofrom",
2735
+]
2736
+
2737
+[[package]]
2738
+name = "yoke-derive"
2739
+version = "0.8.1"
2740
+source = "registry+https://github.com/rust-lang/crates.io-index"
2741
+checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d"
2742
+dependencies = [
2743
+ "proc-macro2",
2744
+ "quote",
2745
+ "syn",
2746
+ "synstructure",
2747
+]
2748
+
2749
+[[package]]
2750
+name = "zerocopy"
2751
+version = "0.8.33"
2752
+source = "registry+https://github.com/rust-lang/crates.io-index"
2753
+checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
2754
+dependencies = [
2755
+ "zerocopy-derive",
2756
+]
2757
+
2758
+[[package]]
2759
+name = "zerocopy-derive"
2760
+version = "0.8.33"
2761
+source = "registry+https://github.com/rust-lang/crates.io-index"
2762
+checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
2763
+dependencies = [
2764
+ "proc-macro2",
2765
+ "quote",
2766
+ "syn",
2767
+]
2768
+
2769
+[[package]]
2770
+name = "zerofrom"
2771
+version = "0.1.6"
2772
+source = "registry+https://github.com/rust-lang/crates.io-index"
2773
+checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5"
2774
+dependencies = [
2775
+ "zerofrom-derive",
2776
+]
2777
+
2778
+[[package]]
2779
+name = "zerofrom-derive"
2780
+version = "0.1.6"
2781
+source = "registry+https://github.com/rust-lang/crates.io-index"
2782
+checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502"
2783
+dependencies = [
2784
+ "proc-macro2",
2785
+ "quote",
2786
+ "syn",
2787
+ "synstructure",
2788
+]
2789
+
2790
+[[package]]
2791
+name = "zeroize"
2792
+version = "1.8.2"
2793
+source = "registry+https://github.com/rust-lang/crates.io-index"
2794
+checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
2795
+
2796
+[[package]]
2797
+name = "zerotrie"
2798
+version = "0.2.3"
2799
+source = "registry+https://github.com/rust-lang/crates.io-index"
2800
+checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851"
2801
+dependencies = [
2802
+ "displaydoc",
2803
+ "yoke",
2804
+ "zerofrom",
2805
+]
2806
+
2807
+[[package]]
2808
+name = "zerovec"
2809
+version = "0.11.5"
2810
+source = "registry+https://github.com/rust-lang/crates.io-index"
2811
+checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002"
2812
+dependencies = [
2813
+ "yoke",
2814
+ "zerofrom",
2815
+ "zerovec-derive",
2816
+]
2817
+
2818
+[[package]]
2819
+name = "zerovec-derive"
2820
+version = "0.11.2"
2821
+source = "registry+https://github.com/rust-lang/crates.io-index"
2822
+checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3"
2823
+dependencies = [
2824
+ "proc-macro2",
2825
+ "quote",
2826
+ "syn",
2827
+]
2828
+
2829
+[[package]]
2830
+name = "zmij"
2831
+version = "1.0.13"
2832
+source = "registry+https://github.com/rust-lang/crates.io-index"
2833
+checksum = "ac93432f5b761b22864c774aac244fa5c0fd877678a4c37ebf6cf42208f9c9ec"
2834
+
2835
+[[package]]
2836
+name = "zune-core"
2837
+version = "0.5.0"
2838
+source = "registry+https://github.com/rust-lang/crates.io-index"
2839
+checksum = "111f7d9820f05fd715df3144e254d6fc02ee4088b0644c0ffd0efc9e6d9d2773"
2840
+
2841
+[[package]]
2842
+name = "zune-jpeg"
2843
+version = "0.5.8"
2844
+source = "registry+https://github.com/rust-lang/crates.io-index"
2845
+checksum = "e35aee689668bf9bd6f6f3a6c60bb29ba1244b3b43adfd50edd554a371da37d5"
2846
+dependencies = [
2847
+ "zune-core",
2848
+]
Cargo.tomladded
@@ -0,0 +1,70 @@
1
+[workspace]
2
+members = ["garbg"]
3
+resolver = "2"
4
+
5
+[workspace.package]
6
+version = "0.1.0"
7
+edition = "2021"
8
+authors = ["mfwolffe <wolffemf@dukes.jmu.edu>"]
9
+license = "MIT"
10
+repository = "https://github.com/tenseleyFlow/garbg"
11
+
12
+[workspace.dependencies]
13
+# X11 (match gar)
14
+x11rb = { version = "0.13", features = ["allow-unsafe-code", "randr"] }
15
+
16
+# Async runtime
17
+tokio = { version = "1", features = ["full"] }
18
+
19
+# Image decoding
20
+image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp"] }
21
+
22
+# Configuration
23
+toml = "0.8"
24
+serde = { version = "1", features = ["derive"] }
25
+serde_json = "1"
26
+
27
+# HTTP client
28
+reqwest = { version = "0.12", features = ["json", "stream", "blocking"] }
29
+
30
+# HTML parsing for directory indexes
31
+scraper = "0.20"
32
+
33
+# Caching
34
+lru = "0.12"
35
+
36
+# Logging
37
+tracing = "0.1"
38
+tracing-subscriber = { version = "0.3", features = ["env-filter"] }
39
+
40
+# Error handling
41
+thiserror = "2"
42
+anyhow = "1"
43
+
44
+# CLI
45
+clap = { version = "4", features = ["derive"] }
46
+
47
+# Async traits
48
+async-trait = "0.1"
49
+
50
+# Hashing for cache keys
51
+blake3 = "1"
52
+
53
+# Time handling
54
+chrono = { version = "0.4", features = ["serde"] }
55
+humantime = "2"
56
+
57
+# Cross-thread channels
58
+crossbeam-channel = "0.5"
59
+
60
+# Directory utilities
61
+dirs = "6"
62
+
63
+# Path expansion
64
+shellexpand = "3"
65
+
66
+# URL parsing
67
+url = "2"
68
+
69
+# Random selection
70
+rand = "0.8"
docs/phases/phase1-core-foundation.mdadded
@@ -0,0 +1,58 @@
1
+# Phase 1: Core Foundation
2
+
3
+## Goal
4
+Establish the basic infrastructure for garbg: project structure, X11 connection, static image rendering, and a minimal CLI.
5
+
6
+## Tasks
7
+
8
+### 1.1 Project Scaffolding
9
+- [x] Create workspace Cargo.toml with garbg and garbgctl members
10
+- [x] Define workspace dependencies (x11rb, image, tokio, clap, etc.)
11
+- [x] Create module structure under garbg/src/
12
+
13
+### 1.2 X11 Connection
14
+- [x] Connect to X server using x11rb
15
+- [x] Intern required atoms (_XROOTPMAP_ID, ESETROOT_PMAP_ID)
16
+- [x] Create graphics context for drawing
17
+- [ ] Handle connection errors gracefully
18
+
19
+### 1.3 Root Window Pixmap Rendering
20
+- [x] Create pixmap from image data
21
+- [x] Convert RGBA to BGRA (X11 native format)
22
+- [x] Set pixmap as root window background
23
+- [x] Set standard atoms for compatibility with other tools
24
+- [x] Clear root window to display new background
25
+- [ ] Free old pixmap on wallpaper change
26
+
27
+### 1.4 Static Image Loading
28
+- [ ] Load PNG images
29
+- [ ] Load JPEG images
30
+- [ ] Load WebP images
31
+- [ ] Implement scale modes: fill, fit, stretch, center, tile
32
+
33
+### 1.5 Basic CLI
34
+- [x] Parse commands with clap
35
+- [x] `garbg set <source>` command
36
+- [x] `--mode` flag for scale mode
37
+- [x] `--monitor` flag for target monitor
38
+- [ ] `--verbose` logging support
39
+
40
+## Deliverables
41
+- `garbg set ~/path/to/image.png` sets wallpaper
42
+- Static images display correctly at screen resolution
43
+- Scale modes work as expected
44
+
45
+## Files Modified/Created
46
+- `/garbg/Cargo.toml` - workspace config
47
+- `/garbg/garbg/Cargo.toml` - main crate config
48
+- `/garbg/garbgctl/Cargo.toml` - CLI tool config
49
+- `/garbg/garbg/src/lib.rs` - library root
50
+- `/garbg/garbg/src/main.rs` - CLI entry point
51
+- `/garbg/garbg/src/x11/mod.rs` - X11 module
52
+- `/garbg/garbg/src/x11/connection.rs` - X11 connection
53
+- `/garbg/garbg/src/x11/renderer.rs` - Pixmap rendering
54
+- `/garbg/garbg/src/x11/monitors.rs` - RandR monitor detection
55
+- `/garbg/garbg/src/media/mod.rs` - Media module
56
+- `/garbg/garbg/src/media/loader.rs` - Image loading
57
+- `/garbg/garbg/src/media/scaler.rs` - Image scaling
58
+- `/garbg/garbg/src/config/mod.rs` - Config types
docs/phases/phase2-animation-video.mdadded
@@ -0,0 +1,91 @@
1
+# Phase 2: Animation & Video Support
2
+
3
+## Goal
4
+Add support for animated GIFs and video wallpapers with efficient frame rendering.
5
+
6
+## Tasks
7
+
8
+### 2.1 Animated GIF Support
9
+- [ ] Parse GIF files with frame-by-frame access
10
+- [ ] Extract frame delays from GIF metadata
11
+- [ ] Handle GIF disposal methods (replace, combine, etc.)
12
+- [ ] Implement frame timing with proper delays
13
+
14
+### 2.2 Double Buffering
15
+- [ ] Create front and back buffer pixmaps
16
+- [ ] Render to back buffer while displaying front
17
+- [ ] Swap buffers atomically
18
+- [ ] Minimize visual tearing
19
+
20
+### 2.3 Animation Event Loop
21
+- [ ] Timer-based frame advancement
22
+- [ ] Adaptive frame skipping under load
23
+- [ ] Max 60fps cap to prevent excessive CPU usage
24
+- [ ] Pause/resume animation state
25
+
26
+### 2.4 Video Decoding (Optional Feature)
27
+- [ ] Integrate ffmpeg-next for video decoding
28
+- [ ] Decode video frames to RGBA
29
+- [ ] Handle common codecs (H.264, VP9, AV1)
30
+- [ ] Support MP4 and WebM containers
31
+
32
+### 2.5 Frame Pre-rendering Buffer
33
+- [ ] Ring buffer of ~30 pre-decoded frames
34
+- [ ] Background thread for frame decoding
35
+- [ ] Producer-consumer pattern with channels
36
+- [ ] Memory-efficient frame recycling
37
+
38
+### 2.6 Animated WebP/APNG
39
+- [ ] Detect animated WebP files
40
+- [ ] Parse APNG frame structure
41
+- [ ] Unified animation interface for all formats
42
+
43
+## Deliverables
44
+- `garbg set ~/animation.gif` plays animated GIF
45
+- `garbg set ~/video.mp4` plays video as wallpaper (with --features video)
46
+- Smooth playback at correct frame rates
47
+- Minimal CPU usage during playback
48
+
49
+## Technical Notes
50
+
51
+### GIF Frame Timing
52
+```rust
53
+// GIF delays are in centiseconds (1/100th second)
54
+let delay_ms = frame.delay * 10;
55
+// Some GIFs have 0 delay, default to ~100ms
56
+let delay_ms = if delay_ms == 0 { 100 } else { delay_ms };
57
+```
58
+
59
+### Double Buffer Swap
60
+```rust
61
+// Render to back buffer
62
+put_image(back_buffer, frame_data);
63
+// Swap pointers
64
+std::mem::swap(&mut front_buffer, &mut back_buffer);
65
+// Set root to new front buffer
66
+set_root_pixmap(front_buffer);
67
+```
68
+
69
+### Video Pipeline
70
+```
71
+MP4/WebM File
72
+     |
73
+     v
74
+[FFmpeg Demuxer]
75
+     |
76
+     v
77
+[Video Decoder] --> Frame Queue (30 frames)
78
+     |                    |
79
+     v                    v
80
+[Scaler/Converter]  [Render Thread]
81
+     |                    |
82
+     v                    v
83
+  BGRA Data         X11 PutImage
84
+```
85
+
86
+## Files Modified/Created
87
+- `/garbg/garbg/src/media/gif.rs` - GIF decoder
88
+- `/garbg/garbg/src/media/video.rs` - Video decoder (optional)
89
+- `/garbg/garbg/src/media/animation.rs` - Animation frame management
90
+- `/garbg/garbg/src/x11/animation.rs` - Double buffering
91
+- `/garbg/garbg/src/daemon/animation_loop.rs` - Frame timing loop
docs/phases/phase3-remote-sources.mdadded
@@ -0,0 +1,114 @@
1
+# Phase 3: Remote Image Sources
2
+
3
+## Goal
4
+Implement multiple image source providers for fetching wallpapers from various remote locations.
5
+
6
+## Tasks
7
+
8
+### 3.1 Provider Architecture
9
+- [ ] Define `SourceProvider` trait
10
+- [ ] Create `ProviderRegistry` for provider lookup
11
+- [ ] URI-based provider selection
12
+- [ ] Async fetch operations
13
+
14
+### 3.2 HTTP Provider
15
+- [ ] Fetch images from direct URLs
16
+- [ ] Support redirects and HTTPS
17
+- [ ] Handle common HTTP errors
18
+- [ ] Respect Content-Type headers
19
+
20
+### 3.3 GitHub Provider
21
+- [ ] Parse `github://user/repo/path` URIs
22
+- [ ] Use GitHub API for directory listings
23
+- [ ] Fetch raw file content
24
+- [ ] Handle rate limiting (with optional token)
25
+
26
+### 3.4 Directory Index Provider
27
+- [ ] Parse Apache autoindex HTML
28
+- [ ] Parse nginx autoindex HTML
29
+- [ ] Extract image links from listings
30
+- [ ] Support recursive directory traversal
31
+
32
+### 3.5 S3 Provider (Optional Feature)
33
+- [ ] Parse `s3://bucket/prefix` URIs
34
+- [ ] List objects with prefix
35
+- [ ] Support S3-compatible endpoints (MinIO, etc.)
36
+- [ ] Handle authentication
37
+
38
+### 3.6 Disk Cache
39
+- [ ] Cache fetched images to ~/.cache/garbg/
40
+- [ ] LRU eviction when cache exceeds size limit
41
+- [ ] Store metadata (URL, fetch time, ETag)
42
+- [ ] Conditional requests for cache validation
43
+
44
+## Deliverables
45
+- `garbg set https://example.com/wallpaper.png` fetches and displays
46
+- `garbg set github://user/repo/wallpapers/` lists and picks random
47
+- Directory index URLs work for bulk wallpaper sources
48
+- Fetched images are cached locally
49
+
50
+## Provider Trait
51
+
52
+```rust
53
+#[async_trait]
54
+pub trait SourceProvider: Send + Sync {
55
+    /// Provider identifier
56
+    fn id(&self) -> &str;
57
+
58
+    /// Check if this provider handles a URI
59
+    fn can_handle(&self, uri: &str) -> bool;
60
+
61
+    /// List available wallpapers from source
62
+    async fn list(&self, uri: &str) -> Result<Vec<WallpaperEntry>>;
63
+
64
+    /// Fetch a specific wallpaper
65
+    async fn fetch(&self, entry: &WallpaperEntry) -> Result<FetchedImage>;
66
+}
67
+
68
+pub struct WallpaperEntry {
69
+    pub uri: String,
70
+    pub name: String,
71
+    pub media_type: MediaType,
72
+    pub size: Option<u64>,
73
+}
74
+
75
+pub enum MediaType {
76
+    StaticImage,
77
+    AnimatedImage,
78
+    Video,
79
+}
80
+```
81
+
82
+## URI Schemes
83
+
84
+| Scheme | Example | Provider |
85
+|--------|---------|----------|
86
+| (none) | `/path/to/file.png` | FileProvider |
87
+| `file://` | `file:///path/to/file.png` | FileProvider |
88
+| `http://` | `http://example.com/img.png` | HttpProvider |
89
+| `https://` | `https://example.com/img.png` | HttpProvider |
90
+| `github://` | `github://user/repo/path` | GitHubProvider |
91
+| `s3://` | `s3://bucket/prefix` | S3Provider |
92
+
93
+## Cache Structure
94
+
95
+```
96
+~/.cache/garbg/
97
+├── index.json          # Cache index with metadata
98
+├── ab/                 # First 2 chars of hash
99
+│   └── abcd1234...     # Cached image file
100
+├── cd/
101
+│   └── cdef5678...
102
+└── ...
103
+```
104
+
105
+## Files Modified/Created
106
+- `/garbg/garbg/src/sources/mod.rs` - Sources module
107
+- `/garbg/garbg/src/sources/provider.rs` - Provider trait
108
+- `/garbg/garbg/src/sources/file.rs` - Local file provider
109
+- `/garbg/garbg/src/sources/http.rs` - HTTP provider
110
+- `/garbg/garbg/src/sources/github.rs` - GitHub provider
111
+- `/garbg/garbg/src/sources/directory.rs` - Directory index parser
112
+- `/garbg/garbg/src/sources/s3.rs` - S3 provider (optional)
113
+- `/garbg/garbg/src/cache/mod.rs` - Cache module
114
+- `/garbg/garbg/src/cache/disk.rs` - Disk cache implementation
docs/phases/phase4-daemon-integration.mdadded
@@ -0,0 +1,116 @@
1
+# Phase 4: Daemon Mode & gar Integration
2
+
3
+## Goal
4
+Implement daemon mode with IPC server, integrate with gar window manager for workspace-aware wallpapers.
5
+
6
+## Tasks
7
+
8
+### 4.1 Daemon Mode
9
+- [ ] Daemonize process (fork, setsid, etc.)
10
+- [ ] PID file management
11
+- [ ] Signal handling (SIGHUP for reload, SIGTERM for shutdown)
12
+- [ ] Graceful shutdown with resource cleanup
13
+
14
+### 4.2 IPC Server
15
+- [ ] Unix socket at `$XDG_RUNTIME_DIR/garbg.sock`
16
+- [ ] JSON lines protocol
17
+- [ ] Command parsing and dispatch
18
+- [ ] Response serialization
19
+- [ ] Client connection management
20
+
21
+### 4.3 IPC Commands
22
+- [ ] `set` - Set wallpaper
23
+- [ ] `next` / `prev` - Slideshow navigation
24
+- [ ] `random` - Random wallpaper from source
25
+- [ ] `reload` - Reload configuration
26
+- [ ] `pause` / `resume` - Animation/slideshow control
27
+- [ ] `status` - Get current state
28
+- [ ] `subscribe` - Event subscription
29
+
30
+### 4.4 gar IPC Client
31
+- [ ] Connect to gar's IPC socket
32
+- [ ] Subscribe to workspace events
33
+- [ ] Subscribe to monitor events
34
+- [ ] Parse gar's JSON event format
35
+
36
+### 4.5 Per-Workspace Wallpapers
37
+- [ ] Map workspace ID to wallpaper config
38
+- [ ] Switch wallpaper on workspace change
39
+- [ ] Smooth transition (instant, per design decision)
40
+- [ ] Fallback to default wallpaper
41
+
42
+### 4.6 Slideshow/Rotation
43
+- [ ] Timer-based rotation
44
+- [ ] Configurable interval
45
+- [ ] Random or sequential ordering
46
+- [ ] Per-source playlist management
47
+
48
+## Deliverables
49
+- `garbg daemon` starts background daemon
50
+- `garbgctl set /path/to/image.png` sends command to daemon
51
+- Changing workspaces in gar changes wallpaper
52
+- Slideshow rotates wallpapers at configured interval
53
+
54
+## IPC Protocol
55
+
56
+### Commands (Client -> Server)
57
+
58
+```json
59
+{"command": "set", "source": "/path/to/image.png", "mode": "fill"}
60
+{"command": "next"}
61
+{"command": "prev"}
62
+{"command": "random"}
63
+{"command": "reload"}
64
+{"command": "pause"}
65
+{"command": "resume"}
66
+{"command": "status"}
67
+{"command": "subscribe", "events": ["wallpaper_changed", "slideshow_advanced"]}
68
+```
69
+
70
+### Responses (Server -> Client)
71
+
72
+```json
73
+{"success": true}
74
+{"success": true, "data": {"current": "/path/to/image.png", "mode": "fill", "paused": false}}
75
+{"success": false, "error": "File not found"}
76
+```
77
+
78
+### Events (Server -> Subscribed Clients)
79
+
80
+```json
81
+{"event": "wallpaper_changed", "source": "/path/to/new.png", "workspace": 1}
82
+{"event": "slideshow_advanced", "current": 5, "total": 20}
83
+{"event": "error", "message": "Failed to fetch remote image"}
84
+```
85
+
86
+## gar Event Handling
87
+
88
+```rust
89
+// Subscribe to gar workspace events
90
+let subscribe_msg = json!({
91
+    "command": "subscribe",
92
+    "events": ["workspace"]
93
+});
94
+
95
+// Handle workspace change
96
+fn handle_gar_event(&mut self, event: GarEvent) {
97
+    match event {
98
+        GarEvent::Workspace { current, .. } => {
99
+            if let Some(config) = self.config.workspaces.get(&current) {
100
+                self.set_wallpaper(&config.source, config.mode);
101
+            }
102
+        }
103
+        _ => {}
104
+    }
105
+}
106
+```
107
+
108
+## Files Modified/Created
109
+- `/garbg/garbg/src/daemon/mod.rs` - Daemon module
110
+- `/garbg/garbg/src/daemon/state.rs` - Daemon state management
111
+- `/garbg/garbg/src/daemon/event_loop.rs` - Main event loop
112
+- `/garbg/garbg/src/daemon/signals.rs` - Signal handling
113
+- `/garbg/garbg/src/ipc/mod.rs` - IPC module
114
+- `/garbg/garbg/src/ipc/server.rs` - IPC server
115
+- `/garbg/garbg/src/ipc/protocol.rs` - Command/Response types
116
+- `/garbg/garbg/src/ipc/gar_client.rs` - gar IPC client
docs/phases/phase5-polish.mdadded
@@ -0,0 +1,157 @@
1
+# Phase 5: Polish & Integration
2
+
3
+## Goal
4
+Complete the user experience with Lua integration, CLI tool, multi-monitor support, and configuration hot-reload.
5
+
6
+## Tasks
7
+
8
+### 5.1 Lua Module for gar
9
+- [ ] Create shared library loadable by gar's Lua
10
+- [ ] Expose `garbg.set()` function
11
+- [ ] Expose `garbg.next()`, `garbg.prev()`, `garbg.random()`
12
+- [ ] Expose `garbg.config()` for declarative setup
13
+- [ ] Handle IPC connection from Lua context
14
+
15
+### 5.2 garbgctl CLI Tool
16
+- [ ] `garbgctl set <source>` - Set wallpaper
17
+- [ ] `garbgctl next` / `prev` - Slideshow control
18
+- [ ] `garbgctl random` - Random wallpaper
19
+- [ ] `garbgctl status` - Show current state
20
+- [ ] `garbgctl reload` - Reload config
21
+- [ ] `garbgctl pause` / `resume` - Animation control
22
+- [ ] Pretty-printed output with colors
23
+
24
+### 5.3 Multi-Monitor Support
25
+- [ ] Detect monitors via RandR
26
+- [ ] Per-monitor wallpaper configuration
27
+- [ ] Handle monitor hotplug events
28
+- [ ] Composite wallpapers for spanning setups
29
+- [ ] Independent slideshows per monitor
30
+
31
+### 5.4 Configuration Hot-Reload
32
+- [ ] Watch config file with inotify
33
+- [ ] Parse and validate new config
34
+- [ ] Apply changes without restart
35
+- [ ] SIGHUP trigger for manual reload
36
+- [ ] Report config errors via IPC
37
+
38
+### 5.5 Error Handling & Logging
39
+- [ ] Structured logging with tracing
40
+- [ ] Log levels (error, warn, info, debug, trace)
41
+- [ ] Journal/syslog integration for daemon
42
+- [ ] User-friendly error messages
43
+- [ ] Detailed errors for debugging
44
+
45
+### 5.6 Documentation
46
+- [ ] Man page for garbg
47
+- [ ] Man page for garbgctl
48
+- [ ] Example configurations
49
+- [ ] Integration guide for gar
50
+
51
+## Deliverables
52
+- Lua module works in gar's init.lua
53
+- garbgctl provides full control over daemon
54
+- Multi-monitor setups work correctly
55
+- Config changes apply without restart
56
+
57
+## Lua Integration Example
58
+
59
+```lua
60
+-- ~/.config/gar/init.lua
61
+local garbg = require("garbg")
62
+
63
+-- Declarative configuration
64
+garbg.config({
65
+    default = {
66
+        source = "~/Pictures/wallpapers",
67
+        mode = "fill",
68
+        slideshow = {
69
+            enabled = true,
70
+            interval = 300,
71
+            shuffle = true,
72
+        },
73
+    },
74
+    workspaces = {
75
+        [1] = "~/Pictures/workspace1.png",
76
+        [2] = { source = "~/Videos/loop.mp4", mode = "fill" },
77
+        [3] = "github://user/repo/wallpapers/ws3.png",
78
+    },
79
+    monitors = {
80
+        ["DP-1"] = { source = "~/Pictures/wide/", mode = "fill" },
81
+    },
82
+})
83
+
84
+-- Keybinds
85
+gar.bind("mod+w", function() garbg.next() end)
86
+gar.bind("mod+shift+w", function() garbg.random() end)
87
+
88
+-- React to workspace changes
89
+gar.on("workspace", function(event)
90
+    garbg.switch_workspace(event.current)
91
+end)
92
+```
93
+
94
+## garbgctl Usage
95
+
96
+```bash
97
+# Set wallpaper
98
+garbgctl set ~/Pictures/wallpaper.png
99
+garbgctl set github://user/repo/wallpapers --random
100
+
101
+# Slideshow control
102
+garbgctl next
103
+garbgctl prev
104
+garbgctl random
105
+
106
+# Animation control
107
+garbgctl pause
108
+garbgctl resume
109
+
110
+# Status and management
111
+garbgctl status
112
+garbgctl reload
113
+
114
+# Output example
115
+$ garbgctl status
116
+garbg daemon v0.1.0
117
+  Status: running
118
+  Current: ~/Pictures/wallpaper.png
119
+  Mode: fill
120
+  Slideshow: enabled (5m interval, 12/50)
121
+  Animation: playing
122
+  Monitors: DP-1, HDMI-1
123
+```
124
+
125
+## Multi-Monitor Configuration
126
+
127
+```toml
128
+# ~/.config/garbg/config.toml
129
+
130
+[[monitors]]
131
+name = "DP-1"
132
+source = "~/Pictures/wide/"
133
+mode = "fill"
134
+slideshow = true
135
+interval = "10m"
136
+
137
+[[monitors]]
138
+name = "HDMI-1"
139
+source = "~/Pictures/vertical/"
140
+mode = "fit"
141
+
142
+# Spanning mode (single wallpaper across all monitors)
143
+[spanning]
144
+enabled = false
145
+source = "~/Pictures/ultrawide.png"
146
+mode = "fill"
147
+```
148
+
149
+## Files Modified/Created
150
+- `/garbg/garbg/src/lua/mod.rs` - Lua module
151
+- `/garbg/garbg/src/lua/api.rs` - Lua API functions
152
+- `/garbg/garbgctl/src/main.rs` - CLI implementation
153
+- `/garbg/garbg/src/x11/monitors.rs` - RandR implementation
154
+- `/garbg/garbg/src/config/watch.rs` - Config file watching
155
+- `/garbg/docs/garbg.1.md` - Man page source
156
+- `/garbg/docs/garbgctl.1.md` - Man page source
157
+- `/garbg/config/default.toml` - Example configuration
garbg.serviceadded
@@ -0,0 +1,22 @@
1
+[Unit]
2
+Description=garbg wallpaper daemon for gar window manager
3
+Documentation=https://github.com/tenseleyFlow/garbg
4
+After=graphical-session.target
5
+
6
+[Service]
7
+Type=simple
8
+
9
+# Development path (current build)
10
+ExecStart=/home/mfwolffe/GithubOrgs/tenseleyFlow/garbg/target/release/garbg daemon
11
+
12
+# Production path (after install to PATH)
13
+#ExecStart=garbg daemon
14
+
15
+Restart=on-failure
16
+RestartSec=5
17
+
18
+# Environment
19
+Environment=DISPLAY=:0
20
+
21
+[Install]
22
+WantedBy=default.target
garbg/Cargo.tomladded
@@ -0,0 +1,47 @@
1
+[package]
2
+name = "garbg"
3
+version.workspace = true
4
+edition.workspace = true
5
+authors.workspace = true
6
+license.workspace = true
7
+description = "A bespoke wallpaper daemon for the gar window manager"
8
+
9
+[[bin]]
10
+name = "garbg"
11
+path = "src/main.rs"
12
+
13
+[lib]
14
+name = "garbg"
15
+path = "src/lib.rs"
16
+
17
+[dependencies]
18
+x11rb.workspace = true
19
+tokio.workspace = true
20
+image.workspace = true
21
+toml.workspace = true
22
+serde.workspace = true
23
+serde_json.workspace = true
24
+reqwest.workspace = true
25
+scraper.workspace = true
26
+lru.workspace = true
27
+tracing.workspace = true
28
+tracing-subscriber.workspace = true
29
+thiserror.workspace = true
30
+anyhow.workspace = true
31
+clap.workspace = true
32
+async-trait.workspace = true
33
+blake3.workspace = true
34
+chrono.workspace = true
35
+humantime.workspace = true
36
+crossbeam-channel.workspace = true
37
+dirs.workspace = true
38
+shellexpand.workspace = true
39
+url.workspace = true
40
+rand.workspace = true
41
+
42
+[features]
43
+default = []
44
+# Video support via ffmpeg (optional, requires system ffmpeg)
45
+video = []
46
+# S3/object storage support
47
+s3 = []
garbg/src/cache/disk.rsadded
@@ -0,0 +1,189 @@
1
+//! Disk cache for remote images
2
+
3
+use anyhow::{Context, Result};
4
+use serde::{Deserialize, Serialize};
5
+use std::collections::HashMap;
6
+use std::path::{Path, PathBuf};
7
+use std::time::SystemTime;
8
+
9
+/// Disk cache for remote images
10
+pub struct DiskCache {
11
+    cache_dir: PathBuf,
12
+    max_size: u64,
13
+    index: CacheIndex,
14
+}
15
+
16
+/// Cache index stored on disk
17
+#[derive(Debug, Default, Serialize, Deserialize)]
18
+struct CacheIndex {
19
+    entries: HashMap<String, CacheEntry>,
20
+    total_size: u64,
21
+}
22
+
23
+/// Individual cache entry
24
+#[derive(Debug, Clone, Serialize, Deserialize)]
25
+struct CacheEntry {
26
+    /// Hash of the original URI
27
+    uri_hash: String,
28
+    /// Original URI
29
+    original_uri: String,
30
+    /// Path to cached file (relative to cache dir)
31
+    file_path: String,
32
+    /// File size in bytes
33
+    size: u64,
34
+    /// When the file was fetched
35
+    fetched_at: SystemTime,
36
+    /// Last access time
37
+    last_accessed: SystemTime,
38
+    /// HTTP ETag for conditional requests
39
+    etag: Option<String>,
40
+}
41
+
42
+impl DiskCache {
43
+    /// Create a new disk cache
44
+    pub fn new(cache_dir: PathBuf, max_size_mb: u64) -> Result<Self> {
45
+        std::fs::create_dir_all(&cache_dir)
46
+            .with_context(|| format!("Failed to create cache directory: {}", cache_dir.display()))?;
47
+
48
+        let index_path = cache_dir.join("index.json");
49
+        let index = if index_path.exists() {
50
+            let data = std::fs::read_to_string(&index_path)?;
51
+            serde_json::from_str(&data).unwrap_or_default()
52
+        } else {
53
+            CacheIndex::default()
54
+        };
55
+
56
+        Ok(Self {
57
+            cache_dir,
58
+            max_size: max_size_mb * 1024 * 1024,
59
+            index,
60
+        })
61
+    }
62
+
63
+    /// Get the default cache directory
64
+    pub fn default_dir() -> Option<PathBuf> {
65
+        dirs::cache_dir().map(|d| d.join("garbg"))
66
+    }
67
+
68
+    /// Check if a URI is cached
69
+    pub fn is_cached(&self, uri: &str) -> bool {
70
+        let hash = Self::hash_uri(uri);
71
+        if let Some(entry) = self.index.entries.get(&hash) {
72
+            let path = self.cache_dir.join(&entry.file_path);
73
+            path.exists()
74
+        } else {
75
+            false
76
+        }
77
+    }
78
+
79
+    /// Get cached file path for a URI
80
+    pub fn get(&mut self, uri: &str) -> Option<PathBuf> {
81
+        let hash = Self::hash_uri(uri);
82
+        if let Some(entry) = self.index.entries.get_mut(&hash) {
83
+            let path = self.cache_dir.join(&entry.file_path);
84
+            if path.exists() {
85
+                entry.last_accessed = SystemTime::now();
86
+                return Some(path);
87
+            }
88
+        }
89
+        None
90
+    }
91
+
92
+    /// Store data in the cache
93
+    pub fn store(&mut self, uri: &str, data: &[u8], etag: Option<String>) -> Result<PathBuf> {
94
+        let hash = Self::hash_uri(uri);
95
+
96
+        // Create subdirectory based on first 2 chars of hash
97
+        let subdir = &hash[..2];
98
+        let dir = self.cache_dir.join(subdir);
99
+        std::fs::create_dir_all(&dir)?;
100
+
101
+        // Write file
102
+        let file_path = format!("{}/{}", subdir, hash);
103
+        let full_path = self.cache_dir.join(&file_path);
104
+        std::fs::write(&full_path, data)?;
105
+
106
+        // Update index
107
+        let entry = CacheEntry {
108
+            uri_hash: hash.clone(),
109
+            original_uri: uri.to_string(),
110
+            file_path,
111
+            size: data.len() as u64,
112
+            fetched_at: SystemTime::now(),
113
+            last_accessed: SystemTime::now(),
114
+            etag,
115
+        };
116
+
117
+        // Remove old entry if exists
118
+        if let Some(old) = self.index.entries.remove(&hash) {
119
+            self.index.total_size -= old.size;
120
+        }
121
+
122
+        self.index.total_size += entry.size;
123
+        self.index.entries.insert(hash, entry);
124
+
125
+        // Evict if over size limit
126
+        self.evict_if_needed()?;
127
+
128
+        // Save index
129
+        self.save_index()?;
130
+
131
+        Ok(full_path)
132
+    }
133
+
134
+    /// Evict old entries if cache is over size limit
135
+    fn evict_if_needed(&mut self) -> Result<()> {
136
+        if self.index.total_size <= self.max_size {
137
+            return Ok(());
138
+        }
139
+
140
+        // Sort by last accessed time (oldest first)
141
+        let mut entries: Vec<_> = self.index.entries.values().cloned().collect();
142
+        entries.sort_by_key(|e| e.last_accessed);
143
+
144
+        // Remove oldest until under limit
145
+        for entry in entries {
146
+            if self.index.total_size <= self.max_size {
147
+                break;
148
+            }
149
+
150
+            let path = self.cache_dir.join(&entry.file_path);
151
+            if path.exists() {
152
+                std::fs::remove_file(&path)?;
153
+            }
154
+
155
+            self.index.total_size -= entry.size;
156
+            self.index.entries.remove(&entry.uri_hash);
157
+        }
158
+
159
+        Ok(())
160
+    }
161
+
162
+    /// Save the cache index to disk
163
+    fn save_index(&self) -> Result<()> {
164
+        let index_path = self.cache_dir.join("index.json");
165
+        let data = serde_json::to_string_pretty(&self.index)?;
166
+        std::fs::write(index_path, data)?;
167
+        Ok(())
168
+    }
169
+
170
+    /// Clear the entire cache
171
+    pub fn clear(&mut self) -> Result<()> {
172
+        for entry in self.index.entries.values() {
173
+            let path = self.cache_dir.join(&entry.file_path);
174
+            if path.exists() {
175
+                let _ = std::fs::remove_file(&path);
176
+            }
177
+        }
178
+        self.index.entries.clear();
179
+        self.index.total_size = 0;
180
+        self.save_index()?;
181
+        Ok(())
182
+    }
183
+
184
+    /// Hash a URI for cache key
185
+    fn hash_uri(uri: &str) -> String {
186
+        let hash = blake3::hash(uri.as_bytes());
187
+        hash.to_hex().to_string()
188
+    }
189
+}
garbg/src/cache/memory.rsadded
@@ -0,0 +1,82 @@
1
+//! Memory cache for decoded frames
2
+
3
+use image::RgbaImage;
4
+use lru::LruCache;
5
+use std::num::NonZeroUsize;
6
+
7
+/// Key for frame cache
8
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
9
+pub struct FrameKey {
10
+    /// Source URI
11
+    pub uri: String,
12
+    /// Frame index (0 for static images)
13
+    pub frame_index: usize,
14
+    /// Target width
15
+    pub width: u16,
16
+    /// Target height
17
+    pub height: u16,
18
+}
19
+
20
+/// Cached frame data
21
+pub struct CachedFrame {
22
+    /// Decoded RGBA image
23
+    pub image: RgbaImage,
24
+    /// Memory size in bytes
25
+    pub size: usize,
26
+}
27
+
28
+/// LRU cache for decoded frames
29
+pub struct FrameCache {
30
+    cache: LruCache<FrameKey, CachedFrame>,
31
+    max_memory: usize,
32
+    current_memory: usize,
33
+}
34
+
35
+impl FrameCache {
36
+    /// Create a new frame cache with maximum memory limit
37
+    pub fn new(max_memory_mb: usize) -> Self {
38
+        // Allow up to 1000 frames
39
+        let capacity = NonZeroUsize::new(1000).unwrap();
40
+        Self {
41
+            cache: LruCache::new(capacity),
42
+            max_memory: max_memory_mb * 1024 * 1024,
43
+            current_memory: 0,
44
+        }
45
+    }
46
+
47
+    /// Get a frame from cache
48
+    pub fn get(&mut self, key: &FrameKey) -> Option<&CachedFrame> {
49
+        self.cache.get(key)
50
+    }
51
+
52
+    /// Put a frame in cache
53
+    pub fn put(&mut self, key: FrameKey, frame: CachedFrame) {
54
+        let size = frame.size;
55
+
56
+        // Evict if adding this would exceed limit
57
+        while self.current_memory + size > self.max_memory {
58
+            if let Some((_, evicted)) = self.cache.pop_lru() {
59
+                self.current_memory -= evicted.size;
60
+            } else {
61
+                break;
62
+            }
63
+        }
64
+
65
+        // Add to cache
66
+        if let Some((_, old)) = self.cache.push(key, frame) {
67
+            self.current_memory -= old.size;
68
+        }
69
+        self.current_memory += size;
70
+    }
71
+
72
+    /// Clear the cache
73
+    pub fn clear(&mut self) {
74
+        self.cache.clear();
75
+        self.current_memory = 0;
76
+    }
77
+
78
+    /// Get current memory usage
79
+    pub fn memory_usage(&self) -> usize {
80
+        self.current_memory
81
+    }
82
+}
garbg/src/cache/mod.rsadded
@@ -0,0 +1,9 @@
1
+//! Caching for remote images
2
+//!
3
+//! Provides disk and memory caching for fetched wallpapers.
4
+
5
+mod disk;
6
+mod memory;
7
+
8
+pub use disk::DiskCache;
9
+pub use memory::FrameCache;
garbg/src/config/mod.rsadded
@@ -0,0 +1,42 @@
1
+//! Configuration management
2
+//!
3
+//! Handles TOML configuration files and runtime settings.
4
+
5
+mod types;
6
+
7
+pub use types::{Config, ScaleMode, WorkspaceConfig, MonitorConfig, SlideshowConfig};
8
+
9
+use anyhow::{Context, Result};
10
+use std::path::Path;
11
+
12
+impl Config {
13
+    /// Load configuration from a TOML file
14
+    pub fn load<P: AsRef<Path>>(path: P) -> Result<Self> {
15
+        let path = path.as_ref();
16
+        let content = std::fs::read_to_string(path)
17
+            .with_context(|| format!("Failed to read config file: {}", path.display()))?;
18
+
19
+        toml::from_str(&content)
20
+            .with_context(|| format!("Failed to parse config file: {}", path.display()))
21
+    }
22
+
23
+    /// Load configuration from the default location
24
+    pub fn load_default() -> Result<Self> {
25
+        let config_dir = dirs::config_dir()
26
+            .context("Could not determine config directory")?
27
+            .join("garbg");
28
+
29
+        let config_path = config_dir.join("config.toml");
30
+
31
+        if config_path.exists() {
32
+            Self::load(&config_path)
33
+        } else {
34
+            Ok(Self::default())
35
+        }
36
+    }
37
+
38
+    /// Get the default config file path
39
+    pub fn default_path() -> Option<std::path::PathBuf> {
40
+        dirs::config_dir().map(|d| d.join("garbg").join("config.toml"))
41
+    }
42
+}
garbg/src/config/types.rsadded
@@ -0,0 +1,255 @@
1
+//! Configuration type definitions
2
+
3
+use serde::{Deserialize, Serialize};
4
+use std::collections::HashMap;
5
+use std::str::FromStr;
6
+use std::time::Duration;
7
+
8
+/// Main configuration structure
9
+#[derive(Debug, Clone, Serialize, Deserialize)]
10
+#[serde(default)]
11
+pub struct Config {
12
+    /// General settings
13
+    pub general: GeneralConfig,
14
+
15
+    /// Animation settings
16
+    pub animation: AnimationConfig,
17
+
18
+    /// Cache settings
19
+    pub cache: CacheConfig,
20
+
21
+    /// Default wallpaper source
22
+    pub default: DefaultConfig,
23
+
24
+    /// Per-workspace wallpaper configurations
25
+    #[serde(default)]
26
+    pub workspaces: Vec<WorkspaceConfig>,
27
+
28
+    /// Per-monitor wallpaper configurations
29
+    #[serde(default)]
30
+    pub monitors: Vec<MonitorConfig>,
31
+}
32
+
33
+impl Default for Config {
34
+    fn default() -> Self {
35
+        Self {
36
+            general: GeneralConfig::default(),
37
+            animation: AnimationConfig::default(),
38
+            cache: CacheConfig::default(),
39
+            default: DefaultConfig::default(),
40
+            workspaces: Vec::new(),
41
+            monitors: Vec::new(),
42
+        }
43
+    }
44
+}
45
+
46
+/// General configuration options
47
+#[derive(Debug, Clone, Serialize, Deserialize)]
48
+#[serde(default)]
49
+pub struct GeneralConfig {
50
+    /// Default scaling mode
51
+    pub mode: ScaleMode,
52
+}
53
+
54
+impl Default for GeneralConfig {
55
+    fn default() -> Self {
56
+        Self {
57
+            mode: ScaleMode::Fill,
58
+        }
59
+    }
60
+}
61
+
62
+/// Animation configuration
63
+#[derive(Debug, Clone, Serialize, Deserialize)]
64
+#[serde(default)]
65
+pub struct AnimationConfig {
66
+    /// Whether animations are enabled
67
+    pub enabled: bool,
68
+
69
+    /// Maximum FPS for animations
70
+    pub max_fps: u32,
71
+
72
+    /// Pause animations when idle/locked
73
+    pub pause_on_idle: bool,
74
+}
75
+
76
+impl Default for AnimationConfig {
77
+    fn default() -> Self {
78
+        Self {
79
+            enabled: true,
80
+            max_fps: 60,
81
+            pause_on_idle: true,
82
+        }
83
+    }
84
+}
85
+
86
+/// Cache configuration
87
+#[derive(Debug, Clone, Serialize, Deserialize)]
88
+#[serde(default)]
89
+pub struct CacheConfig {
90
+    /// Cache directory (default: ~/.cache/garbg)
91
+    pub directory: Option<String>,
92
+
93
+    /// Maximum cache size in MB
94
+    pub max_size_mb: u64,
95
+
96
+    /// Maximum age of cached items in days
97
+    pub max_age_days: u32,
98
+}
99
+
100
+impl Default for CacheConfig {
101
+    fn default() -> Self {
102
+        Self {
103
+            directory: None,
104
+            max_size_mb: 1024,
105
+            max_age_days: 30,
106
+        }
107
+    }
108
+}
109
+
110
+/// Default wallpaper configuration
111
+#[derive(Debug, Clone, Serialize, Deserialize)]
112
+#[serde(default)]
113
+pub struct DefaultConfig {
114
+    /// Source path or URI
115
+    pub source: String,
116
+
117
+    /// Scaling mode
118
+    pub mode: ScaleMode,
119
+
120
+    /// Slideshow configuration
121
+    pub slideshow: Option<SlideshowConfig>,
122
+}
123
+
124
+impl Default for DefaultConfig {
125
+    fn default() -> Self {
126
+        Self {
127
+            source: String::new(),
128
+            mode: ScaleMode::Fill,
129
+            slideshow: None,
130
+        }
131
+    }
132
+}
133
+
134
+/// Per-workspace wallpaper configuration
135
+#[derive(Debug, Clone, Serialize, Deserialize)]
136
+pub struct WorkspaceConfig {
137
+    /// Workspace ID (1-indexed)
138
+    pub id: usize,
139
+
140
+    /// Source path or URI
141
+    pub source: String,
142
+
143
+    /// Scaling mode (optional, uses default if not specified)
144
+    #[serde(default)]
145
+    pub mode: Option<ScaleMode>,
146
+}
147
+
148
+/// Per-monitor wallpaper configuration
149
+#[derive(Debug, Clone, Serialize, Deserialize)]
150
+pub struct MonitorConfig {
151
+    /// Monitor name (RandR output name, e.g., "DP-1")
152
+    pub name: String,
153
+
154
+    /// Source path or URI
155
+    pub source: String,
156
+
157
+    /// Scaling mode (optional, uses default if not specified)
158
+    #[serde(default)]
159
+    pub mode: Option<ScaleMode>,
160
+
161
+    /// Slideshow configuration (optional)
162
+    #[serde(default)]
163
+    pub slideshow: Option<SlideshowConfig>,
164
+}
165
+
166
+/// Slideshow configuration
167
+#[derive(Debug, Clone, Serialize, Deserialize)]
168
+#[serde(default)]
169
+pub struct SlideshowConfig {
170
+    /// Whether slideshow is enabled
171
+    pub enabled: bool,
172
+
173
+    /// Interval between slides (e.g., "5m", "1h")
174
+    #[serde(with = "humantime_serde")]
175
+    pub interval: Duration,
176
+
177
+    /// Shuffle order
178
+    pub shuffle: bool,
179
+}
180
+
181
+impl Default for SlideshowConfig {
182
+    fn default() -> Self {
183
+        Self {
184
+            enabled: false,
185
+            interval: Duration::from_secs(300), // 5 minutes
186
+            shuffle: true,
187
+        }
188
+    }
189
+}
190
+
191
+/// Image scaling mode
192
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
193
+#[serde(rename_all = "lowercase")]
194
+pub enum ScaleMode {
195
+    /// Scale to fill the screen, cropping excess
196
+    #[default]
197
+    Fill,
198
+    /// Scale to fit within the screen, letterboxing if needed
199
+    Fit,
200
+    /// Stretch to exact screen size, ignoring aspect ratio
201
+    Stretch,
202
+    /// Display at original size, centered
203
+    Center,
204
+    /// Tile the image to fill the screen
205
+    Tile,
206
+}
207
+
208
+impl FromStr for ScaleMode {
209
+    type Err = anyhow::Error;
210
+
211
+    fn from_str(s: &str) -> Result<Self, Self::Err> {
212
+        match s.to_lowercase().as_str() {
213
+            "fill" => Ok(ScaleMode::Fill),
214
+            "fit" => Ok(ScaleMode::Fit),
215
+            "stretch" => Ok(ScaleMode::Stretch),
216
+            "center" => Ok(ScaleMode::Center),
217
+            "tile" => Ok(ScaleMode::Tile),
218
+            _ => anyhow::bail!("Unknown scale mode: {}. Valid modes: fill, fit, stretch, center, tile", s),
219
+        }
220
+    }
221
+}
222
+
223
+impl std::fmt::Display for ScaleMode {
224
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225
+        match self {
226
+            ScaleMode::Fill => write!(f, "fill"),
227
+            ScaleMode::Fit => write!(f, "fit"),
228
+            ScaleMode::Stretch => write!(f, "stretch"),
229
+            ScaleMode::Center => write!(f, "center"),
230
+            ScaleMode::Tile => write!(f, "tile"),
231
+        }
232
+    }
233
+}
234
+
235
+/// Serde helper for humantime durations
236
+mod humantime_serde {
237
+    use serde::{self, Deserialize, Deserializer, Serializer};
238
+    use std::time::Duration;
239
+
240
+    pub fn serialize<S>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error>
241
+    where
242
+        S: Serializer,
243
+    {
244
+        let s = humantime::format_duration(*duration).to_string();
245
+        serializer.serialize_str(&s)
246
+    }
247
+
248
+    pub fn deserialize<'de, D>(deserializer: D) -> Result<Duration, D::Error>
249
+    where
250
+        D: Deserializer<'de>,
251
+    {
252
+        let s = String::deserialize(deserializer)?;
253
+        humantime::parse_duration(&s).map_err(serde::de::Error::custom)
254
+    }
255
+}
garbg/src/daemon/mod.rsadded
@@ -0,0 +1,7 @@
1
+//! Daemon mode for garbg
2
+//!
3
+//! Runs as a background service managing wallpapers.
4
+
5
+mod state;
6
+
7
+pub use state::{Daemon, DaemonState};
garbg/src/daemon/state.rsadded
@@ -0,0 +1,606 @@
1
+//! Daemon state management
2
+
3
+use anyhow::Result;
4
+use std::collections::HashMap;
5
+use std::time::Duration;
6
+use tokio::net::UnixStream;
7
+
8
+use crate::config::{Config, ScaleMode};
9
+use crate::ipc::{Command, GarEvent, GarIpcClient, IpcServer, Response};
10
+use crate::ipc::server::IpcClient;
11
+use crate::media::{scale_image, ImageLoader};
12
+use crate::state::{detect_source_type, PlaylistState};
13
+use crate::x11::Connection;
14
+
15
+/// Current wallpaper state for a monitor
16
+#[derive(Debug, Clone)]
17
+pub struct MonitorWallpaper {
18
+    /// Monitor name
19
+    pub name: String,
20
+    /// Current wallpaper source
21
+    pub source: String,
22
+    /// Scale mode
23
+    pub mode: ScaleMode,
24
+}
25
+
26
+/// Main daemon state
27
+pub struct DaemonState {
28
+    /// Current wallpaper per monitor
29
+    pub monitors: HashMap<String, MonitorWallpaper>,
30
+
31
+    /// Current workspace
32
+    pub current_workspace: usize,
33
+
34
+    /// Whether slideshow/animations are paused
35
+    pub paused: bool,
36
+
37
+    /// Configuration
38
+    pub config: Config,
39
+
40
+    /// Current playlist state (if any)
41
+    pub playlist: Option<PlaylistState>,
42
+
43
+    /// Current slideshow interval (None = no auto-rotation)
44
+    pub slideshow_interval: Option<Duration>,
45
+}
46
+
47
+impl DaemonState {
48
+    pub fn new(config: Config) -> Self {
49
+        // Try to load existing playlist state
50
+        let playlist = PlaylistState::load().ok().flatten();
51
+
52
+        // Get initial slideshow interval from config
53
+        let slideshow_interval = config.default.slideshow
54
+            .as_ref()
55
+            .filter(|s| s.enabled)
56
+            .map(|s| s.interval);
57
+
58
+        Self {
59
+            monitors: HashMap::new(),
60
+            current_workspace: 1,
61
+            paused: false,
62
+            config,
63
+            playlist,
64
+            slideshow_interval,
65
+        }
66
+    }
67
+}
68
+
69
+/// Main daemon struct
70
+pub struct Daemon {
71
+    /// X11 connection
72
+    conn: Connection,
73
+
74
+    /// Daemon state
75
+    state: DaemonState,
76
+}
77
+
78
+impl Daemon {
79
+    /// Create a new daemon
80
+    pub fn new(config: Config) -> Result<Self> {
81
+        let conn = Connection::new()?;
82
+        let state = DaemonState::new(config);
83
+
84
+        Ok(Self { conn, state })
85
+    }
86
+
87
+    /// Run the daemon event loop
88
+    pub async fn run(&mut self) -> Result<()> {
89
+        let server = IpcServer::new().await?;
90
+        tracing::info!("Listening on {}", server.path().display());
91
+
92
+        // Set initial wallpaper from config if specified
93
+        if !self.state.config.default.source.is_empty() {
94
+            if let Err(e) = self.apply_default_wallpaper() {
95
+                tracing::warn!("Failed to set initial wallpaper: {}", e);
96
+            }
97
+        }
98
+
99
+        // Try to connect to gar (optional)
100
+        let mut gar_client = self.try_connect_gar().await;
101
+
102
+        // Track next slideshow time
103
+        let mut next_slideshow: Option<tokio::time::Instant> = self.state.slideshow_interval
104
+            .map(|d| tokio::time::Instant::now() + d);
105
+
106
+        tracing::info!("Daemon started");
107
+        if let Some(interval) = self.state.slideshow_interval {
108
+            tracing::info!("Slideshow enabled: {:?} interval", interval);
109
+        }
110
+
111
+        // Main event loop
112
+        loop {
113
+            tokio::select! {
114
+                // IPC client connection
115
+                result = server.accept() => {
116
+                    match result {
117
+                        Ok(stream) => {
118
+                            if let Err(e) = self.handle_client(stream).await {
119
+                                tracing::debug!("Client error: {}", e);
120
+                            }
121
+                            // Update slideshow timer if interval changed
122
+                            next_slideshow = self.state.slideshow_interval
123
+                                .map(|d| tokio::time::Instant::now() + d);
124
+                        }
125
+                        Err(e) => {
126
+                            tracing::warn!("Accept error: {}", e);
127
+                        }
128
+                    }
129
+                }
130
+
131
+                // Slideshow timer (only if enabled and not paused)
132
+                _ = async {
133
+                    match (next_slideshow, self.state.paused) {
134
+                        (Some(deadline), false) => {
135
+                            tokio::time::sleep_until(deadline).await;
136
+                        }
137
+                        _ => {
138
+                            std::future::pending::<()>().await;
139
+                        }
140
+                    }
141
+                } => {
142
+                    if let Err(e) = self.advance_slideshow() {
143
+                        tracing::warn!("Slideshow advance failed: {}", e);
144
+                    }
145
+                    // Reset timer using current interval
146
+                    next_slideshow = self.state.slideshow_interval
147
+                        .map(|d| tokio::time::Instant::now() + d);
148
+                }
149
+
150
+                // gar workspace events (only if connected)
151
+                event = async {
152
+                    if let Some(ref mut client) = gar_client {
153
+                        client.read_event().await
154
+                    } else {
155
+                        std::future::pending().await
156
+                    }
157
+                } => {
158
+                    match event {
159
+                        Ok(event) => {
160
+                            if let Err(e) = self.handle_gar_event(event) {
161
+                                tracing::warn!("gar event handling failed: {}", e);
162
+                            }
163
+                        }
164
+                        Err(e) => {
165
+                            tracing::debug!("gar connection lost: {}", e);
166
+                            gar_client = None;
167
+                        }
168
+                    }
169
+                }
170
+            }
171
+        }
172
+    }
173
+
174
+    /// Handle a single client connection
175
+    async fn handle_client(&mut self, stream: UnixStream) -> Result<()> {
176
+        let mut client = IpcClient::new(stream);
177
+
178
+        // Single request-response per connection (stateless)
179
+        if let Some(cmd) = client.read_command().await? {
180
+            let response = self.handle_command(cmd);
181
+            client.send_response(&response).await?;
182
+        }
183
+
184
+        Ok(())
185
+    }
186
+
187
+    /// Handle an IPC command
188
+    fn handle_command(&mut self, cmd: Command) -> Response {
189
+        match cmd {
190
+            Command::Set { source, mode, monitor: _, interval_secs, shuffle } => {
191
+                let scale_mode = mode.unwrap_or(self.state.config.general.mode);
192
+
193
+                // Set up slideshow with the new source
194
+                match self.set_wallpaper_with_options(&source, scale_mode, shuffle, interval_secs) {
195
+                    Ok(_) => {
196
+                        // Update slideshow interval
197
+                        self.state.slideshow_interval = interval_secs.map(Duration::from_secs);
198
+
199
+                        if let Some(secs) = interval_secs {
200
+                            tracing::info!("Slideshow started: {} second interval", secs);
201
+                        }
202
+
203
+                        Response::ok()
204
+                    }
205
+                    Err(e) => Response::error(e.to_string()),
206
+                }
207
+            }
208
+            Command::SetWorkspace { workspace, source, mode } => {
209
+                let scale_mode = mode.unwrap_or(self.state.config.general.mode);
210
+                // Only set if we're on this workspace
211
+                if self.state.current_workspace == workspace {
212
+                    match self.set_wallpaper_from_source(&source, scale_mode, false) {
213
+                        Ok(_) => Response::ok(),
214
+                        Err(e) => Response::error(e.to_string()),
215
+                    }
216
+                } else {
217
+                    Response::ok() // Ignore, we're not on this workspace
218
+                }
219
+            }
220
+            Command::Next { .. } => {
221
+                match self.advance_slideshow() {
222
+                    Ok(_) => Response::ok(),
223
+                    Err(e) => Response::error(e.to_string()),
224
+                }
225
+            }
226
+            Command::Prev { .. } => {
227
+                match self.prev_slideshow() {
228
+                    Ok(_) => Response::ok(),
229
+                    Err(e) => Response::error(e.to_string()),
230
+                }
231
+            }
232
+            Command::Random { .. } => {
233
+                match self.random_wallpaper() {
234
+                    Ok(_) => Response::ok(),
235
+                    Err(e) => Response::error(e.to_string()),
236
+                }
237
+            }
238
+            Command::Status => {
239
+                Response::ok_with_data(self.get_status())
240
+            }
241
+            Command::Pause => {
242
+                self.state.paused = true;
243
+                tracing::info!("Slideshow paused");
244
+                Response::ok()
245
+            }
246
+            Command::Resume => {
247
+                self.state.paused = false;
248
+                tracing::info!("Slideshow resumed");
249
+                Response::ok()
250
+            }
251
+            Command::Toggle => {
252
+                self.state.paused = !self.state.paused;
253
+                tracing::info!("Slideshow {}", if self.state.paused { "paused" } else { "resumed" });
254
+                Response::ok()
255
+            }
256
+            Command::Reload => {
257
+                match self.reload_config() {
258
+                    Ok(_) => Response::ok(),
259
+                    Err(e) => Response::error(e.to_string()),
260
+                }
261
+            }
262
+            Command::ClearCache => {
263
+                // TODO: Implement cache clearing
264
+                Response::ok()
265
+            }
266
+            Command::List { source } => {
267
+                match self.list_source(&source) {
268
+                    Ok(images) => Response::ok_with_data(serde_json::json!(images)),
269
+                    Err(e) => Response::error(e.to_string()),
270
+                }
271
+            }
272
+            Command::Subscribe { .. } | Command::Unsubscribe { .. } => {
273
+                // Subscriptions not yet implemented
274
+                Response::error("Subscriptions not yet implemented")
275
+            }
276
+        }
277
+    }
278
+
279
+    /// Handle a gar event
280
+    fn handle_gar_event(&mut self, event: GarEvent) -> Result<()> {
281
+        match event {
282
+            GarEvent::Workspace { current, previous } => {
283
+                tracing::debug!("Workspace changed: {} -> {}", previous, current);
284
+                self.on_workspace_change(current)?;
285
+            }
286
+            GarEvent::Monitor { name, action } => {
287
+                tracing::debug!("Monitor {}: {}", action, name);
288
+                // TODO: Handle monitor changes
289
+            }
290
+            GarEvent::Focus { .. } => {
291
+                // Ignore focus events
292
+            }
293
+            GarEvent::Unknown => {
294
+                // Ignore unknown events
295
+            }
296
+        }
297
+        Ok(())
298
+    }
299
+
300
+    /// Try to connect to gar IPC
301
+    async fn try_connect_gar(&self) -> Option<GarIpcClient> {
302
+        match GarIpcClient::connect().await {
303
+            Ok(mut client) => {
304
+                if client.subscribe(&["workspace"]).await.is_ok() {
305
+                    tracing::info!("Connected to gar IPC");
306
+                    Some(client)
307
+                } else {
308
+                    tracing::debug!("Failed to subscribe to gar events");
309
+                    None
310
+                }
311
+            }
312
+            Err(_) => {
313
+                tracing::debug!("gar not running, workspace integration disabled");
314
+                None
315
+            }
316
+        }
317
+    }
318
+
319
+    /// Apply the default wallpaper from config
320
+    fn apply_default_wallpaper(&mut self) -> Result<()> {
321
+        let source = self.state.config.default.source.clone();
322
+        let mode = self.state.config.default.mode;
323
+        let shuffle = self.state.config.default.slideshow
324
+            .as_ref()
325
+            .map(|s| s.shuffle)
326
+            .unwrap_or(false);
327
+
328
+        if source.is_empty() {
329
+            return Ok(());
330
+        }
331
+
332
+        self.set_wallpaper_from_source(&source, mode, shuffle)
333
+    }
334
+
335
+    /// Set wallpaper with full options (used by IPC Set command)
336
+    fn set_wallpaper_with_options(
337
+        &mut self,
338
+        source: &str,
339
+        mode: ScaleMode,
340
+        shuffle: bool,
341
+        _interval_secs: Option<u64>,
342
+    ) -> Result<()> {
343
+        self.set_wallpaper_from_source(source, mode, shuffle)
344
+    }
345
+
346
+    /// Set wallpaper from a source (file, directory, or URL)
347
+    fn set_wallpaper_from_source(&mut self, source: &str, mode: ScaleMode, shuffle: bool) -> Result<()> {
348
+        // Expand path
349
+        let expanded = shellexpand::tilde(source);
350
+        let path = std::path::Path::new(expanded.as_ref());
351
+
352
+        // Check if it's a directory
353
+        if path.is_dir() {
354
+            // Create a playlist from the directory
355
+            let images = self.list_local_directory(&expanded)?;
356
+            if images.is_empty() {
357
+                anyhow::bail!("No images found in directory: {}", source);
358
+            }
359
+
360
+            let mut playlist = PlaylistState::new(
361
+                source.to_string(),
362
+                detect_source_type(source),
363
+                images,
364
+                shuffle,
365
+                mode,
366
+            );
367
+
368
+            if shuffle {
369
+                playlist.reshuffle();
370
+            }
371
+
372
+            let first = playlist.current().unwrap_or("").to_string();
373
+            playlist.save()?;
374
+            self.state.playlist = Some(playlist);
375
+
376
+            self.set_wallpaper(&first, mode)?;
377
+
378
+            tracing::info!(
379
+                "Playlist loaded: {} images{}",
380
+                self.state.playlist.as_ref().map(|p| p.len()).unwrap_or(0),
381
+                if shuffle { " (shuffled)" } else { "" }
382
+            );
383
+        } else if source.starts_with("http://") || source.starts_with("https://") {
384
+            // Remote URL
385
+            let image = self.fetch_image(source)?;
386
+            let (width, height) = self.conn.screen_dimensions();
387
+            let scaled = scale_image(&image, width as u32, height as u32, mode);
388
+            self.conn.set_wallpaper(&scaled)?;
389
+            tracing::info!("Wallpaper set: {} (mode: {})", source, mode);
390
+        } else {
391
+            // Single file
392
+            self.set_wallpaper(source, mode)?;
393
+        }
394
+
395
+        Ok(())
396
+    }
397
+
398
+    /// Set wallpaper from a local file
399
+    pub fn set_wallpaper(&mut self, source: &str, mode: ScaleMode) -> Result<()> {
400
+        let expanded = shellexpand::tilde(source);
401
+        let image = ImageLoader::load_file(expanded.as_ref())?;
402
+        let (width, height) = self.conn.screen_dimensions();
403
+        let scaled = scale_image(&image, width as u32, height as u32, mode);
404
+        self.conn.set_wallpaper(&scaled)?;
405
+
406
+        tracing::info!("Wallpaper set: {} (mode: {})", source, mode);
407
+
408
+        Ok(())
409
+    }
410
+
411
+    /// Fetch image from URL
412
+    fn fetch_image(&self, url: &str) -> Result<image::RgbaImage> {
413
+        let client = reqwest::blocking::Client::builder()
414
+            .user_agent("garbg/0.1")
415
+            .build()?;
416
+
417
+        let response = client.get(url).send()?;
418
+        let status = response.status();
419
+
420
+        if !status.is_success() {
421
+            anyhow::bail!("HTTP error {}: {}", status, url);
422
+        }
423
+
424
+        let bytes = response.bytes()?;
425
+        ImageLoader::load_bytes(&bytes, None)
426
+    }
427
+
428
+    /// Advance to the next wallpaper in the slideshow
429
+    fn advance_slideshow(&mut self) -> Result<()> {
430
+        // Reload state in case it was modified externally
431
+        if let Some(ref mut playlist) = self.state.playlist {
432
+            playlist.reload()?;
433
+        } else if let Some(playlist) = PlaylistState::load()? {
434
+            self.state.playlist = Some(playlist);
435
+        }
436
+
437
+        // Extract what we need from the playlist first
438
+        let (next, mode, current_index, total) = {
439
+            let playlist = self.state.playlist.as_mut()
440
+                .ok_or_else(|| anyhow::anyhow!("No active playlist"))?;
441
+
442
+            let next = playlist.next().to_string();
443
+            let mode = playlist.mode;
444
+            let current_index = playlist.current_index;
445
+            let total = playlist.len();
446
+            playlist.save()?;
447
+
448
+            (next, mode, current_index, total)
449
+        };
450
+
451
+        self.set_wallpaper(&next, mode)?;
452
+
453
+        tracing::info!(
454
+            "Slideshow [{}/{}]: {}",
455
+            current_index + 1,
456
+            total,
457
+            next
458
+        );
459
+
460
+        Ok(())
461
+    }
462
+
463
+    /// Go to the previous wallpaper in the slideshow
464
+    fn prev_slideshow(&mut self) -> Result<()> {
465
+        // Reload state in case it was modified externally
466
+        if let Some(ref mut playlist) = self.state.playlist {
467
+            playlist.reload()?;
468
+        } else if let Some(playlist) = PlaylistState::load()? {
469
+            self.state.playlist = Some(playlist);
470
+        }
471
+
472
+        // Extract what we need from the playlist first
473
+        let (prev, mode, current_index, total) = {
474
+            let playlist = self.state.playlist.as_mut()
475
+                .ok_or_else(|| anyhow::anyhow!("No active playlist"))?;
476
+
477
+            let prev = playlist.prev().to_string();
478
+            let mode = playlist.mode;
479
+            let current_index = playlist.current_index;
480
+            let total = playlist.len();
481
+            playlist.save()?;
482
+
483
+            (prev, mode, current_index, total)
484
+        };
485
+
486
+        self.set_wallpaper(&prev, mode)?;
487
+
488
+        tracing::info!(
489
+            "Slideshow [{}/{}]: {}",
490
+            current_index + 1,
491
+            total,
492
+            prev
493
+        );
494
+
495
+        Ok(())
496
+    }
497
+
498
+    /// Set a random wallpaper from the current playlist
499
+    fn random_wallpaper(&mut self) -> Result<()> {
500
+        if let Some(ref mut playlist) = self.state.playlist {
501
+            use rand::Rng;
502
+            let idx = rand::thread_rng().gen_range(0..playlist.len());
503
+            playlist.current_index = idx;
504
+            let img = playlist.images[idx].clone();
505
+            let mode = playlist.mode;
506
+            playlist.save()?;
507
+
508
+            self.set_wallpaper(&img, mode)?;
509
+        } else {
510
+            anyhow::bail!("No active playlist");
511
+        }
512
+
513
+        Ok(())
514
+    }
515
+
516
+    /// Handle workspace change
517
+    pub fn on_workspace_change(&mut self, workspace: usize) -> Result<()> {
518
+        self.state.current_workspace = workspace;
519
+
520
+        // Check if this workspace has a specific wallpaper
521
+        let ws_config = self.state.config.workspaces
522
+            .iter()
523
+            .find(|w| w.id == workspace)
524
+            .cloned();
525
+
526
+        if let Some(config) = ws_config {
527
+            let mode = config.mode.unwrap_or(self.state.config.general.mode);
528
+            self.set_wallpaper_from_source(&config.source, mode, false)?;
529
+        }
530
+
531
+        Ok(())
532
+    }
533
+
534
+    /// Reload configuration
535
+    fn reload_config(&mut self) -> Result<()> {
536
+        let config = Config::load_default()?;
537
+
538
+        // Update slideshow interval from new config
539
+        self.state.slideshow_interval = config.default.slideshow
540
+            .as_ref()
541
+            .filter(|s| s.enabled)
542
+            .map(|s| s.interval);
543
+
544
+        self.state.config = config;
545
+        tracing::info!("Configuration reloaded");
546
+
547
+        // Re-apply default wallpaper
548
+        self.apply_default_wallpaper()?;
549
+
550
+        Ok(())
551
+    }
552
+
553
+    /// Get current status as JSON
554
+    fn get_status(&self) -> serde_json::Value {
555
+        let playlist_info = self.state.playlist.as_ref().map(|p| {
556
+            serde_json::json!({
557
+                "source": p.source,
558
+                "current_index": p.current_index,
559
+                "total": p.len(),
560
+                "current_image": p.current(),
561
+                "shuffled": p.shuffled,
562
+                "mode": format!("{}", p.mode),
563
+            })
564
+        });
565
+
566
+        let interval_secs = self.state.slideshow_interval.map(|d| d.as_secs());
567
+
568
+        serde_json::json!({
569
+            "workspace": self.state.current_workspace,
570
+            "paused": self.state.paused,
571
+            "interval_secs": interval_secs,
572
+            "playlist": playlist_info,
573
+        })
574
+    }
575
+
576
+    /// List images from a source
577
+    fn list_source(&self, source: &str) -> Result<Vec<String>> {
578
+        let expanded = shellexpand::tilde(source);
579
+        self.list_local_directory(&expanded)
580
+    }
581
+
582
+    /// List images in a local directory
583
+    fn list_local_directory(&self, path: &str) -> Result<Vec<String>> {
584
+        let dir_path = std::path::Path::new(path);
585
+
586
+        if dir_path.is_file() {
587
+            return Ok(vec![path.to_string()]);
588
+        }
589
+
590
+        if !dir_path.is_dir() {
591
+            anyhow::bail!("Path is not a file or directory: {}", path);
592
+        }
593
+
594
+        let mut images = Vec::new();
595
+        for entry in std::fs::read_dir(dir_path)? {
596
+            let entry = entry?;
597
+            let entry_path = entry.path();
598
+            if entry_path.is_file() && ImageLoader::is_supported_format(&entry_path) {
599
+                images.push(entry_path.to_string_lossy().to_string());
600
+            }
601
+        }
602
+
603
+        images.sort();
604
+        Ok(images)
605
+    }
606
+}
garbg/src/ipc/client.rsadded
@@ -0,0 +1,63 @@
1
+//! IPC client for communicating with the garbg daemon
2
+
3
+use anyhow::{Context, Result};
4
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
5
+use tokio::net::UnixStream;
6
+
7
+use super::protocol::{Command, Response};
8
+use super::server::IpcServer;
9
+
10
+/// Send a command to the daemon asynchronously
11
+pub async fn send_command(cmd: &Command) -> Result<Response> {
12
+    let socket_path = IpcServer::socket_path()?;
13
+
14
+    let mut stream = UnixStream::connect(&socket_path)
15
+        .await
16
+        .with_context(|| format!("Failed to connect to daemon at {}", socket_path.display()))?;
17
+
18
+    // Send command
19
+    let json = serde_json::to_string(cmd)?;
20
+    stream.write_all(json.as_bytes()).await?;
21
+    stream.write_all(b"\n").await?;
22
+
23
+    // Read response
24
+    let mut reader = BufReader::new(&mut stream);
25
+    let mut line = String::new();
26
+    reader.read_line(&mut line).await?;
27
+
28
+    let response: Response = serde_json::from_str(&line)
29
+        .context("Failed to parse response from daemon")?;
30
+
31
+    Ok(response)
32
+}
33
+
34
+/// Send a command to the daemon (blocking)
35
+pub fn send_command_blocking(cmd: &Command) -> Result<Response> {
36
+    let rt = tokio::runtime::Builder::new_current_thread()
37
+        .enable_all()
38
+        .build()?;
39
+
40
+    rt.block_on(send_command(cmd))
41
+}
42
+
43
+/// Check if the daemon is running by attempting to connect
44
+pub fn is_daemon_running() -> bool {
45
+    let socket_path = match IpcServer::socket_path() {
46
+        Ok(p) => p,
47
+        Err(_) => return false,
48
+    };
49
+
50
+    if !socket_path.exists() {
51
+        return false;
52
+    }
53
+
54
+    // Try to connect to verify daemon is actually responding
55
+    match std::os::unix::net::UnixStream::connect(&socket_path) {
56
+        Ok(_) => true,
57
+        Err(_) => {
58
+            // Socket exists but can't connect - stale socket, clean it up
59
+            let _ = std::fs::remove_file(&socket_path);
60
+            false
61
+        }
62
+    }
63
+}
garbg/src/ipc/gar_client.rsadded
@@ -0,0 +1,89 @@
1
+//! Client for gar window manager's IPC
2
+
3
+use anyhow::{Context, Result};
4
+use serde::{Deserialize, Serialize};
5
+use std::path::PathBuf;
6
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
7
+use tokio::net::UnixStream;
8
+
9
+/// Client for communicating with gar window manager
10
+pub struct GarIpcClient {
11
+    stream: UnixStream,
12
+}
13
+
14
+impl GarIpcClient {
15
+    /// Connect to gar's IPC socket
16
+    pub async fn connect() -> Result<Self> {
17
+        let path = Self::socket_path()?;
18
+
19
+        let stream = UnixStream::connect(&path)
20
+            .await
21
+            .with_context(|| format!("Failed to connect to gar at {}", path.display()))?;
22
+
23
+        Ok(Self { stream })
24
+    }
25
+
26
+    /// Get gar's socket path
27
+    fn socket_path() -> Result<PathBuf> {
28
+        let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
29
+            .unwrap_or_else(|_| "/tmp".to_string());
30
+
31
+        Ok(PathBuf::from(runtime_dir).join("gar.sock"))
32
+    }
33
+
34
+    /// Subscribe to gar events
35
+    pub async fn subscribe(&mut self, events: &[&str]) -> Result<()> {
36
+        let cmd = serde_json::json!({
37
+            "command": "subscribe",
38
+            "args": {
39
+                "events": events
40
+            }
41
+        });
42
+
43
+        let json = serde_json::to_string(&cmd)?;
44
+        self.stream.write_all(json.as_bytes()).await?;
45
+        self.stream.write_all(b"\n").await?;
46
+
47
+        Ok(())
48
+    }
49
+
50
+    /// Read the next event from gar
51
+    pub async fn read_event(&mut self) -> Result<GarEvent> {
52
+        let mut reader = BufReader::new(&mut self.stream);
53
+        let mut line = String::new();
54
+
55
+        reader.read_line(&mut line).await?;
56
+
57
+        let event: GarEvent = serde_json::from_str(&line)
58
+            .context("Failed to parse gar event")?;
59
+
60
+        Ok(event)
61
+    }
62
+}
63
+
64
+/// Events from gar window manager
65
+#[derive(Debug, Clone, Serialize, Deserialize)]
66
+#[serde(tag = "event", rename_all = "snake_case")]
67
+pub enum GarEvent {
68
+    /// Workspace changed
69
+    Workspace {
70
+        current: usize,
71
+        previous: usize,
72
+    },
73
+
74
+    /// Monitor configuration changed
75
+    Monitor {
76
+        name: String,
77
+        action: String, // "added", "removed", "changed"
78
+    },
79
+
80
+    /// Window focused
81
+    Focus {
82
+        window_id: u32,
83
+        workspace: usize,
84
+    },
85
+
86
+    /// Unknown event (for forward compatibility)
87
+    #[serde(other)]
88
+    Unknown,
89
+}
garbg/src/ipc/mod.rsadded
@@ -0,0 +1,13 @@
1
+//! IPC (Inter-Process Communication)
2
+//!
3
+//! Provides Unix socket-based communication for controlling garbg.
4
+
5
+mod protocol;
6
+pub mod server;
7
+mod gar_client;
8
+pub mod client;
9
+
10
+pub use protocol::{Command, Response, Event};
11
+pub use server::IpcServer;
12
+pub use gar_client::{GarIpcClient, GarEvent};
13
+pub use client::{send_command, send_command_blocking, is_daemon_running};
garbg/src/ipc/protocol.rsadded
@@ -0,0 +1,152 @@
1
+//! IPC protocol definitions
2
+
3
+use serde::{Deserialize, Serialize};
4
+
5
+use crate::config::ScaleMode;
6
+
7
+/// Commands that can be sent to the daemon
8
+#[derive(Debug, Clone, Serialize, Deserialize)]
9
+#[serde(tag = "command", rename_all = "snake_case")]
10
+pub enum Command {
11
+    /// Set wallpaper
12
+    Set {
13
+        source: String,
14
+        #[serde(default)]
15
+        mode: Option<ScaleMode>,
16
+        #[serde(default)]
17
+        monitor: Option<String>,
18
+        /// Slideshow interval in seconds (None = no auto-rotation)
19
+        #[serde(default)]
20
+        interval_secs: Option<u64>,
21
+        /// Shuffle the playlist
22
+        #[serde(default)]
23
+        shuffle: bool,
24
+    },
25
+
26
+    /// Set wallpaper for a specific workspace
27
+    SetWorkspace {
28
+        workspace: usize,
29
+        source: String,
30
+        #[serde(default)]
31
+        mode: Option<ScaleMode>,
32
+    },
33
+
34
+    /// Next wallpaper in slideshow
35
+    Next {
36
+        #[serde(default)]
37
+        monitor: Option<String>,
38
+    },
39
+
40
+    /// Previous wallpaper in slideshow
41
+    Prev {
42
+        #[serde(default)]
43
+        monitor: Option<String>,
44
+    },
45
+
46
+    /// Random wallpaper from current source
47
+    Random {
48
+        #[serde(default)]
49
+        monitor: Option<String>,
50
+    },
51
+
52
+    /// Reload configuration
53
+    Reload,
54
+
55
+    /// Pause animations/slideshow
56
+    Pause,
57
+
58
+    /// Resume animations/slideshow
59
+    Resume,
60
+
61
+    /// Toggle pause state
62
+    Toggle,
63
+
64
+    /// Get current status
65
+    Status,
66
+
67
+    /// List wallpapers from a source
68
+    List { source: String },
69
+
70
+    /// Clear cache
71
+    ClearCache,
72
+
73
+    /// Subscribe to events
74
+    Subscribe { events: Vec<String> },
75
+
76
+    /// Unsubscribe from events
77
+    Unsubscribe { events: Vec<String> },
78
+}
79
+
80
+/// Response to a command
81
+#[derive(Debug, Clone, Serialize, Deserialize)]
82
+pub struct Response {
83
+    /// Whether the command succeeded
84
+    pub success: bool,
85
+
86
+    /// Response data (command-specific)
87
+    #[serde(skip_serializing_if = "Option::is_none")]
88
+    pub data: Option<serde_json::Value>,
89
+
90
+    /// Error message if failed
91
+    #[serde(skip_serializing_if = "Option::is_none")]
92
+    pub error: Option<String>,
93
+}
94
+
95
+impl Response {
96
+    pub fn ok() -> Self {
97
+        Self {
98
+            success: true,
99
+            data: None,
100
+            error: None,
101
+        }
102
+    }
103
+
104
+    pub fn ok_with_data(data: serde_json::Value) -> Self {
105
+        Self {
106
+            success: true,
107
+            data: Some(data),
108
+            error: None,
109
+        }
110
+    }
111
+
112
+    pub fn error(message: impl Into<String>) -> Self {
113
+        Self {
114
+            success: false,
115
+            data: None,
116
+            error: Some(message.into()),
117
+        }
118
+    }
119
+}
120
+
121
+/// Events sent to subscribed clients
122
+#[derive(Debug, Clone, Serialize, Deserialize)]
123
+#[serde(tag = "event", rename_all = "snake_case")]
124
+pub enum Event {
125
+    /// Wallpaper was changed
126
+    WallpaperChanged {
127
+        monitor: String,
128
+        source: String,
129
+        #[serde(skip_serializing_if = "Option::is_none")]
130
+        workspace: Option<usize>,
131
+    },
132
+
133
+    /// Source was updated (new wallpapers available)
134
+    SourceUpdated { source: String, count: usize },
135
+
136
+    /// Animation state changed
137
+    AnimationState { playing: bool },
138
+
139
+    /// Slideshow advanced
140
+    SlideshowAdvanced {
141
+        current: usize,
142
+        total: usize,
143
+        source: String,
144
+    },
145
+
146
+    /// Error occurred
147
+    Error {
148
+        message: String,
149
+        #[serde(skip_serializing_if = "Option::is_none")]
150
+        context: Option<String>,
151
+    },
152
+}
garbg/src/ipc/server.rsadded
@@ -0,0 +1,135 @@
1
+//! IPC server for garbg daemon
2
+
3
+use anyhow::{Context, Result};
4
+use std::collections::HashSet;
5
+use std::os::unix::fs::PermissionsExt;
6
+use std::path::PathBuf;
7
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
8
+use tokio::net::{UnixListener, UnixStream};
9
+use tokio::sync::mpsc;
10
+
11
+use super::protocol::{Command, Event, Response};
12
+
13
+/// IPC server for accepting client connections
14
+pub struct IpcServer {
15
+    listener: UnixListener,
16
+    socket_path: PathBuf,
17
+}
18
+
19
+impl IpcServer {
20
+    /// Create a new IPC server
21
+    pub async fn new() -> Result<Self> {
22
+        let socket_path = Self::socket_path()?;
23
+
24
+        // Remove existing socket if present
25
+        if socket_path.exists() {
26
+            std::fs::remove_file(&socket_path)?;
27
+        }
28
+
29
+        // Create parent directory if needed
30
+        if let Some(parent) = socket_path.parent() {
31
+            std::fs::create_dir_all(parent)?;
32
+        }
33
+
34
+        let listener = UnixListener::bind(&socket_path)
35
+            .with_context(|| format!("Failed to bind to {}", socket_path.display()))?;
36
+
37
+        // Set permissions to user-only (0600)
38
+        std::fs::set_permissions(&socket_path, std::fs::Permissions::from_mode(0o600))?;
39
+
40
+        Ok(Self {
41
+            listener,
42
+            socket_path,
43
+        })
44
+    }
45
+
46
+    /// Get the socket path
47
+    pub fn socket_path() -> Result<PathBuf> {
48
+        let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
49
+            .unwrap_or_else(|_| "/tmp".to_string());
50
+
51
+        Ok(PathBuf::from(runtime_dir).join("garbg.sock"))
52
+    }
53
+
54
+    /// Accept a new client connection
55
+    pub async fn accept(&self) -> Result<UnixStream> {
56
+        let (stream, _) = self.listener.accept().await?;
57
+        Ok(stream)
58
+    }
59
+
60
+    /// Get the path this server is bound to
61
+    pub fn path(&self) -> &PathBuf {
62
+        &self.socket_path
63
+    }
64
+}
65
+
66
+impl Drop for IpcServer {
67
+    fn drop(&mut self) {
68
+        // Clean up socket file
69
+        let _ = std::fs::remove_file(&self.socket_path);
70
+    }
71
+}
72
+
73
+/// Handle a single client connection
74
+pub struct IpcClient {
75
+    stream: UnixStream,
76
+    subscriptions: HashSet<String>,
77
+}
78
+
79
+impl IpcClient {
80
+    pub fn new(stream: UnixStream) -> Self {
81
+        Self {
82
+            stream,
83
+            subscriptions: HashSet::new(),
84
+        }
85
+    }
86
+
87
+    /// Read a command from the client
88
+    pub async fn read_command(&mut self) -> Result<Option<Command>> {
89
+        let mut reader = BufReader::new(&mut self.stream);
90
+        let mut line = String::new();
91
+
92
+        match reader.read_line(&mut line).await {
93
+            Ok(0) => Ok(None), // EOF
94
+            Ok(_) => {
95
+                let cmd: Command = serde_json::from_str(&line)
96
+                    .context("Failed to parse command")?;
97
+                Ok(Some(cmd))
98
+            }
99
+            Err(e) => Err(e.into()),
100
+        }
101
+    }
102
+
103
+    /// Send a response to the client
104
+    pub async fn send_response(&mut self, response: &Response) -> Result<()> {
105
+        let json = serde_json::to_string(response)?;
106
+        self.stream.write_all(json.as_bytes()).await?;
107
+        self.stream.write_all(b"\n").await?;
108
+        Ok(())
109
+    }
110
+
111
+    /// Send an event to the client
112
+    pub async fn send_event(&mut self, event: &Event) -> Result<()> {
113
+        let json = serde_json::to_string(event)?;
114
+        self.stream.write_all(json.as_bytes()).await?;
115
+        self.stream.write_all(b"\n").await?;
116
+        Ok(())
117
+    }
118
+
119
+    /// Subscribe to event types
120
+    pub fn subscribe(&mut self, events: &[String]) {
121
+        self.subscriptions.extend(events.iter().cloned());
122
+    }
123
+
124
+    /// Unsubscribe from event types
125
+    pub fn unsubscribe(&mut self, events: &[String]) {
126
+        for event in events {
127
+            self.subscriptions.remove(event);
128
+        }
129
+    }
130
+
131
+    /// Check if subscribed to an event type
132
+    pub fn is_subscribed(&self, event_type: &str) -> bool {
133
+        self.subscriptions.contains(event_type) || self.subscriptions.contains("*")
134
+    }
135
+}
garbg/src/lib.rsadded
@@ -0,0 +1,24 @@
1
+//! garbg - A bespoke wallpaper daemon for the gar window manager
2
+//!
3
+//! Features:
4
+//! - Static images (PNG, JPEG, WebP, AVIF)
5
+//! - Animated images (GIF, APNG, animated WebP)
6
+//! - Video wallpapers (MP4, WebM) - optional
7
+//! - Multiple image sources (local, HTTP, GitHub, S3)
8
+//! - Per-workspace wallpapers
9
+//! - Slideshow/rotation support
10
+
11
+pub mod cache;
12
+pub mod config;
13
+pub mod daemon;
14
+pub mod ipc;
15
+pub mod media;
16
+pub mod sources;
17
+pub mod state;
18
+pub mod x11;
19
+
20
+pub use config::Config;
21
+pub use daemon::Daemon;
22
+
23
+/// Result type alias using anyhow for error handling
24
+pub type Result<T> = anyhow::Result<T>;
garbg/src/main.rsadded
@@ -0,0 +1,673 @@
1
+//! garbg - Wallpaper daemon for gar window manager
2
+
3
+use anyhow::Result;
4
+use clap::{Parser, Subcommand};
5
+use rand::seq::SliceRandom;
6
+use std::time::Duration;
7
+use tracing_subscriber::{fmt, prelude::*, EnvFilter};
8
+
9
+use garbg::state::{detect_source_type, PlaylistState};
10
+
11
+#[derive(Parser)]
12
+#[command(name = "garbg")]
13
+#[command(about = "A bespoke wallpaper daemon for the gar window manager")]
14
+#[command(version)]
15
+struct Cli {
16
+    #[command(subcommand)]
17
+    command: Commands,
18
+
19
+    /// Enable verbose logging
20
+    #[arg(short, long, global = true)]
21
+    verbose: bool,
22
+}
23
+
24
+#[derive(Subcommand)]
25
+enum Commands {
26
+    /// Set a wallpaper from a file, directory, or URL
27
+    Set {
28
+        /// Path or URI to the wallpaper source (file, directory, URL, or github://user/repo/path)
29
+        source: String,
30
+
31
+        /// Scaling mode (fill, fit, stretch, center, tile)
32
+        #[arg(short, long, default_value = "fill")]
33
+        mode: String,
34
+
35
+        /// Target monitor (default: all)
36
+        #[arg(short = 'o', long)]
37
+        monitor: Option<String>,
38
+
39
+        /// Shuffle images when source is a directory
40
+        #[arg(short, long)]
41
+        random: bool,
42
+
43
+        /// Auto-rotate interval (e.g., "5m", "30s", "1h"). Process stays running.
44
+        #[arg(short, long, value_parser = parse_duration)]
45
+        interval: Option<Duration>,
46
+    },
47
+
48
+    /// Advance to the next image in the playlist
49
+    Next,
50
+
51
+    /// Go back to the previous image in the playlist
52
+    Prev,
53
+
54
+    /// List images from a source (directory, GitHub, URL)
55
+    List {
56
+        /// Path or URI to list
57
+        source: String,
58
+    },
59
+
60
+    /// Start the daemon
61
+    Daemon {
62
+        /// Path to config file
63
+        #[arg(short, long)]
64
+        config: Option<String>,
65
+
66
+        /// Fork to background (daemonize)
67
+        #[arg(short, long)]
68
+        daemonize: bool,
69
+    },
70
+
71
+    /// Reload configuration
72
+    Reload,
73
+
74
+    /// Get current status
75
+    Status,
76
+}
77
+
78
+/// Parse a duration string like "5m", "30s", "1h"
79
+fn parse_duration(s: &str) -> Result<Duration, String> {
80
+    humantime::parse_duration(s).map_err(|e| e.to_string())
81
+}
82
+
83
+fn main() -> Result<()> {
84
+    let cli = Cli::parse();
85
+
86
+    // Initialize logging
87
+    let filter = if cli.verbose {
88
+        EnvFilter::new("garbg=debug")
89
+    } else {
90
+        EnvFilter::new("garbg=info")
91
+    };
92
+
93
+    tracing_subscriber::registry()
94
+        .with(fmt::layer())
95
+        .with(filter)
96
+        .init();
97
+
98
+    match cli.command {
99
+        Commands::Set { source, mode, monitor, random, interval } => {
100
+            set_wallpaper(&source, &mode, monitor.as_deref(), random, interval)?;
101
+        }
102
+        Commands::Next => {
103
+            cmd_next()?;
104
+        }
105
+        Commands::Prev => {
106
+            cmd_prev()?;
107
+        }
108
+        Commands::List { source } => {
109
+            list_images(&source)?;
110
+        }
111
+        Commands::Daemon { config, daemonize } => {
112
+            if daemonize {
113
+                daemonize_process(config)?;
114
+            } else {
115
+                tracing::info!("Starting daemon");
116
+                run_daemon(config.as_deref())?;
117
+            }
118
+        }
119
+        Commands::Reload => {
120
+            tracing::info!("Reloading configuration");
121
+            send_reload_command()?;
122
+        }
123
+        Commands::Status => {
124
+            print_status()?;
125
+        }
126
+    }
127
+
128
+    Ok(())
129
+}
130
+
131
+fn set_wallpaper(
132
+    source: &str,
133
+    mode: &str,
134
+    _monitor: Option<&str>,
135
+    random: bool,
136
+    interval: Option<Duration>,
137
+) -> Result<()> {
138
+    use garbg::config::ScaleMode;
139
+    use garbg::ipc::{is_daemon_running, send_command_blocking, Command};
140
+
141
+    let scale_mode: ScaleMode = mode.parse()?;
142
+
143
+    // Normalize GitHub URLs first
144
+    let normalized_source = normalize_github_url(source);
145
+
146
+    // If daemon is running, delegate to it (especially for interval-based rotation)
147
+    if is_daemon_running() {
148
+        let interval_secs = interval.map(|d| d.as_secs());
149
+
150
+        let cmd = Command::Set {
151
+            source: normalized_source.clone(),
152
+            mode: Some(scale_mode),
153
+            monitor: None,
154
+            interval_secs,
155
+            shuffle: random,
156
+        };
157
+
158
+        let response = send_command_blocking(&cmd)?;
159
+
160
+        if response.success {
161
+            if let Some(secs) = interval_secs {
162
+                println!("Slideshow scheduled: {} (every {}s, shuffle: {})",
163
+                    normalized_source, secs, random);
164
+            } else {
165
+                println!("Wallpaper set via daemon: {}", normalized_source);
166
+            }
167
+            return Ok(());
168
+        } else if let Some(err) = response.error {
169
+            anyhow::bail!("Daemon error: {}", err);
170
+        }
171
+    }
172
+
173
+    // Daemon not running - handle locally
174
+    if interval.is_some() {
175
+        eprintln!("Note: Daemon not running. Using foreground rotation (blocks terminal).");
176
+        eprintln!("      For background rotation, first run: garbg daemon -d");
177
+        eprintln!();
178
+    }
179
+
180
+    // Get list of images from the source
181
+    let images = list_images_from_source(&normalized_source)?;
182
+
183
+    if images.is_empty() {
184
+        anyhow::bail!("No images found in source: {}", source);
185
+    }
186
+
187
+    // Determine the image to display and whether to save state
188
+    let (resolved_source, mut state) = if images.len() > 1 {
189
+        // Directory/collection - create playlist state
190
+        let mut imgs = images;
191
+        if random {
192
+            let mut rng = rand::thread_rng();
193
+            imgs.shuffle(&mut rng);
194
+        }
195
+
196
+        let state = PlaylistState::new(
197
+            normalized_source.clone(),
198
+            detect_source_type(&normalized_source),
199
+            imgs,
200
+            random,
201
+            scale_mode,
202
+        );
203
+
204
+        let first_image = state.images[0].clone();
205
+        (first_image, Some(state))
206
+    } else {
207
+        // Single file - no playlist state needed
208
+        let single = if normalized_source.starts_with("github://") {
209
+            github_to_raw_url(&normalized_source)?
210
+        } else {
211
+            images[0].clone()
212
+        };
213
+        (single, None)
214
+    };
215
+
216
+    // Save state if we have a playlist
217
+    if let Some(ref mut s) = state {
218
+        s.save()?;
219
+        tracing::info!(
220
+            "Playlist created: {} images, shuffled: {}",
221
+            s.len(),
222
+            s.shuffled
223
+        );
224
+    }
225
+
226
+    // Set the initial wallpaper
227
+    set_single_wallpaper(&resolved_source, scale_mode)?;
228
+
229
+    // If interval specified and daemon not running, enter foreground rotation loop
230
+    if let Some(interval_duration) = interval {
231
+        if let Some(mut playlist_state) = state {
232
+            tracing::info!(
233
+                "Starting rotation every {:?} (Ctrl+C to stop)",
234
+                interval_duration
235
+            );
236
+
237
+            loop {
238
+                std::thread::sleep(interval_duration);
239
+
240
+                // Reload state in case next/prev was called externally
241
+                playlist_state.reload()?;
242
+
243
+                // Advance to next
244
+                let next_img = playlist_state.next().to_string();
245
+                playlist_state.save()?;
246
+
247
+                set_single_wallpaper(&next_img, playlist_state.mode)?;
248
+                tracing::info!(
249
+                    "Rotated to [{}/{}]: {}",
250
+                    playlist_state.current_index + 1,
251
+                    playlist_state.len(),
252
+                    next_img
253
+                );
254
+            }
255
+        } else {
256
+            tracing::warn!("--interval requires a directory source with multiple images");
257
+        }
258
+    }
259
+
260
+    Ok(())
261
+}
262
+
263
+/// Set a single wallpaper (used by set, next, prev)
264
+fn set_single_wallpaper(source: &str, mode: garbg::config::ScaleMode) -> Result<()> {
265
+    use garbg::media::ImageLoader;
266
+    use garbg::x11::Connection;
267
+
268
+    tracing::info!("Setting wallpaper: {}", source);
269
+
270
+    let mut conn = Connection::new()?;
271
+
272
+    let image = if source.starts_with("http://") || source.starts_with("https://") {
273
+        fetch_image_from_url(source)?
274
+    } else {
275
+        ImageLoader::load_file(source)?
276
+    };
277
+
278
+    let (width, height) = conn.screen_dimensions();
279
+    let scaled = garbg::media::scale_image(&image, width as u32, height as u32, mode);
280
+    conn.set_wallpaper(&scaled)?;
281
+
282
+    tracing::info!("Wallpaper set: {} ({}x{}, mode: {:?})", source, width, height, mode);
283
+
284
+    Ok(())
285
+}
286
+
287
+/// Advance to the next image in the playlist
288
+fn cmd_next() -> Result<()> {
289
+    let mut state = PlaylistState::load()?
290
+        .ok_or_else(|| anyhow::anyhow!("No active playlist. Use 'garbg set <directory>' first."))?;
291
+
292
+    let next_image = state.next().to_string();
293
+    state.save()?;
294
+
295
+    set_single_wallpaper(&next_image, state.mode)?;
296
+
297
+    tracing::info!(
298
+        "Next [{}/{}]: {}",
299
+        state.current_index + 1,
300
+        state.len(),
301
+        next_image
302
+    );
303
+
304
+    Ok(())
305
+}
306
+
307
+/// Go back to the previous image in the playlist
308
+fn cmd_prev() -> Result<()> {
309
+    let mut state = PlaylistState::load()?
310
+        .ok_or_else(|| anyhow::anyhow!("No active playlist. Use 'garbg set <directory>' first."))?;
311
+
312
+    let prev_image = state.prev().to_string();
313
+    state.save()?;
314
+
315
+    set_single_wallpaper(&prev_image, state.mode)?;
316
+
317
+    tracing::info!(
318
+        "Prev [{}/{}]: {}",
319
+        state.current_index + 1,
320
+        state.len(),
321
+        prev_image
322
+    );
323
+
324
+    Ok(())
325
+}
326
+
327
+/// List images from a source (directory, GitHub, etc.)
328
+fn list_images_from_source(source: &str) -> Result<Vec<String>> {
329
+    // Check for GitHub URLs and convert to github:// format
330
+    let normalized = normalize_github_url(source);
331
+
332
+    if normalized.starts_with("github://") {
333
+        list_github_directory(&normalized)
334
+    } else if normalized.starts_with("http://") || normalized.starts_with("https://") {
335
+        // For HTTP, could be a directory index - try to parse
336
+        list_http_directory(&normalized)
337
+    } else {
338
+        // Local path
339
+        list_local_directory(&normalized)
340
+    }
341
+}
342
+
343
+/// Convert github:// URI to raw.githubusercontent.com URL
344
+fn github_to_raw_url(uri: &str) -> Result<String> {
345
+    let path = uri.strip_prefix("github://")
346
+        .ok_or_else(|| anyhow::anyhow!("Invalid GitHub URI"))?;
347
+
348
+    let parts: Vec<&str> = path.splitn(3, '/').collect();
349
+    if parts.len() < 3 {
350
+        anyhow::bail!("GitHub URI must include a file path: github://user/repo/path/to/file");
351
+    }
352
+
353
+    let (user, repo, file_path) = (parts[0], parts[1], parts[2]);
354
+
355
+    Ok(format!(
356
+        "https://raw.githubusercontent.com/{}/{}/HEAD/{}",
357
+        user, repo, file_path
358
+    ))
359
+}
360
+
361
+/// Convert GitHub web URLs to github:// format
362
+/// Handles:
363
+///   https://github.com/user/repo/tree/branch/path -> github://user/repo/path
364
+///   https://github.com/user/repo/blob/branch/path -> github://user/repo/path
365
+///   https://github.com/user/repo -> github://user/repo
366
+fn normalize_github_url(source: &str) -> String {
367
+    // Check if it's a GitHub web URL
368
+    if source.starts_with("https://github.com/") || source.starts_with("http://github.com/") {
369
+        let path = source
370
+            .trim_start_matches("https://github.com/")
371
+            .trim_start_matches("http://github.com/");
372
+
373
+        let parts: Vec<&str> = path.split('/').collect();
374
+
375
+        if parts.len() >= 2 {
376
+            let user = parts[0];
377
+            let repo = parts[1];
378
+
379
+            // Check for /tree/branch/path or /blob/branch/path
380
+            if parts.len() >= 4 && (parts[2] == "tree" || parts[2] == "blob") {
381
+                // Skip "tree" or "blob" and branch name, take the rest as path
382
+                let file_path = parts[4..].join("/");
383
+                if file_path.is_empty() {
384
+                    return format!("github://{}/{}", user, repo);
385
+                }
386
+                return format!("github://{}/{}/{}", user, repo, file_path);
387
+            }
388
+
389
+            // Just user/repo
390
+            return format!("github://{}/{}", user, repo);
391
+        }
392
+    }
393
+
394
+    // Return as-is if not a GitHub URL
395
+    source.to_string()
396
+}
397
+
398
+/// List images in a local directory
399
+fn list_local_directory(path: &str) -> Result<Vec<String>> {
400
+    use garbg::media::ImageLoader;
401
+    use std::path::Path;
402
+
403
+    let path_str = shellexpand::tilde(path);
404
+    let dir_path = Path::new(path_str.as_ref());
405
+
406
+    if dir_path.is_file() {
407
+        // Single file, return as-is
408
+        return Ok(vec![path_str.to_string()]);
409
+    }
410
+
411
+    if !dir_path.is_dir() {
412
+        anyhow::bail!("Path is not a file or directory: {}", path);
413
+    }
414
+
415
+    let mut images = Vec::new();
416
+    for entry in std::fs::read_dir(dir_path)? {
417
+        let entry = entry?;
418
+        let entry_path = entry.path();
419
+        if entry_path.is_file() && ImageLoader::is_supported_format(&entry_path) {
420
+            images.push(entry_path.to_string_lossy().to_string());
421
+        }
422
+    }
423
+
424
+    images.sort();
425
+    Ok(images)
426
+}
427
+
428
+/// List images from a GitHub directory
429
+fn list_github_directory(uri: &str) -> Result<Vec<String>> {
430
+    // Parse github://user/repo/path format
431
+    let path = uri.strip_prefix("github://")
432
+        .ok_or_else(|| anyhow::anyhow!("Invalid GitHub URI"))?;
433
+
434
+    let parts: Vec<&str> = path.splitn(3, '/').collect();
435
+    if parts.len() < 2 {
436
+        anyhow::bail!("GitHub URI must be github://user/repo[/path]");
437
+    }
438
+
439
+    let user = parts[0];
440
+    let repo = parts[1];
441
+    let dir_path = parts.get(2).unwrap_or(&"");
442
+
443
+    // Call GitHub API to list contents
444
+    let api_url = format!(
445
+        "https://api.github.com/repos/{}/{}/contents/{}",
446
+        user, repo, dir_path
447
+    );
448
+
449
+    tracing::debug!("Fetching GitHub directory: {}", api_url);
450
+
451
+    let client = reqwest::blocking::Client::builder()
452
+        .user_agent("garbg/0.1")
453
+        .build()?;
454
+
455
+    let response = client.get(&api_url).send()?;
456
+    let status = response.status();
457
+
458
+    if !status.is_success() {
459
+        // If it's a file, not a directory, return the direct raw URL
460
+        if status.as_u16() == 404 || dir_path.contains('.') {
461
+            // Likely a file path, return as single image
462
+            let raw_url = format!(
463
+                "https://raw.githubusercontent.com/{}/{}/HEAD/{}",
464
+                user, repo, dir_path
465
+            );
466
+            return Ok(vec![raw_url]);
467
+        }
468
+        anyhow::bail!("GitHub API error {}: {}", status, api_url);
469
+    }
470
+
471
+    let text = response.text()?;
472
+
473
+    // Parse JSON response
474
+    let contents: Vec<serde_json::Value> = serde_json::from_str(&text)?;
475
+
476
+    let image_extensions = ["png", "jpg", "jpeg", "gif", "webp", "bmp"];
477
+
478
+    let mut images = Vec::new();
479
+    for item in contents {
480
+        if item["type"].as_str() == Some("file") {
481
+            if let Some(name) = item["name"].as_str() {
482
+                let ext = name.rsplit('.').next().unwrap_or("").to_lowercase();
483
+                if image_extensions.contains(&ext.as_str()) {
484
+                    if let Some(download_url) = item["download_url"].as_str() {
485
+                        images.push(download_url.to_string());
486
+                    }
487
+                }
488
+            }
489
+        }
490
+    }
491
+
492
+    Ok(images)
493
+}
494
+
495
+/// List images from an HTTP directory index
496
+fn list_http_directory(url: &str) -> Result<Vec<String>> {
497
+    // If URL doesn't end with /, it's probably a direct file
498
+    if !url.ends_with('/') {
499
+        return Ok(vec![url.to_string()]);
500
+    }
501
+
502
+    let client = reqwest::blocking::Client::builder()
503
+        .user_agent("garbg/0.1")
504
+        .build()?;
505
+
506
+    let response = client.get(url).send()?;
507
+    let status = response.status();
508
+
509
+    if !status.is_success() {
510
+        anyhow::bail!("HTTP error {}: {}", status, url);
511
+    }
512
+
513
+    let html = response.text()?;
514
+
515
+    // Parse HTML for links
516
+    let image_extensions = ["png", "jpg", "jpeg", "gif", "webp", "bmp"];
517
+    let mut images = Vec::new();
518
+
519
+    // Simple regex-free parsing - look for href="..."
520
+    for part in html.split("href=\"") {
521
+        if let Some(end) = part.find('"') {
522
+            let href = &part[..end];
523
+            // Skip parent links and query strings
524
+            if href == "../" || href.starts_with('?') || href.starts_with('/') {
525
+                continue;
526
+            }
527
+            let ext = href.rsplit('.').next().unwrap_or("").to_lowercase();
528
+            if image_extensions.contains(&ext.as_str()) {
529
+                let full_url = format!("{}{}", url, href);
530
+                images.push(full_url);
531
+            }
532
+        }
533
+    }
534
+
535
+    Ok(images)
536
+}
537
+
538
+fn fetch_image_from_url(url: &str) -> Result<image::RgbaImage> {
539
+    use garbg::media::ImageLoader;
540
+
541
+    let client = reqwest::blocking::Client::builder()
542
+        .user_agent("garbg/0.1")
543
+        .build()?;
544
+
545
+    let response = client.get(url).send()?;
546
+    let status = response.status();
547
+
548
+    if !status.is_success() {
549
+        anyhow::bail!("HTTP error {}: {}", status, url);
550
+    }
551
+
552
+    let bytes = response.bytes()?;
553
+    ImageLoader::load_bytes(&bytes, None)
554
+}
555
+
556
+/// List images from a source and print them
557
+fn list_images(source: &str) -> Result<()> {
558
+    let images = list_images_from_source(source)?;
559
+
560
+    if images.is_empty() {
561
+        println!("No images found in: {}", source);
562
+    } else {
563
+        println!("Found {} images in {}:", images.len(), source);
564
+        for img in &images {
565
+            println!("  {}", img);
566
+        }
567
+    }
568
+
569
+    Ok(())
570
+}
571
+
572
+fn run_daemon(config_path: Option<&str>) -> Result<()> {
573
+    use garbg::config::Config;
574
+    use garbg::daemon::Daemon;
575
+
576
+    // Load configuration
577
+    let config = match config_path {
578
+        Some(path) => Config::load(path)?,
579
+        None => Config::load_default()?,
580
+    };
581
+
582
+    // Create the daemon
583
+    let mut daemon = Daemon::new(config)?;
584
+
585
+    // Run with a single-threaded async runtime (lightweight)
586
+    let rt = tokio::runtime::Builder::new_current_thread()
587
+        .enable_all()
588
+        .build()?;
589
+
590
+    rt.block_on(daemon.run())
591
+}
592
+
593
+/// Fork the daemon to background
594
+fn daemonize_process(config: Option<String>) -> Result<()> {
595
+    use std::process::{Command, Stdio};
596
+
597
+    let exe = std::env::current_exe()?;
598
+
599
+    let mut cmd = Command::new(&exe);
600
+    cmd.arg("daemon");
601
+
602
+    if let Some(config_path) = config {
603
+        cmd.arg("--config").arg(config_path);
604
+    }
605
+
606
+    // Detach from terminal
607
+    cmd.stdin(Stdio::null())
608
+        .stdout(Stdio::null())
609
+        .stderr(Stdio::null());
610
+
611
+    // Spawn as independent process
612
+    let child = cmd.spawn()?;
613
+
614
+    println!("Daemon started (PID: {})", child.id());
615
+
616
+    // Give daemon a moment to start and verify it's running
617
+    std::thread::sleep(std::time::Duration::from_millis(500));
618
+
619
+    if garbg::ipc::is_daemon_running() {
620
+        println!("Daemon is running");
621
+    } else {
622
+        anyhow::bail!("Daemon failed to start");
623
+    }
624
+
625
+    Ok(())
626
+}
627
+
628
+fn send_reload_command() -> Result<()> {
629
+    use garbg::ipc::{Command, is_daemon_running, send_command_blocking};
630
+
631
+    if !is_daemon_running() {
632
+        anyhow::bail!("Daemon is not running. Start it with 'garbg daemon'");
633
+    }
634
+
635
+    let cmd = Command::Reload;
636
+    let response = send_command_blocking(&cmd)?;
637
+
638
+    if response.success {
639
+        println!("Configuration reloaded");
640
+    } else if let Some(err) = response.error {
641
+        anyhow::bail!("Reload failed: {}", err);
642
+    }
643
+
644
+    Ok(())
645
+}
646
+
647
+fn print_status() -> Result<()> {
648
+    match PlaylistState::load()? {
649
+        Some(state) => {
650
+            println!("garbg playlist status");
651
+            println!("---------------------");
652
+            println!("Source: {}", state.source);
653
+            println!("Type: {:?}", state.source_type);
654
+            println!("Images: {} total", state.len());
655
+            println!(
656
+                "Current: [{}/{}]",
657
+                state.current_index + 1,
658
+                state.len()
659
+            );
660
+            if let Some(current) = state.current() {
661
+                println!("  {}", current);
662
+            }
663
+            println!("Shuffled: {}", state.shuffled);
664
+            println!("Mode: {:?}", state.mode);
665
+            println!("Last updated: {}", state.last_updated);
666
+        }
667
+        None => {
668
+            println!("No active playlist.");
669
+            println!("Use 'garbg set <directory>' to create one.");
670
+        }
671
+    }
672
+    Ok(())
673
+}
garbg/src/media/loader.rsadded
@@ -0,0 +1,58 @@
1
+//! Image loading and format detection
2
+
3
+use anyhow::{Context, Result};
4
+use image::{DynamicImage, ImageFormat, RgbaImage};
5
+use std::fs;
6
+use std::path::Path;
7
+
8
+/// Image loader supporting multiple formats
9
+pub struct ImageLoader;
10
+
11
+impl ImageLoader {
12
+    /// Load an image from a file path
13
+    pub fn load_file<P: AsRef<Path>>(path: P) -> Result<RgbaImage> {
14
+        let path = path.as_ref();
15
+        let data = fs::read(path)
16
+            .with_context(|| format!("Failed to read file: {}", path.display()))?;
17
+
18
+        Self::load_bytes(&data, Self::guess_format(path))
19
+    }
20
+
21
+    /// Load an image from bytes with optional format hint
22
+    pub fn load_bytes(data: &[u8], format: Option<ImageFormat>) -> Result<RgbaImage> {
23
+        let img = if let Some(fmt) = format {
24
+            image::load_from_memory_with_format(data, fmt)
25
+                .context("Failed to decode image with specified format")?
26
+        } else {
27
+            image::load_from_memory(data)
28
+                .context("Failed to decode image")?
29
+        };
30
+
31
+        Ok(img.into_rgba8())
32
+    }
33
+
34
+    /// Guess image format from file extension
35
+    fn guess_format(path: &Path) -> Option<ImageFormat> {
36
+        path.extension()
37
+            .and_then(|ext| ext.to_str())
38
+            .and_then(|ext| match ext.to_lowercase().as_str() {
39
+                "png" => Some(ImageFormat::Png),
40
+                "jpg" | "jpeg" => Some(ImageFormat::Jpeg),
41
+                "gif" => Some(ImageFormat::Gif),
42
+                "webp" => Some(ImageFormat::WebP),
43
+                "bmp" => Some(ImageFormat::Bmp),
44
+                "tiff" | "tif" => Some(ImageFormat::Tiff),
45
+                _ => None,
46
+            })
47
+    }
48
+
49
+    /// Check if a path points to a supported image format
50
+    pub fn is_supported_format(path: &Path) -> bool {
51
+        Self::guess_format(path).is_some()
52
+    }
53
+
54
+    /// Get list of supported extensions
55
+    pub fn supported_extensions() -> &'static [&'static str] {
56
+        &["png", "jpg", "jpeg", "gif", "webp", "bmp", "tiff", "tif"]
57
+    }
58
+}
garbg/src/media/mod.rsadded
@@ -0,0 +1,12 @@
1
+//! Media loading and processing
2
+//!
3
+//! Handles image loading, decoding, and scaling for wallpapers.
4
+
5
+mod loader;
6
+mod scaler;
7
+
8
+pub use loader::ImageLoader;
9
+pub use scaler::scale_image;
10
+
11
+// Re-export ScaleMode from config for convenience
12
+pub use crate::config::ScaleMode;
garbg/src/media/scaler.rsadded
@@ -0,0 +1,170 @@
1
+//! Image scaling with various modes
2
+
3
+use image::{imageops::FilterType, RgbaImage};
4
+
5
+use crate::config::ScaleMode;
6
+
7
+/// Scale an image according to the specified mode
8
+pub fn scale_image(
9
+    image: &RgbaImage,
10
+    target_width: u32,
11
+    target_height: u32,
12
+    mode: ScaleMode,
13
+) -> RgbaImage {
14
+    match mode {
15
+        ScaleMode::Fill => scale_fill(image, target_width, target_height),
16
+        ScaleMode::Fit => scale_fit(image, target_width, target_height),
17
+        ScaleMode::Stretch => scale_stretch(image, target_width, target_height),
18
+        ScaleMode::Center => scale_center(image, target_width, target_height),
19
+        ScaleMode::Tile => scale_tile(image, target_width, target_height),
20
+    }
21
+}
22
+
23
+/// Fill: Scale to cover entire area, crop excess
24
+fn scale_fill(image: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage {
25
+    let (src_width, src_height) = image.dimensions();
26
+
27
+    // Calculate scale factor to cover the entire target
28
+    let scale_x = target_width as f64 / src_width as f64;
29
+    let scale_y = target_height as f64 / src_height as f64;
30
+    let scale = scale_x.max(scale_y);
31
+
32
+    let scaled_width = (src_width as f64 * scale).round() as u32;
33
+    let scaled_height = (src_height as f64 * scale).round() as u32;
34
+
35
+    // Scale image
36
+    let scaled = image::imageops::resize(image, scaled_width, scaled_height, FilterType::Lanczos3);
37
+
38
+    // Crop to target size (center crop)
39
+    let crop_x = (scaled_width.saturating_sub(target_width)) / 2;
40
+    let crop_y = (scaled_height.saturating_sub(target_height)) / 2;
41
+
42
+    image::imageops::crop_imm(&scaled, crop_x, crop_y, target_width, target_height).to_image()
43
+}
44
+
45
+/// Fit: Scale to fit within area, letterbox if needed
46
+fn scale_fit(image: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage {
47
+    let (src_width, src_height) = image.dimensions();
48
+
49
+    // Calculate scale factor to fit within target
50
+    let scale_x = target_width as f64 / src_width as f64;
51
+    let scale_y = target_height as f64 / src_height as f64;
52
+    let scale = scale_x.min(scale_y);
53
+
54
+    let scaled_width = (src_width as f64 * scale).round() as u32;
55
+    let scaled_height = (src_height as f64 * scale).round() as u32;
56
+
57
+    // Scale image
58
+    let scaled = image::imageops::resize(image, scaled_width, scaled_height, FilterType::Lanczos3);
59
+
60
+    // Create output with black background
61
+    let mut output = RgbaImage::from_pixel(target_width, target_height, image::Rgba([0, 0, 0, 255]));
62
+
63
+    // Center the scaled image
64
+    let offset_x = (target_width.saturating_sub(scaled_width)) / 2;
65
+    let offset_y = (target_height.saturating_sub(scaled_height)) / 2;
66
+
67
+    image::imageops::overlay(&mut output, &scaled, offset_x as i64, offset_y as i64);
68
+
69
+    output
70
+}
71
+
72
+/// Stretch: Scale to exact target size, ignoring aspect ratio
73
+fn scale_stretch(image: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage {
74
+    image::imageops::resize(image, target_width, target_height, FilterType::Lanczos3)
75
+}
76
+
77
+/// Center: Display at original size, centered
78
+fn scale_center(image: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage {
79
+    let (src_width, src_height) = image.dimensions();
80
+
81
+    // Create output with black background
82
+    let mut output = RgbaImage::from_pixel(target_width, target_height, image::Rgba([0, 0, 0, 255]));
83
+
84
+    // Calculate offset to center
85
+    let offset_x = (target_width as i64 - src_width as i64) / 2;
86
+    let offset_y = (target_height as i64 - src_height as i64) / 2;
87
+
88
+    // If image is larger than target, we need to crop it
89
+    if offset_x < 0 || offset_y < 0 {
90
+        let crop_x = (-offset_x).max(0) as u32;
91
+        let crop_y = (-offset_y).max(0) as u32;
92
+        let crop_width = src_width.min(target_width);
93
+        let crop_height = src_height.min(target_height);
94
+
95
+        let cropped = image::imageops::crop_imm(image, crop_x, crop_y, crop_width, crop_height);
96
+
97
+        let paste_x = offset_x.max(0);
98
+        let paste_y = offset_y.max(0);
99
+
100
+        image::imageops::overlay(&mut output, &cropped.to_image(), paste_x, paste_y);
101
+    } else {
102
+        image::imageops::overlay(&mut output, image, offset_x, offset_y);
103
+    }
104
+
105
+    output
106
+}
107
+
108
+/// Tile: Repeat image to fill area
109
+fn scale_tile(image: &RgbaImage, target_width: u32, target_height: u32) -> RgbaImage {
110
+    let (src_width, src_height) = image.dimensions();
111
+
112
+    let mut output = RgbaImage::new(target_width, target_height);
113
+
114
+    let tiles_x = (target_width + src_width - 1) / src_width;
115
+    let tiles_y = (target_height + src_height - 1) / src_height;
116
+
117
+    for ty in 0..tiles_y {
118
+        for tx in 0..tiles_x {
119
+            let x = (tx * src_width) as i64;
120
+            let y = (ty * src_height) as i64;
121
+            image::imageops::overlay(&mut output, image, x, y);
122
+        }
123
+    }
124
+
125
+    output
126
+}
127
+
128
+#[cfg(test)]
129
+mod tests {
130
+    use super::*;
131
+
132
+    fn test_image(width: u32, height: u32) -> RgbaImage {
133
+        RgbaImage::from_pixel(width, height, image::Rgba([255, 0, 0, 255]))
134
+    }
135
+
136
+    #[test]
137
+    fn test_scale_stretch() {
138
+        let img = test_image(100, 100);
139
+        let scaled = scale_stretch(&img, 200, 150);
140
+        assert_eq!(scaled.dimensions(), (200, 150));
141
+    }
142
+
143
+    #[test]
144
+    fn test_scale_fill() {
145
+        let img = test_image(100, 100);
146
+        let scaled = scale_fill(&img, 200, 150);
147
+        assert_eq!(scaled.dimensions(), (200, 150));
148
+    }
149
+
150
+    #[test]
151
+    fn test_scale_fit() {
152
+        let img = test_image(100, 100);
153
+        let scaled = scale_fit(&img, 200, 150);
154
+        assert_eq!(scaled.dimensions(), (200, 150));
155
+    }
156
+
157
+    #[test]
158
+    fn test_scale_center() {
159
+        let img = test_image(100, 100);
160
+        let scaled = scale_center(&img, 200, 200);
161
+        assert_eq!(scaled.dimensions(), (200, 200));
162
+    }
163
+
164
+    #[test]
165
+    fn test_scale_tile() {
166
+        let img = test_image(100, 100);
167
+        let scaled = scale_tile(&img, 250, 250);
168
+        assert_eq!(scaled.dimensions(), (250, 250));
169
+    }
170
+}
garbg/src/sources/directory.rsadded
@@ -0,0 +1,158 @@
1
+//! Directory index source provider
2
+//!
3
+//! Parses Apache/nginx autoindex HTML pages to list available files.
4
+
5
+use anyhow::{Context, Result};
6
+use async_trait::async_trait;
7
+use scraper::{Html, Selector};
8
+use std::path::Path;
9
+
10
+use super::{FetchedImage, MediaType, SourceProvider, WallpaperEntry};
11
+use crate::media::ImageLoader;
12
+
13
+/// Provider for HTTP directory indexes (Apache/nginx autoindex)
14
+pub struct DirectoryIndexProvider {
15
+    client: reqwest::Client,
16
+}
17
+
18
+impl DirectoryIndexProvider {
19
+    pub fn new() -> Self {
20
+        let client = reqwest::Client::builder()
21
+            .user_agent("garbg/0.1")
22
+            .build()
23
+            .expect("Failed to create HTTP client");
24
+
25
+        Self { client }
26
+    }
27
+
28
+    /// Determine media type from filename
29
+    fn media_type_from_filename(name: &str) -> MediaType {
30
+        let ext = Path::new(name)
31
+            .extension()
32
+            .and_then(|e| e.to_str())
33
+            .map(|e| e.to_lowercase());
34
+
35
+        match ext.as_deref() {
36
+            Some("gif") => MediaType::AnimatedImage,
37
+            Some("mp4" | "webm") => MediaType::Video,
38
+            _ => MediaType::StaticImage,
39
+        }
40
+    }
41
+
42
+    /// Check if a filename is a supported image format
43
+    fn is_image_file(name: &str) -> bool {
44
+        ImageLoader::is_supported_format(Path::new(name))
45
+    }
46
+
47
+    /// Parse links from HTML directory listing
48
+    fn parse_directory_html(html: &str, base_url: &str) -> Vec<(String, String)> {
49
+        let document = Html::parse_document(html);
50
+        let selector = Selector::parse("a[href]").unwrap();
51
+
52
+        let mut links = Vec::new();
53
+
54
+        for element in document.select(&selector) {
55
+            if let Some(href) = element.value().attr("href") {
56
+                // Skip parent directory links
57
+                if href == "../" || href == ".." || href.starts_with('?') {
58
+                    continue;
59
+                }
60
+
61
+                // Get the display name (either href or element text)
62
+                let name = element.text().collect::<String>();
63
+                let name = name.trim();
64
+                let name = if name.is_empty() { href } else { name };
65
+
66
+                // Build full URL
67
+                let full_url = if href.starts_with("http://") || href.starts_with("https://") {
68
+                    href.to_string()
69
+                } else if href.starts_with('/') {
70
+                    // Absolute path
71
+                    let url = url::Url::parse(base_url).ok();
72
+                    url.map(|u| format!("{}://{}{}", u.scheme(), u.host_str().unwrap_or(""), href))
73
+                        .unwrap_or_else(|| href.to_string())
74
+                } else {
75
+                    // Relative path
76
+                    let base = if base_url.ends_with('/') {
77
+                        base_url.to_string()
78
+                    } else {
79
+                        format!("{}/", base_url)
80
+                    };
81
+                    format!("{}{}", base, href)
82
+                };
83
+
84
+                links.push((name.to_string(), full_url));
85
+            }
86
+        }
87
+
88
+        links
89
+    }
90
+}
91
+
92
+#[async_trait]
93
+impl SourceProvider for DirectoryIndexProvider {
94
+    fn id(&self) -> &str {
95
+        "directory"
96
+    }
97
+
98
+    fn can_handle(&self, uri: &str) -> bool {
99
+        // This provider handles HTTP URLs that end with / (directory listings)
100
+        // Or are explicitly marked as directory indexes
101
+        (uri.starts_with("http://") || uri.starts_with("https://"))
102
+            && (uri.ends_with('/') || uri.contains("?C=") || uri.contains("autoindex"))
103
+    }
104
+
105
+    async fn list(&self, uri: &str) -> Result<Vec<WallpaperEntry>> {
106
+        let response = self
107
+            .client
108
+            .get(uri)
109
+            .send()
110
+            .await
111
+            .with_context(|| format!("Failed to fetch directory: {}", uri))?;
112
+
113
+        let status = response.status();
114
+        if !status.is_success() {
115
+            anyhow::bail!("HTTP error {}: {}", status, uri);
116
+        }
117
+
118
+        let html = response.text().await?;
119
+        let links = Self::parse_directory_html(&html, uri);
120
+
121
+        let entries: Vec<WallpaperEntry> = links
122
+            .into_iter()
123
+            .filter(|(name, _url)| Self::is_image_file(name))
124
+            .map(|(name, url)| WallpaperEntry {
125
+                uri: url,
126
+                name: name.clone(),
127
+                media_type: Self::media_type_from_filename(&name),
128
+                size: None,
129
+                metadata: Default::default(),
130
+            })
131
+            .collect();
132
+
133
+        Ok(entries)
134
+    }
135
+
136
+    async fn fetch(&self, entry: &WallpaperEntry) -> Result<FetchedImage> {
137
+        let response = self
138
+            .client
139
+            .get(&entry.uri)
140
+            .send()
141
+            .await
142
+            .with_context(|| format!("Failed to fetch: {}", entry.uri))?;
143
+
144
+        let status = response.status();
145
+        if !status.is_success() {
146
+            anyhow::bail!("HTTP error {}: {}", status, entry.uri);
147
+        }
148
+
149
+        let bytes = response.bytes().await?;
150
+        let image = ImageLoader::load_bytes(&bytes, None)?;
151
+
152
+        Ok(FetchedImage {
153
+            image,
154
+            uri: entry.uri.clone(),
155
+            media_type: entry.media_type,
156
+        })
157
+    }
158
+}
garbg/src/sources/file.rsadded
@@ -0,0 +1,115 @@
1
+//! Local file source provider
2
+
3
+use anyhow::{Context, Result};
4
+use async_trait::async_trait;
5
+use std::path::Path;
6
+
7
+use super::{FetchedImage, MediaType, SourceProvider, WallpaperEntry};
8
+use crate::media::ImageLoader;
9
+
10
+/// Provider for local files
11
+pub struct FileProvider;
12
+
13
+impl FileProvider {
14
+    pub fn new() -> Self {
15
+        Self
16
+    }
17
+
18
+    /// Determine media type from file extension
19
+    fn media_type_from_path(path: &Path) -> MediaType {
20
+        let ext = path
21
+            .extension()
22
+            .and_then(|e| e.to_str())
23
+            .map(|e| e.to_lowercase());
24
+
25
+        match ext.as_deref() {
26
+            Some("gif") => MediaType::AnimatedImage,
27
+            Some("mp4" | "webm" | "mkv" | "avi") => MediaType::Video,
28
+            _ => MediaType::StaticImage,
29
+        }
30
+    }
31
+}
32
+
33
+#[async_trait]
34
+impl SourceProvider for FileProvider {
35
+    fn id(&self) -> &str {
36
+        "file"
37
+    }
38
+
39
+    fn can_handle(&self, uri: &str) -> bool {
40
+        // Handle file:// URIs and bare paths
41
+        uri.starts_with("file://") || uri.starts_with('/') || uri.starts_with('~')
42
+    }
43
+
44
+    async fn list(&self, uri: &str) -> Result<Vec<WallpaperEntry>> {
45
+        let path_str = uri.strip_prefix("file://").unwrap_or(uri);
46
+        let path_str = shellexpand::tilde(path_str);
47
+        let path = Path::new(path_str.as_ref());
48
+
49
+        if path.is_file() {
50
+            // Single file
51
+            let name = path
52
+                .file_name()
53
+                .and_then(|n| n.to_str())
54
+                .unwrap_or("unknown")
55
+                .to_string();
56
+
57
+            let size = path.metadata().ok().map(|m| m.len());
58
+
59
+            Ok(vec![WallpaperEntry {
60
+                uri: uri.to_string(),
61
+                name,
62
+                media_type: Self::media_type_from_path(path),
63
+                size,
64
+                metadata: Default::default(),
65
+            }])
66
+        } else if path.is_dir() {
67
+            // Directory - list all supported files
68
+            let mut entries = Vec::new();
69
+
70
+            let read_dir = std::fs::read_dir(path)
71
+                .with_context(|| format!("Failed to read directory: {}", path.display()))?;
72
+
73
+            for entry in read_dir.flatten() {
74
+                let entry_path = entry.path();
75
+                if entry_path.is_file() && ImageLoader::is_supported_format(&entry_path) {
76
+                    let name = entry_path
77
+                        .file_name()
78
+                        .and_then(|n| n.to_str())
79
+                        .unwrap_or("unknown")
80
+                        .to_string();
81
+
82
+                    let size = entry.metadata().ok().map(|m| m.len());
83
+
84
+                    entries.push(WallpaperEntry {
85
+                        uri: entry_path.to_string_lossy().to_string(),
86
+                        name,
87
+                        media_type: Self::media_type_from_path(&entry_path),
88
+                        size,
89
+                        metadata: Default::default(),
90
+                    });
91
+                }
92
+            }
93
+
94
+            // Sort by name
95
+            entries.sort_by(|a, b| a.name.cmp(&b.name));
96
+
97
+            Ok(entries)
98
+        } else {
99
+            anyhow::bail!("Path does not exist: {}", path.display());
100
+        }
101
+    }
102
+
103
+    async fn fetch(&self, entry: &WallpaperEntry) -> Result<FetchedImage> {
104
+        let path_str = entry.uri.strip_prefix("file://").unwrap_or(&entry.uri);
105
+        let path_str = shellexpand::tilde(path_str);
106
+
107
+        let image = ImageLoader::load_file(path_str.as_ref())?;
108
+
109
+        Ok(FetchedImage {
110
+            image,
111
+            uri: entry.uri.clone(),
112
+            media_type: entry.media_type,
113
+        })
114
+    }
115
+}
garbg/src/sources/github.rsadded
@@ -0,0 +1,165 @@
1
+//! GitHub repository source provider
2
+
3
+use anyhow::{Context, Result};
4
+use async_trait::async_trait;
5
+use serde::Deserialize;
6
+use std::path::Path;
7
+
8
+use super::{FetchedImage, MediaType, SourceProvider, WallpaperEntry};
9
+use crate::media::ImageLoader;
10
+
11
+/// Provider for GitHub repositories
12
+///
13
+/// Supports URIs like:
14
+/// - `github://user/repo/path/to/file.png`
15
+/// - `github://user/repo/path/to/directory`
16
+pub struct GitHubProvider {
17
+    client: reqwest::Client,
18
+    /// Optional personal access token for higher rate limits
19
+    token: Option<String>,
20
+}
21
+
22
+impl GitHubProvider {
23
+    pub fn new() -> Self {
24
+        Self::with_token(None)
25
+    }
26
+
27
+    pub fn with_token(token: Option<String>) -> Self {
28
+        let client = reqwest::Client::builder()
29
+            .user_agent("garbg/0.1")
30
+            .build()
31
+            .expect("Failed to create HTTP client");
32
+
33
+        Self { client, token }
34
+    }
35
+
36
+    /// Parse a github:// URI into (user, repo, path)
37
+    fn parse_uri(uri: &str) -> Result<(String, String, String)> {
38
+        let path = uri
39
+            .strip_prefix("github://")
40
+            .context("Invalid GitHub URI")?;
41
+
42
+        let parts: Vec<&str> = path.splitn(3, '/').collect();
43
+        if parts.len() < 2 {
44
+            anyhow::bail!("GitHub URI must be github://user/repo[/path]");
45
+        }
46
+
47
+        let user = parts[0].to_string();
48
+        let repo = parts[1].to_string();
49
+        let path = parts.get(2).map(|s| s.to_string()).unwrap_or_default();
50
+
51
+        Ok((user, repo, path))
52
+    }
53
+
54
+    /// Determine media type from path
55
+    fn media_type_from_path(path: &str) -> MediaType {
56
+        let ext = Path::new(path)
57
+            .extension()
58
+            .and_then(|e| e.to_str())
59
+            .map(|e| e.to_lowercase());
60
+
61
+        match ext.as_deref() {
62
+            Some("gif") => MediaType::AnimatedImage,
63
+            Some("mp4" | "webm") => MediaType::Video,
64
+            _ => MediaType::StaticImage,
65
+        }
66
+    }
67
+}
68
+
69
+#[derive(Deserialize)]
70
+struct GitHubContent {
71
+    name: String,
72
+    path: String,
73
+    #[serde(rename = "type")]
74
+    content_type: String,
75
+    size: Option<u64>,
76
+    download_url: Option<String>,
77
+}
78
+
79
+#[async_trait]
80
+impl SourceProvider for GitHubProvider {
81
+    fn id(&self) -> &str {
82
+        "github"
83
+    }
84
+
85
+    fn can_handle(&self, uri: &str) -> bool {
86
+        uri.starts_with("github://")
87
+    }
88
+
89
+    async fn list(&self, uri: &str) -> Result<Vec<WallpaperEntry>> {
90
+        let (user, repo, path) = Self::parse_uri(uri)?;
91
+
92
+        let api_url = format!(
93
+            "https://api.github.com/repos/{}/{}/contents/{}",
94
+            user, repo, path
95
+        );
96
+
97
+        let mut request = self.client.get(&api_url);
98
+
99
+        if let Some(token) = &self.token {
100
+            request = request.header("Authorization", format!("token {}", token));
101
+        }
102
+
103
+        let response = request
104
+            .send()
105
+            .await
106
+            .with_context(|| format!("Failed to fetch GitHub API: {}", api_url))?;
107
+
108
+        let status = response.status();
109
+        if !status.is_success() {
110
+            let body = response.text().await.unwrap_or_default();
111
+            anyhow::bail!("GitHub API error {}: {}", status, body);
112
+        }
113
+
114
+        let text = response.text().await?;
115
+
116
+        // Try to parse as array (directory) or single object (file)
117
+        let contents: Vec<GitHubContent> = if text.starts_with('[') {
118
+            serde_json::from_str(&text)?
119
+        } else {
120
+            let single: GitHubContent = serde_json::from_str(&text)?;
121
+            vec![single]
122
+        };
123
+
124
+        let entries: Vec<WallpaperEntry> = contents
125
+            .into_iter()
126
+            .filter(|c| {
127
+                c.content_type == "file"
128
+                    && c.download_url.is_some()
129
+                    && ImageLoader::is_supported_format(Path::new(&c.name))
130
+            })
131
+            .map(|c| WallpaperEntry {
132
+                uri: c.download_url.unwrap(),
133
+                name: c.name,
134
+                media_type: Self::media_type_from_path(&c.path),
135
+                size: c.size,
136
+                metadata: Default::default(),
137
+            })
138
+            .collect();
139
+
140
+        Ok(entries)
141
+    }
142
+
143
+    async fn fetch(&self, entry: &WallpaperEntry) -> Result<FetchedImage> {
144
+        let response = self
145
+            .client
146
+            .get(&entry.uri)
147
+            .send()
148
+            .await
149
+            .with_context(|| format!("Failed to fetch: {}", entry.uri))?;
150
+
151
+        let status = response.status();
152
+        if !status.is_success() {
153
+            anyhow::bail!("HTTP error {}: {}", status, entry.uri);
154
+        }
155
+
156
+        let bytes = response.bytes().await?;
157
+        let image = ImageLoader::load_bytes(&bytes, None)?;
158
+
159
+        Ok(FetchedImage {
160
+            image,
161
+            uri: entry.uri.clone(),
162
+            media_type: entry.media_type,
163
+        })
164
+    }
165
+}
garbg/src/sources/http.rsadded
@@ -0,0 +1,98 @@
1
+//! HTTP/HTTPS source provider
2
+
3
+use anyhow::{Context, Result};
4
+use async_trait::async_trait;
5
+use std::path::Path;
6
+
7
+use super::{FetchedImage, MediaType, SourceProvider, WallpaperEntry};
8
+use crate::media::ImageLoader;
9
+
10
+/// Provider for HTTP/HTTPS URLs
11
+pub struct HttpProvider {
12
+    client: reqwest::Client,
13
+}
14
+
15
+impl HttpProvider {
16
+    pub fn new() -> Self {
17
+        let client = reqwest::Client::builder()
18
+            .user_agent("garbg/0.1")
19
+            .build()
20
+            .expect("Failed to create HTTP client");
21
+
22
+        Self { client }
23
+    }
24
+
25
+    /// Determine media type from URL or content-type
26
+    fn media_type_from_url(url: &str) -> MediaType {
27
+        let path = url.split('?').next().unwrap_or(url);
28
+        let ext = Path::new(path)
29
+            .extension()
30
+            .and_then(|e| e.to_str())
31
+            .map(|e| e.to_lowercase());
32
+
33
+        match ext.as_deref() {
34
+            Some("gif") => MediaType::AnimatedImage,
35
+            Some("mp4" | "webm" | "mkv") => MediaType::Video,
36
+            _ => MediaType::StaticImage,
37
+        }
38
+    }
39
+}
40
+
41
+#[async_trait]
42
+impl SourceProvider for HttpProvider {
43
+    fn id(&self) -> &str {
44
+        "http"
45
+    }
46
+
47
+    fn can_handle(&self, uri: &str) -> bool {
48
+        uri.starts_with("http://") || uri.starts_with("https://")
49
+    }
50
+
51
+    async fn list(&self, uri: &str) -> Result<Vec<WallpaperEntry>> {
52
+        // For HTTP, we treat the URL as a single entry
53
+        // Directory listing is handled by DirectoryIndexProvider
54
+        let name = uri
55
+            .split('/')
56
+            .last()
57
+            .unwrap_or("image")
58
+            .split('?')
59
+            .next()
60
+            .unwrap_or("image")
61
+            .to_string();
62
+
63
+        Ok(vec![WallpaperEntry {
64
+            uri: uri.to_string(),
65
+            name,
66
+            media_type: Self::media_type_from_url(uri),
67
+            size: None,
68
+            metadata: Default::default(),
69
+        }])
70
+    }
71
+
72
+    async fn fetch(&self, entry: &WallpaperEntry) -> Result<FetchedImage> {
73
+        let response = self
74
+            .client
75
+            .get(&entry.uri)
76
+            .send()
77
+            .await
78
+            .with_context(|| format!("Failed to fetch: {}", entry.uri))?;
79
+
80
+        let status = response.status();
81
+        if !status.is_success() {
82
+            anyhow::bail!("HTTP error {}: {}", status, entry.uri);
83
+        }
84
+
85
+        let bytes = response
86
+            .bytes()
87
+            .await
88
+            .with_context(|| format!("Failed to read response body: {}", entry.uri))?;
89
+
90
+        let image = ImageLoader::load_bytes(&bytes, None)?;
91
+
92
+        Ok(FetchedImage {
93
+            image,
94
+            uri: entry.uri.clone(),
95
+            media_type: entry.media_type,
96
+        })
97
+    }
98
+}
garbg/src/sources/mod.rsadded
@@ -0,0 +1,20 @@
1
+//! Image source providers
2
+//!
3
+//! Supports fetching wallpapers from various sources:
4
+//! - Local files
5
+//! - HTTP/HTTPS URLs
6
+//! - GitHub repositories
7
+//! - Directory indexes (Apache/nginx)
8
+//! - S3-compatible storage (optional)
9
+
10
+mod provider;
11
+mod file;
12
+mod http;
13
+mod github;
14
+mod directory;
15
+
16
+pub use provider::{SourceProvider, ProviderRegistry, WallpaperEntry, MediaType, FetchedImage};
17
+pub use file::FileProvider;
18
+pub use http::HttpProvider;
19
+pub use github::GitHubProvider;
20
+pub use directory::DirectoryIndexProvider;
garbg/src/sources/provider.rsadded
@@ -0,0 +1,110 @@
1
+//! Source provider trait and registry
2
+
3
+use anyhow::Result;
4
+use async_trait::async_trait;
5
+use image::RgbaImage;
6
+use std::collections::HashMap;
7
+
8
+/// Type of media content
9
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10
+pub enum MediaType {
11
+    /// Static image (PNG, JPEG, WebP, etc.)
12
+    StaticImage,
13
+    /// Animated image (GIF, APNG, animated WebP)
14
+    AnimatedImage,
15
+    /// Video file (MP4, WebM)
16
+    Video,
17
+}
18
+
19
+/// Entry representing a wallpaper from a source
20
+#[derive(Debug, Clone)]
21
+pub struct WallpaperEntry {
22
+    /// Full URI to the wallpaper
23
+    pub uri: String,
24
+    /// Display name
25
+    pub name: String,
26
+    /// Type of media
27
+    pub media_type: MediaType,
28
+    /// File size in bytes (if known)
29
+    pub size: Option<u64>,
30
+    /// Additional metadata
31
+    pub metadata: HashMap<String, String>,
32
+}
33
+
34
+/// Result of fetching a wallpaper
35
+pub struct FetchedImage {
36
+    /// The decoded image data
37
+    pub image: RgbaImage,
38
+    /// Original URI
39
+    pub uri: String,
40
+    /// Media type
41
+    pub media_type: MediaType,
42
+}
43
+
44
+/// Trait for wallpaper source providers
45
+#[async_trait]
46
+pub trait SourceProvider: Send + Sync {
47
+    /// Provider identifier (e.g., "file", "http", "github")
48
+    fn id(&self) -> &str;
49
+
50
+    /// Check if this provider can handle a given URI
51
+    fn can_handle(&self, uri: &str) -> bool;
52
+
53
+    /// List available wallpapers from a source
54
+    ///
55
+    /// For single-file sources, returns a single entry.
56
+    /// For directory sources, returns all entries.
57
+    async fn list(&self, uri: &str) -> Result<Vec<WallpaperEntry>>;
58
+
59
+    /// Fetch a specific wallpaper
60
+    async fn fetch(&self, entry: &WallpaperEntry) -> Result<FetchedImage>;
61
+
62
+    /// Whether this source supports streaming (for video)
63
+    fn supports_streaming(&self) -> bool {
64
+        false
65
+    }
66
+}
67
+
68
+/// Registry of all available providers
69
+pub struct ProviderRegistry {
70
+    providers: Vec<Box<dyn SourceProvider>>,
71
+}
72
+
73
+impl ProviderRegistry {
74
+    /// Create a new registry with default providers
75
+    pub fn new() -> Self {
76
+        Self {
77
+            providers: Vec::new(),
78
+        }
79
+    }
80
+
81
+    /// Register a provider
82
+    pub fn register(&mut self, provider: Box<dyn SourceProvider>) {
83
+        self.providers.push(provider);
84
+    }
85
+
86
+    /// Find a provider that can handle a URI
87
+    pub fn find_provider(&self, uri: &str) -> Option<&dyn SourceProvider> {
88
+        self.providers
89
+            .iter()
90
+            .find(|p| p.can_handle(uri))
91
+            .map(|p| p.as_ref())
92
+    }
93
+
94
+    /// Get all registered providers
95
+    pub fn providers(&self) -> &[Box<dyn SourceProvider>] {
96
+        &self.providers
97
+    }
98
+}
99
+
100
+impl Default for ProviderRegistry {
101
+    fn default() -> Self {
102
+        let mut registry = Self::new();
103
+
104
+        // Register default providers
105
+        registry.register(Box::new(super::FileProvider::new()));
106
+        // HTTP and other providers will be registered when needed
107
+
108
+        registry
109
+    }
110
+}
garbg/src/state.rsadded
@@ -0,0 +1,191 @@
1
+//! Playlist state management for slideshow functionality
2
+
3
+use anyhow::{Context, Result};
4
+use chrono::{DateTime, Utc};
5
+use rand::seq::SliceRandom;
6
+use serde::{Deserialize, Serialize};
7
+use std::fs;
8
+use std::path::PathBuf;
9
+
10
+use crate::config::ScaleMode;
11
+
12
+/// Type of source for the playlist
13
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14
+#[serde(rename_all = "lowercase")]
15
+pub enum SourceType {
16
+    Local,
17
+    GitHub,
18
+    Http,
19
+}
20
+
21
+/// Persistent playlist state for slideshow navigation
22
+#[derive(Debug, Clone, Serialize, Deserialize)]
23
+pub struct PlaylistState {
24
+    /// Original source path/URI
25
+    pub source: String,
26
+
27
+    /// Type of source
28
+    pub source_type: SourceType,
29
+
30
+    /// List of image paths/URLs in playlist order
31
+    pub images: Vec<String>,
32
+
33
+    /// Current position in the playlist
34
+    pub current_index: usize,
35
+
36
+    /// Whether the playlist was shuffled
37
+    pub shuffled: bool,
38
+
39
+    /// Scaling mode for wallpapers
40
+    pub mode: ScaleMode,
41
+
42
+    /// When the state was last updated
43
+    pub last_updated: DateTime<Utc>,
44
+}
45
+
46
+impl PlaylistState {
47
+    /// Get the path to the state file
48
+    pub fn state_path() -> PathBuf {
49
+        dirs::cache_dir()
50
+            .unwrap_or_else(|| PathBuf::from("/tmp"))
51
+            .join("garbg")
52
+            .join("state.json")
53
+    }
54
+
55
+    /// Create a new playlist state
56
+    pub fn new(
57
+        source: String,
58
+        source_type: SourceType,
59
+        images: Vec<String>,
60
+        shuffled: bool,
61
+        mode: ScaleMode,
62
+    ) -> Self {
63
+        Self {
64
+            source,
65
+            source_type,
66
+            images,
67
+            current_index: 0,
68
+            shuffled,
69
+            mode,
70
+            last_updated: Utc::now(),
71
+        }
72
+    }
73
+
74
+    /// Load state from disk, returns None if file doesn't exist
75
+    pub fn load() -> Result<Option<Self>> {
76
+        let path = Self::state_path();
77
+
78
+        if !path.exists() {
79
+            return Ok(None);
80
+        }
81
+
82
+        let content = fs::read_to_string(&path)
83
+            .with_context(|| format!("Failed to read state file: {}", path.display()))?;
84
+
85
+        let state: Self = serde_json::from_str(&content)
86
+            .with_context(|| "Failed to parse state file")?;
87
+
88
+        Ok(Some(state))
89
+    }
90
+
91
+    /// Save state to disk
92
+    pub fn save(&mut self) -> Result<()> {
93
+        self.last_updated = Utc::now();
94
+
95
+        let path = Self::state_path();
96
+
97
+        // Ensure parent directory exists
98
+        if let Some(parent) = path.parent() {
99
+            fs::create_dir_all(parent)
100
+                .with_context(|| format!("Failed to create cache directory: {}", parent.display()))?;
101
+        }
102
+
103
+        let content = serde_json::to_string_pretty(self)
104
+            .with_context(|| "Failed to serialize state")?;
105
+
106
+        fs::write(&path, content)
107
+            .with_context(|| format!("Failed to write state file: {}", path.display()))?;
108
+
109
+        Ok(())
110
+    }
111
+
112
+    /// Reload state from disk (for when another process may have modified it)
113
+    pub fn reload(&mut self) -> Result<()> {
114
+        if let Some(loaded) = Self::load()? {
115
+            *self = loaded;
116
+        }
117
+        Ok(())
118
+    }
119
+
120
+    /// Get the current image
121
+    pub fn current(&self) -> Option<&str> {
122
+        self.images.get(self.current_index).map(|s| s.as_str())
123
+    }
124
+
125
+    /// Advance to the next image, returns the new current image
126
+    /// Re-shuffles on wrap-around if in shuffle mode
127
+    pub fn next(&mut self) -> &str {
128
+        if self.images.is_empty() {
129
+            return "";
130
+        }
131
+
132
+        self.current_index += 1;
133
+
134
+        // Wrap around
135
+        if self.current_index >= self.images.len() {
136
+            self.current_index = 0;
137
+
138
+            // Re-shuffle on wrap if in shuffle mode
139
+            if self.shuffled {
140
+                self.reshuffle();
141
+            }
142
+        }
143
+
144
+        &self.images[self.current_index]
145
+    }
146
+
147
+    /// Go to the previous image, returns the new current image
148
+    pub fn prev(&mut self) -> &str {
149
+        if self.images.is_empty() {
150
+            return "";
151
+        }
152
+
153
+        // Wrap around
154
+        if self.current_index == 0 {
155
+            self.current_index = self.images.len() - 1;
156
+        } else {
157
+            self.current_index -= 1;
158
+        }
159
+
160
+        &self.images[self.current_index]
161
+    }
162
+
163
+    /// Shuffle the playlist (keeps current image but resets index to 0)
164
+    pub fn reshuffle(&mut self) {
165
+        let mut rng = rand::thread_rng();
166
+        self.images.shuffle(&mut rng);
167
+        // After reshuffle, we're at the start of a new random order
168
+        // current_index is already 0 from wrap-around
169
+    }
170
+
171
+    /// Get total number of images
172
+    pub fn len(&self) -> usize {
173
+        self.images.len()
174
+    }
175
+
176
+    /// Check if playlist is empty
177
+    pub fn is_empty(&self) -> bool {
178
+        self.images.is_empty()
179
+    }
180
+}
181
+
182
+/// Detect the source type from a URI/path
183
+pub fn detect_source_type(source: &str) -> SourceType {
184
+    if source.starts_with("github://") || source.contains("github.com") {
185
+        SourceType::GitHub
186
+    } else if source.starts_with("http://") || source.starts_with("https://") {
187
+        SourceType::Http
188
+    } else {
189
+        SourceType::Local
190
+    }
191
+}
garbg/src/x11/connection.rsadded
@@ -0,0 +1,227 @@
1
+//! X11 connection management and atom interning
2
+
3
+use anyhow::{Context, Result};
4
+use x11rb::connection::Connection as X11Connection;
5
+use x11rb::protocol::xproto::*;
6
+use x11rb::rust_connection::RustConnection;
7
+use x11rb::wrapper::ConnectionExt as _;
8
+
9
+/// Interned X11 atoms for wallpaper operations
10
+pub struct Atoms {
11
+    /// Standard atom for root pixmap (used by many apps)
12
+    pub xrootpmap_id: Atom,
13
+    /// Esetroot compatibility atom
14
+    pub esetroot_pmap_id: Atom,
15
+}
16
+
17
+impl Atoms {
18
+    fn intern(conn: &RustConnection) -> Result<Self> {
19
+        let xrootpmap_id = conn
20
+            .intern_atom(false, b"_XROOTPMAP_ID")?
21
+            .reply()
22
+            .context("Failed to intern _XROOTPMAP_ID")?
23
+            .atom;
24
+
25
+        let esetroot_pmap_id = conn
26
+            .intern_atom(false, b"ESETROOT_PMAP_ID")?
27
+            .reply()
28
+            .context("Failed to intern ESETROOT_PMAP_ID")?
29
+            .atom;
30
+
31
+        Ok(Self {
32
+            xrootpmap_id,
33
+            esetroot_pmap_id,
34
+        })
35
+    }
36
+}
37
+
38
+/// X11 connection wrapper for garbg
39
+pub struct Connection {
40
+    conn: RustConnection,
41
+    screen_num: usize,
42
+    root: Window,
43
+    depth: u8,
44
+    visual: Visualid,
45
+    gc: Gcontext,
46
+    atoms: Atoms,
47
+    /// Currently set root pixmap (if any)
48
+    current_pixmap: Option<Pixmap>,
49
+}
50
+
51
+impl Connection {
52
+    /// Create a new X11 connection
53
+    pub fn new() -> Result<Self> {
54
+        let (conn, screen_num) = RustConnection::connect(None)
55
+            .context("Failed to connect to X server")?;
56
+
57
+        let screen = &conn.setup().roots[screen_num];
58
+        let root = screen.root;
59
+        let depth = screen.root_depth;
60
+        let visual = screen.root_visual;
61
+
62
+        // Create a graphics context for drawing
63
+        let gc = conn.generate_id()?;
64
+        conn.create_gc(gc, root, &CreateGCAux::new())?;
65
+
66
+        let atoms = Atoms::intern(&conn)?;
67
+
68
+        Ok(Self {
69
+            conn,
70
+            screen_num,
71
+            root,
72
+            depth,
73
+            visual,
74
+            gc,
75
+            atoms,
76
+            current_pixmap: None,
77
+        })
78
+    }
79
+
80
+    /// Get screen dimensions (width, height)
81
+    pub fn screen_dimensions(&self) -> (u16, u16) {
82
+        let screen = &self.conn.setup().roots[self.screen_num];
83
+        (screen.width_in_pixels, screen.height_in_pixels)
84
+    }
85
+
86
+    /// Get the root window ID
87
+    pub fn root(&self) -> Window {
88
+        self.root
89
+    }
90
+
91
+    /// Get the connection reference
92
+    pub fn conn(&self) -> &RustConnection {
93
+        &self.conn
94
+    }
95
+
96
+    /// Get screen depth
97
+    pub fn depth(&self) -> u8 {
98
+        self.depth
99
+    }
100
+
101
+    /// Get visual ID
102
+    pub fn visual(&self) -> Visualid {
103
+        self.visual
104
+    }
105
+
106
+    /// Get graphics context
107
+    pub fn gc(&self) -> Gcontext {
108
+        self.gc
109
+    }
110
+
111
+    /// Get atoms
112
+    pub fn atoms(&self) -> &Atoms {
113
+        &self.atoms
114
+    }
115
+
116
+    /// Set a wallpaper from BGRA image data
117
+    pub fn set_wallpaper(&mut self, image: &image::RgbaImage) -> Result<()> {
118
+        let (width, height) = self.screen_dimensions();
119
+
120
+        // Convert RGBA to BGRA (X11 native format)
121
+        let bgra_data = rgba_to_bgra(image);
122
+
123
+        // Create a new pixmap
124
+        let pixmap = self.conn.generate_id()?;
125
+        self.conn.create_pixmap(self.depth, pixmap, self.root, width, height)?;
126
+
127
+        // X11 has a maximum request size. We need to send large images in chunks.
128
+        // Calculate how many rows we can send per request.
129
+        // Request overhead is ~28 bytes, max request size from setup.
130
+        let max_request_bytes = self.conn.setup().maximum_request_length as usize * 4;
131
+        let bytes_per_row = width as usize * 4; // 4 bytes per pixel (BGRA)
132
+        let request_overhead = 28; // PutImage request header size
133
+        let max_rows_per_request = (max_request_bytes - request_overhead) / bytes_per_row;
134
+        let max_rows_per_request = max_rows_per_request.max(1) as u16;
135
+
136
+        // Send image in chunks
137
+        let mut y_offset: u16 = 0;
138
+        while y_offset < height {
139
+            let rows_to_send = (height - y_offset).min(max_rows_per_request);
140
+            let start_byte = y_offset as usize * bytes_per_row;
141
+            let end_byte = (y_offset as usize + rows_to_send as usize) * bytes_per_row;
142
+            let chunk = &bgra_data[start_byte..end_byte];
143
+
144
+            self.conn.put_image(
145
+                ImageFormat::Z_PIXMAP,
146
+                pixmap,
147
+                self.gc,
148
+                width,
149
+                rows_to_send,
150
+                0,
151
+                y_offset as i16,
152
+                0,
153
+                self.depth,
154
+                chunk,
155
+            )?;
156
+
157
+            y_offset += rows_to_send;
158
+        }
159
+
160
+        // Set the pixmap as root window background
161
+        self.set_root_pixmap(pixmap)?;
162
+
163
+        // Free the old pixmap if we had one
164
+        if let Some(old_pixmap) = self.current_pixmap.take() {
165
+            self.conn.free_pixmap(old_pixmap)?;
166
+        }
167
+
168
+        self.current_pixmap = Some(pixmap);
169
+        self.conn.flush()?;
170
+
171
+        Ok(())
172
+    }
173
+
174
+    /// Set a pixmap as the root window background
175
+    fn set_root_pixmap(&self, pixmap: Pixmap) -> Result<()> {
176
+        // Set the standard atoms so other applications can detect the wallpaper
177
+        self.conn.change_property32(
178
+            PropMode::REPLACE,
179
+            self.root,
180
+            self.atoms.xrootpmap_id,
181
+            AtomEnum::PIXMAP,
182
+            &[pixmap],
183
+        )?;
184
+
185
+        self.conn.change_property32(
186
+            PropMode::REPLACE,
187
+            self.root,
188
+            self.atoms.esetroot_pmap_id,
189
+            AtomEnum::PIXMAP,
190
+            &[pixmap],
191
+        )?;
192
+
193
+        // Set the pixmap as the actual background
194
+        self.conn.change_window_attributes(
195
+            self.root,
196
+            &ChangeWindowAttributesAux::new().background_pixmap(pixmap),
197
+        )?;
198
+
199
+        // Clear the root window to display the new background
200
+        let (width, height) = self.screen_dimensions();
201
+        self.conn.clear_area(false, self.root, 0, 0, width, height)?;
202
+
203
+        Ok(())
204
+    }
205
+}
206
+
207
+impl Drop for Connection {
208
+    fn drop(&mut self) {
209
+        // Clean up the pixmap when we're done
210
+        if let Some(pixmap) = self.current_pixmap.take() {
211
+            let _ = self.conn.free_pixmap(pixmap);
212
+        }
213
+        let _ = self.conn.free_gc(self.gc);
214
+    }
215
+}
216
+
217
+/// Convert RGBA to BGRA (X11 native format for 32-bit visuals)
218
+fn rgba_to_bgra(image: &image::RgbaImage) -> Vec<u8> {
219
+    let mut bgra = Vec::with_capacity(image.len());
220
+    for pixel in image.pixels() {
221
+        bgra.push(pixel[2]); // B
222
+        bgra.push(pixel[1]); // G
223
+        bgra.push(pixel[0]); // R
224
+        bgra.push(pixel[3]); // A
225
+    }
226
+    bgra
227
+}
garbg/src/x11/mod.rsadded
@@ -0,0 +1,12 @@
1
+//! X11 integration for garbg
2
+//!
3
+//! Handles connection to the X server, root window manipulation,
4
+//! and pixmap-based wallpaper rendering.
5
+
6
+mod connection;
7
+mod renderer;
8
+mod monitors;
9
+
10
+pub use connection::Connection;
11
+pub use renderer::Renderer;
12
+pub use monitors::Monitor;
garbg/src/x11/monitors.rsadded
@@ -0,0 +1,29 @@
1
+//! Multi-monitor detection via RandR
2
+
3
+use anyhow::Result;
4
+
5
+/// Represents a connected monitor/output
6
+#[derive(Debug, Clone)]
7
+pub struct Monitor {
8
+    /// Output name (e.g., "DP-1", "HDMI-1")
9
+    pub name: String,
10
+    /// X position
11
+    pub x: i16,
12
+    /// Y position
13
+    pub y: i16,
14
+    /// Width in pixels
15
+    pub width: u16,
16
+    /// Height in pixels
17
+    pub height: u16,
18
+    /// Whether this is the primary monitor
19
+    pub primary: bool,
20
+}
21
+
22
+impl Monitor {
23
+    /// Get all connected monitors
24
+    pub fn get_all(_conn: &super::Connection) -> Result<Vec<Monitor>> {
25
+        // TODO: Implement RandR monitor detection
26
+        // For now, return a single monitor covering the whole screen
27
+        Ok(vec![])
28
+    }
29
+}
garbg/src/x11/renderer.rsadded
@@ -0,0 +1,29 @@
1
+//! High-level wallpaper rendering abstraction
2
+
3
+use anyhow::Result;
4
+use image::RgbaImage;
5
+
6
+use super::Connection;
7
+
8
+/// Wallpaper renderer using X11 pixmaps
9
+pub struct Renderer {
10
+    conn: Connection,
11
+}
12
+
13
+impl Renderer {
14
+    /// Create a new renderer
15
+    pub fn new() -> Result<Self> {
16
+        let conn = Connection::new()?;
17
+        Ok(Self { conn })
18
+    }
19
+
20
+    /// Set a static wallpaper
21
+    pub fn set_wallpaper(&mut self, image: &RgbaImage) -> Result<()> {
22
+        self.conn.set_wallpaper(image)
23
+    }
24
+
25
+    /// Get screen dimensions
26
+    pub fn screen_dimensions(&self) -> (u16, u16) {
27
+        self.conn.screen_dimensions()
28
+    }
29
+}