gardesk/garcard / e5a40fb

Browse files

add gartk prompt path timeout handling

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
e5a40fbb1aebb36dcd3b91df59ee6b4d5f71204b
Parents
517f24c
Tree
8866c29

10 changed files

StatusFile+-
M Cargo.lock 356 5
M Cargo.toml 4 0
M README.md 5 2
M garcard/Cargo.toml 4 0
M garcard/src/agent.rs 1 0
M garcard/src/main.rs 54 1
M garcard/src/polkit_helper.rs 77 15
M garcard/src/prompt.rs 89 15
A garcard/src/prompt_ui.rs 451 0
M garcard/src/state.rs 4 0
Cargo.lockmodified
@@ -255,6 +255,39 @@ version = "1.11.1"
255255
 source = "registry+https://github.com/rust-lang/crates.io-index"
256256
 checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
257257
 
258
+[[package]]
259
+name = "cairo-rs"
260
+version = "0.20.12"
261
+source = "registry+https://github.com/rust-lang/crates.io-index"
262
+checksum = "91e3bd0f4e25afa9cabc157908d14eeef9067d6448c49414d17b3fb55f0eadd0"
263
+dependencies = [
264
+ "bitflags",
265
+ "cairo-sys-rs",
266
+ "glib",
267
+ "libc",
268
+]
269
+
270
+[[package]]
271
+name = "cairo-sys-rs"
272
+version = "0.20.10"
273
+source = "registry+https://github.com/rust-lang/crates.io-index"
274
+checksum = "059cc746549898cbfd9a47754288e5a958756650ef4652bbb6c5f71a6bda4f8b"
275
+dependencies = [
276
+ "glib-sys",
277
+ "libc",
278
+ "system-deps",
279
+]
280
+
281
+[[package]]
282
+name = "cfg-expr"
283
+version = "0.20.6"
284
+source = "registry+https://github.com/rust-lang/crates.io-index"
285
+checksum = "78cef5b5a1a6827c7322ae2a636368a573006b27cfa76c7ebd53e834daeaab6a"
286
+dependencies = [
287
+ "smallvec",
288
+ "target-lexicon",
289
+]
290
+
258291
 [[package]]
259292
 name = "cfg-if"
260293
 version = "1.0.4"
@@ -448,12 +481,32 @@ version = "2.3.0"
448481
 source = "registry+https://github.com/rust-lang/crates.io-index"
449482
 checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
450483
 
484
+[[package]]
485
+name = "futures-channel"
486
+version = "0.3.32"
487
+source = "registry+https://github.com/rust-lang/crates.io-index"
488
+checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d"
489
+dependencies = [
490
+ "futures-core",
491
+]
492
+
451493
 [[package]]
452494
 name = "futures-core"
453495
 version = "0.3.32"
454496
 source = "registry+https://github.com/rust-lang/crates.io-index"
455497
 checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
456498
 
499
+[[package]]
500
+name = "futures-executor"
501
+version = "0.3.31"
502
+source = "registry+https://github.com/rust-lang/crates.io-index"
503
+checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f"
504
+dependencies = [
505
+ "futures-core",
506
+ "futures-task",
507
+ "futures-util",
508
+]
509
+
457510
 [[package]]
458511
 name = "futures-io"
459512
 version = "0.3.32"
@@ -473,6 +526,17 @@ dependencies = [
473526
  "pin-project-lite",
474527
 ]
475528
 
529
+[[package]]
530
+name = "futures-macro"
531
+version = "0.3.31"
532
+source = "registry+https://github.com/rust-lang/crates.io-index"
533
+checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650"
534
+dependencies = [
535
+ "proc-macro2",
536
+ "quote",
537
+ "syn",
538
+]
539
+
476540
 [[package]]
477541
 name = "futures-sink"
478542
 version = "0.3.31"
@@ -493,6 +557,7 @@ checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
493557
 dependencies = [
494558
  "futures-core",
495559
  "futures-io",
560
+ "futures-macro",
496561
  "futures-sink",
497562
  "futures-task",
498563
  "memchr",
@@ -509,13 +574,17 @@ dependencies = [
509574
  "clap",
510575
  "dirs",
511576
  "garcard-ipc",
577
+ "gartk-core",
578
+ "gartk-render",
579
+ "gartk-x11",
512580
  "nix",
513581
  "serde",
514582
  "serde_json",
515583
  "tokio",
516
- "toml",
584
+ "toml 0.8.23",
517585
  "tracing",
518586
  "tracing-subscriber",
587
+ "x11rb",
519588
  "zbus",
520589
 ]
521590
 
@@ -537,6 +606,38 @@ dependencies = [
537606
  "serde_json",
538607
 ]
539608
 
609
+[[package]]
610
+name = "gartk-core"
611
+version = "0.3.0"
612
+dependencies = [
613
+ "serde",
614
+ "thiserror 2.0.18",
615
+]
616
+
617
+[[package]]
618
+name = "gartk-render"
619
+version = "0.3.0"
620
+dependencies = [
621
+ "cairo-rs",
622
+ "gartk-core",
623
+ "gartk-x11",
624
+ "pango",
625
+ "pangocairo",
626
+ "thiserror 2.0.18",
627
+ "tracing",
628
+ "x11rb",
629
+]
630
+
631
+[[package]]
632
+name = "gartk-x11"
633
+version = "0.3.0"
634
+dependencies = [
635
+ "gartk-core",
636
+ "thiserror 2.0.18",
637
+ "tracing",
638
+ "x11rb",
639
+]
640
+
540641
 [[package]]
541642
 name = "generic-array"
542643
 version = "0.14.7"
@@ -547,6 +648,16 @@ dependencies = [
547648
  "version_check",
548649
 ]
549650
 
651
+[[package]]
652
+name = "gethostname"
653
+version = "1.1.0"
654
+source = "registry+https://github.com/rust-lang/crates.io-index"
655
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
656
+dependencies = [
657
+ "rustix",
658
+ "windows-link",
659
+]
660
+
550661
 [[package]]
551662
 name = "getrandom"
552663
 version = "0.2.17"
@@ -570,6 +681,91 @@ dependencies = [
570681
  "wasip2",
571682
 ]
572683
 
684
+[[package]]
685
+name = "gio"
686
+version = "0.20.12"
687
+source = "registry+https://github.com/rust-lang/crates.io-index"
688
+checksum = "8e27e276e7b6b8d50f6376ee7769a71133e80d093bdc363bd0af71664228b831"
689
+dependencies = [
690
+ "futures-channel",
691
+ "futures-core",
692
+ "futures-io",
693
+ "futures-util",
694
+ "gio-sys",
695
+ "glib",
696
+ "libc",
697
+ "pin-project-lite",
698
+ "smallvec",
699
+]
700
+
701
+[[package]]
702
+name = "gio-sys"
703
+version = "0.20.10"
704
+source = "registry+https://github.com/rust-lang/crates.io-index"
705
+checksum = "521e93a7e56fc89e84aea9a52cfc9436816a4b363b030260b699950ff1336c83"
706
+dependencies = [
707
+ "glib-sys",
708
+ "gobject-sys",
709
+ "libc",
710
+ "system-deps",
711
+ "windows-sys 0.59.0",
712
+]
713
+
714
+[[package]]
715
+name = "glib"
716
+version = "0.20.12"
717
+source = "registry+https://github.com/rust-lang/crates.io-index"
718
+checksum = "ffc4b6e352d4716d84d7dde562dd9aee2a7d48beb872dd9ece7f2d1515b2d683"
719
+dependencies = [
720
+ "bitflags",
721
+ "futures-channel",
722
+ "futures-core",
723
+ "futures-executor",
724
+ "futures-task",
725
+ "futures-util",
726
+ "gio-sys",
727
+ "glib-macros",
728
+ "glib-sys",
729
+ "gobject-sys",
730
+ "libc",
731
+ "memchr",
732
+ "smallvec",
733
+]
734
+
735
+[[package]]
736
+name = "glib-macros"
737
+version = "0.20.12"
738
+source = "registry+https://github.com/rust-lang/crates.io-index"
739
+checksum = "e8084af62f09475a3f529b1629c10c429d7600ee1398ae12dd3bf175d74e7145"
740
+dependencies = [
741
+ "heck",
742
+ "proc-macro-crate",
743
+ "proc-macro2",
744
+ "quote",
745
+ "syn",
746
+]
747
+
748
+[[package]]
749
+name = "glib-sys"
750
+version = "0.20.10"
751
+source = "registry+https://github.com/rust-lang/crates.io-index"
752
+checksum = "8ab79e1ed126803a8fb827e3de0e2ff95191912b8db65cee467edb56fc4cc215"
753
+dependencies = [
754
+ "libc",
755
+ "system-deps",
756
+]
757
+
758
+[[package]]
759
+name = "gobject-sys"
760
+version = "0.20.10"
761
+source = "registry+https://github.com/rust-lang/crates.io-index"
762
+checksum = "ec9aca94bb73989e3cfdbf8f2e0f1f6da04db4d291c431f444838925c4c63eda"
763
+dependencies = [
764
+ "glib-sys",
765
+ "libc",
766
+ "system-deps",
767
+]
768
+
573769
 [[package]]
574770
 name = "hashbrown"
575771
 version = "0.16.1"
@@ -744,6 +940,56 @@ dependencies = [
744940
  "pin-project-lite",
745941
 ]
746942
 
943
+[[package]]
944
+name = "pango"
945
+version = "0.20.12"
946
+source = "registry+https://github.com/rust-lang/crates.io-index"
947
+checksum = "6576b311f6df659397043a5fa8a021da8f72e34af180b44f7d57348de691ab5c"
948
+dependencies = [
949
+ "gio",
950
+ "glib",
951
+ "libc",
952
+ "pango-sys",
953
+]
954
+
955
+[[package]]
956
+name = "pango-sys"
957
+version = "0.20.10"
958
+source = "registry+https://github.com/rust-lang/crates.io-index"
959
+checksum = "186909673fc09be354555c302c0b3dcf753cd9fa08dcb8077fa663c80fb243fa"
960
+dependencies = [
961
+ "glib-sys",
962
+ "gobject-sys",
963
+ "libc",
964
+ "system-deps",
965
+]
966
+
967
+[[package]]
968
+name = "pangocairo"
969
+version = "0.20.10"
970
+source = "registry+https://github.com/rust-lang/crates.io-index"
971
+checksum = "58890dc451db9964ac2d8874f903a4370a4b3932aa5281ff0c8d9810937ad84f"
972
+dependencies = [
973
+ "cairo-rs",
974
+ "glib",
975
+ "libc",
976
+ "pango",
977
+ "pangocairo-sys",
978
+]
979
+
980
+[[package]]
981
+name = "pangocairo-sys"
982
+version = "0.20.10"
983
+source = "registry+https://github.com/rust-lang/crates.io-index"
984
+checksum = "b9952903f88aa93e2927e7bca2d1ebae64fc26545a9280b4ce6bddeda26b5c42"
985
+dependencies = [
986
+ "cairo-sys-rs",
987
+ "glib-sys",
988
+ "libc",
989
+ "pango-sys",
990
+ "system-deps",
991
+]
992
+
747993
 [[package]]
748994
 name = "parking"
749995
 version = "2.2.1"
@@ -796,6 +1042,12 @@ dependencies = [
7961042
  "futures-io",
7971043
 ]
7981044
 
1045
+[[package]]
1046
+name = "pkg-config"
1047
+version = "0.3.32"
1048
+source = "registry+https://github.com/rust-lang/crates.io-index"
1049
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
1050
+
7991051
 [[package]]
8001052
 name = "polling"
8011053
 version = "3.11.0"
@@ -899,7 +1151,7 @@ checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43"
8991151
 dependencies = [
9001152
  "getrandom 0.2.17",
9011153
  "libredox",
902
- "thiserror",
1154
+ "thiserror 1.0.69",
9031155
 ]
9041156
 
9051157
 [[package]]
@@ -1001,6 +1253,15 @@ dependencies = [
10011253
  "serde",
10021254
 ]
10031255
 
1256
+[[package]]
1257
+name = "serde_spanned"
1258
+version = "1.0.4"
1259
+source = "registry+https://github.com/rust-lang/crates.io-index"
1260
+checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
1261
+dependencies = [
1262
+ "serde_core",
1263
+]
1264
+
10041265
 [[package]]
10051266
 name = "sha1"
10061267
 version = "0.10.6"
@@ -1076,6 +1337,25 @@ dependencies = [
10761337
  "unicode-ident",
10771338
 ]
10781339
 
1340
+[[package]]
1341
+name = "system-deps"
1342
+version = "7.0.7"
1343
+source = "registry+https://github.com/rust-lang/crates.io-index"
1344
+checksum = "48c8f33736f986f16d69b6cb8b03f55ddcad5c41acc4ccc39dd88e84aa805e7f"
1345
+dependencies = [
1346
+ "cfg-expr",
1347
+ "heck",
1348
+ "pkg-config",
1349
+ "toml 0.9.12+spec-1.1.0",
1350
+ "version-compare",
1351
+]
1352
+
1353
+[[package]]
1354
+name = "target-lexicon"
1355
+version = "0.13.3"
1356
+source = "registry+https://github.com/rust-lang/crates.io-index"
1357
+checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
1358
+
10791359
 [[package]]
10801360
 name = "tempfile"
10811361
 version = "3.24.0"
@@ -1095,7 +1375,16 @@ version = "1.0.69"
10951375
 source = "registry+https://github.com/rust-lang/crates.io-index"
10961376
 checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
10971377
 dependencies = [
1098
- "thiserror-impl",
1378
+ "thiserror-impl 1.0.69",
1379
+]
1380
+
1381
+[[package]]
1382
+name = "thiserror"
1383
+version = "2.0.18"
1384
+source = "registry+https://github.com/rust-lang/crates.io-index"
1385
+checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
1386
+dependencies = [
1387
+ "thiserror-impl 2.0.18",
10991388
 ]
11001389
 
11011390
 [[package]]
@@ -1109,6 +1398,17 @@ dependencies = [
11091398
  "syn",
11101399
 ]
11111400
 
1401
+[[package]]
1402
+name = "thiserror-impl"
1403
+version = "2.0.18"
1404
+source = "registry+https://github.com/rust-lang/crates.io-index"
1405
+checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5"
1406
+dependencies = [
1407
+ "proc-macro2",
1408
+ "quote",
1409
+ "syn",
1410
+]
1411
+
11121412
 [[package]]
11131413
 name = "thread_local"
11141414
 version = "1.1.9"
@@ -1153,11 +1453,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
11531453
 checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
11541454
 dependencies = [
11551455
  "serde",
1156
- "serde_spanned",
1456
+ "serde_spanned 0.6.9",
11571457
  "toml_datetime 0.6.11",
11581458
  "toml_edit 0.22.27",
11591459
 ]
11601460
 
1461
+[[package]]
1462
+name = "toml"
1463
+version = "0.9.12+spec-1.1.0"
1464
+source = "registry+https://github.com/rust-lang/crates.io-index"
1465
+checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
1466
+dependencies = [
1467
+ "indexmap",
1468
+ "serde_core",
1469
+ "serde_spanned 1.0.4",
1470
+ "toml_datetime 0.7.5+spec-1.1.0",
1471
+ "toml_parser",
1472
+ "toml_writer",
1473
+ "winnow",
1474
+]
1475
+
11611476
 [[package]]
11621477
 name = "toml_datetime"
11631478
 version = "0.6.11"
@@ -1184,7 +1499,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
11841499
 dependencies = [
11851500
  "indexmap",
11861501
  "serde",
1187
- "serde_spanned",
1502
+ "serde_spanned 0.6.9",
11881503
  "toml_datetime 0.6.11",
11891504
  "toml_write",
11901505
  "winnow",
@@ -1217,6 +1532,12 @@ version = "0.1.2"
12171532
 source = "registry+https://github.com/rust-lang/crates.io-index"
12181533
 checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
12191534
 
1535
+[[package]]
1536
+name = "toml_writer"
1537
+version = "1.0.6+spec-1.1.0"
1538
+source = "registry+https://github.com/rust-lang/crates.io-index"
1539
+checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
1540
+
12201541
 [[package]]
12211542
 name = "tracing"
12221543
 version = "0.1.44"
@@ -1313,6 +1634,12 @@ version = "0.1.1"
13131634
 source = "registry+https://github.com/rust-lang/crates.io-index"
13141635
 checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
13151636
 
1637
+[[package]]
1638
+name = "version-compare"
1639
+version = "0.2.1"
1640
+source = "registry+https://github.com/rust-lang/crates.io-index"
1641
+checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e"
1642
+
13161643
 [[package]]
13171644
 name = "version_check"
13181645
 version = "0.9.5"
@@ -1608,6 +1935,30 @@ version = "0.46.0"
16081935
 source = "registry+https://github.com/rust-lang/crates.io-index"
16091936
 checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
16101937
 
1938
+[[package]]
1939
+name = "x11rb"
1940
+version = "0.13.2"
1941
+source = "registry+https://github.com/rust-lang/crates.io-index"
1942
+checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
1943
+dependencies = [
1944
+ "gethostname",
1945
+ "rustix",
1946
+ "x11rb-protocol",
1947
+ "xcursor",
1948
+]
1949
+
1950
+[[package]]
1951
+name = "x11rb-protocol"
1952
+version = "0.13.2"
1953
+source = "registry+https://github.com/rust-lang/crates.io-index"
1954
+checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
1955
+
1956
+[[package]]
1957
+name = "xcursor"
1958
+version = "0.3.10"
1959
+source = "registry+https://github.com/rust-lang/crates.io-index"
1960
+checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b"
1961
+
16111962
 [[package]]
16121963
 name = "xdg-home"
16131964
 version = "1.3.0"
Cargo.tomlmodified
@@ -10,6 +10,9 @@ authors = ["gardesk contributors"]
1010
 
1111
 [workspace.dependencies]
1212
 garcard-ipc = { path = "garcard-ipc" }
13
+gartk-core = { path = "../gartk/gartk-core" }
14
+gartk-render = { path = "../gartk/gartk-render" }
15
+gartk-x11 = { path = "../gartk/gartk-x11" }
1316
 
1417
 anyhow = "1"
1518
 clap = { version = "4", features = ["derive"] }
@@ -22,3 +25,4 @@ dirs = "5"
2225
 toml = "0.8"
2326
 zbus = "4"
2427
 nix = { version = "0.29", features = ["user"] }
28
+x11rb = { version = "0.13", features = ["randr"] }
README.mdmodified
@@ -10,6 +10,7 @@
1010
 ## Quick Start
1111
 1. `cargo run -p garcard -- daemon`
1212
 2. `cargo run -p garcardctl -- status`
13
+3. `cargo run -p garcard -- prompt --mode secret --message "Validation prompt"`
1314
 
1415
 ## Config
1516
 Default config path: `~/.config/garcard/config.toml`
@@ -23,8 +24,10 @@ Environment overrides:
2324
 6. `GARCARD_LOCALE`
2425
 7. `GARCARD_POLKIT_HELPER_SOCKET`
2526
 8. `GARCARD_PROMPT_COMMAND`
27
+9. `GARCARD_PROMPT_TIMEOUT_SECS`
2628
 
2729
 See `examples/config.toml` for a starter file.
2830
 
29
-`GARCARD_PROMPT_COMMAND` is optional. If unset, `garcard` falls back to
30
-`systemd-ask-password` for prompt interaction.
31
+`GARCARD_PROMPT_COMMAND` is optional. If unset, `garcard` runs the built-in
32
+`garcard prompt` gartk dialog path and falls back to `systemd-ask-password`
33
+when the X11 prompt backend is unavailable.
garcard/Cargo.tomlmodified
@@ -7,6 +7,9 @@ authors.workspace = true
77
 
88
 [dependencies]
99
 garcard-ipc.workspace = true
10
+gartk-core.workspace = true
11
+gartk-render.workspace = true
12
+gartk-x11.workspace = true
1013
 
1114
 anyhow.workspace = true
1215
 clap.workspace = true
@@ -19,3 +22,4 @@ toml.workspace = true
1922
 serde.workspace = true
2023
 zbus.workspace = true
2124
 nix.workspace = true
25
+x11rb.workspace = true
garcard/src/agent.rsmodified
@@ -294,6 +294,7 @@ impl PolkitRuntime {
294294
             HelperOutcome::Authorized => AuthPhase::Success,
295295
             HelperOutcome::Denied => AuthPhase::Failure,
296296
             HelperOutcome::Canceled => AuthPhase::Canceled,
297
+            HelperOutcome::Timeout => AuthPhase::Timeout,
297298
         };
298299
         self.auth_state.set_phase(phase);
299300
     }
garcard/src/main.rsmodified
@@ -3,10 +3,11 @@ mod config;
33
 mod daemon;
44
 mod polkit_helper;
55
 mod prompt;
6
+mod prompt_ui;
67
 mod state;
78
 
89
 use anyhow::Result;
9
-use clap::{Parser, Subcommand};
10
+use clap::{Parser, Subcommand, ValueEnum};
1011
 use tracing_subscriber::EnvFilter;
1112
 
1213
 #[derive(Parser, Debug)]
@@ -27,6 +28,27 @@ struct Cli {
2728
 enum Commands {
2829
     /// Start daemon mode
2930
     Daemon,
31
+    /// Run interactive auth prompt mode (used internally by daemon)
32
+    Prompt(PromptArgs),
33
+}
34
+
35
+#[derive(Parser, Debug)]
36
+struct PromptArgs {
37
+    /// Prompt message to display
38
+    #[arg(long)]
39
+    message: String,
40
+    /// Input mode
41
+    #[arg(long, value_enum, default_value_t = PromptModeArg::Secret)]
42
+    mode: PromptModeArg,
43
+    /// Prompt timeout in seconds
44
+    #[arg(long, default_value_t = 120)]
45
+    timeout_secs: u64,
46
+}
47
+
48
+#[derive(Debug, Clone, Copy, ValueEnum)]
49
+enum PromptModeArg {
50
+    Secret,
51
+    Plain,
3052
 }
3153
 
3254
 #[tokio::main]
@@ -40,6 +62,37 @@ async fn main() -> Result<()> {
4062
             let config = config::Config::load()?;
4163
             daemon::run(config).await
4264
         }
65
+        Commands::Prompt(args) => {
66
+            let request = prompt_ui::PromptRequest {
67
+                message: args.message,
68
+                mode: match args.mode {
69
+                    PromptModeArg::Secret => prompt_ui::PromptMode::Secret,
70
+                    PromptModeArg::Plain => prompt_ui::PromptMode::Plain,
71
+                },
72
+                timeout_secs: args.timeout_secs,
73
+            };
74
+
75
+            let outcome = match prompt_ui::run_prompt_dialog(request) {
76
+                Ok(outcome) => outcome,
77
+                Err(err) => {
78
+                    eprintln!("garcard prompt backend unavailable: {}", err);
79
+                    std::process::exit(2);
80
+                }
81
+            };
82
+
83
+            match outcome {
84
+                prompt_ui::PromptExit::Submitted(value) => {
85
+                    println!("{}", value);
86
+                    Ok(())
87
+                }
88
+                prompt_ui::PromptExit::Canceled => {
89
+                    std::process::exit(1);
90
+                }
91
+                prompt_ui::PromptExit::TimedOut => {
92
+                    std::process::exit(124);
93
+                }
94
+            }
95
+        }
4396
     }
4497
 }
4598
 
garcard/src/polkit_helper.rsmodified
@@ -10,6 +10,7 @@ pub enum HelperOutcome {
1010
     Authorized,
1111
     Denied,
1212
     Canceled,
13
+    Timeout,
1314
 }
1415
 
1516
 #[derive(Debug, Clone, PartialEq, Eq)]
@@ -22,9 +23,16 @@ pub enum HelperEvent {
2223
     Failure,
2324
 }
2425
 
26
+#[derive(Debug, Clone, PartialEq, Eq)]
27
+pub enum PromptResponse {
28
+    Submitted(String),
29
+    Canceled,
30
+    TimedOut,
31
+}
32
+
2533
 pub trait PromptProvider {
26
-    fn prompt_secret(&mut self, prompt: &str) -> Result<Option<String>>;
27
-    fn prompt_plain(&mut self, prompt: &str) -> Result<Option<String>>;
34
+    fn prompt_secret(&mut self, prompt: &str) -> Result<PromptResponse>;
35
+    fn prompt_plain(&mut self, prompt: &str) -> Result<PromptResponse>;
2836
 
2937
     fn show_error(&mut self, _message: &str) -> Result<()> {
3038
         Ok(())
@@ -90,9 +98,12 @@ impl HelperSocketClient {
9098
                         .prompt_secret(&prompt)
9199
                         .context("prompt handler failed")?
92100
                     {
93
-                        Some(response) => write_line(&mut stream, &sanitize_response(&response))
94
-                            .context("failed to send helper secret response")?,
95
-                        None => return Ok(HelperOutcome::Canceled),
101
+                        PromptResponse::Submitted(response) => {
102
+                            write_line(&mut stream, &sanitize_response(&response))
103
+                                .context("failed to send helper secret response")?
104
+                        }
105
+                        PromptResponse::Canceled => return Ok(HelperOutcome::Canceled),
106
+                        PromptResponse::TimedOut => return Ok(HelperOutcome::Timeout),
96107
                     }
97108
                 }
98109
                 HelperEvent::PromptVisible(prompt) => {
@@ -100,9 +111,12 @@ impl HelperSocketClient {
100111
                         .prompt_plain(&prompt)
101112
                         .context("prompt handler failed")?
102113
                     {
103
-                        Some(response) => write_line(&mut stream, &sanitize_response(&response))
104
-                            .context("failed to send helper visible response")?,
105
-                        None => return Ok(HelperOutcome::Canceled),
114
+                        PromptResponse::Submitted(response) => {
115
+                            write_line(&mut stream, &sanitize_response(&response))
116
+                                .context("failed to send helper visible response")?
117
+                        }
118
+                        PromptResponse::Canceled => return Ok(HelperOutcome::Canceled),
119
+                        PromptResponse::TimedOut => return Ok(HelperOutcome::Timeout),
106120
                     }
107121
                 }
108122
                 HelperEvent::Error(message) => {
@@ -166,20 +180,30 @@ mod tests {
166180
     use std::thread;
167181
     use std::time::{SystemTime, UNIX_EPOCH};
168182
 
169
-    #[derive(Default)]
170183
     struct FakePrompt {
171
-        secret_response: Option<String>,
172
-        plain_response: Option<String>,
184
+        secret_response: PromptResponse,
185
+        plain_response: PromptResponse,
173186
         infos: Vec<String>,
174187
         errors: Vec<String>,
175188
     }
176189
 
190
+    impl Default for FakePrompt {
191
+        fn default() -> Self {
192
+            Self {
193
+                secret_response: PromptResponse::Canceled,
194
+                plain_response: PromptResponse::Canceled,
195
+                infos: Vec::new(),
196
+                errors: Vec::new(),
197
+            }
198
+        }
199
+    }
200
+
177201
     impl PromptProvider for FakePrompt {
178
-        fn prompt_secret(&mut self, _prompt: &str) -> Result<Option<String>> {
202
+        fn prompt_secret(&mut self, _prompt: &str) -> Result<PromptResponse> {
179203
             Ok(self.secret_response.clone())
180204
         }
181205
 
182
-        fn prompt_plain(&mut self, _prompt: &str) -> Result<Option<String>> {
206
+        fn prompt_plain(&mut self, _prompt: &str) -> Result<PromptResponse> {
183207
             Ok(self.plain_response.clone())
184208
         }
185209
 
@@ -282,8 +306,8 @@ mod tests {
282306
 
283307
         let client = HelperSocketClient::new(&socket_path);
284308
         let mut prompts = FakePrompt {
285
-            secret_response: Some("correct horse".to_string()),
286
-            plain_response: None,
309
+            secret_response: PromptResponse::Submitted("correct horse".to_string()),
310
+            plain_response: PromptResponse::Canceled,
287311
             infos: Vec::new(),
288312
             errors: Vec::new(),
289313
         };
@@ -299,4 +323,42 @@ mod tests {
299323
 
300324
         let _ = std::fs::remove_file(&socket_path);
301325
     }
326
+
327
+    #[test]
328
+    fn helper_client_reports_timeout_from_prompt_provider() {
329
+        let socket_path = temp_socket_path();
330
+        let listener = UnixListener::bind(&socket_path).expect("bind test socket");
331
+
332
+        let server = thread::spawn(move || {
333
+            let (mut stream, _) = listener.accept().expect("accept");
334
+            let read_stream = stream.try_clone().expect("clone");
335
+            let mut reader = BufReader::new(read_stream);
336
+
337
+            let mut username = String::new();
338
+            reader.read_line(&mut username).expect("read username");
339
+            let mut cookie = String::new();
340
+            reader.read_line(&mut cookie).expect("read cookie");
341
+
342
+            stream
343
+                .write_all(b"PAM_PROMPT_ECHO_OFF Password:\n")
344
+                .expect("write prompt");
345
+            stream.flush().expect("flush prompt");
346
+        });
347
+
348
+        let client = HelperSocketClient::new(&socket_path);
349
+        let mut prompts = FakePrompt {
350
+            secret_response: PromptResponse::TimedOut,
351
+            plain_response: PromptResponse::Canceled,
352
+            infos: Vec::new(),
353
+            errors: Vec::new(),
354
+        };
355
+
356
+        let outcome = client
357
+            .authenticate("alice", "cookie-timeout", &mut prompts)
358
+            .expect("authenticate timeout");
359
+        assert_eq!(outcome, HelperOutcome::Timeout);
360
+
361
+        server.join().expect("server join");
362
+        let _ = std::fs::remove_file(&socket_path);
363
+    }
302364
 }
garcard/src/prompt.rsmodified
@@ -1,38 +1,55 @@
1
-use crate::polkit_helper::PromptProvider;
1
+use crate::polkit_helper::{PromptProvider, PromptResponse};
22
 use anyhow::{Context, Result};
3
-use std::process::Command;
3
+use std::process::{Command, ExitStatus};
44
 
55
 const DEFAULT_ASK_TIMEOUT_SECS: u64 = 120;
66
 
77
 #[derive(Debug, Clone)]
88
 pub struct CommandPrompt {
99
     prompt_command: Option<String>,
10
+    prompt_timeout_secs: u64,
1011
 }
1112
 
1213
 impl Default for CommandPrompt {
1314
     fn default() -> Self {
15
+        let prompt_timeout_secs = std::env::var("GARCARD_PROMPT_TIMEOUT_SECS")
16
+            .ok()
17
+            .and_then(|raw| raw.parse::<u64>().ok())
18
+            .filter(|value| *value > 0)
19
+            .unwrap_or(DEFAULT_ASK_TIMEOUT_SECS);
20
+
1421
         Self {
1522
             prompt_command: std::env::var("GARCARD_PROMPT_COMMAND").ok(),
23
+            prompt_timeout_secs,
1624
         }
1725
     }
1826
 }
1927
 
2028
 impl CommandPrompt {
21
-    fn run_prompt(&self, prompt: &str, visible: bool) -> Result<Option<String>> {
29
+    fn run_prompt(&self, prompt: &str, visible: bool) -> Result<PromptResponse> {
2230
         if let Some(command) = self.prompt_command.as_deref() {
2331
             return run_custom_prompt_command(command, prompt, visible);
2432
         }
2533
 
26
-        run_systemd_ask_password(prompt, visible)
34
+        match run_gartk_prompt_subcommand(prompt, visible, self.prompt_timeout_secs) {
35
+            Ok(response) => Ok(response),
36
+            Err(err) => {
37
+                tracing::warn!(
38
+                    error = %err,
39
+                    "Failed to run built-in gartk prompt; falling back to systemd-ask-password"
40
+                );
41
+                run_systemd_ask_password(prompt, visible, self.prompt_timeout_secs)
42
+            }
43
+        }
2744
     }
2845
 }
2946
 
3047
 impl PromptProvider for CommandPrompt {
31
-    fn prompt_secret(&mut self, prompt: &str) -> Result<Option<String>> {
48
+    fn prompt_secret(&mut self, prompt: &str) -> Result<PromptResponse> {
3249
         self.run_prompt(prompt, false)
3350
     }
3451
 
35
-    fn prompt_plain(&mut self, prompt: &str) -> Result<Option<String>> {
52
+    fn prompt_plain(&mut self, prompt: &str) -> Result<PromptResponse> {
3653
         self.run_prompt(prompt, true)
3754
     }
3855
 
@@ -47,7 +64,7 @@ impl PromptProvider for CommandPrompt {
4764
     }
4865
 }
4966
 
50
-fn run_custom_prompt_command(command: &str, prompt: &str, visible: bool) -> Result<Option<String>> {
67
+fn run_custom_prompt_command(command: &str, prompt: &str, visible: bool) -> Result<PromptResponse> {
5168
     let mode = if visible { "plain" } else { "secret" };
5269
     let output = Command::new("sh")
5370
         .arg("-c")
@@ -57,18 +74,51 @@ fn run_custom_prompt_command(command: &str, prompt: &str, visible: bool) -> Resu
5774
         .output()
5875
         .with_context(|| format!("failed to run custom prompt command: {}", command))?;
5976
 
60
-    if !output.status.success() {
61
-        return Ok(None);
77
+    Ok(map_output_to_prompt_response(
78
+        &output.status,
79
+        &output.stdout,
80
+    ))
81
+}
82
+
83
+fn run_gartk_prompt_subcommand(
84
+    prompt: &str,
85
+    visible: bool,
86
+    timeout_secs: u64,
87
+) -> Result<PromptResponse> {
88
+    let mode = if visible { "plain" } else { "secret" };
89
+    let executable =
90
+        std::env::current_exe().context("failed to resolve current executable path")?;
91
+    let output = Command::new(executable)
92
+        .arg("prompt")
93
+        .arg("--mode")
94
+        .arg(mode)
95
+        .arg("--message")
96
+        .arg(prompt)
97
+        .arg("--timeout-secs")
98
+        .arg(timeout_secs.to_string())
99
+        .output()
100
+        .context("failed to launch garcard prompt subcommand")?;
101
+
102
+    if output.status.code() == Some(2) {
103
+        let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
104
+        anyhow::bail!("garcard prompt subcommand unavailable: {}", stderr);
62105
     }
63106
 
64
-    Ok(extract_response(&output.stdout))
107
+    Ok(map_output_to_prompt_response(
108
+        &output.status,
109
+        &output.stdout,
110
+    ))
65111
 }
66112
 
67
-fn run_systemd_ask_password(prompt: &str, visible: bool) -> Result<Option<String>> {
113
+fn run_systemd_ask_password(
114
+    prompt: &str,
115
+    visible: bool,
116
+    timeout_secs: u64,
117
+) -> Result<PromptResponse> {
68118
     let mut command = Command::new("systemd-ask-password");
69119
     command.arg("--user");
70120
     command.arg("--no-tty");
71
-    command.arg(format!("--timeout={}", DEFAULT_ASK_TIMEOUT_SECS));
121
+    command.arg(format!("--timeout={}", timeout_secs));
72122
     if visible {
73123
         command.arg("--echo=yes");
74124
     }
@@ -77,11 +127,24 @@ fn run_systemd_ask_password(prompt: &str, visible: bool) -> Result<Option<String
77127
     let output = command
78128
         .output()
79129
         .context("failed to run systemd-ask-password")?;
80
-    if !output.status.success() {
81
-        return Ok(None);
130
+    Ok(map_output_to_prompt_response(
131
+        &output.status,
132
+        &output.stdout,
133
+    ))
134
+}
135
+
136
+fn map_output_to_prompt_response(status: &ExitStatus, stdout: &[u8]) -> PromptResponse {
137
+    if status.success() {
138
+        return extract_response(stdout)
139
+            .map(PromptResponse::Submitted)
140
+            .unwrap_or(PromptResponse::Canceled);
82141
     }
83142
 
84
-    Ok(extract_response(&output.stdout))
143
+    if status.code() == Some(124) {
144
+        PromptResponse::TimedOut
145
+    } else {
146
+        PromptResponse::Canceled
147
+    }
85148
 }
86149
 
87150
 fn extract_response(stdout: &[u8]) -> Option<String> {
@@ -107,4 +170,15 @@ mod tests {
107170
     fn extract_response_rejects_empty_value() {
108171
         assert_eq!(extract_response(b" \n\t"), None);
109172
     }
173
+
174
+    #[test]
175
+    fn map_output_maps_timeout_status_code() {
176
+        let status = Command::new("sh")
177
+            .arg("-c")
178
+            .arg("exit 124")
179
+            .status()
180
+            .expect("run shell");
181
+        let mapped = map_output_to_prompt_response(&status, b"");
182
+        assert_eq!(mapped, PromptResponse::TimedOut);
183
+    }
110184
 }
garcard/src/prompt_ui.rsadded
@@ -0,0 +1,451 @@
1
+use anyhow::{Context, Result};
2
+use gartk_core::{Color, InputEvent, Key, KeyEvent, Rect, Theme};
3
+use gartk_render::{Renderer, TextStyle, copy_surface_to_window};
4
+use gartk_x11::{
5
+    Connection, EventLoop, EventLoopConfig, Window, WindowConfig, monitor_at_pointer,
6
+    primary_monitor,
7
+};
8
+use std::time::{Duration, Instant};
9
+use x11rb::protocol::xproto::ConnectionExt;
10
+
11
+const DIALOG_WIDTH: u32 = 560;
12
+const DIALOG_HEIGHT: u32 = 240;
13
+const CARD_PADDING: i32 = 18;
14
+
15
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
16
+pub enum PromptMode {
17
+    Secret,
18
+    Plain,
19
+}
20
+
21
+#[derive(Debug, Clone)]
22
+pub struct PromptRequest {
23
+    pub message: String,
24
+    pub mode: PromptMode,
25
+    pub timeout_secs: u64,
26
+}
27
+
28
+#[derive(Debug, Clone, PartialEq, Eq)]
29
+pub enum PromptExit {
30
+    Submitted(String),
31
+    Canceled,
32
+    TimedOut,
33
+}
34
+
35
+struct PromptDialog {
36
+    window: Window,
37
+    renderer: Renderer,
38
+    gc: u32,
39
+    request: PromptRequest,
40
+    input: String,
41
+    cursor: usize,
42
+    exit: Option<PromptExit>,
43
+    deadline: Option<Instant>,
44
+    remaining_secs: Option<u64>,
45
+    card_background: Color,
46
+    card_border: Color,
47
+    accent: Color,
48
+}
49
+
50
+pub fn run_prompt_dialog(request: PromptRequest) -> Result<PromptExit> {
51
+    let conn = Connection::connect(None).context("failed to connect to X11 display")?;
52
+    let (x, y) = centered_position(&conn, DIALOG_WIDTH, DIALOG_HEIGHT);
53
+    let window = Window::create(
54
+        conn.clone(),
55
+        WindowConfig::dialog()
56
+            .title("garcard authentication")
57
+            .class("garcard")
58
+            .position(x, y)
59
+            .size(DIALOG_WIDTH, DIALOG_HEIGHT)
60
+            .transparent(true)
61
+            .modal(true),
62
+    )
63
+    .context("failed to create prompt window")?;
64
+    window.focus().context("failed to focus prompt window")?;
65
+
66
+    let mut dialog = PromptDialog::new(window, request)?;
67
+    dialog.run()
68
+}
69
+
70
+fn centered_position(conn: &Connection, width: u32, height: u32) -> (i32, i32) {
71
+    let monitor = monitor_at_pointer(conn)
72
+        .or_else(|_| primary_monitor(conn))
73
+        .ok();
74
+    if let Some(monitor) = monitor {
75
+        let x = monitor.rect.x + (monitor.rect.width as i32 - width as i32) / 2;
76
+        let y = monitor.rect.y + (monitor.rect.height as i32 - height as i32) / 3;
77
+        return (x, y);
78
+    }
79
+
80
+    (
81
+        (conn.screen_width() as i32 - width as i32) / 2,
82
+        (conn.screen_height() as i32 - height as i32) / 3,
83
+    )
84
+}
85
+
86
+impl PromptDialog {
87
+    fn new(window: Window, request: PromptRequest) -> Result<Self> {
88
+        let mut theme = Theme::dark();
89
+        theme.font_family = "Sans".to_string();
90
+        theme.font_size = 14.0;
91
+        let card_background = Color::from_hex("#111318").expect("valid card color");
92
+        let card_border = Color::from_hex("#2c3442").expect("valid border color");
93
+        let accent = Color::from_hex("#8ab4f8").expect("valid accent color");
94
+        let size = window.size();
95
+        let renderer = Renderer::with_theme(size.width, size.height, theme)?;
96
+
97
+        let conn = window.connection();
98
+        let gc = conn.generate_id()?;
99
+        conn.inner()
100
+            .create_gc(gc, window.id(), &Default::default())?;
101
+        conn.flush()?;
102
+
103
+        let deadline = if request.timeout_secs > 0 {
104
+            Some(Instant::now() + Duration::from_secs(request.timeout_secs))
105
+        } else {
106
+            None
107
+        };
108
+
109
+        Ok(Self {
110
+            window,
111
+            renderer,
112
+            gc,
113
+            request,
114
+            input: String::new(),
115
+            cursor: 0,
116
+            exit: None,
117
+            deadline,
118
+            remaining_secs: None,
119
+            card_background,
120
+            card_border,
121
+            accent,
122
+        })
123
+    }
124
+
125
+    fn run(&mut self) -> Result<PromptExit> {
126
+        let mut event_loop = EventLoop::new(&self.window, EventLoopConfig::default())?;
127
+        self.refresh_timeout();
128
+        self.render()?;
129
+
130
+        event_loop.run(|ev, event| {
131
+            match event {
132
+                InputEvent::Key(key_event) if key_event.pressed => {
133
+                    self.handle_key(&key_event);
134
+                    ev.request_redraw();
135
+                }
136
+                InputEvent::Resize { width, height } => {
137
+                    if let Err(err) = self.renderer.resize(width, height) {
138
+                        tracing::error!(error = %err, "failed to resize prompt renderer");
139
+                        self.exit = Some(PromptExit::Canceled);
140
+                    }
141
+                    ev.request_redraw();
142
+                }
143
+                InputEvent::Expose => {
144
+                    ev.request_redraw();
145
+                }
146
+                InputEvent::CloseRequested => {
147
+                    self.exit = Some(PromptExit::Canceled);
148
+                }
149
+                InputEvent::Idle => {
150
+                    if self.refresh_timeout() {
151
+                        ev.request_redraw();
152
+                    }
153
+                }
154
+                _ => {}
155
+            }
156
+
157
+            if ev.needs_redraw() {
158
+                let _ = self.render();
159
+                ev.redraw_done();
160
+            }
161
+
162
+            Ok(self.exit.is_none())
163
+        })?;
164
+
165
+        Ok(self.exit.take().unwrap_or(PromptExit::Canceled))
166
+    }
167
+
168
+    fn refresh_timeout(&mut self) -> bool {
169
+        let Some(deadline) = self.deadline else {
170
+            return false;
171
+        };
172
+
173
+        let now = Instant::now();
174
+        if now >= deadline {
175
+            self.exit = Some(PromptExit::TimedOut);
176
+            return true;
177
+        }
178
+
179
+        let remaining = deadline.duration_since(now).as_secs();
180
+        if self.remaining_secs != Some(remaining) {
181
+            self.remaining_secs = Some(remaining);
182
+            return true;
183
+        }
184
+
185
+        false
186
+    }
187
+
188
+    fn handle_key(&mut self, key_event: &KeyEvent) {
189
+        match key_event.key {
190
+            Key::Escape => {
191
+                self.exit = Some(PromptExit::Canceled);
192
+            }
193
+            Key::Return => {
194
+                self.exit = Some(PromptExit::Submitted(self.input.clone()));
195
+            }
196
+            Key::Left => {
197
+                if self.cursor > 0 {
198
+                    self.cursor -= 1;
199
+                }
200
+            }
201
+            Key::Right => {
202
+                if self.cursor < self.input.chars().count() {
203
+                    self.cursor += 1;
204
+                }
205
+            }
206
+            Key::Home => {
207
+                self.cursor = 0;
208
+            }
209
+            Key::End => {
210
+                self.cursor = self.input.chars().count();
211
+            }
212
+            Key::Backspace => {
213
+                remove_char_before(&mut self.input, &mut self.cursor);
214
+            }
215
+            Key::Delete => {
216
+                remove_char_at(&mut self.input, self.cursor);
217
+            }
218
+            Key::Space => {
219
+                if key_event.modifiers.is_empty() || key_event.modifiers.shift {
220
+                    insert_char_at(&mut self.input, self.cursor, ' ');
221
+                    self.cursor += 1;
222
+                }
223
+            }
224
+            Key::Char(ch) => {
225
+                if ch.is_control() {
226
+                    return;
227
+                }
228
+                if key_event.modifiers.ctrl
229
+                    || key_event.modifiers.alt
230
+                    || key_event.modifiers.super_key
231
+                {
232
+                    return;
233
+                }
234
+                insert_char_at(&mut self.input, self.cursor, ch);
235
+                self.cursor += 1;
236
+            }
237
+            _ => {}
238
+        }
239
+    }
240
+
241
+    fn render(&mut self) -> Result<()> {
242
+        let size = self.renderer.size();
243
+        let width = size.width as i32;
244
+        let height = size.height as i32;
245
+        let theme = self.renderer.theme().clone();
246
+
247
+        self.renderer
248
+            .clear_color(Color::from_hex("#0a0b10").expect("valid backdrop color"))?;
249
+
250
+        let card_rect = Rect::new(
251
+            CARD_PADDING,
252
+            CARD_PADDING,
253
+            (width - CARD_PADDING * 2) as u32,
254
+            (height - CARD_PADDING * 2) as u32,
255
+        );
256
+        self.renderer
257
+            .fill_rounded_rect(card_rect, 12.0, self.card_background)?;
258
+        self.renderer
259
+            .stroke_rounded_rect(card_rect, 12.0, self.card_border, 2.0)?;
260
+
261
+        let title_style = TextStyle::new()
262
+            .font_family(theme.font_family.clone())
263
+            .font_size(18.0)
264
+            .color(theme.foreground);
265
+        self.renderer.text(
266
+            "Authentication Required",
267
+            (CARD_PADDING + 16) as f64,
268
+            (CARD_PADDING + 14) as f64,
269
+            &title_style,
270
+        )?;
271
+
272
+        let message_style = TextStyle::new()
273
+            .font_family(theme.font_family.clone())
274
+            .font_size(13.0)
275
+            .color(theme.item_description)
276
+            .max_width(card_rect.width as i32 - 32)
277
+            .wrap(true)
278
+            .ellipsize(true);
279
+        self.renderer.text(
280
+            &self.request.message,
281
+            (CARD_PADDING + 16) as f64,
282
+            (CARD_PADDING + 46) as f64,
283
+            &message_style,
284
+        )?;
285
+
286
+        let input_label = match self.request.mode {
287
+            PromptMode::Secret => "Password",
288
+            PromptMode::Plain => "Response",
289
+        };
290
+        let label_style = TextStyle::new()
291
+            .font_family(theme.font_family.clone())
292
+            .font_size(12.0)
293
+            .color(theme.input_placeholder);
294
+        self.renderer.text(
295
+            input_label,
296
+            (CARD_PADDING + 16) as f64,
297
+            (CARD_PADDING + 108) as f64,
298
+            &label_style,
299
+        )?;
300
+
301
+        let input_rect = Rect::new(
302
+            CARD_PADDING + 16,
303
+            CARD_PADDING + 126,
304
+            (width - (CARD_PADDING + 16) * 2) as u32,
305
+            40,
306
+        );
307
+        self.renderer
308
+            .fill_rounded_rect(input_rect, 8.0, theme.input_background)?;
309
+        self.renderer
310
+            .stroke_rounded_rect(input_rect, 8.0, self.accent.with_alpha(0.7), 1.5)?;
311
+
312
+        let display_input = display_value(&self.input, self.request.mode);
313
+        let input_style = TextStyle::new()
314
+            .font_family(theme.font_family.clone())
315
+            .font_size(14.0)
316
+            .color(theme.input_foreground);
317
+        let input_y = input_rect.y + 12;
318
+        let input_x = input_rect.x + 10;
319
+        self.renderer
320
+            .text(&display_input, input_x as f64, input_y as f64, &input_style)?;
321
+
322
+        let cursor_prefix = display_prefix(&self.input, self.cursor, self.request.mode);
323
+        let cursor_size = self.renderer.measure_text(&cursor_prefix, &input_style)?;
324
+        let cursor_x = input_x + cursor_size.width as i32;
325
+        self.renderer.fill_rect(
326
+            Rect::new(cursor_x, input_rect.y + 8, 2, input_rect.height - 16),
327
+            self.accent,
328
+        )?;
329
+
330
+        let footer_style = TextStyle::new()
331
+            .font_family(theme.font_family)
332
+            .font_size(12.0)
333
+            .color(theme.item_description);
334
+        self.renderer.text(
335
+            "Enter submit   Esc cancel",
336
+            (CARD_PADDING + 16) as f64,
337
+            (height - CARD_PADDING - 26) as f64,
338
+            &footer_style,
339
+        )?;
340
+
341
+        if let Some(remaining) = self.remaining_secs {
342
+            let timer_text = format!("timeout {}s", remaining);
343
+            let timer_style = TextStyle::new()
344
+                .font_family("Sans")
345
+                .font_size(12.0)
346
+                .color(self.accent);
347
+            let timer_size = self.renderer.measure_text(&timer_text, &timer_style)?;
348
+            self.renderer.text(
349
+                &timer_text,
350
+                (width - CARD_PADDING - timer_size.width as i32 - 16) as f64,
351
+                (height - CARD_PADDING - 26) as f64,
352
+                &timer_style,
353
+            )?;
354
+        }
355
+
356
+        self.renderer.flush();
357
+        copy_surface_to_window(self.renderer.surface_mut(), &self.window, self.gc, 0, 0)?;
358
+        Ok(())
359
+    }
360
+}
361
+
362
+impl Drop for PromptDialog {
363
+    fn drop(&mut self) {
364
+        let _ = self.window.connection().inner().free_gc(self.gc);
365
+    }
366
+}
367
+
368
+fn display_value(input: &str, mode: PromptMode) -> String {
369
+    match mode {
370
+        PromptMode::Secret => "*".repeat(input.chars().count()),
371
+        PromptMode::Plain => input.to_string(),
372
+    }
373
+}
374
+
375
+fn display_prefix(input: &str, cursor: usize, mode: PromptMode) -> String {
376
+    let prefix = prefix_chars(input, cursor);
377
+    match mode {
378
+        PromptMode::Secret => "*".repeat(prefix.chars().count()),
379
+        PromptMode::Plain => prefix,
380
+    }
381
+}
382
+
383
+fn prefix_chars(input: &str, char_count: usize) -> String {
384
+    input.chars().take(char_count).collect()
385
+}
386
+
387
+fn char_to_byte_index(input: &str, char_index: usize) -> usize {
388
+    input
389
+        .char_indices()
390
+        .nth(char_index)
391
+        .map(|(byte, _)| byte)
392
+        .unwrap_or_else(|| input.len())
393
+}
394
+
395
+fn insert_char_at(input: &mut String, char_index: usize, value: char) {
396
+    let byte_index = char_to_byte_index(input, char_index);
397
+    input.insert(byte_index, value);
398
+}
399
+
400
+fn remove_char_before(input: &mut String, cursor: &mut usize) {
401
+    if *cursor == 0 {
402
+        return;
403
+    }
404
+
405
+    let end = char_to_byte_index(input, *cursor);
406
+    let start = char_to_byte_index(input, *cursor - 1);
407
+    input.drain(start..end);
408
+    *cursor -= 1;
409
+}
410
+
411
+fn remove_char_at(input: &mut String, cursor: usize) {
412
+    if cursor >= input.chars().count() {
413
+        return;
414
+    }
415
+
416
+    let start = char_to_byte_index(input, cursor);
417
+    let end = char_to_byte_index(input, cursor + 1);
418
+    input.drain(start..end);
419
+}
420
+
421
+#[cfg(test)]
422
+mod tests {
423
+    use super::*;
424
+
425
+    #[test]
426
+    fn display_value_masks_secret_text() {
427
+        assert_eq!(display_value("secret", PromptMode::Secret), "******");
428
+        assert_eq!(display_value("plain", PromptMode::Plain), "plain");
429
+    }
430
+
431
+    #[test]
432
+    fn insert_and_remove_respect_char_positions() {
433
+        let mut value = String::from("abcd");
434
+        insert_char_at(&mut value, 2, 'X');
435
+        assert_eq!(value, "abXcd");
436
+
437
+        let mut cursor = 3;
438
+        remove_char_before(&mut value, &mut cursor);
439
+        assert_eq!(value, "abcd");
440
+        assert_eq!(cursor, 2);
441
+
442
+        remove_char_at(&mut value, 1);
443
+        assert_eq!(value, "acd");
444
+    }
445
+
446
+    #[test]
447
+    fn display_prefix_uses_cursor_and_mode() {
448
+        assert_eq!(display_prefix("hello", 2, PromptMode::Plain), "he");
449
+        assert_eq!(display_prefix("hello", 2, PromptMode::Secret), "**");
450
+    }
451
+}
garcard/src/state.rsmodified
@@ -39,6 +39,7 @@ impl fmt::Display for AuthPhase {
3939
     }
4040
 }
4141
 
42
+#[allow(dead_code)]
4243
 fn can_transition(current: AuthPhase, next: AuthPhase) -> bool {
4344
     if current == next {
4445
         return true;
@@ -92,6 +93,7 @@ impl AuthState {
9293
         }
9394
     }
9495
 
96
+    #[allow(dead_code)]
9597
     pub fn transition(&self, next: AuthPhase) -> bool {
9698
         if let Ok(mut phase) = self.current_phase.write() {
9799
             if can_transition(*phase, next) {
@@ -213,6 +215,7 @@ impl<T> AuthQueue<T> {
213215
         self.queued.len()
214216
     }
215217
 
218
+    #[allow(dead_code)]
216219
     pub fn is_empty(&self) -> bool {
217220
         self.active.is_none() && self.queued.is_empty()
218221
     }
@@ -244,6 +247,7 @@ pub struct RuntimeState {
244247
 }
245248
 
246249
 impl RuntimeState {
250
+    #[cfg(test)]
247251
     pub fn new(socket_path: String, backend_name: &'static str) -> Self {
248252
         Self::with_auth(socket_path, backend_name, Arc::new(AuthState::default()))
249253
     }