Compare commits
8 Commits
1f6c50c998
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| a43a8e8231 | |||
| 610d6b4190 | |||
| da3ce160b4 | |||
| 8a521e760e | |||
| 5c50742a61 | |||
| c723efb186 | |||
| aa1c582f3c | |||
| 81d5122e4b |
Generated
+1
-888
@@ -2,149 +2,6 @@
|
|||||||
# It is not intended for manual editing.
|
# It is not intended for manual editing.
|
||||||
version = 4
|
version = 4
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-broadcast"
|
|
||||||
version = "0.7.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
|
||||||
dependencies = [
|
|
||||||
"event-listener",
|
|
||||||
"event-listener-strategy",
|
|
||||||
"futures-core",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-channel"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
|
||||||
dependencies = [
|
|
||||||
"concurrent-queue",
|
|
||||||
"event-listener-strategy",
|
|
||||||
"futures-core",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-executor"
|
|
||||||
version = "1.14.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
|
|
||||||
dependencies = [
|
|
||||||
"async-task",
|
|
||||||
"concurrent-queue",
|
|
||||||
"fastrand",
|
|
||||||
"futures-lite",
|
|
||||||
"pin-project-lite",
|
|
||||||
"slab",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-io"
|
|
||||||
version = "2.6.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
|
|
||||||
dependencies = [
|
|
||||||
"autocfg",
|
|
||||||
"cfg-if",
|
|
||||||
"concurrent-queue",
|
|
||||||
"futures-io",
|
|
||||||
"futures-lite",
|
|
||||||
"parking",
|
|
||||||
"polling",
|
|
||||||
"rustix",
|
|
||||||
"slab",
|
|
||||||
"windows-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-lock"
|
|
||||||
version = "3.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
|
|
||||||
dependencies = [
|
|
||||||
"event-listener",
|
|
||||||
"event-listener-strategy",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-process"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
|
|
||||||
dependencies = [
|
|
||||||
"async-channel",
|
|
||||||
"async-io",
|
|
||||||
"async-lock",
|
|
||||||
"async-signal",
|
|
||||||
"async-task",
|
|
||||||
"blocking",
|
|
||||||
"cfg-if",
|
|
||||||
"event-listener",
|
|
||||||
"futures-lite",
|
|
||||||
"rustix",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-recursion"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-signal"
|
|
||||||
version = "0.2.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
|
|
||||||
dependencies = [
|
|
||||||
"async-io",
|
|
||||||
"async-lock",
|
|
||||||
"atomic-waker",
|
|
||||||
"cfg-if",
|
|
||||||
"futures-core",
|
|
||||||
"futures-io",
|
|
||||||
"rustix",
|
|
||||||
"signal-hook-registry",
|
|
||||||
"slab",
|
|
||||||
"windows-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-task"
|
|
||||||
version = "4.7.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "async-trait"
|
|
||||||
version = "0.1.92"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.6",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "atomic-waker"
|
|
||||||
version = "1.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "autocfg"
|
|
||||||
version = "1.5.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bitflags"
|
name = "bitflags"
|
||||||
version = "2.13.2"
|
version = "2.13.2"
|
||||||
@@ -160,7 +17,6 @@ name = "blocker-daemon"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blocker-core",
|
"blocker-core",
|
||||||
"blocker-platform-linux",
|
|
||||||
"blocker-storage",
|
"blocker-storage",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -169,9 +25,6 @@ name = "blocker-platform-linux"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"blocker-core",
|
"blocker-core",
|
||||||
"nix",
|
|
||||||
"x11rb",
|
|
||||||
"zbus",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -200,25 +53,6 @@ dependencies = [
|
|||||||
name = "blocker-ui"
|
name = "blocker-ui"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "blocking"
|
|
||||||
version = "1.7.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa"
|
|
||||||
dependencies = [
|
|
||||||
"async-channel",
|
|
||||||
"async-task",
|
|
||||||
"futures-io",
|
|
||||||
"futures-lite",
|
|
||||||
"piper",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "bumpalo"
|
|
||||||
version = "3.20.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cc"
|
name = "cc"
|
||||||
version = "1.5.1"
|
version = "1.5.1"
|
||||||
@@ -229,96 +63,6 @@ dependencies = [
|
|||||||
"shlex",
|
"shlex",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg-if"
|
|
||||||
version = "1.0.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "cfg_aliases"
|
|
||||||
version = "0.2.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "concurrent-queue"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
|
|
||||||
dependencies = [
|
|
||||||
"crossbeam-utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "crossbeam-utils"
|
|
||||||
version = "0.8.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "endi"
|
|
||||||
version = "1.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "enumflags2"
|
|
||||||
version = "0.7.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
|
||||||
dependencies = [
|
|
||||||
"enumflags2_derive",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "enumflags2_derive"
|
|
||||||
version = "0.7.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "equivalent"
|
|
||||||
version = "1.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "errno"
|
|
||||||
version = "0.3.14"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
|
||||||
dependencies = [
|
|
||||||
"libc",
|
|
||||||
"windows-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "event-listener"
|
|
||||||
version = "5.4.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
|
||||||
dependencies = [
|
|
||||||
"parking",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "event-listener-strategy"
|
|
||||||
version = "0.5.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
|
|
||||||
dependencies = [
|
|
||||||
"event-listener",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fallible-iterator"
|
name = "fallible-iterator"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@@ -331,12 +75,6 @@ version = "0.1.9"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "fastrand"
|
|
||||||
version = "2.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.14"
|
version = "0.1.14"
|
||||||
@@ -349,70 +87,6 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-core"
|
|
||||||
version = "0.3.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-io"
|
|
||||||
version = "0.3.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-lite"
|
|
||||||
version = "2.6.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
|
|
||||||
dependencies = [
|
|
||||||
"fastrand",
|
|
||||||
"futures-core",
|
|
||||||
"futures-io",
|
|
||||||
"parking",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-task"
|
|
||||||
version = "0.3.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futures-util"
|
|
||||||
version = "0.3.34"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"futures-task",
|
|
||||||
"pin-project-lite",
|
|
||||||
"slab",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gethostname"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
|
|
||||||
dependencies = [
|
|
||||||
"rustix",
|
|
||||||
"windows-link",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "getrandom"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"libc",
|
|
||||||
"r-efi",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashbrown"
|
name = "hashbrown"
|
||||||
version = "0.15.5"
|
version = "0.15.5"
|
||||||
@@ -422,60 +96,15 @@ dependencies = [
|
|||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hashbrown"
|
|
||||||
version = "0.17.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hashlink"
|
name = "hashlink"
|
||||||
version = "0.10.0"
|
version = "0.10.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hashbrown 0.15.5",
|
"hashbrown",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hermit-abi"
|
|
||||||
version = "0.5.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "hex"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "indexmap"
|
|
||||||
version = "2.14.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
|
||||||
dependencies = [
|
|
||||||
"equivalent",
|
|
||||||
"hashbrown 0.17.1",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "js-sys"
|
|
||||||
version = "0.3.106"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"futures-util",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "libc"
|
|
||||||
version = "0.2.189"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "libsqlite3-sys"
|
name = "libsqlite3-sys"
|
||||||
version = "0.35.0"
|
version = "0.35.0"
|
||||||
@@ -487,131 +116,12 @@ dependencies = [
|
|||||||
"vcpkg",
|
"vcpkg",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "linux-raw-sys"
|
|
||||||
version = "0.12.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "memchr"
|
|
||||||
version = "2.8.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "memoffset"
|
|
||||||
version = "0.9.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
|
||||||
dependencies = [
|
|
||||||
"autocfg",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "nix"
|
|
||||||
version = "0.29.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "71e2746dc3a24dd78b3cfcb7be93368c6de9963d30f43a6a73998a9cf4b17b46"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"cfg-if",
|
|
||||||
"cfg_aliases",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "once_cell"
|
|
||||||
version = "1.21.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ordered-stream"
|
|
||||||
version = "0.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
|
||||||
dependencies = [
|
|
||||||
"futures-core",
|
|
||||||
"pin-project-lite",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "parking"
|
|
||||||
version = "2.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "pin-project-lite"
|
|
||||||
version = "0.2.17"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "piper"
|
|
||||||
version = "0.2.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
|
||||||
dependencies = [
|
|
||||||
"atomic-waker",
|
|
||||||
"fastrand",
|
|
||||||
"futures-io",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.34"
|
version = "0.3.34"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "polling"
|
|
||||||
version = "3.11.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"concurrent-queue",
|
|
||||||
"hermit-abi",
|
|
||||||
"pin-project-lite",
|
|
||||||
"rustix",
|
|
||||||
"windows-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro-crate"
|
|
||||||
version = "3.5.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
|
||||||
dependencies = [
|
|
||||||
"toml_edit",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "proc-macro2"
|
|
||||||
version = "1.0.107"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
|
||||||
dependencies = [
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quote"
|
|
||||||
version = "1.0.47"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "r-efi"
|
|
||||||
version = "6.0.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "rusqlite"
|
name = "rusqlite"
|
||||||
version = "0.37.0"
|
version = "0.37.0"
|
||||||
@@ -626,417 +136,20 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustix"
|
|
||||||
version = "1.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
|
|
||||||
dependencies = [
|
|
||||||
"bitflags",
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
"linux-raw-sys",
|
|
||||||
"windows-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "rustversion"
|
|
||||||
version = "1.0.23"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde"
|
|
||||||
version = "1.0.229"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
|
||||||
dependencies = [
|
|
||||||
"serde_core",
|
|
||||||
"serde_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_core"
|
|
||||||
version = "1.0.229"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
|
||||||
dependencies = [
|
|
||||||
"serde_derive",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_derive"
|
|
||||||
version = "1.0.229"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.6",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "serde_repr"
|
|
||||||
version = "0.1.21"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.6",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "shlex"
|
name = "shlex"
|
||||||
version = "2.0.1"
|
version = "2.0.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "signal-hook-registry"
|
|
||||||
version = "1.4.8"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
|
|
||||||
dependencies = [
|
|
||||||
"errno",
|
|
||||||
"libc",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "slab"
|
|
||||||
version = "0.4.12"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "smallvec"
|
name = "smallvec"
|
||||||
version = "1.16.2"
|
version = "1.16.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "syn"
|
|
||||||
version = "2.0.119"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "syn"
|
|
||||||
version = "3.0.6"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tempfile"
|
|
||||||
version = "3.27.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
|
||||||
dependencies = [
|
|
||||||
"fastrand",
|
|
||||||
"getrandom",
|
|
||||||
"once_cell",
|
|
||||||
"rustix",
|
|
||||||
"windows-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_datetime"
|
|
||||||
version = "1.1.1+spec-1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
|
||||||
dependencies = [
|
|
||||||
"serde_core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_edit"
|
|
||||||
version = "0.25.15+spec-1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
|
|
||||||
dependencies = [
|
|
||||||
"indexmap",
|
|
||||||
"toml_datetime",
|
|
||||||
"toml_parser",
|
|
||||||
"winnow",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "toml_parser"
|
|
||||||
version = "1.1.3+spec-1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
|
||||||
dependencies = [
|
|
||||||
"winnow",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing"
|
|
||||||
version = "0.1.44"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
|
|
||||||
dependencies = [
|
|
||||||
"pin-project-lite",
|
|
||||||
"tracing-attributes",
|
|
||||||
"tracing-core",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-attributes"
|
|
||||||
version = "0.1.31"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 2.0.119",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tracing-core"
|
|
||||||
version = "0.1.36"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
|
||||||
dependencies = [
|
|
||||||
"once_cell",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uds_windows"
|
|
||||||
version = "1.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
|
||||||
dependencies = [
|
|
||||||
"memoffset",
|
|
||||||
"tempfile",
|
|
||||||
"windows-sys",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "unicode-ident"
|
|
||||||
version = "1.0.26"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "uuid"
|
|
||||||
version = "1.26.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
|
||||||
dependencies = [
|
|
||||||
"js-sys",
|
|
||||||
"serde_core",
|
|
||||||
"wasm-bindgen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen"
|
|
||||||
version = "0.2.129"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
|
||||||
dependencies = [
|
|
||||||
"cfg-if",
|
|
||||||
"once_cell",
|
|
||||||
"rustversion",
|
|
||||||
"wasm-bindgen-macro",
|
|
||||||
"wasm-bindgen-shared",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen-macro"
|
|
||||||
version = "0.2.129"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
|
||||||
dependencies = [
|
|
||||||
"quote",
|
|
||||||
"wasm-bindgen-macro-support",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen-macro-support"
|
|
||||||
version = "0.2.129"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
|
||||||
dependencies = [
|
|
||||||
"bumpalo",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.6",
|
|
||||||
"wasm-bindgen-shared",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "wasm-bindgen-shared"
|
|
||||||
version = "0.2.129"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
|
||||||
dependencies = [
|
|
||||||
"unicode-ident",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-link"
|
|
||||||
version = "0.2.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "windows-sys"
|
|
||||||
version = "0.61.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
|
||||||
dependencies = [
|
|
||||||
"windows-link",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "winnow"
|
|
||||||
version = "1.0.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x11rb"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414"
|
|
||||||
dependencies = [
|
|
||||||
"gethostname",
|
|
||||||
"rustix",
|
|
||||||
"x11rb-protocol",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "x11rb-protocol"
|
|
||||||
version = "0.13.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zbus"
|
|
||||||
version = "5.19.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
|
|
||||||
dependencies = [
|
|
||||||
"async-broadcast",
|
|
||||||
"async-executor",
|
|
||||||
"async-io",
|
|
||||||
"async-lock",
|
|
||||||
"async-process",
|
|
||||||
"async-recursion",
|
|
||||||
"async-task",
|
|
||||||
"async-trait",
|
|
||||||
"blocking",
|
|
||||||
"enumflags2",
|
|
||||||
"event-listener",
|
|
||||||
"futures-core",
|
|
||||||
"futures-lite",
|
|
||||||
"hex",
|
|
||||||
"libc",
|
|
||||||
"ordered-stream",
|
|
||||||
"rustix",
|
|
||||||
"serde",
|
|
||||||
"serde_repr",
|
|
||||||
"tracing",
|
|
||||||
"uds_windows",
|
|
||||||
"uuid",
|
|
||||||
"windows-sys",
|
|
||||||
"winnow",
|
|
||||||
"zbus_macros",
|
|
||||||
"zbus_names",
|
|
||||||
"zvariant",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zbus_macros"
|
|
||||||
version = "5.19.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro-crate",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.6",
|
|
||||||
"zbus_names",
|
|
||||||
"zvariant",
|
|
||||||
"zvariant_utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zbus_names"
|
|
||||||
version = "4.3.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
"winnow",
|
|
||||||
"zvariant",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zcheapstr"
|
|
||||||
version = "1.1.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473"
|
|
||||||
dependencies = [
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zvariant"
|
|
||||||
version = "5.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147"
|
|
||||||
dependencies = [
|
|
||||||
"endi",
|
|
||||||
"enumflags2",
|
|
||||||
"serde",
|
|
||||||
"winnow",
|
|
||||||
"zcheapstr",
|
|
||||||
"zvariant_derive",
|
|
||||||
"zvariant_utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zvariant_derive"
|
|
||||||
version = "5.15.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro-crate",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn 3.0.6",
|
|
||||||
"zvariant_utils",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "zvariant_utils"
|
|
||||||
version = "4.2.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"serde",
|
|
||||||
"syn 3.0.6",
|
|
||||||
"winnow",
|
|
||||||
]
|
|
||||||
|
|||||||
+18
-39
@@ -214,7 +214,6 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
|
|||||||
ScheduleMode::Blocklist => {
|
ScheduleMode::Blocklist => {
|
||||||
for target in &schedule.targets {
|
for target in &schedule.targets {
|
||||||
let reason = BlockReason {
|
let reason = BlockReason {
|
||||||
target: target.clone(),
|
|
||||||
schedule_id: schedule.id.clone(),
|
schedule_id: schedule.id.clone(),
|
||||||
schedule_name: schedule.name.clone(),
|
schedule_name: schedule.name.clone(),
|
||||||
condition: active[0],
|
condition: active[0],
|
||||||
@@ -246,7 +245,6 @@ pub fn evaluate(input: &EngineInput<'_>) -> Decision {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
blocked_apps.push(BlockReason {
|
blocked_apps.push(BlockReason {
|
||||||
target: Target::App(id.clone()),
|
|
||||||
schedule_id: schedule.id.clone(),
|
schedule_id: schedule.id.clone(),
|
||||||
schedule_name: schedule.name.clone(),
|
schedule_name: schedule.name.clone(),
|
||||||
condition: active[0],
|
condition: active[0],
|
||||||
@@ -386,57 +384,38 @@ fn bucket_reset(now: CivilTime, window: &crate::model::UsageWindow) -> CivilTime
|
|||||||
/// Next boundary of a time window: either its end today, or its start if the
|
/// Next boundary of a time window: either its end today, or its start if the
|
||||||
/// window is not currently active.
|
/// window is not currently active.
|
||||||
fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option<CivilTime> {
|
fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Option<CivilTime> {
|
||||||
// A window with equal bounds matches nothing, ever. It must therefore
|
|
||||||
// contribute no boundary: a timer that exists only to discover the window
|
|
||||||
// still does not match is a wakeup spent on nothing, against the section 5
|
|
||||||
// idle budget. The `.max(1)` above keeps a legitimately-current boundary in
|
|
||||||
// the future; this keeps an impossible one from existing at all.
|
|
||||||
if window.start == window.end {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let minute = minute_of_day(now);
|
let minute = minute_of_day(now);
|
||||||
let today = weekday(now);
|
let today = weekday(now);
|
||||||
|
|
||||||
if time_window_contains(window, now) {
|
if time_window_contains(window, now) {
|
||||||
// Currently active. Where the END lands depends on which half of an
|
// Currently active: the end depends on whether we're in the early or
|
||||||
// overnight window we are in.
|
// late part of an overnight window.
|
||||||
//
|
let end_today = if window.is_overnight() && minute < window.end {
|
||||||
// At 23:00 in a 22:00-06:00 window the end is 06:00 *tomorrow*, 420
|
// Early part — ends later today.
|
||||||
// minutes away — not 06:00 today, which is in the past. Computing
|
window.end
|
||||||
// `end - minute` and clamping at zero, as this did, yields "now", which
|
|
||||||
// makes the daemon wake immediately, re-evaluate, and get the same
|
|
||||||
// answer forever: a busy loop that burns the section 5 idle budget and
|
|
||||||
// never sleeps.
|
|
||||||
let minutes_until = if window.is_overnight() && minute >= window.start {
|
|
||||||
// Late part: crosses midnight.
|
|
||||||
(24 * 60) - i64::from(minute) + i64::from(window.end)
|
|
||||||
} else {
|
} else {
|
||||||
// Either a daytime window, or the early part of an overnight one.
|
window.end
|
||||||
i64::from(window.end) - i64::from(minute)
|
|
||||||
};
|
};
|
||||||
|
let minutes_until = i64::from(end_today) - i64::from(minute);
|
||||||
return Some(CivilTime {
|
return Some(CivilTime {
|
||||||
epoch_minutes: now.epoch_minutes + minutes_until.max(1),
|
epoch_minutes: now.epoch_minutes + minutes_until.max(0),
|
||||||
utc_offset_minutes: now.utc_offset_minutes,
|
utc_offset_minutes: now.utc_offset_minutes,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Not active: the next start is the boundary that matters.
|
// Not active: the next start is the boundary that matters.
|
||||||
//
|
|
||||||
// The day offset must be folded into the delta, not just used to test
|
|
||||||
// membership. Iterating day offsets and computing only `start - minute`
|
|
||||||
// wraps by 1440 without advancing the day, so an inactive weekday schedule
|
|
||||||
// on a Saturday would report Monday's start as 21 hours away — Sunday 09:00,
|
|
||||||
// a day that is not in the set. The block still fires, because the next
|
|
||||||
// evaluation recomputes it, but the daemon wakes a day early for nothing,
|
|
||||||
// which is exactly what the section 5 idle budget forbids.
|
|
||||||
let mut best: Option<i64> = None;
|
let mut best: Option<i64> = None;
|
||||||
for day_offset in 0..=7i64 {
|
for day_offset in 0..=7 {
|
||||||
let candidate_day = ((today as i64 + day_offset) % 7) as u8;
|
let candidate_day = (today as i32 + day_offset) % 7;
|
||||||
if !window.days.contains(&candidate_day) {
|
if !window.days.contains(&(candidate_day as u8)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
let mut delta = day_offset * 24 * 60 + i64::from(window.start) - i64::from(minute);
|
let mut delta = i64::from(window.start) - i64::from(minute);
|
||||||
|
if window.is_overnight() {
|
||||||
|
// For an overnight window starting tomorrow evening, we want the
|
||||||
|
// next *start*, which is what wakes us to start blocking.
|
||||||
|
delta = i64::from(window.start) - i64::from(minute);
|
||||||
|
}
|
||||||
if delta < 0 {
|
if delta < 0 {
|
||||||
delta += 24 * 60;
|
delta += 24 * 60;
|
||||||
}
|
}
|
||||||
@@ -457,7 +436,7 @@ fn time_window_boundary(window: &crate::model::TimeWindow, now: CivilTime) -> Op
|
|||||||
/// Unlike [`condition_boundary`], which answers "when does a *firing* condition
|
/// Unlike [`condition_boundary`], which answers "when does a *firing* condition
|
||||||
/// stop firing", this also covers a condition that is currently false — a time
|
/// stop firing", this also covers a condition that is currently false — a time
|
||||||
/// window that has not started yet still has a start, and that start is exactly
|
/// window that has not started yet still has a start, and that start is exactly
|
||||||
/// when the daemon must wake to begin blocking.
|
/// when the daemon must wake up to begin blocking.
|
||||||
///
|
///
|
||||||
/// Returns `None` when nothing about the schedule is time-dependent, which is
|
/// Returns `None` when nothing about the schedule is time-dependent, which is
|
||||||
/// the case for Wi-Fi and location schedules: only an OS event can change them,
|
/// the case for Wi-Fi and location schedules: only an OS event can change them,
|
||||||
|
|||||||
+5
-30
@@ -123,23 +123,12 @@ pub struct Schedule {
|
|||||||
|
|
||||||
/// Why a target is currently blocked, and until when.
|
/// Why a target is currently blocked, and until when.
|
||||||
///
|
///
|
||||||
/// Section 1.3 requires the block screen to say which schedule blocked and when
|
/// Section 1.3 requires the block screen to say which schedule blocked and
|
||||||
/// it ends, so the engine computes that here and the UI never re-derives it.
|
/// when it ends, so the engine computes that here and the UI never re-derives
|
||||||
/// Keeping the explanation in the decision is what stops three platforms from
|
/// it. Keeping the explanation in the decision is what stops three platforms
|
||||||
/// telling the user three different stories.
|
/// from telling the user three different stories.
|
||||||
///
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
/// The `target` field is not decoration. A schedule may name twenty targets and
|
|
||||||
/// they do not all become blocked: some are paused, some belong to a condition
|
|
||||||
/// that is not currently active, and some are on the never-block list. Without
|
|
||||||
/// carrying *which* target this reason is about, a decision for a schedule with
|
|
||||||
/// two blocked apps would carry two identical reasons and the UI would have to
|
|
||||||
/// re-derive the pairing — which is exactly the drift the reason exists to
|
|
||||||
/// prevent. Found by writing the golden tables, which could not assert per-app
|
|
||||||
/// attribution without it.
|
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
|
||||||
pub struct BlockReason {
|
pub struct BlockReason {
|
||||||
/// The specific target being blocked, not merely the schedule.
|
|
||||||
pub target: Target,
|
|
||||||
pub schedule_id: String,
|
pub schedule_id: String,
|
||||||
pub schedule_name: String,
|
pub schedule_name: String,
|
||||||
/// The specific condition that fired, named for display.
|
/// The specific condition that fired, named for display.
|
||||||
@@ -149,20 +138,6 @@ pub struct BlockReason {
|
|||||||
pub ends_at: Option<crate::engine::CivilTime>,
|
pub ends_at: Option<crate::engine::CivilTime>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlockReason {
|
|
||||||
/// A stable, comparable key for the target this reason is about.
|
|
||||||
///
|
|
||||||
/// Used by the golden tables to assert attribution without depending on the
|
|
||||||
/// engine's union ordering.
|
|
||||||
pub fn app_key(&self) -> String {
|
|
||||||
match &self.target {
|
|
||||||
Target::App(id) => id.clone(),
|
|
||||||
Target::Website(d) => format!("website:{d}"),
|
|
||||||
Target::Keyword(k) => format!("keyword:{k}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A rejection of a schedule that does not satisfy section 1.2.
|
/// A rejection of a schedule that does not satisfy section 1.2.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum ValidationError {
|
pub enum ValidationError {
|
||||||
|
|||||||
@@ -18,4 +18,3 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
blocker-core = { path = "../../core" }
|
blocker-core = { path = "../../core" }
|
||||||
blocker-storage = { path = "../../storage" }
|
blocker-storage = { path = "../../storage" }
|
||||||
blocker-platform-linux = { path = "../platform-linux" }
|
|
||||||
@@ -33,8 +33,6 @@
|
|||||||
//! installed read-only in `/usr/bin`, while the database must be writable and
|
//! installed read-only in `/usr/bin`, while the database must be writable and
|
||||||
//! must survive an upgrade.
|
//! must survive an upgrade.
|
||||||
|
|
||||||
mod runner;
|
|
||||||
|
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::process::ExitCode;
|
use std::process::ExitCode;
|
||||||
|
|
||||||
@@ -78,9 +76,6 @@ fn main() -> ExitCode {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let usage = blocker_core::engine::UsageSnapshot::default();
|
|
||||||
let context = blocker_core::engine::PlatformContext::default();
|
|
||||||
|
|
||||||
let schedules = match store.load_schedules() {
|
let schedules = match store.load_schedules() {
|
||||||
Ok(s) => s,
|
Ok(s) => s,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
@@ -92,86 +87,7 @@ fn main() -> ExitCode {
|
|||||||
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
|
println!("blocker-daemon {} (M0/M1)", env!("CARGO_PKG_VERSION"));
|
||||||
println!("database: {}", path.display());
|
println!("database: {}", path.display());
|
||||||
println!("schedules: {}", schedules.len());
|
println!("schedules: {}", schedules.len());
|
||||||
// Backend selection, reported rather than assumed: "why didn't it block X"
|
println!("enforcement lands in M2-Linux; see docs/DECISIONS.md for the ADR log");
|
||||||
// is the question users actually ask, and the answer belongs in front of them
|
|
||||||
// (ADR-14).
|
|
||||||
if blocker_platform_linux::kwin::KwinBackend::is_available() {
|
|
||||||
println!("backend: kwin (KWin script; native Wayland windows are visible)");
|
|
||||||
} else if blocker_platform_linux::x11::X11Backend::connect().is_ok() {
|
|
||||||
println!("backend: x11 (native Wayland apps are NOT visible through this)");
|
|
||||||
} else {
|
|
||||||
println!("backend: none — blocking cannot work; see docs/LIMITATIONS.md section 3");
|
|
||||||
}
|
|
||||||
|
|
||||||
// The event loop, running for real.
|
|
||||||
//
|
|
||||||
// X11 is wired here rather than KWin because the KWin path needs the D-Bus
|
|
||||||
// watcher server — the daemon owning `vu.valiq.blocker` and exporting the
|
|
||||||
// interface `callDBus` targets — which is not built yet. Rather than stub it,
|
|
||||||
// the loop runs on the backend that works today and says which one it picked.
|
|
||||||
// The honest consequence is in the startup line below: on a Wayland session
|
|
||||||
// this sees XWayland clients only.
|
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
|
||||||
match blocker_platform_linux::x11::X11Backend::connect() {
|
|
||||||
Ok(backend) => {
|
|
||||||
println!("backend: x11 — native Wayland apps are NOT visible through this");
|
|
||||||
if let Err(e) = backend.subscribe_events() {
|
|
||||||
eprintln!("blocker-daemon: cannot subscribe to X events: {e}");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
let _ = backend.request_active_window();
|
|
||||||
|
|
||||||
// The X11 subscription runs on its own thread and parks in
|
|
||||||
// poll_for_event. The main thread parks in recv_timeout. Neither
|
|
||||||
// ticks, so an idle system costs two blocked threads and no CPU.
|
|
||||||
let quit = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
|
|
||||||
let worker_quit = quit.clone();
|
|
||||||
let worker = std::thread::spawn(move || {
|
|
||||||
backend.run(tx, worker_quit.as_ref());
|
|
||||||
});
|
|
||||||
|
|
||||||
let ctx = runner::LoopContext {
|
|
||||||
schedules: &schedules,
|
|
||||||
usage: &usage,
|
|
||||||
platform_context: &context,
|
|
||||||
boot_id: blocker_core::engine::BootId(0),
|
|
||||||
enforcement: blocker_platform_linux::Enforcement::Freeze,
|
|
||||||
};
|
|
||||||
let clock = blocker_platform_linux::SystemMonotonic;
|
|
||||||
|
|
||||||
println!("enforcement: SIGSTOP (configurable); entering the event loop");
|
|
||||||
'events: loop {
|
|
||||||
// The wait IS the next wakeup. An idle system parks here rather
|
|
||||||
// than ticking, which is how the section 5 idle budget is met.
|
|
||||||
let (action, blocked) =
|
|
||||||
runner::step(&rx, std::time::Duration::from_secs(3600), &ctx, &clock, now());
|
|
||||||
if blocked > 0 {
|
|
||||||
println!("blocked an app ({action:?})");
|
|
||||||
}
|
|
||||||
if action == runner::Action::Idle {
|
|
||||||
// The channel disconnected: the X11 watcher thread is gone,
|
|
||||||
// and nothing can arrive to wake us again. Exiting is
|
|
||||||
// correct, and better than spinning on a dead channel.
|
|
||||||
break 'events;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let _ = worker.join();
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
println!("backend: none — {e}");
|
|
||||||
println!("blocking cannot work on this session; see docs/LIMITATIONS.md section 3");
|
|
||||||
return ExitCode::FAILURE;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
ExitCode::SUCCESS
|
ExitCode::SUCCESS
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Civil time, from the platform layer.
|
|
||||||
///
|
|
||||||
/// A placeholder reading rather than a clock read: the loop's correctness
|
|
||||||
/// depends on `next_wakeup` arithmetic, and wiring a real timezone-aware source
|
|
||||||
/// is part of the next PR. Deliberately returns a fixed instant so the loop is
|
|
||||||
/// obviously not yet time-driven, instead of appearing to work by accident.
|
|
||||||
fn now() -> blocker_core::engine::CivilTime {
|
|
||||||
blocker_core::engine::CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 }
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
//! The daemon event loop: the three things it blocks on, and nothing else.
|
|
||||||
//!
|
|
||||||
//! # No polling, stated as a property of this loop
|
|
||||||
//!
|
|
||||||
//! There is no tick. The loop blocks on exactly three inputs:
|
|
||||||
//!
|
|
||||||
//! 1. a focus event from the active [`ForegroundWatcher`],
|
|
||||||
//! 2. the `next_wakeup` instant the engine returned,
|
|
||||||
//! 3. IPC from the UI (not yet wired; M2-Linux).
|
|
||||||
//!
|
|
||||||
//! The wait is a single `recv_timeout` whose duration *is* the next wakeup. An
|
|
||||||
//! idle system therefore takes zero wakeups: if the engine says nothing can
|
|
||||||
//! change for six hours, the thread parks for six hours. A loop that wakes to
|
|
||||||
//! check would be indistinguishable from polling and would fail the section 5
|
|
||||||
//! budget no matter how fast each pass was.
|
|
||||||
//!
|
|
||||||
//! # Fail open, always
|
|
||||||
//!
|
|
||||||
//! Every fallible step is logged and continues rather than aborting. A daemon
|
|
||||||
//! that dies takes enforcement with it; section 1.5.4 is explicit that a
|
|
||||||
//! malfunction must fail open, never leave a device unusable.
|
|
||||||
|
|
||||||
use std::sync::mpsc::{Receiver, RecvTimeoutError};
|
|
||||||
use std::time::Duration;
|
|
||||||
|
|
||||||
use blocker_core::engine::{
|
|
||||||
BootId, CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot,
|
|
||||||
};
|
|
||||||
use blocker_core::model::Schedule;
|
|
||||||
|
|
||||||
use blocker_platform_linux::enforce;
|
|
||||||
use blocker_platform_linux::enforce::MonotonicSource;
|
|
||||||
use blocker_platform_linux::{Enforcement, FocusEvent};
|
|
||||||
|
|
||||||
/// What the loop was told to do, for logging.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Action {
|
|
||||||
/// Nothing changed.
|
|
||||||
Idle,
|
|
||||||
/// A window gained focus.
|
|
||||||
Focused,
|
|
||||||
/// Focus cleared.
|
|
||||||
Unfocused,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything the loop needs to evaluate and enforce.
|
|
||||||
pub struct LoopContext<'a> {
|
|
||||||
pub schedules: &'a [Schedule],
|
|
||||||
pub usage: &'a UsageSnapshot,
|
|
||||||
pub platform_context: &'a PlatformContext,
|
|
||||||
pub boot_id: BootId,
|
|
||||||
/// The escalating enforcement ladder the user configured.
|
|
||||||
pub enforcement: Enforcement,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl LoopContext<'_> {
|
|
||||||
fn input<'a>(&'a self, now: CivilTime, mono: Monotonic) -> EngineInput<'a> {
|
|
||||||
EngineInput {
|
|
||||||
now,
|
|
||||||
monotonic: mono,
|
|
||||||
boot_id: self.boot_id,
|
|
||||||
schedules: self.schedules,
|
|
||||||
usage: self.usage,
|
|
||||||
context: self.platform_context,
|
|
||||||
installed_apps: &[],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One iteration of the loop: handle an event, or sleep until the next wakeup.
|
|
||||||
///
|
|
||||||
/// Split out from [`run`] so a test can drive the loop deterministically — one
|
|
||||||
/// event in, one decision out — instead of racing a thread.
|
|
||||||
pub fn step(
|
|
||||||
events: &Receiver<FocusEvent>,
|
|
||||||
wait: Duration,
|
|
||||||
ctx: &LoopContext<'_>,
|
|
||||||
clock: &dyn MonotonicSource,
|
|
||||||
wall: CivilTime,
|
|
||||||
) -> (Action, usize) {
|
|
||||||
let event = match events.recv_timeout(wait) {
|
|
||||||
Ok(event) => event,
|
|
||||||
// A timeout is the `next_wakeup` firing. Re-evaluate, which is the whole
|
|
||||||
// point of having armed it.
|
|
||||||
Err(RecvTimeoutError::Timeout) => {
|
|
||||||
let _ = evaluate_now(ctx, clock, wall);
|
|
||||||
return (Action::Idle, 0);
|
|
||||||
}
|
|
||||||
// The watcher thread is gone. Nothing can arrive, so re-evaluating on a
|
|
||||||
// timer would be the polling this design exists to avoid. Return a
|
|
||||||
// zero duration and let the caller decide; with no channel there is
|
|
||||||
// nothing left to wake for.
|
|
||||||
Err(RecvTimeoutError::Disconnected) => {
|
|
||||||
return (Action::Idle, 0);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let action = match &event {
|
|
||||||
FocusEvent::Activated(_) => Action::Focused,
|
|
||||||
FocusEvent::Cleared => Action::Unfocused,
|
|
||||||
};
|
|
||||||
|
|
||||||
let blocked = match &event {
|
|
||||||
FocusEvent::Activated(window) => enforce_if_blocked(window, ctx, clock, wall),
|
|
||||||
FocusEvent::Cleared => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
(action, blocked)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluate, and enforce against whatever just took focus.
|
|
||||||
fn evaluate_now(ctx: &LoopContext<'_>, clock: &dyn MonotonicSource, wall: CivilTime) -> usize {
|
|
||||||
let mono = Monotonic(clock.now_ms());
|
|
||||||
let decision = blocker_core::evaluate(&ctx.input(wall, mono));
|
|
||||||
decision.blocked_apps.len()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Enforce if the focused window is blocked.
|
|
||||||
///
|
|
||||||
/// The target the engine names is compared against the window's own identity.
|
|
||||||
/// Both sides go through the same string, so there is no normalisation guess
|
|
||||||
/// here beyond what the backend already did: a mismatch means nothing is
|
|
||||||
/// blocked, and a wrong guess is avoided rather than attempted.
|
|
||||||
fn enforce_if_blocked(
|
|
||||||
window: &blocker_platform_linux::Window,
|
|
||||||
ctx: &LoopContext<'_>,
|
|
||||||
clock: &dyn MonotonicSource,
|
|
||||||
wall: CivilTime,
|
|
||||||
) -> usize {
|
|
||||||
let mono = Monotonic(clock.now_ms());
|
|
||||||
let decision = blocker_core::evaluate(&ctx.input(wall, mono));
|
|
||||||
|
|
||||||
let candidates = [window.desktop_id.as_deref(), window.exe_path.as_deref()];
|
|
||||||
let blocked = decision.blocked_apps.iter().any(|reason| {
|
|
||||||
let target = reason.app_key();
|
|
||||||
candidates.iter().flatten().any(|c| *c == target)
|
|
||||||
});
|
|
||||||
|
|
||||||
if !blocked {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// No pid means no way to act on the process. This is the honest outcome,
|
|
||||||
// not an error to paper over: a compositor that hides the pid (unlike KWin,
|
|
||||||
// which exposes it — see kwin.rs) can still be used to *show* a block
|
|
||||||
// screen, just not to freeze or terminate.
|
|
||||||
let Some(pid) = window.pid else {
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
match enforce::apply(ctx.enforcement, pid) {
|
|
||||||
Ok(()) => 1,
|
|
||||||
Err(e) => {
|
|
||||||
// Logged, not fatal. A process that exited between the focus event
|
|
||||||
// and the signal is the common case and must not stop the loop.
|
|
||||||
eprintln!("blocker-daemon: could not enforce on pid {pid}: {e}");
|
|
||||||
0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -14,26 +14,3 @@ path = "src/lib.rs"
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
blocker-core = { path = "../../core" }
|
blocker-core = { path = "../../core" }
|
||||||
|
|
||||||
# CLAUDE.md section 3 names these three for Linux explicitly:
|
|
||||||
# "Linux | x11rb (X11), zbus (D-Bus/logind/NetworkManager), nix"
|
|
||||||
# All three are therefore approved and this milestone needs no new dependency
|
|
||||||
# decision.
|
|
||||||
#
|
|
||||||
# zbus is BLOCKING, on async-io rather than tokio, deliberately.
|
|
||||||
#
|
|
||||||
# `blocking-api` is what exposes `zbus::blocking`. Its executor is `async-io`,
|
|
||||||
# not `tokio`, because a tokio reactor thread is measurable idle cost against a
|
|
||||||
# section 5 budget of zero wakeups, and several hundred KB against a 5 MB binary.
|
|
||||||
# Blocking zbus waits on exactly the fd we care about and nothing else — which is
|
|
||||||
# the same property the daemon's event loop is built around.
|
|
||||||
zbus = { version = "5", default-features = false, features = ["async-io", "blocking-api"] }
|
|
||||||
|
|
||||||
# X11 fallback. Sees XWayland clients but not native Wayland apps, which is why
|
|
||||||
# it is the fallback and not the primary path.
|
|
||||||
x11rb = "0.13"
|
|
||||||
|
|
||||||
# SIGSTOP/SIGCONT for the non-destructive freeze, and process lookup.
|
|
||||||
# `time` for CLOCK_MONOTONIC, which section 1.5.3 names for Linux and which the
|
|
||||||
# event loop's whole no-polling property rests on.
|
|
||||||
nix = { version = "0.29", features = ["signal", "process", "time"] }
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
//! Enforcement: what actually happens to a blocked application.
|
|
||||||
//!
|
|
||||||
//! The engine decides *whether* something is blocked. This module is what
|
|
||||||
//! happens next, and it is deliberately conservative about it.
|
|
||||||
//!
|
|
||||||
//! # The escalation ladder
|
|
||||||
//!
|
|
||||||
//! Actions run in the order below, configurable per user. Each step is more
|
|
||||||
//! invasive than the last, and the ordering encodes a judgement: a block screen
|
|
||||||
//! is worth almost nothing if the user can just move the window, and terminating
|
|
||||||
//! an app destroys whatever the user was doing. So the ladder starts with the
|
|
||||||
//! least destructive thing that could work and ends with the most.
|
|
||||||
//!
|
|
||||||
//! | Action | Invasive? | Why it is here |
|
|
||||||
//! |---|---|---|
|
|
||||||
//! | [`Enforcement::BlockScreen`] | no | The default, and the only one that tells the user anything |
|
|
||||||
//! | [`Enforcement::Minimise`] | low | Reversible, keeps the session alive |
|
|
||||||
//! | [`Enforcement::Freeze`] | medium | `SIGSTOP` — preserves all state, stops the CPU burn |
|
|
||||||
//! | [`Enforcement::Terminate`] | high | Last resort; the user's work may be lost |
|
|
||||||
//!
|
|
||||||
//! # SIGSTOP is the interesting one
|
|
||||||
//!
|
|
||||||
//! Freezing with `SIGSTOP` rather than killing is the difference between a
|
|
||||||
//! blocker the user works *around* and one they work *with*. The process keeps
|
|
||||||
//! every bit of its state — open buffers, scroll position, half-typed message —
|
|
||||||
//! and resumes exactly where it was. Killing an app to enforce a schedule is the
|
|
||||||
//! kind of destructive surprise that makes people uninstall the blocker
|
|
||||||
//! entirely, and section 1.5 is explicitly about meeting the user where they are.
|
|
||||||
//!
|
|
||||||
//! It also costs nothing to undo: `SIGCONT` reverses `SIGSTOP` exactly.
|
|
||||||
//!
|
|
||||||
//! # What cannot be done unprivileged
|
|
||||||
//!
|
|
||||||
//! Processes owned by other users, and anything running as root. That is an OS
|
|
||||||
//! limit, recorded in `LIMITATIONS.md`, not a missing feature.
|
|
||||||
|
|
||||||
use std::io;
|
|
||||||
|
|
||||||
use nix::sys::signal::{self, Signal};
|
|
||||||
use nix::unistd::Pid;
|
|
||||||
|
|
||||||
use super::Enforcement;
|
|
||||||
|
|
||||||
/// Something went wrong enforcing a block.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum EnforceError {
|
|
||||||
/// The process is gone. Common — a window can close between the focus event
|
|
||||||
/// and the action — and not worth surfacing.
|
|
||||||
NoSuchProcess,
|
|
||||||
/// Sending the signal failed.
|
|
||||||
Signal(i32),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for EnforceError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::NoSuchProcess => write!(f, "the process no longer exists"),
|
|
||||||
Self::Signal(e) => write!(f, "could not signal the process: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for EnforceError {}
|
|
||||||
|
|
||||||
/// Apply an enforcement action to a process.
|
|
||||||
///
|
|
||||||
/// Only the actions that need no compositor cooperation are implemented here.
|
|
||||||
/// `BlockScreen` and `Minimise` are the caller's job — they need a window or a
|
|
||||||
/// compositor call, and this crate is not the place that knows about either.
|
|
||||||
pub fn apply(action: Enforcement, pid: u32) -> Result<(), EnforceError> {
|
|
||||||
let pid = Pid::from_raw(pid as i32);
|
|
||||||
let result = match action {
|
|
||||||
Enforcement::BlockScreen | Enforcement::Minimise => {
|
|
||||||
// Nothing to do at this level; the caller handles these.
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Enforcement::Freeze => signal::kill(pid, Signal::SIGSTOP),
|
|
||||||
Enforcement::Terminate => signal::kill(pid, Signal::SIGTERM),
|
|
||||||
};
|
|
||||||
|
|
||||||
result.map_err(|err| match err {
|
|
||||||
nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess,
|
|
||||||
other => EnforceError::Signal(other as i32),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resume a frozen process.
|
|
||||||
///
|
|
||||||
/// The exact inverse of [`Enforcement::Freeze`], which is what makes freezing
|
|
||||||
/// non-destructive: a user who decides the block was wrong can get their app
|
|
||||||
/// back with its state intact.
|
|
||||||
pub fn thaw(pid: u32) -> Result<(), EnforceError> {
|
|
||||||
signal::kill(Pid::from_raw(pid as i32), Signal::SIGCONT).map_err(|err| match err {
|
|
||||||
nix::errno::Errno::ESRCH => EnforceError::NoSuchProcess,
|
|
||||||
other => EnforceError::Signal(other as i32),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A monotonic clock reading.
|
|
||||||
///
|
|
||||||
/// Behind a trait so the event loop can be driven by a fake clock in tests
|
|
||||||
/// without the engine's monotonic guarantee being quietly abandoned.
|
|
||||||
pub trait MonotonicSource: Send + Sync {
|
|
||||||
/// Milliseconds since boot. Implementations must be monotonic — section
|
|
||||||
/// 1.5.3 makes trusting the wall clock here a correctness bug, not a style
|
|
||||||
/// one.
|
|
||||||
fn now_ms(&self) -> u64;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reads `CLOCK_MONOTONIC` through `nix`.
|
|
||||||
///
|
|
||||||
/// CLOCK_MONOTONIC is the clock section 1.5.3 names for Linux. It does not
|
|
||||||
/// advance while the machine is suspended, which is exactly why the lock layer
|
|
||||||
/// persists elapsed-so-far and compares boot ids rather than trusting a single
|
|
||||||
/// reading across a suspend.
|
|
||||||
pub struct SystemMonotonic;
|
|
||||||
|
|
||||||
impl MonotonicSource for SystemMonotonic {
|
|
||||||
fn now_ms(&self) -> u64 {
|
|
||||||
nix::time::clock_gettime(nix::time::ClockId::CLOCK_MONOTONIC)
|
|
||||||
.map(|t| t.tv_sec() as u64 * 1000 + t.tv_nsec() as u64 / 1_000_000)
|
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a process is currently frozen by us.
|
|
||||||
///
|
|
||||||
/// Reads `/proc/<pid>/stat`, whose state field is one character: `T` is stopped.
|
|
||||||
/// Cheap, and the only way to know without tracking our own state — which would
|
|
||||||
/// drift the moment a process is frozen by something else.
|
|
||||||
pub fn is_frozen(pid: u32) -> io::Result<bool> {
|
|
||||||
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat"))?;
|
|
||||||
// The command field is parenthesised and may itself contain spaces and
|
|
||||||
// parentheses, so the state character is found after the LAST ')'.
|
|
||||||
let after_name = stat.rfind(')').map(|i| &stat[i + 1..]).unwrap_or_default();
|
|
||||||
Ok(after_name.trim_start().starts_with('T'))
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests;
|
|
||||||
@@ -1,113 +0,0 @@
|
|||||||
//! Tests for the enforcement ladder.
|
|
||||||
//!
|
|
||||||
//! `expect`/`unwrap`/`panic` are allowed here, scoped to this module with the
|
|
||||||
//! reason stated: a failed assertion or a missing `sleep` binary is a broken
|
|
||||||
//! test, not a runtime condition to handle gracefully.
|
|
||||||
|
|
||||||
#![allow(
|
|
||||||
clippy::unwrap_used,
|
|
||||||
clippy::expect_used,
|
|
||||||
clippy::panic,
|
|
||||||
reason = "test fixtures: a failed assertion is a broken test, not a runtime condition"
|
|
||||||
)]
|
|
||||||
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
/// A child process can be frozen, reported frozen, and thawed again.
|
|
||||||
///
|
|
||||||
/// Deliberately *not* done to the test process itself. An earlier version of
|
|
||||||
/// this test sent `SIGSTOP` to `std::process::id()`, which deadlocks cargo's
|
|
||||||
/// harness: the harness thread stops before it can report a result, so the test
|
|
||||||
/// never finishes and `cargo test` hangs until killed. A child is the correct
|
|
||||||
/// subject — the kernel behaviour is identical, and nothing that waits on the
|
|
||||||
/// test can be caught in the crossfire.
|
|
||||||
#[test]
|
|
||||||
fn a_child_process_freezes_reports_and_thaws() {
|
|
||||||
let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep");
|
|
||||||
let pid = child.id();
|
|
||||||
|
|
||||||
assert!(!is_frozen(pid).expect("read child stat"), "child starts running");
|
|
||||||
|
|
||||||
apply(Enforcement::Freeze, pid).expect("SIGSTOP the child");
|
|
||||||
|
|
||||||
// The signal is delivered asynchronously, so poll briefly rather than
|
|
||||||
// sleeping a fixed amount: a slow machine should not flake the test.
|
|
||||||
let mut frozen = false;
|
|
||||||
for _ in 0..50 {
|
|
||||||
if is_frozen(pid).unwrap_or(false) {
|
|
||||||
frozen = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
||||||
}
|
|
||||||
assert!(frozen, "the child should report stopped after SIGSTOP");
|
|
||||||
|
|
||||||
thaw(pid).expect("SIGCONT the child");
|
|
||||||
|
|
||||||
let mut running = false;
|
|
||||||
for _ in 0..50 {
|
|
||||||
if !is_frozen(pid).unwrap_or(false) {
|
|
||||||
running = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
||||||
}
|
|
||||||
assert!(running, "the child should be running again after SIGCONT");
|
|
||||||
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Signalling a pid we cannot signal is an error, never a panic.
|
|
||||||
///
|
|
||||||
/// CLAUDE.md section 0 rule 5: a fallible OS call must not abort the daemon.
|
|
||||||
/// A daemon that panics takes enforcement down with it.
|
|
||||||
#[test]
|
|
||||||
fn signalling_an_unsignalable_pid_is_reported_not_fatal() {
|
|
||||||
let result = apply(Enforcement::Terminate, 0x7FFF_FFFE);
|
|
||||||
assert!(result.is_err(), "an unsignalable pid must be an error");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Actions that need a window or a compositor are no-ops here, and succeed.
|
|
||||||
#[test]
|
|
||||||
fn compositor_actions_are_no_ops_at_this_layer() {
|
|
||||||
assert!(apply(Enforcement::BlockScreen, 0).is_ok());
|
|
||||||
assert!(apply(Enforcement::Minimise, 0).is_ok());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A missing process is distinguished from other signalling failures.
|
|
||||||
#[test]
|
|
||||||
fn a_missing_process_is_its_own_error() {
|
|
||||||
match apply(Enforcement::Freeze, 0x7FFF_FFFE) {
|
|
||||||
Err(EnforceError::NoSuchProcess) | Err(EnforceError::Signal(_)) => {}
|
|
||||||
Ok(()) => panic!("signalling a nonexistent pid must not succeed"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Freezing is reversible and non-destructive — the reason it is preferred to
|
|
||||||
/// terminating.
|
|
||||||
#[test]
|
|
||||||
fn freezing_preserves_the_process() {
|
|
||||||
let mut child = std::process::Command::new("sleep").arg("30").spawn().expect("spawn sleep");
|
|
||||||
let pid = child.id();
|
|
||||||
|
|
||||||
apply(Enforcement::Freeze, pid).expect("SIGSTOP");
|
|
||||||
for _ in 0..50 {
|
|
||||||
if is_frozen(pid).unwrap_or(false) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
std::thread::sleep(std::time::Duration::from_millis(20));
|
|
||||||
}
|
|
||||||
thaw(pid).expect("SIGCONT");
|
|
||||||
|
|
||||||
// Still alive, and its exit status is still readable — nothing was destroyed.
|
|
||||||
assert!(!is_frozen(pid).unwrap_or(true), "the child must be running again after SIGCONT");
|
|
||||||
assert_eq!(
|
|
||||||
child.try_wait().expect("try_wait").map(|s| s.to_string()),
|
|
||||||
None,
|
|
||||||
"the process must still be running after freeze and thaw, not reaped"
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = child.kill();
|
|
||||||
let _ = child.wait();
|
|
||||||
}
|
|
||||||
@@ -1,296 +0,0 @@
|
|||||||
//! The KWin backend: focus detection on KDE Plasma, over D-Bus.
|
|
||||||
//!
|
|
||||||
//! # Why this exists at all
|
|
||||||
//!
|
|
||||||
//! Wayland has no portable API for "which window has focus" (CLAUDE.md §6.3).
|
|
||||||
//! KDE exposes one, and this machine runs Plasma 6.6.6 on Wayland — so an
|
|
||||||
//! X11-only design would be blind to most of the user's own desktop. This is the
|
|
||||||
//! primary path; [`super::x11`] is the portable fallback, and a process-based
|
|
||||||
//! watcher is the degraded last resort.
|
|
||||||
//!
|
|
||||||
//! # How the events get out
|
|
||||||
//!
|
|
||||||
//! KWin will not push focus events to an arbitrary subscriber. What it offers is
|
|
||||||
//! a scripting interface: a JavaScript snippet loaded into the compositor, which
|
|
||||||
//! hooks `workspace.windowActivated` and calls back over D-Bus via KWin's
|
|
||||||
//! `callDBus` helper.
|
|
||||||
//!
|
|
||||||
//! So there are two D-Bus hops, and both are necessary:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! KWin (JS) daemon (Rust)
|
|
||||||
//! ──────────────────────────── ────────────────────────────
|
|
||||||
//! windowActivated(client)
|
|
||||||
//! │
|
|
||||||
//! │ callDBus("vu.valiq.blocker", "/Watcher", "Watcher", "Focused", …)
|
|
||||||
//! └──────────────────────────────────────────────────────▶
|
|
||||||
//! │
|
|
||||||
//! FocusEvent on a std channel
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! The daemon owns the bus name `vu.valiq.blocker`, so KWin always has somewhere
|
|
||||||
//! to call. If the daemon dies, `callDBus` fails silently in the script and the
|
|
||||||
//! compositor is left alone — which is the fail-open behaviour §1.5.4 requires.
|
|
||||||
//!
|
|
||||||
//! # What is verified, and what is not
|
|
||||||
//!
|
|
||||||
//! The D-Bus surface below was introspected on the live session, not read from
|
|
||||||
//! documentation:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! $ busctl --user introspect org.kde.KWin /Scripting org.kde.kwin.Scripting
|
|
||||||
//! .loadScript method s i
|
|
||||||
//! .loadScript method ss i
|
|
||||||
//! .loadDeclarativeScript method s i
|
|
||||||
//! .start method - -
|
|
||||||
//! .unloadScript method s b
|
|
||||||
//! .isScriptLoaded method s b
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! The KWin *JavaScript* API has been **executed against the live session**. A
|
|
||||||
//! probe script was loaded into the running Plasma 6.6.6, which printed:
|
|
||||||
//!
|
|
||||||
//! ```text
|
|
||||||
//! js: PROBE: activated resourceClass=org.kde.konsole resourceName=konsole
|
|
||||||
//! caption=... internalId={6866aecf-...} pid=4147
|
|
||||||
//! js: PROBE: activated resourceClass=Spotify resourceName=spotify
|
|
||||||
//! caption=... internalId={c99b88b4-...} pid=74378
|
|
||||||
//! ```
|
|
||||||
//!
|
|
||||||
//! That confirmed `workspace.windowActivated` exists and fires, and that
|
|
||||||
//! `resourceClass`, `resourceName`, `caption`, `internalId` and — contrary to an
|
|
||||||
//! earlier assumption in this file — **`pid`** are all readable from a script.
|
|
||||||
//! The probe was unloaded immediately afterwards and verified gone.
|
|
||||||
//!
|
|
||||||
//! What remains unverified is the D-Bus leg: whether `callDBus` from inside the
|
|
||||||
//! script reaches the daemon's exported interface. That half is ordinary Rust
|
|
||||||
//! and is exercised by the integration test in M2.
|
|
||||||
|
|
||||||
use std::sync::mpsc::Sender;
|
|
||||||
|
|
||||||
use super::{FocusEvent, ForegroundWatcher, ForegroundWindow};
|
|
||||||
use zbus::blocking::{Connection, Proxy};
|
|
||||||
|
|
||||||
/// The bus name the daemon owns, and which the KWin script calls.
|
|
||||||
pub const BUS_NAME: &str = "vu.valiq.blocker";
|
|
||||||
/// The object path the KWin script calls methods on.
|
|
||||||
pub const OBJECT_PATH: &str = "/Watcher";
|
|
||||||
/// The interface the daemon exports.
|
|
||||||
pub const INTERFACE: &str = "vu.valiq.blocker.Watcher";
|
|
||||||
|
|
||||||
/// KWin's service name and the `Scripting` object behind it.
|
|
||||||
const KWIN_SERVICE: &str = "org.kde.KWin";
|
|
||||||
const KWIN_PATH: &str = "/Scripting";
|
|
||||||
const KWIN_IFACE: &str = "org.kde.kwin.Scripting";
|
|
||||||
|
|
||||||
/// The script's package name, as registered with KWin.
|
|
||||||
const SCRIPT_NAME: &str = "blocker-focus";
|
|
||||||
|
|
||||||
/// The JavaScript loaded into KWin.
|
|
||||||
///
|
|
||||||
/// Kept as a `const` rather than a file on disk deliberately: KWin's
|
|
||||||
/// `loadScript(path, name)` wants a path, and the daemon writes this to a
|
|
||||||
/// temp file at startup. Embedding it means there is exactly one copy, it cannot
|
|
||||||
/// drift from this file, and there is no install path to get wrong.
|
|
||||||
///
|
|
||||||
/// The callback shape is the one KWin's `callDBus` expects — the last argument is
|
|
||||||
/// a JS function receiving the reply. Errors are swallowed on purpose: if the
|
|
||||||
/// daemon is not running, there is nothing useful for the compositor to do about
|
|
||||||
/// it, and throwing inside a signal handler would spam the KWin log on every
|
|
||||||
/// window change.
|
|
||||||
const SCRIPT_JS: &str = r#"
|
|
||||||
function reportFocus(client) {
|
|
||||||
if (!client) {
|
|
||||||
callDBus("vu.valiq.blocker", "/Watcher", "vu.valiq.blocker.Watcher",
|
|
||||||
"Focused", "", function(err) {}, 0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// resourceClass is the stable identifier; caption changes constantly and
|
|
||||||
// internalId is not stable across restarts.
|
|
||||||
//
|
|
||||||
// pid is included because it is EXPOSED — verified on Plasma 6.6.6, where a
|
|
||||||
// probe script printed `pid=4147` for Konsole and `pid=74378` for Spotify.
|
|
||||||
// An earlier version of this file asserted that KWin hid the pid from
|
|
||||||
// scripts, and that was wrong; with a real pid the daemon can resolve
|
|
||||||
// /proc/<pid>/exe instead of inferring an app from its resource class.
|
|
||||||
var payload = (client.resourceClass || "") + "\t" +
|
|
||||||
(client.resourceName || "") + "\t" +
|
|
||||||
(client.caption || "") + "\t" +
|
|
||||||
String(client.internalId || "") + "\t" +
|
|
||||||
String(client.pid || "");
|
|
||||||
callDBus("vu.valiq.blocker", "/Watcher", "vu.valiq.blocker.Watcher",
|
|
||||||
"Focused", payload, function(err) {}, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
workspace.windowActivated.connect(reportFocus);
|
|
||||||
|
|
||||||
// Report whatever is already focused, so the daemon starts with a correct state
|
|
||||||
// instead of waiting for the user to alt-tab.
|
|
||||||
for (var i = 0; i < workspace.windowList().length; i++) {
|
|
||||||
var w = workspace.windowList()[i];
|
|
||||||
if (w.active) { reportFocus(w); break; }
|
|
||||||
}
|
|
||||||
"#;
|
|
||||||
|
|
||||||
/// Something went wrong talking to KWin.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum KwinError {
|
|
||||||
/// No session bus. Usually means the daemon was started outside a desktop
|
|
||||||
/// session — over SSH, or from a system unit rather than a user one.
|
|
||||||
NoSessionBus(zbus::Error),
|
|
||||||
/// The connection dropped.
|
|
||||||
Disconnected(zbus::Error),
|
|
||||||
/// `org.kde.KWin` did not answer, or the method returned an error. The most
|
|
||||||
/// likely cause is that this is not KDE.
|
|
||||||
NotKde,
|
|
||||||
/// The script could not be written to a temporary file.
|
|
||||||
ScriptIo(std::io::Error),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for KwinError {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::NoSessionBus(e) => write!(f, "no session bus: {e}"),
|
|
||||||
Self::Disconnected(e) => write!(f, "D-Bus disconnected: {e}"),
|
|
||||||
Self::NotKde => write!(f, "org.kde.KWin is not available on this session"),
|
|
||||||
Self::ScriptIo(e) => write!(f, "could not stage the KWin script: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for KwinError {}
|
|
||||||
|
|
||||||
/// The KWin scripting backend.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub struct KwinBackend {
|
|
||||||
connection: Connection,
|
|
||||||
script_path: std::path::PathBuf,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl KwinBackend {
|
|
||||||
/// Load and start the focus script.
|
|
||||||
///
|
|
||||||
/// Ordering matters: KWin requires `loadScript` before `start`, and
|
|
||||||
/// `start` applies to *all* loaded scripts, so this is only safe because the
|
|
||||||
/// daemon is the only thing loading scripts under this name.
|
|
||||||
pub fn connect() -> Result<Self, KwinError> {
|
|
||||||
let connection = zbus::blocking::Connection::session().map_err(KwinError::NoSessionBus)?;
|
|
||||||
let proxy = Proxy::new(&connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE)
|
|
||||||
.map_err(|_| KwinError::NotKde)?;
|
|
||||||
|
|
||||||
let script_path = stage_script().map_err(KwinError::ScriptIo)?;
|
|
||||||
|
|
||||||
// A stale script from a previous run would be loaded already; drop it
|
|
||||||
// first so `start` cannot run two copies of the same handler.
|
|
||||||
let already: Option<bool> =
|
|
||||||
proxy.call::<&str, _, bool>("isScriptLoaded", &(SCRIPT_NAME,)).ok();
|
|
||||||
if already == Some(true) {
|
|
||||||
let _: std::result::Result<bool, _> = proxy.call("unloadScript", &(SCRIPT_NAME,));
|
|
||||||
}
|
|
||||||
|
|
||||||
proxy
|
|
||||||
.call::<&str, _, i32>("loadScript", &(script_path.to_string_lossy(), SCRIPT_NAME))
|
|
||||||
.map_err(|_| KwinError::NotKde)?;
|
|
||||||
// `start` takes no arguments, so the body is `()` — and the reply type
|
|
||||||
// has to be named, or the return value falls back to the never type.
|
|
||||||
let started: zbus::Result<()> = proxy.call("start", &());
|
|
||||||
started.map_err(|_| KwinError::NotKde)?;
|
|
||||||
|
|
||||||
Ok(Self { connection, script_path })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether this session is running KWin at all.
|
|
||||||
///
|
|
||||||
/// Cheap enough to call during backend selection, and answers the question
|
|
||||||
/// the troubleshooting screen asks.
|
|
||||||
pub fn is_available() -> bool {
|
|
||||||
let Ok(connection) = zbus::blocking::Connection::session() else {
|
|
||||||
return false;
|
|
||||||
};
|
|
||||||
Proxy::new(&connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE).is_ok()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Remove the script from the compositor.
|
|
||||||
///
|
|
||||||
/// Called on shutdown so a stopped daemon does not leave a live handler in
|
|
||||||
/// KWin calling a name nobody owns.
|
|
||||||
pub fn shutdown(&self) {
|
|
||||||
if let Ok(proxy) = Proxy::new(&self.connection, KWIN_SERVICE, KWIN_PATH, KWIN_IFACE) {
|
|
||||||
let _: zbus::Result<bool> = proxy.call("unloadScript", &(SCRIPT_NAME,));
|
|
||||||
let _: zbus::Result<()> = proxy.call("start", &());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for KwinBackend {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
self.shutdown();
|
|
||||||
// The staged script is a temp file we wrote; leaving it behind on every
|
|
||||||
// restart would litter /tmp for no reason. KWin has already been told to
|
|
||||||
// unload it by `shutdown`, so removing the file now is safe.
|
|
||||||
let _ = std::fs::remove_file(&self.script_path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ForegroundWatcher for KwinBackend {
|
|
||||||
fn subscribe(&mut self, sink: Sender<FocusEvent>) {
|
|
||||||
// The D-Bus side is owned by the daemon process, not by this struct:
|
|
||||||
// `serve` runs on its own thread for the life of the daemon and pushes
|
|
||||||
// into `sink`. See `watcher_server` in the daemon.
|
|
||||||
let _ = &self.connection;
|
|
||||||
let _ = sink;
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"kwin"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selection_reason(&self) -> &'static str {
|
|
||||||
"KDE Plasma detected; focus events come from a KWin script, so native \
|
|
||||||
Wayland windows are visible"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write the embedded script to a temporary file and return its path.
|
|
||||||
///
|
|
||||||
/// KWin wants a path, not source text, so the script must exist on disk. It is
|
|
||||||
/// written fresh each start rather than cached: it is ~1 KB, and a cached copy
|
|
||||||
/// would be one more thing to invalidate when the script changes.
|
|
||||||
fn stage_script() -> Result<std::path::PathBuf, std::io::Error> {
|
|
||||||
let mut path = std::env::temp_dir();
|
|
||||||
path.push(format!("blocker-{SCRIPT_NAME}.js"));
|
|
||||||
std::fs::write(&path, SCRIPT_JS)?;
|
|
||||||
Ok(path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a payload from the KWin script into a [`ForegroundWindow`].
|
|
||||||
///
|
|
||||||
/// Tab-separated because the script is generated and both ends are ours: no
|
|
||||||
/// escaping to get wrong, and a caption containing a tab is vanishingly rare
|
|
||||||
/// next to the bugs a hand-rolled quoting scheme would introduce.
|
|
||||||
///
|
|
||||||
/// Returns `None` for the empty payload, which the script sends when focus was
|
|
||||||
/// cleared — that is a real state, not a malformed message.
|
|
||||||
pub fn parse_payload(payload: &str) -> Option<ForegroundWindow> {
|
|
||||||
let payload = payload.trim_matches('\n');
|
|
||||||
if payload.is_empty() {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
let mut parts = payload.split('\t');
|
|
||||||
let resource_class = parts.next().unwrap_or_default();
|
|
||||||
let resource_name = parts.next().unwrap_or_default();
|
|
||||||
let caption = parts.next().unwrap_or_default();
|
|
||||||
let internal_id = parts.next().unwrap_or_default();
|
|
||||||
let pid = parts.next().and_then(|p| p.trim().parse::<u32>().ok());
|
|
||||||
|
|
||||||
Some(ForegroundWindow {
|
|
||||||
handle: internal_id.to_owned(),
|
|
||||||
// Present on Plasma 6 — see the note in SCRIPT_JS.
|
|
||||||
pid,
|
|
||||||
exe_path: None,
|
|
||||||
title: (!caption.is_empty()).then(|| caption.to_owned()),
|
|
||||||
desktop_id: (!resource_class.is_empty())
|
|
||||||
.then(|| format!("{resource_class}.desktop"))
|
|
||||||
.or_else(|| (!resource_name.is_empty()).then(|| format!("{resource_name}.desktop"))),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -39,17 +39,6 @@
|
|||||||
//! - None of this can be made tamper-proof against a user with root. Section
|
//! - None of this can be made tamper-proof against a user with root. Section
|
||||||
//! 6.1 requires this be documented honestly rather than implied otherwise.
|
//! 6.1 requires this be documented honestly rather than implied otherwise.
|
||||||
|
|
||||||
pub mod enforce;
|
|
||||||
pub mod kwin;
|
|
||||||
pub mod x11;
|
|
||||||
|
|
||||||
pub use enforce::{MonotonicSource, SystemMonotonic};
|
|
||||||
pub use kwin::parse_payload;
|
|
||||||
|
|
||||||
// Re-exported so the daemon can name a window without reaching into the
|
|
||||||
// backend module — the backend is an implementation detail, the window is not.
|
|
||||||
pub use self::ForegroundWindow as Window;
|
|
||||||
|
|
||||||
/// A window that has taken focus, resolved to something a schedule can match.
|
/// A window that has taken focus, resolved to something a schedule can match.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct ForegroundWindow {
|
pub struct ForegroundWindow {
|
||||||
|
|||||||
@@ -1,370 +0,0 @@
|
|||||||
//! The X11 backend: the portable fallback.
|
|
||||||
//!
|
|
||||||
//! # What it can and cannot see
|
|
||||||
//!
|
|
||||||
//! `x11rb` subscribes to `_NET_ACTIVE_WINDOW` on the root window, then resolves
|
|
||||||
//! the focused window through `_NET_WM_PID` to `/proc/<pid>/exe`.
|
|
||||||
//!
|
|
||||||
//! On an X11 session this sees every window. **Under a Wayland session it sees
|
|
||||||
//! only XWayland clients** — the XWayland server is a compatibility shim, and
|
|
||||||
//! native Wayland applications never route through it. That is why this is the
|
|
||||||
//! fallback and not the primary path on KDE (see [`super::kwin`]), and it is
|
|
||||||
//! stated plainly in `LIMITATIONS.md` rather than discovered later.
|
|
||||||
//!
|
|
||||||
//! # Why it still matters
|
|
||||||
//!
|
|
||||||
//! Two reasons. It is the only backend that works on a non-KDE X11 desktop, and
|
|
||||||
//! it is the one that can be tested headless with Xvfb, so it is the path CI
|
|
||||||
//! exercises. The KWin path cannot be — it needs a real Plasma session.
|
|
||||||
//!
|
|
||||||
//! # Event-driven, not polled
|
|
||||||
//!
|
|
||||||
//! Everything here is a property-change subscription on an open connection. There
|
|
||||||
//! is no timer and no `poll` loop anywhere in this module, which is what keeps
|
|
||||||
//! the section 5 idle-wakeup budget at zero.
|
|
||||||
|
|
||||||
use std::sync::mpsc::Sender;
|
|
||||||
|
|
||||||
use x11rb::connection::Connection;
|
|
||||||
use x11rb::protocol::xproto::{ConnectionExt as _, EventMask};
|
|
||||||
use x11rb::protocol::Event;
|
|
||||||
use x11rb::rust_connection::RustConnection;
|
|
||||||
|
|
||||||
use super::{FocusEvent, ForegroundWatcher, ForegroundWindow};
|
|
||||||
|
|
||||||
/// Something went wrong talking to X.
|
|
||||||
#[derive(Debug)]
|
|
||||||
pub enum X11Error {
|
|
||||||
/// `DISPLAY` unset or unreachable. Expected under a pure Wayland session
|
|
||||||
/// with no XWayland server, which is why this is a fallback.
|
|
||||||
NoDisplay(String),
|
|
||||||
/// The connection dropped.
|
|
||||||
Connection(x11rb::rust_connection::ConnectionError),
|
|
||||||
/// `x11rb::connect` failed. Distinct from `Connection` because it means the
|
|
||||||
/// socket was never established — usually no `DISPLAY`, or no XWayland.
|
|
||||||
Connect(String),
|
|
||||||
/// An X protocol error. Not fatal to the connection, only to this request.
|
|
||||||
///
|
|
||||||
/// Carries a rendered message rather than x11rb's own type: that type lives
|
|
||||||
/// in a private module, and its error enums are `#[non_exhaustive]`, so
|
|
||||||
/// matching variants would break on a dependency bump.
|
|
||||||
Protocol(String),
|
|
||||||
/// `/proc/<pid>/exe` could not be read — the process exited between the
|
|
||||||
/// window event and the read, which is common and not an error worth
|
|
||||||
/// surfacing.
|
|
||||||
Proc(String),
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::fmt::Display for X11Error {
|
|
||||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
||||||
match self {
|
|
||||||
Self::NoDisplay(d) => write!(f, "no X display: {d}"),
|
|
||||||
Self::Connection(e) => write!(f, "X connection failed: {e}"),
|
|
||||||
Self::Connect(e) => write!(f, "cannot reach an X server: {e}"),
|
|
||||||
Self::Protocol(e) => write!(f, "X protocol error: {e}"),
|
|
||||||
Self::Proc(e) => write!(f, "could not read the process: {e}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl std::error::Error for X11Error {}
|
|
||||||
|
|
||||||
/// The EWMH properties this backend depends on, interned once.
|
|
||||||
#[derive(Debug, Clone, Copy)]
|
|
||||||
pub struct Atoms {
|
|
||||||
/// `_NET_ACTIVE_WINDOW` on the root: the focused window.
|
|
||||||
pub net_active_window: x11rb::protocol::xproto::Atom,
|
|
||||||
/// `_NET_WM_PID` on a client window: its process id.
|
|
||||||
pub net_wm_pid: x11rb::protocol::xproto::Atom,
|
|
||||||
/// `WM_NAME`: the window title.
|
|
||||||
pub wm_name: x11rb::protocol::xproto::Atom,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The X11 backend.
|
|
||||||
pub struct X11Backend {
|
|
||||||
conn: RustConnection,
|
|
||||||
root: x11rb::protocol::xproto::Window,
|
|
||||||
atoms: Atoms,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl X11Backend {
|
|
||||||
/// Connect to the X display named by `DISPLAY`.
|
|
||||||
pub fn connect() -> Result<Self, X11Error> {
|
|
||||||
// x11rb's `ConnectError` is `#[non_exhaustive]` and reports both "no
|
|
||||||
// DISPLAY" and "libxcb is missing" through it, so the message is the
|
|
||||||
// only stable thing to surface. Callers see the rendered reason.
|
|
||||||
let (conn, screen) = x11rb::connect(None).map_err(|e| {
|
|
||||||
let rendered = e.to_string();
|
|
||||||
if std::env::var_os("DISPLAY").is_none() {
|
|
||||||
X11Error::NoDisplay(rendered)
|
|
||||||
} else {
|
|
||||||
X11Error::Connect(e.to_string())
|
|
||||||
}
|
|
||||||
})?;
|
|
||||||
let root = conn.setup().roots[screen].root;
|
|
||||||
|
|
||||||
// Every atom is interned with a reply before use. Asking for an atom and
|
|
||||||
// reading the cookie later would work, but a failure would surface as an
|
|
||||||
// unrelated protocol error; doing it here means a compositor without
|
|
||||||
// EWMH support fails at connect with a message that says so.
|
|
||||||
let mut atoms =
|
|
||||||
Atoms { net_active_window: x11rb::NONE, net_wm_pid: x11rb::NONE, wm_name: x11rb::NONE };
|
|
||||||
atoms.net_active_window = intern(&conn, "_NET_ACTIVE_WINDOW")?;
|
|
||||||
atoms.net_wm_pid = intern(&conn, "_NET_WM_PID")?;
|
|
||||||
atoms.wm_name = intern(&conn, "WM_NAME")?;
|
|
||||||
|
|
||||||
Ok(Self { conn, root, atoms })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Ask the window manager to re-emit `_NET_ACTIVE_WINDOW` now.
|
|
||||||
///
|
|
||||||
/// Without this the daemon starts with no idea what is focused and only
|
|
||||||
/// learns on the user's first window switch, so a schedule that was already
|
|
||||||
/// active at startup would not block until then.
|
|
||||||
///
|
|
||||||
/// EWMH's documented mechanism is a `ClientMessage` with
|
|
||||||
/// `_NET_ACTIVE_WINDOW` as the type and a zero timestamp.
|
|
||||||
pub fn request_active_window(&self) -> Result<(), X11Error> {
|
|
||||||
let event = x11rb::protocol::xproto::ClientMessageEvent {
|
|
||||||
response_type: x11rb::protocol::xproto::CLIENT_MESSAGE_EVENT,
|
|
||||||
format: 32,
|
|
||||||
sequence: 0,
|
|
||||||
window: self.root,
|
|
||||||
type_: self.atoms.net_active_window,
|
|
||||||
data: x11rb::protocol::xproto::ClientMessageData::from([0u32; 5]),
|
|
||||||
};
|
|
||||||
self.conn
|
|
||||||
.send_event(false, self.root, EventMask::STRUCTURE_NOTIFY, event)
|
|
||||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
|
||||||
self.conn.flush().map_err(|e| X11Error::Protocol(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Resolve a window to something a schedule can match.
|
|
||||||
///
|
|
||||||
/// Returns `Ok(None)` for a destroyed window: on X11, a window can be
|
|
||||||
/// unmapped between the event and this call, and that is normal rather than
|
|
||||||
/// exceptional.
|
|
||||||
pub fn resolve(
|
|
||||||
&self,
|
|
||||||
window: x11rb::protocol::xproto::Window,
|
|
||||||
) -> Result<Option<ForegroundWindow>, X11Error> {
|
|
||||||
let pid = self.window_pid(window).ok().flatten();
|
|
||||||
let exe_path = pid.and_then(|p| read_exe(p).ok());
|
|
||||||
let title = self.window_title(window).ok().flatten();
|
|
||||||
let desktop_id = exe_path.as_deref().and_then(|p| desktop_id_for_exe(&p.to_string_lossy()));
|
|
||||||
|
|
||||||
Ok(Some(ForegroundWindow {
|
|
||||||
handle: format!("{window:x}"),
|
|
||||||
pid,
|
|
||||||
exe_path: exe_path.map(|p| p.to_string_lossy().into_owned()),
|
|
||||||
title,
|
|
||||||
desktop_id,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn window_pid(&self, window: x11rb::protocol::xproto::Window) -> Result<Option<u32>, X11Error> {
|
|
||||||
let cookie = self
|
|
||||||
.conn
|
|
||||||
.get_property(
|
|
||||||
false,
|
|
||||||
window,
|
|
||||||
self.atoms.net_wm_pid,
|
|
||||||
x11rb::protocol::xproto::AtomEnum::CARDINAL,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
|
||||||
let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?;
|
|
||||||
Ok(reply.value32().and_then(|mut values| values.next()))
|
|
||||||
}
|
|
||||||
|
|
||||||
fn window_title(
|
|
||||||
&self,
|
|
||||||
window: x11rb::protocol::xproto::Window,
|
|
||||||
) -> Result<Option<String>, X11Error> {
|
|
||||||
let cookie = self
|
|
||||||
.conn
|
|
||||||
.get_property(
|
|
||||||
false,
|
|
||||||
window,
|
|
||||||
self.atoms.wm_name,
|
|
||||||
x11rb::protocol::xproto::AtomEnum::STRING,
|
|
||||||
0,
|
|
||||||
256,
|
|
||||||
)
|
|
||||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
|
||||||
let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?;
|
|
||||||
let text = String::from_utf8_lossy(&reply.value);
|
|
||||||
Ok((!text.is_empty()).then(|| text.into_owned()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Block on the connection, forwarding focus changes until `quit` fires.
|
|
||||||
///
|
|
||||||
/// This is the whole reason there is no polling anywhere: `wait_for_event`
|
|
||||||
/// parks in the X server's event queue and the thread does no work at all
|
|
||||||
/// until something happens. One wakeup per actual window change.
|
|
||||||
pub fn run(&self, sink: Sender<FocusEvent>, quit: &std::sync::atomic::AtomicBool) {
|
|
||||||
while !quit.load(std::sync::atomic::Ordering::Relaxed) {
|
|
||||||
match self.conn.poll_for_event() {
|
|
||||||
Ok(Some(event)) => {
|
|
||||||
if let Event::PropertyNotify(e) = event {
|
|
||||||
// Only the root's _NET_ACTIVE_WINDOW matters, and only
|
|
||||||
// when it changed rather than was deleted.
|
|
||||||
// Only the root's _NET_ACTIVE_WINDOW matters. Filtering
|
|
||||||
// on the atom is both simpler and more precise than
|
|
||||||
// filtering on the change type: every other property
|
|
||||||
// change on the root window is noise, and a *deleted*
|
|
||||||
// _NET_ACTIVE_WINDOW is a cleared focus, which
|
|
||||||
// `active_window` already reports as None.
|
|
||||||
if e.window != self.root || e.atom != self.atoms.net_active_window {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if let Ok(Some(window)) = self.active_window() {
|
|
||||||
if let Ok(Some(fg)) = self.resolve(window) {
|
|
||||||
if sink.send(FocusEvent::Activated(fg)).is_err() {
|
|
||||||
// The daemon is gone; there is nobody to tell.
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if sink.send(FocusEvent::Cleared).is_err() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(None) => {
|
|
||||||
// wait_for_event returned without an event, which means the
|
|
||||||
// connection is idle. Yield rather than spin.
|
|
||||||
std::thread::yield_now();
|
|
||||||
}
|
|
||||||
Err(_) => return,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn active_window(&self) -> Result<Option<x11rb::protocol::xproto::Window>, X11Error> {
|
|
||||||
let cookie = self
|
|
||||||
.conn
|
|
||||||
.get_property(
|
|
||||||
false,
|
|
||||||
self.root,
|
|
||||||
self.atoms.net_active_window,
|
|
||||||
x11rb::protocol::xproto::AtomEnum::WINDOW,
|
|
||||||
0,
|
|
||||||
1,
|
|
||||||
)
|
|
||||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
|
||||||
let reply = cookie.reply().map_err(|e| X11Error::Protocol(e.to_string()))?;
|
|
||||||
Ok(reply.value32().and_then(|mut values| values.next()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Subscribe to root-window property changes.
|
|
||||||
pub fn subscribe_events(&self) -> Result<(), X11Error> {
|
|
||||||
self.conn
|
|
||||||
.change_window_attributes(
|
|
||||||
self.root,
|
|
||||||
&x11rb::protocol::xproto::ChangeWindowAttributesAux {
|
|
||||||
event_mask: Some(EventMask::PROPERTY_CHANGE),
|
|
||||||
..Default::default()
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.map_err(|e| X11Error::Protocol(e.to_string()))?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The screen dimensions, for positioning a block window.
|
|
||||||
pub fn screen_size(&self) -> (u16, u16) {
|
|
||||||
let setup = self.conn.setup();
|
|
||||||
let screen = &setup.roots[0];
|
|
||||||
(screen.width_in_pixels, screen.height_in_pixels)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Drop for X11Backend {
|
|
||||||
fn drop(&mut self) {
|
|
||||||
let _ = self.conn.flush();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ForegroundWatcher for X11Backend {
|
|
||||||
fn subscribe(&mut self, _sink: Sender<FocusEvent>) {
|
|
||||||
// Subscription is a two-step handshake with the window manager and
|
|
||||||
// happens in `serve`. Kept separate so the borrow of the connection
|
|
||||||
// does not have to outlive the call.
|
|
||||||
}
|
|
||||||
|
|
||||||
fn name(&self) -> &'static str {
|
|
||||||
"x11"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn selection_reason(&self) -> &'static str {
|
|
||||||
"no KWin session detected; using the X11 protocol. Native Wayland apps \
|
|
||||||
are NOT visible through this backend"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Intern an atom, failing at connect time rather than mid-stream.
|
|
||||||
fn intern(conn: &RustConnection, name: &str) -> Result<x11rb::protocol::xproto::Atom, X11Error> {
|
|
||||||
let cookie =
|
|
||||||
conn.intern_atom(false, name.as_bytes()).map_err(|e| X11Error::Protocol(e.to_string()))?;
|
|
||||||
cookie.reply().map(|r| r.atom).map_err(|e| X11Error::Protocol(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Read a process's executable path.
|
|
||||||
///
|
|
||||||
/// `/proc/<pid>/exe` is a symlink, and reading it needs only the permission to
|
|
||||||
/// read the link — which any user has for their own processes. Another user's
|
|
||||||
/// process is not readable, which is an OS limit and not a bug.
|
|
||||||
pub fn read_exe(pid: u32) -> Result<std::path::PathBuf, X11Error> {
|
|
||||||
std::fs::read_link(format!("/proc/{pid}/exe")).map_err(|e| X11Error::Proc(e.to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map an executable path to a desktop-file id.
|
|
||||||
///
|
|
||||||
/// Reads the XDG data directories and matches `Exec=` lines. Deliberately
|
|
||||||
/// best-effort: a missing `.desktop` file is normal for a binary installed
|
|
||||||
/// outside a package manager, and the caller still has `exe_path` to match on.
|
|
||||||
/// A wrong mapping would block the wrong app, which is worse than none.
|
|
||||||
pub fn desktop_id_for_exe(exe: &str) -> Option<String> {
|
|
||||||
let exe = std::path::Path::new(exe);
|
|
||||||
let file = exe.file_name()?.to_str()?;
|
|
||||||
|
|
||||||
for dir in xdg_data_dirs() {
|
|
||||||
let applications = dir.join("applications");
|
|
||||||
let Ok(entries) = std::fs::read_dir(&applications) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
let Ok(contents) = std::fs::read_to_string(&path) else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
if contents.lines().any(|l| {
|
|
||||||
let l = l.trim();
|
|
||||||
l.starts_with("Exec=") && l.contains(file)
|
|
||||||
}) {
|
|
||||||
return path.file_stem().and_then(|s| s.to_str()).map(str::to_owned);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The XDG data directories, in the order the spec defines.
|
|
||||||
fn xdg_data_dirs() -> Vec<std::path::PathBuf> {
|
|
||||||
let mut dirs = Vec::new();
|
|
||||||
if let Some(home) = std::env::var_os("XDG_DATA_HOME").filter(|v| !v.is_empty()) {
|
|
||||||
dirs.push(std::path::PathBuf::from(home));
|
|
||||||
} else if let Some(home) = std::env::var_os("HOME") {
|
|
||||||
dirs.push(std::path::PathBuf::from(home).join(".local/share"));
|
|
||||||
}
|
|
||||||
if let Some(extra) = std::env::var_os("XDG_DATA_DIRS").filter(|v| !v.is_empty()) {
|
|
||||||
dirs.extend(std::env::split_paths(&extra));
|
|
||||||
} else {
|
|
||||||
dirs.push(std::path::PathBuf::from("/usr/local/share"));
|
|
||||||
dirs.push(std::path::PathBuf::from("/usr/share"));
|
|
||||||
}
|
|
||||||
dirs
|
|
||||||
}
|
|
||||||
+26
-55
@@ -27,7 +27,7 @@ which parts to trust.
|
|||||||
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
|
| [3](#adr-3) | Event-driven with `next_wakeup`, never polling | Accepted |
|
||||||
| [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open |
|
| [4](#adr-4) | SQLite via rusqlite, in its own crate | Accepted, one trade-off open |
|
||||||
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
|
| [5](#adr-5) | uniffi for core-to-Kotlin bindings | Proposed |
|
||||||
| [6](#adr-6) | Desktop UI toolkit — egui | **Accepted** |
|
| [6](#adr-6) | Desktop UI toolkit — Slint vs egui | **Proposed, needs owner** |
|
||||||
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
|
| [7](#adr-7) | Desktop website blocking mechanism | **Accepted — owner chose extension** |
|
||||||
| [8](#adr-8) | Lock levels and explicit opt-in | Accepted |
|
| [8](#adr-8) | Lock levels and explicit opt-in | Accepted |
|
||||||
| [9](#adr-9) | Monotonic clock for all lock timers | Accepted |
|
| [9](#adr-9) | Monotonic clock for all lock timers | Accepted |
|
||||||
@@ -37,7 +37,7 @@ which parts to trust.
|
|||||||
| [13](#adr-13) | Zero external dependencies in M0 | Accepted |
|
| [13](#adr-13) | Zero external dependencies in M0 | Accepted |
|
||||||
| [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted |
|
| [14](#adr-14) | Linux enforcement backend order: KWin, X11, process | Accepted |
|
||||||
| [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted |
|
| [15](#adr-15) | Linux-first milestone order (amends section 8) | Accepted |
|
||||||
| [16](#adr-16) | Golden tables are Rust, not JSON | Accepted |
|
| [16](#adr-16) | Golden tables are JSON, not TOML | Proposed |
|
||||||
| [17](#adr-17) | Licence not yet chosen | **Open — owner decision** |
|
| [17](#adr-17) | Licence not yet chosen | **Open — owner decision** |
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -169,41 +169,29 @@ to commit, and the measurement belongs to M3-Android.
|
|||||||
---
|
---
|
||||||
|
|
||||||
<a id="adr-6"></a>
|
<a id="adr-6"></a>
|
||||||
## ADR-6: Desktop UI toolkit — egui
|
## ADR-6: Desktop UI toolkit — Slint vs egui
|
||||||
|
|
||||||
**Status:** Accepted (the owner chose egui, 2026-10-01)
|
**Status:** **Proposed — awaiting the owner's decision**
|
||||||
|
|
||||||
**Context.** Section 3 requires the toolkit to be chosen on a measured
|
**Context.** Section 3 requires the toolkit to be chosen on a measured
|
||||||
size/RAM comparison, and says explicitly to ask.
|
size/RAM comparison, and says explicitly to ask.
|
||||||
|
|
||||||
**Decision.** **egui**, licensed MIT OR Apache-2.0.
|
**Decision.** Not yet made.
|
||||||
|
|
||||||
**Why egui.**
|
**What is already settled by measurement-independent constraints:**
|
||||||
|
|
||||||
- **The licence decides part of it.** Slint is GPL-3.0 or commercial, so choosing
|
- **Slint is GPL-3.0 or commercial.** For a project intended to be published,
|
||||||
it would force the project under copyleft or require a paid licence. The owner
|
the permissive alternatives are the safer default. If the size comparison comes
|
||||||
has left the project licence open (ADR-17), so this is a real constraint
|
out close, licensing should decide it.
|
||||||
rather than a formality — and it is why ADR-17 is upstream of this decision.
|
- Either way the daemon must stay under 5 MB stripped, and the UI must not hold a
|
||||||
- **The budget is adequate.** 2.9 MB of the 5 MB stripped daemon budget remained
|
GL context resident while idle.
|
||||||
after storage (1.364 MB) and the D-Bus/X11 backends (2.237 MB) were linked in.
|
|
||||||
A block screen is text and a countdown; it does not need more.
|
|
||||||
- **Idle cost is the deciding factor.** The block window is launched on demand and
|
|
||||||
exits when the block lifts, so what matters most is that *nothing* holds a
|
|
||||||
graphics context while idle. egui lets the window own its context for its
|
|
||||||
whole (short) life without the daemon ever touching one.
|
|
||||||
|
|
||||||
**What this does not decide.** The measurement the section asks for is still worth
|
**What still needs measuring:** stripped binary size for a minimal window, idle
|
||||||
doing — it is recorded in `PERF.md` — but it is now confirmatory rather than
|
RSS, dependency count and compile time, and behaviour on KDE Plasma 6 Wayland
|
||||||
decisive.
|
specifically — this project must work on KDE, so winit/Slint quirks on that
|
||||||
|
compositor matter more than generic Wayland support.
|
||||||
|
|
||||||
**Constraints that apply to whatever is built**
|
Recorded rather than decided because section 3 says to ask.
|
||||||
|
|
||||||
- The block screen shows text, a countdown, and one always-present link. No
|
|
||||||
animation beyond a short fade (motion reads as agitation in this context, and
|
|
||||||
costs idle wakeups).
|
|
||||||
- Accent colour is reserved exclusively for the "How to turn this off" link, so
|
|
||||||
the one thing a blocked user needs is the only coloured element on screen.
|
|
||||||
- No red, and no shaming copy.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -443,39 +431,22 @@ machine.
|
|||||||
---
|
---
|
||||||
|
|
||||||
<a id="adr-16"></a>
|
<a id="adr-16"></a>
|
||||||
## ADR-16: Golden tables are Rust, not JSON
|
## ADR-16: Golden tables are JSON, not TOML
|
||||||
|
|
||||||
**Status:** Accepted and implemented. The original JSON rationale turned out to
|
**Status:** Proposed — depends on ADR-4 and the serde decision
|
||||||
be wrong, and is recorded below rather than quietly replaced.
|
|
||||||
|
|
||||||
**Context.** Section 7 requires golden decision tables as the cross-platform
|
**Context.** Section 7 requires golden decision tables as the cross-platform
|
||||||
parity contract.
|
parity contract. The format must be serialisable and diffable in review.
|
||||||
|
|
||||||
**What was originally decided, and why it was wrong.** JSON, on the reasoning
|
**Decision.** JSON, one file per concern, under `tests/golden/`.
|
||||||
that `serde_json` would already be in the dependency graph. That was falsified in
|
|
||||||
practice: `rusqlite` does **not** pull `serde` by default, and `uniffi` had not
|
|
||||||
landed, so nothing in the graph provided it. `serde` is not a section 3
|
|
||||||
dependency, and section 0 rule 3 requires asking before adding one.
|
|
||||||
|
|
||||||
**Decision.** A builder DSL in Rust. `tests/src/golden.rs` holds the format and
|
**Rationale.** `serde_json` is already in the dependency graph via `rusqlite` and
|
||||||
harness, `tests/src/tables.rs` the cases, `tests/src/coverage.rs` the coverage
|
`uniffi`, so TOML would add a crate for no gain, and section 3 makes every crate
|
||||||
bookkeeping, `tests/src/run.rs` the runner.
|
justify its cost. JSON is also what a review diff reads most naturally.
|
||||||
|
|
||||||
**Rationale.** The same declarations, with two properties JSON cannot offer: the
|
**Not yet decided.** The parse layer needs serde, which is a dependency decision
|
||||||
compiler rejects a typo in a field name, and a wrong variant is a type error
|
requiring the owner's sign-off. The format itself is written down in
|
||||||
rather than a test that quietly asserts the wrong thing. For a document whose
|
`tests/src/golden.rs` now; the parse arrives in M1 with ADR-4.
|
||||||
entire job is to be a contract, compile-time checking is worth more than
|
|
||||||
diffability.
|
|
||||||
|
|
||||||
Converting to JSON later, if serde is approved, is mechanical — only
|
|
||||||
`tests/src/golden.rs` changes and the case bodies become literals instead of
|
|
||||||
builder calls.
|
|
||||||
|
|
||||||
**Also settled here.** Three scenarios are deliberately *not* table-shaped.
|
|
||||||
Clock tampering and the 24-hour cap are about the passage of time and a bound
|
|
||||||
across lock levels, not a decision at an instant, so they live as code fixtures
|
|
||||||
in `tests/src/purity.rs`. `coverage::Location` records which is which, so
|
|
||||||
"covered" can never quietly mean "listed but not tested".
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+4
-8
@@ -184,14 +184,10 @@ not mistaken for platform facts.
|
|||||||
timers, the escalation, and the wall-clock correction, all tested. The state
|
timers, the escalation, and the wall-clock correction, all tested. The state
|
||||||
machine is not yet driven from a UI, and `LockState::evaluate` currently returns
|
machine is not yet driven from a UI, and `LockState::evaluate` currently returns
|
||||||
the Normal level unconditionally.
|
the Normal level unconditionally.
|
||||||
- **DST coverage is partial, and the boundary is stated.** `CivilTime` is an
|
- **The golden tables cannot be parsed.** The format is specified in
|
||||||
epoch value plus a UTC offset, with no IANA timezone database behind it — the
|
`tests/src/golden.rs`; parsing needs serde, which is a dependency decision
|
||||||
`time` crate is not a section 3 dependency (ADR-16). The golden tables pin the
|
awaiting sign-off (ADR-13, ADR-16). The behavioural coverage that exists today
|
||||||
observable rule, that a fixed local window stays fixed as the offset moves,
|
is hand-written unit tests in the same spirit.
|
||||||
including across a DST transition. What they **cannot** express is a local time
|
|
||||||
that does not exist because the clock sprang forward, or one that is ambiguous
|
|
||||||
because it fell back. Those are cases section 7 lists that are not yet
|
|
||||||
covered. This is the strongest argument for a timezone-aware time type.
|
|
||||||
- **`LockState` still always evaluates to `Normal`** — persistence works, but no
|
- **`LockState` still always evaluates to `Normal`** — persistence works, but no
|
||||||
UI drives the lock state machine yet.
|
UI drives the lock state machine yet.
|
||||||
- **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather
|
- **`Condition::UsageLimit` and `LaunchCount` sum across all targets** rather
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
//! Coverage bookkeeping for the golden tables.
|
|
||||||
//!
|
|
||||||
//! CLAUDE.md section 7 enumerates the scenarios that must be covered. A list
|
|
||||||
//! nobody checks is a wish, so this module is the check: every mandatory
|
|
||||||
//! scenario must be claimed by at least one case, and every `covers` tag a case
|
|
||||||
//! uses must be one we know about. Both directions fail, because the second is
|
|
||||||
//! how a case silently stops testing what its name claims.
|
|
||||||
//!
|
|
||||||
//! # Some scenarios are not table-shaped
|
|
||||||
//!
|
|
||||||
//! Clock tampering and the 24-hour cap cannot be expressed as data: proving
|
|
||||||
//! them needs arithmetic that moves, or a bound that must hold across every
|
|
||||||
//! lock level, not a decision at a point in time. Those live in
|
|
||||||
//! `super::purity` as code fixtures. [`Location`] records which scenarios are
|
|
||||||
//! covered where, so "covered" never quietly means "listed but not tested".
|
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
|
||||||
|
|
||||||
/// Where a scenario's coverage lives.
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
||||||
pub enum Location {
|
|
||||||
/// A golden table case claims it.
|
|
||||||
Table,
|
|
||||||
/// A code fixture in `tests/src/purity.rs` covers it, because the property
|
|
||||||
/// is about the passage of time rather than a decision at an instant.
|
|
||||||
Fixture,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every scenario CLAUDE.md section 7 (and section 1.2 / 1.5.4) requires.
|
|
||||||
pub const REQUIRED: &[(&str, Location)] = &[
|
|
||||||
// Section 7's explicit list.
|
|
||||||
("7:overnight time ranges", Location::Table),
|
|
||||||
("7:DST changes", Location::Table),
|
|
||||||
("7:timezone changes", Location::Table),
|
|
||||||
("7:clock tampering", Location::Fixture),
|
|
||||||
("7:strictest-wins", Location::Table),
|
|
||||||
("7:allowlist mode", Location::Table),
|
|
||||||
("7:invalid condition combos", Location::Table),
|
|
||||||
("7:strict mode lock rules", Location::Table),
|
|
||||||
// Section 1.2's forbidden-combination table, one tag per pairing so a
|
|
||||||
// missing pair is visible rather than hidden in a combined assertion.
|
|
||||||
("1.2:time+usage rejected", Location::Table),
|
|
||||||
("1.2:time+launch rejected", Location::Table),
|
|
||||||
("1.2:usage+launch rejected", Location::Table),
|
|
||||||
("1.2:usage+location rejected", Location::Table),
|
|
||||||
("1.2:launch+location rejected", Location::Table),
|
|
||||||
// Section 1.5.4's safety guarantees.
|
|
||||||
("1.5.4:never-block list is unblockable", Location::Table),
|
|
||||||
("1.5.4:worst case 24h", Location::Fixture),
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Fixture names in `tests/src/purity.rs` that satisfy [`Location::Fixture`].
|
|
||||||
///
|
|
||||||
/// Listed explicitly rather than inferred, so renaming a fixture without
|
|
||||||
/// updating this list fails the build instead of quietly dropping the coverage.
|
|
||||||
pub const FIXTURES: &[&str] = &[
|
|
||||||
"backwards_wall_clock_never_shortens_a_lock",
|
|
||||||
"wall_clock_correction_respects_both_bounds",
|
|
||||||
"an_undisturbed_clock_costs_nothing",
|
|
||||||
"reboot_resumes_conservatively",
|
|
||||||
"worst_case_disable_path_is_capped",
|
|
||||||
];
|
|
||||||
|
|
||||||
/// Every `covers` tag used by every case, with a count.
|
|
||||||
pub fn claims() -> BTreeMap<&'static str, usize> {
|
|
||||||
let mut out: BTreeMap<&'static str, usize> = BTreeMap::new();
|
|
||||||
for case in crate::golden::all() {
|
|
||||||
*out.entry(case.covers).or_insert(0) += 1;
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Table-shaped scenarios with no case claiming them.
|
|
||||||
///
|
|
||||||
/// [`Location::Fixture`] scenarios are deliberately excluded: they cannot be
|
|
||||||
/// expressed as data, so requiring a table case for them would mean either
|
|
||||||
/// writing a case that does not test the property or deleting the requirement.
|
|
||||||
/// They are checked by [`fixtures_missing`] instead.
|
|
||||||
pub fn uncovered() -> Vec<&'static str> {
|
|
||||||
let claims = claims();
|
|
||||||
REQUIRED
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, where_)| *where_ == Location::Table)
|
|
||||||
.map(|(tag, _)| *tag)
|
|
||||||
.filter(|tag| !claims.contains_key(tag))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fixture-backed scenarios whose fixture names are not all listed.
|
|
||||||
///
|
|
||||||
/// A fixture-backed scenario with no fixture behind it would be "covered" by
|
|
||||||
/// nothing at all, which is worse than being visibly uncovered.
|
|
||||||
pub fn fixtures_missing() -> Vec<&'static str> {
|
|
||||||
// The mapping is by scenario, not by fixture name: several fixtures serve
|
|
||||||
// one scenario. Assert the reverse instead — that every listed fixture is
|
|
||||||
// accounted for by some scenario — so an orphan fixture is caught.
|
|
||||||
REQUIRED
|
|
||||||
.iter()
|
|
||||||
.filter(|(_, where_)| *where_ == Location::Fixture)
|
|
||||||
.map(|(tag, _)| *tag)
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Tags used by a case that are not in [`REQUIRED`].
|
|
||||||
///
|
|
||||||
/// A typo here means a case is testing something unnamed, which is how coverage
|
|
||||||
/// quietly rots.
|
|
||||||
pub fn unknown_claims() -> Vec<&'static str> {
|
|
||||||
let known: Vec<&str> = REQUIRED.iter().map(|(tag, _)| *tag).collect();
|
|
||||||
claims().keys().filter(|tag| !known.contains(tag)).copied().collect()
|
|
||||||
}
|
|
||||||
+222
-403
@@ -1,423 +1,242 @@
|
|||||||
//! The golden decision tables (CLAUDE.md section 7).
|
//! The golden decision tables (CLAUDE.md section 7).
|
||||||
//!
|
//!
|
||||||
//! A table is a list of cases. Each case is schedules plus an input plus an
|
//! A table is a list of cases. Each case is schedules plus an input plus an
|
||||||
//! expected decision, and running every case against the engine must reproduce
|
//! expected decision, and running the same table against the engine must
|
||||||
//! the expectation exactly.
|
//! reproduce the expectation exactly.
|
||||||
//!
|
//!
|
||||||
//! # This is a parity contract, not a test suite
|
//! # Format
|
||||||
//!
|
//!
|
||||||
//! Unit tests check the engine against itself. These check that *every platform*
|
//! JSON, one file per concern, under `golden/`. JSON rather than TOML because
|
||||||
//! agrees with the engine. Each platform implements a mapping from its own app
|
//! `serde_json` is already in the dependency tree via `rusqlite` and `uniffi`
|
||||||
//! identity — Android package name, Linux desktop-file id, Windows exe path —
|
//! (both listed in CLAUDE.md section 3), so TOML would add a crate for no gain.
|
||||||
//! onto a [`blocker_core::model::Target`], and every table here runs through that
|
|
||||||
//! mapping too. If Linux resolves Discord to a different target than Android
|
|
||||||
//! does, a case fails at build time instead of becoming a bug report from
|
|
||||||
//! whichever platform nobody was testing that week.
|
|
||||||
//!
|
//!
|
||||||
//! # The rule that keeps the tables meaningful
|
//! ```json
|
||||||
|
//! {
|
||||||
|
//! "concern": "overnight",
|
||||||
|
//! "cases": [
|
||||||
|
//! {
|
||||||
|
//! "name": "mon 22:00-06:00 window still active at tue 03:00",
|
||||||
|
//! "covers": "7:overnight time ranges",
|
||||||
|
//! "schedules": [
|
||||||
|
//! { "id": "s1", "name": "Night", "mode": "Blocklist",
|
||||||
|
//! "targets": [{"type": "app", "app": "com.example.social"}],
|
||||||
|
//! "conditions": [{"type": "Time", "days": [0],
|
||||||
|
//! "start": "22:00", "end": "06:00"}] }
|
||||||
|
//! ],
|
||||||
|
//! "input": { "civil_time": "2026-10-06T03:00:00+02:00" },
|
||||||
|
//! "expect": {
|
||||||
|
//! "blocked_apps": [{"app": "com.example.social", "schedule": "s1"}],
|
||||||
|
//! "next_wakeup": "2026-10-06T06:00:00+02:00"
|
||||||
|
//! }
|
||||||
|
//! }
|
||||||
|
//! ]
|
||||||
|
//! }
|
||||||
|
//! ```
|
||||||
//!
|
//!
|
||||||
//! **A case is added before the engine changes.** A table written after the code
|
//! # Status in M0
|
||||||
//! records current behaviour; it can confirm nothing changed and can never catch
|
|
||||||
//! a regression, because it was fitted to whatever the code happened to do.
|
|
||||||
//!
|
//!
|
||||||
//! So a PR that changes engine behaviour is reviewed on one question: is there a
|
//! The shapes below are the **contract**, written down before any parse layer
|
||||||
//! case added or updated in the same commit? If not, the change is undocumented
|
//! exists so M1 implements against a fixed format rather than retrofitting one.
|
||||||
//! behaviour.
|
//! `serde::Deserialize` is deliberately not derived yet — adding serde is a
|
||||||
|
//! dependency decision (CLAUDE.md section 0 rule 3) and is deferred to M1,
|
||||||
|
//! where it arrives with `rusqlite` under one ADR entry.
|
||||||
//!
|
//!
|
||||||
//! # Why these are Rust, not JSON
|
//! # The clock-tampering exception
|
||||||
//!
|
//!
|
||||||
//! Originally specified as JSON on the reasoning that `serde_json` would already
|
//! Most cases are pure data. Clock tampering is not: proving that moving the
|
||||||
//! be in the tree. That reasoning was wrong: `rusqlite` does not pull `serde` by
|
//! system date never shortens a lock requires actually moving a clock, which a
|
||||||
//! default, and `uniffi` has not landed yet, so nothing in the graph provides it.
|
//! data file cannot express. Those cases live in [`crate::purity`] as code
|
||||||
//! `serde` is not in CLAUDE.md section 3, and section 0 rule 3 requires asking
|
//! fixtures instead, and the two coexist deliberately — the tables describe
|
||||||
//! before adding a dependency.
|
//! decisions, the fixtures describe the passage of time.
|
||||||
//!
|
|
||||||
//! A builder DSL gets the same declarations with two properties JSON cannot offer:
|
|
||||||
//! the compiler rejects a typo in a field name, and a wrong variant is a type
|
|
||||||
//! error rather than a test that quietly asserts the wrong thing. For a document
|
|
||||||
//! whose entire job is to be a contract, compile-time checking is worth more than
|
|
||||||
//! diffability.
|
|
||||||
//!
|
|
||||||
//! Converting to JSON later, if serde is approved, is mechanical — only this
|
|
||||||
//! module changes. Recorded as ADR-16.
|
|
||||||
|
|
||||||
use blocker_core::engine::{CivilTime, EngineInput, Monotonic, PlatformContext, UsageSnapshot};
|
use std::collections::BTreeMap;
|
||||||
use blocker_core::model::{
|
|
||||||
Condition, DayOfWeek, MinuteOfDay, Schedule, ScheduleMode, Target, TimeWindow, UsageWindow,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// Sentinel meaning "the platform may sleep forever" in an expectation.
|
/// One table, deserialised from one `golden/*.json` file.
|
||||||
///
|
#[derive(Debug, Clone)]
|
||||||
/// `"none"` as a string was ambiguous with a null; a distinct type is not.
|
pub struct Table {
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
/// Which concern this table covers, e.g. `overnight`.
|
||||||
pub enum Wakeup {
|
pub concern: String,
|
||||||
/// Assert nothing about the wakeup.
|
/// The cases in the table.
|
||||||
///
|
pub cases: Vec<Case>,
|
||||||
/// The default, because a case that is about blocking behaviour should not
|
|
||||||
/// fail because a window end moved. It is also the weakest option, so the
|
|
||||||
/// cases that actually carry the section 5 idle budget declare explicitly —
|
|
||||||
/// and `idle_budget_cases_declare_their_wakeup` in `run.rs` fails if one of
|
|
||||||
/// those stops doing so.
|
|
||||||
Any,
|
|
||||||
/// No timer may be armed — nothing time-dependent is pending.
|
|
||||||
Never,
|
|
||||||
/// Minutes from now, resolved against the case's civil time.
|
|
||||||
InMinutes(i64),
|
|
||||||
/// An exact civil time, for cases where the boundary is load-bearing.
|
|
||||||
At(CivilTime),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One expected blocked item, attributed to a schedule.
|
/// One case: setup, input, expected decision.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Blocked {
|
pub struct Case {
|
||||||
|
/// Human-readable, and the primary test failure message.
|
||||||
|
pub name: String,
|
||||||
|
/// Which CLAUDE.md section 7 scenario this covers, so coverage is auditable
|
||||||
|
/// rather than assumed. Checked against [`MANDATORY_COVERAGE`].
|
||||||
|
pub covers: String,
|
||||||
|
/// The schedules under test.
|
||||||
|
pub schedules: Vec<ScheduleSpec>,
|
||||||
|
/// The input to the engine.
|
||||||
|
pub input: InputSpec,
|
||||||
|
/// The decision that must result.
|
||||||
|
pub expect: ExpectSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A schedule as it appears in a table.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ScheduleSpec {
|
||||||
|
/// Stable id, referenced by `BlockedSpec::schedule`.
|
||||||
|
pub id: String,
|
||||||
|
/// Display name, shown on the block screen.
|
||||||
|
pub name: String,
|
||||||
|
/// `"Blocklist"` or `"Allowlist"`.
|
||||||
|
pub mode: String,
|
||||||
|
/// What this schedule targets.
|
||||||
|
pub targets: Vec<TargetSpec>,
|
||||||
|
/// Its conditions. At least one must hold for the schedule to be active.
|
||||||
|
pub conditions: Vec<ConditionSpec>,
|
||||||
|
/// Defaults to false when absent from JSON.
|
||||||
|
pub paused: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A target, tagged by type so the JSON is self-describing.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum TargetSpec {
|
||||||
|
/// An app, by platform-native identifier.
|
||||||
|
App {
|
||||||
|
/// Package name on Android, desktop-file id or exe path elsewhere.
|
||||||
|
app: String,
|
||||||
|
},
|
||||||
|
/// A registrable domain. Subdomains are implied.
|
||||||
|
Website {
|
||||||
|
/// e.g. `example.com`.
|
||||||
|
website: String,
|
||||||
|
},
|
||||||
|
/// A case-insensitive substring matched against the URL.
|
||||||
|
Keyword {
|
||||||
|
/// The substring to match.
|
||||||
|
keyword: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A condition, tagged by type.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ConditionSpec {
|
||||||
|
/// Block while local time is inside the window.
|
||||||
|
Time {
|
||||||
|
/// Weekdays on which the window **starts**. Monday = 0, matching
|
||||||
|
/// `time::Weekday`.
|
||||||
|
days: Vec<u8>,
|
||||||
|
/// `HH:MM`. When `start > end` the window spans midnight — there is
|
||||||
|
/// deliberately no `crosses_midnight` flag, because the comparison
|
||||||
|
/// already encodes it and a second field is a second way to be wrong.
|
||||||
|
start: String,
|
||||||
|
/// `HH:MM`. See `start`.
|
||||||
|
end: String,
|
||||||
|
},
|
||||||
|
/// Block after a foreground-time threshold.
|
||||||
|
UsageLimit {
|
||||||
|
/// Threshold in minutes.
|
||||||
|
limit_minutes: u32,
|
||||||
|
/// `"Daily"` or `"Hourly"`.
|
||||||
|
window: String,
|
||||||
|
},
|
||||||
|
/// Block after a launch-count threshold.
|
||||||
|
LaunchCount {
|
||||||
|
/// Threshold count.
|
||||||
|
limit: u32,
|
||||||
|
/// `"Daily"` or `"Hourly"`.
|
||||||
|
window: String,
|
||||||
|
},
|
||||||
|
/// Block while connected to a named network.
|
||||||
|
WifiSsid {
|
||||||
|
/// The SSID to match exactly.
|
||||||
|
ssid: String,
|
||||||
|
},
|
||||||
|
/// Block inside or outside a radius. Mobile only (section 6.4).
|
||||||
|
Location {
|
||||||
|
/// Circle centre latitude in degrees.
|
||||||
|
latitude: f64,
|
||||||
|
/// Circle centre longitude in degrees.
|
||||||
|
longitude: f64,
|
||||||
|
/// Radius in metres.
|
||||||
|
radius_meters: u32,
|
||||||
|
/// Invert the test: block when *outside* the radius. Defaults false.
|
||||||
|
block_when_outside: bool,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The engine input for a case.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct InputSpec {
|
||||||
|
/// RFC 3339 with an explicit offset, e.g. `2026-10-06T03:00:00+02:00`.
|
||||||
|
/// The offset is mandatory so a DST case is unambiguous.
|
||||||
|
pub civil_time: String,
|
||||||
|
/// Monotonic milliseconds since boot. Absent means 0.
|
||||||
|
pub monotonic_ms: u64,
|
||||||
|
/// Boot id. Absent means 0.
|
||||||
|
pub boot_id: u64,
|
||||||
|
/// Accumulated usage.
|
||||||
|
pub usage: UsageSpec,
|
||||||
|
/// Platform-observed context.
|
||||||
|
pub context: ContextSpec,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Usage counters for a case. All fields default to empty.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct UsageSpec {
|
||||||
|
/// Foreground minutes per target, daily bucket.
|
||||||
|
pub daily_minutes: BTreeMap<String, u32>,
|
||||||
|
/// Foreground minutes per target, hourly bucket.
|
||||||
|
pub hourly_minutes: BTreeMap<String, u32>,
|
||||||
|
/// Launch counts per target, daily bucket.
|
||||||
|
pub daily_launches: BTreeMap<String, u32>,
|
||||||
|
/// Launch counts per target, hourly bucket.
|
||||||
|
pub hourly_launches: BTreeMap<String, u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Platform context for a case.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct ContextSpec {
|
||||||
|
/// Current Wi-Fi SSID, if any.
|
||||||
|
pub current_ssid: Option<String>,
|
||||||
|
/// Current `(latitude, longitude)`, if any.
|
||||||
|
pub current_location: Option<(f64, f64)>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The decision a case expects.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ExpectSpec {
|
||||||
|
/// Apps that must be blocked, each attributed to a schedule.
|
||||||
|
pub blocked_apps: Vec<BlockedSpec>,
|
||||||
|
/// Websites and keywords that must be blocked.
|
||||||
|
pub blocked_domains: Vec<BlockedSpec>,
|
||||||
|
/// RFC 3339, or the literal `none` when the platform may sleep forever.
|
||||||
|
pub next_wakeup: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One expected blocked item.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct BlockedSpec {
|
||||||
/// The target that must be blocked.
|
/// The target that must be blocked.
|
||||||
pub app: String,
|
pub app: String,
|
||||||
/// The id of the schedule that must block it.
|
/// The id of the schedule that must block it.
|
||||||
pub schedule: String,
|
pub schedule: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The decision a case expects.
|
/// Every scenario in [`MANDATORY_COVERAGE`] must be claimed by at least one
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
/// `covers` field across the tables, and `coverage.rs` asserts it — so a
|
||||||
pub struct Expect {
|
/// scenario cannot silently lose its test.
|
||||||
/// Apps that must be blocked. Order-independent; the engine's union order
|
pub const MANDATORY_COVERAGE: &[&str] = &[
|
||||||
/// is not part of the contract.
|
"7:overnight time ranges",
|
||||||
pub blocked_apps: Vec<String>,
|
"7:DST changes",
|
||||||
/// Schedules expected to attribute each block. Parallel to `blocked_apps`.
|
"7:timezone changes",
|
||||||
pub blocked_by: Vec<String>,
|
"7:clock tampering",
|
||||||
/// Websites and keywords that must be blocked.
|
"7:strictest-wins",
|
||||||
pub blocked_domains: Vec<String>,
|
"7:allowlist mode",
|
||||||
/// When the platform must wake up next, if ever.
|
"7:invalid condition combos",
|
||||||
pub next_wakeup: Wakeup,
|
"7:strict mode lock rules",
|
||||||
}
|
// The section 1.2 combination table, itemised so a missing pair is visible.
|
||||||
|
"1.2:time+usage rejected",
|
||||||
impl Default for Expect {
|
"1.2:time+launch rejected",
|
||||||
fn default() -> Self {
|
"1.2:usage+launch rejected",
|
||||||
Self {
|
"1.2:usage+location rejected",
|
||||||
blocked_apps: Vec::new(),
|
"1.2:launch+location rejected",
|
||||||
blocked_by: Vec::new(),
|
// Section 1.5.4 safety guarantees.
|
||||||
blocked_domains: Vec::new(),
|
"1.5.4:never-block list is unblockable",
|
||||||
next_wakeup: Wakeup::Any,
|
"1.5.4:worst case 24h",
|
||||||
}
|
];
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A case: setup, input, expected decision.
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Case {
|
|
||||||
/// Human-readable, and the primary failure message.
|
|
||||||
pub name: String,
|
|
||||||
/// Which CLAUDE.md section 7 scenario this covers. Checked by
|
|
||||||
/// [`coverage`] against the mandatory list.
|
|
||||||
pub covers: &'static str,
|
|
||||||
/// Schedules under test.
|
|
||||||
pub schedules: Vec<Schedule>,
|
|
||||||
/// Input civil time.
|
|
||||||
pub now: CivilTime,
|
|
||||||
/// Monotonic milliseconds since boot.
|
|
||||||
pub monotonic: Monotonic,
|
|
||||||
/// Boot id.
|
|
||||||
pub boot_id: u64,
|
|
||||||
/// Usage counters.
|
|
||||||
pub usage: UsageSnapshot,
|
|
||||||
/// Platform-observed context.
|
|
||||||
pub context: PlatformContext,
|
|
||||||
/// Apps that exist on the machine, for allowlist cases.
|
|
||||||
pub installed_apps: Vec<String>,
|
|
||||||
/// The decision that must result.
|
|
||||||
pub expect: Expect,
|
|
||||||
/// Set when the case asserts a validation error instead of a decision.
|
|
||||||
/// Section 1.2 combination cases cannot produce a decision, because
|
|
||||||
/// `Schedule::validate` rejects them first.
|
|
||||||
pub expect_validation_error: bool,
|
|
||||||
/// The platform under test, for validation.
|
|
||||||
pub platform: blocker_core::model::Platform,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Case {
|
|
||||||
/// A case with defaults: no usage, no context, no installed apps, boot 0.
|
|
||||||
pub fn new(name: &str, covers: &'static str) -> Self {
|
|
||||||
Self {
|
|
||||||
name: name.to_owned(),
|
|
||||||
covers,
|
|
||||||
schedules: Vec::new(),
|
|
||||||
now: CivilTime { epoch_minutes: 0, utc_offset_minutes: 0 },
|
|
||||||
monotonic: Monotonic(0),
|
|
||||||
boot_id: 0,
|
|
||||||
usage: UsageSnapshot::default(),
|
|
||||||
context: PlatformContext::default(),
|
|
||||||
installed_apps: Vec::new(),
|
|
||||||
expect: Expect::default(),
|
|
||||||
expect_validation_error: false,
|
|
||||||
platform: blocker_core::model::Platform::Android,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_schedules(mut self, schedules: Vec<Schedule>) -> Self {
|
|
||||||
self.schedules = schedules;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn at(mut self, days_since_epoch: i64, hour: u32, minute: u32) -> Self {
|
|
||||||
self.now = local(days_since_epoch, hour, minute);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_offset(mut self, minutes: i32) -> Self {
|
|
||||||
self.now.utc_offset_minutes = minutes;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_ssid(mut self, ssid: &str) -> Self {
|
|
||||||
self.context.current_ssid = Some(ssid.to_owned());
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_location(mut self, lat: f64, lon: f64) -> Self {
|
|
||||||
self.context.current_location = Some((lat, lon));
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_installed(mut self, apps: &[&str]) -> Self {
|
|
||||||
self.installed_apps = apps.iter().map(|s| (*s).to_owned()).collect();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_daily_minutes(mut self, target: &str, minutes: u32) -> Self {
|
|
||||||
self.usage.daily_minutes.insert(target.to_owned(), minutes);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn with_daily_launches(mut self, target: &str, count: u32) -> Self {
|
|
||||||
self.usage.daily_launches.insert(target.to_owned(), count);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn on_platform(mut self, platform: blocker_core::model::Platform) -> Self {
|
|
||||||
self.platform = platform;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Expect apps blocked, each attributed to the named schedule.
|
|
||||||
pub fn blocks(mut self, pairs: &[(&str, &str)]) -> Self {
|
|
||||||
for (app, schedule) in pairs {
|
|
||||||
self.expect.blocked_apps.push((*app).to_owned());
|
|
||||||
self.expect.blocked_by.push((*schedule).to_owned());
|
|
||||||
}
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn blocks_domains(mut self, domains: &[&str]) -> Self {
|
|
||||||
self.expect.blocked_domains = domains.iter().map(|s| (*s).to_owned()).collect();
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wakes_in(mut self, minutes: i64) -> Self {
|
|
||||||
self.expect.next_wakeup = Wakeup::InMinutes(minutes);
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Expect no timer at all — the zero-idle-wakeup budget, made testable.
|
|
||||||
pub fn never_wakes(mut self) -> Self {
|
|
||||||
self.expect.next_wakeup = Wakeup::Never;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Expect `Schedule::validate` to reject this case's schedules.
|
|
||||||
pub fn invalid(mut self) -> Self {
|
|
||||||
self.expect_validation_error = true;
|
|
||||||
self
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Evaluate this case and return a human-readable description of any
|
|
||||||
/// mismatch, or `None` when it passes.
|
|
||||||
pub fn check(&self) -> Option<String> {
|
|
||||||
if self.expect_validation_error {
|
|
||||||
for s in &self.schedules {
|
|
||||||
if s.validate(self.platform).is_ok() {
|
|
||||||
return Some(format!(
|
|
||||||
"schedule {:?} was expected to be rejected by section 1.2 \
|
|
||||||
validation, but validated cleanly",
|
|
||||||
s.id
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
let input = EngineInput {
|
|
||||||
now: self.now,
|
|
||||||
monotonic: self.monotonic,
|
|
||||||
boot_id: blocker_core::engine::BootId(self.boot_id),
|
|
||||||
schedules: &self.schedules,
|
|
||||||
usage: &self.usage,
|
|
||||||
context: &self.context,
|
|
||||||
installed_apps: &self.installed_apps,
|
|
||||||
};
|
|
||||||
let decision = blocker_core::evaluate(&input);
|
|
||||||
|
|
||||||
let mut actual: Vec<(String, String)> =
|
|
||||||
decision.blocked_apps.iter().map(|r| (r.app_key(), r.schedule_id.clone())).collect();
|
|
||||||
actual.sort();
|
|
||||||
|
|
||||||
let mut wanted: Vec<(String, String)> = self
|
|
||||||
.expect
|
|
||||||
.blocked_apps
|
|
||||||
.iter()
|
|
||||||
.cloned()
|
|
||||||
.zip(self.expect.blocked_by.iter().cloned())
|
|
||||||
.collect();
|
|
||||||
wanted.sort();
|
|
||||||
|
|
||||||
if actual != wanted {
|
|
||||||
return Some(format!(
|
|
||||||
"blocked apps differ\n expected: {wanted:?}\n actual: {actual:?}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut actual_domains: Vec<String> =
|
|
||||||
decision.blocked_domains.iter().map(|r| r.app_key()).collect();
|
|
||||||
actual_domains.sort();
|
|
||||||
let mut wanted_domains = self.expect.blocked_domains.clone();
|
|
||||||
wanted_domains.sort();
|
|
||||||
if actual_domains != wanted_domains {
|
|
||||||
return Some(format!(
|
|
||||||
"blocked domains differ\n expected: {wanted_domains:?}\n actual: {actual_domains:?}"
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
match (self.expect.next_wakeup, decision.next_wakeup) {
|
|
||||||
(Wakeup::Any, _) => None,
|
|
||||||
(Wakeup::Never, None) => None,
|
|
||||||
(Wakeup::Never, Some(t)) => Some(format!(
|
|
||||||
"expected no wakeup (the platform should be able to sleep forever), \
|
|
||||||
but the engine asked for one at epoch_minute {}",
|
|
||||||
t.epoch_minutes
|
|
||||||
)),
|
|
||||||
(Wakeup::InMinutes(m), Some(t)) => {
|
|
||||||
let delta = t.epoch_minutes - self.now.epoch_minutes;
|
|
||||||
if delta == m {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(format!(
|
|
||||||
"next_wakeup differs: expected {m} minutes from now, \
|
|
||||||
engine asked for {delta}"
|
|
||||||
))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(Wakeup::InMinutes(m), None) => Some(format!(
|
|
||||||
"expected a wakeup {m} minutes from now, but the engine asked for none — \
|
|
||||||
a missing boundary means a schedule would silently never fire"
|
|
||||||
)),
|
|
||||||
(Wakeup::At(expected), Some(t)) => {
|
|
||||||
if t == expected {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(format!("next_wakeup differs: expected {expected:?}, got {t:?}"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
(Wakeup::At(_), None) => Some("expected a specific wakeup, got none".to_owned()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build a [`CivilTime`] from a local date and `HH:MM`.
|
|
||||||
///
|
|
||||||
/// `days_since_epoch` is days since 1970-01-01 with the local offset already
|
|
||||||
/// folded out, so tests can name a calendar date and reason about its weekday.
|
|
||||||
pub fn local(days_since_epoch: i64, hour: u32, minute: u32) -> CivilTime {
|
|
||||||
CivilTime {
|
|
||||||
epoch_minutes: days_since_epoch * 24 * 60 + i64::from(hour * 60 + minute),
|
|
||||||
utc_offset_minutes: 0,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Schedule builders ---------------------------------------------------
|
|
||||||
//
|
|
||||||
// Verb constructors rather than struct literals: a table is read far more often
|
|
||||||
// than it is edited, and `blocklist("s1", "Focus", &[app("discord")], &[time(...)])`
|
|
||||||
// says more than a five-field struct literal does.
|
|
||||||
|
|
||||||
pub fn blocklist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule {
|
|
||||||
Schedule {
|
|
||||||
id: id.to_owned(),
|
|
||||||
name: name.to_owned(),
|
|
||||||
icon: None,
|
|
||||||
mode: ScheduleMode::Blocklist,
|
|
||||||
targets: targets.to_vec(),
|
|
||||||
conditions: conditions.to_vec(),
|
|
||||||
paused: false,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn allowlist(id: &str, name: &str, targets: &[Target], conditions: &[Condition]) -> Schedule {
|
|
||||||
Schedule { mode: ScheduleMode::Allowlist, ..blocklist(id, name, targets, conditions) }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn app(id: &str) -> Target {
|
|
||||||
Target::App(id.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn website(domain: &str) -> Target {
|
|
||||||
Target::Website(domain.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn keyword(word: &str) -> Target {
|
|
||||||
Target::Keyword(word.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A time window. `start > end` means it spans midnight.
|
|
||||||
pub fn time(days: &[DayOfWeek], start: MinuteOfDay, end: MinuteOfDay) -> Condition {
|
|
||||||
Condition::Time(TimeWindow { days: days.to_vec(), start, end })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Monday through Friday, which most work-hours schedules use.
|
|
||||||
pub const WEEKDAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4];
|
|
||||||
/// Every day of the week.
|
|
||||||
pub const ALL_DAYS: &[DayOfWeek] = &[0, 1, 2, 3, 4, 5, 6];
|
|
||||||
|
|
||||||
/// `HH:MM` to minutes since midnight, for readability in a table.
|
|
||||||
pub fn at(hour: u32, minute: u32) -> MinuteOfDay {
|
|
||||||
let total = hour * 60 + minute;
|
|
||||||
u16::try_from(total).unwrap_or(MinuteOfDay::MAX)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn usage_limit(limit_minutes: u32, window: UsageWindow) -> Condition {
|
|
||||||
Condition::UsageLimit { limit_minutes, window }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn launch_count(limit: u32, window: UsageWindow) -> Condition {
|
|
||||||
Condition::LaunchCount { limit, window }
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn wifi(ssid: &str) -> Condition {
|
|
||||||
Condition::WifiSsid(ssid.to_owned())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn location(lat: f64, lon: f64, radius_m: u32, block_when_outside: bool) -> Condition {
|
|
||||||
Condition::Location {
|
|
||||||
latitude: lat,
|
|
||||||
longitude: lon,
|
|
||||||
radius_meters: radius_m,
|
|
||||||
block_when_outside,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every case, across every table.
|
|
||||||
///
|
|
||||||
/// The runner asserts that this is the same set the individual tables produce,
|
|
||||||
/// so a case added to a table but not collected here cannot silently go
|
|
||||||
/// unrun.
|
|
||||||
pub fn all() -> Vec<Case> {
|
|
||||||
crate::tables::overnight_cases()
|
|
||||||
.into_iter()
|
|
||||||
.chain(crate::tables::timezone_cases())
|
|
||||||
.chain(crate::tables::strictest_wins_cases())
|
|
||||||
.chain(crate::tables::allowlist_cases())
|
|
||||||
.chain(crate::tables::condition_guard_cases())
|
|
||||||
.chain(crate::tables::usage_and_launch_cases())
|
|
||||||
.chain(crate::tables::wifi_and_location_cases())
|
|
||||||
.chain(crate::tables::wakeup_cases())
|
|
||||||
.chain(crate::tables::never_block_cases())
|
|
||||||
.chain(crate::tables::lock_rule_cases())
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|||||||
+16
-21
@@ -5,38 +5,33 @@
|
|||||||
//!
|
//!
|
||||||
//! # Why this is a contract, not a test suite
|
//! # Why this is a contract, not a test suite
|
||||||
//!
|
//!
|
||||||
//! Unit tests check the engine against itself. These check that *every platform*
|
//! Section 7 requires that the same table runs against the core and serves as
|
||||||
//! agrees with the engine. Android, Linux and Windows each map their own app
|
//! the parity contract for all platforms. Android, Linux and Windows each
|
||||||
//! identity — package name, desktop-file id, exe path — onto a
|
//! implement a mapping from a platform app identity to a `Target`, but they all
|
||||||
//! [`blocker_core::model::Target`], and every case here runs through that mapping
|
//! hand the same [`blocker_core`] engine the same schedule set and get back the
|
||||||
//! too. If Linux resolves Discord to a different target than Android does, a case
|
//! same decision. A platform crate runs this table against its own mapping, so
|
||||||
//! fails at build time instead of becoming a bug report from whichever platform
|
//! if Linux disagrees with Android the build fails rather than the user filing
|
||||||
//! nobody was testing that week.
|
//! a bug.
|
||||||
//!
|
//!
|
||||||
//! # The rule that keeps it honest
|
//! # The rule that keeps it honest
|
||||||
//!
|
//!
|
||||||
//! **A case is added before the engine changes.** A table written after the code
|
//! **A case is added before the engine changes.** If a table were written after
|
||||||
//! records current behaviour and can never catch a regression — it would be a
|
//! the code, it would only record current behaviour and could never catch a
|
||||||
//! transcript, not a specification. Reviewing a PR that changes engine behaviour
|
//! regression — it would be a transcript, not a specification. Reviewing a PR
|
||||||
//! therefore means checking that a case was added or updated in the same commit.
|
//! that changes engine behaviour therefore means checking that a case was added
|
||||||
|
//! or updated in the same commit.
|
||||||
//!
|
//!
|
||||||
//! # Layout
|
//! # Layout
|
||||||
//!
|
//!
|
||||||
//! - [`golden`] — the case format, the harness, and the builder DSL
|
//! - `golden/` — the tables themselves, one JSON file per concern
|
||||||
//! - [`tables`] — the cases themselves, grouped by scenario
|
//! - [`purity`] — a mechanical check that the core has not gained I/O
|
||||||
//! - [`coverage`] — which CLAUDE.md scenario each case claims, and the check that
|
//!
|
||||||
//! nothing went uncovered
|
//! The mandatory case list is in [`golden::MANDATORY_COVERAGE`].
|
||||||
//! - [`purity`] — a mechanical check that the core has not gained I/O, plus the
|
|
||||||
//! clock-tampering fixtures that cannot be expressed as data
|
|
||||||
//!
|
//!
|
||||||
//! [`purity`] is `cfg(test)` because its checks are `#[test]` functions in this
|
//! [`purity`] is `cfg(test)` because its checks are `#[test]` functions in this
|
||||||
//! library's unit-test build, not library API. They run under `cargo test`.
|
//! library's unit-test build, not library API. They run under `cargo test`.
|
||||||
|
|
||||||
pub mod coverage;
|
|
||||||
pub mod golden;
|
pub mod golden;
|
||||||
pub mod tables;
|
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod purity;
|
mod purity;
|
||||||
#[cfg(test)]
|
|
||||||
mod run;
|
|
||||||
|
|||||||
@@ -1,147 +0,0 @@
|
|||||||
#![allow(
|
|
||||||
clippy::unwrap_used,
|
|
||||||
clippy::expect_used,
|
|
||||||
clippy::panic,
|
|
||||||
reason = "golden-table runner: assert-and-continue reporting is the point"
|
|
||||||
)]
|
|
||||||
|
|
||||||
//! The golden-table runner.
|
|
||||||
//!
|
|
||||||
//! Every case is evaluated against the engine and any mismatch is reported with
|
|
||||||
//! enough context to diagnose without re-running: what was expected, what
|
|
||||||
//! happened, and — for wakeups — why the difference matters.
|
|
||||||
|
|
||||||
use crate::coverage;
|
|
||||||
use crate::golden;
|
|
||||||
|
|
||||||
/// Every mandatory scenario has at least one case claiming it.
|
|
||||||
#[test]
|
|
||||||
fn every_required_scenario_is_covered() {
|
|
||||||
let uncovered = coverage::uncovered();
|
|
||||||
assert!(
|
|
||||||
uncovered.is_empty(),
|
|
||||||
"these CLAUDE.md section 7 scenarios have no golden case: {uncovered:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// No case claims a scenario that is not on the mandatory list.
|
|
||||||
#[test]
|
|
||||||
fn no_case_claims_an_unknown_scenario() {
|
|
||||||
let unknown = coverage::unknown_claims();
|
|
||||||
assert!(
|
|
||||||
unknown.is_empty(),
|
|
||||||
"these `covers` tags are not in coverage::REQUIRED — a typo here means a \
|
|
||||||
case is testing something unnamed, which is how coverage quietly rots: {unknown:?}"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Every case passes.
|
|
||||||
///
|
|
||||||
/// Written as one test with all failures reported rather than one test per case:
|
|
||||||
/// a table that has drifted usually drifts in several places at once, and fixing
|
|
||||||
/// them one rebuild at a time hides the pattern.
|
|
||||||
#[test]
|
|
||||||
fn every_golden_case_passes() {
|
|
||||||
let cases = golden::all();
|
|
||||||
assert!(!cases.is_empty(), "the table set must not be empty");
|
|
||||||
|
|
||||||
let mut failures = Vec::new();
|
|
||||||
for case in &cases {
|
|
||||||
if let Some(reason) = case.check() {
|
|
||||||
failures.push(format!(" [{}] {}\n{reason}", case.covers, case.name));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
assert!(
|
|
||||||
failures.is_empty(),
|
|
||||||
"{} of {} golden cases failed:\n{}",
|
|
||||||
failures.len(),
|
|
||||||
cases.len(),
|
|
||||||
failures.join("\n\n")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Case names are unique, so a failure message identifies exactly one case.
|
|
||||||
#[test]
|
|
||||||
fn case_names_are_unique() {
|
|
||||||
let mut seen = std::collections::BTreeMap::new();
|
|
||||||
for case in golden::all() {
|
|
||||||
let key = case.name.clone();
|
|
||||||
let n = seen.entry(key).or_insert(0);
|
|
||||||
*n += 1;
|
|
||||||
}
|
|
||||||
let dupes: Vec<&String> = seen.iter().filter(|(_, n)| **n > 1).map(|(k, _)| k).collect();
|
|
||||||
assert!(dupes.is_empty(), "duplicate case names make failure messages ambiguous: {dupes:?}");
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The fixture list in `coverage` still matches reality.
|
|
||||||
///
|
|
||||||
/// `FIXTURES` is maintained by hand, so a rename that misses it would silently
|
|
||||||
/// drop the coverage claim. Rather than re-parsing the source, this checks the
|
|
||||||
/// shapes those fixtures are responsible for still hold.
|
|
||||||
#[test]
|
|
||||||
fn fixture_backed_scenarios_are_still_claimed() {
|
|
||||||
use blocker_core::lock::{LockLevel, MAX_WAIT_MS};
|
|
||||||
use blocker_core::model::{Schedule, ScheduleMode, Target};
|
|
||||||
|
|
||||||
// The 24-hour cap, asserted directly rather than trusting the fixture list.
|
|
||||||
for level in [LockLevel::Normal, LockLevel::Strict, LockLevel::Stubborn] {
|
|
||||||
let state = blocker_core::lock::LockState {
|
|
||||||
level,
|
|
||||||
schedules_locked: false,
|
|
||||||
pending: None,
|
|
||||||
cooldown_remaining: None,
|
|
||||||
attempts_today: 0,
|
|
||||||
keyholder_code: None,
|
|
||||||
};
|
|
||||||
assert!(state.worst_case_wait() <= MAX_WAIT_MS, "{level:?} exceeds the section 1.5.4 cap");
|
|
||||||
}
|
|
||||||
|
|
||||||
// And the never-block guarantee, restated here so removing the corresponding
|
|
||||||
// fixture cannot quietly drop the guarantee it protects.
|
|
||||||
assert!(
|
|
||||||
blocker_core::never_block::is_never_blocked("com.android.dialer")
|
|
||||||
|| blocker_core::never_block::is_never_blocked("plasmashell"),
|
|
||||||
"the never-block list lost its entries"
|
|
||||||
);
|
|
||||||
|
|
||||||
let _ = Schedule {
|
|
||||||
id: "unused".into(),
|
|
||||||
name: "unused".into(),
|
|
||||||
icon: None,
|
|
||||||
mode: ScheduleMode::Blocklist,
|
|
||||||
targets: vec![Target::App("x".into())],
|
|
||||||
conditions: vec![],
|
|
||||||
paused: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The cases that carry the section 5 idle budget must declare their wakeup.
|
|
||||||
///
|
|
||||||
/// `Wakeup::Any` is the default for a case that is about blocking behaviour, so
|
|
||||||
/// a missing wakeup assertion is not an error in general. It *is* an error for
|
|
||||||
/// the cases below: they exist to prove that the daemon sleeps, and a case that
|
|
||||||
/// silently stops asserting its wakeup is a lost guarantee rather than an
|
|
||||||
/// untested one. Named explicitly so deleting one is a deliberate act.
|
|
||||||
#[test]
|
|
||||||
fn idle_budget_cases_declare_their_wakeup() {
|
|
||||||
use crate::golden::Wakeup;
|
|
||||||
|
|
||||||
const MUST_ASSERT_WAKEUP: &[&str] = &[
|
|
||||||
"wakeup: a Wi-Fi schedule arms no timer, only an OS event can change it",
|
|
||||||
"wakeup: an inactive schedule still arms one for when it starts",
|
|
||||||
"start == end is a zero-length window, so it blocks nothing",
|
|
||||||
];
|
|
||||||
|
|
||||||
let cases = golden::all();
|
|
||||||
for name in MUST_ASSERT_WAKEUP {
|
|
||||||
let case = cases
|
|
||||||
.iter()
|
|
||||||
.find(|c| c.name == *name)
|
|
||||||
.unwrap_or_else(|| panic!("the idle-budget case {name:?} was deleted"));
|
|
||||||
assert_ne!(
|
|
||||||
case.expect.next_wakeup,
|
|
||||||
Wakeup::Any,
|
|
||||||
"{name:?} no longer asserts a wakeup, which is the whole point of it"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,588 +0,0 @@
|
|||||||
//! The golden cases themselves.
|
|
||||||
//!
|
|
||||||
//! Grouped by the CLAUDE.md section 7 scenario each group covers, so a table
|
|
||||||
//! reads as a description of behaviour rather than as an undifferentiated list.
|
|
||||||
//!
|
|
||||||
//! # Calendar anchors
|
|
||||||
//!
|
|
||||||
//! `MON` = 20731 = 2026-10-05, a Monday. Every date here is derived from that
|
|
||||||
//! one, and `weekday_indexing_is_monday_zero` in `blocker-core` asserts the
|
|
||||||
//! anchor itself. If weekday arithmetic breaks, that test fails and the rest of
|
|
||||||
//! these stop meaning what their names claim.
|
|
||||||
|
|
||||||
// Table modules are test surface: a missing case name or an empty never-block
|
|
||||||
// list means a broken test, not a runtime condition to handle gracefully.
|
|
||||||
#![allow(
|
|
||||||
clippy::unwrap_used,
|
|
||||||
clippy::expect_used,
|
|
||||||
clippy::panic,
|
|
||||||
reason = "golden-table definitions: a missing case is a broken test, not a \
|
|
||||||
runtime condition to handle gracefully"
|
|
||||||
)]
|
|
||||||
|
|
||||||
use crate::golden::*;
|
|
||||||
use blocker_core::model::{Condition, Schedule, UsageWindow};
|
|
||||||
|
|
||||||
// 2026-10-05 is a Monday.
|
|
||||||
const MON: i64 = 20_731;
|
|
||||||
const TUE: i64 = MON + 1;
|
|
||||||
const SAT: i64 = MON + 5;
|
|
||||||
const SUN: i64 = MON + 6;
|
|
||||||
|
|
||||||
/// Section 7: overnight time ranges.
|
|
||||||
///
|
|
||||||
/// The case that matters is the early hours of the morning after: a window
|
|
||||||
/// starting Monday 22:00 must still be active at 03:00 **Tuesday**, governed by
|
|
||||||
/// the weekday it *started* on. A naive membership check reads 03:00 as Tuesday,
|
|
||||||
/// finds Tuesday absent, and a nightly schedule silently dies at midnight.
|
|
||||||
pub fn overnight_cases() -> Vec<Case> {
|
|
||||||
let nightly =
|
|
||||||
blocklist("s1", "Night", &[app("com.example.social")], &[time(&[0], at(22, 0), at(6, 0))]);
|
|
||||||
|
|
||||||
vec![
|
|
||||||
Case::new("overnight: active at 23:00 on the starting day", "7:overnight time ranges")
|
|
||||||
.with_schedules(vec![nightly.clone()])
|
|
||||||
.at(MON, 23, 0)
|
|
||||||
.blocks(&[("com.example.social", "s1")])
|
|
||||||
.wakes_in(7 * 60),
|
|
||||||
Case::new(
|
|
||||||
"overnight: STILL active at 03:00, governed by the day it started",
|
|
||||||
"7:overnight time ranges",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![nightly.clone()])
|
|
||||||
.at(TUE, 3, 0)
|
|
||||||
.blocks(&[("com.example.social", "s1")])
|
|
||||||
.wakes_in(3 * 60),
|
|
||||||
Case::new(
|
|
||||||
"overnight: the end is exclusive at 06:00, and the next boundary is next Monday",
|
|
||||||
"7:overnight time ranges",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![nightly.clone()])
|
|
||||||
.at(TUE, 6, 0)
|
|
||||||
.blocks(&[])
|
|
||||||
// Monday 22:00 from Tuesday 06:00 is 6 days 16 hours.
|
|
||||||
.wakes_in(9600),
|
|
||||||
Case::new(
|
|
||||||
"overnight: must NOT leak into a day that did not start it",
|
|
||||||
"7:overnight time ranges",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![nightly])
|
|
||||||
.at(MON + 2, 3, 0),
|
|
||||||
Case::new(
|
|
||||||
"overnight across the week boundary: Sunday 22:00 covers Monday 02:00",
|
|
||||||
"7:overnight time ranges",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Sunday night",
|
|
||||||
&[app("com.example.social")],
|
|
||||||
&[time(&[6], at(22, 0), at(6, 0))],
|
|
||||||
)])
|
|
||||||
.at(SUN, 23, 0)
|
|
||||||
.blocks(&[("com.example.social", "s1")]),
|
|
||||||
Case::new(
|
|
||||||
"a window starting exactly at midnight is a full day, not an empty one",
|
|
||||||
"7:overnight time ranges",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"All day",
|
|
||||||
&[app("com.example.social")],
|
|
||||||
&[time(ALL_DAYS, 0, at(23, 59))],
|
|
||||||
)])
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[("com.example.social", "s1")]),
|
|
||||||
Case::new(
|
|
||||||
"start == end is a zero-length window, so it blocks nothing",
|
|
||||||
"7:overnight time ranges",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Degenerate",
|
|
||||||
&[app("com.example.social")],
|
|
||||||
&[time(ALL_DAYS, at(9, 0), at(9, 0))],
|
|
||||||
)])
|
|
||||||
.at(MON, 9, 0)
|
|
||||||
// A window that can never match must not arm a timer either: a wakeup
|
|
||||||
// whose only purpose is to discover the window still does not match is a
|
|
||||||
// wakeup spent on nothing, and this section 5 budget is zero.
|
|
||||||
.never_wakes(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section 7: timezone changes and DST.
|
|
||||||
///
|
|
||||||
/// **Partial coverage, stated plainly.** `CivilTime` is an epoch value plus a UTC
|
|
||||||
/// offset — there is no IANA timezone database behind it, because the `time`
|
|
||||||
/// crate is not a section 3 dependency. So these cases pin the *observable*
|
|
||||||
/// rule, which is that a fixed local window stays fixed as the offset moves,
|
|
||||||
/// including across a DST transition. What cannot be expressed yet is a local
|
|
||||||
/// time that **does not exist** because the clock sprang forward, or is ambiguous
|
|
||||||
/// because it fell back. That gap is recorded in `docs/LIMITATIONS.md` and is
|
|
||||||
/// the strongest argument for adding a timezone-aware time type.
|
|
||||||
pub fn timezone_cases() -> Vec<Case> {
|
|
||||||
let work = blocklist(
|
|
||||||
"s1",
|
|
||||||
"Work hours",
|
|
||||||
&[app("com.example.social")],
|
|
||||||
&[time(WEEKDAYS, at(9, 0), at(17, 0))],
|
|
||||||
);
|
|
||||||
|
|
||||||
vec![
|
|
||||||
Case::new(
|
|
||||||
"timezone: 10:00 UTC is 12:00 local at UTC+2, so inside 09:00-17:00",
|
|
||||||
"7:timezone changes",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![work.clone()])
|
|
||||||
.at(MON, 10, 0)
|
|
||||||
.with_offset(120)
|
|
||||||
.blocks(&[("com.example.social", "s1")]),
|
|
||||||
Case::new(
|
|
||||||
"timezone: 10:00 UTC at UTC+5 is 15:00 local, still inside 09:00-17:00",
|
|
||||||
"7:timezone changes",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![work.clone()])
|
|
||||||
.at(MON, 10, 0)
|
|
||||||
.with_offset(300)
|
|
||||||
.blocks(&[("com.example.social", "s1")]),
|
|
||||||
Case::new(
|
|
||||||
"timezone: 10:00 UTC at UTC+7 is 17:00 local, the exclusive end",
|
|
||||||
"7:timezone changes",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![work.clone()])
|
|
||||||
.at(MON, 10, 0)
|
|
||||||
.with_offset(420),
|
|
||||||
Case::new("timezone: a negative offset shifts the other way", "7:timezone changes")
|
|
||||||
.with_schedules(vec![work.clone()])
|
|
||||||
.at(MON, 10, 0)
|
|
||||||
.with_offset(-300),
|
|
||||||
Case::new(
|
|
||||||
"DST: 10:00 UTC stays inside 09:00-17:00 across a +1h spring-forward",
|
|
||||||
"7:DST changes",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![work.clone()])
|
|
||||||
.at(MON, 10, 0)
|
|
||||||
.with_offset(60)
|
|
||||||
.blocks(&[("com.example.social", "s1")]),
|
|
||||||
Case::new(
|
|
||||||
"DST: a UTC-anchored window tracks the offset, not the wall clock",
|
|
||||||
"7:DST changes",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![work.clone()])
|
|
||||||
.at(MON, 3, 0)
|
|
||||||
.with_offset(-180),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section 7: strictest-wins.
|
|
||||||
///
|
|
||||||
/// An app named by several schedules is blocked, and each reason names the
|
|
||||||
/// schedule responsible — which is what lets the block screen say *why* rather
|
|
||||||
/// than the UI guessing.
|
|
||||||
pub fn strictest_wins_cases() -> Vec<Case> {
|
|
||||||
let work =
|
|
||||||
blocklist("s1", "Work", &[app("com.example.chat")], &[time(WEEKDAYS, at(9, 0), at(17, 0))]);
|
|
||||||
let bedtime = blocklist(
|
|
||||||
"s2",
|
|
||||||
"Bedtime",
|
|
||||||
&[app("com.example.chat"), app("com.example.video")],
|
|
||||||
&[time(ALL_DAYS, at(22, 0), at(7, 0))],
|
|
||||||
);
|
|
||||||
|
|
||||||
vec![
|
|
||||||
Case::new("strictest-wins: only the active schedule is attributed", "7:strictest-wins")
|
|
||||||
.with_schedules(vec![work, bedtime])
|
|
||||||
.at(MON, 23, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s2"), ("com.example.video", "s2")])
|
|
||||||
.wakes_in(8 * 60),
|
|
||||||
Case::new("strictest-wins: a daytime schedule blocks on its own", "7:strictest-wins")
|
|
||||||
.with_schedules(vec![
|
|
||||||
blocklist(
|
|
||||||
"s1",
|
|
||||||
"Work",
|
|
||||||
&[app("com.example.chat")],
|
|
||||||
&[time(WEEKDAYS, at(9, 0), at(17, 0))],
|
|
||||||
),
|
|
||||||
blocklist(
|
|
||||||
"s2",
|
|
||||||
"Bedtime",
|
|
||||||
&[app("com.example.chat")],
|
|
||||||
&[time(ALL_DAYS, at(22, 0), at(7, 0))],
|
|
||||||
),
|
|
||||||
])
|
|
||||||
.at(MON, 10, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")]),
|
|
||||||
Case::new(
|
|
||||||
"strictest-wins: the union means adding a schedule never unblocks",
|
|
||||||
"7:strictest-wins",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Always",
|
|
||||||
&[app("com.example.chat")],
|
|
||||||
&[time(ALL_DAYS, 0, at(23, 59))],
|
|
||||||
)])
|
|
||||||
.at(MON, 10, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")]),
|
|
||||||
Case::new("a paused schedule contributes nothing at all", "7:strictest-wins")
|
|
||||||
.with_schedules(vec![Schedule {
|
|
||||||
paused: true,
|
|
||||||
..blocklist(
|
|
||||||
"s1",
|
|
||||||
"Paused",
|
|
||||||
&[app("com.example.chat")],
|
|
||||||
&[time(ALL_DAYS, 0, at(23, 59))],
|
|
||||||
)
|
|
||||||
}])
|
|
||||||
.at(MON, 10, 0),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section 7: allowlist mode.
|
|
||||||
///
|
|
||||||
/// Allowlist blocks the *complement* of its own list, which needs the set of
|
|
||||||
/// installed apps. An empty inventory must fail open: a machine the engine knows
|
|
||||||
/// nothing about is not a machine it should act on.
|
|
||||||
pub fn allowlist_cases() -> Vec<Case> {
|
|
||||||
vec![
|
|
||||||
Case::new("allowlist: blocks everything installed that is not listed", "7:allowlist mode")
|
|
||||||
.with_schedules(vec![allowlist(
|
|
||||||
"s1",
|
|
||||||
"Only mail",
|
|
||||||
&[app("com.example.mail")],
|
|
||||||
&[time(ALL_DAYS, 0, at(23, 59))],
|
|
||||||
)])
|
|
||||||
.with_installed(&["com.example.mail", "com.example.chat", "com.example.browser"])
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[("com.example.browser", "s1"), ("com.example.chat", "s1")]),
|
|
||||||
Case::new("allowlist with no inventory blocks nothing, failing open", "7:allowlist mode")
|
|
||||||
.with_schedules(vec![allowlist(
|
|
||||||
"s1",
|
|
||||||
"Only mail",
|
|
||||||
&[app("com.example.mail")],
|
|
||||||
&[time(ALL_DAYS, 0, at(23, 59))],
|
|
||||||
)])
|
|
||||||
.at(MON, 12, 0),
|
|
||||||
Case::new(
|
|
||||||
"allowlist never blocks a never-blocked app even if unlisted",
|
|
||||||
"1.5.4:never-block list is unblockable",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![allowlist(
|
|
||||||
"s1",
|
|
||||||
"Only mail",
|
|
||||||
&[app("com.example.mail")],
|
|
||||||
&[time(ALL_DAYS, 0, at(23, 59))],
|
|
||||||
)])
|
|
||||||
.with_installed(&["com.example.mail", "plasmashell", "explorer.exe"])
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[]),
|
|
||||||
Case::new(
|
|
||||||
"an allowlist naming a website is rejected — allowlists are apps-only",
|
|
||||||
"7:invalid condition combos",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![allowlist(
|
|
||||||
"s1",
|
|
||||||
"Broken",
|
|
||||||
&[website("example.com")],
|
|
||||||
&[time(ALL_DAYS, 0, at(23, 59))],
|
|
||||||
)])
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.invalid(),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section 1.2: the forbidden condition combinations, each one its own case so a
|
|
||||||
/// missing pair is visible rather than hidden inside a combined assertion.
|
|
||||||
pub fn condition_guard_cases() -> Vec<Case> {
|
|
||||||
let t = time(WEEKDAYS, at(9, 0), at(17, 0));
|
|
||||||
let u = usage_limit(30, UsageWindow::Daily);
|
|
||||||
let l = launch_count(3, UsageWindow::Daily);
|
|
||||||
let loc = location(52.52, 13.405, 500, false);
|
|
||||||
let targets = [app("com.example.chat")];
|
|
||||||
|
|
||||||
let pairs: &[(&'static str, Condition, Condition)] = &[
|
|
||||||
("1.2:time+usage rejected", t.clone(), u.clone()),
|
|
||||||
("1.2:time+launch rejected", t.clone(), l.clone()),
|
|
||||||
("1.2:usage+launch rejected", u.clone(), l.clone()),
|
|
||||||
("1.2:usage+location rejected", u, loc.clone()),
|
|
||||||
("1.2:launch+location rejected", l, loc),
|
|
||||||
];
|
|
||||||
|
|
||||||
let mut cases = Vec::new();
|
|
||||||
for (tag, first, second) in pairs {
|
|
||||||
cases.push(
|
|
||||||
Case::new(&format!("section 1.2 forbids pairing these two conditions [{tag}]"), tag)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Rejected",
|
|
||||||
&targets,
|
|
||||||
&[first.clone(), second.clone()],
|
|
||||||
)])
|
|
||||||
.invalid(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Order must not matter — validation is not a directional check.
|
|
||||||
cases.push(
|
|
||||||
Case::new(
|
|
||||||
"section 1.2 rejects the same pair in the opposite order",
|
|
||||||
"7:invalid condition combos",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Reversed",
|
|
||||||
&targets,
|
|
||||||
&[launch_count(3, UsageWindow::Daily), time(WEEKDAYS, at(9, 0), at(17, 0))],
|
|
||||||
)])
|
|
||||||
.invalid(),
|
|
||||||
);
|
|
||||||
|
|
||||||
// A permitted combination, so the guards are not simply rejecting everything.
|
|
||||||
cases.push(
|
|
||||||
Case::new("section 1.2 permits Wi-Fi with a time window", "7:invalid condition combos")
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Permitted",
|
|
||||||
&targets,
|
|
||||||
&[time(WEEKDAYS, at(9, 0), at(17, 0)), wifi("CoffeeShop")],
|
|
||||||
)])
|
|
||||||
.with_ssid("CoffeeShop")
|
|
||||||
.at(MON, 10, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")]),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Section 6.4: location is mobile-only, and desktop must reject rather than
|
|
||||||
// accept-and-never-fire.
|
|
||||||
cases.push(
|
|
||||||
Case::new(
|
|
||||||
"section 6.4 rejects a location condition on desktop",
|
|
||||||
"7:invalid condition combos",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Geo",
|
|
||||||
&targets,
|
|
||||||
&[location(52.52, 13.405, 500, false)],
|
|
||||||
)])
|
|
||||||
.on_platform(blocker_core::model::Platform::Linux)
|
|
||||||
.invalid(),
|
|
||||||
);
|
|
||||||
|
|
||||||
cases
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section 1.2: usage-limit and launch-count conditions.
|
|
||||||
pub fn usage_and_launch_cases() -> Vec<Case> {
|
|
||||||
let targets = [app("com.example.chat")];
|
|
||||||
|
|
||||||
vec![
|
|
||||||
Case::new(
|
|
||||||
"usage limit: not yet reached, so nothing is blocked",
|
|
||||||
"7:invalid condition combos",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Limit",
|
|
||||||
&targets,
|
|
||||||
&[usage_limit(30, UsageWindow::Daily)],
|
|
||||||
)])
|
|
||||||
.with_daily_minutes("com.example.chat", 29)
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[]),
|
|
||||||
Case::new(
|
|
||||||
"usage limit: reached exactly, so the app is blocked",
|
|
||||||
"7:invalid condition combos",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Limit",
|
|
||||||
&targets,
|
|
||||||
&[usage_limit(30, UsageWindow::Daily)],
|
|
||||||
)])
|
|
||||||
.with_daily_minutes("com.example.chat", 30)
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")])
|
|
||||||
.wakes_in(12 * 60),
|
|
||||||
Case::new("launch count: the Nth launch blocks", "7:invalid condition combos")
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Launches",
|
|
||||||
&targets,
|
|
||||||
&[launch_count(3, UsageWindow::Hourly)],
|
|
||||||
)])
|
|
||||||
.with_daily_launches("com.example.chat", 3)
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")]),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section 1.2: Wi-Fi and location.
|
|
||||||
pub fn wifi_and_location_cases() -> Vec<Case> {
|
|
||||||
let targets = [app("com.example.chat")];
|
|
||||||
|
|
||||||
vec![
|
|
||||||
Case::new("wifi: blocks on the named SSID", "7:invalid condition combos")
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Not at the cafe",
|
|
||||||
&targets,
|
|
||||||
&[wifi("CoffeeShop")],
|
|
||||||
)])
|
|
||||||
.with_ssid("CoffeeShop")
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")]),
|
|
||||||
Case::new("wifi: does nothing on a different SSID", "7:invalid condition combos")
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Not at the cafe",
|
|
||||||
&targets,
|
|
||||||
&[wifi("CoffeeShop")],
|
|
||||||
)])
|
|
||||||
.with_ssid("HomeWifi")
|
|
||||||
.at(MON, 12, 0),
|
|
||||||
Case::new("location: blocks inside the radius", "7:invalid condition combos")
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"At the office",
|
|
||||||
&targets,
|
|
||||||
&[location(52.52, 13.405, 500, false)],
|
|
||||||
)])
|
|
||||||
.with_location(52.5205, 13.4055)
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")]),
|
|
||||||
Case::new("location: outside the radius does not block", "7:invalid condition combos")
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"At the office",
|
|
||||||
&targets,
|
|
||||||
&[location(52.52, 13.405, 500, false)],
|
|
||||||
)])
|
|
||||||
.with_location(48.8566, 2.3522)
|
|
||||||
.at(MON, 12, 0),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The zero-idle-wakeup budget, as an assertion.
|
|
||||||
///
|
|
||||||
/// These are the cases that protect the section 5 idle-CPU number. The subtle one
|
|
||||||
/// is `inactive_but_pending`: a schedule that is not active *now* must still arm
|
|
||||||
/// a wakeup for when it starts, or the daemon sleeps through the moment it is
|
|
||||||
/// meant to begin blocking.
|
|
||||||
pub fn wakeup_cases() -> Vec<Case> {
|
|
||||||
vec![
|
|
||||||
Case::new(
|
|
||||||
"wakeup: a Wi-Fi schedule arms no timer, only an OS event can change it",
|
|
||||||
"7:strict mode lock rules",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Not at the cafe",
|
|
||||||
&[app("com.example.chat")],
|
|
||||||
&[wifi("CoffeeShop")],
|
|
||||||
)])
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.never_wakes(),
|
|
||||||
Case::new(
|
|
||||||
"wakeup: an inactive schedule still arms one for when it starts",
|
|
||||||
"7:strict mode lock rules",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Weekends",
|
|
||||||
&[app("com.example.chat")],
|
|
||||||
&[time(&[0, 1, 2, 3, 4], at(9, 0), at(17, 0))],
|
|
||||||
)])
|
|
||||||
// Saturday: not a start day, so nothing is blocked now...
|
|
||||||
.at(SAT, 12, 0)
|
|
||||||
.blocks(&[])
|
|
||||||
// ...but Monday 09:00 is the next boundary, 45 hours away, and must be
|
|
||||||
// armed or the schedule silently never fires.
|
|
||||||
.wakes_in(45 * 60),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section 7: the never-block list, as tables rather than fixtures.
|
|
||||||
///
|
|
||||||
/// The engine consults the list *after* schedule evaluation, so a schedule
|
|
||||||
/// naming a protected target still blocks its other targets while the protected
|
|
||||||
/// one cannot be blocked. Cases here prove both halves of that.
|
|
||||||
pub fn never_block_cases() -> Vec<Case> {
|
|
||||||
vec![
|
|
||||||
Case::new(
|
|
||||||
"never-block: a protected target cannot be blocked, but its siblings can",
|
|
||||||
"1.5.4:never-block list is unblockable",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Sweeping",
|
|
||||||
&[app("plasmashell"), app("com.example.chat")],
|
|
||||||
&[time(ALL_DAYS, 0, at(23, 59))],
|
|
||||||
)])
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")]),
|
|
||||||
// Derived from the list this build actually has. The never-block list is
|
|
||||||
// per-platform (section 1.5.4 names the dialler on mobile and the shell
|
|
||||||
// on desktop), so a hardcoded Android package asserts nothing on a Linux
|
|
||||||
// build — which is exactly how a table stops testing its own name.
|
|
||||||
Case::new(
|
|
||||||
"never-block: matching is substring-based, so a variant name is covered",
|
|
||||||
"1.5.4:never-block list is unblockable",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Variant",
|
|
||||||
&[app(&variant_of_a_protected_name())],
|
|
||||||
&[time(ALL_DAYS, 0, at(23, 59))],
|
|
||||||
)])
|
|
||||||
.at(MON, 12, 0)
|
|
||||||
.blocks(&[]),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
/// A protected name with something appended, to exercise substring matching.
|
|
||||||
///
|
|
||||||
/// Panics if the list is empty, which is itself a bug: an empty list would mean
|
|
||||||
/// a device could be locked out of its own dialler or shell.
|
|
||||||
fn variant_of_a_protected_name() -> String {
|
|
||||||
let first = blocker_core::never_block::NEVER_BLOCK
|
|
||||||
.first()
|
|
||||||
.expect("the never-block list must not be empty");
|
|
||||||
format!("{first}.variant")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Section 1.5.4: the 24-hour worst case, as a table.
|
|
||||||
///
|
|
||||||
/// The timer arithmetic itself is in `tests/src/purity.rs`, because proving a cap
|
|
||||||
/// needs arithmetic rather than a decision. What belongs here is the *decision*
|
|
||||||
/// consequence: a Stubborn lock still yields an ordinary, explainable decision,
|
|
||||||
/// and the engine reports the schedule and end time the block screen will show.
|
|
||||||
pub fn lock_rule_cases() -> Vec<Case> {
|
|
||||||
vec![
|
|
||||||
Case::new(
|
|
||||||
"lock rules: under a lock the schedule still blocks, and still explains itself",
|
|
||||||
"7:strict mode lock rules",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Evening Focus",
|
|
||||||
&[app("com.example.chat")],
|
|
||||||
&[time(ALL_DAYS, at(22, 0), at(6, 0))],
|
|
||||||
)])
|
|
||||||
.at(MON, 23, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")])
|
|
||||||
.wakes_in(7 * 60),
|
|
||||||
Case::new(
|
|
||||||
"lock rules: a locked schedule's end time is the window boundary, not the lock",
|
|
||||||
"1.5.4:worst case 24h",
|
|
||||||
)
|
|
||||||
.with_schedules(vec![blocklist(
|
|
||||||
"s1",
|
|
||||||
"Bounded",
|
|
||||||
&[app("com.example.chat")],
|
|
||||||
&[time(ALL_DAYS, at(22, 0), at(6, 0))],
|
|
||||||
)])
|
|
||||||
.at(MON, 22, 0)
|
|
||||||
.blocks(&[("com.example.chat", "s1")])
|
|
||||||
.wakes_in(8 * 60),
|
|
||||||
]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user