From af3c080ace143d52f39687d6cda54912323542f3 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 06:13:53 -0700 Subject: [PATCH 001/108] chore(repo): a workspace of one crate A cargo workspace with one member, `komorebi-recorder`, and the files a project needs around it: toolchain, rustfmt and cargo-deny configuration (both the macOS and the Windows target are in the dependency graph), a justfile whose `check` recipe is the one command CONTRIBUTING names, the license, and README and CONTRIBUTING. The crate is a skeleton that prints its version; the recorder arrives in the commits after it. --- .gitignore | 4 + CONTRIBUTING.md | 62 ++++++ Cargo.lock | 385 ++++++++++++++++++++++++++++++++++ Cargo.toml | 22 ++ LICENSE.md | 108 ++++++++++ README.md | 45 ++++ deny.toml | 52 +++++ dependencies.json | 93 ++++++++ justfile | 58 +++++ komorebi-recorder/Cargo.toml | 9 + komorebi-recorder/src/lib.rs | 10 + komorebi-recorder/src/main.rs | 15 ++ rust-toolchain.toml | 2 + rustfmt.toml | 1 + 14 files changed, 866 insertions(+) create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 deny.toml create mode 100644 dependencies.json create mode 100644 justfile create mode 100644 komorebi-recorder/Cargo.toml create mode 100644 komorebi-recorder/src/lib.rs create mode 100644 komorebi-recorder/src/main.rs create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..29aa63bf --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/target +/result* +/.direnv +/.idea diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..3e9bea5f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,62 @@ +# Contributing to komorebi-recorder + +The project is a collection of contributions from both the project leaders and +community members. There are many ways to contribute, this can include content +in the project repositories, as well as contributing in public and private +conversation, assisting users, writing blog posts, and many other ways. + +## How contributions are made + +Contributions to the project primarily happen in the project source +repositories, but may also occur in other places, such as discussion forums and +public and private discourse. + +## Contributing content to the Project + +In order for the project leaders to manage sustained progress toward the +project goals and maintain project velocity, focus and quality, the project may +adjust the license terms over time. + +Content contributed to the project must therefore be provided under +sufficiently liberal terms to allow these operations to proceed unimpeded. As +such contributions are accepted with the following understanding: + +* Contributed content is licensed under the terms of the 0-BSD license +* Contributors accept the terms of the project license at the time of + contribution + +By making a contribution, you accept both the current project license terms, +and that all contributions that you have made are provided under the terms of +the 0-BSD license. + +## Zero-Clause BSD + +``` +Permission to use, copy, modify, and/or distribute this software for +any purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED “AS IS” AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES +OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE +FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY +DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN +AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT +OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +``` + +## Development environment + +The project builds with the stable Rust toolchain, and is formatted with nightly `rustfmt` (`cargo +nightly fmt`). + +## Checks + +Before pushing, run `just check`, which runs formatting, clippy, the tests, rustdoc with its warnings as errors and +`cargo deny`. `just --list` shows the other recipes. + +## Commits + +Commit messages follow [Conventional Commits](https://www.conventionalcommits.org/), as the pull request +template asks: the release notes are generated from them. + +A change that alters what the documentation says (the rustdoc, the README, `docs/`) updates the +documentation in the same commit, so that the documentation never describes code that is not there. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 00000000..5ca549ce --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,385 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.60.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.60.2", +] + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", + "terminal_size", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "komorebi-recorder" +version = "0.1.0" +dependencies = [ + "clap", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "terminal_size" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "230a1b821ccbd75b185820a1f1ff7b14d21da1e442e22c0863ea5f08771a8874" +dependencies = [ + "rustix", + "windows-sys 0.59.0", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[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.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 00000000..6bfde2c2 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,22 @@ +[workspace] +resolver = "2" +members = ["komorebi-recorder"] + +[workspace.package] +version = "0.1.0" +edition = "2024" +repository = "https://github.com/LGUG2Z/komorebi-recorder" + +[workspace.dependencies] +chrono = "0.4" +clap = { version = "4", features = ["derive", "wrap_help"] } +color-eyre = "0.6" +crossbeam-channel = "0.5" +dirs = "6" +dunce = "1" +eframe = "0.33" +parking_lot = "0.12" +schemars = "1.1" +serde = { version = "1", features = ["derive"] } +serde_json = { package = "serde_json_lenient", version = "0.2" } +sysinfo = "0.38" diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..8796dd2f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,108 @@ +# Komorebi License + +Version 2.0.0 + +## Acceptance + +In order to get any license under these terms, you must agree +to them as both strict obligations and conditions to all +your licenses. + +## Copyright License + +The licensor grants you a copyright license for the software +to do everything you might do with the software that would +otherwise infringe the licensor's copyright in it for any +permitted purpose. However, you may only distribute the source +code of the software according to the [Distribution License]( +#distribution-license), you may only make changes according +to the [Changes License](#changes-license), and you may not +otherwise distribute the software or new works based on the +software. + +## Distribution License + +The licensor grants you an additional copyright license to +distribute copies of the source code of the software. Your +license to distribute covers distributing the source code of +the software with changes permitted by the [Changes License]( +#changes-license). + +## Changes License + +The licensor grants you an additional copyright license to +make changes for any permitted purpose. + +## Patent License + +The licensor grants you a patent license for the software that +covers patent claims the licensor can license, or becomes able +to license, that you would infringe by using the software. + +## Personal Uses + +Personal use for research, experiment, and testing for +the benefit of public knowledge, personal study, private +entertainment, hobby projects, amateur pursuits, or religious +observance, without any anticipated commercial application, +is use for a permitted purpose. + +## Fair Use + +You may have "fair use" rights for the software under the +law. These terms do not limit them. + +## No Other Rights + +These terms do not allow you to sublicense or transfer any of +your licenses to anyone else, or prevent the licensor from +granting licenses to anyone else. These terms do not imply +any other licenses. + +## Patent Defense + +If you make any written claim that the software infringes or +contributes to infringement of any patent, your patent license +for the software granted under these terms ends immediately. If +your company makes such a claim, your patent license ends +immediately for work on behalf of your company. + +## Violations + +The first time you are notified in writing that you have +violated any of these terms, or done anything with the software +not covered by your licenses, your licenses can nonetheless +continue if you come into full compliance with these terms, +and take practical steps to correct past violations, within +32 days of receiving notice. Otherwise, all your licenses +end immediately. + +## No Liability + +***As far as the law allows, the software comes as is, without +any warranty or condition, and the licensor will not be liable +to you for any damages arising out of these terms or the use +or nature of the software, under any kind of legal claim.*** + +## Definitions + +The **licensor** is the individual or entity offering these +terms, and the **software** is the software the licensor makes +available under these terms. + +**You** refers to the individual or entity agreeing to these +terms. + +**Your company** is any legal entity, sole proprietorship, +or other kind of organization that you work for, plus all +organizations that have control over, are under the control of, +or are under common control with that organization. **Control** +means ownership of substantially all the assets of an entity, +or the power to direct its management and policies by vote, +contract, or otherwise. Control can be direct or indirect. + +**Your licenses** are all the licenses granted to you for the +software under these terms. + +**Use** means anything you do with the software requiring one +of your licenses. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 00000000..9d7fdafb --- /dev/null +++ b/README.md @@ -0,0 +1,45 @@ +# komorebi-recorder + +Synchronized screen, log and trace capture for komorebi bug reproduction. `komorebi-recorder` records a bug while it +happens, on macOS and on Windows: the screen, komorebi's log, every event komorebi sends its subscribers and the +operating system's own trace, all on one clock, into a single _session directory_. It then answers, from that +directory alone, what komorebi and the OS were doing at any second of the video and where in the video any log line or +event is: for a person, with a window that plays the recording with everything that happened laid out on a timeline, +and for an agent, with commands that print JSON. + +## Install + +There are no releases yet: build it from source, below. + +### From source + +With a stable Rust toolchain, in a checkout: + +```sh +cargo install --path komorebi-recorder --locked +``` + +## How it fits together + +Two things vary independently: the _host_ the recorder runs on (macOS or Windows) and the _flavor_ of komorebi it +records (komorebi for Mac or komorebi for Windows). A session records both, so one recorded on Windows can be +investigated on a Mac. Every record in a session is stamped on one monotonic clock, and the files in it are never +rewritten after they are written. + +## Crates + +- [`komorebi-recorder`](komorebi-recorder): the library, the `komorebi-recorder` command and, with the same code, the + `komorebi-recorder-gui` executable (no console window on Windows). + +## Checks + +`just check` runs formatting, clippy, the tests, rustdoc with its warnings as errors, and `cargo deny`. +CI runs the same checks, builds both platforms and, for Windows, the installer. + +## Documentation + +`cargo doc --workspace --no-deps --open` for the code. [CONTRIBUTING.md](CONTRIBUTING.md) says how to work on it. + +## License + +[Komorebi License 2.0.0](LICENSE.md). diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..5a4ab026 --- /dev/null +++ b/deny.toml @@ -0,0 +1,52 @@ +[graph] +targets = ["aarch64-apple-darwin", "x86_64-pc-windows-msvc"] +all-features = false +no-default-features = false + +[output] +feature-depth = 1 + +[advisories] +ignore = [ + { id = "RUSTSEC-2026-0192", reason = "ttf-parser is unmaintained and has no safe upgrade; it is reached only through egui's font stack (eframe, egui, epaint, ab_glyph, owned_ttf_parser)" }, +] + +[licenses] +allow = [ + "0BSD", + "Apache-2.0", + "Artistic-2.0", + # clipboard-win's error-code, which eframe pulls in on Windows + "BSL-1.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "ISC", + "MIT", + "MPL-2.0", + "OFL-1.1", + "Ubuntu-font-1.0", + "Unicode-3.0", + "Zlib", + + "LicenseRef-Komorebi-2.0", +] +confidence-threshold = 0.8 + +[[licenses.clarify]] +crate = "komorebi-recorder" +expression = "LicenseRef-Komorebi-2.0" +license-files = [] + +[bans] +multiple-versions = "allow" +wildcards = "allow" +highlight = "all" +workspace-default-features = "allow" +external-default-features = "allow" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-git = [] diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 00000000..a0e3e2d7 --- /dev/null +++ b/dependencies.json @@ -0,0 +1,93 @@ +{ + "licenses": [ + [ + "Apache-2.0", + [ + "anstream 1.0.0 registry+https://github.com/rust-lang/crates.io-index", + "anstyle 1.0.14 registry+https://github.com/rust-lang/crates.io-index", + "anstyle-parse 1.0.0 registry+https://github.com/rust-lang/crates.io-index", + "anstyle-query 1.1.5 registry+https://github.com/rust-lang/crates.io-index", + "anstyle-wincon 3.0.11 registry+https://github.com/rust-lang/crates.io-index", + "bitflags 2.11.1 registry+https://github.com/rust-lang/crates.io-index", + "clap 4.6.1 registry+https://github.com/rust-lang/crates.io-index", + "clap_builder 4.6.0 registry+https://github.com/rust-lang/crates.io-index", + "clap_derive 4.6.1 registry+https://github.com/rust-lang/crates.io-index", + "clap_lex 1.1.0 registry+https://github.com/rust-lang/crates.io-index", + "colorchoice 1.0.5 registry+https://github.com/rust-lang/crates.io-index", + "errno 0.3.14 registry+https://github.com/rust-lang/crates.io-index", + "heck 0.5.0 registry+https://github.com/rust-lang/crates.io-index", + "is_terminal_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", + "libc 0.2.186 registry+https://github.com/rust-lang/crates.io-index", + "once_cell_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", + "proc-macro2 1.0.106 registry+https://github.com/rust-lang/crates.io-index", + "quote 1.0.45 registry+https://github.com/rust-lang/crates.io-index", + "rustix 1.1.4 registry+https://github.com/rust-lang/crates.io-index", + "syn 2.0.117 registry+https://github.com/rust-lang/crates.io-index", + "terminal_size 0.4.4 registry+https://github.com/rust-lang/crates.io-index", + "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index", + "utf8parse 0.2.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-sys 0.52.0 registry+https://github.com/rust-lang/crates.io-index", + "windows-sys 0.59.0 registry+https://github.com/rust-lang/crates.io-index", + "windows-sys 0.60.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-targets 0.52.6 registry+https://github.com/rust-lang/crates.io-index", + "windows-targets 0.53.5 registry+https://github.com/rust-lang/crates.io-index", + "windows_x86_64_msvc 0.52.6 registry+https://github.com/rust-lang/crates.io-index", + "windows_x86_64_msvc 0.53.1 registry+https://github.com/rust-lang/crates.io-index" + ] + ], + [ + "Apache-2.0 WITH LLVM-exception", + [ + "rustix 1.1.4 registry+https://github.com/rust-lang/crates.io-index" + ] + ], + [ + "LicenseRef-Komorebi-2.0", + [ + "komorebi-recorder 0.1.1 path+file:///Users/lgug2z/.cache/komorebi-recorder/lockwork/komorebi-recorder" + ] + ], + [ + "MIT", + [ + "anstream 1.0.0 registry+https://github.com/rust-lang/crates.io-index", + "anstyle 1.0.14 registry+https://github.com/rust-lang/crates.io-index", + "anstyle-parse 1.0.0 registry+https://github.com/rust-lang/crates.io-index", + "anstyle-query 1.1.5 registry+https://github.com/rust-lang/crates.io-index", + "anstyle-wincon 3.0.11 registry+https://github.com/rust-lang/crates.io-index", + "bitflags 2.11.1 registry+https://github.com/rust-lang/crates.io-index", + "clap 4.6.1 registry+https://github.com/rust-lang/crates.io-index", + "clap_builder 4.6.0 registry+https://github.com/rust-lang/crates.io-index", + "clap_derive 4.6.1 registry+https://github.com/rust-lang/crates.io-index", + "clap_lex 1.1.0 registry+https://github.com/rust-lang/crates.io-index", + "colorchoice 1.0.5 registry+https://github.com/rust-lang/crates.io-index", + "errno 0.3.14 registry+https://github.com/rust-lang/crates.io-index", + "heck 0.5.0 registry+https://github.com/rust-lang/crates.io-index", + "is_terminal_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", + "libc 0.2.186 registry+https://github.com/rust-lang/crates.io-index", + "once_cell_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", + "proc-macro2 1.0.106 registry+https://github.com/rust-lang/crates.io-index", + "quote 1.0.45 registry+https://github.com/rust-lang/crates.io-index", + "rustix 1.1.4 registry+https://github.com/rust-lang/crates.io-index", + "strsim 0.11.1 registry+https://github.com/rust-lang/crates.io-index", + "syn 2.0.117 registry+https://github.com/rust-lang/crates.io-index", + "terminal_size 0.4.4 registry+https://github.com/rust-lang/crates.io-index", + "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index", + "utf8parse 0.2.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-sys 0.52.0 registry+https://github.com/rust-lang/crates.io-index", + "windows-sys 0.59.0 registry+https://github.com/rust-lang/crates.io-index", + "windows-sys 0.60.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-targets 0.52.6 registry+https://github.com/rust-lang/crates.io-index", + "windows-targets 0.53.5 registry+https://github.com/rust-lang/crates.io-index", + "windows_x86_64_msvc 0.52.6 registry+https://github.com/rust-lang/crates.io-index", + "windows_x86_64_msvc 0.53.1 registry+https://github.com/rust-lang/crates.io-index" + ] + ], + [ + "Unicode-3.0", + [ + "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index" + ] + ] + ] +} diff --git a/justfile b/justfile new file mode 100644 index 00000000..7477692c --- /dev/null +++ b/justfile @@ -0,0 +1,58 @@ +export RUST_BACKTRACE := "full" + +# everything CI checks, in order +check: fmt-check clippy test doc deny + +# nightly rustfmt, which the devShell provides, and the macros this project does not allow +fmt-check: + test -z "$(rg 'eyre!' --type rust)" || (echo "eyre! macro not allowed"; false) + test -z "$(rg 'dbg!' --type rust)" || (echo "dbg! macro not allowed"; false) + cargo fmt --check + +# format everything: Rust, TOML, Nix and this file +fmt: + cargo clippy --workspace --all-targets --locked + nix fmt + +fix: + cargo clippy --workspace --all-targets --fix --allow-dirty + +clippy: + cargo clippy --workspace --all-targets --locked -- -D warnings + + +# lib, integration and doc tests; the ones that read komorebi's source need KOMOREBI_MAC_SRC, which `nix develop` sets +test: + cargo test --workspace --locked + +# rustdoc with its warnings as errors, for both hosts: links in `cfg(windows)` docs are only checked on Windows +doc: + RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked + +deny: + cargo deny check + +clean: + cargo clean + +install: + cargo install --path komorebi-recorder --locked + +build: + cargo build --package komorebi-recorder --locked --release + +run target: + cargo run --bin {{ target }} --locked + +# record a bug repro: screen + komorebi log/events + OS trace, then query it with `komorebi-recorder at` +record *args: + cargo run --bin komorebi-recorder --locked -- {{ args }} + + +deps: + cargo update + just depgen + +depgen: + cargo deny check + cargo deny list --format json | jq 'del(.unlicensed)' > dependencies.json diff --git a/komorebi-recorder/Cargo.toml b/komorebi-recorder/Cargo.toml new file mode 100644 index 00000000..2f21686b --- /dev/null +++ b/komorebi-recorder/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "komorebi-recorder" +version.workspace = true +description = "Synchronized screen, log and trace capture for komorebi bug reproduction" +edition.workspace = true +repository.workspace = true + +[dependencies] +clap = { workspace = true } diff --git a/komorebi-recorder/src/lib.rs b/komorebi-recorder/src/lib.rs new file mode 100644 index 00000000..a6d80956 --- /dev/null +++ b/komorebi-recorder/src/lib.rs @@ -0,0 +1,10 @@ +//! Synchronized screen, log and trace capture for komorebi bug reproduction. `komorebi-recorder` records a bug while it +//! happens, on macOS and on Windows: the screen, komorebi's log, every event komorebi sends its subscribers and the +//! operating system's own trace, all on one clock, into a single _session directory_. It then answers, from that +//! directory alone, what komorebi and the OS were doing at any second of the video and where in the video any log line or +//! event is: for a person, with a window that plays the recording with everything that happened laid out on a timeline, +//! and for an agent, with commands that print JSON. +//! +//! This crate is the library behind the `komorebi-recorder` command. A reader who wants to run it should start with the +//! [README](https://github.com/LGUG2Z/komorebi-recorder#readme); this documentation is for someone who is changing it, +//! or reading a session directory from a program of their own. diff --git a/komorebi-recorder/src/main.rs b/komorebi-recorder/src/main.rs new file mode 100644 index 00000000..709c8895 --- /dev/null +++ b/komorebi-recorder/src/main.rs @@ -0,0 +1,15 @@ +//! The `komorebi-recorder` command. + +use clap::Parser; + +#[derive(Parser)] +#[command( + author, + version, + about = "Synchronized screen, log and trace capture for komorebi bug reproduction" +)] +struct Opts; + +fn main() { + Opts::parse(); +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 00000000..292fe499 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 00000000..63df91e0 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1 @@ +imports_granularity = "Item" From 76bae0630ebcfd2b03bccb4c91a4d949f7d1c347 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 06:13:57 -0700 Subject: [PATCH 002/108] build(nix): build and check for macOS and Windows A flake that builds the recorder for macOS and cross-compiles it for Windows from a Mac (xwin for the SDK, LLVM for the toolchain), with a dev shell for each, and `nix flake check` running clippy for both targets, rustfmt, taplo, cargo-deny and the tests. The sources of komorebi for Mac and komorebi for Windows are flake inputs, pinned in `flake.lock`: the recorder indexes them so that a line of komorebi's log can be traced to the statement that wrote it, and the dev shells and every derivation export where they are. --- .envrc | 1 + CONTRIBUTING.md | 20 ++- README.md | 10 +- flake.lock | 191 ++++++++++++++++++++++++++ flake.nix | 359 ++++++++++++++++++++++++++++++++++++++++++++++++ justfile | 11 +- nix/windows.nix | 80 +++++++++++ 7 files changed, 665 insertions(+), 7 deletions(-) create mode 100644 .envrc create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 nix/windows.nix diff --git a/.envrc b/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3e9bea5f..14e76069 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -46,12 +46,26 @@ OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ## Development environment -The project builds with the stable Rust toolchain, and is formatted with nightly `rustfmt` (`cargo +nightly fmt`). +The project is built with Nix. `nix develop` (or direnv, whose `.envrc` is `use flake`) provides the +Rust toolchain with nightly `rustfmt`, `cargo-deny`, `cargo-nextest`, `ffmpeg`, `jq`, `just` and +`ripgrep`, and sets `KOMOREBI_MAC_SRC` and `KOMOREBI_WINDOWS_SRC` (with `_COMMIT`) to the komorebi +sources pinned in `flake.lock`. The tests that read komorebi's source need them, so run `cargo` inside +the shell. + +The Windows code is type-checked from a Mac with `nix develop .#windows`, whose target is +`x86_64-pc-windows-msvc`. Nothing there runs it: a change to a `cfg(windows)` line is first run by the +`windows` job in CI. ## Checks -Before pushing, run `just check`, which runs formatting, clippy, the tests, rustdoc with its warnings as errors and -`cargo deny`. `just --list` shows the other recipes. +Before pushing, run + +```sh +just check +``` + +which runs formatting, clippy and the tests for macOS, clippy for Windows, rustdoc with its warnings as +errors for both hosts, and `cargo deny`. `just --list` shows the other recipes. ## Commits diff --git a/README.md b/README.md index 9d7fdafb..3fb80ff6 100644 --- a/README.md +++ b/README.md @@ -13,12 +13,17 @@ There are no releases yet: build it from source, below. ### From source -With a stable Rust toolchain, in a checkout: +The repository is a Nix flake. In a checkout: ```sh +nix develop # the toolchain and the pinned komorebi sources cargo install --path komorebi-recorder --locked ``` +`nix build` makes the macOS binary in `./result/bin`, and `nix build .#komorebi-recorder-windows` cross-compiles +`komorebi-recorder.exe` and, with it, `komorebi-recorder-gui.exe` from a Mac. `nix run github:LGUG2Z/komorebi-recorder` +runs the latest commit without a checkout. + ## How it fits together Two things vary independently: the _host_ the recorder runs on (macOS or Windows) and the _flavor_ of komorebi it @@ -33,7 +38,8 @@ rewritten after they are written. ## Checks -`just check` runs formatting, clippy, the tests, rustdoc with its warnings as errors, and `cargo deny`. +`just check` runs formatting, clippy and the tests for macOS, clippy for Windows (the Windows code is type-checked +from a Mac, not run), rustdoc with its warnings as errors for both hosts, and `cargo deny`. It needs `nix develop`. CI runs the same checks, builds both platforms and, for Windows, the installer. ## Documentation diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..ed3454d7 --- /dev/null +++ b/flake.lock @@ -0,0 +1,191 @@ +{ + "nodes": { + "crane": { + "locked": { + "lastModified": 1792350552, + "narHash": "sha256-292lvpvxTGKryKxh3/3tcIVi85CwYGdl4oOi113Sob8=", + "owner": "ipetkov", + "repo": "crane", + "rev": "e675a36b7377bebe41d548388a2501cbb16d380d", + "type": "github" + }, + "original": { + "owner": "ipetkov", + "repo": "crane", + "type": "github" + } + }, + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1767039857, + "narHash": "sha256-vNpUSpF5Nuw8xvDLj2KCwwksIbjua2LZCqhV1LNRDns=", + "owner": "NixOS", + "repo": "flake-compat", + "rev": "5edf11c44bc78a0d334f6334cdaf7d60d732daab", + "type": "github" + }, + "original": { + "owner": "NixOS", + "repo": "flake-compat", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1790898470, + "narHash": "sha256-zTwuwezD0w3hpga6a6P8emidzAZFyWqlNeRBlpWOOl8=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "024633cd702b10285db5cb19b40ad48d2399ba60", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "git-hooks-nix": { + "inputs": { + "flake-compat": "flake-compat", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791909798, + "narHash": "sha256-VAhpCOmrun/KXimYfR8NM88bciKCui7CqHHhThSzgag=", + "owner": "cachix", + "repo": "git-hooks.nix", + "rev": "1ba1dbfe28738c9a71c266be6efa5b89e75491a9", + "type": "github" + }, + "original": { + "owner": "cachix", + "repo": "git-hooks.nix", + "type": "github" + } + }, + "komorebi-mac": { + "flake": false, + "locked": { + "lastModified": 1777946799, + "narHash": "sha256-uDprEfYaPugY2eJpKRIHJQoyxYKwXmnu/r8VQ7j9Fts=", + "owner": "LGUG2Z", + "repo": "komorebi-for-mac", + "rev": "bdf10452f10ebf15ec1978cb05d0aac79ef5611f", + "type": "github" + }, + "original": { + "owner": "LGUG2Z", + "repo": "komorebi-for-mac", + "type": "github" + } + }, + "komorebi-windows": { + "flake": false, + "locked": { + "lastModified": 1787433983, + "narHash": "sha256-L17PiaT8KH+2lEmsWJYnPV4HVifRi44tXJTCpCf4kwc=", + "owner": "LGUG2Z", + "repo": "komorebi", + "rev": "e0709f02bfae4e503bf4640f58ee75ecbbfdbb97", + "type": "github" + }, + "original": { + "owner": "LGUG2Z", + "repo": "komorebi", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1792842277, + "narHash": "sha256-pGPnZGi8y9d76xNeracniXO3dsYVROEmMkMc8jD0Ihc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d3b13ad75cc492690728f9307e443dec9ef9a20e", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "lastModified": 1790477367, + "narHash": "sha256-oujbxmVKDhci2E4EM3KyAbRUgp5/IsCoiA/LfEF7OOk=", + "owner": "nix-community", + "repo": "nixpkgs.lib", + "rev": "f7cd230690d9fc982d06129f9189b101fddcfa3b", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "nixpkgs.lib", + "type": "github" + } + }, + "root": { + "inputs": { + "crane": "crane", + "flake-parts": "flake-parts", + "git-hooks-nix": "git-hooks-nix", + "komorebi-mac": "komorebi-mac", + "komorebi-windows": "komorebi-windows", + "nixpkgs": "nixpkgs", + "rust-overlay": "rust-overlay", + "treefmt-nix": "treefmt-nix" + } + }, + "rust-overlay": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1793086596, + "narHash": "sha256-ObKS44vw4IIVpQ69YJzUGjZ0IwnpWoUlwjJSY7gefdo=", + "owner": "oxalica", + "repo": "rust-overlay", + "rev": "c7e9d512653a35ef4d5879c3df4d44d7b78d0e6d", + "type": "github" + }, + "original": { + "owner": "oxalica", + "repo": "rust-overlay", + "type": "github" + } + }, + "treefmt-nix": { + "inputs": { + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1791885023, + "narHash": "sha256-C3alQ2MEt0zVrUSho5NUDo3jmhbff72++bfwoqT9Omo=", + "owner": "numtide", + "repo": "treefmt-nix", + "rev": "0d6d739a833ba9a9c93034862e4a7b061a062a1c", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "treefmt-nix", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..b677d619 --- /dev/null +++ b/flake.nix @@ -0,0 +1,359 @@ +{ + description = "komorebi-recorder: synchronized screen, log and trace capture for komorebi bug reproduction"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + crane.url = "github:ipetkov/crane"; + rust-overlay.url = "github:oxalica/rust-overlay"; + rust-overlay.inputs.nixpkgs.follows = "nixpkgs"; + treefmt-nix.url = "github:numtide/treefmt-nix"; + treefmt-nix.inputs.nixpkgs.follows = "nixpkgs"; + git-hooks-nix.url = "github:cachix/git-hooks.nix"; + git-hooks-nix.inputs.nixpkgs.follows = "nixpkgs"; + + # komorebi for Mac and komorebi for Windows: only their sources, which the recorder indexes so + # that `code` can map a line of a session's log to the statement that wrote it + komorebi-mac = { + url = "github:LGUG2Z/komorebi-for-mac"; + flake = false; + }; + komorebi-windows = { + url = "github:LGUG2Z/komorebi"; + flake = false; + }; + }; + + outputs = + inputs@{ + nixpkgs, + flake-parts, + crane, + rust-overlay, + ... + }: + let + windows = import ./nix/windows.nix; + + # the sources the recorder indexes for each flavor of komorebi (see komorebi-recorder/build.rs), + # which are also the trees the tests that read komorebi's source read + sourceEnv = { + KOMOREBI_MAC_SRC = "${inputs.komorebi-mac}"; + KOMOREBI_MAC_COMMIT = inputs.komorebi-mac.rev; + KOMOREBI_WINDOWS_SRC = "${inputs.komorebi-windows}"; + KOMOREBI_WINDOWS_COMMIT = inputs.komorebi-windows.rev; + }; + + version = (builtins.fromTOML (builtins.readFile ./Cargo.toml)).workspace.package.version; + + # what the build needs of the repository: Cargo sources, and the files the crate embeds or + # its tests read + mkSource = + { pkgs, craneLib }: + pkgs.lib.cleanSourceWith { + src = ./.; + filter = + path: type: + (craneLib.filterCargoSources path type) + || (pkgs.lib.hasSuffix "/komorebi-recorder/src/guide.md" path) + || (pkgs.lib.hasInfix "/komorebi-recorder/tests/fixtures/" path); + }; + + mkPackages = + { pkgs }: + let + toolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + craneLib = (crane.mkLib pkgs).overrideToolchain toolchain; + src = mkSource { inherit pkgs craneLib; }; + + commonArgs = { + inherit src version; + pname = "komorebi-recorder"; + strictDeps = true; + inherit (sourceEnv) + KOMOREBI_MAC_SRC + KOMOREBI_MAC_COMMIT + KOMOREBI_WINDOWS_SRC + KOMOREBI_WINDOWS_COMMIT + ; + nativeBuildInputs = [ + pkgs.darwin.cctools + ]; + postFixup = '' + for bin in $out/bin/*; do + if [ -f "$bin" ] && [ -x "$bin" ]; then + # Get all linked libraries and fix libiconv references + otool -L "$bin" | grep -o '/nix/store/[^/]*/lib/libiconv[^ ]*' | while read -r lib; do + install_name_tool -change "$lib" /usr/lib/libiconv.dylib "$bin" + done + fi + done + ''; + }; + + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + + individualCrateArgs = commonArgs // { + inherit cargoArtifacts; + doCheck = false; + doDoc = false; + }; + + fullBuild = craneLib.buildPackage individualCrateArgs; + + # `komorebi-recorder-gui` is the same program as `komorebi-recorder` on a Mac + komorebi-recorder = pkgs.runCommand "komorebi-recorder" { meta = fullBuild.meta // { }; } '' + mkdir -p $out/bin + cp ${fullBuild}/bin/komorebi-recorder $out/bin/ + ''; + in + { + inherit + craneLib + src + individualCrateArgs + fullBuild + komorebi-recorder + ; + }; + + # komorebi-recorder for Windows, cross-compiled from this host + mkWindowsPackages = + { pkgs, windowsSdk }: + let + toolchain = (pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml).override { + targets = [ "x86_64-pc-windows-msvc" ]; + }; + craneLib = (crane.mkLib pkgs).overrideToolchain toolchain; + msvcEnv = windows.mkMsvcEnv { inherit pkgs windowsSdk; }; + src = mkSource { inherit pkgs craneLib; }; + + commonArgs = { + inherit src version; + pname = "komorebi-recorder-windows"; + strictDeps = true; + cargoExtraArgs = "--locked -p komorebi-recorder"; + inherit (sourceEnv) + KOMOREBI_MAC_SRC + KOMOREBI_MAC_COMMIT + KOMOREBI_WINDOWS_SRC + KOMOREBI_WINDOWS_COMMIT + ; + + nativeBuildInputs = [ + pkgs.llvmPackages.clang-unwrapped + pkgs.llvmPackages.lld + pkgs.llvmPackages.llvm + ]; + } + // msvcEnv; + + cargoArtifacts = craneLib.buildDepsOnly commonArgs; + individualCrateArgs = commonArgs // { + inherit cargoArtifacts; + doCheck = false; + doDoc = false; + }; + + komorebi-recorder = craneLib.buildPackage individualCrateArgs; + in + { + inherit + craneLib + individualCrateArgs + msvcEnv + komorebi-recorder + ; + }; + + mkPkgs = + system: + import nixpkgs { + inherit system; + overlays = [ (import rust-overlay) ]; + }; + in + flake-parts.lib.mkFlake { inherit inputs; } { + systems = [ + "aarch64-darwin" + "x86_64-darwin" + ]; + + imports = [ + inputs.treefmt-nix.flakeModule + inputs.git-hooks-nix.flakeModule + ]; + + perSystem = + { config, system, ... }: + let + pkgs = mkPkgs system; + build = mkPackages { inherit pkgs; }; + windowsSdk = windows.mkWindowsSdk { + inherit pkgs; + inherit (windows) windowsSdkVersion windowsCrtVersion; + }; + windowsBuild = mkWindowsPackages { inherit pkgs windowsSdk; }; + rustToolchain = pkgs.rust-bin.fromRustupToolchainFile ./rust-toolchain.toml; + nightlyRustfmt = pkgs.rust-bin.nightly.latest.rustfmt; + rustToolchainWithNightlyRustfmt = pkgs.symlinkJoin { + name = "rust-toolchain-with-nightly-rustfmt"; + paths = [ + nightlyRustfmt + rustToolchain + ]; + }; + in + { + treefmt = { + projectRootFile = "flake.nix"; + programs = { + deadnix.enable = true; + just.enable = true; + nixfmt.enable = true; + taplo.enable = true; + rustfmt = { + enable = true; + package = pkgs.rust-bin.nightly.latest.rustfmt; + }; + }; + }; + + checks = { + komorebi-recorder-clippy = build.craneLib.cargoClippy ( + build.individualCrateArgs + // { + cargoClippyExtraArgs = "--all-targets -- -D warnings"; + } + ); + + komorebi-recorder-fmt = build.craneLib.cargoFmt { + inherit (build) src; + pname = "komorebi-recorder"; + }; + + komorebi-recorder-toml-fmt = build.craneLib.taploFmt { + src = pkgs.lib.sources.sourceFilesBySuffices build.src [ ".toml" ]; + pname = "komorebi-recorder"; + }; + + komorebi-recorder-deny = build.craneLib.cargoDeny { + inherit (build) src; + pname = "komorebi-recorder"; + }; + + komorebi-recorder-nextest = build.craneLib.cargoNextest build.individualCrateArgs; + + # `--all-targets` so that every line of `cfg(windows)` code, tests included, is type-checked + # without a Windows machine + komorebi-recorder-windows-clippy = windowsBuild.craneLib.cargoClippy ( + windowsBuild.individualCrateArgs + // { + cargoClippyExtraArgs = "--all-targets -- -D warnings"; + } + ); + }; + + packages = { + inherit (build) komorebi-recorder; + inherit windowsSdk; + komorebi-recorder-windows = windowsBuild.komorebi-recorder; + default = build.komorebi-recorder; + }; + + apps = { + komorebi-recorder = { + type = "app"; + program = "${build.komorebi-recorder}/bin/komorebi-recorder"; + }; + default = { + type = "app"; + program = "${build.komorebi-recorder}/bin/komorebi-recorder"; + }; + }; + + devShells.default = pkgs.mkShell { + name = "komorebi-recorder"; + + RUST_BACKTRACE = "full"; + + inherit (sourceEnv) + KOMOREBI_MAC_SRC + KOMOREBI_MAC_COMMIT + KOMOREBI_WINDOWS_SRC + KOMOREBI_WINDOWS_COMMIT + ; + + inputsFrom = [ build.fullBuild ]; + + packages = [ + rustToolchainWithNightlyRustfmt + + pkgs.cargo-deny + pkgs.cargo-nextest + # `komorebi-recorder frames` and `selftest` extract frames with ffmpeg; it is never + # used for capture and is not bundled into the packaged binary + pkgs.ffmpeg-headless + pkgs.jq + pkgs.just + pkgs.ripgrep + ]; + }; + + # For working on the Windows side from a Mac: `nix develop .#windows`, then plain + # `cargo clippy --all-targets` (the target is already `x86_64-pc-windows-msvc`). + devShells.windows = pkgs.mkShell { + name = "komorebi-recorder-windows"; + + RUST_BACKTRACE = "full"; + + inherit (windowsBuild.msvcEnv) + CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER + CC_x86_64_pc_windows_msvc + CXX_x86_64_pc_windows_msvc + AR_x86_64_pc_windows_msvc + CFLAGS_x86_64_pc_windows_msvc + CXXFLAGS_x86_64_pc_windows_msvc + CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS + CARGO_BUILD_TARGET + ; + inherit (sourceEnv) + KOMOREBI_MAC_SRC + KOMOREBI_MAC_COMMIT + KOMOREBI_WINDOWS_SRC + KOMOREBI_WINDOWS_COMMIT + ; + + packages = [ + (rustToolchain.override { targets = [ "x86_64-pc-windows-msvc" ]; }) + pkgs.llvmPackages.clang-unwrapped # provides clang-cl + pkgs.llvmPackages.lld # provides lld-link + pkgs.llvmPackages.llvm # provides llvm-lib + pkgs.cargo-deny + pkgs.just + ]; + }; + + pre-commit = { + check.enable = true; + settings.hooks.treefmt = { + enable = true; + package = config.treefmt.build.wrapper; + pass_filenames = false; + }; + }; + }; + + flake = { + overlays.default = + final: _: + let + pkgs = mkPkgs final.system; + build = mkPackages { inherit pkgs; }; + in + { + inherit (build) komorebi-recorder; + }; + }; + }; +} diff --git a/justfile b/justfile index 7477692c..d1e7dbb4 100644 --- a/justfile +++ b/justfile @@ -1,7 +1,7 @@ export RUST_BACKTRACE := "full" -# everything CI checks, in order -check: fmt-check clippy test doc deny +# everything CI checks, in order; run it inside `nix develop` (direnv does) +check: fmt-check clippy check-windows test doc deny # nightly rustfmt, which the devShell provides, and the macros this project does not allow fmt-check: @@ -20,6 +20,9 @@ fix: clippy: cargo clippy --workspace --all-targets --locked -- -D warnings +# type-check every `cfg(windows)` line, tests included, from a Mac (the Windows devShell targets x86_64-pc-windows-msvc) +check-windows: + nix develop .#windows --command cargo clippy --workspace --all-targets --locked -- -D warnings # lib, integration and doc tests; the ones that read komorebi's source need KOMOREBI_MAC_SRC, which `nix develop` sets test: @@ -28,6 +31,7 @@ test: # rustdoc with its warnings as errors, for both hosts: links in `cfg(windows)` docs are only checked on Windows doc: RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked + nix develop .#windows --command env RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --locked deny: cargo deny check @@ -48,6 +52,9 @@ run target: record *args: cargo run --bin komorebi-recorder --locked -- {{ args }} +# cross-compile komorebi-recorder.exe and komorebi-recorder-gui.exe into ./result-windows +build-windows: + nix build .#komorebi-recorder-windows --out-link result-windows deps: cargo update diff --git a/nix/windows.nix b/nix/windows.nix new file mode 100644 index 00000000..245ac568 --- /dev/null +++ b/nix/windows.nix @@ -0,0 +1,80 @@ +# Building for Windows (x86_64-pc-windows-msvc) from any host. +# +# The MSVC CRT and the Windows SDK come from xwin, the compiler and linker from LLVM, so nothing +# here needs a Windows machine. Copied from komorebi for Windows' own flake, with the same pinned +# versions. +{ + windowsSdkVersion = "10.0.26100"; + windowsCrtVersion = "14.44.17.14"; + + mkWindowsSdk = + { + pkgs, + windowsSdkVersion, + windowsCrtVersion, + }: + pkgs.stdenvNoCC.mkDerivation { + name = "windows-sdk-${windowsSdkVersion}-crt-${windowsCrtVersion}"; + + nativeBuildInputs = [ pkgs.xwin ]; + + outputHashAlgo = "sha256"; + outputHashMode = "recursive"; + # NOT the hash komorebi for Windows' own flake pins (sha256-6cLS5q1BDRpLPScfmmKpTTEHUzsgKTKD1+mKvGX9Deo=): + # that did not reproduce on aarch64-darwin on 2026-10-27 with xwin 0.7.0, which produced this one. + # Whether Microsoft re-published that SDK or the splat differs by host is not known. + outputHash = "sha256-YjmEfoxtcmQXvUc+WtaElnyP/4osNvtSI7X6MDaNTjg="; + + buildCommand = '' + export HOME=$(mktemp -d) + xwin --accept-license \ + --sdk-version ${windowsSdkVersion} \ + --crt-version ${windowsCrtVersion} \ + splat --output $out + ''; + }; + + mkMsvcEnv = + { pkgs, windowsSdk }: + let + clangVersion = pkgs.lib.versions.major pkgs.llvmPackages.clang.version; + includes = [ + # IMPORTANT: libclang include path MUST come first to avoid header conflicts + "/imsvc${pkgs.llvmPackages.libclang.lib}/lib/clang/${clangVersion}/include" + "/imsvc${windowsSdk}/crt/include" + "/imsvc${windowsSdk}/sdk/include/ucrt" + "/imsvc${windowsSdk}/sdk/include/um" + "/imsvc${windowsSdk}/sdk/include/shared" + ]; + cflags = builtins.concatStringsSep " " ( + [ + "--target=x86_64-pc-windows-msvc" + "-Wno-unused-command-line-argument" + "-fuse-ld=lld-link" + ] + ++ includes + ); + in + { + # linker for the windows target + CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_LINKER = "lld-link"; + + # c/c++ compiler + CC_x86_64_pc_windows_msvc = "clang-cl"; + CXX_x86_64_pc_windows_msvc = "clang-cl"; + AR_x86_64_pc_windows_msvc = "llvm-lib"; + CFLAGS_x86_64_pc_windows_msvc = cflags; + CXXFLAGS_x86_64_pc_windows_msvc = cflags; + + # target-specific rust flags with linker flavor and library search paths + CARGO_TARGET_X86_64_PC_WINDOWS_MSVC_RUSTFLAGS = builtins.concatStringsSep " " [ + "-Clinker-flavor=lld-link" + "-Lnative=${windowsSdk}/crt/lib/x86_64" + "-Lnative=${windowsSdk}/sdk/lib/um/x86_64" + "-Lnative=${windowsSdk}/sdk/lib/ucrt/x86_64" + ]; + + # cargo target + CARGO_BUILD_TARGET = "x86_64-pc-windows-msvc"; + }; +} From 928ee8ebaa877f2aa6329c1fc44bdf11e01eed3d Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 06:14:00 -0700 Subject: [PATCH 003/108] ci(github): build and attach artifacts for macOS and Windows One workflow builds both platforms and publishes what they built. macOS builds and checks through nix on the self-hosted runner; Windows builds natively on `windows-latest` for x86_64 and aarch64, with fmt, clippy and the tests on x86_64. A publish job packages the executables and checksums and writes the release notes with kokai: a `v*` tag makes a release, a scheduled or manual run on master replaces the rolling nightly, and any other push to master makes a draft. There is no nix on the Windows runner, so it checks out the komorebi sources at the revisions `flake.lock` pins. --- .github/dependabot.yml | 21 ++++ .github/pull_request_template.md | 7 ++ .github/workflows/ci.yaml | 204 +++++++++++++++++++++++++++++++ 3 files changed, 232 insertions(+) create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/ci.yaml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 00000000..48cb2675 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,21 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + assignees: + - "LGUG2Z" + commit-message: + prefix: chore + include: scope + + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + assignees: + - "LGUG2Z" + commit-message: + prefix: chore + include: scope diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..5059bfde --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,7 @@ + diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 00000000..5f5b5249 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,204 @@ +# Adapted from komorebi's windows workflow, with a macOS job added. One workflow builds both +# platforms and one job publishes what they built. + +name: ci + +on: + pull_request: + branches: + - "*" + push: + branches: + - master + - feature/* + - hotfix/* + tags: + - v* + schedule: + - cron: "30 0 * * 0" # Every Sunday at 00:30 UTC + workflow_dispatch: + +jobs: + cargo-deny: + if: github.repository == 'LGUG2Z/komorebi-recorder' + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - uses: EmbarkStudios/cargo-deny-action@v2 + + # `nix flake check` includes the Windows clippy check, which type-checks every `cfg(windows)` line + macos: + if: github.repository == 'LGUG2Z/komorebi-recorder' + runs-on: self-hosted + permissions: write-all + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - run: nix flake check + - run: nix build + - uses: actions/upload-artifact@v7 + with: + name: komorebi-recorder-aarch64-apple-darwin-${{ github.sha }} + path: result/bin/komorebi-recorder + retention-days: 14 + + windows: + if: github.repository == 'LGUG2Z/komorebi-recorder' + strategy: + fail-fast: true + matrix: + platform: + - os-name: Windows-x86_64 + runs-on: windows-latest + target: x86_64-pc-windows-msvc + - os-name: Windows-aarch64 + runs-on: windows-latest + target: aarch64-pc-windows-msvc + runs-on: ${{ matrix.platform.runs-on }} + permissions: write-all + env: + RUSTFLAGS: -Ctarget-feature=+crt-static -Dwarnings + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + # There is no nix here, so the komorebi sources the recorder indexes (and its tests read) are + # checked out at the revisions flake.lock pins, which stays the single place they are pinned + - name: Check out the komorebi sources at the revisions flake.lock pins + shell: bash + run: | + for input in komorebi-mac:LGUG2Z/komorebi-for-mac komorebi-windows:LGUG2Z/komorebi; do + name="${input%%:*}" + repository="${input#*:}" + rev="$(jq -r ".nodes[\"$name\"].locked.rev" flake.lock)" + directory="$RUNNER_TEMP/$name" + git init --quiet "$directory" + git -C "$directory" fetch --quiet --depth 100 "https://github.com/$repository" "$rev" + git -C "$directory" checkout --quiet FETCH_HEAD + variable="$(echo "$name" | sed -e 's/^komorebi-mac$/KOMOREBI_MAC/' -e 's/^komorebi-windows$/KOMOREBI_WINDOWS/')" + echo "${variable}_SRC=$(cygpath -m "$directory")" >> "$GITHUB_ENV" + echo "${variable}_COMMIT=$rev" >> "$GITHUB_ENV" + done + - run: rustup toolchain install stable --profile minimal + - run: rustup component add --toolchain stable-x86_64-pc-windows-msvc clippy + - run: rustup toolchain install nightly --allow-downgrade -c rustfmt + - uses: Swatinem/rust-cache@v2 + with: + cache-on-failure: "true" + cache-all-crates: "true" + key: ${{ matrix.platform.target }} + # the checks run on the x86_64 host, once + - if: matrix.platform.target == 'x86_64-pc-windows-msvc' + run: cargo +nightly fmt --check + - if: matrix.platform.target == 'x86_64-pc-windows-msvc' + run: cargo clippy --workspace --all-targets --locked -- -D warnings + # Informational until it has been seen green on Windows: the tests were written on a Mac and + # nothing has run them here. Make it blocking by removing `continue-on-error`. + - if: matrix.platform.target == 'x86_64-pc-windows-msvc' + run: cargo test --workspace --locked + continue-on-error: true + - uses: houseabsolute/actions-rust-cross@v1 + with: + command: "build" + target: ${{ matrix.platform.target }} + args: "--locked --release" + - uses: actions/upload-artifact@v7 + with: + name: komorebi-recorder-${{ matrix.platform.target }}-${{ github.sha }} + path: | + target/${{ matrix.platform.target }}/release/*.exe + target/${{ matrix.platform.target }}/release/*.pdb + retention-days: 14 + + # Packages what the two builds made and publishes it. What kind of publication depends on the + # event: a push of a `v*` tag releases; a scheduled or manual run on master replaces the rolling + # `nightly` prerelease; any other push to master makes a draft release to look at. + publish: + if: github.repository == 'LGUG2Z/komorebi-recorder' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) + needs: [macos, windows] + runs-on: self-hosted + permissions: write-all + env: + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Decide what is being published + shell: bash + run: | + if [[ "$GITHUB_REF" == refs/tags/v* ]]; then + echo "MODE=release" >> "$GITHUB_ENV" + echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV" + elif [[ "$GITHUB_EVENT_NAME" == schedule || "$GITHUB_EVENT_NAME" == workflow_dispatch ]]; then + echo "MODE=nightly" >> "$GITHUB_ENV" + echo "VERSION=nightly" >> "$GITHUB_ENV" + else + echo "MODE=draft" >> "$GITHUB_ENV" + echo "VERSION=$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n 1)" >> "$GITHUB_ENV" + fi + - uses: actions/download-artifact@v8 + - name: Package + shell: bash + run: | + mkdir dist + # artifacts do not keep the executable bit + chmod +x "komorebi-recorder-aarch64-apple-darwin-${GITHUB_SHA}/komorebi-recorder" + zip -j "dist/komorebi-recorder-${VERSION}-aarch64-apple-darwin.zip" \ + "komorebi-recorder-aarch64-apple-darwin-${GITHUB_SHA}/komorebi-recorder" + for triple in x86_64-pc-windows-msvc aarch64-pc-windows-msvc; do + zip -j "dist/komorebi-recorder-${VERSION}-${triple}.zip" \ + "komorebi-recorder-${triple}-${GITHUB_SHA}/${triple}/release/"*.exe + done + (cd dist && shasum -a 256 -- * > checksums.txt) + - name: Write the release notes + shell: bash + run: | + if ! type kokai >/dev/null; then cargo install --locked kokai --force; fi + case "$MODE" in + nightly) + git tag -d nightly || true + git tag nightly + ref=nightly ;; + draft) ref="$GITHUB_REF_NAME" ;; + release) ref="$GITHUB_REF_NAME" ;; + esac + kokai release --no-emoji --add-links github:commits,issues --ref "$ref" > "$RUNNER_TEMP/release-notes.md" + - name: Publish + shell: bash + run: | + case "$MODE" in + nightly) + gh release delete nightly --yes || true + git push origin :nightly || true + gh release create nightly \ + --target "$GITHUB_SHA" \ + --prerelease \ + --title "komorebi-recorder nightly (${GITHUB_SHA})" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + dist/* ;; + draft) + # a draft is not found by its tag, so the previous one is found in the list + gh api "repos/${GITHUB_REPOSITORY}/releases" --paginate \ + --jq '.[] | select(.draft and .tag_name == "draft") | .id' | + while read -r id; do gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/${id}"; done + gh release create draft \ + --draft \ + --target "$GITHUB_SHA" \ + --title "komorebi-recorder ${VERSION} (draft, ${GITHUB_SHA})" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + dist/* ;; + release) + gh release create "$GITHUB_REF_NAME" \ + --verify-tag \ + --title "komorebi-recorder ${VERSION}" \ + --notes-file "$RUNNER_TEMP/release-notes.md" \ + dist/* ;; + esac + From ab0b943ba09693814fc52a4dd23a9d355c8f1ae1 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 06:14:04 -0700 Subject: [PATCH 004/108] build(wix): an installer for Windows An MSI for Windows built with WiX and `cargo wix`, for x86_64 and aarch64, attached to every release and, on tags, offered to winget. It is its own product: its upgrade code and the GUID of its PATH component are new, since reusing komorebi's would make the two installers upgrade and uninstall each other. WiX only runs on Windows, so `tests/packaging.rs` checks what a Mac can: every binary the crate builds is in the installer, and the installer is not komorebi's. --- .github/workflows/ci.yaml | 17 ++++ README.md | 13 ++- flake.nix | 4 +- komorebi-recorder/tests/packaging.rs | 140 +++++++++++++++++++++++++++ wix/License.rtf | Bin 0 -> 4525 bytes wix/main.wxs | 118 ++++++++++++++++++++++ 6 files changed, 290 insertions(+), 2 deletions(-) create mode 100644 komorebi-recorder/tests/packaging.rs create mode 100644 wix/License.rtf create mode 100644 wix/main.wxs diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 5f5b5249..d16d99ea 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -108,12 +108,17 @@ jobs: command: "build" target: ${{ matrix.platform.target }} args: "--locked --release" + - shell: pwsh + run: | + cargo install cargo-wix + cargo wix --no-build -p komorebi-recorder --nocapture -I .\wix\main.wxs --target ${{ matrix.platform.target }} - uses: actions/upload-artifact@v7 with: name: komorebi-recorder-${{ matrix.platform.target }}-${{ github.sha }} path: | target/${{ matrix.platform.target }}/release/*.exe target/${{ matrix.platform.target }}/release/*.pdb + target/wix/komorebi-recorder-*.msi retention-days: 14 # Packages what the two builds made and publishes it. What kind of publication depends on the @@ -153,8 +158,11 @@ jobs: zip -j "dist/komorebi-recorder-${VERSION}-aarch64-apple-darwin.zip" \ "komorebi-recorder-aarch64-apple-darwin-${GITHUB_SHA}/komorebi-recorder" for triple in x86_64-pc-windows-msvc aarch64-pc-windows-msvc; do + arch="${triple%%-*}" zip -j "dist/komorebi-recorder-${VERSION}-${triple}.zip" \ "komorebi-recorder-${triple}-${GITHUB_SHA}/${triple}/release/"*.exe + cp "komorebi-recorder-${triple}-${GITHUB_SHA}/wix/"*"-${arch}.msi" \ + "dist/komorebi-recorder-${VERSION}-${arch}.msi" done (cd dist && shasum -a 256 -- * > checksums.txt) - name: Write the release notes @@ -202,3 +210,12 @@ jobs: dist/* ;; esac + winget: + if: github.repository == 'LGUG2Z/komorebi-recorder' && startsWith(github.ref, 'refs/tags/v') + needs: publish + runs-on: ubuntu-latest + steps: + - uses: vedantmgoyal2009/winget-releaser@main + with: + identifier: LGUG2Z.komorebi-recorder + token: ${{ secrets.WINGET_TOKEN }} diff --git a/README.md b/README.md index 3fb80ff6..1d842d83 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,18 @@ and for an agent, with commands that print JSON. ## Install -There are no releases yet: build it from source, below. +Every release has, for macOS and for both architectures of Windows, a zip of the executables and, for Windows, an MSI +installer that puts them in `Program Files\komorebi-recorder\bin` and optionally on the `PATH`. The nightly release +is rebuilt every week from `master`. + +| | file | +|---|---| +| macOS (Apple silicon) | `komorebi-recorder--aarch64-apple-darwin.zip` | +| Windows x86_64 | `komorebi-recorder--x86_64.msi` or `komorebi-recorder--x86_64-pc-windows-msvc.zip` | +| Windows ARM64 | `komorebi-recorder--aarch64.msi` or `komorebi-recorder--aarch64-pc-windows-msvc.zip` | + +`checksums.txt` lists the SHA-256 of every file. The installers and executables are not signed, so Windows +SmartScreen and Defender may warn about them. ### From source diff --git a/flake.nix b/flake.nix index b677d619..131667b6 100644 --- a/flake.nix +++ b/flake.nix @@ -56,7 +56,9 @@ path: type: (craneLib.filterCargoSources path type) || (pkgs.lib.hasSuffix "/komorebi-recorder/src/guide.md" path) - || (pkgs.lib.hasInfix "/komorebi-recorder/tests/fixtures/" path); + || (pkgs.lib.hasInfix "/komorebi-recorder/tests/fixtures/" path) + # the installer, which `tests/packaging.rs` checks + || (pkgs.lib.hasInfix "/wix/" path); }; mkPackages = diff --git a/komorebi-recorder/tests/packaging.rs b/komorebi-recorder/tests/packaging.rs new file mode 100644 index 00000000..92c62c0c --- /dev/null +++ b/komorebi-recorder/tests/packaging.rs @@ -0,0 +1,140 @@ +//! The Windows installer (`wix/main.wxs`) has to ship every binary the crate builds and has to be +//! its own product: `WiX` itself only runs on Windows, so these checks are what a Mac or Linux +//! machine can say about the installer. + +use std::path::Path; +use std::path::PathBuf; + +/// komorebi's own installer: reusing its `UpgradeCode` would make the two products upgrade and +/// uninstall each other. +const KOMOREBI_UPGRADE_CODE: &str = "F8B967B5-7E7B-4E3A-895B-B789EC898B54"; + +/// komorebi's `PATH` component, which the installer tracks the `PATH` entry by. +const KOMOREBI_PATH_COMPONENT: &str = "6C6DF276-06C4-4675-BDED-48C5C2BC9BC5"; + +fn repository() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("the crate is a member of the workspace") + .to_path_buf() +} + +fn installer() -> String { + std::fs::read_to_string(repository().join("wix/main.wxs")).expect("wix/main.wxs") +} + +/// The value of attribute `name` on the first element of `tag` in the installer. +fn attribute(text: &str, tag: &str, name: &str) -> String { + let start = text + .find(&format!("<{tag} ")) + .unwrap_or_else(|| panic!("no <{tag}> in the installer")); + let element = &text[start..start + text[start..].find('>').expect("a closed tag")]; + let key = format!("{name}='"); + let value = element + .find(&key) + .unwrap_or_else(|| panic!("<{tag}> has no {name}")) + + key.len(); + element[value..][..element[value..].find('\'').expect("a closed value")].to_string() +} + +fn is_guid(text: &str) -> bool { + let parts: Vec<&str> = text.split('-').collect(); + parts.iter().map(|part| part.len()).collect::>() == [8, 4, 4, 4, 12] + && parts + .iter() + .all(|part| part.chars().all(|c| c.is_ascii_hexdigit())) +} + +/// The binaries cargo builds for this crate: the package's own (`src/main.rs`) and one for each +/// file in `src/bin`. +fn binaries() -> Vec { + let mut names = vec![env!("CARGO_PKG_NAME").to_string()]; + let bin = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/bin"); + if let Ok(entries) = std::fs::read_dir(bin) { + for entry in entries { + let path = entry.expect("a directory entry").path(); + if path.extension().is_some_and(|extension| extension == "rs") { + names.push( + path.file_stem() + .expect("a file name") + .to_string_lossy() + .into_owned(), + ); + } + } + } + names.sort(); + names +} + +#[test] +fn every_binary_the_crate_builds_is_in_the_installer() { + let text = installer(); + for name in binaries() { + assert!( + text.contains(&format!("Name='{name}.exe'")), + "wix/main.wxs does not install {name}.exe" + ); + assert!( + text.contains(&format!("\\{name}.exe'")), + "wix/main.wxs does not take {name}.exe from the build directory" + ); + } +} + +#[test] +fn the_installer_installs_nothing_the_crate_does_not_build() { + let text = installer(); + let built = binaries(); + for piece in text.split("Name='").skip(1) { + let name = &piece[..piece.find('\'').expect("a closed value")]; + if let Some(binary) = name.strip_suffix(".exe") { + assert!( + built.iter().any(|built| built == binary), + "wix/main.wxs installs {name}, which this crate does not build" + ); + } + } +} + +#[test] +fn the_installer_is_its_own_product() { + let text = installer(); + + let upgrade_code = attribute(&text, "Product", "UpgradeCode"); + assert!( + is_guid(&upgrade_code), + "UpgradeCode is not a GUID: {upgrade_code}" + ); + assert!( + !upgrade_code.eq_ignore_ascii_case(KOMOREBI_UPGRADE_CODE), + "UpgradeCode is komorebi's: the two products would replace each other" + ); + assert_eq!(attribute(&text, "Product", "Name"), env!("CARGO_PKG_NAME")); + + assert!( + !text + .to_uppercase() + .contains(&KOMOREBI_PATH_COMPONENT.to_uppercase()), + "the PATH component has komorebi's GUID" + ); + let path_guid = attribute(&text, "Component Id='Path'", "Guid"); + assert!( + is_guid(&path_guid), + "the PATH component's Guid: {path_guid}" + ); +} + +#[test] +fn the_license_the_installer_shows_is_there_and_is_the_projects() { + let rtf = std::fs::read_to_string(repository().join("wix/License.rtf")).expect("License.rtf"); + assert!( + rtf.contains("Komorebi License"), + "not the project's license" + ); + assert!(rtf.contains("Version 2.0.0"), "not the project's license"); + assert!( + installer().contains("wix\\License.rtf"), + "main.wxs does not refer to wix\\License.rtf" + ); +} diff --git a/wix/License.rtf b/wix/License.rtf new file mode 100644 index 0000000000000000000000000000000000000000..3cc0c53300ef2af7cb004df6fc03d2a0154e09e1 GIT binary patch literal 4525 zcmbtY!H(ND5bZfY{(;CI+8VB5JKGdRPeFpBiw12_qyd7c2U?`6qVBreXhJ(d85U>cZz& zwmUh!KZI++zB{P25xLkqX@ZabNgS28#SER z@sl0ModmQz32T~@0FQv_t`BVA1G1dO(pE~?dhVwzOC*&7;@pUjud@YWMFZpvVY~I1ocav<*C}f1Jn60vzBr($|Sl&Nz7o5|8W=sX8 z2sefd9c`v9~N|OyOiJCiwK0|e}YtG7&PFhCGk@HbG&QgK2^!}V%X8I-eZ@~m>K*Ha> z^3zDi4?X0EqH&OE>B_@WsK0=qZV~P>DsNCe&^cJe7Kk)Ew6PkB1j2Sbq`DOM#tx0D z_G(EP;8T6*s}p`ZeFT2I8+MfoQX4+uV0Vb5bM{YF2DAu??x&#X$er&sy@}4D8ED(5 z5bVw?ccfmljHI^e9Yd&!c0&>q-v-15Kq@}ty_0G#57 zA`~q|=pc_uJgaF1EIt#l-S~*;thUBx$VM7DtOVGWTcbe3fS!RCkaS{8dzr~!toRl0 zX>344o8|I~9rQ;#MAkrEeM}HFd%`lH)%hBRs8`#X{h75a%{P)Q%^vTAv=Sk zwrE7GJ+2>ed(^qyZ6UXb(HJgLhm4Nr1;kKdI2q-7j%^KS%+!D(cq9oC6EJa1{g`ZJ zDlatnS!oz*PAhRY%|o0zaU`jmNA}8tz)(U$a`kvY=Gcdc%}7CNc+}~3>L8n%LWzk^ z2V5fjUfYHzF1E%`uuIh1VQ32wg@`mJAY!YoHNwMWLV&XpVZgx&u_}1?G1|nknBX{Q zWEdi^Dq94}3+EKS<5f#+LM z$y03bSgiDs@FEOLjBQ-mGzmig;Lj{T-j5v57{hP*iIxU+c%$V`H#($(;!lgk;w98b zqS{jvk^>!K2lzKD3W5YJ2)s<5LsZ#DEGg+|LWW?VedS3!wTG!93>xlJx^^TJvxY{z zpc)}_6;wV>f=GPsTke6)Zzk*$eD6GW1{`FC5ExnF@^T(v^t5H)>CV7nRHk z5U>S)0xU>~Hhh#+#buutXvn154MM02RX#B2PRWCueuuVTG7?GXy)x>o0MN% zdd-QQCR76#SDG;m*6pRye@9J$e@PsoD1ix>3z{C3 z%U8MSYC;~%V|R_>Yyp0}*^u^(ib`Azj=t4rAI>WfNelJMi;}v)?(`^>-(vBK?PRL7 z3O!QT0ata3k0^yE>T&%I7$|V1qADf8rN + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From d5eacecef8d80af9d1c3aee0d9d75da960764792 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 06:14:07 -0700 Subject: [PATCH 005/108] feat(clock): the master clock Every record in a session is stamped on one monotonic clock: `mach_absolute_time` on macOS and `QueryPerformanceCounter` on Windows, behind a `platform` facade with a third `unsupported` host that builds anywhere. Anchors pair that clock with the wall clock, a map interpolates between them, and a jump detector notices sleep and clock steps by comparing how far each moved. Video timecodes are parsed and printed here too. `Host` and `ClockKind` say what a session was recorded on. --- Cargo.lock | 449 +++++++++ README.md | 2 + dependencies.json | 111 +- docs/ARCHITECTURE.md | 51 + docs/PLATFORM.md | 66 ++ komorebi-recorder/Cargo.toml | 47 + komorebi-recorder/src/clock.rs | 949 ++++++++++++++++++ komorebi-recorder/src/lib.rs | 49 + komorebi-recorder/src/platform/macos/clock.rs | 112 +++ komorebi-recorder/src/platform/macos/mod.rs | 22 + komorebi-recorder/src/platform/macos/shell.rs | 72 ++ komorebi-recorder/src/platform/mod.rs | 181 ++++ komorebi-recorder/src/platform/unsupported.rs | 62 ++ komorebi-recorder/src/platform/win/clock.rs | 83 ++ komorebi-recorder/src/platform/win/mod.rs | 33 + komorebi-recorder/src/platform/win/shell.rs | 111 ++ komorebi-recorder/src/timecode.rs | 277 +++++ 17 files changed, 2676 insertions(+), 1 deletion(-) create mode 100644 docs/ARCHITECTURE.md create mode 100644 docs/PLATFORM.md create mode 100644 komorebi-recorder/src/clock.rs create mode 100644 komorebi-recorder/src/platform/macos/clock.rs create mode 100644 komorebi-recorder/src/platform/macos/mod.rs create mode 100644 komorebi-recorder/src/platform/macos/shell.rs create mode 100644 komorebi-recorder/src/platform/mod.rs create mode 100644 komorebi-recorder/src/platform/unsupported.rs create mode 100644 komorebi-recorder/src/platform/win/clock.rs create mode 100644 komorebi-recorder/src/platform/win/mod.rs create mode 100644 komorebi-recorder/src/platform/win/shell.rs create mode 100644 komorebi-recorder/src/timecode.rs diff --git a/Cargo.lock b/Cargo.lock index 5ca549ce..ee8a0abd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,21 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "addr2line" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5d307320b3181d6d7954e663bd7c774a838b8220fe0593c86d9fb09f498b4b" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "anstream" version = "1.0.0" @@ -52,12 +67,33 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "backtrace" +version = "0.3.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb531853791a215d7c62a30daf0dde835f381ab5de4589cfe7c649d2cbe92bd6" +dependencies = [ + "addr2line", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", + "windows-link", +] + [[package]] name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.1" @@ -99,12 +135,45 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "color-eyre" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5920befb47832a6d61ee3a3a846565cfa39b331331e68a3b1d1116630f2f26d" +dependencies = [ + "backtrace", + "color-spantrace", + "eyre", + "indenter", + "once_cell", + "owo-colors", + "tracing-error", +] + +[[package]] +name = "color-spantrace" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8b88ea9df13354b55bc7234ebcce36e6ef896aca2e42a15de9e10edce01b427" +dependencies = [ + "once_cell", + "owo-colors", + "tracing-core", + "tracing-error", +] + [[package]] name = "colorchoice" version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + [[package]] name = "errno" version = "0.3.14" @@ -115,25 +184,65 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "eyre" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd915d99f24784cdc19fd37ef22b97e3ff0ae756c7e492e9fbfe897d61e2aec" +dependencies = [ + "indenter", + "once_cell", +] + +[[package]] +name = "gimli" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "indenter" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "komorebi-recorder" version = "0.1.0" dependencies = [ "clap", + "color-eyre", + "libc", + "schemars", + "serde", + "serde_json_lenient", + "windows", ] +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + [[package]] name = "libc" version = "0.2.186" @@ -146,12 +255,54 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", +] + +[[package]] +name = "object" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff76201f031d8863c38aa7f905eca4f53abbfa15f609db4277d44cd8938f33fe" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + [[package]] name = "once_cell_polyfill" version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "owo-colors" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -170,6 +321,32 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustix" version = "1.1.4" @@ -183,6 +360,112 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_json_lenient" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e033097bf0d2b59a62b42c18ebbb797503839b26afdda2c4e1415cb6c813540" +dependencies = [ + "itoa", + "memchr", + "ryu", + "serde", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "strsim" version = "0.11.1" @@ -210,6 +493,56 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-error" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b1581020d7a273442f5b45074a6a57d5757ad0a47dac0e9f0bd57b81936f3db" +dependencies = [ + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "sharded-slab", + "thread_local", + "tracing-core", +] + [[package]] name = "unicode-ident" version = "1.0.24" @@ -222,12 +555,113 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -288,6 +722,15 @@ dependencies = [ "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -383,3 +826,9 @@ name = "windows_x86_64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/README.md b/README.md index 1d842d83..e980eb28 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,8 @@ Two things vary independently: the _host_ the recorder runs on (macOS or Windows records (komorebi for Mac or komorebi for Windows). A session records both, so one recorded on Windows can be investigated on a Mac. Every record in a session is stamped on one monotonic clock, and the files in it are never rewritten after they are written. +[Architecture](docs/ARCHITECTURE.md) explains the model and which layer enforces each rule; +[Platform](docs/PLATFORM.md) answers the same questions for each host. ## Crates diff --git a/dependencies.json b/dependencies.json index a0e3e2d7..1276c738 100644 --- a/dependencies.json +++ b/dependencies.json @@ -1,36 +1,80 @@ { "licenses": [ + [ + "0BSD", + [ + "adler2 2.0.1 registry+https://github.com/rust-lang/crates.io-index" + ] + ], [ "Apache-2.0", [ + "addr2line 0.25.1 registry+https://github.com/rust-lang/crates.io-index", + "adler2 2.0.1 registry+https://github.com/rust-lang/crates.io-index", "anstream 1.0.0 registry+https://github.com/rust-lang/crates.io-index", "anstyle 1.0.14 registry+https://github.com/rust-lang/crates.io-index", "anstyle-parse 1.0.0 registry+https://github.com/rust-lang/crates.io-index", "anstyle-query 1.1.5 registry+https://github.com/rust-lang/crates.io-index", "anstyle-wincon 3.0.11 registry+https://github.com/rust-lang/crates.io-index", + "backtrace 0.3.76 registry+https://github.com/rust-lang/crates.io-index", "bitflags 2.11.1 registry+https://github.com/rust-lang/crates.io-index", + "cfg-if 1.0.4 registry+https://github.com/rust-lang/crates.io-index", "clap 4.6.1 registry+https://github.com/rust-lang/crates.io-index", "clap_builder 4.6.0 registry+https://github.com/rust-lang/crates.io-index", "clap_derive 4.6.1 registry+https://github.com/rust-lang/crates.io-index", "clap_lex 1.1.0 registry+https://github.com/rust-lang/crates.io-index", + "color-eyre 0.6.5 registry+https://github.com/rust-lang/crates.io-index", + "color-spantrace 0.3.0 registry+https://github.com/rust-lang/crates.io-index", "colorchoice 1.0.5 registry+https://github.com/rust-lang/crates.io-index", + "dyn-clone 1.0.20 registry+https://github.com/rust-lang/crates.io-index", "errno 0.3.14 registry+https://github.com/rust-lang/crates.io-index", + "eyre 0.6.12 registry+https://github.com/rust-lang/crates.io-index", + "gimli 0.32.3 registry+https://github.com/rust-lang/crates.io-index", "heck 0.5.0 registry+https://github.com/rust-lang/crates.io-index", + "indenter 0.3.4 registry+https://github.com/rust-lang/crates.io-index", "is_terminal_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", + "itoa 1.0.18 registry+https://github.com/rust-lang/crates.io-index", + "lazy_static 1.5.0 registry+https://github.com/rust-lang/crates.io-index", "libc 0.2.186 registry+https://github.com/rust-lang/crates.io-index", + "miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index", + "object 0.37.3 registry+https://github.com/rust-lang/crates.io-index", + "once_cell 1.21.4 registry+https://github.com/rust-lang/crates.io-index", "once_cell_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", + "pin-project-lite 0.2.17 registry+https://github.com/rust-lang/crates.io-index", "proc-macro2 1.0.106 registry+https://github.com/rust-lang/crates.io-index", "quote 1.0.45 registry+https://github.com/rust-lang/crates.io-index", + "ref-cast 1.0.25 registry+https://github.com/rust-lang/crates.io-index", + "ref-cast-impl 1.0.25 registry+https://github.com/rust-lang/crates.io-index", + "rustc-demangle 0.1.27 registry+https://github.com/rust-lang/crates.io-index", "rustix 1.1.4 registry+https://github.com/rust-lang/crates.io-index", + "ryu 1.0.23 registry+https://github.com/rust-lang/crates.io-index", + "serde 1.0.228 registry+https://github.com/rust-lang/crates.io-index", + "serde_core 1.0.228 registry+https://github.com/rust-lang/crates.io-index", + "serde_derive 1.0.228 registry+https://github.com/rust-lang/crates.io-index", + "serde_derive_internals 0.29.1 registry+https://github.com/rust-lang/crates.io-index", + "serde_json 1.0.149 registry+https://github.com/rust-lang/crates.io-index", + "serde_json_lenient 0.2.4 registry+https://github.com/rust-lang/crates.io-index", "syn 2.0.117 registry+https://github.com/rust-lang/crates.io-index", "terminal_size 0.4.4 registry+https://github.com/rust-lang/crates.io-index", + "thread_local 1.1.9 registry+https://github.com/rust-lang/crates.io-index", "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index", "utf8parse 0.2.2 registry+https://github.com/rust-lang/crates.io-index", + "windows 0.62.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-collections 0.3.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-core 0.62.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-future 0.3.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-implement 0.60.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-interface 0.59.3 registry+https://github.com/rust-lang/crates.io-index", + "windows-link 0.2.1 registry+https://github.com/rust-lang/crates.io-index", + "windows-numerics 0.3.1 registry+https://github.com/rust-lang/crates.io-index", + "windows-result 0.4.1 registry+https://github.com/rust-lang/crates.io-index", + "windows-strings 0.5.1 registry+https://github.com/rust-lang/crates.io-index", "windows-sys 0.52.0 registry+https://github.com/rust-lang/crates.io-index", "windows-sys 0.59.0 registry+https://github.com/rust-lang/crates.io-index", "windows-sys 0.60.2 registry+https://github.com/rust-lang/crates.io-index", "windows-targets 0.52.6 registry+https://github.com/rust-lang/crates.io-index", "windows-targets 0.53.5 registry+https://github.com/rust-lang/crates.io-index", + "windows-threading 0.2.1 registry+https://github.com/rust-lang/crates.io-index", "windows_x86_64_msvc 0.52.6 registry+https://github.com/rust-lang/crates.io-index", "windows_x86_64_msvc 0.53.1 registry+https://github.com/rust-lang/crates.io-index" ] @@ -41,6 +85,12 @@ "rustix 1.1.4 registry+https://github.com/rust-lang/crates.io-index" ] ], + [ + "BSL-1.0", + [ + "ryu 1.0.23 registry+https://github.com/rust-lang/crates.io-index" + ] + ], [ "LicenseRef-Komorebi-2.0", [ @@ -50,37 +100,84 @@ [ "MIT", [ + "addr2line 0.25.1 registry+https://github.com/rust-lang/crates.io-index", + "adler2 2.0.1 registry+https://github.com/rust-lang/crates.io-index", "anstream 1.0.0 registry+https://github.com/rust-lang/crates.io-index", "anstyle 1.0.14 registry+https://github.com/rust-lang/crates.io-index", "anstyle-parse 1.0.0 registry+https://github.com/rust-lang/crates.io-index", "anstyle-query 1.1.5 registry+https://github.com/rust-lang/crates.io-index", "anstyle-wincon 3.0.11 registry+https://github.com/rust-lang/crates.io-index", + "backtrace 0.3.76 registry+https://github.com/rust-lang/crates.io-index", "bitflags 2.11.1 registry+https://github.com/rust-lang/crates.io-index", + "cfg-if 1.0.4 registry+https://github.com/rust-lang/crates.io-index", "clap 4.6.1 registry+https://github.com/rust-lang/crates.io-index", "clap_builder 4.6.0 registry+https://github.com/rust-lang/crates.io-index", "clap_derive 4.6.1 registry+https://github.com/rust-lang/crates.io-index", "clap_lex 1.1.0 registry+https://github.com/rust-lang/crates.io-index", + "color-eyre 0.6.5 registry+https://github.com/rust-lang/crates.io-index", + "color-spantrace 0.3.0 registry+https://github.com/rust-lang/crates.io-index", "colorchoice 1.0.5 registry+https://github.com/rust-lang/crates.io-index", + "dyn-clone 1.0.20 registry+https://github.com/rust-lang/crates.io-index", "errno 0.3.14 registry+https://github.com/rust-lang/crates.io-index", + "eyre 0.6.12 registry+https://github.com/rust-lang/crates.io-index", + "gimli 0.32.3 registry+https://github.com/rust-lang/crates.io-index", "heck 0.5.0 registry+https://github.com/rust-lang/crates.io-index", + "indenter 0.3.4 registry+https://github.com/rust-lang/crates.io-index", "is_terminal_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", + "itoa 1.0.18 registry+https://github.com/rust-lang/crates.io-index", + "lazy_static 1.5.0 registry+https://github.com/rust-lang/crates.io-index", "libc 0.2.186 registry+https://github.com/rust-lang/crates.io-index", + "memchr 2.8.0 registry+https://github.com/rust-lang/crates.io-index", + "miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index", + "object 0.37.3 registry+https://github.com/rust-lang/crates.io-index", + "once_cell 1.21.4 registry+https://github.com/rust-lang/crates.io-index", "once_cell_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", + "owo-colors 4.3.0 registry+https://github.com/rust-lang/crates.io-index", + "pin-project-lite 0.2.17 registry+https://github.com/rust-lang/crates.io-index", "proc-macro2 1.0.106 registry+https://github.com/rust-lang/crates.io-index", "quote 1.0.45 registry+https://github.com/rust-lang/crates.io-index", + "ref-cast 1.0.25 registry+https://github.com/rust-lang/crates.io-index", + "ref-cast-impl 1.0.25 registry+https://github.com/rust-lang/crates.io-index", + "rustc-demangle 0.1.27 registry+https://github.com/rust-lang/crates.io-index", "rustix 1.1.4 registry+https://github.com/rust-lang/crates.io-index", + "schemars 1.2.1 registry+https://github.com/rust-lang/crates.io-index", + "schemars_derive 1.2.1 registry+https://github.com/rust-lang/crates.io-index", + "serde 1.0.228 registry+https://github.com/rust-lang/crates.io-index", + "serde_core 1.0.228 registry+https://github.com/rust-lang/crates.io-index", + "serde_derive 1.0.228 registry+https://github.com/rust-lang/crates.io-index", + "serde_derive_internals 0.29.1 registry+https://github.com/rust-lang/crates.io-index", + "serde_json 1.0.149 registry+https://github.com/rust-lang/crates.io-index", + "serde_json_lenient 0.2.4 registry+https://github.com/rust-lang/crates.io-index", + "sharded-slab 0.1.7 registry+https://github.com/rust-lang/crates.io-index", "strsim 0.11.1 registry+https://github.com/rust-lang/crates.io-index", "syn 2.0.117 registry+https://github.com/rust-lang/crates.io-index", "terminal_size 0.4.4 registry+https://github.com/rust-lang/crates.io-index", + "thread_local 1.1.9 registry+https://github.com/rust-lang/crates.io-index", + "tracing 0.1.44 registry+https://github.com/rust-lang/crates.io-index", + "tracing-core 0.1.36 registry+https://github.com/rust-lang/crates.io-index", + "tracing-error 0.2.1 registry+https://github.com/rust-lang/crates.io-index", + "tracing-subscriber 0.3.23 registry+https://github.com/rust-lang/crates.io-index", "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index", "utf8parse 0.2.2 registry+https://github.com/rust-lang/crates.io-index", + "windows 0.62.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-collections 0.3.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-core 0.62.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-future 0.3.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-implement 0.60.2 registry+https://github.com/rust-lang/crates.io-index", + "windows-interface 0.59.3 registry+https://github.com/rust-lang/crates.io-index", + "windows-link 0.2.1 registry+https://github.com/rust-lang/crates.io-index", + "windows-numerics 0.3.1 registry+https://github.com/rust-lang/crates.io-index", + "windows-result 0.4.1 registry+https://github.com/rust-lang/crates.io-index", + "windows-strings 0.5.1 registry+https://github.com/rust-lang/crates.io-index", "windows-sys 0.52.0 registry+https://github.com/rust-lang/crates.io-index", "windows-sys 0.59.0 registry+https://github.com/rust-lang/crates.io-index", "windows-sys 0.60.2 registry+https://github.com/rust-lang/crates.io-index", "windows-targets 0.52.6 registry+https://github.com/rust-lang/crates.io-index", "windows-targets 0.53.5 registry+https://github.com/rust-lang/crates.io-index", + "windows-threading 0.2.1 registry+https://github.com/rust-lang/crates.io-index", "windows_x86_64_msvc 0.52.6 registry+https://github.com/rust-lang/crates.io-index", - "windows_x86_64_msvc 0.53.1 registry+https://github.com/rust-lang/crates.io-index" + "windows_x86_64_msvc 0.53.1 registry+https://github.com/rust-lang/crates.io-index", + "zmij 1.0.21 registry+https://github.com/rust-lang/crates.io-index" ] ], [ @@ -88,6 +185,18 @@ [ "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index" ] + ], + [ + "Unlicense", + [ + "memchr 2.8.0 registry+https://github.com/rust-lang/crates.io-index" + ] + ], + [ + "Zlib", + [ + "miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index" + ] ] ] } diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 00000000..3261fc18 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,51 @@ +# Architecture + +`komorebi-recorder` connects three things that do not know about each other: komorebi, which says what it is doing in +a log and in events on a socket; the operating system, which says what it is doing in its own trace and shows it on a +screen; and the person or agent who has to work out why they disagreed. It records all of them on one clock into a +session directory, and reads that directory back. + +Two things about it vary independently, and everything below is arranged so that neither leaks into the other. + +| axis | what varies | where the variation lives | what a session records | +|---|---|---|---| +| host | the OS the recorder runs on: macOS or Windows (a third `unsupported` host builds anywhere) | [`platform`](../komorebi-recorder/src/platform/mod.rs), selected by `cfg` | `host` in the manifest | + +## The master clock + +Everything in a session is stamped on one clock: the host's monotonic clock, in nanoseconds. That is +`mach_absolute_time` on macOS and `QueryPerformanceCounter` on Windows. Video frames and the OS trace are already on it. +komorebi's log lines carry UTC wall-clock time, so they are mapped onto it: the recorder samples a paired reading of +both clocks (an `Anchor`) at the start, every few seconds and at every sleep, and a `ClockMap` interpolates between +them. A `JumpDetector` notices sleep and clock steps by comparing how far each clock moved between two observations, +and the anchors on either side of the jump are what make the mapping right on both sides of it. + +Three things are worth knowing before reading a session: + +- The field names ending in `_mach_ns` keep their historical spelling. They hold master-clock nanoseconds on whichever + host recorded the session; the manifest's `host.clock` says which clock that was (`mach`, `qpc` or `std`). A name that + changed would stop older sessions being readable by newer builds and the reverse. +- The master clock is not the wall clock. On macOS it pauses while the machine sleeps, so video time skips a sleep + instead of showing a hole. Whether the performance counter counts a suspend on Windows is measured, not assumed. +- A timestamp is a plain `u64` of nanoseconds. Nothing in the type system says which clock it is on: that is held by + convention, and by the tests that place records at known times. + +## The platform facade + +The rest of the crate never names a system API. [`platform`](../komorebi-recorder/src/platform/mod.rs) is a facade of +small modules (`clock`, `shell` and, as the recorder grows, `capture`, `power`, `session`, `trace`, `process`, `apps` +and `window`), and `cfg` selects the directory behind it: `platform/macos/`, `platform/win/` or `platform/unsupported.rs`. +Each host provides the same names with the same signatures. A missing or mismatched function is a compile error on the +host being built, and on no other, so the Windows host is built from a Mac by `just check-windows` and by CI, and +`unsupported` (which exists so that the commands that only read a session directory work on a Linux agent box) is built +only on a third OS. + +Where one host has no analogue of something, the facade function exists on both and one answers "nothing": `trace::start` +on the `unsupported` host returns no taps and a line saying so, and `power::start` returns no watcher. + +## Rules and the layer that enforces them + +| Rule | Enforced by | +|---|---| +| Every host provides the same functions with the same signatures | the compiler, for the host being built; `just check-windows` and CI build the Windows host from a Mac; nothing builds `unsupported` | +| A timestamp in a session is on the master clock | convention, and the tests that place records at known times: the type is a plain `u64` | diff --git a/docs/PLATFORM.md b/docs/PLATFORM.md new file mode 100644 index 00000000..1c7f9cb3 --- /dev/null +++ b/docs/PLATFORM.md @@ -0,0 +1,66 @@ +# Platform support + +This document gathers, in one place, what differs by operating system. Most of the details are written down on the items +that implement them; here the same questions are answered once per host, so that someone who has to run the recorder on +a machine, or change it for one, does not have to read the code to find out. + +The recorder is built for macOS and Windows. A third host, `unsupported`, builds on anything else so that the commands +that only read a session directory work on a Linux agent box; recording there says that it cannot. + +## Status + +| host | built | run | +|---|---|---| +| macOS (Apple silicon) | yes | yes: written and run on macOS 26 | +| Windows x86_64 | yes, natively on `windows-latest` and cross-compiled from a Mac | no: type-checked and cross-built only | +| Windows ARM64 | yes, cross-built on an x86_64 runner | no | +| anything else | the `unsupported` host builds | nothing records | + +Nothing that touches a real Windows desktop has been seen running: the Windows host was written from the Windows API +documentation and komorebi for Windows' source, and every claim below about Windows says what the code does, not what +was observed. + +## Vocabulary + +- **Host**: the OS the recorder runs on. It decides how the screen is captured, which clock is read, how the OS says it + is going to sleep and what the OS trace is made of. +- **Flavor**: which komorebi is being recorded. There is one per OS, and a recorder watches the one on its own host, but + a session records its flavor, so a session from either can be read on either host. +- **Master clock**: the host's monotonic clock, in nanoseconds, on which every record in a session is stamped. +- **Session**: the directory a recording is written to. +- **Segment**: one contiguous stretch of one display's video, a file of its own. A display that is unplugged and plugged + in again has two. + +## The same questions, per host + +### The master clock + +| | macOS | Windows | unsupported | +|---|---|---|---| +| clock | `mach_absolute_time`, scaled by `mach_timebase_info` | `QueryPerformanceCounter`, scaled by `QueryPerformanceFrequency` (10 MHz when Windows will not say) | the time since the first reading in the process, plus one second so that no reading is zero | +| `host.clock` in the manifest | `mach` | `qpc` | `std` | +| counts the time the machine sleeps | no | not known: measured, not assumed | no | +| other clocks read beside it | `mach_continuous_time`, which counts sleep | `QueryInterruptTime`, which counts sleep, and `QueryUnbiasedInterruptTimePrecise`, which does not | none | + +Video frame times and the OS trace are on the master clock already on both hosts (ScreenCaptureKit and the unified log's +`machTimestamp` on a Mac; Windows.Graphics.Capture on Windows). The field names ending in `_mach_ns` are spelled that way +on every host: see [the architecture](ARCHITECTURE.md#the-master-clock). + +## Degraded modes + +- `record --no-video` records everything but the screen, on the same clock. It asks the system nothing about displays and + needs no permission. +- `record --no-komorebi` skips touching komorebi altogether, and `--no-oslog` skips the OS trace. +- A komorebi that is not running when a recording starts is not an error: it is noted, and the recorder subscribes when + it appears. +- The `unsupported` host investigates sessions and refuses to record. + +## Advice by audience + +- **Recording on macOS.** Grant Screen Recording to the terminal, and start komorebi with `--log-level trace`. +- **Recording on Windows.** There is nothing to grant. Use `komorebi-recorder.exe` for commands, since the GUI build + prints nowhere, and start komorebi with `--log-level debug` and `RUST_LOG=komorebi=debug`. +- **Investigating on any host.** A session is read the same everywhere. `ffmpeg` is needed only for `frames` and + `selftest`. +- **Changing code that has a host-specific part.** Provide the function on every host, and run `just check-windows` from a + Mac: it type-checks every `cfg(windows)` line, tests included. diff --git a/komorebi-recorder/Cargo.toml b/komorebi-recorder/Cargo.toml index 2f21686b..30a1ac9d 100644 --- a/komorebi-recorder/Cargo.toml +++ b/komorebi-recorder/Cargo.toml @@ -7,3 +7,50 @@ repository.workspace = true [dependencies] clap = { workspace = true } +color-eyre = { workspace = true } +schemars = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[target.'cfg(target_os = "macos")'.dependencies] +libc = "0.2" + +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62", features = [ + "Foundation", + "Graphics", + "Graphics_Capture", + "Graphics_DirectX", + "Graphics_DirectX_Direct3D11", + "Wdk_System_SystemServices", + "Win32_Foundation", + "Win32_Graphics_Direct3D", + "Win32_Graphics_Direct3D11", + "Win32_Graphics_Dwm", + "Win32_Graphics_Dxgi", + "Win32_Graphics_Dxgi_Common", + "Win32_Graphics_Gdi", + "Win32_Media", + "Win32_Media_MediaFoundation", + "Win32_System_Com", + "Win32_System_Com_StructuredStorage", + "Win32_System_EventLog", + "Win32_System_LibraryLoader", + "Win32_System_Performance", + "Win32_System_Power", + "Win32_System_RemoteDesktop", + "Win32_System_StationsAndDesktops", + "Win32_System_SystemInformation", + "Win32_System_SystemServices", + "Win32_System_Threading", + "Win32_System_Variant", + "Win32_System_WinRT", + "Win32_System_WinRT_Direct3D11", + "Win32_System_WinRT_Graphics_Capture", + "Win32_System_WindowsProgramming", + "Win32_UI_Accessibility", + "Win32_UI_HiDpi", + "Win32_UI_Shell", + "Win32_UI_Shell_Common", + "Win32_UI_WindowsAndMessaging", +] } diff --git a/komorebi-recorder/src/clock.rs b/komorebi-recorder/src/clock.rs new file mode 100644 index 00000000..9c3e6863 --- /dev/null +++ b/komorebi-recorder/src/clock.rs @@ -0,0 +1,949 @@ +//! The master clock: the one monotonic clock every record in a session is stamped on, and the +//! conversions between it, the wall clock and video time. +//! +//! Every source in a recording is expressed in nanoseconds on one host monotonic clock: capture +//! frame timestamps, the OS trace, komorebi's subscriber events, incidents. komorebi's own log +//! lines are the exception. They carry a UTC wall clock time, and are mapped +//! onto the master clock with the [`Anchor`]s sampled while recording. +//! +//! # Which clock is the master clock +//! +//! [`mono_now_ns`] reads it, through [`crate::platform::clock`], and [`Host::clock`] names it: the +//! field holds the [`ClockKind`] the session was recorded with, which the manifest stores as +//! `host.clock`. It does not name the wall clock, or any [`ReferenceClock`]. +//! +//! | [`ClockKind`] | `host.clock` | read with | +//! |---|---|---| +//! | [`Mach`](crate::platform::ClockKind::Mach) | `mach` | `mach_absolute_time`, on macOS | +//! | [`Qpc`](crate::platform::ClockKind::Qpc) | `qpc` | `QueryPerformanceCounter`, on Windows | +//! | [`Std`](crate::platform::ClockKind::Std) | `std` | [`std::time::Instant`] since the first reading in the process, plus one second so that it is never zero, on a host with no capture | +//! +//! `mach_absolute_time` pauses while the machine sleeps. Whether the Windows counter does is not +//! assumed: `doctor --probe clock` measures it, and where the OS can announce a sleep the +//! recorder listens for it (`platform::power`) rather than inferring it from the clocks alone. +//! +//! The `*_mach_ns` field names throughout a session are historical: they mean "master monotonic +//! nanoseconds", whichever clock that was, and keeping them means a session written by any build +//! is readable by any other. `host.clock` is what says which clock the numbers are on. +//! +//! # The four scales +//! +//! | Scale | Unit | Where it appears | Converted with | +//! |---|---|---|---| +//! | host ticks | ticks of the OS counter | raw counter reads, the unified log's `machTimestamp` | [`Timebase`] | +//! | master clock | nanoseconds, `u64` | every `*_mach_ns` field | (the scale the others convert to and from) | +//! | wall time | microseconds since the Unix epoch, `i64`, UTC | komorebi's log lines, every `wall_us` field | [`ClockMap`] | +//! | video time | seconds, `f64`, since the first captured frame | timecodes (see [`crate::timecode`]), `video_s` fields | subtracting the master clock time of video `t = 0` | +//! +//! Video `t = 0` is the master clock time of the earliest first frame across displays, recorded in +//! the manifest as `video_t0_mach_ns`; with no frame at all it is the recording's start. A time +//! before the first frame is negative. The session manifest's `video_seconds` and +//! `mach_at_video_seconds` do that subtraction in each direction. +//! +//! # How the pieces relate +//! +//! * [`Timebase`] says what one host tick is worth. It is read from the OS once ([`timebase`]), +//! and stored in the manifest, so that a session recorded on one machine is read on another with +//! the tick size of the machine that recorded it. +//! * [`Anchor`] pairs one wall clock reading with the master clock time at which it was made. +//! The recording loop samples one about every 5 seconds, and one on either side of every sleep. +//! * [`ClockMap`] is built from a session's anchors, and maps between wall time and the master +//! clock by interpolating between them. +//! * [`JumpDetector`] compares the wall clock with the master clock over each loop iteration, and +//! reports a [`Jump`] when they disagree. The two anchors of a [`Jump`] are the pair of anchors +//! [`ClockMap`] needs to treat a sleep as a step, rather than as drift. +//! * [`ReferenceClock`] is another host clock, read next to the master clock so that a probe can +//! say what the master clock does across a sleep. +//! +//! # Example: ticks, master clock, wall time and video time +//! +//! ```rust +//! use komorebi_recorder::clock::{Anchor, ClockMap, Timebase}; +//! +//! // a counter that ticks at 24 MHz, which is 125/3 nanoseconds a tick +//! let timebase = Timebase::from_frequency(24_000_000); +//! assert_eq!(timebase, Timebase { numer: 125, denom: 3 }); +//! +//! // host ticks -> master clock nanoseconds +//! let mach_ns = timebase.ticks_to_ns(2_400_000_000); +//! assert_eq!(mach_ns, 100_000_000_000); +//! +//! // two anchors taken 5 seconds apart, as the recorder does +//! let map = ClockMap::new(vec![ +//! Anchor { wall_us: 1_800_000_000_000_000, mach_ns: 100_000_000_000 }, +//! Anchor { wall_us: 1_800_000_005_000_000, mach_ns: 105_000_000_000 }, +//! ]); +//! +//! // master clock -> wall time, and back +//! let wall_us = map.mach_to_wall(102_500_000_000).expect("the map has anchors"); +//! assert_eq!(wall_us, 1_800_000_002_500_000); +//! assert_eq!(map.wall_to_mach(wall_us), Some(102_500_000_000)); +//! +//! // master clock -> video time, for a first frame that was captured at 101 s on the master clock +//! let video_t0_mach_ns = 101_000_000_000_i128; +//! let video_s = (102_500_000_000_i128 - video_t0_mach_ns) as f64 / 1e9; +//! assert_eq!(video_s, 1.5); +//! +//! // and back, from video time to the master clock +//! assert_eq!(video_t0_mach_ns + (video_s * 1e9).round() as i128, 102_500_000_000); +//! +//! // a time before the first frame is negative in video time +//! assert_eq!((100_500_000_000_i128 - video_t0_mach_ns) as f64 / 1e9, -0.5); +//! ``` +//! +//! [`Host::clock`]: crate::platform::Host::clock +//! [`ClockKind`]: crate::platform::ClockKind + +use crate::platform; +use serde::Deserialize; +use serde::Serialize; +use std::time::SystemTime; +use std::time::UNIX_EPOCH; + +/// The size of one tick of the host clock: `numer / denom` nanoseconds. +/// +/// It is read from the OS once ([`timebase`]) and stored in a session's manifest as `timebase`, so +/// that a session recorded on one machine is converted with the tick size of the machine that +/// recorded it, and not with the reader's. The unified log's `machTimestamp` is in ticks of this +/// size, and the manifest's timebase is what converts it. +/// +/// A timebase this crate produces has a non-zero `numer` and `denom`. The fields are public and +/// are read back from `session.json`, so a hand-edited manifest can hold zeros, which make +/// [`ticks_to_ns`](Timebase::ticks_to_ns) and [`ns_to_ticks`](Timebase::ns_to_ticks) panic. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::clock::Timebase; +/// +/// // Apple silicon's counter: 24 MHz, so 125/3 nanoseconds a tick +/// let timebase = Timebase { numer: 125, denom: 3 }; +/// assert_eq!(timebase.ticks_to_ns(24_000_000), 1_000_000_000); +/// assert_eq!(timebase.ns_to_ticks(1_000_000_000), 24_000_000); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Timebase { + /// The numerator of the nanoseconds a tick is worth. + pub numer: u32, + /// The denominator of the nanoseconds a tick is worth. + pub denom: u32, +} + +impl Timebase { + /// Returns the timebase of a counter that ticks `hertz` times a second, as + /// `QueryPerformanceCounter` does. + /// + /// One tick is `1e9 / hertz` nanoseconds. The fraction is reduced, so a 10 MHz counter is + /// `100/1`, not `1000000000/10000000`. A counter faster than about 4.29 GHz has a reduced + /// denominator that does not fit in a `u32`: both parts are then halved until they do, which + /// keeps the ratio approximately. A `hertz` of zero is treated as one. + /// + /// The result always has a non-zero `numer` and `denom`. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::clock::Timebase; + /// + /// // the usual `QueryPerformanceFrequency`: 10 MHz, which is 100 ns a tick + /// assert_eq!(Timebase::from_frequency(10_000_000), Timebase { numer: 100, denom: 1 }); + /// + /// // Apple silicon's counter + /// assert_eq!(Timebase::from_frequency(24_000_000), Timebase { numer: 125, denom: 3 }); + /// + /// // a nanosecond counter, and a nonsense zero, are both fine + /// assert_eq!(Timebase::from_frequency(1_000_000_000), Timebase { numer: 1, denom: 1 }); + /// assert_eq!(Timebase::from_frequency(0).ticks_to_ns(5), 5_000_000_000); + /// ``` + #[must_use] + pub fn from_frequency(hertz: u64) -> Self { + fn gcd(a: u64, b: u64) -> u64 { + if b == 0 { a } else { gcd(b, a % b) } + } + + let hertz = hertz.max(1); + let divisor = gcd(1_000_000_000, hertz); + let (mut numer, mut denom) = (1_000_000_000 / divisor, hertz / divisor); + while numer > u64::from(u32::MAX) || denom > u64::from(u32::MAX) { + numer = (numer / 2).max(1); + denom = (denom / 2).max(1); + } + + Self { + numer: numer as u32, + denom: denom as u32, + } + } + + /// Returns `ticks` host clock ticks as nanoseconds: `ticks * numer / denom`. + /// + /// The product is computed in 128 bits, so it cannot overflow, and the quotient is truncated + /// toward zero. A result that does not fit in a `u64` is truncated to its low 64 bits. + /// + /// # Panics + /// + /// Panics if `denom` is zero. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::clock::Timebase; + /// + /// let timebase = Timebase { numer: 125, denom: 3 }; + /// assert_eq!(timebase.ticks_to_ns(24_000_000), 1_000_000_000); + /// + /// // one tick is 41.67 ns, truncated + /// assert_eq!(timebase.ticks_to_ns(1), 41); + /// ``` + #[must_use] + pub fn ticks_to_ns(self, ticks: u64) -> u64 { + (u128::from(ticks) * u128::from(self.numer) / u128::from(self.denom)) as u64 + } + + /// Returns `ns` nanoseconds as host clock ticks: `ns * denom / numer`. + /// + /// The product is computed in 128 bits, so it cannot overflow, and the quotient is truncated + /// toward zero. A result that does not fit in a `u64` is truncated to its low 64 bits. + /// + /// # Panics + /// + /// Panics if `numer` is zero. + /// + /// # Example: a round trip can lose a tick + /// + /// Both directions truncate, so a tick count that goes to nanoseconds and back comes back + /// equal only when the nanoseconds were exact: + /// + /// ```rust + /// use komorebi_recorder::clock::Timebase; + /// + /// let timebase = Timebase { numer: 125, denom: 3 }; + /// assert_eq!(timebase.ns_to_ticks(timebase.ticks_to_ns(24_000_000)), 24_000_000); + /// assert_eq!(timebase.ns_to_ticks(timebase.ticks_to_ns(1)), 0); + /// ``` + #[must_use] + pub fn ns_to_ticks(self, ns: u64) -> u64 { + (u128::from(ns) * u128::from(self.denom) / u128::from(self.numer)) as u64 + } +} + +/// Returns what one tick of the host clock is worth in nanoseconds. +/// +/// It is the host's own: from `mach_timebase_info` on macOS (125/3 on Apple silicon, and 1/1 if the +/// OS reports an unusable one), from [`Timebase::from_frequency`] of `QueryPerformanceFrequency` on +/// Windows (10 MHz if that cannot be read), and 1/1 on a host with no capture, whose clock already +/// counts nanoseconds. A session records the timebase it was written with in its manifest; use that +/// one, not this, to convert a recorded session's ticks. +#[must_use] +pub fn timebase() -> Timebase { + platform::clock::timebase() +} + +/// Another clock the host has, read alongside the master clock to find out what the master clock +/// does while the machine sleeps. +/// +/// A reference clock is never used to stamp a record. `doctor` reads each one together with the +/// master clock and the wall clock, and reports how far each moved across a sleep. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct ReferenceClock { + /// The name of the OS clock, as the OS spells it (`mach_continuous_time`). + pub name: String, + /// Whether it keeps counting while the machine is suspended. + pub counts_sleep: bool, + /// Its reading, in nanoseconds from an origin that the clock chooses: only differences between + /// two readings of the same clock mean anything. + pub ns: u64, +} + +/// Returns a reading of each of the host's other clocks (see [`ReferenceClock`]). +/// +/// On macOS that is `mach_continuous_time`, which counts the time the machine sleeps. On Windows +/// it is `QueryInterruptTime`, which counts it, and `QueryUnbiasedInterruptTimePrecise`, which +/// does not. A host with no capture has none. +#[must_use] +pub fn reference_clocks() -> Vec { + platform::clock::reference_clocks() +} + +/// Returns the master clock in nanoseconds: the host monotonic clock every record in a session is +/// on. +/// +/// On macOS this is `mach_absolute_time`, which pauses while the machine sleeps; on Windows it is +/// `QueryPerformanceCounter`. The `*_mach_ns` fields in a session keep their historical names and +/// hold this value; the manifest's `host.clock` says which clock it was. +/// +/// The origin is chosen by the OS, so a reading means something only next to another reading of the +/// same clock. The clock is monotonic: it does not go backwards, and `doctor` reports whether it +/// did while it was read. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::clock::mono_now_ns; +/// +/// let first = mono_now_ns(); +/// let second = mono_now_ns(); +/// assert!(second >= first); +/// ``` +#[must_use] +pub fn mono_now_ns() -> u64 { + platform::clock::now_ns() +} + +/// Returns the wall clock in microseconds since the Unix epoch (UTC). +/// +/// Unlike [`mono_now_ns`], the wall clock can be stepped, forwards or backwards, by the user or by +/// time synchronization, and keeps counting while the machine sleeps. A time before 1970, which +/// means the clock is badly wrong, is returned as a negative number rather than as an error. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::clock::wall_now_us; +/// +/// // after 2020-01-01 and before 2100-01-01 +/// let now = wall_now_us(); +/// assert!(now > 1_577_836_800_000_000); +/// assert!(now < 4_102_444_800_000_000); +/// ``` +#[must_use] +pub fn wall_now_us() -> i64 { + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(since) => since.as_micros() as i64, + // before 1970: the clock is badly wrong, but a negative time is still a time + Err(before) => -(before.duration().as_micros() as i64), + } +} + +/// A paired reading of the wall clock and the master clock: the master clock time at which the +/// wall clock read `wall_us`. +/// +/// An anchor is the only link between komorebi's log, which is stamped with the wall clock, and +/// everything else, which is on the master clock. A session keeps a series of them in its manifest +/// (`anchors`), and [`ClockMap`] interpolates between them. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::clock::{wall_now_us, Anchor}; +/// +/// let anchor = Anchor::sample(); +/// // the wall clock was read a moment ago +/// assert!((wall_now_us() - anchor.wall_us).abs() < 1_000_000); +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Anchor { + /// The wall clock, in microseconds since the Unix epoch (UTC). + pub wall_us: i64, + /// The master clock, in nanoseconds, at the same moment. + pub mach_ns: u64, +} + +impl Anchor { + /// Takes a tight paired reading: several back-to-back samples, keeping the one whose wall read + /// was bracketed by the narrowest pair of mach reads, which bounds the pairing error to the + /// duration of a couple of syscalls. + /// + /// Seven samples are taken. The `mach_ns` of the one kept is the midpoint of the two master + /// clock reads around its wall clock read. + #[must_use] + pub fn sample() -> Self { + let mut best: Option<(u64, Anchor)> = None; + for _ in 0..7 { + let before = mono_now_ns(); + let wall_us = wall_now_us(); + let after = mono_now_ns(); + let width = after.saturating_sub(before); + let anchor = Anchor { + wall_us, + mach_ns: before + width / 2, + }; + + if best.is_none_or(|(best_width, _)| width < best_width) { + best = Some((width, anchor)); + } + } + + best.map(|(_, anchor)| anchor).unwrap_or_else(|| Anchor { + wall_us: wall_now_us(), + mach_ns: mono_now_ns(), + }) + } + + /// The wall clock minus the mach clock, in microseconds. + fn offset_us(self) -> i128 { + i128::from(self.wall_us) - i128::from(self.mach_ns / 1_000) + } +} + +/// How far the wall clock must outrun the mach clock between two close observations for it to +/// count as a jump. Observations are made every ~50 ms, so ordinary drift is microseconds. +const JUMP_THRESHOLD_NS: i128 = 1_000_000_000; + +/// A discontinuity between the wall clock and the master clock, as [`JumpDetector::observe`] +/// reports it. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Jump { + /// How far the wall clock moved beyond the master clock, in seconds. Positive: the machine + /// slept (mach time pauses while asleep, wall time does not) or the clock stepped forward. + /// Negative: the wall clock was stepped back. + pub seconds: f64, + /// The last good pairing before the jump: the previous observation. + pub before: Anchor, + /// The first pairing after the jump: the observation that found it. + pub after: Anchor, +} + +/// Notices sleep/wake and clock steps by watching both clocks. +/// +/// Feed it a fresh [`Anchor`] every loop iteration. When the two clocks disagree about how much +/// time passed since the previous observation, the machine was suspended (or the clock was +/// stepped), and the two anchors on either side of the discontinuity are exactly what +/// [`ClockMap`] needs to map wall time to mach time correctly on both sides of it. +/// +/// The detector is a state machine with one piece of state, the previous observation. It reports a +/// [`Jump`] when the wall clock and the master clock moved by amounts that differ by 1 second or +/// more since then. Observations are expected about every 50 ms, so ordinary drift is +/// microseconds, and a process that was merely descheduled for a long time is not a jump, because +/// both clocks moved together. A suspension shorter than 1 second is not reported. +/// +/// On a host whose master clock keeps counting through a sleep, the two clocks do not disagree and +/// a sleep is not noticed this way: see the module docs, and `PowerTracker` for the OS's own +/// announcement of a sleep. +/// +/// # Example: noticing a sleep, and mapping time across it +/// +/// ```rust +/// use komorebi_recorder::clock::{Anchor, ClockMap, JumpDetector}; +/// +/// // wall time 2000 s and master clock time 1000 s are the same moment +/// let start = Anchor { wall_us: 2_000_000_000, mach_ns: 1_000_000_000_000 }; +/// let mut detector = JumpDetector::new(start); +/// +/// // 50 ms later on both clocks: nothing to report +/// let before = Anchor { wall_us: 2_000_050_000, mach_ns: 1_000_050_000_000 }; +/// assert_eq!(detector.observe(before), None); +/// +/// // the lid is closed for an hour: the wall clock moves on, the master clock barely does +/// let after = Anchor { +/// wall_us: 2_000_100_000 + 3_600_000_000, +/// mach_ns: 1_000_100_000_000, +/// }; +/// let jump = detector.observe(after).expect("the clocks disagree by an hour"); +/// assert!((jump.seconds - 3_600.0).abs() < 0.01); +/// assert_eq!((jump.before, jump.after), (before, after)); +/// +/// // with both anchors in the map, wall time maps correctly on either side of the sleep +/// let map = ClockMap::new(vec![jump.before, jump.after]); +/// assert_eq!(map.wall_to_mach(2_000_050_000), Some(1_000_050_000_000)); +/// let two_seconds_after_waking = after.wall_us + 2_000_000; +/// assert_eq!(map.wall_to_mach(two_seconds_after_waking), Some(1_002_100_000_000)); +/// +/// // the detector carries on from the new position +/// let later = Anchor { wall_us: after.wall_us + 50_000, mach_ns: after.mach_ns + 50_000_000 }; +/// assert_eq!(detector.observe(later), None); +/// ``` +#[derive(Debug, Clone, Copy)] +pub struct JumpDetector { + last: Anchor, +} + +impl JumpDetector { + /// Returns a detector whose previous observation is `first`. + #[must_use] + pub fn new(first: Anchor) -> Self { + Self { last: first } + } + + /// Compares `now` with the previous observation, and makes `now` the previous observation. + /// + /// Returns a [`Jump`] if the wall clock and the master clock moved by amounts that differ by + /// at least 1 second, in either direction, and `None` otherwise. The observation is recorded + /// either way, so one discontinuity is reported once. + pub fn observe(&mut self, now: Anchor) -> Option { + let wall_ns = (i128::from(now.wall_us) - i128::from(self.last.wall_us)) * 1_000; + let mach_ns = i128::from(now.mach_ns) - i128::from(self.last.mach_ns); + let difference = wall_ns - mach_ns; + let before = std::mem::replace(&mut self.last, now); + + (difference.abs() >= JUMP_THRESHOLD_NS).then_some(Jump { + seconds: difference as f64 / 1e9, + before, + after: now, + }) + } +} + +/// Segments whose wall and mach durations disagree by more than this are treated as a clock step +/// (an NTP jump, or the machine sleeping while the mach clock was paused) rather than as drift. +const STEP_THRESHOLD_NS: i128 = 20_000_000; + +/// A two-way map between wall clock time and the master clock, built by piecewise-linear +/// interpolation between the anchors sampled during a recording. +/// +/// A map is built from a session's anchors (the manifest's `anchors`) and answers in both +/// directions: [`wall_to_mach`] turns a wall clock time, such as one of komorebi's log lines, into +/// the master clock, and [`mach_to_wall`] does the reverse, to show a master clock time as UTC. +/// +/// Each pair of neighboring anchors, ordered by master clock time, is a segment. The wall clock +/// and the master clock tick at slightly different rates, so inside a segment a time is +/// interpolated linearly. If the two clocks moved by amounts that differ by more than 20 ms over a +/// segment, the segment holds a step rather than drift: the machine slept while the master clock +/// was paused, or the wall clock was stepped. Where in the segment the step happened is not +/// known, so a time inside it is taken from the nearer of the segment's two anchors, using that +/// anchor's offset between the clocks. Outside the first and last anchors, a time is taken +/// from the nearer end in the same way. With a single anchor, the map is a constant offset. +/// +/// Both directions return `None` if, and only if, the map has no anchors. +/// +/// A sleep inside a segment is placed at the nearer anchor, which is why the recorder adds an +/// anchor on either side of every sleep it finds (see [`JumpDetector`]). +/// +/// The anchors of a map are guaranteed to be sorted by master clock time, with no two at the same +/// time, because [`ClockMap::new`] is the only way to make one that has any: the field is private. +/// +/// ```rust,compile_fail,E0451 +/// use komorebi_recorder::clock::{Anchor, ClockMap}; +/// +/// // an unsorted list of anchors, which `ClockMap::new` would put in order +/// let anchors = vec![ +/// Anchor { wall_us: 2_005_000_000, mach_ns: 1_005_000_000_000 }, +/// Anchor { wall_us: 2_000_000_000, mach_ns: 1_000_000_000_000 }, +/// ]; +/// let _map = ClockMap { anchors }; +/// ``` +/// +/// # Example: a clock step +/// +/// ```rust +/// use komorebi_recorder::clock::{Anchor, ClockMap}; +/// +/// let at = |wall_s: i64, mach_s: u64| Anchor { +/// wall_us: wall_s * 1_000_000, +/// mach_ns: mach_s * 1_000_000_000, +/// }; +/// +/// // the wall clock jumps forward 30 s between the second and third anchors, while the master +/// // clock moves 5 s: the machine slept, or the clock was stepped +/// let map = ClockMap::new(vec![at(2000, 1000), at(2005, 1005), at(2040, 1010)]); +/// +/// // before the step, the clocks are 1000 s apart +/// assert_eq!(map.wall_to_mach(2_001_000_000), Some(1_001_000_000_000)); +/// // after it, 1030 s apart +/// assert_eq!(map.wall_to_mach(2_039_000_000), Some(1_009_000_000_000)); +/// // and the other direction agrees on both sides +/// assert_eq!(map.mach_to_wall(1_001_000_000_000), Some(2_001_000_000)); +/// assert_eq!(map.mach_to_wall(1_009_000_000_000), Some(2_039_000_000)); +/// +/// // an empty map has no answer +/// assert_eq!(ClockMap::default().wall_to_mach(1), None); +/// ``` +/// +/// [`wall_to_mach`]: ClockMap::wall_to_mach +/// [`mach_to_wall`]: ClockMap::mach_to_wall +#[derive(Debug, Clone, Default)] +pub struct ClockMap { + anchors: Vec, +} + +impl ClockMap { + /// Returns a map over `anchors`, which may be given in any order. + /// + /// The anchors are sorted by master clock time, and of several with the same master clock time + /// only the first given is kept. + #[must_use] + pub fn new(mut anchors: Vec) -> Self { + anchors.sort_by_key(|anchor| anchor.mach_ns); + anchors.dedup_by_key(|anchor| anchor.mach_ns); + Self { anchors } + } + + /// Returns the anchors the map was built from, ascending by master clock time, with no two + /// sharing a master clock time. + #[must_use] + pub fn anchors(&self) -> &[Anchor] { + &self.anchors + } + + /// Returns true if and only if the map has no anchors, in which case every lookup returns + /// `None`. + #[must_use] + pub fn is_empty(&self) -> bool { + self.anchors.is_empty() + } + + /// Whether the segment between two anchors contains a clock step rather than plain drift. + fn is_step(a: Anchor, b: Anchor) -> bool { + let wall_ns = (i128::from(b.wall_us) - i128::from(a.wall_us)) * 1_000; + let mach_ns = i128::from(b.mach_ns) - i128::from(a.mach_ns); + (wall_ns - mach_ns).abs() > STEP_THRESHOLD_NS + } + + /// Returns the master clock time, in nanoseconds, of the wall clock time `wall_us` + /// (microseconds since the Unix epoch), or `None` if the map has no anchors. + /// + /// The result is never negative: a time that maps before the origin of the master clock is + /// returned as zero. If the wall clock was stepped back, two segments can contain the same wall + /// clock time; the segment earlier in master clock time is used. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::clock::{Anchor, ClockMap}; + /// + /// // wall = master + 1000 s, with the wall clock running slightly fast between the anchors + /// let map = ClockMap::new(vec![ + /// Anchor { wall_us: 2_000_000_000, mach_ns: 1_000_000_000_000 }, + /// Anchor { wall_us: 2_005_000_500, mach_ns: 1_005_000_000_000 }, + /// ]); + /// + /// // halfway between them, interpolated + /// assert_eq!(map.wall_to_mach(2_002_500_250), Some(1_002_500_000_000)); + /// + /// // outside them, extended from the nearer end at a constant offset + /// assert_eq!(map.wall_to_mach(1_999_000_000), Some(999_000_000_000)); + /// assert_eq!(map.wall_to_mach(2_010_000_500), Some(1_010_000_000_000)); + /// ``` + #[must_use] + pub fn wall_to_mach(&self, wall_us: i64) -> Option { + let (first, last) = (*self.anchors.first()?, *self.anchors.last()?); + let extrapolate = |anchor: Anchor| { + let mach = i128::from(wall_us) - anchor.offset_us(); + (mach * 1_000).max(0) as u64 + }; + + if self.anchors.len() == 1 { + return Some(extrapolate(first)); + } + + for pair in self.anchors.windows(2) { + let (a, b) = (pair[0], pair[1]); + let (lo, hi) = (a.wall_us.min(b.wall_us), a.wall_us.max(b.wall_us)); + if wall_us < lo || wall_us > hi { + continue; + } + + if Self::is_step(a, b) { + // we cannot know when inside the segment the step happened, so trust whichever + // end is closer in wall time + let nearest = if (wall_us - a.wall_us).abs() <= (wall_us - b.wall_us).abs() { + a + } else { + b + }; + return Some(extrapolate(nearest)); + } + + let wall_span = i128::from(b.wall_us) - i128::from(a.wall_us); + if wall_span == 0 { + return Some(a.mach_ns); + } + + let mach_span = i128::from(b.mach_ns) - i128::from(a.mach_ns); + let into = i128::from(wall_us) - i128::from(a.wall_us); + return Some((i128::from(a.mach_ns) + into * mach_span / wall_span).max(0) as u64); + } + + // outside every segment: extend from whichever end is nearest in wall time + let nearest = if (wall_us - first.wall_us).abs() <= (wall_us - last.wall_us).abs() { + first + } else { + last + }; + Some(extrapolate(nearest)) + } + + /// Returns the wall clock time, in microseconds since the Unix epoch, of the master clock time + /// `mach_ns`, or `None` if the map has no anchors. + /// + /// The master clock has a resolution finer than a microsecond, and the result is truncated to + /// whole microseconds. Within a segment that holds no step, a wall clock time taken through + /// [`wall_to_mach`](ClockMap::wall_to_mach) and back comes back within 1 microsecond (the test + /// `roundtrips_within_a_microsecond`); that is not guaranteed across a step. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::clock::{Anchor, ClockMap}; + /// + /// let map = ClockMap::new(vec![ + /// Anchor { wall_us: 2_000_000_000, mach_ns: 1_000_000_000_000 }, + /// Anchor { wall_us: 2_005_000_000, mach_ns: 1_005_000_000_000 }, + /// ]); + /// + /// let wall_us = map.mach_to_wall(1_002_345_678_000).expect("the map has anchors"); + /// assert_eq!(wall_us, 2_002_345_678); + /// + /// let back = map.wall_to_mach(wall_us).expect("the map has anchors"); + /// assert_eq!(back, 1_002_345_678_000); + /// ``` + #[must_use] + pub fn mach_to_wall(&self, mach_ns: u64) -> Option { + let (first, last) = (*self.anchors.first()?, *self.anchors.last()?); + let extrapolate = |anchor: Anchor| { + let wall = i128::from(mach_ns / 1_000) + anchor.offset_us(); + wall as i64 + }; + + if mach_ns <= first.mach_ns { + return Some(extrapolate(first)); + } + + if mach_ns >= last.mach_ns { + return Some(extrapolate(last)); + } + + for pair in self.anchors.windows(2) { + let (a, b) = (pair[0], pair[1]); + if mach_ns < a.mach_ns || mach_ns > b.mach_ns { + continue; + } + + if Self::is_step(a, b) { + let nearest = if mach_ns - a.mach_ns <= b.mach_ns - mach_ns { + a + } else { + b + }; + return Some(extrapolate(nearest)); + } + + let mach_span = i128::from(b.mach_ns) - i128::from(a.mach_ns); + let wall_span = i128::from(b.wall_us) - i128::from(a.wall_us); + let into = i128::from(mach_ns) - i128::from(a.mach_ns); + return Some((i128::from(a.wall_us) + into * wall_span / mach_span) as i64); + } + + Some(extrapolate(last)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn anchor(wall_s: i64, mach_s: u64) -> Anchor { + Anchor { + wall_us: wall_s * 1_000_000, + mach_ns: mach_s * 1_000_000_000, + } + } + + #[test] + fn a_counters_frequency_is_a_timebase() { + // the usual QueryPerformanceFrequency: 10 MHz, 100 ns a tick + let ten_mhz = Timebase::from_frequency(10_000_000); + assert_eq!( + ten_mhz, + Timebase { + numer: 100, + denom: 1 + } + ); + assert_eq!(ten_mhz.ticks_to_ns(10_000_000), 1_000_000_000); + assert_eq!(ten_mhz.ns_to_ticks(1_000_000_000), 10_000_000); + + // one that does not divide evenly + let odd = Timebase::from_frequency(3_579_545); + let one_second = odd.ns_to_ticks(1_000_000_000); + assert!( + (3_579_544..=3_579_546).contains(&one_second), + "{one_second}" + ); + + // a nanosecond counter, and a nonsense zero, are both fine + assert_eq!( + Timebase::from_frequency(1_000_000_000), + Timebase { numer: 1, denom: 1 } + ); + assert_eq!(Timebase::from_frequency(0).ticks_to_ns(5), 5_000_000_000); + + // faster than u32 can hold: still close + let fast = Timebase::from_frequency(10_000_000_000); + assert!(fast.ticks_to_ns(10_000_000_000).abs_diff(1_000_000_000) < 10_000_000); + } + + #[test] + fn timebase_roundtrip() { + let tb = Timebase { + numer: 125, + denom: 3, + }; + let ns = tb.ticks_to_ns(24_000_000); + assert_eq!(ns, 1_000_000_000); + assert_eq!(tb.ns_to_ticks(ns), 24_000_000); + } + + #[test] + fn mach_is_monotonic_and_wall_is_sane() { + let a = mono_now_ns(); + let b = mono_now_ns(); + assert!(b >= a); + // after 2020 and before 2100 + assert!(wall_now_us() > 1_577_836_800_000_000); + assert!(wall_now_us() < 4_102_444_800_000_000); + } + + #[test] + fn anchor_sample_is_close_to_now() { + let anchor = Anchor::sample(); + let now = wall_now_us(); + assert!((now - anchor.wall_us).abs() < 50_000); + } + + #[test] + fn interpolates_between_anchors() { + // wall = mach + 1000s, with the wall clock running 100ppm fast over the segment + let a = anchor(2000, 1000); + let b = Anchor { + wall_us: 2005 * 1_000_000 + 500, + mach_ns: 1005 * 1_000_000_000, + }; + let map = ClockMap::new(vec![a, b]); + + let mach = map.wall_to_mach(2_002_500_000 + 250).expect("mach"); + assert_eq!(mach, 1_002_500_000_000); + + let wall = map.mach_to_wall(1_002_500_000_000).expect("wall"); + assert_eq!(wall, 2_002_500_000 + 250); + } + + #[test] + fn roundtrips_within_a_microsecond() { + let map = ClockMap::new(vec![ + anchor(2000, 1000), + anchor(2005, 1005), + anchor(2010, 1010), + ]); + for wall in [2_000_123_456, 2_004_999_999, 2_007_777_777, 2_009_000_001] { + let mach = map.wall_to_mach(wall).expect("mach"); + let back = map.mach_to_wall(mach).expect("wall"); + assert!((back - wall).abs() <= 1, "{wall} -> {mach} -> {back}"); + } + } + + #[test] + fn extrapolates_outside_the_anchor_range() { + let map = ClockMap::new(vec![anchor(2000, 1000), anchor(2005, 1005)]); + assert_eq!( + map.wall_to_mach(1_999_000_000), + Some(999_000_000_000), + "before the first anchor" + ); + assert_eq!( + map.wall_to_mach(2_010_000_000), + Some(1_010_000_000_000), + "after the last anchor" + ); + } + + #[test] + fn handles_a_clock_step() { + // the wall clock jumps forward 30s between the second and third anchors (an NTP step, or + // the machine slept while mach time was paused) + let map = ClockMap::new(vec![ + anchor(2000, 1000), + anchor(2005, 1005), + anchor(2040, 1010), + ]); + + // before the step the offset is 1000s + assert_eq!(map.wall_to_mach(2_001_000_000), Some(1_001_000_000_000)); + // after the step the offset is 1030s + assert_eq!(map.wall_to_mach(2_039_000_000), Some(1_009_000_000_000)); + // and mach -> wall agrees on both sides + assert_eq!(map.mach_to_wall(1_001_000_000_000), Some(2_001_000_000)); + assert_eq!(map.mach_to_wall(1_009_000_000_000), Some(2_039_000_000)); + } + + #[test] + fn single_anchor_is_a_constant_offset() { + let map = ClockMap::new(vec![anchor(2000, 1000)]); + assert_eq!(map.wall_to_mach(2_003_000_000), Some(1_003_000_000_000)); + assert_eq!(map.mach_to_wall(1_003_000_000_000), Some(2_003_000_000)); + } + + #[test] + fn the_jump_detector_ignores_ordinary_time_passing() { + let mut detector = JumpDetector::new(anchor(2000, 1000)); + // 50 ms later on both clocks, with a little drift + let tick = |wall_us: i64, mach_ns: u64| Anchor { wall_us, mach_ns }; + assert_eq!( + detector.observe(tick(2_000_050_010, 1_000_050_000_000)), + None + ); + // a long but consistent interval (the process was merely descheduled) + assert_eq!( + detector.observe(tick(2_030_050_010, 1_030_050_000_000)), + None + ); + } + + #[test] + fn the_jump_detector_notices_sleep_and_brackets_it() { + let mut detector = JumpDetector::new(anchor(2000, 1000)); + let before = Anchor { + wall_us: 2_000_050_000, + mach_ns: 1_000_050_000_000, + }; + assert_eq!(detector.observe(before), None); + + // the lid is closed for 3600 s: wall time moves on, mach time barely does + let after = Anchor { + wall_us: 2_000_100_000 + 3_600_000_000, + mach_ns: 1_000_100_000_000, + }; + let jump = detector.observe(after).expect("a jump"); + assert!((jump.seconds - 3_600.0).abs() < 0.01, "{}", jump.seconds); + assert_eq!( + jump.before, before, + "the last good pairing before the sleep" + ); + assert_eq!(jump.after, after); + + // and with those two anchors the map is right on both sides of the sleep + let map = ClockMap::new(vec![jump.before, jump.after]); + assert_eq!(map.wall_to_mach(2_000_050_000), Some(1_000_050_000_000)); + assert_eq!( + map.wall_to_mach(2_000_100_000 + 3_600_000_000), + Some(1_000_100_000_000) + ); + assert_eq!( + map.wall_to_mach(2_000_100_000 + 3_600_000_000 + 2_000_000), + Some(1_000_100_000_000 + 2_000_000_000), + "two seconds after waking" + ); + + // the detector carries on from the new position + assert_eq!( + detector.observe(Anchor { + wall_us: after.wall_us + 50_000, + mach_ns: after.mach_ns + 50_000_000 + }), + None + ); + } + + #[test] + fn the_jump_detector_notices_the_clock_stepping_back() { + let mut detector = JumpDetector::new(anchor(2000, 1000)); + let jump = detector + .observe(Anchor { + wall_us: 2_000_050_000 - 30_000_000, + mach_ns: 1_000_050_000_000, + }) + .expect("a jump"); + assert!( + jump.seconds < -29.0 && jump.seconds > -31.0, + "{}", + jump.seconds + ); + } + + #[test] + fn empty_map_has_no_answer() { + let map = ClockMap::default(); + assert_eq!(map.wall_to_mach(1), None); + assert_eq!(map.mach_to_wall(1), None); + } +} diff --git a/komorebi-recorder/src/lib.rs b/komorebi-recorder/src/lib.rs index a6d80956..a095a17d 100644 --- a/komorebi-recorder/src/lib.rs +++ b/komorebi-recorder/src/lib.rs @@ -8,3 +8,52 @@ //! This crate is the library behind the `komorebi-recorder` command. A reader who wants to run it should start with the //! [README](https://github.com/LGUG2Z/komorebi-recorder#readme); this documentation is for someone who is changing it, //! or reading a session directory from a program of their own. +//! +//! # The model +//! +//! Two things vary independently. The _host_ is the OS the recorder runs on, and everything that differs by it is behind +//! [`platform`]. The _flavor_ is the komorebi being recorded, komorebi for Mac or komorebi for Windows, and everything +//! that differs by it is behind a trait in the analysis. A session records both, so a session recorded on one host can +//! be read on the other. +//! +//! Every record in a session is stamped on one monotonic clock, the _master clock_ ([`clock`]). Video frames and the OS +//! trace are already on it; komorebi's log lines carry wall-clock time and are mapped onto it with paired readings of +//! both clocks. For example, a log line stamped five seconds after the first of two anchors that are ten seconds apart +//! is five seconds after it on the master clock too: +//! +//! ```rust +//! use komorebi_recorder::clock::Anchor; +//! use komorebi_recorder::clock::ClockMap; +//! +//! let map = ClockMap::new(vec![ +//! Anchor { wall_us: 1_000_000, mach_ns: 5_000_000_000 }, +//! Anchor { wall_us: 11_000_000, mach_ns: 15_000_000_000 }, +//! ]); +//! +//! assert_eq!(map.wall_to_mach(6_000_000), Some(10_000_000_000)); +//! ``` +//! +//! # Where to start +//! +//! The modules, from the ones everything else uses to the ones that use everything: +//! +//! | module | what it is | +//! |---|---| +//! | [`timecode`] | parsing and printing video timecodes | +//! | [`clock`] | the master clock, anchors and the map between the wall clock and the master clock | +//! | [`platform`] | everything that differs by host, behind one facade | +//! +//! # What is not supported +//! +//! The recorder does not change komorebi's log level or lifecycle, it does not need `sudo` and changes no SIP setting on +//! a Mac, and it does not trace system calls. A session records what komorebi, the OS trace and the screen said, and +//! nothing from before the recording began. +//! +//! # Platform support +//! +//! The Windows host is type-checked and cross-built from a Mac and built on Windows in CI, and has never been run. See +//! [`platform`] for what each host provides. + +pub mod clock; +pub mod platform; +pub mod timecode; diff --git a/komorebi-recorder/src/platform/macos/clock.rs b/komorebi-recorder/src/platform/macos/clock.rs new file mode 100644 index 00000000..e913b387 --- /dev/null +++ b/komorebi-recorder/src/platform/macos/clock.rs @@ -0,0 +1,112 @@ +//! The master clock on macOS: `mach_absolute_time`, the clock `ScreenCaptureKit` and the unified +//! log already use. +//! +//! Every time in a session is nanoseconds on this clock (see [`crate::clock`]). It counts ticks of +//! a size the OS reports ([`timebase`]) and pauses while the machine sleeps, so a sleep shows up as +//! the wall clock moving on while this one does not. [`reference_clocks`] reads the clock that does +//! keep counting through a sleep. +//! +//! The same items exist on Windows, where the clock is `QueryPerformanceCounter`. + +use crate::clock::Timebase; +use crate::platform::ClockKind; +use std::sync::OnceLock; + +/// Which clock the master clock is: [`ClockKind::Mach`], recorded as `host.clock` in the manifest. +pub const KIND: ClockKind = ClockKind::Mach; + +#[repr(C)] +struct MachTimebaseInfo { + numer: u32, + denom: u32, +} + +// `libc` marks its mach bindings deprecated in favour of the `mach2` crate; these two symbols +// are all we need from libSystem, so declare them directly instead of adding a dependency +unsafe extern "C" { + fn mach_absolute_time() -> u64; + fn mach_continuous_time() -> u64; + fn mach_timebase_info(info: *mut MachTimebaseInfo) -> i32; +} + +/// Returns what one tick of [`now_ticks`] is worth, as `numer / denom` nanoseconds. +/// +/// It is read from `mach_timebase_info` once and kept. If the OS reports no usable timebase (which +/// a Mac that can run this always does), it is `1 / 1`. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::platform::clock::{now_ns, now_ticks, timebase}; +/// +/// // the master clock is the tick count scaled by the timebase, and never goes backwards +/// let ns = timebase().ticks_to_ns(now_ticks()); +/// assert!(now_ns() >= ns); +/// ``` +#[must_use] +pub fn timebase() -> Timebase { + static TIMEBASE: OnceLock = OnceLock::new(); + *TIMEBASE.get_or_init(|| { + let mut info = MachTimebaseInfo { numer: 0, denom: 0 }; + // SAFETY: `info` is a valid out pointer for the duration of the call + let status = unsafe { mach_timebase_info(&raw mut info) }; + if status != 0 || info.denom == 0 { + // every Mac that can run this has a valid timebase; 1/1 is the safe identity + return Timebase { numer: 1, denom: 1 }; + } + + Timebase { + numer: info.numer, + denom: info.denom, + } + }) +} + +/// Returns the raw tick count of `mach_absolute_time`, which pauses while the machine sleeps. +#[must_use] +pub fn now_ticks() -> u64 { + // SAFETY: no preconditions + unsafe { mach_absolute_time() } +} + +/// Returns nanoseconds on the host monotonic clock: the master clock every record in a session is +/// on. It is [`now_ticks`] scaled by [`timebase`], so it pauses while the machine sleeps. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::platform::clock::now_ns; +/// +/// let first = now_ns(); +/// let second = now_ns(); +/// assert!(second >= first); +/// ``` +#[must_use] +pub fn now_ns() -> u64 { + timebase().ticks_to_ns(now_ticks()) +} + +/// Returns the host's other clocks, to be read beside the master clock: one, +/// `mach_continuous_time`, which counts the time the machine spends asleep, unlike the master +/// clock. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::platform::clock::reference_clocks; +/// +/// let clocks = reference_clocks(); +/// assert_eq!(clocks.len(), 1); +/// assert_eq!(clocks[0].name, "mach_continuous_time"); +/// assert!(clocks[0].counts_sleep); +/// ``` +#[must_use] +pub fn reference_clocks() -> Vec { + // SAFETY: no preconditions + let ticks = unsafe { mach_continuous_time() }; + vec![crate::clock::ReferenceClock { + name: "mach_continuous_time".to_string(), + counts_sleep: true, + ns: timebase().ticks_to_ns(ticks), + }] +} diff --git a/komorebi-recorder/src/platform/macos/mod.rs b/komorebi-recorder/src/platform/macos/mod.rs new file mode 100644 index 00000000..89dd8331 --- /dev/null +++ b/komorebi-recorder/src/platform/macos/mod.rs @@ -0,0 +1,22 @@ +//! The macOS host: what the [`platform`](crate::platform) facade is made of on a Mac. +//! +//! | module | built on | +//! |---|---| +//! | `apps` | the window server's window list (`CGWindowListCopyWindowInfo`) | +//! | `capture` | `ScreenCaptureKit` and `AVFoundation` (in `crate::capture::sck`) | +//! | `clock` | `mach_absolute_time` | +//! | `power` | nothing: a Mac's sleep is found from the clocks, so there is no watcher | +//! | `process` | process groups and `SIGTERM` | +//! | `session` | the CoreGraphics session dictionary | +//! | `shell` | `open`, `osascript` and `sw_vers` | +//! | `trace` | nothing: the OS trace is `log stream` children (`crate::sources::oslog`), not taps | +//! +//! Nothing here is public API except through the facade: this module is private, and the facade +//! re-exports its submodules under the same names on every host. + +pub mod clock; +pub mod shell; + +/// Does nothing: a Mac needs nothing done once at the start of `main`. (Windows makes the process +/// DPI aware.) +pub fn init() {} diff --git a/komorebi-recorder/src/platform/macos/shell.rs b/komorebi-recorder/src/platform/macos/shell.rs new file mode 100644 index 00000000..102de6b0 --- /dev/null +++ b/komorebi-recorder/src/platform/macos/shell.rs @@ -0,0 +1,72 @@ +//! Showing a folder, asking for one, and naming the OS. +//! +//! The recorder window offers to show a finished session in the file manager, the viewer asks for +//! a session folder, and the manifest records the OS version. On macOS these are `open`, +//! `osascript` and `sw_vers`; the same items exist on Windows. + +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; + +/// What the OS calls its file manager, for button labels: `Finder`. +pub const FILE_MANAGER: &str = "Finder"; + +/// Returns the OS version as `sw_vers -productVersion` prints it, e.g. `26.0`, or `None` if that +/// cannot be run or prints nothing. On Windows it is `major.minor.build`. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::platform::shell::{FILE_MANAGER, os_version}; +/// +/// assert_eq!(FILE_MANAGER, "Finder"); +/// if let Some(version) = os_version() { +/// assert!(version.chars().all(|c| c.is_ascii_digit() || c == '.'), "{version}"); +/// } +/// ``` +#[must_use] +pub fn os_version() -> Option { + let output = Command::new("/usr/bin/sw_vers") + .arg("-productVersion") + .output() + .ok()?; + let version = String::from_utf8(output.stdout).ok()?.trim().to_string(); + (!version.is_empty()).then_some(version) +} + +/// Shows `path` in the file manager. +/// +/// This runs `open path` and does not wait for it, and a failure to start it is not reported. A +/// folder opens in Finder. A file opens in its default application, not selected in Finder (`open +/// -R` would select it); the recorder only passes folders. On Windows a file is selected in its +/// folder. +/// +/// This starts another program, so it has no doctest. +pub fn reveal(path: &Path) { + let _ = Command::new("open").arg(path).spawn(); +} + +/// Asks the user for a folder with the system's own dialog, and blocks until they answer. +/// +/// Returns the folder they chose, or `None` if they canceled, the dialog could not be shown, or +/// the path came back empty. `prompt` is the dialog's prompt text. It is not escaped: it is placed +/// inside an `AppleScript` string, so it should not contain `"` or `\`. +/// +/// This shows a dialog, so it has no doctest. +#[must_use] +pub fn pick_folder(prompt: &str) -> Option { + let output = Command::new("osascript") + .args([ + "-e", + &format!("POSIX path of (choose folder with prompt \"{prompt}\")"), + ]) + .output() + .ok()?; + + output + .status + .success() + .then(|| String::from_utf8_lossy(&output.stdout).trim().to_string()) + .filter(|path| !path.is_empty()) + .map(PathBuf::from) +} diff --git a/komorebi-recorder/src/platform/mod.rs b/komorebi-recorder/src/platform/mod.rs new file mode 100644 index 00000000..bb531186 --- /dev/null +++ b/komorebi-recorder/src/platform/mod.rs @@ -0,0 +1,181 @@ +//! Everything that differs by the OS the recorder runs on. +//! +//! The rest of the crate never names a system API. It asks this module for the handful of things +//! that are not portable, and each host provides them: +//! +//! | module | what it answers | +//! |---|---| +//! | [`clock`] | the master monotonic clock and its tick size | +//! | [`shell`] | the OS version, and (for the recorder window) showing a folder and asking for one | +//! +//! A third host, `unsupported`, builds on anything else so that the investigation commands (which +//! only read a session directory) still work on a Linux agent box; recording there says so. +//! +//! # The host axis +//! +//! This is the *host* axis: what the recorder is running on. +//! Which komorebi produced a session is a separate axis, the *flavor*: a session recorded on +//! Windows can be investigated on a Mac, and the [`Host`] a session was recorded on is part of its +//! manifest. +//! +//! # Adding a function +//! +//! A function that the rest of the crate calls as `platform::::` has to exist, with +//! the same signature, in `macos/.rs`, `win/.rs` and `unsupported.rs`. The +//! compiler enforces that for the host being built, and for no other: the Windows host is built +//! from a Mac by `just check-windows` and by CI, and `unsupported` is built only on a third OS, +//! where nothing in CI builds it. + +#[cfg(target_os = "macos")] +mod macos; +#[cfg(target_os = "macos")] +use macos as imp; + +#[cfg(not(any(target_os = "macos", windows)))] +mod unsupported; +#[cfg(not(any(target_os = "macos", windows)))] +use unsupported as imp; + +// not `windows`: that would be ambiguous with the `windows` crate in every `use` of this module +#[cfg(windows)] +mod win; +#[cfg(windows)] +use win as imp; + +pub use imp::clock; +pub use imp::shell; + +/// Things the OS wants done once, first thing in `main`: on Windows, becoming DPI aware so that +/// sizes and positions are in physical pixels. Nothing on other hosts. +pub fn init() { + imp::init(); +} + +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; + +/// Which master clock a session's `*_mach_ns` values are on. +/// +/// The field names keep their historical `mach_` spelling so that sessions stay readable by every +/// build of the tool; what they hold is "nanoseconds on the host's monotonic clock", and this says +/// which clock that was. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum ClockKind { + /// `mach_absolute_time`: pauses while the machine sleeps. + Mach, + /// `QueryPerformanceCounter`. + Qpc, + /// A host with no capture, which still needs some monotonic clock to run on. + Std, +} + +/// What a session was recorded on. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::platform::ClockKind; +/// use komorebi_recorder::platform::Host; +/// +/// // a session from before hosts were recorded can only have been recorded on a Mac +/// let host = Host::legacy_macos(Some("26.0".to_string())); +/// assert!(host.is_macos() && !host.is_windows()); +/// assert_eq!(host.clock, ClockKind::Mach); +/// assert_eq!(host.describe(), "macOS 26.0"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Host { + /// `macos`, `windows`, ... + pub os: String, + /// The CPU architecture, as Rust names it (`aarch64`, `x86_64`). + pub arch: String, + /// The OS version, if the host could say (`26.0` on macOS, `10.0.26100` on Windows). + pub os_version: Option, + /// Which clock the session's `*_mach_ns` values are on. + pub clock: ClockKind, +} + +impl Host { + /// The machine this process is running on. + #[must_use] + pub fn current() -> Self { + Self { + os: std::env::consts::OS.to_string(), + arch: std::env::consts::ARCH.to_string(), + os_version: shell::os_version(), + clock: clock::KIND, + } + } + + /// What a session from before hosts were recorded was recorded on: it can only have been a Mac. + #[must_use] + pub fn legacy_macos(os_version: Option) -> Self { + Self { + os: "macos".to_string(), + arch: "aarch64".to_string(), + os_version, + clock: ClockKind::Mach, + } + } + + #[must_use] + /// Returns `true` if and only if this is macOS. + pub fn is_macos(&self) -> bool { + self.os == "macos" + } + + #[must_use] + /// Returns `true` if and only if this is Windows. + pub fn is_windows(&self) -> bool { + self.os == "windows" + } + + /// `macOS 26.0`, `windows 10.0.26100`: for prose. + #[must_use] + pub fn describe(&self) -> String { + let os = match self.os.as_str() { + "macos" => "macOS", + "windows" => "Windows", + other => other, + }; + match &self.os_version { + Some(version) => format!("{os} {version}"), + None => os.to_string(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_host_reads_back_what_it_wrote() { + let host = Host::current(); + let text = serde_json::to_string(&host).expect("serialize"); + let back: Host = serde_json::from_str(&text).expect("parse"); + assert_eq!(back, host); + } + + #[test] + fn the_clock_kind_is_spelled_in_lowercase() { + assert_eq!( + serde_json::to_string(&ClockKind::Qpc).expect("serialize"), + r#""qpc""# + ); + assert_eq!( + serde_json::to_string(&ClockKind::Mach).expect("serialize"), + r#""mach""# + ); + } + + #[test] + fn a_session_from_before_hosts_were_recorded_was_a_mac() { + let host = Host::legacy_macos(Some("26.0".into())); + assert!(host.is_macos()); + assert_eq!(host.describe(), "macOS 26.0"); + assert_eq!(host.clock, ClockKind::Mach); + } +} diff --git a/komorebi-recorder/src/platform/unsupported.rs b/komorebi-recorder/src/platform/unsupported.rs new file mode 100644 index 00000000..28f79691 --- /dev/null +++ b/komorebi-recorder/src/platform/unsupported.rs @@ -0,0 +1,62 @@ +//! Any other OS: enough to build and to investigate a session directory that was recorded +//! elsewhere (the commands that only read files), but not to record one. + +/// Nothing to do first. +pub fn init() {} + +/// The master clock, which here is Rust's monotonic [`Instant`]. +pub mod clock { + use crate::clock::Timebase; + use crate::platform::ClockKind; + use std::sync::OnceLock; + use std::time::Instant; + + /// The clock a session recorded here is on: Rust's [`Instant`]. + pub const KIND: ClockKind = ClockKind::Std; + + /// Returns the identity timebase: [`now_ns`] is already in nanoseconds. + #[must_use] + pub fn timebase() -> Timebase { + Timebase { numer: 1, denom: 1 } + } + + /// Returns no other clocks. + #[must_use] + pub fn reference_clocks() -> Vec { + Vec::new() + } + + /// Nanoseconds since the first reading in this process, offset so that none is ever zero + /// (zero means "not yet" in the capture counters). + #[must_use] + pub fn now_ns() -> u64 { + static START: OnceLock = OnceLock::new(); + START.get_or_init(Instant::now).elapsed().as_nanos() as u64 + 1_000_000_000 + } +} + +/// Showing a folder, asking for one, and naming the OS, with what this host can offer. +pub mod shell { + use std::path::Path; + use std::path::PathBuf; + + /// What this host calls its file manager, for button labels. + pub const FILE_MANAGER: &str = "file manager"; + + /// Returns `None`: this host cannot say which version it is. + #[must_use] + pub fn os_version() -> Option { + None + } + + /// Asks `xdg-open` to show `path`. + pub fn reveal(path: &Path) { + let _ = std::process::Command::new("xdg-open").arg(path).spawn(); + } + + /// Returns `None`: this host has no dialog to ask with. + #[must_use] + pub fn pick_folder(_prompt: &str) -> Option { + None + } +} diff --git a/komorebi-recorder/src/platform/win/clock.rs b/komorebi-recorder/src/platform/win/clock.rs new file mode 100644 index 00000000..1320ab50 --- /dev/null +++ b/komorebi-recorder/src/platform/win/clock.rs @@ -0,0 +1,83 @@ +//! The master clock on Windows: `QueryPerformanceCounter`, the clock Windows.Graphics.Capture frame +//! times are on. +//! +//! Every time in a session is nanoseconds on this clock (see [`crate::clock`]). It counts ticks of +//! a size the OS reports ([`timebase`]). Whether it keeps counting while the machine sleeps is not +//! assumed: `doctor --probe clock` measures it, using the two clocks of [`reference_clocks`], and +//! the power watcher (`crate::platform::power`) says when a sleep happens either way. +//! +//! The same items exist on macOS, where the clock is `mach_absolute_time`. + +use crate::clock::Timebase; +use crate::platform::ClockKind; +use std::sync::OnceLock; +use windows::Win32::System::Performance::QueryPerformanceCounter; +use windows::Win32::System::Performance::QueryPerformanceFrequency; +use windows::Win32::System::WindowsProgramming::QueryInterruptTime; +use windows::Win32::System::WindowsProgramming::QueryUnbiasedInterruptTimePrecise; + +/// Which clock the master clock is: [`ClockKind::Qpc`], recorded as `host.clock` in the manifest. +pub const KIND: ClockKind = ClockKind::Qpc; + +/// Returns what one tick of [`now_ticks`] is worth, as `numer / denom` nanoseconds. +/// +/// It is made from `QueryPerformanceFrequency` once and kept. If that cannot be read, which it +/// cannot do on any Windows that runs this, the frequency is taken to be 10 MHz, which is what it +/// is nearly everywhere. +#[must_use] +pub fn timebase() -> Timebase { + static TIMEBASE: OnceLock = OnceLock::new(); + *TIMEBASE.get_or_init(|| { + let mut frequency = 0_i64; + // SAFETY: `frequency` is a valid out pointer for the duration of the call + let known = unsafe { QueryPerformanceFrequency(&raw mut frequency) }.is_ok(); + // it cannot fail on any Windows that runs this; 10 MHz is what it says nearly everywhere + Timebase::from_frequency(if known && frequency > 0 { + frequency as u64 + } else { + 10_000_000 + }) + }) +} + +/// Returns the raw `QueryPerformanceCounter` tick count. A negative reading, which does not +/// happen, is `0`. +#[must_use] +pub fn now_ticks() -> u64 { + let mut ticks = 0_i64; + // SAFETY: `ticks` is a valid out pointer for the duration of the call + let _ = unsafe { QueryPerformanceCounter(&raw mut ticks) }; + ticks.max(0) as u64 +} + +/// Returns nanoseconds on the host monotonic clock: the master clock every record in a session is +/// on. It is [`now_ticks`] scaled by [`timebase`]. +#[must_use] +pub fn now_ns() -> u64 { + timebase().ticks_to_ns(now_ticks()) +} + +/// Returns the host's other clocks, to be read beside the master clock: the two interrupt-time +/// clocks. +/// +/// `QueryInterruptTime` counts the time the machine is suspended and +/// `QueryUnbiasedInterruptTimePrecise` does not. Both are read in 100 ns units and returned in +/// nanoseconds. Which of them the performance counter behaves like is what decides how a sleep +/// can be noticed. +#[must_use] +pub fn reference_clocks() -> Vec { + // SAFETY: no preconditions + let (biased, unbiased) = unsafe { (QueryInterruptTime(), QueryUnbiasedInterruptTimePrecise()) }; + vec![ + crate::clock::ReferenceClock { + name: "QueryInterruptTime".to_string(), + counts_sleep: true, + ns: biased.saturating_mul(100), + }, + crate::clock::ReferenceClock { + name: "QueryUnbiasedInterruptTimePrecise".to_string(), + counts_sleep: false, + ns: unbiased.saturating_mul(100), + }, + ] +} diff --git a/komorebi-recorder/src/platform/win/mod.rs b/komorebi-recorder/src/platform/win/mod.rs new file mode 100644 index 00000000..bffd4cbe --- /dev/null +++ b/komorebi-recorder/src/platform/win/mod.rs @@ -0,0 +1,33 @@ +//! The Windows host: what the [`platform`](crate::platform) facade is made of on Windows. +//! +//! | module | built on | +//! |---|---| +//! | `apps` | `EnumWindows` and `GetWindowThreadProcessId` | +//! | `capture` | monitor enumeration and per-monitor DPI; the capture itself is Windows.Graphics.Capture (in `crate::capture::wgc`) | +//! | `clock` | `QueryPerformanceCounter` | +//! | `media` | Windows Runtime and Media Foundation start-up, shared by the capture and the decoder | +//! | `power` | a hidden window that is sent suspend, resume, lid, display and session messages | +//! | `process` | process creation flags and `TerminateProcess` | +//! | `session` | whether the input desktop can be opened | +//! | `shell` | Explorer, the folder dialog and `RtlGetVersion` | +//! | `trace` | a `WinEvent` hook and Event Log subscriptions (see `crate::sources::wintrace`) | +//! | `window` | this program's own windows and the reads about any window | +//! +//! Nothing here is public API except through the facade: this module is private, and the facade +//! re-exports its submodules (all but `window`, which the shared `platform::window` calls) under the +//! same names as on the other hosts. +//! +//! This code is compiled only for Windows. From another host it is type-checked and linted with +//! `just check-windows`, and the checklist for running it on a real machine is +//! `docs/dev/windows-checkpoints.md`. + +pub mod clock; +pub mod shell; + +/// Does what Windows needs once, first thing in `main`: makes the process per-monitor DPI aware +/// ([`capture::become_per_monitor_dpi_aware`]), so that every size and position it reads is in +/// physical pixels, as komorebi's are. A Mac needs nothing here. +pub fn init() { + // Without this Windows lies to us about sizes: a display scaled to 150 % reads as smaller than + // it is, and komorebi's rects (which are physical pixels) would not match ours. +} diff --git a/komorebi-recorder/src/platform/win/shell.rs b/komorebi-recorder/src/platform/win/shell.rs new file mode 100644 index 00000000..bd75dc55 --- /dev/null +++ b/komorebi-recorder/src/platform/win/shell.rs @@ -0,0 +1,111 @@ +//! Showing a folder, asking for one, and naming the OS. +//! +//! The recorder window offers to show a finished session in the file manager, the viewer asks for +//! a session folder, and the manifest records the OS version. On Windows these are Explorer, the +//! common file dialog and `RtlGetVersion`; the same items exist on macOS. + +use std::path::Path; +use std::path::PathBuf; +use std::process::Command; +use windows::Wdk::System::SystemServices::RtlGetVersion; +use windows::Win32::System::Com::CLSCTX_INPROC_SERVER; +use windows::Win32::System::Com::COINIT_APARTMENTTHREADED; +use windows::Win32::System::Com::CoCreateInstance; +use windows::Win32::System::Com::CoInitializeEx; +use windows::Win32::System::Com::CoTaskMemFree; +use windows::Win32::System::Com::CoUninitialize; +use windows::Win32::System::SystemInformation::OSVERSIONINFOW; +use windows::Win32::UI::Shell::FOS_FORCEFILESYSTEM; +use windows::Win32::UI::Shell::FOS_PICKFOLDERS; +use windows::Win32::UI::Shell::FileOpenDialog; +use windows::Win32::UI::Shell::IFileOpenDialog; +use windows::Win32::UI::Shell::SIGDN_FILESYSPATH; +use windows::core::PCWSTR; + +/// What the OS calls its file manager, for button labels: `Explorer`. +pub const FILE_MANAGER: &str = "Explorer"; + +/// Returns the Windows version as `major.minor.build`, e.g. `10.0.26100`, or `None` if it cannot be +/// read. On macOS it is the product version, e.g. `26.0`. +/// +/// This is `RtlGetVersion`, because the documented `GetVersionEx` lies to a program without a +/// manifest saying which Windows it was made for. +#[must_use] +pub fn os_version() -> Option { + let mut info = OSVERSIONINFOW { + dwOSVersionInfoSize: size_of::() as u32, + ..Default::default() + }; + // SAFETY: `info` is a valid, correctly sized out structure + let status = unsafe { RtlGetVersion(&raw mut info) }; + status.is_ok().then(|| { + format!( + "{}.{}.{}", + info.dwMajorVersion, info.dwMinorVersion, info.dwBuildNumber + ) + }) +} + +/// Shows `path` in the file manager: a folder is opened, a file is selected in its folder. +/// +/// This starts `explorer` and does not wait for it; Explorer exits with status 1 even when it +/// worked, so only starting it is checked, and a failure to start it is not reported. On macOS a +/// file is opened in its default application instead of being selected. +pub fn reveal(path: &Path) { + let mut command = Command::new("explorer"); + if path.is_file() { + let mut select = std::ffi::OsString::from("/select,"); + select.push(path); + command.arg(select); + } else { + command.arg(path); + } + + // explorer exits with 1 even when it worked; only starting it matters + let _ = command.spawn(); +} + +/// Asks the user for a folder with the system's own dialog, and blocks until they answer. +/// +/// Returns the folder they chose, or `None` if they canceled, the dialog could not be shown, or +/// the path came back empty. `prompt` is the dialog's title. The dialog is made on the calling +/// thread, which is given a COM apartment for the duration unless it already has one. +#[must_use] +pub fn pick_folder(prompt: &str) -> Option { + // SAFETY: COM is initialised for this thread for the duration of the dialog and released after + // (unless some other code had initialised it differently, in which case it is theirs to release) + unsafe { + let initialised = CoInitializeEx(None, COINIT_APARTMENTTHREADED).is_ok(); + let chosen = pick(prompt); + if initialised { + CoUninitialize(); + } + chosen + } +} + +/// # Safety +/// COM must be initialized on the calling thread. +unsafe fn pick(prompt: &str) -> Option { + // SAFETY: the caller initialised COM; every pointer below is owned by a COM wrapper or freed + unsafe { + let dialog: IFileOpenDialog = + CoCreateInstance(&FileOpenDialog, None, CLSCTX_INPROC_SERVER).ok()?; + let options = dialog.GetOptions().ok()?; + dialog + .SetOptions(options | FOS_PICKFOLDERS | FOS_FORCEFILESYSTEM) + .ok()?; + + let title: Vec = prompt.encode_utf16().chain(Some(0)).collect(); + dialog.SetTitle(PCWSTR(title.as_ptr())).ok()?; + + // cancelling is an error as far as COM is concerned + dialog.Show(None).ok()?; + + let item = dialog.GetResult().ok()?; + let name = item.GetDisplayName(SIGDN_FILESYSPATH).ok()?; + let path = name.to_string().ok(); + CoTaskMemFree(Some(name.0.cast_const().cast())); + path.filter(|path| !path.is_empty()).map(PathBuf::from) + } +} diff --git a/komorebi-recorder/src/timecode.rs b/komorebi-recorder/src/timecode.rs new file mode 100644 index 00000000..78a41bce --- /dev/null +++ b/komorebi-recorder/src/timecode.rs @@ -0,0 +1,277 @@ +//! Parsing and formatting of video timecodes: the way a position in a recording is typed on a +//! command line and shown back. +//! +//! A timecode is a number of seconds of video time, an `f64`, counted from the first captured +//! frame of a session (video `00:00.000`). A time before the first frame is negative. +//! +//! | Function | Does | +//! |---|---| +//! | [`parse`] | reads a timecode into seconds | +//! | [`parse_span`] | reads a duration (`500ms`, `2m`) into seconds | +//! | [`format()`] | writes seconds as a timecode | +//! +//! Accepted by [`parse`]: `12.5` (seconds), `12`, `00:12.500`, `1:02`, `1:02:03` and `1:02:03.25`. +//! A leading `-` is accepted so that offsets before the start of the video can be expressed. +//! [`format()`] writes `MM:SS.mmm`, or `H:MM:SS.mmm` from one hour on, and [`parse`] reads back +//! what it writes. +//! +//! This module has no idea what a recording is: it converts between text and a number of seconds, +//! and places nothing on a clock. Turning video seconds into a time on the master clock is the +//! session manifest's `mach_at_video_seconds`, and the reverse its `video_seconds`. +//! +//! # Example +//! +//! ```rust +//! use komorebi_recorder::timecode; +//! +//! assert_eq!(timecode::parse("00:12.500")?, 12.5); +//! assert_eq!(timecode::parse("1:02:03")?, 3723.0); +//! assert_eq!(timecode::parse("-1.5")?, -1.5); +//! +//! assert_eq!(timecode::format(12.5), "00:12.500"); +//! assert_eq!(timecode::format(3723.25), "1:02:03.250"); +//! +//! // a span is a length of time, not a position +//! assert_eq!(timecode::parse_span("500ms")?, 0.5); +//! assert_eq!(timecode::parse_span("2m")?, 120.0); +//! # Ok::<(), Box>(()) +//! ``` + +use color_eyre::eyre; + +/// Parses a timecode into seconds. +/// +/// The forms are `[[H:]M:]S[.fraction]`, optionally preceded by `-`, with surrounding whitespace +/// ignored: `12.5`, `12`, `00:12.500`, `1:02`, `1:02:03` and `1:02:03.25`. The hours and minutes +/// are whole numbers, and the seconds are any non-negative number Rust reads as an `f64`. The +/// parts are not range checked, so `1:75` is `135` seconds and `90:00` is `5400`. A `-` negates +/// the whole timecode, and is only accepted once, at the start. +/// +/// # Errors +/// +/// Returns an error if the input +/// +/// * is empty (after trimming, and after a leading `-`): `empty timecode`; +/// * has more than three `:`-separated parts: `expected [[H:]M:]S[.fraction]`; +/// * has an hours or minutes part that is not an unsigned whole number (`1.5:30`, `1:-2:30`); +/// * has a seconds part, the last one, that is not a number, or is negative, `NaN` or infinite +/// (`abc`, `--1`, `1:-2`, `nan`, `inf`). +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::timecode::parse; +/// +/// assert_eq!(parse("12.5")?, 12.5); +/// assert_eq!(parse("1:02")?, 62.0); +/// assert_eq!(parse("1:02:03.25")?, 3723.25); +/// assert_eq!(parse("-0:01.500")?, -1.5); +/// +/// // the parts are not range checked +/// assert_eq!(parse("1:75")?, 135.0); +/// +/// let error = parse("1:2:3:4").unwrap_err(); +/// assert_eq!(error.to_string(), "invalid timecode '1:2:3:4': expected [[H:]M:]S[.fraction]"); +/// assert_eq!(parse("").unwrap_err().to_string(), "empty timecode"); +/// assert!(parse("nan").is_err()); +/// # Ok::<(), Box>(()) +/// ``` +pub fn parse(input: &str) -> eyre::Result { + let trimmed = input.trim(); + let (negative, body) = match trimmed.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, trimmed), + }; + + if body.is_empty() { + eyre::bail!("empty timecode"); + } + + let parts: Vec<&str> = body.split(':').collect(); + if parts.len() > 3 { + eyre::bail!("invalid timecode '{input}': expected [[H:]M:]S[.fraction]"); + } + + let mut seconds = 0.0_f64; + for (index, part) in parts.iter().enumerate() { + let is_last = index == parts.len() - 1; + let invalid = || eyre::Report::msg(format!("invalid timecode '{input}'")); + let value: f64 = if is_last { + part.parse().map_err(|_| invalid())? + } else { + f64::from(part.parse::().map_err(|_| invalid())?) + }; + + if !value.is_finite() || value < 0.0 { + eyre::bail!("invalid timecode '{input}'"); + } + + seconds = seconds * 60.0 + value; + } + + Ok(if negative { -seconds } else { seconds }) +} + +/// Parses a duration such as `1s`, `500ms`, `1.5` (seconds) or `2m` into seconds. +/// +/// A number followed by `ms` (milliseconds), `s` (seconds) or `m` (minutes), with nothing after it +/// meaning seconds. Whitespace around the input, and between the number and its unit, is ignored. +/// Hours are not a unit: use minutes, or a timecode with [`parse`]. +/// +/// # Errors +/// +/// Returns an error if the number is not a number Rust reads as an `f64` (`soon`, or an empty +/// string), or if it is negative, `NaN` or infinite: a span cannot be negative. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::timecode::parse_span; +/// +/// assert_eq!(parse_span("1s")?, 1.0); +/// assert_eq!(parse_span("500ms")?, 0.5); +/// assert_eq!(parse_span("1.5")?, 1.5); +/// assert_eq!(parse_span("2m")?, 120.0); +/// +/// assert!(parse_span("-1s").is_err()); +/// assert!(parse_span("soon").is_err()); +/// assert_eq!( +/// parse_span("soon").unwrap_err().to_string(), +/// "invalid duration 'soon': expected e.g. 1s, 500ms or 1.5" +/// ); +/// # Ok::<(), Box>(()) +/// ``` +pub fn parse_span(input: &str) -> eyre::Result { + let trimmed = input.trim(); + let (number, scale) = if let Some(rest) = trimmed.strip_suffix("ms") { + (rest, 0.001) + } else if let Some(rest) = trimmed.strip_suffix('s') { + (rest, 1.0) + } else if let Some(rest) = trimmed.strip_suffix('m') { + (rest, 60.0) + } else { + (trimmed, 1.0) + }; + + let value: f64 = number.trim().parse().map_err(|_| { + eyre::Report::msg(format!( + "invalid duration '{input}': expected e.g. 1s, 500ms or 1.5" + )) + })?; + + if !value.is_finite() || value < 0.0 { + eyre::bail!("invalid duration '{input}': must be a non-negative number"); + } + + Ok(value * scale) +} + +/// Returns `seconds` as a timecode: `MM:SS.mmm`, or `H:MM:SS.mmm` from one hour on. +/// +/// The value is rounded to the nearest millisecond. Minutes and seconds are two digits and +/// milliseconds three; hours have as many digits as they need. A negative value is written with a +/// leading `-`, which a value that rounds to zero keeps (`-00:00.000`). The result is not +/// specified for a `NaN` or infinite `seconds`. +/// +/// [`parse`] reads back what this writes: a value that is a whole number of milliseconds comes +/// back equal, to within floating point error (the test `format_parse_roundtrip`). +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::timecode::{format, parse}; +/// +/// assert_eq!(format(0.0), "00:00.000"); +/// assert_eq!(format(12.5), "00:12.500"); +/// assert_eq!(format(62.0), "01:02.000"); +/// assert_eq!(format(3723.25), "1:02:03.250"); +/// assert_eq!(format(-1.5), "-00:01.500"); +/// +/// // rounded to the millisecond +/// assert_eq!(format(1.2346), "00:01.235"); +/// +/// assert!((parse(&format(7322.125))? - 7322.125).abs() < 1e-9); +/// # Ok::<(), Box>(()) +/// ``` +#[must_use] +pub fn format(seconds: f64) -> String { + let sign = if seconds < 0.0 { "-" } else { "" }; + let total_ms = (seconds.abs() * 1_000.0).round() as u64; + let ms = total_ms % 1_000; + let total_s = total_ms / 1_000; + let s = total_s % 60; + let m = (total_s / 60) % 60; + let h = total_s / 3_600; + + if h > 0 { + format!("{sign}{h}:{m:02}:{s:02}.{ms:03}") + } else { + format!("{sign}{m:02}:{s:02}.{ms:03}") + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn close(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 + } + + #[test] + fn parses_plain_seconds() { + assert!(close(parse("12.5").unwrap(), 12.5)); + assert!(close(parse("12").unwrap(), 12.0)); + assert!(close(parse("0.04").unwrap(), 0.04)); + } + + #[test] + fn parses_clock_forms() { + assert!(close(parse("00:12.500").unwrap(), 12.5)); + assert!(close(parse("1:02").unwrap(), 62.0)); + assert!(close(parse("1:02:03").unwrap(), 3723.0)); + assert!(close(parse("1:02:03.25").unwrap(), 3723.25)); + } + + #[test] + fn parses_negative_offsets() { + assert!(close(parse("-1.5").unwrap(), -1.5)); + assert!(close(parse("-0:01.500").unwrap(), -1.5)); + } + + #[test] + fn rejects_garbage() { + for bad in [ + "", "abc", "1:2:3:4", "1.5:30", "::", "1:-2", "--1", "nan", "inf", + ] { + assert!(parse(bad).is_err(), "{bad:?} should not parse"); + } + } + + #[test] + fn parses_spans() { + assert!(close(parse_span("1s").unwrap(), 1.0)); + assert!(close(parse_span("500ms").unwrap(), 0.5)); + assert!(close(parse_span("1.5").unwrap(), 1.5)); + assert!(close(parse_span("2m").unwrap(), 120.0)); + assert!(parse_span("-1s").is_err()); + assert!(parse_span("soon").is_err()); + assert!(parse_span("").is_err()); + } + + #[test] + fn formats() { + assert_eq!(format(12.5), "00:12.500"); + assert_eq!(format(0.0), "00:00.000"); + assert_eq!(format(62.0), "01:02.000"); + assert_eq!(format(3723.25), "1:02:03.250"); + assert_eq!(format(-1.5), "-00:01.500"); + } + + #[test] + fn format_parse_roundtrip() { + for value in [0.0, 0.001, 12.5, 59.999, 3600.0, 7322.125] { + assert!(close(parse(&format(value)).unwrap(), value)); + } + } +} From 0de0f8284034bd2f6d539d2b09196d27ce4fb82a Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 06:14:10 -0700 Subject: [PATCH 006/108] feat(flavor): read komorebi for Mac and for Windows Which komorebi produced a session is a separate axis from the host the recorder ran on. `Flavor` names the two and `Rules` holds what each knows: what its events look like on the wire, which of them move a window, and which are komorebi making things up. komorebi for Mac and komorebi for Windows each implement it, and a missing method is a compile error. The Windows rules were written from komorebi's source and say so until a recording confirms them. Events are classified through the rules, and the names of things in a session (`event:12`, `log:7`) are parsed here too, with the envelope and exit codes every command will answer in. --- docs/ARCHITECTURE.md | 23 + komorebi-recorder/src/analysis/classify.rs | 406 ++++++++++++++ komorebi-recorder/src/analysis/envelope.rs | 468 ++++++++++++++++ komorebi-recorder/src/analysis/events.rs | 175 ++++++ komorebi-recorder/src/analysis/flavor/mac.rs | 272 ++++++++++ komorebi-recorder/src/analysis/flavor/mod.rs | 510 ++++++++++++++++++ .../src/analysis/flavor/windows.rs | 351 ++++++++++++ komorebi-recorder/src/analysis/ids.rs | 264 +++++++++ komorebi-recorder/src/analysis/mod.rs | 12 + komorebi-recorder/src/lib.rs | 6 +- komorebi-recorder/src/platform/mod.rs | 6 +- 11 files changed, 2488 insertions(+), 5 deletions(-) create mode 100644 komorebi-recorder/src/analysis/classify.rs create mode 100644 komorebi-recorder/src/analysis/envelope.rs create mode 100644 komorebi-recorder/src/analysis/events.rs create mode 100644 komorebi-recorder/src/analysis/flavor/mac.rs create mode 100644 komorebi-recorder/src/analysis/flavor/mod.rs create mode 100644 komorebi-recorder/src/analysis/flavor/windows.rs create mode 100644 komorebi-recorder/src/analysis/ids.rs create mode 100644 komorebi-recorder/src/analysis/mod.rs diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3261fc18..32234603 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -10,6 +10,11 @@ Two things about it vary independently, and everything below is arranged so that | axis | what varies | where the variation lives | what a session records | |---|---|---|---| | host | the OS the recorder runs on: macOS or Windows (a third `unsupported` host builds anywhere) | [`platform`](../komorebi-recorder/src/platform/mod.rs), selected by `cfg` | `host` in the manifest | +| flavor | the komorebi being recorded: komorebi for Mac or komorebi for Windows | `Rules` in [`analysis::flavor`](../komorebi-recorder/src/analysis/flavor/mod.rs), selected by data | `komorebi.flavor` in the manifest | + +They are independent because a session recorded on one host is investigated on whichever machine the investigator has. +A session recorded on Windows is read on a Mac, and the other way round, so reading never asks which host it is +running on: it asks the session. ## The master clock @@ -43,9 +48,27 @@ only on a third OS. Where one host has no analogue of something, the facade function exists on both and one answers "nothing": `trace::start` on the `unsupported` host returns no taps and a line saying so, and `power::start` returns no watcher. +## Flavors and rules + +Which komorebi produced a session decides what its events mean (a process id and an accessibility notification on a Mac, +a window handle and a WinEvent on Windows), how it hides a window (by moving it to a corner on a Mac, by cloaking it in +place on Windows) and how its log marks where a handler starts and ends. `Flavor` names the two and is recorded in the +manifest. `Rules` is the trait that holds what each knows, and `Flavor::rules` is where a name becomes rules. Code that +reads komorebi's output asks `Rules` and does not match on `Flavor`; only convention holds that, and it has exceptions: +the index of komorebi's source that log lines are mapped to, and the places the log and a checkout of the source are +looked for on disk, are keyed by `Flavor` directly. + +The contract is a counting rule: a flavor implements every method of `Rules` that has no default, and the compiler +refuses an implementor that misses one. A method with a default is a question most flavors answer the same way, and the +doc of each says what the default is. `Rules::verified` is how a flavor says that its rules were written from komorebi's +source and have not been checked against a recording of it: an analysis that reads such a session says so. + ## Rules and the layer that enforces them | Rule | Enforced by | |---|---| | Every host provides the same functions with the same signatures | the compiler, for the host being built; `just check-windows` and CI build the Windows host from a Mac; nothing builds `unsupported` | | A timestamp in a session is on the master clock | convention, and the tests that place records at known times: the type is a plain `u64` | +| An implementor of `Rules` answers every question that has no default | the type system: a missing method is E0046, proved by a `compile_fail` example on `Rules` | +| Code that reads komorebi's output asks `Rules` and does not match on `Flavor` | convention only | +| Rules that were not checked against a recording of komorebi say so | the framework path: an analysis that reads such a session adds a warning | diff --git a/komorebi-recorder/src/analysis/classify.rs b/komorebi-recorder/src/analysis/classify.rs new file mode 100644 index 00000000..a99ed81a --- /dev/null +++ b/komorebi-recorder/src/analysis/classify.rs @@ -0,0 +1,406 @@ +//! What kind of thing each event was, and which ones are noise. +//! +//! A recording of komorebi at work is mostly events nobody asked for: when komorebi moves a window +//! the OS reports the move back, so a single command is followed by a burst of `MoveEnd` +//! notifications for the windows it just placed. Reading those as if the user had dragged windows +//! around sends an investigation the wrong way, so events are classified, with the reason. +//! +//! The classification is a judgement made from the event, its neighbours and whether the state +//! changed, and the `reason` explains it. It never hides an event; it labels it. +//! +//! Two parties make it. Which class a single event has depends on komorebi's vocabulary, so the +//! [`Rules`] of the flavor that recorded the session answer that: +//! [`Rules::classify_window_manager`] for what the OS reported and [`Rules::classify_monitor`] for +//! a display or machine notification. What only the neighbours can tell, an echo, and whether a +//! button release was a real drag, is the same for both flavors and is decided here, by +//! [`classify`]. + +use super::events::EventRecord; +use super::events::Family; +use super::flavor::Rules; +use schemars::JsonSchema; +use serde::Serialize; + +/// The kind of thing an event was. +/// +/// Serialized in kebab case (`command`, `drag`, ..., `other`). A class tells a reader what to make +/// of an event in a recording; the [`Classification`] that carries it gives the reason. +/// +/// Most are answers of the flavor's [`Rules`]. [`classify`] itself gives [`Class::Command`] to every +/// command that is not a query, and [`Class::Other`] to a query or an unreadable notification. It +/// derives [`Class::Echo`] from an event and its neighbours, and a [`Rules`] implementor never +/// answers that one. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum Class { + /// Sent to komorebi by `komorebic` or a hotkey daemon. + Command, + /// The OS reported a window being moved or resized, not caused by komorebi. + Drag, + /// Which application or window has focus changed. + Focus, + /// A window or application appeared, went away, was minimized or restored. On Windows, also a + /// window hidden or shown, which is often komorebi cloaking the windows of a workspace it left. + Lifecycle, + /// The OS reporting back a move or resize komorebi itself had just made. + Echo, + /// komorebi's own synthetic events: a show it made up, a button release its mouse listener + /// reported as the end of a move or resize that moved nothing, or (on Windows) a window it + /// raised. + Manual, + /// A display was connected, disconnected or resized. + Monitor, + /// Screen lock, unlock or a space change. On Windows, also a sleep or wake and a virtual + /// desktop left or returned to. + Lock, + /// None of the above: a query or a subscription (usually this recorder talking to komorebi), an + /// event no rule recognizes, or a notification that could not be read. + Other, +} + +/// A [`Class`] and the reason for it. +/// +/// The reason is a sentence for a reader, written by whoever gave the class: a [`Rules`] method, +/// or [`classify`] for an echo, a promoted drag, a command and a query. It names the evidence +/// where there is some (an echo names the event it followed and how long after). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct Classification { + /// The kind of event it was. + pub class: Class, + /// Why it was given that class. + pub reason: String, +} + +/// How long after komorebi acts the OS may still be reporting the windows it moved. +const ECHO_WINDOW_NS: u64 = 1_500_000_000; + +/// Commands that ask komorebi something or register a listener rather than change anything. +fn is_bookkeeping(name: &str) -> bool { + matches!( + name, + "State" + | "GlobalState" + | "DebugWindow" + | "AddSubscriberSocket" + | "AddSubscriberSocketWithOptions" + | "RemoveSubscriberSocket" + ) || name.starts_with("Query") +} + +fn class_of(event: &EventRecord, rules: &dyn Rules) -> Classification { + let (class, reason) = match event.info.family { + Family::Command if is_bookkeeping(&event.info.name) => ( + Class::Other, + "a query or subscription, not a change: usually this recorder talking to komorebi", + ), + Family::Command => (Class::Command, "a command sent to komorebi"), + Family::Monitor => rules.classify_monitor(event), + Family::Unknown => (Class::Other, "the notification could not be read"), + Family::WindowManager => rules.classify_window_manager(event), + }; + + Classification { + class, + reason: reason.to_string(), + } +} + +/// Returns the [`Classification`] of every event: one per event, in the same order. +/// +/// `events` must be in receipt order, which is how `events::load` returns +/// them. Each event is first classified alone: a command is [`Class::Command`], except a query or +/// subscription (`State`, `GlobalState`, `DebugWindow`, `AddSubscriberSocket`, +/// `AddSubscriberSocketWithOptions`, `RemoveSubscriberSocket`, or a name that starts with +/// `Query`), which is [`Class::Other`]; an event that could not be read is [`Class::Other`]; a +/// window-manager event or a monitor notification is whatever +/// [`Rules::classify_window_manager`] or [`Rules::classify_monitor`] returns. Then the neighbours +/// are looked at, and two labels change: +/// +/// - A [`Class::Manual`] event that [`Rules::is_geometry_event`] names, after which the state +/// changed (it has a different state id from the event before, and both have one), is a real +/// drag: [`Class::Drag`]. +/// - A [`Class::Drag`] after which the state did not change (the same state id as the event +/// before) is an echo, [`Class::Echo`], if komorebi acted shortly before: looking back from it +/// for the nearest earlier event that is a command, focus, lifecycle, monitor or lock event, +/// that event was received at most 1.5 s earlier. An event with no state id is never called an +/// echo. +/// +/// When the pattern does not hold (the move changed the state, nothing happened in the second and +/// a half before it, or the state is not known) the event stays a drag. It is a judgement, not a +/// proof, and the reason gives what it was based on. +/// +/// # Example +/// +/// A move that changes nothing right after a command is the OS reporting the command back; the same +/// move later, with the layout changed, is a real one: +/// +/// ```rust +/// use komorebi_recorder::analysis::classify::{classify, Class}; +/// use komorebi_recorder::analysis::events::EventRecord; +/// use komorebi_recorder::analysis::flavor::Flavor; +/// use serde_json::json; +/// +/// let rules = Flavor::Macos.rules(); +/// let event = |seq: u64, at_ms: u64, state_id: u64, value: serde_json::Value| EventRecord { +/// seq, +/// recv_mach_ns: at_ms * 1_000_000, +/// recv_wall_us: 0, +/// state_id: Some(state_id), +/// info: rules.describe_event(&value), +/// parse_error: None, +/// }; +/// +/// let moved = json!({ +/// "type": "MoveEnd", +/// "content": [{"type": "Accessibility", "content": "AXWindowMoved"}, 10, 7] +/// }); +/// let events = vec![ +/// event(1, 0, 1, json!({"type": "FocusWorkspaceNumber", "content": 1})), +/// event(2, 80, 1, moved.clone()), // state unchanged, 80 ms after the command +/// event(3, 10_000, 2, moved), // much later, and the state changed +/// ]; +/// +/// let found = classify(&events, rules); +/// let classes: Vec = found.iter().map(|found| found.class).collect(); +/// assert_eq!(classes, [Class::Command, Class::Echo, Class::Drag]); +/// assert!(found[1].reason.contains("FocusWorkspaceNumber")); +/// ``` +#[must_use] +pub fn classify(events: &[EventRecord], rules: &dyn Rules) -> Vec { + let mut classes: Vec = + events.iter().map(|event| class_of(event, rules)).collect(); + + for index in 0..events.len() { + let event = &events[index]; + + // a release that did change the layout was a real drag + if classes[index].class == Class::Manual + && rules.is_geometry_event(&event.info.name) + && index > 0 + && event.state_id.is_some() + && events[index - 1].state_id.is_some() + && event.state_id != events[index - 1].state_id + { + classes[index] = Classification { + class: Class::Drag, + reason: "the end of a mouse drag or resize, and the layout changed because of it" + .to_string(), + }; + continue; + } + + if classes[index].class != Class::Drag { + continue; + } + + // a move or resize that changed nothing, soon after komorebi acted, is komorebi's own + let unchanged = + index > 0 && event.state_id.is_some() && event.state_id == events[index - 1].state_id; + if !unchanged { + continue; + } + + let acted = (0..index).rev().find_map(|earlier| { + let gap = event + .recv_mach_ns + .saturating_sub(events[earlier].recv_mach_ns); + if gap > ECHO_WINDOW_NS { + return Some(None); + } + + matches!( + classes[earlier].class, + Class::Command | Class::Focus | Class::Lifecycle | Class::Monitor | Class::Lock + ) + .then(|| Some((earlier, gap))) + }); + + if let Some(Some((earlier, gap))) = acted { + classes[index] = Classification { + class: Class::Echo, + reason: format!( + "state unchanged, {:.0} ms after komorebi handled {} (#{}): the OS reporting back windows komorebi moved", + gap as f64 / 1e6, + events[earlier].info.summary(), + events[earlier].seq + ), + }; + } + } + + classes +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analysis::flavor::Flavor; + use serde_json::json; + + fn event(seq: u64, at_ms: u64, state_id: Option, value: serde_json::Value) -> EventRecord { + EventRecord { + seq, + recv_mach_ns: at_ms * 1_000_000, + recv_wall_us: 0, + state_id, + info: Flavor::Macos.rules().describe_event(&value), + parse_error: None, + } + } + + fn command(seq: u64, at_ms: u64, state: u64) -> EventRecord { + event( + seq, + at_ms, + Some(state), + json!({"type": "FocusWorkspaceNumber", "content": 1}), + ) + } + + fn move_end(seq: u64, at_ms: u64, state: u64) -> EventRecord { + event( + seq, + at_ms, + Some(state), + json!({"type": "MoveEnd", "content": [{"type": "Accessibility", "content": "AXWindowMoved"}, 10, 7]}), + ) + } + + fn classes(events: &[EventRecord]) -> Vec { + classify(events, Flavor::Macos.rules()) + .iter() + .map(|c| c.class) + .collect() + } + + #[test] + fn each_family_and_name_has_a_class() { + let events = vec![ + command(1, 0, 1), + event(2, 10, Some(2), json!({"DisplayConnectionChange": 1})), + event( + 3, + 20, + Some(3), + json!({"type": "FocusChange", "content": [{"type": "Accessibility", "content": "AXApplicationActivated"}, 1, null]}), + ), + event( + 4, + 30, + Some(4), + json!({"type": "Show", "content": [{"type": "Accessibility", "content": "AXWindowCreated"}, 1]}), + ), + event( + 5, + 40, + Some(5), + json!({"type": "Show", "content": [{"type": "Manual", "content": "ShowOnInputEvent"}, 1]}), + ), + event( + 6, + 50, + Some(6), + json!({"type": "ScreenLock", "content": [{"type": "Accessibility", "content": "x"}, 1]}), + ), + ]; + + assert_eq!( + classes(&events), + [ + Class::Command, + Class::Monitor, + Class::Focus, + Class::Lifecycle, + Class::Manual, + Class::Lock + ] + ); + } + + fn synthesized_move_end(seq: u64, at_ms: u64, state: u64) -> EventRecord { + event( + seq, + at_ms, + Some(state), + json!({"type": "MoveEnd", "content": [{"type": "Manual", "content": "MoveEnd"}, 10, 7]}), + ) + } + + #[test] + fn a_button_release_komorebi_made_up_is_not_an_echo_of_a_move() { + // seen on a real recording: a click, then komorebi's mouse listener reports MoveEnd and + // ResizeEnd with the notification "MoveEnd" rather than one of macOS's + let events = vec![command(1, 0, 1), synthesized_move_end(2, 100, 1)]; + let found = classify(&events, Flavor::Macos.rules()); + assert_eq!(found[1].class, Class::Manual); + assert!( + found[1].reason.contains("mouse listener"), + "{}", + found[1].reason + ); + + // but one after which the layout really changed is a drag that ended + let dragged = vec![command(1, 0, 1), synthesized_move_end(2, 100, 2)]; + assert_eq!( + classify(&dragged, Flavor::Macos.rules())[1].class, + Class::Drag + ); + } + + #[test] + fn queries_and_subscriptions_are_not_commands() { + let events = vec![ + event( + 1, + 0, + Some(1), + json!({"type": "AddSubscriberSocketWithOptions", "content": ["x", {}]}), + ), + event(2, 10, Some(1), json!({"type": "State"})), + event(3, 20, Some(1), json!({"type": "QueryFocusedWorkspaceName"})), + ]; + assert_eq!(classes(&events), [Class::Other, Class::Other, Class::Other]); + } + + #[test] + fn a_move_that_changed_the_state_is_a_real_move() { + let events = vec![command(1, 0, 1), move_end(2, 200, 2)]; + assert_eq!(classes(&events), [Class::Command, Class::Drag]); + } + + #[test] + fn moves_that_change_nothing_right_after_a_command_are_echoes_of_it() { + // the burst seen after FocusWorkspaceNumber in a real log: one MoveEnd per window + let events = vec![ + command(1, 0, 1), + move_end(2, 80, 1), + move_end(3, 120, 1), + move_end(4, 160, 1), + ]; + + let found = classify(&events, Flavor::Macos.rules()); + assert_eq!(found[1].class, Class::Echo); + assert_eq!(found[3].class, Class::Echo); + assert!( + found[1].reason.contains("FocusWorkspaceNumber"), + "{}", + found[1].reason + ); + assert!(found[1].reason.contains("#1")); + } + + #[test] + fn an_unchanged_move_long_after_anything_is_not_called_an_echo() { + let events = vec![command(1, 0, 1), move_end(2, 10_000, 1)]; + assert_eq!(classes(&events), [Class::Command, Class::Drag]); + } + + #[test] + fn an_unchanged_move_with_no_state_is_not_called_an_echo() { + let events = vec![command(1, 0, 1), move_end(2, 100, 1)]; + let mut without = events.clone(); + without[1].state_id = None; + assert_eq!(classes(&without)[1], Class::Drag); + assert_eq!(classes(&events)[1], Class::Echo); + } +} diff --git a/komorebi-recorder/src/analysis/envelope.rs b/komorebi-recorder/src/analysis/envelope.rs new file mode 100644 index 00000000..aff362ab --- /dev/null +++ b/komorebi-recorder/src/analysis/envelope.rs @@ -0,0 +1,468 @@ +//! The shape every investigation command prints with `--json`, and how a failure maps to an exit +//! code. +//! +//! An agent driving the CLI should never have to guess whether a command worked, whether the output +//! was cut short, or why it failed, so every command answers with the same envelope and exits with +//! a code that names the kind of failure it was. +//! +//! There are two halves. An [`Envelope`] is what a command prints: `ok`, the `schema` that names +//! the shape of its `data`, the `data`, any `warnings`, whether the output was `truncated` (and the +//! `next_cursor` to ask for the rest) and, on failure, an `error`. An [`ExitKind`] is the kind of a +//! failure, and decides the process's exit code. Code that fails returns an [`eyre::Report`] built +//! by [`usage`], [`not_found`], [`unusable`] or [`over_budget`]; [`exit_kind`] reads the kind back +//! out of a report, however much context was added to it on the way up, and +//! `crate::cli::execute` turns it into the exit code and, with `--json`, an envelope. +//! +//! | Exit code | [`ExitKind`] | Meaning | +//! |---|---|---| +//! | 0 | [`Ok`](ExitKind::Ok) | the command worked | +//! | 1 | [`Error`](ExitKind::Error) | something went wrong that is none of the kinds below | +//! | 2 | [`Usage`](ExitKind::Usage) | the command line was wrong | +//! | 3 | [`NotFound`](ExitKind::NotFound) | the session, or something asked for in it, does not exist | +//! | 4 | [`Unusable`](ExitKind::Unusable) | the session exists but cannot answer: still recording, or it recorded nothing | +//! | 5 | [`Budget`](ExitKind::Budget) | the answer would have exceeded the time or size budget | +//! +//! The codes are stable. [`ENVELOPE_VERSION`] is bumped when the envelope's own fields change; +//! the shape of each command's `data` is versioned by its `schema` (`brief/1`), and the +//! `schemas` module has them. +//! +//! # Example +//! +//! A failure, with a hint for what to do next, and what a command makes of it: +//! +//! ```rust +//! use komorebi_recorder::analysis::envelope::{self, Envelope, ExitKind}; +//! +//! let failure = envelope::with_hint( +//! envelope::unusable("/tmp/s is still being recorded"), +//! "stop the recording first", +//! ); +//! assert_eq!(envelope::exit_kind(&failure), ExitKind::Unusable); +//! assert_eq!(envelope::exit_kind(&failure).code(), 4); +//! +//! // with --json the same failure is printed as an envelope with `ok: false` +//! let json = serde_json::to_value(Envelope::failed(&failure))?; +//! assert_eq!(json["ok"], false); +//! assert_eq!(json["schema"], "error/1"); +//! assert_eq!(json["error"]["kind"], "unusable"); +//! assert_eq!(json["error"]["code"], 4); +//! assert_eq!(json["error"]["hint"], "stop the recording first"); +//! +//! // and a success has the same fields, with `data` in place of `error` +//! let json = serde_json::to_value(Envelope::ok("demo/1", vec![1, 2, 3]))?; +//! assert_eq!(json["ok"], true); +//! assert_eq!(json["data"], serde_json::json!([1, 2, 3])); +//! assert!(json["error"].is_null()); +//! # Ok::<(), Box>(()) +//! ``` + +use color_eyre::eyre; +use schemars::JsonSchema; +use serde::Serialize; +use std::fmt; + +/// Bumped when the envelope's own fields change (each command's `data` is versioned by `schema`). +pub const ENVELOPE_VERSION: u32 = 1; + +/// How the process exits: the kind of a failure, and the exit code that goes with it. +/// +/// Serialized in kebab case (`ok`, `error`, `usage`, `not-found`, `unusable`, `budget`) in an +/// envelope's `error.kind`. [`ExitKind::code`] gives the exit code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum ExitKind { + /// The command worked: exit code 0. + Ok, + /// Something went wrong that is not one of the cases below: exit code 1. This is also the kind + /// of any error that does not carry one. + Error, + /// The command line was wrong: exit code 2. For example an argument that does not parse (a + /// time like `soon`) or a path that points outside the session. + Usage, + /// The session, or something asked for in it, does not exist: exit code 3. + NotFound, + /// The session exists but cannot answer: still recording, or it recorded nothing. Exit code 4. + Unusable, + /// The answer would have exceeded the time or size budget: exit code 5. Narrowing the request + /// is the way forward. + Budget, +} + +impl ExitKind { + /// Returns the process exit code of this kind: 0 for [`ExitKind::Ok`], then 1 to 5 in the order + /// the variants are declared. + /// + /// The mapping is stable, so a script may rely on the numbers (`exit_codes_are_stable` pins + /// them). + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::analysis::envelope::ExitKind; + /// + /// assert_eq!(ExitKind::Ok.code(), 0); + /// assert_eq!(ExitKind::Usage.code(), 2); + /// assert_eq!(ExitKind::NotFound.code(), 3); + /// assert_eq!(ExitKind::Budget.code(), 5); + /// ``` + #[must_use] + pub fn code(self) -> u8 { + match self { + Self::Ok => 0, + Self::Error => 1, + Self::Usage => 2, + Self::NotFound => 3, + Self::Unusable => 4, + Self::Budget => 5, + } + } +} + +/// An error that carries how the process should exit and, optionally, what to do about it. +/// +/// It is not built directly: [`usage`], [`not_found`], [`unusable`] and [`over_budget`] make one and +/// return it as an [`eyre::Report`], and [`with_hint`] adds the hint. Its [`Display`](fmt::Display) +/// is the message, followed by the hint in parentheses when there is one. +#[derive(Debug)] +pub struct CliError { + /// How the process should exit. + pub kind: ExitKind, + /// A description of what went wrong, for a reader. + pub message: String, + /// A suggestion for what the caller can do next, if there is one. + pub hint: Option, +} + +impl fmt::Display for CliError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.message)?; + if let Some(hint) = &self.hint { + write!(f, " ({hint})")?; + } + Ok(()) + } +} + +impl std::error::Error for CliError {} + +fn failure(kind: ExitKind, message: impl Into) -> eyre::Report { + eyre::Report::new(CliError { + kind, + message: message.into(), + hint: None, + }) +} + +/// Returns an error of kind [`ExitKind::Usage`] with this message: the command line was wrong. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::analysis::envelope::{self, ExitKind}; +/// +/// let error = envelope::usage("an empty time"); +/// assert_eq!(envelope::exit_kind(&error), ExitKind::Usage); +/// assert_eq!(error.to_string(), "an empty time"); +/// ``` +pub fn usage(message: impl Into) -> eyre::Report { + failure(ExitKind::Usage, message) +} + +/// Returns an error of kind [`ExitKind::NotFound`] with this message: the session, or something +/// asked for in it, does not exist. +pub fn not_found(message: impl Into) -> eyre::Report { + failure(ExitKind::NotFound, message) +} + +/// Returns an error of kind [`ExitKind::Unusable`] with this message: the session exists but +/// cannot answer. +pub fn unusable(message: impl Into) -> eyre::Report { + failure(ExitKind::Unusable, message) +} + +/// Returns an error of kind [`ExitKind::Budget`] with this message: the answer would have +/// exceeded the time or size budget. +/// +/// The message should say how to narrow the request. It is made from an `Exceeded` of the +/// `budget` module where the scan has one. +pub fn over_budget(message: impl Into) -> eyre::Report { + failure(ExitKind::Budget, message) +} + +/// Returns the same failure with a pointer to what the caller can do next. +/// +/// If `report` is a [`CliError`], its kind is kept and the hint is set on it (replacing an earlier +/// hint). That holds for a [`CliError`] that has had context added with `wrap_err` as well, but the +/// result is then the [`CliError`] alone: the context that was added is not kept. Any other error +/// becomes the context of the hint: the hint is then the outermost message, and the kind is +/// [`ExitKind::Error`]. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::analysis::envelope::{self, ExitKind}; +/// +/// let error = envelope::with_hint( +/// envelope::unusable("still recording"), +/// "run `reindex` if it died", +/// ); +/// assert_eq!(envelope::exit_kind(&error), ExitKind::Unusable); +/// assert_eq!(error.to_string(), "still recording (run `reindex` if it died)"); +/// ``` +pub fn with_hint(report: eyre::Report, hint: impl Into) -> eyre::Report { + match report.downcast::() { + Ok(mut error) => { + error.hint = Some(hint.into()); + eyre::Report::new(error) + } + Err(report) => report.wrap_err(hint.into()), + } +} + +/// Returns how `report` should make the process exit. +/// +/// The kind is that of the first [`CliError`] anywhere in the report's chain of causes, so context +/// added with `wrap_err` on the way up does not change it. A report with none is +/// [`ExitKind::Error`]. +/// +/// # Example +/// +/// ```rust +/// use color_eyre::eyre::WrapErr; +/// use komorebi_recorder::analysis::envelope::{self, ExitKind}; +/// +/// let report = Err::<(), _>(envelope::not_found("no such event")) +/// .wrap_err("while resolving the start") +/// .unwrap_err(); +/// assert_eq!(envelope::exit_kind(&report), ExitKind::NotFound); +/// +/// // an error from anywhere else is the generic kind +/// let plain = color_eyre::eyre::Report::msg("boom"); +/// assert_eq!(envelope::exit_kind(&plain), ExitKind::Error); +/// ``` +#[must_use] +pub fn exit_kind(report: &eyre::Report) -> ExitKind { + report + .chain() + .find_map(|cause| cause.downcast_ref::()) + .map_or(ExitKind::Error, |error| error.kind) +} + +/// The `error` of a failed [`Envelope`]: the kind of failure and what to do about it. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ErrorBody { + /// The kind of failure, in kebab case. + pub kind: ExitKind, + /// The process exit code of `kind`, which is also this command's exit status. + pub code: u8, + /// The message of the outermost error. When the error has a hint this includes it, in + /// parentheses, as [`CliError`]'s `Display` does. + pub message: String, + /// A suggestion for what the caller can do next, if the error gave one. + pub hint: Option, +} + +/// The JSON object every command prints with `--json`. +/// +/// Every command prints this shape, whatever it answers: `data` varies and is described by +/// `schema`, and the other fields mean the same everywhere. On success `ok` is `true`, `data` is +/// the answer and `error` is `null`. On failure `ok` is `false`, `data` is `null`, and `error` +/// describes what happened. On failure the process exit code is `error.code`; on success it is 0. +/// +/// The fields are serialized in this order: `ok`, `schema`, `envelope_version`, `data`, +/// `warnings`, `truncated`, `next_cursor`, `error`. They are always present; an absent value is +/// `null` or an empty list. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::analysis::envelope::Envelope; +/// +/// let envelope = Envelope::ok("events/1", vec!["event:1", "event:2"]) +/// .warnings(vec!["the log level was info".to_string()]) +/// .truncated(Some("2".to_string())); +/// +/// let json = serde_json::to_value(&envelope)?; +/// assert_eq!(json["ok"], true); +/// assert_eq!(json["schema"], "events/1"); +/// assert_eq!(json["warnings"][0], "the log level was info"); +/// // cut short: pass the cursor back as `--cursor` for the rest +/// assert_eq!(json["truncated"], true); +/// assert_eq!(json["next_cursor"], "2"); +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Debug, Serialize, JsonSchema)] +pub struct Envelope { + /// Whether the command worked. + pub ok: bool, + /// Which command's `data` this is, e.g. `brief/1`; see `komorebi-recorder schema`. + pub schema: String, + /// The version of the envelope's own fields: [`ENVELOPE_VERSION`]. + pub envelope_version: u32, + /// The command's answer, shaped as `schema` says. `None` (`null`) on failure. + pub data: Option, + /// Things that went wrong reading the session that did not stop the answer, shown with it. + pub warnings: Vec, + /// The output was cut short; pass `next_cursor` back as `--cursor` for the rest. + pub truncated: bool, + /// Where to continue from when `truncated`, as the command's `--cursor` takes it. `None` + /// (`null`) when the output was not cut short, or was but the command has no cursor to give. + pub next_cursor: Option, + /// Why the command failed. `None` (`null`) on success. + pub error: Option, +} + +impl Envelope { + /// Returns the envelope of a successful command: `ok`, with `data` as the answer to the command + /// whose output is described by `schema`. + /// + /// The envelope has no warnings, is not truncated and has no error; add warnings with + /// [`Envelope::warnings`] and mark a cut-short answer with [`Envelope::truncated`]. + #[must_use] + pub fn ok(schema: &str, data: T) -> Self { + Self { + ok: true, + schema: schema.to_string(), + envelope_version: ENVELOPE_VERSION, + data: Some(data), + warnings: Vec::new(), + truncated: false, + next_cursor: None, + error: None, + } + } + + /// Returns the envelope with `warnings` as its warnings. They replace any that were there. + #[must_use] + pub fn warnings(mut self, warnings: Vec) -> Self { + self.warnings = warnings; + self + } + + /// Returns the envelope marked as cut short, with `next_cursor` as how to ask for the rest. + /// + /// A `next_cursor` of `None` still marks the output truncated. + #[must_use] + pub fn truncated(mut self, next_cursor: Option) -> Self { + self.truncated = true; + self.next_cursor = next_cursor; + self + } +} + +impl Envelope<()> { + /// Returns the envelope of a failed command: `ok` is `false`, the `schema` is `error/1`, and + /// the `error` has the kind and exit code of `report` ([`exit_kind`]). + /// + /// The `message` is the outermost error of the report, not the whole chain of causes. A + /// [`CliError`] with a hint has the hint in its message as well as in `hint`. The `hint` is the + /// one of the first [`CliError`] in the chain, if it has one. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::analysis::envelope::{self, Envelope}; + /// + /// let report = envelope::not_found("there is no event #99"); + /// let envelope = Envelope::failed(&report); + /// + /// assert!(!envelope.ok); + /// assert!(envelope.data.is_none()); + /// let error = envelope.error.expect("a failed envelope has an error"); + /// assert_eq!(error.code, 3); + /// assert_eq!(error.message, "there is no event #99"); + /// assert_eq!(error.hint, None); + /// ``` + #[must_use] + pub fn failed(report: &eyre::Report) -> Self { + let kind = exit_kind(report); + let hint = report + .chain() + .find_map(|cause| cause.downcast_ref::()) + .and_then(|error| error.hint.clone()); + + Self { + ok: false, + schema: "error/1".to_string(), + envelope_version: ENVELOPE_VERSION, + data: None, + warnings: Vec::new(), + truncated: false, + next_cursor: None, + error: Some(ErrorBody { + kind, + code: kind.code(), + message: report + .chain() + .next() + .map_or_else(String::new, ToString::to_string), + hint, + }), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn exit_codes_are_stable() { + let codes: Vec = [ + ExitKind::Ok, + ExitKind::Error, + ExitKind::Usage, + ExitKind::NotFound, + ExitKind::Unusable, + ExitKind::Budget, + ] + .iter() + .map(|kind| kind.code()) + .collect(); + assert_eq!(codes, [0, 1, 2, 3, 4, 5]); + } + + #[test] + fn a_plain_error_exits_with_the_generic_code() { + assert_eq!(exit_kind(&eyre::Report::msg("boom")), ExitKind::Error); + } + + #[test] + fn the_kind_survives_added_context() { + use color_eyre::eyre::WrapErr; + + let report = Err::<(), _>(not_found("no such event")) + .wrap_err("while resolving the start") + .unwrap_err(); + assert_eq!(exit_kind(&report), ExitKind::NotFound); + } + + #[test] + fn a_hint_travels_with_the_error() { + let report = with_hint(unusable("still recording"), "run `reindex` if it died"); + assert_eq!(exit_kind(&report), ExitKind::Unusable); + + let envelope = Envelope::failed(&report); + let body = envelope.error.expect("error body"); + assert_eq!(body.code, 4); + assert_eq!(body.hint.as_deref(), Some("run `reindex` if it died")); + assert!(!envelope.ok); + } + + #[test] + fn the_envelope_has_a_fixed_shape() { + let envelope = Envelope::ok("demo/1", vec![1, 2, 3]) + .warnings(vec!["careful".to_string()]) + .truncated(Some("c2".to_string())); + let value = serde_json::to_value(&envelope).expect("serialize"); + + assert_eq!(value["ok"], true); + assert_eq!(value["schema"], "demo/1"); + assert_eq!(value["envelope_version"], ENVELOPE_VERSION); + assert_eq!(value["data"], serde_json::json!([1, 2, 3])); + assert_eq!(value["warnings"], serde_json::json!(["careful"])); + assert_eq!(value["truncated"], true); + assert_eq!(value["next_cursor"], "c2"); + assert!(value["error"].is_null()); + } +} diff --git a/komorebi-recorder/src/analysis/events.rs b/komorebi-recorder/src/analysis/events.rs new file mode 100644 index 00000000..ed9d3f9c --- /dev/null +++ b/komorebi-recorder/src/analysis/events.rs @@ -0,0 +1,175 @@ +//! komorebi's subscriber events, understood. +//! +//! `events.jsonl` keeps each notification's JSON as komorebi serialized it. Three kinds reach a +//! subscriber and they look different on the wire: +//! +//! - window-manager events, from the OS: `{"type":"MoveEnd","content":[{"type":"Accessibility", +//! "content":"AXWindowMoved"},,]}` +//! - commands, from `komorebic` or a hotkey daemon: `{"type":"FocusWorkspaceNumber","content":0}` +//! - monitor changes: `{"DisplayConnectionChange":}` +//! +//! The shapes above are komorebi for Mac's. komorebi for Windows serializes the same three kinds +//! differently (a window handle and a `WinEvent` in place of a process id and an accessibility +//! notification), so reading one is up to the `Rules` of the flavor that recorded the session +//! ([`super::flavor`]). This module defines what a read event looks like, an [`EventRecord`] with +//! the name, process and window pulled out, and `load`, which reads a session's whole +//! `events.jsonl` through those rules. +//! +//! What an event *was* (a command, a drag, an echo of komorebi's own move) is not decided here; +//! [`super::classify`] labels each [`EventRecord`]. Tying an event to the log lines of the handler +//! that produced it is done by `blocks::pair`. Both read the events this module returns. + +use schemars::JsonSchema; +use serde::Serialize; +use serde_json::Value; + +/// Which of the kinds of notification a subscriber receives an event is, or that it could not be +/// read. +/// +/// Serialized in kebab case (`window-manager`, `command`, `monitor`, `unknown`). +/// `Rules::describe_event` decides the family of an event, so the same JSON can be in a different +/// family for the two flavors (a bare string is a command for komorebi for Mac and, if it is +/// not a virtual desktop notification, [`Family::Unknown`] for komorebi for Windows). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, JsonSchema)] +#[serde(rename_all = "kebab-case")] +pub enum Family { + /// Something the operating system reported about a window or application. + WindowManager, + /// A command sent to komorebi. + Command, + /// A display was connected, disconnected or resized. On Windows, also a notification about the + /// machine (a lock, a sleep, a wake) or a virtual desktop. + Monitor, + /// The notification could not be read. + Unknown, +} + +/// One notification from komorebi, read by the `Rules` of the flavor that sent it. +/// +/// The fields are what the analysis asks of an event whatever the flavor: what kind it is, what it +/// is called, which process or window it is about, and what the OS said. A field is `None` when the +/// event does not say, which is not the same as the event being about nothing. The `exe` is left +/// out of the serialized form when it is `None`. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::analysis::events::Family; +/// use komorebi_recorder::analysis::flavor::Flavor; +/// use serde_json::json; +/// +/// let info = Flavor::Macos.rules().describe_event(&json!({ +/// "type": "FocusWorkspaceNumber", +/// "content": 2, +/// })); +/// assert_eq!(info.family, Family::Command); +/// assert_eq!(info.name, "FocusWorkspaceNumber"); +/// assert_eq!(info.argument.as_deref(), Some("2")); +/// assert_eq!(info.summary(), "FocusWorkspaceNumber 2"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +pub struct EventInfo { + /// Which kind of notification this is. + pub family: Family, + /// `MoveEnd`, `FocusWorkspaceNumber`, `DisplayConnectionChange`, ... For an event that could + /// not be read, `unreadable`. + pub name: String, + /// The notification the OS reported, e.g. `AXWindowMoved`; window-manager events only. + pub notification: Option, + /// The process the event is about. Window-manager events of komorebi for Mac have one; + /// komorebi for Windows events never do (they have a window handle instead). + pub pid: Option, + /// The window the event is about: a window id on a Mac (a focus change may name none) or a + /// handle on Windows. + pub window_id: Option, + /// The program of the window the event is about, where the event names it (komorebi for Windows + /// does; a process id is all the Mac gives). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exe: Option, + /// A command's argument or a monitor event's display id, as compact JSON. Longer than 80 + /// characters, it is cut to 79 and ends in `…`. + pub argument: Option, +} + +impl EventInfo { + /// Returns one line saying what this is: `MoveEnd pid 20934 AXWindowMoved`. + /// + /// The line is the name, then the program, the argument, `pid `, `window ` and the + /// notification, each only if there is one, separated by spaces. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::analysis::flavor::Flavor; + /// use serde_json::json; + /// + /// let moved = Flavor::Macos.rules().describe_event(&json!({ + /// "type": "MoveEnd", + /// "content": [{"type": "Accessibility", "content": "AXWindowMoved"}, 20934, 4711] + /// })); + /// assert_eq!(moved.summary(), "MoveEnd pid 20934 window 4711 AXWindowMoved"); + /// + /// let display = Flavor::Macos.rules().describe_event(&json!({"DisplayConnectionChange": 1})); + /// assert_eq!(display.summary(), "DisplayConnectionChange 1"); + /// ``` + #[must_use] + pub fn summary(&self) -> String { + let mut text = self.name.clone(); + if let Some(exe) = &self.exe { + text.push(' '); + text.push_str(exe); + } + if let Some(argument) = &self.argument { + text.push(' '); + text.push_str(argument); + } + if let Some(pid) = self.pid { + text.push_str(&format!(" pid {pid}")); + } + if let Some(window_id) = self.window_id { + text.push_str(&format!(" window {window_id}")); + } + if let Some(notification) = &self.notification { + text.push(' '); + text.push_str(notification); + } + text + } +} + +pub(crate) fn compact(value: &Value) -> String { + let text = value.to_string(); + if text.chars().count() > 80 { + let cut: String = text.chars().take(79).collect(); + format!("{cut}…") + } else { + text + } +} + +/// One recorded notification: a line of `events.jsonl` with its JSON read into an [`EventInfo`]. +/// +/// [`EventRecord::info`] is flattened when a record is serialized, so a record's JSON has the +/// `family`, `name` and the other fields of [`EventInfo`] beside the record's fields. +#[derive(Debug, Clone, PartialEq, Serialize, JsonSchema)] +pub struct EventRecord { + /// The notification's sequence number in the session: 1 for the first the recorder accepted, + /// counting up. It is what `event:` names. + pub seq: u64, + /// When the recorder received the notification, in nanoseconds on the host's master monotonic + /// clock (`mach_` is a historical spelling; the manifest's `host.clock` says `mach`, `qpc` or + /// `std`). For a window-manager event this is when its handler finished, because komorebi + /// notifies subscribers after the handler body. + pub recv_mach_ns: u64, + /// The same moment on the wall clock, in microseconds since the Unix epoch. + pub recv_wall_us: i64, + /// The state snapshot after this event (`states/.json`); the same id as the previous event + /// means the state did not change. + pub state_id: Option, + /// The notification, as the flavor's rules read it. + #[serde(flatten)] + pub info: EventInfo, + /// Why the notification's JSON could not be parsed, if it could not. Such a record has + /// [`Family::Unknown`] and the name `unreadable`. + pub parse_error: Option, +} diff --git a/komorebi-recorder/src/analysis/flavor/mac.rs b/komorebi-recorder/src/analysis/flavor/mac.rs new file mode 100644 index 00000000..85e0dfd4 --- /dev/null +++ b/komorebi-recorder/src/analysis/flavor/mac.rs @@ -0,0 +1,272 @@ +//! komorebi for Mac, as it describes itself. +//! +//! [`MacRules`] is the [`Rules`] of [`Flavor::Macos`]. It keeps the defaults of +//! [`Rules::verified`] (`true`), [`Rules::classify_monitor`] and [`Rules::window_is_unreadable`]. +//! Its tests read events shaped as komorebi for Mac sends them, and a `State` and a log that were +//! recorded from it. What the rules rely on: +//! +//! - A window event serializes as `{"type": "MoveEnd", "content": [, , +//! ]}`, where the notification is `{"type": "Accessibility", "content": +//! "AXWindowMoved"}` for what the accessibility API reported, `{"type": "AppKitWorkspace", +//! "content": "NS..."}` for what the workspace notification center did, or `{"type": "Manual", +//! ...}` for komorebi making one up (its mouse listener reports a button release as a move or +//! resize end this way). The process id is the only identification of the application. +//! - A monitor notification is a one-entry object, `{"DisplayConnectionChange": }`. +//! A command with no argument may be a bare string or `{"type": "Retile"}`. +//! - A window in the `State` is `{"id": ..., "details": {"title": ..., "exe": ...}, "rect": +//! ...}`, and its `rect` is read live from the accessibility API. +//! - The log has a header line for each handler, `processing event: for process with +//! notification ` or `processing command: `, and a command ends with +//! `processed command: `. No line ends an event's handler: komorebi sends the notification +//! after the handler body, so an event's receive time marks the end of its handler. +//! - komorebi hides a window by moving it to a corner of its monitor, leaving a one-point sliver +//! inside the display. +//! +//! [`verified`]: Rules::verified + +use super::Flavor; +use super::Rules; +use crate::analysis::classify::Class; +use crate::analysis::events::EventInfo; +use crate::analysis::events::EventRecord; +use crate::analysis::events::Family; +use serde_json::Value; + +/// The [`Rules`] of komorebi for Mac: what its events, `State`, log and modules say. +/// +/// A unit struct with no state. [`Flavor::rules`] returns a reference to one for +/// [`Flavor::Macos`]. It can also be used directly, as the example on [`Rules`] does. +pub struct MacRules; + +/// The names komorebi gives the events the OS reports. +const WINDOW_MANAGER_EVENTS: [&str; 14] = [ + "FocusChange", + "Show", + "Destroy", + "Minimize", + "Restore", + "Manage", + "Unmanage", + "MoveStart", + "MoveEnd", + "ResizeStart", + "ResizeEnd", + "SpaceChange", + "ScreenLock", + "ScreenUnlock", +]; + +const GEOMETRY_EVENTS: [&str; 4] = ["MoveStart", "MoveEnd", "ResizeStart", "ResizeEnd"]; + +/// Whether a window-manager event was made up by komorebi itself rather than reported by macOS. +fn is_synthesized(event: &EventRecord) -> bool { + event + .info + .notification + .as_deref() + .is_some_and(|notification| { + !notification.starts_with("AX") && !notification.starts_with("NS") + }) +} + +impl Rules for MacRules { + fn flavor(&self) -> Flavor { + Flavor::Macos + } + + fn window_id(&self, window: &Value) -> Option { + window.get("id")?.as_u64() + } + + fn hides_windows_by_position(&self) -> bool { + true + } + + fn describe_event(&self, event: &Value) -> EventInfo { + let unknown = |name: &str| EventInfo { + family: Family::Unknown, + name: name.to_string(), + notification: None, + pid: None, + window_id: None, + exe: None, + argument: None, + }; + + // a monitor notification is a one-entry object: {"Resize": 69733248} + if event.get("type").is_none() + && let Some(object) = event.as_object() + && object.len() == 1 + && let Some((name, value)) = object.iter().next() + { + return EventInfo { + family: Family::Monitor, + name: name.clone(), + notification: None, + pid: None, + window_id: None, + exe: None, + argument: Some(crate::analysis::events::compact(value)), + }; + } + + // a unit-variant command is a bare string + if let Some(name) = event.as_str() { + return EventInfo { + family: Family::Command, + name: name.to_string(), + ..unknown(name) + }; + } + + let Some(name) = event.get("type").and_then(Value::as_str) else { + return unknown("unreadable"); + }; + let content = event.get("content"); + + if WINDOW_MANAGER_EVENTS.contains(&name) { + let parts = content.and_then(Value::as_array); + let at = |index: usize| parts.and_then(|parts| parts.get(index)); + return EventInfo { + family: Family::WindowManager, + name: name.to_string(), + notification: at(0) + .and_then(|notification| notification.get("content")) + .and_then(Value::as_str) + .map(str::to_string), + pid: at(1) + .and_then(Value::as_i64) + .and_then(|pid| i32::try_from(pid).ok()), + window_id: at(2) + .and_then(Value::as_u64) + .and_then(|id| u32::try_from(id).ok()), + exe: None, + argument: None, + }; + } + + EventInfo { + family: Family::Command, + name: name.to_string(), + notification: None, + pid: None, + window_id: None, + exe: None, + argument: content + .filter(|value| !value.is_null()) + .map(crate::analysis::events::compact), + } + } + + fn is_geometry_event(&self, name: &str) -> bool { + GEOMETRY_EVENTS.contains(&name) + } + + fn classify_window_manager(&self, event: &EventRecord) -> (Class, &'static str) { + if event + .info + .notification + .as_deref() + .is_some_and(|notification| notification.starts_with("ShowOn")) + { + return (Class::Manual, "komorebi's own synthetic show event"); + } + + match event.info.name.as_str() { + "FocusChange" => (Class::Focus, "focus moved"), + "Show" | "Destroy" | "Minimize" | "Restore" | "Manage" | "Unmanage" => { + (Class::Lifecycle, "a window or application changed") + } + // komorebi's own mouse listener reports a button release as a move or resize end + // whose notification is not one of macOS's (AX..., NS...) + name if GEOMETRY_EVENTS.contains(&name) && is_synthesized(event) => ( + Class::Manual, + "komorebi's mouse listener saw a button release after a click or drag; nothing was reported moved", + ), + name if GEOMETRY_EVENTS.contains(&name) => ( + Class::Drag, + "a window was moved or resized by something other than komorebi", + ), + "ScreenLock" | "ScreenUnlock" | "SpaceChange" => { + (Class::Lock, "the screen locked or the space changed") + } + _ => (Class::Other, "not a known kind of event"), + } + } + + fn slow_handler_cause(&self) -> &'static str { + "slow accessibility calls into other applications, or waiting for the lock another handler held" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn describe(event: &Value) -> EventInfo { + MacRules.describe_event(event) + } + + #[test] + fn a_window_manager_event_has_its_process_window_and_notification() { + let info = describe(&json!({ + "type": "MoveEnd", + "content": [{"type": "Accessibility", "content": "AXWindowMoved"}, 20934, 4711] + })); + + assert_eq!(info.family, Family::WindowManager); + assert_eq!(info.name, "MoveEnd"); + assert_eq!(info.notification.as_deref(), Some("AXWindowMoved")); + assert_eq!(info.pid, Some(20934)); + assert_eq!(info.window_id, Some(4711)); + assert_eq!( + info.summary(), + "MoveEnd pid 20934 window 4711 AXWindowMoved" + ); + } + + #[test] + fn a_focus_change_may_have_no_window() { + let info = describe(&json!({ + "type": "FocusChange", + "content": [{"type": "AppKitWorkspace", "content": "NSWorkspaceDidActivateApplicationNotification"}, 79469, null] + })); + + assert_eq!(info.pid, Some(79469)); + assert_eq!(info.window_id, None); + assert_eq!( + info.notification.as_deref(), + Some("NSWorkspaceDidActivateApplicationNotification") + ); + } + + #[test] + fn commands_carry_their_argument() { + let info = describe(&json!({"type": "FocusWorkspaceNumber", "content": 2})); + assert_eq!(info.family, Family::Command); + assert_eq!(info.name, "FocusWorkspaceNumber"); + assert_eq!(info.argument.as_deref(), Some("2")); + + let bare = describe(&json!({"type": "Retile"})); + assert_eq!(bare.family, Family::Command); + assert_eq!(bare.argument, None); + + assert_eq!(describe(&json!("Retile")).family, Family::Command); + } + + #[test] + fn monitor_notifications_are_one_entry_objects() { + let info = describe(&json!({"DisplayConnectionChange": 1})); + assert_eq!(info.family, Family::Monitor); + assert_eq!(info.name, "DisplayConnectionChange"); + assert_eq!(info.argument.as_deref(), Some("1")); + } + + #[test] + fn nonsense_is_unknown_not_a_panic() { + assert_eq!(describe(&json!(12)).family, Family::Unknown); + assert_eq!(describe(&json!({"a": 1, "b": 2})).family, Family::Unknown); + assert_eq!(describe(&json!({"type": 3})).family, Family::Unknown); + } +} diff --git a/komorebi-recorder/src/analysis/flavor/mod.rs b/komorebi-recorder/src/analysis/flavor/mod.rs new file mode 100644 index 00000000..a4ea7a19 --- /dev/null +++ b/komorebi-recorder/src/analysis/flavor/mod.rs @@ -0,0 +1,510 @@ +//! The flavors of komorebi (for Mac and for Windows), and what the output of each one means. +//! +//! The host a session was *recorded on* (see [`crate::platform`]) and the *komorebi* it was +//! recording are separate things: a session recorded on Windows is investigated on a Mac, and the +//! other way round. Everything that reads komorebi's output (its events, its `State`, its log +//! and its source) is written in komorebi's vocabulary, and that differs between the two: +//! +//! - what an event looks like on the wire and what its fields mean (a process id and an +//! accessibility notification on macOS, a window handle and a `WinEvent` on Windows); +//! - which events move or resize a window, and which are komorebi making things up; +//! - how komorebi hides a window, and so whether a window's position shows that it is hidden; +//! - how its log marks where a handler starts and ends; +//! - which of its threads and modules do what. +//! +//! The module defines a pair of roles: +//! +//! - **A [`Flavor`] is a name for a komorebi.** It is a two-valued enum, recorded in the session's +//! manifest, that identifies which komorebi wrote the files being read. +//! - **A type that implements [`Rules`] is the vocabulary of that komorebi.** It answers the +//! questions the analysis has about komorebi's events, `State`, log and source. [`MacRules`] +//! (in [`mac`]) and [`WindowsRules`] (in [`windows`]) are the two implementors, and +//! [`Flavor::rules`] is the one place that turns a name into rules. +//! +//! The analysis is written once, against `&dyn Rules`: the functions that read an event, classify +//! it, group log lines into handlers and parse a `State` take the rules as an argument or reach +//! them through the `Snapshot` they read. The host does not take part: [`Flavor::of_host`] is only +//! the flavor a recorder writes into the manifest of a session it records. +//! +//! # The questions, and who answers them +//! +//! Each method of [`Rules`] is one question. Every row of this table is a place where the two +//! flavors differ; the third column names the code that asks. +//! +//! | Method | Answers | Asked by | komorebi for Mac | komorebi for Windows | +//! |---|---|---|---|---| +//! | [`describe_event`](Rules::describe_event) | what a notification's JSON means | `EventRecord::from_row` | `[{"type": "Accessibility", ...}, pid, window]` | `["", window]` | +//! | [`is_geometry_event`](Rules::is_geometry_event) | which events start or end a move or resize | [`classify`] | `MoveStart`, `MoveEnd`, `ResizeStart`, `ResizeEnd` | `MoveResizeStart`, `MoveResizeEnd` | +//! | [`classify_window_manager`](Rules::classify_window_manager) | what an OS-reported event was, and why | [`classify`] | a button release komorebi made up is [`Class::Manual`] | `Cloak` and `Uncloak` are [`Class::Lifecycle`] | +//! | [`classify_monitor`](Rules::classify_monitor) | what a display or machine notification was | [`classify`] | the default: [`Class::Monitor`] | lock, suspend and virtual desktop notifications are [`Class::Lock`] | +//! | [`window_id`](Rules::window_id) | the id a window has in `State` | `Snapshot::parse` | `id` | `hwnd` | +//! | [`window_is_unreadable`](Rules::window_is_unreadable) | whether komorebi failed to read a window | `Snapshot::parse` | the default: never | the title starts with `could not get window` | +//! | [`hides_windows_by_position`](Rules::hides_windows_by_position) | whether a window's position shows that it is hidden | `Snapshot::hides_windows_by_position` | yes: parked at a corner | no: cloaked where it is | +//! | [`slow_handler_cause`](Rules::slow_handler_cause) | what a slow handler was probably doing | the `overview` and `trace` answers | accessibility calls | Win32 calls | +//! | [`verified`](Rules::verified) | whether the rules were checked against a recording | `Analysis::warnings` | the default: yes | no | +//! | [`flavor`](Rules::flavor) | which flavor the rules are for | tests | [`Flavor::Macos`] | [`Flavor::Windows`] | +//! +//! # What is guaranteed, and by what +//! +//! | Rule | Enforced by | Proof | Escape hatch | +//! |---|---|---|---| +//! | An implementor of [`Rules`] answers every question that has no default | type system: the methods have no default | `compile_fail,E0046` on [`Rules`] | none | +//! | An implementor can be shared between threads | type system: `Sync` is a supertrait of [`Rules`] | the trait's definition | none | +//! | [`Flavor::rules`] has rules for every flavor | type system: an exhaustive `match` | a new [`Flavor`] variant without an arm fails to compile in this module | none | +//! | `flavor.rules().flavor()` is `flavor` | test | `each_flavor_has_rules_of_its_own_and_only_the_checked_ones_say_so` | none | +//! | A manifest with no flavor is read as komorebi for Mac | runtime: a serde default | `a_session_with_no_flavor_recorded_is_komorebi_for_mac` | none | +//! | Rules that were not checked against a recording of komorebi say so | framework path: `Analysis::warnings` adds a warning when [`verified`](Rules::verified) is `false`. The method defaults to `true`, so an implementor has to override it | `the_analysis_says_the_windows_rules_are_not_yet_checked_against_a_recording` and `a_mac_session_does_not_get_that_warning` in `tests/windows_flavor.rs` | none | +//! | A flavor for which `hides_windows_by_position` is `false` answers `false` to `is_hidden_position` | convention only | [`WindowsRules`] follows it | none | +//! | A notification that cannot be read is [`Family::Unknown`], not a panic | test, for each implementor: `nonsense_is_unknown_not_a_panic` in [`mac`] and in [`windows`]. For a new implementor, convention only | those tests | none | +//! | Code that reads komorebi's output asks [`Rules`] and does not match on [`Flavor`] | convention only | none | the two cases below | +//! +//! Two things that depend on the flavor are keyed by [`Flavor`] and not asked of [`Rules`]: the +//! index of komorebi's source that log lines are mapped to (`catalog::of`), and the places the +//! log and a checkout of the source are looked for on disk (`komorebi_log::log_dir_candidates` +//! and `annotations::default_code_root`). +//! +//! # One session, end to end +//! +//! 1. `Analysis::open` reads the manifest. Its `komorebi.flavor` is what `Analysis::flavor` +//! returns; a manifest that has none gives [`Flavor::Macos`]. +//! 1. `Analysis::events` calls `events::load` with `flavor.rules()`, which turns each line of +//! `events.jsonl` into an [`EventRecord`] with [`describe_event`](Rules::describe_event). +//! 1. [`classify`] labels each record. It asks +//! [`classify_window_manager`](Rules::classify_window_manager) or +//! [`classify_monitor`](Rules::classify_monitor), by the record's [`Family`], and +//! [`is_geometry_event`](Rules::is_geometry_event) when it decides whether a button release +//! that komorebi made up ended a real drag. +//! +//! [`MacRules`]: mac::MacRules +//! [`WindowsRules`]: windows::WindowsRules +//! [`classify`]: super::classify::classify +//! [`Family`]: super::events::Family +//! [`Family::Unknown`]: super::events::Family::Unknown + +pub mod mac; +pub mod windows; + +use super::classify::Class; +use super::events::EventInfo; +use super::events::EventRecord; +use schemars::JsonSchema; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +/// A name for a komorebi: komorebi for Mac or komorebi for Windows. +/// +/// A session records which of the two it was watching, in its manifest, because what komorebi's +/// events, `State` and log say differs between them. The flavor is the one place the analysis +/// learns which komorebi it is reading: [`Flavor::rules`] returns the [`Rules`] that know that +/// komorebi's vocabulary. +/// +/// A flavor names a komorebi, not an operating system. A recorder watches the komorebi of the host +/// it runs on ([`Flavor::of_host`]) and writes that flavor into the manifest, and whoever investigates +/// the session later uses the flavor from the manifest, whatever machine they are on: a session +/// recorded on Windows is read on a Mac with the Windows rules. +/// +/// The default is [`Flavor::Macos`], which is also what a manifest that has no flavor means. +/// +/// # Serialization +/// +/// A flavor is spelled in lowercase (`"macos"`, `"windows"`) wherever it is serialized, and the +/// type has a JSON Schema. [`Flavor::label`] and the [`Display`](std::fmt::Display) +/// implementation spell it for prose (`macOS`, `Windows`). +/// +/// # Example +/// +/// Each flavor's rules read the wire format of its komorebi. The same call, on the same +/// type, gives a process id and an accessibility notification for one, and a window handle, a +/// program and a `WinEvent` for the other: +/// +/// ```rust +/// use komorebi_recorder::analysis::flavor::Flavor; +/// use serde_json::json; +/// +/// let on_mac = json!({ +/// "type": "MoveEnd", +/// "content": [{"type": "Accessibility", "content": "AXWindowMoved"}, 20934, 4711] +/// }); +/// let info = Flavor::Macos.rules().describe_event(&on_mac); +/// assert_eq!(info.pid, Some(20934)); +/// assert_eq!(info.summary(), "MoveEnd pid 20934 window 4711 AXWindowMoved"); +/// +/// let on_windows = json!({ +/// "type": "FocusChange", +/// "content": ["SystemForeground", {"hwnd": 131072, "title": "a title", "exe": "chrome.exe"}] +/// }); +/// let info = Flavor::Windows.rules().describe_event(&on_windows); +/// assert_eq!(info.pid, None); +/// assert_eq!(info.exe.as_deref(), Some("chrome.exe")); +/// assert_eq!(info.summary(), "FocusChange chrome.exe window 131072 SystemForeground"); +/// +/// // a manifest spells a flavor in lowercase, and has the Mac's when it says nothing +/// assert_eq!(serde_json::to_string(&Flavor::Windows)?, r#""windows""#); +/// assert_eq!(serde_json::from_str::(r#""macos""#)?, Flavor::Macos); +/// assert_eq!(Flavor::default(), Flavor::Macos); +/// +/// // and prose says it the way people do +/// assert_eq!(Flavor::Macos.label(), "macOS"); +/// assert_eq!(Flavor::Windows.to_string(), "Windows"); +/// # Ok::<(), Box>(()) +/// ``` +#[derive( + Debug, + Clone, + Copy, + PartialEq, + Eq, + Hash, + Default, + Serialize, + Deserialize, + JsonSchema, + clap::ValueEnum, +)] +#[serde(rename_all = "lowercase")] +pub enum Flavor { + /// komorebi for Mac. It is also what a manifest that has no flavor means, because every session + /// recorded before manifests said which komorebi they watched was recorded on a Mac. + #[default] + Macos, + /// komorebi for Windows. + Windows, +} + +impl Flavor { + /// Returns the flavor of the komorebi that runs on this machine: there is one per OS, and a + /// recorder watches the one on its own host. + /// + /// This is [`Flavor::Windows`] when the crate is built for Windows and [`Flavor::Macos`] for + /// every other target, including the `unsupported` host. Use it to say which komorebi a + /// recording made *here* is of; to read a session, use the flavor in its manifest. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::analysis::flavor::Flavor; + /// + /// assert_eq!(Flavor::of_host() == Flavor::Windows, cfg!(windows)); + /// ``` + #[must_use] + pub fn of_host() -> Self { + if cfg!(windows) { + Flavor::Windows + } else { + Flavor::Macos + } + } + + /// Returns the [`Rules`] of this flavor: the vocabulary of this komorebi. + /// + /// This is the only place a flavor is turned into rules. The rules are a shared, stateless + /// value of [`MacRules`](mac::MacRules) or [`WindowsRules`](windows::WindowsRules), so calling + /// this is free and the result may be kept for as long as the program runs. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::analysis::flavor::Flavor; + /// + /// for flavor in [Flavor::Macos, Flavor::Windows] { + /// assert_eq!(flavor.rules().flavor(), flavor); + /// } + /// + /// // only the rules of a komorebi that was checked against a recording say so + /// assert!(Flavor::Macos.rules().verified()); + /// assert!(!Flavor::Windows.rules().verified()); + /// ``` + #[must_use] + pub fn rules(self) -> &'static dyn Rules { + match self { + Flavor::Macos => &mac::MacRules, + Flavor::Windows => &windows::WindowsRules, + } + } + + /// Returns the name to use in prose: `macOS` or `Windows`. + /// + /// This is also what the [`Display`](std::fmt::Display) implementation writes. The serialized + /// form is different: `macos` or `windows`. + #[must_use] + pub fn label(self) -> &'static str { + match self { + Flavor::Macos => "macOS", + Flavor::Windows => "Windows", + } + } +} + +impl std::fmt::Display for Flavor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(self.label()) + } +} + +/// The vocabulary of one flavor of komorebi, as the questions the analysis asks of it. +/// +/// The role of this trait is to answer, for one komorebi, every question the analysis has about +/// komorebi's vocabulary: how an event is written, what a `State` calls a window, where a +/// hidden window is, what a handler's first and last log line look like, and what each of +/// komorebi's modules does. Each method is one question. The [module documentation](self) has the +/// table of them, with what each flavor answers, and the guarantee table. +/// +/// - **The analysis calls it.** It is given a `&dyn Rules` (from [`Flavor::rules`]) and asks it, +/// and has no way to tell which flavor it was given. +/// - **A flavor implements it.** [`MacRules`](mac::MacRules) is komorebi for Mac and +/// [`WindowsRules`](windows::WindowsRules) is komorebi for Windows. A third implementor is +/// written only for a third komorebi. To be chosen by name it also needs a [`Flavor`] variant, +/// because [`Flavor::rules`] is an exhaustive `match`. The functions that take a `&dyn Rules` +/// (`events::load`, `EventRecord::from_row` and +/// [`classify`](super::classify::classify), and `blocks::build`) accept any implementor, +/// whether or not `Flavor::rules` can return it. +/// +/// # The contract +/// +/// - **Most methods have no default, and a few do.** An implementor must write +/// [`flavor`](Rules::flavor), [`window_id`](Rules::window_id), +/// [`hides_windows_by_position`](Rules::hides_windows_by_position), +/// [`describe_event`](Rules::describe_event), [`is_geometry_event`](Rules::is_geometry_event), +/// [`classify_window_manager`](Rules::classify_window_manager), +/// `is_hidden_position`, `block_header`, +/// `is_block_end`, `role` and +/// [`slow_handler_cause`](Rules::slow_handler_cause); a missing one is a compile error (see +/// below). [`verified`](Rules::verified), [`classify_monitor`](Rules::classify_monitor) and +/// [`window_is_unreadable`](Rules::window_is_unreadable) have a default that suits a flavor +/// with nothing to say about them. A default is not a guarantee: an implementor may override it. +/// - **Answers come from the arguments.** Every method takes `&self`, and the trait requires +/// `Sync`, so rules are shared between threads and carry no state. The one piece of context a +/// method may need is passed in: `block_header` is given the block that +/// is open. +/// - **Unreadable input is an answer, not a panic.** komorebi's output is read after the fact, from +/// a session that may be from a newer komorebi than this recorder. [`describe_event`] answers +/// [`Family::Unknown`] for JSON it does not understand, and [`window_id`] answers `None` for +/// JSON that is not a window. Both implementors have a test for `describe_event`; nothing +/// enforces the rule for a new implementor. +/// - **Hiding has two questions, and they must agree.** A flavor for which +/// [`hides_windows_by_position`](Rules::hides_windows_by_position) is `false` should answer `false` to every call of +/// `is_hidden_position`. Some checks ask only the second, and would otherwise report a +/// window as parked. Only convention enforces this; [`WindowsRules`](windows::WindowsRules) +/// follows it. +/// - **A block stops collecting lines in one of two ways.** `block_header` is asked of every +/// timestamped line of the log and starts a block when it returns `Some`. A block that is still +/// open stops collecting lines when the next header starts a new block, or when +/// `is_block_end` returns `true`. `is_block_end` is asked only of the lines that were added to +/// the open block, never of the line that started it. A flavor whose log has no end line answers +/// `false`, and its blocks stop at the next header. +/// +/// [`describe_event`]: Rules::describe_event +/// [`window_id`]: Rules::window_id +/// [`hides_windows_by_position`]: Rules::hides_windows_by_position +/// [`Family::Unknown`]: super::events::Family::Unknown +/// +/// # Example implementation +/// +/// An implementor that reads komorebi for Mac, by delegating to [`MacRules`](mac::MacRules), but +/// that reports it was never checked against a recording. [`verified`](Rules::verified) is the one +/// method written beyond the ones that have no default, and the others keep theirs: +/// +/// ```rust +/// # use komorebi_recorder::analysis::classify::Class; +/// # use komorebi_recorder::analysis::events::{EventInfo, EventRecord}; +/// # use komorebi_recorder::analysis::flavor::mac::MacRules; +/// # use komorebi_recorder::analysis::flavor::{Flavor, Rules}; +/// # use serde_json::Value; +/// struct Unchecked; +/// +/// impl Rules for Unchecked { +/// fn flavor(&self) -> Flavor { Flavor::Macos } +/// fn verified(&self) -> bool { false } +/// fn window_id(&self, window: &Value) -> Option { MacRules.window_id(window) } +/// fn hides_windows_by_position(&self) -> bool { MacRules.hides_windows_by_position() } +/// fn describe_event(&self, event: &Value) -> EventInfo { MacRules.describe_event(event) } +/// fn is_geometry_event(&self, name: &str) -> bool { MacRules.is_geometry_event(name) } +/// fn classify_window_manager(&self, event: &EventRecord) -> (Class, &'static str) { +/// MacRules.classify_window_manager(event) +/// } +/// fn slow_handler_cause(&self) -> &'static str { MacRules.slow_handler_cause() } +/// } +/// +/// let rules: &dyn Rules = &Unchecked; +/// let event = serde_json::json!({"type": "FocusWorkspaceNumber", "content": 2}); +/// assert_eq!(rules.describe_event(&event).argument.as_deref(), Some("2")); +/// assert!(!rules.verified()); +/// // a method that was not written has its default +/// assert!(!rules.window_is_unreadable(&serde_json::json!({"id": 1}))); +/// ``` +/// +/// # Example: a missing method is refused +/// +/// An implementor that leaves out a method that has no default does not compile. This is the +/// implementor above without `slow_handler_cause`: +/// +/// ```rust,compile_fail,E0046 +/// # use komorebi_recorder::analysis::classify::Class; +/// # use komorebi_recorder::analysis::events::{EventInfo, EventRecord}; +/// # use komorebi_recorder::analysis::flavor::mac::MacRules; +/// # use komorebi_recorder::analysis::flavor::{Flavor, Rules}; +/// # use serde_json::Value; +/// struct Unchecked; +/// +/// impl Rules for Unchecked { +/// fn flavor(&self) -> Flavor { Flavor::Macos } +/// fn verified(&self) -> bool { false } +/// fn window_id(&self, window: &Value) -> Option { MacRules.window_id(window) } +/// fn hides_windows_by_position(&self) -> bool { MacRules.hides_windows_by_position() } +/// fn describe_event(&self, event: &Value) -> EventInfo { MacRules.describe_event(event) } +/// fn is_geometry_event(&self, name: &str) -> bool { MacRules.is_geometry_event(name) } +/// fn classify_window_manager(&self, event: &EventRecord) -> (Class, &'static str) { +/// MacRules.classify_window_manager(event) +/// } +/// // no `slow_handler_cause` +/// } +/// ``` +/// +/// The error is E0046, `not all trait items implemented, missing: slow_handler_cause`. The same +/// refusal applies to each method without a default, so a new flavor cannot leave +/// one of komorebi's questions unanswered. +pub trait Rules: Sync { + /// Returns the flavor these rules are for. + /// + /// `flavor.rules().flavor()` is `flavor` for both flavors. + fn flavor(&self) -> Flavor; + + /// Returns whether these rules have been checked against a real recording of the komorebi + /// they describe, rather than only written from its source. + /// + /// The default is `true`. `Analysis::warnings` adds a + /// warning to the answers about a session whose flavor has unverified rules, saying that this + /// build reads komorebi for that flavor from its source and has not yet checked it against a + /// recording. A flavor whose rules were not checked must override this to say so. + /// + /// komorebi for Mac keeps the default. komorebi for Windows answers `false`. + fn verified(&self) -> bool { + true + } + + /// Returns what a display or machine notification ([`Family::Monitor`], not a window event or + /// a command) was, as a [`Class`] and a sentence giving the reason. The sentence is what a reader + /// sees as the event's reason. + /// + /// The default is `(Class::Monitor, "a display changed")`. komorebi for Mac keeps it. komorebi + /// for Windows overrides it, because its monitor notifications also carry the machine's + /// state: a session locked or unlocked, a sleep or a wake, and a virtual desktop left or + /// returned to are [`Class::Lock`]. + /// + /// [`Family::Monitor`]: super::events::Family::Monitor + fn classify_monitor(&self, _event: &EventRecord) -> (Class, &'static str) { + (Class::Monitor, "a display changed") + } + + /// Returns the id a window is known by in `State`, or `None` if `window` is not a window. + /// + /// On a Mac that is the window's `id`, and on Windows it is the handle, `hwnd`; a handle that + /// is negative is not an id. The value is the key under which a `Snapshot` lists the window, + /// and what an event's window is looked up by. + fn window_id(&self, window: &Value) -> Option; + + /// Returns whether `window` is one komorebi could not read when it wrote the state (it had + /// been destroyed), so that its title, program and rect say nothing. + /// + /// A window that is unreadable is kept, with its id, but has no rect: a zero rect would read as + /// a window parked at the corner of the screen. + /// + /// The default is `false`. komorebi for Mac keeps it. komorebi for Windows reads a window's + /// title, program and rect when it serializes the state, and writes `could not get window + /// title` (and likewise for the program and class) in place of one it could not read, so its + /// rules look for that. + fn window_is_unreadable(&self, _window: &Value) -> bool { + false + } + + /// Returns whether komorebi hides a window by moving it to a corner, so that whether a window + /// is hidden can be read from its position. + /// + /// komorebi for Mac does. komorebi for Windows hides a window by cloaking it where it is by + /// default, so there a hidden window is not told from a visible one by where it is, and the + /// checks that rely on position are skipped rather than guessed. If this is `false`, + /// `is_hidden_position` should be `false` as well. + fn hides_windows_by_position(&self) -> bool; + + /// Reads the JSON komorebi sent a subscriber as an event, and returns an [`EventInfo`] that + /// describes it. + /// + /// This never fails. JSON with no readable `type` that is not one of the shapes below is + /// [`Family::Unknown`] and is named `unreadable`; a name that is not a window event or a + /// monitor notification is read as a command, because a flavor has no list of commands. + /// + /// Three kinds reach a subscriber and look different on the wire. What differs by flavor: + /// + /// - komorebi for Mac: a window-manager event is `{"type": "MoveEnd", "content": + /// [{"type": "Accessibility", "content": "AXWindowMoved"}, , ]}`, with a + /// process id and no program. A command is `{"type": "", "content": }`, with + /// no `content` when it has no argument, or a bare string. A monitor notification is a + /// one-entry object such as `{"DisplayConnectionChange": }`. + /// - komorebi for Windows: a window event is `{"type": "FocusChange", "content": ["", + /// ]}`, or `{"type": "Manage", "content": }` for the events that have no + /// `WinEvent`. A window is `{"hwnd": ..., "title": ..., "exe": ..., "class": ..., "rect": + /// ...}`, so the event has a window handle and a program and no process id. A notification + /// about the machine is `{"type": "SessionLocked"}`; a virtual desktop notification is a bare + /// string, and any other bare string is [`Family::Unknown`], named after the string. A + /// command is `{"type": "", "content": }`, told apart from a window event + /// by its name. + /// + /// [`Family::Unknown`]: super::events::Family::Unknown + fn describe_event(&self, event: &Value) -> EventInfo; + + /// Returns whether an event with this name starts or ends moving or resizing a window. + /// + /// komorebi for Mac names four: `MoveStart`, `MoveEnd`, `ResizeStart` and `ResizeEnd`. + /// komorebi for Windows names two: `MoveResizeStart` and `MoveResizeEnd`. + /// [`classify`](super::classify::classify) uses this to tell a button release that komorebi + /// made up from the end of a real drag: one after which the layout changed. + fn is_geometry_event(&self, name: &str) -> bool; + + /// Returns what a window-manager event (one the OS reported, as opposed to a command or a + /// monitor change) was, as a [`Class`] and a sentence giving the reason. + /// + /// An event that starts or ends a move or resize (see + /// [`is_geometry_event`](Rules::is_geometry_event)) is [`Class::Drag`] when the OS reported it, + /// and [`Class::Manual`] when komorebi made it up. [`Class::Echo`] is not an answer to give: + /// [`classify`](super::classify::classify) turns a drag that changed nothing, soon after + /// komorebi acted, into an echo itself. + fn classify_window_manager(&self, event: &EventRecord) -> (Class, &'static str); + + /// Returns what a handler that took long was most likely doing, as a phrase that completes a + /// sentence such as "took at least 250 ms to handle: ...". + /// + /// It names the calls the handler makes into other programs' windows, which differ by OS: + /// accessibility calls on a Mac and Win32 calls on Windows. The phrase also names waiting for + /// the lock another handler held. + fn slow_handler_cause(&self) -> &'static str; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn flavors_are_spelled_in_lowercase_and_the_host_has_one() { + assert_eq!( + serde_json::to_string(&Flavor::Windows).expect("json"), + r#""windows""# + ); + assert_eq!( + serde_json::from_str::(r#""macos""#).expect("parse"), + Flavor::Macos + ); + assert_eq!(Flavor::of_host() == Flavor::Windows, cfg!(windows)); + } + + #[test] + fn each_flavor_has_rules_of_its_own_and_only_the_checked_ones_say_so() { + assert_eq!(Flavor::Macos.rules().flavor(), Flavor::Macos); + assert_eq!(Flavor::Windows.rules().flavor(), Flavor::Windows); + assert!(Flavor::Macos.rules().verified()); + assert!( + !Flavor::Windows.rules().verified(), + "the Windows rules are from komorebi's source until a recording of it has been read" + ); + } +} diff --git a/komorebi-recorder/src/analysis/flavor/windows.rs b/komorebi-recorder/src/analysis/flavor/windows.rs new file mode 100644 index 00000000..1f8cf05e --- /dev/null +++ b/komorebi-recorder/src/analysis/flavor/windows.rs @@ -0,0 +1,351 @@ +//! komorebi for Windows, as it describes itself. +//! +//! [`WindowsRules`] is the [`Rules`] of [`Flavor::Windows`]. It overrides all three methods that +//! have a default: [`Rules::verified`], [`Rules::classify_monitor`] and +//! [`Rules::window_is_unreadable`]. +//! +//! The rules are written from komorebi for Windows' source and not yet checked against a recording +//! of it. That is what [`Rules::verified`] reports, and what the analysis tells whoever reads a +//! Windows session. What the rules rely on: +//! +//! - A window event serializes as `{"type": "FocusChange", "content": ["", ]}`, +//! or `{"type": "Manage", "content": }` for the three that have no `WinEvent` (`Manage`, +//! `Unmanage`, `Raise`). A window is `{hwnd, title, exe, class, rect}`, every field read live at +//! the moment of serializing, so a window that was destroyed reads `could not get window title` +//! and a rect of zeros. +//! - Monitor notifications are unit variants tagged the same way, `{"type": "SessionLocked"}`, +//! and virtual desktop notifications are bare strings. +//! - A command is `{"type": "", "content": }` like a window event, told apart by its +//! name. +//! - The log has no `processing event:` line. A handler's lines carry the span +//! `process_event{event="FocusChange" winevent="SystemForeground" hwnd=131072}` (or +//! `process_command{message=...}`), and an INFO `processed: ` (`processed` for a command) +//! after the subscribers were notified marks its end. +//! - komorebi hides a window by cloaking it where it is, by default, so nothing in the `State` +//! shows a window as hidden: [`Rules::hides_windows_by_position`] is `false` and +//! `Rules::is_hidden_position` never matches. + +use super::Flavor; +use super::Rules; +use crate::analysis::classify::Class; +use crate::analysis::events::EventInfo; +use crate::analysis::events::EventRecord; +use crate::analysis::events::Family; +use serde_json::Value; + +/// The [`Rules`] of komorebi for Windows: what its events, `State`, log and modules say. +/// +/// A unit struct with no state. [`Flavor::rules`] returns a reference to one for +/// [`Flavor::Windows`]. Its [`verified`](Rules::verified) is `false`. +pub struct WindowsRules; + +/// The events the OS reports about a window, and komorebi's own `Manage`, `Unmanage` and `Raise`. +const WINDOW_MANAGER_EVENTS: [&str; 14] = [ + "Destroy", + "FocusChange", + "Hide", + "Cloak", + "Minimize", + "Show", + "Uncloak", + "MoveResizeStart", + "MoveResizeEnd", + "MouseCapture", + "Manage", + "Unmanage", + "Raise", + "TitleUpdate", +]; + +/// komorebi's notifications about the machine and its displays. +const MONITOR_NOTIFICATIONS: [&str; 7] = [ + "ResolutionScalingChanged", + "WorkAreaChanged", + "DisplayConnectionChange", + "EnteringSuspendedState", + "ResumingFromSuspendedState", + "SessionLocked", + "SessionUnlocked", +]; + +/// Sent as bare strings. +const VIRTUAL_DESKTOP_NOTIFICATIONS: [&str; 2] = [ + "EnteredAssociatedVirtualDesktop", + "LeftAssociatedVirtualDesktop", +]; + +const GEOMETRY_EVENTS: [&str; 2] = ["MoveResizeStart", "MoveResizeEnd"]; + +/// What komorebi puts in place of a field it could not read. +const COULD_NOT_GET: &str = "could not get window"; + +/// A field of a window, unless komorebi could not read it. +fn field(window: &Value, key: &str) -> Option { + window + .get(key)? + .as_str() + .filter(|text| !text.starts_with(COULD_NOT_GET)) + .map(str::to_string) +} + +fn handle(window: &Value) -> Option { + window + .get("hwnd")? + .as_i64() + .and_then(|hwnd| u32::try_from(hwnd).ok()) +} + +impl Rules for WindowsRules { + fn flavor(&self) -> Flavor { + Flavor::Windows + } + + fn verified(&self) -> bool { + false + } + + fn describe_event(&self, event: &Value) -> EventInfo { + let unknown = |name: &str| EventInfo { + family: Family::Unknown, + name: name.to_string(), + notification: None, + pid: None, + window_id: None, + exe: None, + argument: None, + }; + + // virtual desktop notifications are bare strings + if let Some(name) = event.as_str() { + return EventInfo { + family: if VIRTUAL_DESKTOP_NOTIFICATIONS.contains(&name) { + Family::Monitor + } else { + Family::Unknown + }, + ..unknown(name) + }; + } + + let Some(name) = event.get("type").and_then(Value::as_str) else { + return unknown("unreadable"); + }; + let content = event.get("content"); + + if MONITOR_NOTIFICATIONS.contains(&name) { + return EventInfo { + family: Family::Monitor, + ..unknown(name) + }; + } + + if WINDOW_MANAGER_EVENTS.contains(&name) { + // `[, ]`, or just `` for the events with no WinEvent + let (notification, window) = match content { + Some(Value::Array(parts)) => ( + parts.first().and_then(Value::as_str).map(str::to_string), + parts.get(1), + ), + other => (None, other), + }; + + return EventInfo { + family: Family::WindowManager, + name: name.to_string(), + notification, + pid: None, + window_id: window.and_then(handle), + exe: window.and_then(|window| field(window, "exe")), + argument: None, + }; + } + + EventInfo { + family: Family::Command, + name: name.to_string(), + argument: content + .filter(|value| !value.is_null()) + .map(crate::analysis::events::compact), + ..unknown(name) + } + } + + fn is_geometry_event(&self, name: &str) -> bool { + GEOMETRY_EVENTS.contains(&name) + } + + fn classify_window_manager(&self, event: &EventRecord) -> (Class, &'static str) { + match event.info.name.as_str() { + "FocusChange" => (Class::Focus, "focus moved"), + "Show" | "Destroy" | "Minimize" | "Manage" | "Unmanage" => ( + Class::Lifecycle, + "a window appeared, went away, was minimized or restored, or komorebi began or stopped managing it", + ), + // switching workspace hides the windows of the one left by cloaking them, and the OS + // reports that back + "Hide" | "Cloak" | "Uncloak" => ( + Class::Lifecycle, + "a window was hidden or shown: komorebi cloaks the windows of a workspace it leaves, so this is often its own doing reported back", + ), + name if GEOMETRY_EVENTS.contains(&name) => ( + Class::Drag, + "a window was moved or resized with the mouse (or by something other than komorebi)", + ), + "MouseCapture" => ( + Class::Other, + "a window captured or released the mouse: part of a drag, or a menu", + ), + "Raise" => (Class::Manual, "komorebi raised a window itself"), + "TitleUpdate" => (Class::Other, "a window changed its title"), + _ => (Class::Other, "not a known kind of event"), + } + } + + fn classify_monitor(&self, event: &EventRecord) -> (Class, &'static str) { + match event.info.name.as_str() { + "SessionLocked" | "SessionUnlocked" => { + (Class::Lock, "the session was locked or unlocked") + } + "EnteringSuspendedState" | "ResumingFromSuspendedState" => { + (Class::Lock, "the machine went to sleep or woke") + } + "EnteredAssociatedVirtualDesktop" | "LeftAssociatedVirtualDesktop" => ( + Class::Lock, + "the virtual desktop komorebi is managing was left or returned to", + ), + _ => (Class::Monitor, "a display changed"), + } + } + + fn window_id(&self, window: &Value) -> Option { + window + .get("hwnd")? + .as_i64() + .and_then(|hwnd| u64::try_from(hwnd).ok()) + } + + fn window_is_unreadable(&self, window: &Value) -> bool { + window + .get("title") + .and_then(Value::as_str) + .is_some_and(|title| title.starts_with(COULD_NOT_GET)) + } + + fn hides_windows_by_position(&self) -> bool { + false + } + + fn slow_handler_cause(&self) -> &'static str { + "slow Win32 calls into other programs' windows (positioning one that is busy or hung, or an animation), or waiting for the lock another handler held" + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn describe(event: &Value) -> EventInfo { + WindowsRules.describe_event(event) + } + + fn window(hwnd: i64, exe: &str) -> Value { + json!({ + "hwnd": hwnd, "title": "a title", "exe": exe, "class": "Chrome_WidgetWin_1", + "rect": {"left": 0, "top": 0, "right": 100, "bottom": 100} + }) + } + + #[test] + fn a_window_event_names_its_win_event_handle_and_program() { + let info = describe(&json!({ + "type": "FocusChange", + "content": ["SystemForeground", window(131_072, "chrome.exe")] + })); + + assert_eq!(info.family, Family::WindowManager); + assert_eq!(info.name, "FocusChange"); + assert_eq!(info.notification.as_deref(), Some("SystemForeground")); + assert_eq!(info.window_id, Some(131_072)); + assert_eq!(info.exe.as_deref(), Some("chrome.exe")); + assert_eq!(info.pid, None); + assert_eq!( + info.summary(), + "FocusChange chrome.exe window 131072 SystemForeground" + ); + } + + #[test] + fn manage_unmanage_and_raise_carry_a_window_and_no_win_event() { + for name in ["Manage", "Unmanage", "Raise"] { + let info = describe(&json!({"type": name, "content": window(7, "code.exe")})); + assert_eq!(info.family, Family::WindowManager, "{name}"); + assert_eq!(info.notification, None); + assert_eq!(info.window_id, Some(7)); + assert_eq!(info.exe.as_deref(), Some("code.exe")); + } + } + + #[test] + fn a_window_komorebi_could_not_read_has_a_handle_and_no_program() { + let info = describe(&json!({ + "type": "Destroy", + "content": ["ObjectDestroy", { + "hwnd": 9, "title": "could not get window title", + "exe": "could not get window exe", "class": "could not get window class", + "rect": {"left": 0, "top": 0, "right": 0, "bottom": 0} + }] + })); + + assert_eq!(info.window_id, Some(9)); + assert_eq!(info.exe, None, "an error message is not a program"); + } + + #[test] + fn notifications_about_the_machine_are_monitor_events_and_not_commands() { + for name in MONITOR_NOTIFICATIONS { + let info = describe(&json!({"type": name})); + assert_eq!(info.family, Family::Monitor, "{name}"); + assert_eq!(info.name, name); + } + + for name in VIRTUAL_DESKTOP_NOTIFICATIONS { + let info = describe(&json!(name)); + assert_eq!(info.family, Family::Monitor, "{name}"); + } + + // a bare string that is not one of those is not something to guess at + assert_eq!(describe(&json!("Retile")).family, Family::Unknown); + } + + #[test] + fn commands_carry_their_argument() { + let info = describe(&json!({"type": "FocusWorkspaceNumber", "content": 2})); + assert_eq!(info.family, Family::Command); + assert_eq!(info.argument.as_deref(), Some("2")); + assert_eq!(describe(&json!({"type": "Retile"})).family, Family::Command); + } + + #[test] + fn nonsense_is_unknown_not_a_panic() { + assert_eq!(describe(&json!(12)).family, Family::Unknown); + assert_eq!(describe(&json!({"type": 3})).family, Family::Unknown); + assert_eq!(describe(&json!({"a": 1})).family, Family::Unknown); + } + + #[test] + fn handles_are_window_ids_and_unreadable_windows_are_noticed() { + assert_eq!( + WindowsRules.window_id(&window(131_072, "a.exe")), + Some(131_072) + ); + assert_eq!( + WindowsRules.window_id(&json!({"id": 5})), + None, + "a Mac window" + ); + assert!(WindowsRules.window_is_unreadable(&json!({"title": "could not get window title"}))); + assert!(!WindowsRules.window_is_unreadable(&window(1, "a.exe"))); + assert!(!WindowsRules.hides_windows_by_position()); + } +} diff --git a/komorebi-recorder/src/analysis/ids.rs b/komorebi-recorder/src/analysis/ids.rs new file mode 100644 index 00000000..1142cae3 --- /dev/null +++ b/komorebi-recorder/src/analysis/ids.rs @@ -0,0 +1,264 @@ +//! Stable names for things in a session, so a command's output can be fed straight back in. +//! +//! An event is `event:`, a line of the recorded komorebi log is `log:`, a recorder +//! incident is `incident:` (1-based line of `recorder.jsonl`) and a user or agent annotation is +//! `bug:`, `note:` or `finding:`. All of them are accepted wherever a time is, see +//! `crate::analysis::timespec`. +//! +//! [`ItemRef`] parses and prints these names. An annotation id is `a-` and eight lowercase +//! hexadecimal digits (`a-3f9c1e02`), made by [`new_annotation_id`] and recognized by +//! [`is_annotation_id`]. This module only names things: whether `event:12` exists in a session is +//! asked of the session when the name is resolved to a time. + +use color_eyre::eyre; +use std::collections::hash_map::RandomState; +use std::fmt; +use std::hash::BuildHasher; +use std::hash::Hasher; +use std::str::FromStr; + +/// A reference to one thing in a session: an event, a line of komorebi's log, a recorder incident +/// or an annotation. +/// +/// A reference is parsed from its text with [`str::parse`] ([`FromStr`]) and printed with +/// [`Display`](fmt::Display), and the two agree: the printed form of a reference (with a valid +/// annotation id, if it is an annotation) parses back to it. Parsing accepts more than printing +/// writes, so the text of a reference is not always what the reference prints as. +/// +/// | Text | Reference | +/// |---|---| +/// | `event:12` | [`ItemRef::Event`]`(12)` | +/// | `log:345` | [`ItemRef::Log`]`(345)` | +/// | `incident:2` | [`ItemRef::Incident`]`(2)` | +/// | `ann:a-3f9c1e02`, `bug:a-3f9c1e02`, `note:a-3f9c1e02`, `finding:a-3f9c1e02`, `a-3f9c1e02` | [`ItemRef::Annotation`]`("a-3f9c1e02")` | +/// +/// The kind of an annotation (a bug region, a note or a finding) is not part of the reference: +/// all four prefixes, and the bare id, are the same reference, and it prints as `ann:`. +/// +/// # Errors +/// +/// Parsing fails with a usage error (the exit kind `Usage` of the `envelope` module) when: +/// +/// - the text, trimmed of surrounding whitespace, is neither an annotation id nor has a `:`: +/// `'' is not an item reference`; +/// - the prefix is not `event`, `log`, `incident`, `ann`, `bug`, `note` or `finding`: +/// `unknown reference ''`; +/// - the part after `event:`, `log:` or `incident:` is not a whole number (so it is not empty, +/// negative or fractional), or does not fit in a `u64`; +/// - the part after `ann:`, `bug:`, `note:` or `finding:` is not an annotation id: +/// `'' is not an annotation id like a-3f9c1e02`. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::analysis::ids::ItemRef; +/// +/// let event: ItemRef = "event:12".parse()?; +/// assert_eq!(event, ItemRef::Event(12)); +/// assert_eq!(event.to_string(), "event:12"); +/// +/// // the kind of annotation does not matter, and a bare id is one +/// let note: ItemRef = "note:a-3f9c1e02".parse()?; +/// assert_eq!(note, "bug:a-3f9c1e02".parse()?); +/// assert_eq!(note, "a-3f9c1e02".parse()?); +/// assert_eq!(note.to_string(), "ann:a-3f9c1e02"); +/// +/// // mistakes are errors that say what was wrong +/// let error = "event:twelve".parse::().unwrap_err(); +/// assert_eq!( +/// error.to_string(), +/// "'event:twelve': an event sequence number must be a whole number" +/// ); +/// let error = "frame:3".parse::().unwrap_err(); +/// assert!(error.to_string().starts_with("unknown reference 'frame:3'")); +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ItemRef { + /// A komorebi notification, by its [`seq`](super::events::EventRecord::seq). + Event(u64), + /// A line of the session's recorded `komorebi.log`, 1-based. + Log(u64), + /// A line of the recorder's incident log, 1-based. + Incident(u64), + /// An annotation (a bug region, a note or a finding), by its id: `a-` and eight lowercase hex + /// digits. + Annotation(String), +} + +impl fmt::Display for ItemRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Event(seq) => write!(f, "event:{seq}"), + Self::Log(line) => write!(f, "log:{line}"), + Self::Incident(number) => write!(f, "incident:{number}"), + Self::Annotation(id) => write!(f, "ann:{id}"), + } + } +} + +impl ItemRef { + /// Returns whether `input` starts with one of the prefixes [`ItemRef`] understands: `event`, + /// `log`, `incident`, `ann`, `bug`, `note` or `finding`, followed by a `:`. + /// + /// The part after the colon is not looked at, so a true answer does not mean that `input` + /// parses. A bare annotation id has no prefix; use [`is_annotation_id`] for that. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::analysis::ids::ItemRef; + /// + /// assert!(ItemRef::has_prefix("event:12")); + /// assert!(ItemRef::has_prefix("event:twelve")); // has the prefix, but would not parse + /// assert!(!ItemRef::has_prefix("a-3f9c1e02")); + /// assert!(!ItemRef::has_prefix("12.5")); + /// ``` + #[must_use] + pub fn has_prefix(input: &str) -> bool { + input + .split_once(':') + .is_some_and(|(prefix, _)| PREFIXES.contains(&prefix)) + } +} + +const PREFIXES: [&str; 7] = ["event", "log", "incident", "ann", "bug", "note", "finding"]; + +impl FromStr for ItemRef { + type Err = eyre::Report; + + fn from_str(input: &str) -> Result { + let input = input.trim(); + // a bare annotation id is fine + if is_annotation_id(input) { + return Ok(Self::Annotation(input.to_string())); + } + + let (prefix, rest) = input + .split_once(':') + .ok_or_else(|| super::envelope::usage(format!("'{input}' is not an item reference")))?; + let number = |what: &str| -> eyre::Result { + rest.parse().map_err(|_| { + super::envelope::usage(format!("'{input}': {what} must be a whole number")) + }) + }; + + match prefix { + "event" => Ok(Self::Event(number("an event sequence number")?)), + "log" => Ok(Self::Log(number("a log line")?)), + "incident" => Ok(Self::Incident(number("an incident number")?)), + "ann" | "bug" | "note" | "finding" if is_annotation_id(rest) => { + Ok(Self::Annotation(rest.to_string())) + } + "ann" | "bug" | "note" | "finding" => Err(super::envelope::usage(format!( + "'{rest}' is not an annotation id like a-3f9c1e02" + ))), + _ => Err(super::envelope::usage(format!( + "unknown reference '{input}': expected event:, log:, incident: or bug:" + ))), + } + } +} + +/// Returns whether `text` is an annotation id: `a-` and exactly eight lowercase hexadecimal digits. +/// +/// Uppercase digits are not accepted, and nothing around the id is trimmed. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::analysis::ids::is_annotation_id; +/// +/// assert!(is_annotation_id("a-3f9c1e02")); +/// assert!(!is_annotation_id("a-3F9C1E02")); // lowercase only +/// assert!(!is_annotation_id("a-3f9c1e0")); // eight digits, not seven +/// assert!(!is_annotation_id("bug:a-3f9c1e02")); +/// ``` +#[must_use] +pub fn is_annotation_id(text: &str) -> bool { + text.strip_prefix("a-").is_some_and(|hex| { + hex.len() == 8 + && hex + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + }) +} + +/// Returns a fresh annotation id, one that [`is_annotation_id`] accepts. +/// +/// The id is random per process (the standard library seeds its hasher from the OS) and mixed with +/// the clock and the process id, so two recorders marking at once do not collide in practice. +/// That is not guaranteed: an id has 32 bits, and nothing here checks that an id is unused. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::analysis::ids::{is_annotation_id, new_annotation_id}; +/// +/// let id = new_annotation_id(); +/// assert!(is_annotation_id(&id)); +/// assert_eq!(id.len(), 10); // `a-` and eight digits +/// ``` +#[must_use] +pub fn new_annotation_id() -> String { + let mut hasher = RandomState::new().build_hasher(); + hasher.write_u64(crate::clock::mono_now_ns()); + hasher.write_u32(std::process::id()); + format!("a-{:08x}", hasher.finish() as u32) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn references_round_trip() { + for text in ["event:12", "log:345", "incident:2", "ann:a-3f9c1e02"] { + let item: ItemRef = text.parse().expect(text); + assert_eq!(item.to_string(), text); + } + } + + #[test] + fn annotation_kinds_and_bare_ids_are_aliases() { + let expected = ItemRef::Annotation("a-3f9c1e02".to_string()); + for text in [ + "bug:a-3f9c1e02", + "note:a-3f9c1e02", + "finding:a-3f9c1e02", + "a-3f9c1e02", + ] { + assert_eq!(text.parse::().expect(text), expected); + } + } + + #[test] + fn bad_references_are_usage_errors() { + for text in [ + "event:x", + "log:", + "bug:123", + "frame:3", + "nonsense", + "bug:a-XYZ", + ] { + let error = text.parse::().expect_err(text); + assert_eq!( + super::super::envelope::exit_kind(&error), + super::super::envelope::ExitKind::Usage, + "{text}" + ); + } + } + + #[test] + fn new_ids_look_right_and_do_not_collide() { + let mut seen = std::collections::HashSet::new(); + for _ in 0..1000 { + let id = new_annotation_id(); + assert!(is_annotation_id(&id), "{id}"); + seen.insert(id); + } + assert!(seen.len() > 990, "only {} distinct ids", seen.len()); + } +} diff --git a/komorebi-recorder/src/analysis/mod.rs b/komorebi-recorder/src/analysis/mod.rs new file mode 100644 index 00000000..54b95dd5 --- /dev/null +++ b/komorebi-recorder/src/analysis/mod.rs @@ -0,0 +1,12 @@ +//! Understanding a recorded session without a window. +//! +//! Everything here reads a session directory and produces plain serializable values, so the same +//! answers serve the command line and anything else that wants them. +//! This module holds the parts every command shares: the JSON envelope and exit codes +//! ([`envelope`]) and how an event or a log line is named ([`ids`]). + +pub mod classify; +pub mod envelope; +pub mod events; +pub mod flavor; +pub mod ids; diff --git a/komorebi-recorder/src/lib.rs b/komorebi-recorder/src/lib.rs index a095a17d..9cd6824a 100644 --- a/komorebi-recorder/src/lib.rs +++ b/komorebi-recorder/src/lib.rs @@ -13,8 +13,8 @@ //! //! Two things vary independently. The _host_ is the OS the recorder runs on, and everything that differs by it is behind //! [`platform`]. The _flavor_ is the komorebi being recorded, komorebi for Mac or komorebi for Windows, and everything -//! that differs by it is behind a trait in the analysis. A session records both, so a session recorded on one host can -//! be read on the other. +//! that differs by it is behind a trait in [`analysis::flavor`]. A session records both, so a session recorded on one +//! host can be read on the other. //! //! Every record in a session is stamped on one monotonic clock, the _master clock_ ([`clock`]). Video frames and the OS //! trace are already on it; komorebi's log lines carry wall-clock time and are mapped onto it with paired readings of @@ -42,6 +42,7 @@ //! | [`timecode`] | parsing and printing video timecodes | //! | [`clock`] | the master clock, anchors and the map between the wall clock and the master clock | //! | [`platform`] | everything that differs by host, behind one facade | +//! | [`analysis`] | understanding a session without a window, starting with the komorebi flavors | //! //! # What is not supported //! @@ -54,6 +55,7 @@ //! The Windows host is type-checked and cross-built from a Mac and built on Windows in CI, and has never been run. See //! [`platform`] for what each host provides. +pub mod analysis; pub mod clock; pub mod platform; pub mod timecode; diff --git a/komorebi-recorder/src/platform/mod.rs b/komorebi-recorder/src/platform/mod.rs index bb531186..0f1ebf38 100644 --- a/komorebi-recorder/src/platform/mod.rs +++ b/komorebi-recorder/src/platform/mod.rs @@ -14,9 +14,9 @@ //! # The host axis //! //! This is the *host* axis: what the recorder is running on. -//! Which komorebi produced a session is a separate axis, the *flavor*: a session recorded on -//! Windows can be investigated on a Mac, and the [`Host`] a session was recorded on is part of its -//! manifest. +//! Which komorebi produced a session is a separate axis (see [`crate::analysis::flavor`]): a +//! session recorded on Windows can be investigated on a Mac, and the [`Host`] a session was +//! recorded on is part of its manifest. //! //! # Adding a function //! From c748237741b5250522afa0dab22ea36bccce3fa1 Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 06:14:14 -0700 Subject: [PATCH 007/108] feat(komorebi_link): record what komorebi says The recorder subscribes to komorebi over its socket (a unix socket on macOS, `uds_windows` on Windows) and writes every notification with the state it came with, stamped on the master clock. Its accept loop only hands off to a writer thread, since komorebi notifies subscribers while holding its locks. What happens to the recording itself (a komorebi that exits, a stream that dies) is written as incidents, and a lock file says whether a recorder is still running in a session. --- Cargo.lock | 397 +++++++++++ dependencies.json | 24 + docs/ARCHITECTURE.md | 1 + docs/PLATFORM.md | 18 + komorebi-recorder/Cargo.toml | 3 + komorebi-recorder/src/analysis/events.rs | 61 ++ komorebi-recorder/src/incidents.rs | 433 ++++++++++++ komorebi-recorder/src/komorebi_link.rs | 530 ++++++++++++++ komorebi-recorder/src/lib.rs | 8 + komorebi-recorder/src/lock.rs | 280 ++++++++ .../src/sources/komorebi_events.rs | 665 ++++++++++++++++++ komorebi-recorder/src/sources/mod.rs | 34 + 12 files changed, 2454 insertions(+) create mode 100644 komorebi-recorder/src/incidents.rs create mode 100644 komorebi-recorder/src/komorebi_link.rs create mode 100644 komorebi-recorder/src/lock.rs create mode 100644 komorebi-recorder/src/sources/komorebi_events.rs create mode 100644 komorebi-recorder/src/sources/mod.rs diff --git a/Cargo.lock b/Cargo.lock index ee8a0abd..e4217cf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -67,6 +67,18 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + [[package]] name = "backtrace" version = "0.3.76" @@ -168,12 +180,54 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users", + "windows-sys 0.59.0", +] + [[package]] name = "dyn-clone" version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[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" @@ -194,24 +248,93 @@ dependencies = [ "once_cell", ] +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi", + "wasip2", + "wasip3", +] + [[package]] name = "gimli" version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e629b9b98ef3dd8afe6ca2bd0f89306cec16d43d907889945bc5d6687f2f13c7" +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + [[package]] name = "heck" version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "indenter" version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "964de6e86d545b246d84badc0fef527924ace5134f30641c203ef52ba83f58d5" +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -230,10 +353,13 @@ version = "0.1.0" dependencies = [ "clap", "color-eyre", + "crossbeam-channel", + "dirs", "libc", "schemars", "serde", "serde_json_lenient", + "uds_windows", "windows", ] @@ -243,24 +369,54 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + [[package]] name = "memchr" version = "2.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + [[package]] name = "miniz_oxide" version = "0.8.9" @@ -291,6 +447,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "owo-colors" version = "4.3.0" @@ -303,6 +465,16 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -321,6 +493,23 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -391,6 +580,12 @@ dependencies = [ "syn", ] +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + [[package]] name = "serde" version = "1.0.228" @@ -483,6 +678,19 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + [[package]] name = "terminal_size" version = "0.4.4" @@ -493,6 +701,26 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "thread_local" version = "1.1.9" @@ -543,12 +771,29 @@ dependencies = [ "tracing-core", ] +[[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 0.60.2", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + [[package]] name = "utf8parse" version = "0.2.2" @@ -561,6 +806,64 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + [[package]] name = "windows" version = "0.62.2" @@ -827,6 +1130,100 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck", + "indexmap", + "prettyplease", + "syn", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + [[package]] name = "zmij" version = "1.0.21" diff --git a/dependencies.json b/dependencies.json index 1276c738..e5d20f62 100644 --- a/dependencies.json +++ b/dependencies.json @@ -16,6 +16,7 @@ "anstyle-parse 1.0.0 registry+https://github.com/rust-lang/crates.io-index", "anstyle-query 1.1.5 registry+https://github.com/rust-lang/crates.io-index", "anstyle-wincon 3.0.11 registry+https://github.com/rust-lang/crates.io-index", + "autocfg 1.5.0 registry+https://github.com/rust-lang/crates.io-index", "backtrace 0.3.76 registry+https://github.com/rust-lang/crates.io-index", "bitflags 2.11.1 registry+https://github.com/rust-lang/crates.io-index", "cfg-if 1.0.4 registry+https://github.com/rust-lang/crates.io-index", @@ -26,9 +27,15 @@ "color-eyre 0.6.5 registry+https://github.com/rust-lang/crates.io-index", "color-spantrace 0.3.0 registry+https://github.com/rust-lang/crates.io-index", "colorchoice 1.0.5 registry+https://github.com/rust-lang/crates.io-index", + "crossbeam-channel 0.5.15 registry+https://github.com/rust-lang/crates.io-index", + "crossbeam-utils 0.8.21 registry+https://github.com/rust-lang/crates.io-index", + "dirs 6.0.0 registry+https://github.com/rust-lang/crates.io-index", + "dirs-sys 0.5.0 registry+https://github.com/rust-lang/crates.io-index", "dyn-clone 1.0.20 registry+https://github.com/rust-lang/crates.io-index", "errno 0.3.14 registry+https://github.com/rust-lang/crates.io-index", "eyre 0.6.12 registry+https://github.com/rust-lang/crates.io-index", + "fastrand 2.4.1 registry+https://github.com/rust-lang/crates.io-index", + "getrandom 0.4.2 registry+https://github.com/rust-lang/crates.io-index", "gimli 0.32.3 registry+https://github.com/rust-lang/crates.io-index", "heck 0.5.0 registry+https://github.com/rust-lang/crates.io-index", "indenter 0.3.4 registry+https://github.com/rust-lang/crates.io-index", @@ -55,6 +62,7 @@ "serde_json 1.0.149 registry+https://github.com/rust-lang/crates.io-index", "serde_json_lenient 0.2.4 registry+https://github.com/rust-lang/crates.io-index", "syn 2.0.117 registry+https://github.com/rust-lang/crates.io-index", + "tempfile 3.27.0 registry+https://github.com/rust-lang/crates.io-index", "terminal_size 0.4.4 registry+https://github.com/rust-lang/crates.io-index", "thread_local 1.1.9 registry+https://github.com/rust-lang/crates.io-index", "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index", @@ -107,6 +115,7 @@ "anstyle-parse 1.0.0 registry+https://github.com/rust-lang/crates.io-index", "anstyle-query 1.1.5 registry+https://github.com/rust-lang/crates.io-index", "anstyle-wincon 3.0.11 registry+https://github.com/rust-lang/crates.io-index", + "autocfg 1.5.0 registry+https://github.com/rust-lang/crates.io-index", "backtrace 0.3.76 registry+https://github.com/rust-lang/crates.io-index", "bitflags 2.11.1 registry+https://github.com/rust-lang/crates.io-index", "cfg-if 1.0.4 registry+https://github.com/rust-lang/crates.io-index", @@ -117,9 +126,15 @@ "color-eyre 0.6.5 registry+https://github.com/rust-lang/crates.io-index", "color-spantrace 0.3.0 registry+https://github.com/rust-lang/crates.io-index", "colorchoice 1.0.5 registry+https://github.com/rust-lang/crates.io-index", + "crossbeam-channel 0.5.15 registry+https://github.com/rust-lang/crates.io-index", + "crossbeam-utils 0.8.21 registry+https://github.com/rust-lang/crates.io-index", + "dirs 6.0.0 registry+https://github.com/rust-lang/crates.io-index", + "dirs-sys 0.5.0 registry+https://github.com/rust-lang/crates.io-index", "dyn-clone 1.0.20 registry+https://github.com/rust-lang/crates.io-index", "errno 0.3.14 registry+https://github.com/rust-lang/crates.io-index", "eyre 0.6.12 registry+https://github.com/rust-lang/crates.io-index", + "fastrand 2.4.1 registry+https://github.com/rust-lang/crates.io-index", + "getrandom 0.4.2 registry+https://github.com/rust-lang/crates.io-index", "gimli 0.32.3 registry+https://github.com/rust-lang/crates.io-index", "heck 0.5.0 registry+https://github.com/rust-lang/crates.io-index", "indenter 0.3.4 registry+https://github.com/rust-lang/crates.io-index", @@ -128,6 +143,7 @@ "lazy_static 1.5.0 registry+https://github.com/rust-lang/crates.io-index", "libc 0.2.186 registry+https://github.com/rust-lang/crates.io-index", "memchr 2.8.0 registry+https://github.com/rust-lang/crates.io-index", + "memoffset 0.9.1 registry+https://github.com/rust-lang/crates.io-index", "miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index", "object 0.37.3 registry+https://github.com/rust-lang/crates.io-index", "once_cell 1.21.4 registry+https://github.com/rust-lang/crates.io-index", @@ -151,12 +167,14 @@ "sharded-slab 0.1.7 registry+https://github.com/rust-lang/crates.io-index", "strsim 0.11.1 registry+https://github.com/rust-lang/crates.io-index", "syn 2.0.117 registry+https://github.com/rust-lang/crates.io-index", + "tempfile 3.27.0 registry+https://github.com/rust-lang/crates.io-index", "terminal_size 0.4.4 registry+https://github.com/rust-lang/crates.io-index", "thread_local 1.1.9 registry+https://github.com/rust-lang/crates.io-index", "tracing 0.1.44 registry+https://github.com/rust-lang/crates.io-index", "tracing-core 0.1.36 registry+https://github.com/rust-lang/crates.io-index", "tracing-error 0.2.1 registry+https://github.com/rust-lang/crates.io-index", "tracing-subscriber 0.3.23 registry+https://github.com/rust-lang/crates.io-index", + "uds_windows 1.2.1 registry+https://github.com/rust-lang/crates.io-index", "unicode-ident 1.0.24 registry+https://github.com/rust-lang/crates.io-index", "utf8parse 0.2.2 registry+https://github.com/rust-lang/crates.io-index", "windows 0.62.2 registry+https://github.com/rust-lang/crates.io-index", @@ -180,6 +198,12 @@ "zmij 1.0.21 registry+https://github.com/rust-lang/crates.io-index" ] ], + [ + "MPL-2.0", + [ + "option-ext 0.2.0 registry+https://github.com/rust-lang/crates.io-index" + ] + ], [ "Unicode-3.0", [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 32234603..7db49510 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -72,3 +72,4 @@ source and have not been checked against a recording of it: an analysis that rea | An implementor of `Rules` answers every question that has no default | the type system: a missing method is E0046, proved by a `compile_fail` example on `Rules` | | Code that reads komorebi's output asks `Rules` and does not match on `Flavor` | convention only | | Rules that were not checked against a recording of komorebi say so | the framework path: an analysis that reads such a session adds a warning | +| A running recorder can be told from one that died | the OS: an exclusive lock on `recorder.lock`, held for as long as the recorder runs and dropped by the OS when the process ends however it ends | diff --git a/docs/PLATFORM.md b/docs/PLATFORM.md index 1c7f9cb3..c9a1dd9e 100644 --- a/docs/PLATFORM.md +++ b/docs/PLATFORM.md @@ -46,6 +46,24 @@ Video frame times and the OS trace are on the master clock already on both hosts `machTimestamp` on a Mac; Windows.Graphics.Capture on Windows). The field names ending in `_mach_ns` are spelled that way on every host: see [the architecture](ARCHITECTURE.md#the-master-clock). +### Locking a session + +A recording holds an exclusive lock on `recorder.lock` in its session for as long as it runs, and writes its process id +to `recorder.pid`. The OS drops the lock when the process dies, however it dies, so a lock that is held means a live +recorder and one that is free means there is none. Windows file locks are mandatory, which is why the pid is not kept in +the locked file. + +### komorebi's log and how the recorder talks to it + +| | komorebi for Mac | komorebi for Windows | +|---|---|---| +| data directory | `~/Library/Application Support/komorebi` | `%LOCALAPPDATA%\komorebi` | +| log | `komorebi_plaintext.log.` in the data directory | `%TEMP%\komorebi_plaintext.log.` | +| command and subscriber sockets | unix domain sockets in the data directory | unix domain sockets in the data directory, through `uds_windows` | +| `--log-level debug` | `komorebi --log-level debug` | `komorebi.exe --log-level debug`; `RUST_LOG=debug` also prints every dependency, so name the crate: `RUST_LOG=komorebi=debug` | + +The wire protocol is the same on both. The recorder's own binary does not depend on either komorebi's crates. + ## Degraded modes - `record --no-video` records everything but the screen, on the same clock. It asks the system nothing about displays and diff --git a/komorebi-recorder/Cargo.toml b/komorebi-recorder/Cargo.toml index 30a1ac9d..072b9166 100644 --- a/komorebi-recorder/Cargo.toml +++ b/komorebi-recorder/Cargo.toml @@ -8,6 +8,8 @@ repository.workspace = true [dependencies] clap = { workspace = true } color-eyre = { workspace = true } +crossbeam-channel = { workspace = true } +dirs = { workspace = true } schemars = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -16,6 +18,7 @@ serde_json = { workspace = true } libc = "0.2" [target.'cfg(windows)'.dependencies] +uds_windows = "1" windows = { version = "0.62", features = [ "Foundation", "Graphics", diff --git a/komorebi-recorder/src/analysis/events.rs b/komorebi-recorder/src/analysis/events.rs index ed9d3f9c..eb621225 100644 --- a/komorebi-recorder/src/analysis/events.rs +++ b/komorebi-recorder/src/analysis/events.rs @@ -19,6 +19,8 @@ //! [`super::classify`] labels each [`EventRecord`]. Tying an event to the log lines of the handler //! that produced it is done by `blocks::pair`. Both read the events this module returns. +use super::flavor::Rules; +use crate::sources::komorebi_events::EventRow; use schemars::JsonSchema; use serde::Serialize; use serde_json::Value; @@ -173,3 +175,62 @@ pub struct EventRecord { /// [`Family::Unknown`] and the name `unreadable`. pub parse_error: Option, } + +impl EventRecord { + /// Returns the record for one line of `events.jsonl`, reading its event with `rules`. + /// + /// A row with no event (its notification could not be parsed as JSON) gives + /// [`Family::Unknown`] and the name `unreadable`, not an error; the row's other fields are + /// kept. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::analysis::events::{EventRecord, Family}; + /// use komorebi_recorder::analysis::flavor::Flavor; + /// use komorebi_recorder::sources::komorebi_events::EventRow; + /// + /// let row: EventRow = serde_json::from_str( + /// r#"{"seq": 7, "recv_wall_us": 0, "recv_mach_ns": 30, "state_id": 4, + /// "event": {"type": "Retile"}}"#, + /// )?; + /// let record = EventRecord::from_row(&row, Flavor::Macos.rules()); + /// assert_eq!(record.seq, 7); + /// assert_eq!(record.state_id, Some(4)); + /// assert_eq!(record.info.family, Family::Command); + /// + /// // a notification that was not JSON has no event: it is unreadable, and says why + /// let row: EventRow = serde_json::from_str( + /// r#"{"seq": 8, "recv_wall_us": 0, "recv_mach_ns": 40, "parse_error": "expected value"}"#, + /// )?; + /// let record = EventRecord::from_row(&row, Flavor::Macos.rules()); + /// assert_eq!(record.info.family, Family::Unknown); + /// assert_eq!(record.info.name, "unreadable"); + /// assert_eq!(record.parse_error.as_deref(), Some("expected value")); + /// # Ok::<(), Box>(()) + /// ``` + #[must_use] + pub fn from_row(row: &EventRow, rules: &dyn Rules) -> Self { + let info = row.event.as_ref().map_or_else( + || EventInfo { + family: Family::Unknown, + name: "unreadable".to_string(), + notification: None, + pid: None, + window_id: None, + exe: None, + argument: None, + }, + |event| rules.describe_event(event), + ); + + Self { + seq: row.seq, + recv_mach_ns: row.recv_mach_ns, + recv_wall_us: row.recv_wall_us, + state_id: row.state_id, + info, + parse_error: row.parse_error.clone(), + } + } +} diff --git a/komorebi-recorder/src/incidents.rs b/komorebi-recorder/src/incidents.rs new file mode 100644 index 00000000..a4db20c5 --- /dev/null +++ b/komorebi-recorder/src/incidents.rs @@ -0,0 +1,433 @@ +//! Things that happened to the *recording environment* while recording: a display was unplugged, +//! the machine slept and woke, the screen locked, a log stream was restarted, komorebi restarted. +//! +//! They are appended to `recorder.jsonl` the moment they are noticed (and flushed, so a crash +//! loses nothing) and indexed into the timeline as the `recorder` source. That puts "display 1 +//! disconnected" or "woke after 42 s asleep" right next to the log lines and OS trace around it, +//! which is usually the first thing needed to explain a gap or a strange layout. +//! +//! The same on both hosts: which incidents a host can produce differs (the OS announces a lid +//! closing on Windows, and nothing does on macOS), but a recording from either is read the same +//! way, and the file does not say which host wrote it. +//! +//! # Who writes what +//! +//! A part of the recorder that notices something builds a [`Draft`] with a [`kind`] and a +//! sentence, and the recording loop hands it to the [`IncidentLog`], which timestamps and writes +//! it. The kinds are produced by: +//! +//! | Kinds | Noticed by | +//! |---|---| +//! | [`kind::CAPTURE_STARTED`], [`kind::CAPTURE_STOPPED`], [`kind::CAPTURE_FAILED`], [`kind::CAPTURE_RECOVERED`], the `display-*` kinds | the capture manager, which follows the displays | +//! | [`kind::SUSPEND`], [`kind::WAKE`], [`kind::LID_CLOSED`], [`kind::LID_OPENED`], [`kind::DISPLAY_ASLEEP`], [`kind::DISPLAY_AWAKE`] | `PowerTracker`, from what the OS announces | +//! | [`kind::WAKE`], [`kind::CLOCK_STEP`] | the recording loop, from the clocks disagreeing (see [`crate::clock::JumpDetector`]) | +//! | the `screen-*` and `session-*` kinds | `SessionTracker`, polling the session | +//! | [`kind::KOMOREBI_EXITED`], [`kind::KOMOREBI_STARTED`] | the recording loop, following komorebi's process | +//! | [`kind::LOG_STREAM_RESTARTED`], [`kind::LOG_STREAM_LOST`] | the OS trace | +//! | [`kind::RECORDER`] | the recorder itself, about itself | +//! +//! # The file +//! +//! `recorder.jsonl` is UTF-8, one JSON object per line ([`Incident`]), in the order they were +//! written. That is not always the order they happened in, because a [`Draft`] can be dated before +//! it was noticed. The timeline index sorts them in with every other source by `mach_ns`. +//! +//! ```json +//! {"mach_ns":104500000000,"wall_us":1800000004500000,"kind":"display-disconnected","detail":"display 1 disconnected","display":1} +//! ``` +//! +//! # Example +//! +//! ```rust +//! use komorebi_recorder::incidents::{kind, Draft, Incident, IncidentLog, INCIDENTS_FILE}; +//! +//! let dir = std::env::temp_dir().join(format!("komorebi-recorder-doc-incidents-{}", std::process::id())); +//! std::fs::create_dir_all(&dir)?; +//! +//! let mut log = IncidentLog::create(&dir)?; +//! let incident = log.record( +//! Draft::new(kind::DISPLAY_DISCONNECTED, "display 1 disconnected").display(1), +//! ); +//! +//! // the line is on disk already, without a flush or a drop +//! let text = std::fs::read_to_string(dir.join(INCIDENTS_FILE))?; +//! let on_disk: Incident = serde_json::from_str(text.trim_end())?; +//! assert_eq!(on_disk, incident); +//! assert_eq!(on_disk.kind, "display-disconnected"); +//! assert_eq!(on_disk.display, Some(1)); +//! # std::fs::remove_dir_all(&dir)?; +//! # Ok::<(), Box>(()) +//! ``` + +use crate::clock; +use serde::Deserialize; +use serde::Serialize; +use std::collections::VecDeque; +use std::fs::File; +use std::io::BufWriter; +use std::io::Write; +use std::path::Path; + +/// The file in a session directory that incidents are appended to: `recorder.jsonl`. +/// +/// See the [module docs](self) for the format of a line, which is [`Incident`]. +pub const INCIDENTS_FILE: &str = "recorder.jsonl"; +/// The name of the timeline source that incidents are indexed as: `recorder`. +/// +/// It is the `source` of every incident's entry in the timeline index, and the name a query's +/// source filter uses to ask for them. +pub const SOURCE_RECORDER: &str = "recorder"; + +/// The kinds of incident, as written to `recorder.jsonl`. +/// +/// Each constant is the `kind` of an [`Incident`], spelled in lowercase words joined by hyphens. +/// The `kind` is a plain string in the file and in [`Incident`], and a [`Draft`] takes any +/// `&'static str`: these constants are the kinds this crate writes, and keep their spellings in one +/// place, but nothing stops a writer passing another string, or a reader meeting one. +pub mod kind { + /// A capture of a display started, as a new segment of its video. `display` is set. + pub const CAPTURE_STARTED: &str = "capture-started"; + /// A capture segment was finalized for a reason that is not about the display itself. + /// + /// Its stream stalled or failed, or the machine woke and every capture is being replaced. The + /// detail says why, and which segment file it was. + pub const CAPTURE_STOPPED: &str = "capture-stopped"; + /// A capture could not be started. It is tried again with growing delays, and this is + /// reported once per outage. + pub const CAPTURE_FAILED: &str = "capture-failed"; + /// A capture started again, as a new segment, after one that had stopped on a stream failure + /// or had failed to start. + pub const CAPTURE_RECOVERED: &str = "capture-recovered"; + /// A display appeared that had not been seen during this recording (a monitor was plugged + /// in, or a laptop was docked). Its capture starts after a short settling time. + pub const DISPLAY_CONNECTED: &str = "display-connected"; + /// A display went away (unplugged, undocked, lid closed), and its video segment was finalized + /// on the spot. + pub const DISPLAY_DISCONNECTED: &str = "display-disconnected"; + /// A display's size changed, so its segment was closed and a new one begun. + pub const DISPLAY_RECONFIGURED: &str = "display-reconfigured"; + /// A display is still attached but asleep or turned off. A sleeping display counts as absent: + /// its segment was finalized, and it is not captured until it wakes. + pub const DISPLAY_ASLEEP: &str = "display-asleep"; + /// A display is available again after being asleep or gone: one that had been seen before + /// returned, or the OS said the displays were turned on. + pub const DISPLAY_AWAKE: &str = "display-awake"; + /// The machine is about to sleep or hibernate, as the OS announced it. Nothing is recorded + /// until it wakes. + pub const SUSPEND: &str = "suspend"; + /// The machine woke, either as the OS announced it or as the clocks showed it (the wall clock + /// moved on while the master clock did not). The detail says how long the machine slept. + pub const WAKE: &str = "wake"; + /// The laptop lid was closed, as the OS announced it. + pub const LID_CLOSED: &str = "lid-closed"; + /// The laptop lid was opened, as the OS announced it. + pub const LID_OPENED: &str = "lid-opened"; + /// The wall clock was stepped backwards. (A step forwards cannot be told from a sleep, and is + /// reported as [`WAKE`].) + pub const CLOCK_STEP: &str = "clock-step"; + /// The screen was locked. Video may pause or restart until it is unlocked. + pub const SCREEN_LOCKED: &str = "screen-locked"; + /// The screen was unlocked. + pub const SCREEN_UNLOCKED: &str = "screen-unlocked"; + /// Another user's session came to the front (fast user switching), so this one is in the + /// background and cannot be captured. + pub const SESSION_INACTIVE: &str = "session-inactive"; + /// This user's session is in front again. + pub const SESSION_ACTIVE: &str = "session-active"; + /// The komorebi process exited. Its events and OS trace stop until it runs again. + pub const KOMOREBI_EXITED: &str = "komorebi-exited"; + /// A komorebi process started while recording, and the recorder follows it. + pub const KOMOREBI_STARTED: &str = "komorebi-started"; + /// An OS trace stream exited and was started again. Events in the gap between are missing. + pub const LOG_STREAM_RESTARTED: &str = "log-stream-restarted"; + /// An OS trace stream, or one of the taps that make it up, could not be started or restarted: + /// the OS trace is incomplete. + pub const LOG_STREAM_LOST: &str = "log-stream-lost"; + /// A note from the recorder itself: that recording started, or that it could not do something + /// (list the displays, subscribe to komorebi, trace it). + pub const RECORDER: &str = "recorder"; +} + +/// One line of `recorder.jsonl`: something that happened to the recording environment, and when. +/// +/// An incident is written by [`IncidentLog::record`], with the time it happened on both clocks. +/// The fields are, in the order they are written, `mach_ns`, `wall_us`, `kind`, `detail`, and +/// `display` when there is one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Incident { + /// When it happened, in nanoseconds on the master clock (see [`crate::clock`]). That can be + /// earlier than when it was noticed. + pub mach_ns: u64, + /// When it happened by the wall clock, in microseconds since the Unix epoch, worked out from + /// how long ago `mach_ns` was. + pub wall_us: i64, + /// What kind of incident, one of the constants in [`kind`]. + pub kind: String, + /// A sentence saying what happened, for a person. + pub detail: String, + /// The display the incident is about, when it is about one. Left out of the file when there is + /// none, and `None` when it is not there. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub display: Option, +} + +/// An incident that has not been timestamped yet. The time it *happened* can be earlier than the +/// time it was noticed (a stream that died, a wake detected a moment later). +/// +/// A draft is built with [`Draft::new`] and the builder methods [`display`](Draft::display) and +/// [`at`](Draft::at), then handed to [`IncidentLog::record`], which gives it its times. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::incidents::{kind, Draft}; +/// +/// let draft = Draft::new(kind::CAPTURE_STOPPED, "the stream stalled") +/// .display(2) +/// .at(5_000_000_000); +/// +/// assert_eq!(draft.kind, "capture-stopped"); +/// assert_eq!(draft.display, Some(2)); +/// assert_eq!(draft.mach_ns, Some(5_000_000_000)); +/// +/// // without `at`, the incident is dated when it is recorded +/// assert_eq!(Draft::new(kind::RECORDER, "recording started").mach_ns, None); +/// ``` +/// +/// # Example: the kind is a static string +/// +/// A kind is a `&'static str`, which a constant in [`kind`] is: it is part of the file's format, +/// not something to build at run time. A `String` is refused by the compiler: +/// +/// ```rust,compile_fail,E0308 +/// use komorebi_recorder::incidents::Draft; +/// +/// let kind = format!("display-{}", "disconnected"); +/// let _draft = Draft::new(kind, "display 1 disconnected"); +/// ``` +/// +/// A constant, or any string literal, is accepted: +/// +/// ```rust +/// use komorebi_recorder::incidents::{kind, Draft}; +/// +/// let _draft = Draft::new(kind::DISPLAY_DISCONNECTED, "display 1 disconnected"); +/// ``` +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Draft { + /// The kind, one of the constants in [`kind`]. + pub kind: &'static str, + /// A sentence saying what happened. + pub detail: String, + /// The display it is about, if it is about one. + pub display: Option, + /// When it happened, in nanoseconds on the master clock, if that is not now. + pub mach_ns: Option, +} + +impl Draft { + /// Returns a draft of an incident of `kind`, described by `detail`, that is about no display + /// in particular and happened when it is recorded. + #[must_use] + pub fn new(kind: &'static str, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + display: None, + mach_ns: None, + } + } + + /// Returns the draft, about the display `display`. + #[must_use] + pub fn display(mut self, display: u32) -> Self { + self.display = Some(display); + self + } + + /// Returns the draft, dated `mach_ns` nanoseconds on the master clock instead of when it is + /// recorded. A time in the future is not believed: [`IncidentLog::record`] dates it now. + #[must_use] + pub fn at(mut self, mach_ns: u64) -> Self { + self.mach_ns = Some(mach_ns); + self + } +} + +/// How many recent incidents to keep for the UI. +const RECENT: usize = 8; + +/// The incident file of a recording in progress, open for appending. +/// +/// Every incident is written and flushed the moment it is recorded, because incidents are rare and +/// a crash is exactly when they matter most. The log also keeps the last few incidents in memory, +/// and a count, for the recorder window to show. +/// +/// # Example: an incident dated before it was noticed +/// +/// ```rust +/// use komorebi_recorder::clock::mono_now_ns; +/// use komorebi_recorder::incidents::{kind, Draft, IncidentLog}; +/// +/// let dir = std::env::temp_dir().join(format!("komorebi-recorder-doc-dated-{}", std::process::id())); +/// std::fs::create_dir_all(&dir)?; +/// let mut log = IncidentLog::create(&dir)?; +/// +/// // a stream that died 5 seconds ago, noticed now +/// let died = mono_now_ns() - 5_000_000_000; +/// let incident = log.record(Draft::new(kind::CAPTURE_STOPPED, "stream died").at(died)); +/// assert_eq!(incident.mach_ns, died); +/// +/// // a time in the future is not believed +/// let future = log.record(Draft::new(kind::WAKE, "woke").at(u64::MAX)); +/// assert!(future.mach_ns <= mono_now_ns()); +/// +/// assert_eq!(log.count(), 2); +/// # std::fs::remove_dir_all(&dir)?; +/// # Ok::<(), Box>(()) +/// ``` +pub struct IncidentLog { + writer: BufWriter, + recent: VecDeque, + count: u64, +} + +impl IncidentLog { + /// Creates `recorder.jsonl` in `session_dir`, ready to append to. + /// + /// An existing `recorder.jsonl` is truncated. + /// + /// # Errors + /// + /// Returns the I/O error if the file cannot be created: `session_dir` does not exist, or it + /// cannot be written to. + pub fn create(session_dir: &Path) -> std::io::Result { + Ok(Self { + writer: BufWriter::new(File::create(session_dir.join(INCIDENTS_FILE))?), + recent: VecDeque::new(), + count: 0, + }) + } + + /// Timestamps, writes and flushes an incident, and returns it as it was written. + /// + /// The incident happened at the draft's [`mach_ns`](Draft::mach_ns), or now if the draft has + /// none, and never later than now. Its wall clock time is the current wall clock time minus + /// how long ago that was. + /// + /// This does not fail. An error while writing or flushing is ignored: the incident is still + /// returned, counted and kept for [`recent`](IncidentLog::recent), but may not be in the file. + pub fn record(&mut self, draft: Draft) -> Incident { + let now_mach = clock::mono_now_ns(); + let mach_ns = draft.mach_ns.unwrap_or(now_mach).min(now_mach); + // the wall time it happened at, derived from how long ago that was + let ago_us = i64::try_from(now_mach.saturating_sub(mach_ns) / 1_000).unwrap_or(0); + let incident = Incident { + mach_ns, + wall_us: clock::wall_now_us() - ago_us, + kind: draft.kind.to_string(), + detail: draft.detail, + display: draft.display, + }; + + if let Ok(line) = serde_json::to_string(&incident) { + // incidents are rare and precious: never buffer them + let _ = writeln!(self.writer, "{line}"); + let _ = self.writer.flush(); + } + + self.count += 1; + if self.recent.len() == RECENT { + self.recent.pop_front(); + } + self.recent.push_back(incident.clone()); + incident + } + + /// Records each draft in turn, as [`record`](IncidentLog::record) does. + pub fn record_all(&mut self, drafts: impl IntoIterator) { + for draft in drafts { + self.record(draft); + } + } + + /// Returns how many incidents this log has recorded. + #[must_use] + pub fn count(&self) -> u64 { + self.count + } + + /// Returns the last 8 incidents recorded, oldest first. + #[must_use] + pub fn recent(&self) -> Vec { + self.recent.iter().cloned().collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "komorebi-recorder-incidents-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + dir + } + + #[test] + fn incidents_are_on_disk_the_moment_they_are_recorded() { + let dir = temp("flush"); + let mut log = IncidentLog::create(&dir).expect("create"); + + log.record(Draft::new(kind::DISPLAY_DISCONNECTED, "display 1 went away").display(1)); + + // no flush or drop needed: a crash right now must not lose it + let text = std::fs::read_to_string(dir.join(INCIDENTS_FILE)).expect("read"); + let incident: Incident = + serde_json::from_str(text.lines().next().expect("line")).expect("parse"); + assert_eq!(incident.kind, "display-disconnected"); + assert_eq!(incident.display, Some(1)); + assert_eq!(log.count(), 1); + } + + #[test] + fn an_incident_can_be_dated_before_it_was_noticed() { + let dir = temp("dated"); + let mut log = IncidentLog::create(&dir).expect("create"); + + let before = clock::mono_now_ns(); + let earlier = before - 5_000_000_000; + let incident = log.record(Draft::new(kind::CAPTURE_STOPPED, "stream died").at(earlier)); + + assert_eq!(incident.mach_ns, earlier); + // the wall time is five seconds before now + let skew = (clock::wall_now_us() - incident.wall_us - 5_000_000).abs(); + assert!(skew < 100_000, "wall time was {skew} us off"); + } + + #[test] + fn an_incident_cannot_be_dated_in_the_future() { + let dir = temp("future"); + let mut log = IncidentLog::create(&dir).expect("create"); + let incident = log.record(Draft::new(kind::WAKE, "x").at(u64::MAX)); + assert!(incident.mach_ns <= clock::mono_now_ns()); + } + + #[test] + fn only_the_most_recent_incidents_are_kept_for_the_ui() { + let dir = temp("recent"); + let mut log = IncidentLog::create(&dir).expect("create"); + for index in 0..20 { + log.record(Draft::new(kind::RECORDER, format!("incident {index}"))); + } + + let recent = log.recent(); + assert_eq!(recent.len(), RECENT); + assert_eq!(recent.last().expect("last").detail, "incident 19"); + assert_eq!(log.count(), 20); + } +} diff --git a/komorebi-recorder/src/komorebi_link.rs b/komorebi-recorder/src/komorebi_link.rs new file mode 100644 index 00000000..9dbfbff7 --- /dev/null +++ b/komorebi-recorder/src/komorebi_link.rs @@ -0,0 +1,530 @@ +//! Talking to a running komorebi, on either OS, without depending on either komorebi's crates. +//! +//! komorebi for Mac and komorebi for Windows speak the same wire protocol over a unix domain +//! socket. Commands go to `komorebi.sock` in komorebi's data directory (`/komorebi`); +//! each message is one compact JSON object, `{"type": "", "content": }`, and a query +//! is answered by writing the reply back and closing the connection. To receive notifications a +//! subscriber binds a socket of its own in that directory and asks komorebi to connect to it by +//! name: komorebi then connects once per notification, writes one JSON blob and disconnects. +//! +//! Only the handful of messages the recorder sends are defined here. Sockets are `std`'s on unix +//! and those of the [`uds_windows`] crate on Windows, which has the same interface. +//! +//! # Where this fits +//! +//! This is the lowest layer that touches komorebi, and it is the same on both hosts. Which komorebi +//! is answering (the *flavor*, see [`crate::analysis::flavor`]) is not something this module +//! knows or asks. komorebi's `SocketMessage` is what [`Message`] has to agree with on the wire, +//! and [`Message`] says how that is checked. +//! +//! | Function | Used for | +//! |---|---| +//! | [`data_dir`], [`socket_path`] | finding komorebi's sockets; also the default parent of recordings | +//! | [`query_state`] | asking for komorebi's `State` (the recorder, and `doctor`) | +//! | [`send_message`] | subscribing and unsubscribing (see [`crate::sources::komorebi_events`]) | +//! | [`send_query`] | any other query, such as `doctor --probe window` | +//! | [`bind_subscriber`] | listening for komorebi's notifications | +//! +//! # Timeouts and retries +//! +//! Every read and every write on a connection to komorebi times out after one second, so a komorebi +//! that stops answering fails the exchange. Connecting has no timeout. Nothing here +//! retries, except [`query_state`] when komorebi answers with nothing. +//! +//! # Example +//! +//! The wire format is pinned by what [`Message`] serializes to: +//! +//! ```rust +//! use komorebi_recorder::komorebi_link::{Message, SubscribeOptions}; +//! +//! assert_eq!(serde_json::to_string(&Message::State)?, r#"{"type":"State"}"#); +//! +//! let subscribe = Message::AddSubscriberSocketWithOptions( +//! "komorebi-recorder-7".to_string(), +//! SubscribeOptions { filter_state_changes: false }, +//! ); +//! assert_eq!( +//! serde_json::to_string(&subscribe)?, +//! r#"{"type":"AddSubscriberSocketWithOptions","content":["komorebi-recorder-7",{"filter_state_changes":false}]}"# +//! ); +//! # Ok::<(), serde_json::Error>(()) +//! ``` +//! +//! The functions that need a komorebi, or a socket, have `no_run` examples and name the tests that +//! cover them: see [`query_state`] and [`bind_subscriber`]. +//! +//! [`uds_windows`]: https://docs.rs/uds_windows + +use color_eyre::eyre; +use color_eyre::eyre::WrapErr; +use serde::Serialize; +use std::io::Read; +use std::io::Write; +use std::net::Shutdown; +use std::path::Path; +use std::path::PathBuf; +use std::sync::OnceLock; +use std::time::Duration; + +/// The unix domain socket listener of this host: `std`'s on unix. +#[cfg(unix)] +pub use std::os::unix::net::UnixListener; +/// The unix domain socket stream of this host: `std`'s on unix. +#[cfg(unix)] +pub use std::os::unix::net::UnixStream; +/// The unix domain socket listener of this host: the `uds_windows` crate's on Windows, which has +/// the same interface as `std`'s. +#[cfg(windows)] +pub use uds_windows::UnixListener; +/// The unix domain socket stream of this host: the `uds_windows` crate's on Windows, which has the +/// same interface as `std`'s. +#[cfg(windows)] +pub use uds_windows::UnixStream; + +/// The name of the socket komorebi listens for commands on, inside the data directory. +/// +/// Its path is [`socket_path`]`(KOMOREBI_SOCKET)`. It is `komorebi.sock` on both flavors of +/// komorebi. +pub const KOMOREBI_SOCKET: &str = "komorebi.sock"; + +/// How long to wait on komorebi before giving up on one message. +const IO_TIMEOUT: Duration = Duration::from_secs(1); + +/// Returns komorebi's data directory, where its sockets and every subscriber's socket live. +/// +/// This is `/komorebi` on both OSes (`~/Library/Application Support/komorebi`, +/// `%LOCALAPPDATA%\komorebi`). If the OS has no local data directory, the directory is `komorebi` +/// inside the temporary directory. The path is worked out on the first call, and is the same for +/// the life of the process. The directory is not created and need not exist. +/// +/// A session's recordings go under it, in `recordings/`, unless told otherwise +/// (`session::default_recordings_dir`). +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::komorebi_link::{data_dir, socket_path, KOMOREBI_SOCKET}; +/// +/// assert!(data_dir().ends_with("komorebi")); +/// assert_eq!(socket_path(KOMOREBI_SOCKET), data_dir().join("komorebi.sock")); +/// ``` +#[must_use] +pub fn data_dir() -> &'static Path { + static DATA_DIR: OnceLock = OnceLock::new(); + DATA_DIR.get_or_init(|| { + dirs::data_local_dir() + .unwrap_or_else(std::env::temp_dir) + .join("komorebi") + }) +} + +/// Returns the path of the socket called `name` in komorebi's data directory ([`data_dir`]). +/// +/// The name is joined as given, without checking that it is a plain file name, and the socket +/// need not exist. A unix socket path is limited to about 104 bytes on macOS and 108 on Windows +/// and Linux, so a long data directory leaves room for only a short `name`: `doctor` checks that +/// the recorder's subscriber socket fits. +#[must_use] +pub fn socket_path(name: &str) -> PathBuf { + data_dir().join(name) +} + +/// What a subscriber asks komorebi for. +/// +/// The default is to ask for every notification. The recorder does too: it wants every event, not +/// only the ones that changed state. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)] +pub struct SubscribeOptions { + /// Only send notifications that changed the window manager's state. + pub filter_state_changes: bool, +} + +/// A message to komorebi, in the form komorebi reads from its socket. +/// +/// Only the messages the recorder sends are defined. The type serializes, as `{"type": ""}` +/// or `{"type": "", "content": }`, to what komorebi's `SocketMessage` reads. +/// It is never read back, because the recorder never receives one. +/// +/// komorebi is not a dependency, so nothing in the build checks that the two agree: the variant +/// names and payload shapes are kept as komorebi spells them, and the test +/// `messages_are_spelled_as_komorebi_reads_them` pins the JSON each variant produces. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::komorebi_link::Message; +/// +/// let json = |message: &Message| serde_json::to_string(message); +/// +/// assert_eq!(json(&Message::State)?, r#"{"type":"State"}"#); +/// assert_eq!( +/// json(&Message::RemoveSubscriberSocket("komorebi-recorder-7".to_string()))?, +/// r#"{"type":"RemoveSubscriberSocket","content":"komorebi-recorder-7"}"# +/// ); +/// assert_eq!( +/// json(&Message::DebugWindow(131_072))?, +/// r#"{"type":"DebugWindow","content":131072}"# +/// ); +/// # Ok::<(), serde_json::Error>(()) +/// ``` +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "type", content = "content")] +pub enum Message { + /// Reply with the full window manager state as JSON. + State, + /// Connect to the socket named `.0` in the data directory for every notification, with the + /// options `.1`. + AddSubscriberSocketWithOptions(String, SubscribeOptions), + /// Stop connecting to the socket named `.0` in the data directory. + RemoveSubscriberSocket(String), + /// Reply with komorebi's verdict on whether it would manage the window with this handle, and + /// why (Windows). + DebugWindow(u32), +} + +/// Sends one message and does not wait for an answer. +/// +/// This connects to komorebi's command socket ([`KOMOREBI_SOCKET`]), writes the message as one JSON +/// object, and closes the connection. The write times out after one second. Use [`send_query`] for +/// a message komorebi answers. +/// +/// # Errors +/// +/// Returns the I/O error if +/// +/// * komorebi's socket cannot be connected to: komorebi is not running, or its socket cannot be +/// used by this user; +/// * the write fails or takes longer than one second (`WouldBlock` or `TimedOut`, depending on the +/// OS). +/// +/// # Example +/// +/// ```rust,no_run +/// use komorebi_recorder::komorebi_link::{send_message, Message, SubscribeOptions}; +/// +/// // ask komorebi to connect to `komorebi-recorder-7` in its data directory, per notification +/// send_message(&Message::AddSubscriberSocketWithOptions( +/// "komorebi-recorder-7".to_string(), +/// SubscribeOptions::default(), +/// ))?; +/// # Ok::<(), std::io::Error>(()) +/// ``` +/// +/// This needs komorebi running. What is sent is covered by the test +/// `messages_are_spelled_as_komorebi_reads_them`. No test covers the exchange over the socket. +pub fn send_message(message: &Message) -> std::io::Result<()> { + let mut stream = UnixStream::connect(socket_path(KOMOREBI_SOCKET))?; + stream.set_write_timeout(Some(IO_TIMEOUT))?; + stream.write_all(serde_json::to_string(message)?.as_bytes()) +} + +/// Sends one message and reads komorebi's reply to the end of the connection. +/// +/// This connects to komorebi's command socket, writes the message, shuts down the writing half so +/// that komorebi sees the end of it, and reads until komorebi closes the connection. Reads and +/// writes each time out after one second: the limit applies to each, and not to the exchange as a +/// whole. The reply is returned as komorebi wrote it, which may be empty: komorebi for Windows +/// declines a state query by answering with nothing (see [`query_state`]). +/// +/// # Errors +/// +/// Returns the I/O error if +/// +/// * komorebi's socket cannot be connected to: komorebi is not running, or this user may not use +/// its socket; +/// * a write or a read fails or takes longer than one second; +/// * the reply is not valid UTF-8 (`InvalidData`). +/// +/// # Example +/// +/// ```rust,no_run +/// use komorebi_recorder::komorebi_link::{send_query, Message}; +/// +/// let verdict = send_query(&Message::DebugWindow(131_072))?; +/// println!("komorebi says: {verdict}"); +/// # Ok::<(), std::io::Error>(()) +/// ``` +/// +/// This needs komorebi running, and no test covers the exchange over the socket. +pub fn send_query(message: &Message) -> std::io::Result { + let mut stream = UnixStream::connect(socket_path(KOMOREBI_SOCKET))?; + stream.set_read_timeout(Some(IO_TIMEOUT))?; + stream.set_write_timeout(Some(IO_TIMEOUT))?; + stream.write_all(serde_json::to_string(message)?.as_bytes())?; + stream.shutdown(Shutdown::Write)?; + + let mut response = String::new(); + stream.read_to_string(&mut response)?; + Ok(response) +} + +/// Why komorebi did not hand over its state. +/// +/// The `Display` text is a sentence fit for a warning, with no trailing period. Neither case says +/// anything about the state komorebi holds: it may be perfectly healthy. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::komorebi_link::StateError; +/// +/// let error = StateError::Empty { attempts: 5 }; +/// assert_eq!( +/// error.to_string(), +/// "komorebi answered the state query with nothing 5 times in a row \ +/// (busy, paused, or on another virtual desktop)" +/// ); +/// ``` +#[derive(Debug)] +pub enum StateError { + /// komorebi could not be reached at all (not running, or its socket is unreadable). The + /// error is whatever [`send_query`] returned, including a timeout. + Unreachable(std::io::Error), + /// komorebi accepted the query and answered with nothing, every time we asked. On Windows that + /// is how it declines while it is busy, paused, or showing another virtual desktop. + Empty { + /// How many times the query was made, and answered with nothing. + attempts: u32, + }, +} + +impl std::fmt::Display for StateError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StateError::Unreachable(error) => write!(f, "could not query komorebi: {error}"), + StateError::Empty { attempts } => write!( + f, + "komorebi answered the state query with nothing {attempts} times in a row \ + (busy, paused, or on another virtual desktop)" + ), + } + } +} + +impl std::error::Error for StateError {} + +/// How many times to ask for the state when komorebi answers with nothing, and how long to wait +/// between asks. +const STATE_ATTEMPTS: u32 = 5; +const STATE_RETRY_DELAY: Duration = Duration::from_millis(200); + +/// Asks komorebi for its state, asking again when it answers with nothing. +/// +/// Returns komorebi's `State` as the JSON text komorebi sent, unparsed. A reply that is empty, or +/// only whitespace, is asked for again, up to 5 times in all, 200 ms apart. A failure to reach +/// komorebi is not asked again. +/// +/// # Errors +/// +/// Returns [`StateError::Unreachable`] if [`send_query`] fails, on any attempt, and +/// [`StateError::Empty`] if every attempt was answered with nothing. +/// +/// # Example +/// +/// ```rust,no_run +/// use komorebi_recorder::komorebi_link::{query_state, StateError}; +/// +/// match query_state() { +/// Ok(state) => println!("komorebi's state is {} bytes", state.len()), +/// Err(StateError::Unreachable(error)) => println!("not running? {error}"), +/// Err(error @ StateError::Empty { .. }) => println!("{error}"), +/// } +/// ``` +/// +/// This needs komorebi running. The retry rule is covered by the tests +/// `an_empty_state_reply_is_asked_again`, +/// `a_komorebi_that_only_ever_answers_with_nothing_is_an_error_that_says_so` and +/// `an_unreachable_komorebi_is_not_asked_again`, which supply the answers themselves. No test covers +/// the exchange over the socket. +pub fn query_state() -> Result { + query_state_with( + || send_query(&Message::State), + STATE_ATTEMPTS, + STATE_RETRY_DELAY, + ) +} + +fn query_state_with( + mut ask: impl FnMut() -> std::io::Result, + attempts: u32, + delay: Duration, +) -> Result { + for attempt in 1..=attempts { + let response = ask().map_err(StateError::Unreachable)?; + if !response.trim().is_empty() { + return Ok(response); + } + + if attempt < attempts { + std::thread::sleep(delay); + } + } + + Err(StateError::Empty { attempts }) +} + +/// Binds a listener for komorebi's notifications at `path`, replacing a stale socket file left by +/// a subscriber that died. +/// +/// Whatever is at `path` is removed first. A socket another live process is listening on is taken +/// over, not refused: that process keeps its listener, which nothing can reach by name. This +/// function does not remove the socket file afterwards; the caller does. A subscriber's `path` is +/// [`socket_path`] of the name it then gives komorebi in [`Message::AddSubscriberSocketWithOptions`]. +/// +/// # Errors +/// +/// Returns an error if +/// +/// * something at `path` cannot be removed, for any reason other than there being nothing there; +/// * the socket cannot be bound: the directory does not exist, this user may not write to it, or +/// `path` is longer than a unix socket path may be (about 104 bytes on macOS, 108 elsewhere). +/// +/// # Example +/// +/// ```rust,no_run +/// use komorebi_recorder::komorebi_link::{bind_subscriber, socket_path}; +/// +/// let listener = bind_subscriber(&socket_path("komorebi-recorder-7"))?; +/// // komorebi connects here once per notification, writes one JSON blob and disconnects +/// let (_stream, _) = listener.accept()?; +/// # Ok::<(), Box>(()) +/// ``` +/// +/// This waits for komorebi, so it is not run. It is covered by the test +/// `a_subscriber_socket_receives_what_is_written_to_it`, which binds over a stale file, connects a +/// writer to the socket, and reads what was written. +pub fn bind_subscriber(path: &Path) -> eyre::Result { + match std::fs::remove_file(path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => return Err(error).wrap_err("could not clear a stale subscriber socket"), + } + + UnixListener::bind(path).wrap_err_with(|| format!("could not listen on {}", path.display())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn messages_are_spelled_as_komorebi_reads_them() { + let json = |message: &Message| serde_json::to_string(message).expect("serialize"); + + assert_eq!(json(&Message::State), r#"{"type":"State"}"#); + assert_eq!( + json(&Message::AddSubscriberSocketWithOptions( + "komorebi-recorder-7".to_string(), + SubscribeOptions { + filter_state_changes: false + } + )), + r#"{"type":"AddSubscriberSocketWithOptions","content":["komorebi-recorder-7",{"filter_state_changes":false}]}"# + ); + assert_eq!( + json(&Message::RemoveSubscriberSocket( + "komorebi-recorder-7".into() + )), + r#"{"type":"RemoveSubscriberSocket","content":"komorebi-recorder-7"}"# + ); + assert_eq!( + json(&Message::DebugWindow(131_072)), + r#"{"type":"DebugWindow","content":131072}"# + ); + } + + #[test] + fn the_data_dir_is_komorebis() { + assert!(data_dir().ends_with("komorebi")); + assert_eq!(socket_path("x.sock"), data_dir().join("x.sock")); + } + + #[test] + fn an_empty_state_reply_is_asked_again() { + let mut replies = vec![ + Ok(String::new()), + Ok(" \n".to_string()), + Ok(r#"{"monitors":{}}"#.to_string()), + ] + .into_iter(); + + let state = query_state_with( + || replies.next().expect("asked too often"), + 5, + Duration::ZERO, + ) + .expect("state"); + assert_eq!(state, r#"{"monitors":{}}"#); + } + + #[test] + fn a_komorebi_that_only_ever_answers_with_nothing_is_an_error_that_says_so() { + let mut asked = 0; + let error = query_state_with( + || { + asked += 1; + Ok(String::new()) + }, + 3, + Duration::ZERO, + ) + .expect_err("nothing to return"); + + assert_eq!(asked, 3); + assert!( + matches!(error, StateError::Empty { attempts: 3 }), + "{error}" + ); + assert!(error.to_string().contains("another virtual desktop")); + } + + #[test] + fn an_unreachable_komorebi_is_not_asked_again() { + let mut asked = 0; + let error = query_state_with( + || { + asked += 1; + Err(std::io::Error::from(std::io::ErrorKind::NotFound)) + }, + 5, + Duration::ZERO, + ) + .expect_err("unreachable"); + + assert_eq!(asked, 1); + assert!(matches!(error, StateError::Unreachable(_))); + } + + #[test] + fn a_subscriber_socket_receives_what_is_written_to_it() { + // a unix socket path is limited to ~104 bytes, which a nix shell's $TMPDIR already exceeds + let base = if cfg!(unix) { + PathBuf::from("/tmp") + } else { + std::env::temp_dir() + }; + let dir = base.join(format!("kl-{}", std::process::id())); + std::fs::create_dir_all(&dir).expect("dir"); + let path = dir.join("sub"); + + // binding over a stale file from a dead subscriber works + std::fs::write(&path, b"stale").expect("stale"); + let listener = bind_subscriber(&path).expect("bind"); + + let writer = std::thread::spawn({ + let path = path.clone(); + move || { + let mut stream = UnixStream::connect(path).expect("connect"); + stream.write_all(b"{\"a\":1}").expect("write"); + } + }); + + let (mut stream, _) = listener.accept().expect("accept"); + let mut bytes = Vec::new(); + stream.read_to_end(&mut bytes).expect("read"); + writer.join().expect("writer"); + let _ = std::fs::remove_dir_all(&dir); + assert_eq!(bytes, br#"{"a":1}"#); + } +} diff --git a/komorebi-recorder/src/lib.rs b/komorebi-recorder/src/lib.rs index 9cd6824a..3fc0f631 100644 --- a/komorebi-recorder/src/lib.rs +++ b/komorebi-recorder/src/lib.rs @@ -43,6 +43,10 @@ //! | [`clock`] | the master clock, anchors and the map between the wall clock and the master clock | //! | [`platform`] | everything that differs by host, behind one facade | //! | [`analysis`] | understanding a session without a window, starting with the komorebi flavors | +//! | [`komorebi_link`] | talking to a running komorebi over its socket | +//! | [`incidents`] | what happened to the recording itself, as a log of its own | +//! | [`lock`] | telling a running recorder from one that died | +//! | [`sources`] | the sources that write raw files into the session | //! //! # What is not supported //! @@ -57,5 +61,9 @@ pub mod analysis; pub mod clock; +pub mod incidents; +pub mod komorebi_link; +pub mod lock; pub mod platform; +pub mod sources; pub mod timecode; diff --git a/komorebi-recorder/src/lock.rs b/komorebi-recorder/src/lock.rs new file mode 100644 index 00000000..9b17b47c --- /dev/null +++ b/komorebi-recorder/src/lock.rs @@ -0,0 +1,280 @@ +//! Telling a recorder that is running from one that died. +//! +//! A session's manifest says `recording` both while a recorder is alive and after one was killed, +//! and the right thing to do differs: wait, or `reindex`. The recorder therefore holds an exclusive +//! lock on `recorder.lock` for as long as it runs. The OS drops the lock the instant the process +//! dies, however it dies, so a lock that is still held means a live recorder and one that is free +//! means there is none. +//! +//! The pid lives in a file of its own, `recorder.pid`. Windows file locks are mandatory, so a +//! reader could not read a pid out of the very file that is locked; keeping the lock file empty +//! works the same on every OS. +//! +//! # The two files +//! +//! | File | Holds | +//! |---|---| +//! | [`LOCK_FILE`], `recorder.lock` | nothing: it is empty, and what matters is whether a lock is held on it | +//! | [`PID_FILE`], `recorder.pid` | the recorder's process id as decimal text and a newline, replaced by each recorder that takes the lock | +//! +//! The lock is [`File::try_lock`](std::fs::File::try_lock), which is `flock` on unix and +//! `LockFileEx` on Windows. It is held by an open file, not recorded as a pid, so it cannot go +//! stale. +//! +//! # Who uses this +//! +//! A recorder takes the lock with [`RecorderLock::acquire`] when it begins to record. Anything that +//! meets a session whose manifest says `recording` asks [`recorder`] whether that is a recorder at +//! work (wait for it) or one that died (`reindex` the session). This module is the same on every +//! host. +//! +//! # Example +//! +//! ```rust +//! use komorebi_recorder::lock::{recorder, Recorder, RecorderLock}; +//! +//! let dir = std::env::temp_dir().join(format!("komorebi-recorder-doc-lock-{}", std::process::id())); +//! std::fs::create_dir_all(&dir)?; +//! +//! // no lock file: a session that predates it, or no session at all +//! assert_eq!(recorder(&dir), Recorder::Unknown); +//! +//! // a second recorder cannot take a session that one is recording +//! let lock = RecorderLock::acquire(&dir)?; +//! assert_eq!(recorder(&dir), Recorder::Running(Some(std::process::id()))); +//! assert!(RecorderLock::acquire(&dir).is_err()); +//! +//! // the lock is released when the recorder is dropped, or dies +//! drop(lock); +//! assert_eq!(recorder(&dir), Recorder::NotRunning); +//! # std::fs::remove_dir_all(&dir)?; +//! # Ok::<(), Box>(()) +//! ``` + +use std::fs::File; +use std::fs::OpenOptions; +use std::fs::TryLockError; +use std::path::Path; + +/// The file in a session directory that a recording holds a lock on: `recorder.lock`. It is empty. +pub const LOCK_FILE: &str = "recorder.lock"; +/// The file in a session directory that has the recorder's process id: `recorder.pid`. +pub const PID_FILE: &str = "recorder.pid"; + +/// The lock a running recorder holds on its session; released when dropped or when the process +/// ends. +/// +/// There is no way to hold it without a recorder: the value is made by [`RecorderLock::acquire`], +/// and keeps the lock file open for as long as it lives. Its only field is private, so no other +/// code can make one around a file of its choosing: +/// +/// ```rust,compile_fail,E0451 +/// use komorebi_recorder::lock::RecorderLock; +/// +/// let file = std::fs::File::create("recorder.lock").expect("a file"); +/// let _lock = RecorderLock { _file: file }; +/// ``` +#[derive(Debug)] +pub struct RecorderLock { + _file: File, +} + +impl RecorderLock { + /// Takes the lock for the session in `dir`, and writes this process's id next to it. + /// + /// The lock file is created if it does not exist. The pid file is only written once the lock + /// is held, so it is never replaced by a process that did not get the lock. + /// + /// # Errors + /// + /// Returns the I/O error if + /// + /// * the lock file cannot be created or opened: `dir` does not exist, or cannot be written to; + /// * another recorder holds the lock: the kind is [`WouldBlock`](std::io::ErrorKind::WouldBlock) + /// and the message is `another recorder is already recording this session`; + /// * the lock cannot be taken for another reason, such as a file system that does not support + /// locks; + /// * the pid file cannot be written. The lock is released again in that case. + /// + /// # Example + /// + /// ```rust + /// use komorebi_recorder::lock::RecorderLock; + /// + /// let dir = std::env::temp_dir().join(format!("komorebi-recorder-doc-acquire-{}", std::process::id())); + /// std::fs::create_dir_all(&dir)?; + /// + /// let _first = RecorderLock::acquire(&dir)?; + /// let error = RecorderLock::acquire(&dir).unwrap_err(); + /// assert_eq!(error.kind(), std::io::ErrorKind::WouldBlock); + /// assert_eq!(error.to_string(), "another recorder is already recording this session"); + /// # std::fs::remove_dir_all(&dir)?; + /// # Ok::<(), Box>(()) + /// ``` + pub fn acquire(dir: &Path) -> std::io::Result { + let file = OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(dir.join(LOCK_FILE))?; + + match file.try_lock() { + Ok(()) => {} + Err(TryLockError::WouldBlock) => { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "another recorder is already recording this session", + )); + } + Err(TryLockError::Error(error)) => return Err(error), + } + + // only now that the lock is ours is it safe to replace what the previous owner wrote + std::fs::write(dir.join(PID_FILE), format!("{}\n", std::process::id()))?; + Ok(Self { _file: file }) + } +} + +/// What can be told about the recorder of a session from its lock. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Recorder { + /// A recorder holds the lock; the pid it wrote, if readable. + Running(Option), + /// The lock file exists and nobody holds it. + NotRunning, + /// There is no lock file: the session predates it. + Unknown, +} + +/// Returns whether a recorder is at work on the session in `dir`, by asking whether its lock is +/// held. +/// +/// The lock is tried with a shared lock, and let go of at once. [`Recorder::Running`] therefore +/// means that some open file holds the lock, which includes one held by the calling process. +/// [`Recorder::NotRunning`] means the lock file is there and free, and is also what a lock that +/// cannot be queried at all (a file system without locks, say) reads as: that is no evidence of a +/// live recorder, and calling a dead session live would make `reindex` refuse it. +/// [`Recorder::Unknown`] is for a lock file that is missing, or cannot be opened. +/// +/// A recorder that was killed leaves its lock file and pid file behind, but not the lock, so the +/// session reads as [`NotRunning`](Recorder::NotRunning). +/// +/// This never fails and never blocks. It is a snapshot: the answer can be out of date by the time +/// it is read. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::lock::{recorder, Recorder}; +/// +/// let dir = std::env::temp_dir().join(format!("komorebi-recorder-doc-recorder-{}", std::process::id())); +/// std::fs::create_dir_all(&dir)?; +/// assert_eq!(recorder(&dir), Recorder::Unknown); +/// # std::fs::remove_dir_all(&dir)?; +/// # Ok::<(), Box>(()) +/// ``` +/// +/// What each answer is for a live recorder, a dead one and a session with no lock file is covered +/// by the tests `the_lock_is_held_while_the_recorder_lives_and_free_after`, +/// `a_session_without_a_lock_file_is_unknown` and +/// `a_pid_written_into_the_lock_file_by_an_older_build_is_still_read`. +#[must_use] +pub fn recorder(dir: &Path) -> Recorder { + let path = dir.join(LOCK_FILE); + let Ok(file) = File::open(&path) else { + return Recorder::Unknown; + }; + + match file.try_lock_shared() { + Ok(()) => { + let _ = file.unlock(); + Recorder::NotRunning + } + // anything but "someone holds it" (a filesystem without locks, say) is not evidence of a + // live recorder, and calling a dead session live would make `reindex` refuse it + Err(TryLockError::Error(_)) => Recorder::NotRunning, + Err(TryLockError::WouldBlock) => Recorder::Running(pid(dir)), + } +} + +/// The pid a recorder wrote: in `recorder.pid`, or else in the lock file itself, where a recorder +/// that wrote the pid there left it (readable only on an OS whose locks are advisory). +fn pid(dir: &Path) -> Option { + [PID_FILE, LOCK_FILE].iter().find_map(|name| { + std::fs::read_to_string(dir.join(name)) + .ok() + .and_then(|text| text.trim().parse().ok()) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn temp(name: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!( + "komorebi-recorder-lock-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("dir"); + dir + } + + #[test] + fn a_session_without_a_lock_file_is_unknown() { + assert_eq!(recorder(&temp("none")), Recorder::Unknown); + } + + #[test] + fn the_lock_is_held_while_the_recorder_lives_and_free_after() { + let dir = temp("held"); + let lock = RecorderLock::acquire(&dir).expect("acquire"); + + assert_eq!(recorder(&dir), Recorder::Running(Some(std::process::id()))); + + drop(lock); + // a process spawned at that moment by another test briefly holds a copy of the descriptor, + // and a lock lasts until every copy of the descriptor is closed + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + while recorder(&dir) != Recorder::NotRunning && std::time::Instant::now() < deadline { + std::thread::sleep(std::time::Duration::from_millis(10)); + } + assert_eq!(recorder(&dir), Recorder::NotRunning); + } + + #[test] + fn the_pid_is_kept_out_of_the_locked_file() { + let dir = temp("pidfile"); + let _lock = RecorderLock::acquire(&dir).expect("acquire"); + + // Windows would refuse to read a file that is locked, so the pid cannot live in it + assert_eq!( + std::fs::read_to_string(dir.join(LOCK_FILE)).expect("lock file"), + "" + ); + assert_eq!( + std::fs::read_to_string(dir.join(PID_FILE)) + .expect("pid file") + .trim(), + std::process::id().to_string() + ); + } + + #[test] + fn a_pid_written_into_the_lock_file_by_an_older_build_is_still_read() { + let dir = temp("legacy"); + let lock = File::create(dir.join(LOCK_FILE)).expect("lock file"); + std::fs::write(dir.join(LOCK_FILE), "4242\n").expect("legacy pid"); + lock.try_lock().expect("held"); + + assert_eq!(recorder(&dir), Recorder::Running(Some(4242))); + } + + #[test] + fn two_recorders_cannot_share_a_session() { + let dir = temp("twice"); + let _first = RecorderLock::acquire(&dir).expect("first"); + assert!(RecorderLock::acquire(&dir).is_err()); + } +} diff --git a/komorebi-recorder/src/sources/komorebi_events.rs b/komorebi-recorder/src/sources/komorebi_events.rs new file mode 100644 index 00000000..39bd2813 --- /dev/null +++ b/komorebi-recorder/src/sources/komorebi_events.rs @@ -0,0 +1,665 @@ +//! komorebi's subscriber-socket notifications: every window manager event together with the full +//! `State` it produced. +//! +//! The daemon pushes each notification as one JSON blob over a fresh unix-socket connection to a +//! listener the recorder binds, and connects *synchronously* per subscriber while holding its own +//! locks, so a slow subscriber stalls komorebi. The accept thread therefore does the bare minimum +//! (stamp the receive time, read to EOF, hand the bytes to a channel) and all parsing and disk IO +//! happens on a separate writer thread. +//! +//! The payload carries no timestamp, so the receive time is stamped here on both clocks. komorebi +//! sends the notification *after* its handler has run, so the receive time marks the **end** of +//! handling the event, not its start: in komorebi for Mac's log the `processing event` line is +//! when the handler began (and, at DEBUG, the `pushed notification to subscriber` line is when it +//! sent this). `komorebi-recorder trace` ties the two together. +//! +//! A notification is a JSON object with an `event` (what komorebi was told or did) and the `state` +//! (komorebi's whole `State` after it). This module is the same on both hosts and for both flavors +//! of komorebi. It stores the payload as komorebi sent it and does not read it: what an event +//! means is the business of the [`Rules`](crate::analysis::flavor::Rules) of the flavor that +//! recorded it (see [`crate::analysis::events`]). +//! +//! # Files written +//! +//! | file | contents | +//! |---|---| +//! | `events.jsonl` | one [`EventRow`] per line, one line per notification, in arrival order | +//! | `states/.json` | komorebi's `State` as compact JSON, stored only when it differs from the last one stored; named by [`state_file_name`] | +//! | `state-start.json`, `state-end.json` | the `State` komorebi answered to a query at the start and the end of the recording ([`snapshot_state`]) | +//! +//! A line of `events.jsonl`, for a window manager event on komorebi for Mac: +//! +//! ```json +//! {"seq":7,"recv_wall_us":1793016000123456,"recv_mach_ns":5000000000,"event":{"content":[{"content":"AXWindowMoved","type":"Accessibility"},20934,3395],"type":"MoveEnd"},"state_id":2} +//! ``` +//! +//! The file is flushed whenever the writer has caught up with the notifications that have arrived, +//! so a recording that is killed loses only what the writer had not yet caught up on. +//! +//! # Threads +//! +//! [`EventRecorder`] runs two threads. The accept thread (`komorebi-events-accept`) numbers and +//! stamps each notification and does nothing else. The writer thread (`komorebi-events-write`) +//! parses it, stores a new `State`, tells the app discovery about it ([`Discovery`]), runs the +//! window prober when one was given (`--probe-windows`, see `winprobe`) and appends the row. The +//! prober never runs on the accept thread: komorebi delivers a notification while holding its own +//! locks, so a query sent to komorebi from the accept thread could wait on a lock that is itself +//! waiting for the notification to be accepted. + +use crate::clock; +use crate::komorebi_link; +use crate::komorebi_link::Message; +use crate::komorebi_link::SubscribeOptions; +use crate::komorebi_link::UnixListener; +use crate::komorebi_link::UnixStream; +use color_eyre::eyre; +use color_eyre::eyre::WrapErr; +use crossbeam_channel::Receiver; +use crossbeam_channel::Sender; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; +use std::fs::File; +use std::io::BufWriter; +use std::io::Read; +use std::io::Write; +use std::path::Path; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::AtomicU64; +use std::sync::atomic::Ordering; +use std::thread::JoinHandle; +use std::time::Duration; + +/// How long to keep accepting after asking komorebi to forget the recorder. +const UNSUBSCRIBE_DRAIN: Duration = Duration::from_millis(250); + +fn subscribe_to_komorebi(name: &str) -> std::io::Result<()> { + // unfiltered: every event is wanted, not just the ones that changed state + komorebi_link::send_message(&Message::AddSubscriberSocketWithOptions( + name.to_string(), + SubscribeOptions { + filter_state_changes: false, + }, + )) +} + +/// A notification as it arrived, before any parsing. +struct RawNotification { + seq: u64, + recv_wall_us: i64, + recv_mach_ns: u64, + bytes: Vec, +} + +/// One line of `events.jsonl`: a notification as komorebi sent it, with the time it arrived on +/// both clocks. +/// +/// The file is JSON Lines: one object per line, one line per notification, in the order the +/// recorder accepted them. `seq`, `recv_wall_us` and `recv_mach_ns` are always present. Every +/// other key is left out of the line when it has no value, and reads back as `None` when absent. +/// +/// A row is one of two shapes, and `event` and `parse_error` are never both present: +/// +/// - **read**: the payload was valid JSON. `event` is its `event` value (`None` if the payload had +/// no `event` key) and `parse_error` and `raw` are absent. +/// - **unreadable**: the payload was not valid JSON. `event` is absent, `parse_error` gives the +/// reason and `raw` keeps the payload as text, so a notification that cannot be read is not lost. +/// +/// # Example +/// +/// A row is one line of text, and reads back as it was written: +/// +/// ```rust +/// use komorebi_recorder::sources::komorebi_events::EventRow; +/// +/// let line = r#"{"seq":7,"recv_wall_us":1793016000123456,"recv_mach_ns":5000000000,"event":{"type":"Show","content":[null,1]},"state_id":2}"#; +/// let row: EventRow = serde_json::from_str(line)?; +/// assert_eq!((row.seq, row.state_id), (7, Some(2))); +/// assert_eq!(row.event.as_ref().and_then(|event| event["type"].as_str()), Some("Show")); +/// +/// // keys without a value are left out of the line: there is no `parse_error`, `raw` or `probe` +/// let written = serde_json::to_string(&row)?; +/// assert!(!written.contains("parse_error") && !written.contains("raw") && !written.contains("probe")); +/// assert_eq!(serde_json::from_str::(&written)?.seq, 7); +/// # Ok::<(), Box>(()) +/// ``` +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EventRow { + /// The notification's place in arrival order, counting from 1 for each recorder. The accept + /// thread numbers every non-empty notification it receives. + pub seq: u64, + /// When the notification was accepted, in microseconds since the Unix epoch on the wall clock. + pub recv_wall_us: i64, + /// When the notification was accepted, in nanoseconds on the host's master monotonic clock + /// (see [`crate::clock::mono_now_ns`]). komorebi notifies after its handler has run, so this + /// is when handling the event ended. + pub recv_mach_ns: u64, + /// The `event` value of the payload, re-serialized from the parsed JSON (komorebi's key order + /// and whitespace are not kept). Its shape is komorebi's and differs by flavor: a window + /// manager event, a command or a monitor change. Absent in an unreadable row. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub event: Option, + /// The id of the stored state snapshot (`states/.json`) in effect after this event: the + /// most recent snapshot stored at or before this row. `None` until the first snapshot is + /// stored. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub state_id: Option, + /// Why the payload was not valid JSON, as `serde_json` words it. Present only in an + /// unreadable row. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub parse_error: Option, + /// The payload as text, with any invalid UTF-8 replaced. Present only in an unreadable row. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub raw: Option, +} + +/// Something the app-discovery logic wants to know about as it happens, sent by the writer thread. +pub enum Discovery { + /// The state changed: this is the `State` that was just stored as a new snapshot. + State(Arc), + /// A window manager event mentioned this process id (see `managed_apps::event_pid`). + Pid(i32), +} + +/// Running totals of what the writer thread has handled, readable from any thread while the +/// recording goes on. +/// +/// Each counter only grows and is updated with relaxed ordering, so a reader sees a recent value, +/// not a value that is consistent with the others. [`EventSummary`] is these totals at the end. +#[derive(Debug, Default)] +pub struct EventCounters { + /// Notifications the writer has dequeued, readable or not. + pub events: AtomicU64, + /// `State` snapshots the writer has stored in `states/`. + pub states: AtomicU64, + /// Bytes of payload in those notifications. + pub bytes: AtomicU64, + /// Notifications whose payload was not valid JSON. + pub parse_errors: AtomicU64, +} + +/// The totals of an [`EventCounters`], as the manifest (`session.json`, key `events`) stores them. +/// +/// The recorder stores the totals so far at each checkpoint and the final totals when it stops; +/// [`EventRecorder::stop`] returns them. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +pub struct EventSummary { + /// Notifications handled, readable or not: the number of lines in `events.jsonl`. + pub events: u64, + /// `State` snapshots stored: the number of files in `states/`. + pub state_snapshots: u64, + /// Bytes of payload received, before parsing. + pub bytes: u64, + /// Notifications whose payload was not valid JSON (their rows have `parse_error`). + pub parse_errors: u64, +} + +/// Returns the path of the file that stores state snapshot `id`, relative to the session +/// directory: `states/.json`, with `` zero padded to six digits. +/// +/// Ids count from 1 in the order the snapshots were stored, so file names sort in that order up to +/// 999999 snapshots; an id with more digits is not padded further. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::sources::komorebi_events::state_file_name; +/// +/// assert_eq!(state_file_name(1), "states/000001.json"); +/// assert_eq!(state_file_name(123_456), "states/123456.json"); +/// ``` +#[must_use] +pub fn state_file_name(id: u64) -> String { + format!("states/{id:06}.json") +} + +/// Records komorebi's subscriber notifications into a session directory until it is stopped. +/// +/// A recorder binds a listener socket in komorebi's data directory, asks komorebi to send it every +/// notification, and writes `events.jsonl` and `states/` (see [`crate::sources::komorebi_events`] +/// for the files and the threads). It registers under the name `komorebi-recorder-`, which +/// komorebi's DEBUG log repeats when it pushes a notification, so an event can be tied to its +/// handler. +/// +/// The subscription asks for every event, not only the ones that changed komorebi's state. +/// +/// Stopping, or dropping a recorder that was not stopped, asks komorebi to forget the subscriber +/// and then keeps accepting for a moment before it closes the listener. If komorebi was in the +/// middle of a notification when it was told to forget the recorder, a reader that had already gone +/// would make its write fail, and that failure aborts delivery of the event to komorebi's other +/// subscribers (a status bar, for one). +/// +/// # Example +/// +/// Recording needs a komorebi socket and a session directory, so this example only compiles. The +/// writer's behavior is covered by `the_writer_dedupes_unchanged_states_and_tolerates_garbage` +/// and `the_writer_stores_what_the_prober_read_beside_the_event_it_is_about` in this file's unit +/// tests. The accept thread and the subscription handshake have no unit test: they are exercised +/// by recording against a running komorebi. +/// +/// ```rust,no_run +/// use komorebi_recorder::sources::komorebi_events::EventRecorder; +/// use std::path::Path; +/// +/// let recorder = EventRecorder::start(Path::new("session"), None)?; +/// if !recorder.is_subscribed() { +/// // komorebi was not running: register again once it is +/// let _ = recorder.subscribe(); +/// } +/// +/// // ... record ... +/// +/// let summary = recorder.stop(); +/// println!("{} events, {} states", summary.events, summary.state_snapshots); +/// # Ok::<(), Box>(()) +/// ``` +pub struct EventRecorder { + name: String, + socket: PathBuf, + subscribed: Arc, + stop: Arc, + counters: Arc, + accept: Option>, + writer: Option>, +} + +impl EventRecorder { + /// Listens for komorebi's notifications, writing `events.jsonl` and `states/` into `dir`, and + /// subscribes to komorebi if it is running. + /// + /// komorebi not running is not an error: the listener is bound regardless, so that when it + /// starts (or restarts, which forgets every subscriber) a later [`subscribe`](Self::subscribe) + /// picks the notifications up without losing the listener or the events file. + /// [`is_subscribed`](Self::is_subscribed) reports whether the first attempt succeeded. + /// + /// # Errors + /// + /// Returns an error if + /// + /// - `dir/states` cannot be created, + /// - the listener cannot be bound: a stale socket file of an earlier subscriber that cannot be + /// removed, or komorebi's data directory does not exist (as when komorebi has never run) or + /// cannot be written to (a unix socket path is also limited in length, to about 104 bytes on + /// macOS), + /// - `dir/events.jsonl` cannot be created, or + /// - a thread cannot be spawned. + /// + /// An error from the subscription itself is not one of them. + pub fn start(dir: &Path, discovery: Option>) -> eyre::Result { + std::fs::create_dir_all(dir.join("states"))?; + + let name = format!("komorebi-recorder-{}", std::process::id()); + let socket = komorebi_link::socket_path(&name); + let listener = komorebi_link::bind_subscriber(&socket)?; + + let subscribed = Arc::new(AtomicBool::new(subscribe_to_komorebi(&name).is_ok())); + + let events_file = File::create(dir.join("events.jsonl"))?; + let (tx, rx) = crossbeam_channel::unbounded::(); + let stop = Arc::new(AtomicBool::new(false)); + let counters = Arc::new(EventCounters::default()); + + let accept_stop = stop.clone(); + let accept = std::thread::Builder::new() + .name("komorebi-events-accept".to_string()) + .spawn(move || accept_loop(&listener, &tx, &accept_stop))?; + + let writer_counters = counters.clone(); + let states_dir = dir.join("states"); + let writer = std::thread::Builder::new() + .name("komorebi-events-write".to_string()) + .spawn(move || { + write_loop( + &rx, + events_file, + &states_dir, + &writer_counters, + discovery.as_ref(), + ) + })?; + + Ok(Self { + name, + socket, + subscribed, + stop, + counters, + accept: Some(accept), + writer: Some(writer), + }) + } + + /// Returns the live counters, shared with the writer thread. They keep counting until the + /// recorder is stopped. + #[must_use] + pub fn counters(&self) -> Arc { + self.counters.clone() + } + + /// The name this recorder registers under with komorebi, `komorebi-recorder-` for this + /// process. It names the recorder's socket in komorebi's data directory and is what komorebi's + /// DEBUG log says when it pushes a notification here. + #[must_use] + pub fn subscriber_name(&self) -> &str { + &self.name + } + + /// Returns `true` if and only if the most recent attempt to register with komorebi succeeded. + /// + /// This is the outcome of the last [`start`](Self::start) or [`subscribe`](Self::subscribe), + /// not a live check: a komorebi that has restarted has forgotten the recorder, and this stays + /// `true` until the next call to [`subscribe`](Self::subscribe) says otherwise. + #[must_use] + pub fn is_subscribed(&self) -> bool { + self.subscribed.load(Ordering::Relaxed) + } + + /// (Re-)registers with komorebi. Safe to call at any time; komorebi forgets its subscribers + /// when it restarts, so the recorder calls this when it sees a new komorebi. + /// + /// # Errors + /// + /// Returns an error if komorebi's socket cannot be connected to (komorebi is not running), or + /// the message cannot be written within one second. [`is_subscribed`](Self::is_subscribed) + /// then reads `false`. + pub fn subscribe(&self) -> std::io::Result<()> { + let result = subscribe_to_komorebi(&self.name); + self.subscribed.store(result.is_ok(), Ordering::Relaxed); + result + } + + /// Unsubscribes, drains everything already received, closes the listener and returns the + /// totals. + /// + /// This takes about a quarter of a second longer than the writer needs: the recorder keeps + /// accepting for that long after asking komorebi to forget it (see [`EventRecorder`]). The + /// socket file is removed. When this returns, every notification accepted has been written to + /// `events.jsonl` and flushed. + pub fn stop(mut self) -> EventSummary { + self.shutdown(); + EventSummary { + events: self.counters.events.load(Ordering::Relaxed), + state_snapshots: self.counters.states.load(Ordering::Relaxed), + bytes: self.counters.bytes.load(Ordering::Relaxed), + parse_errors: self.counters.parse_errors.load(Ordering::Relaxed), + } + } + + fn shutdown(&mut self) { + // tell komorebi first so it stops connecting while we wind down + let _ = komorebi_link::send_message(&Message::RemoveSubscriberSocket(self.name.clone())); + + // Keep reading for a moment. If komorebi was mid-notification when we unsubscribed, a + // reader that has already gone away would make its write fail, which aborts delivery of + // that event to its *other* subscribers (e.g. the bar). Draining lets it finish cleanly. + std::thread::sleep(UNSUBSCRIBE_DRAIN); + + self.stop.store(true, Ordering::SeqCst); + // wake the blocking accept so it observes the stop flag + let _ = UnixStream::connect(&self.socket); + + if let Some(accept) = self.accept.take() { + let _ = accept.join(); + } + + // the accept thread owned the only sender, so the writer drains and ends + if let Some(writer) = self.writer.take() { + let _ = writer.join(); + } + + let _ = std::fs::remove_file(&self.socket); + } +} + +impl Drop for EventRecorder { + fn drop(&mut self) { + if self.accept.is_some() || self.writer.is_some() { + self.shutdown(); + } + } +} + +fn accept_loop(listener: &UnixListener, tx: &Sender, stop: &AtomicBool) { + let mut seq = 0_u64; + + loop { + let (mut stream, _) = match listener.accept() { + Ok(accepted) => accepted, + Err(_) if stop.load(Ordering::SeqCst) => return, + Err(_) => { + std::thread::sleep(Duration::from_millis(10)); + continue; + } + }; + + // stamp before anything else: this is the number that matters + let recv_mach_ns = clock::mono_now_ns(); + let recv_wall_us = clock::wall_now_us(); + + if stop.load(Ordering::SeqCst) { + return; + } + + let _ = stream.set_read_timeout(Some(Duration::from_secs(2))); + let mut bytes = Vec::with_capacity(64 * 1024); + let _ = stream.read_to_end(&mut bytes); + if bytes.is_empty() { + continue; + } + + seq += 1; + if tx + .send(RawNotification { + seq, + recv_wall_us, + recv_mach_ns, + bytes, + }) + .is_err() + { + return; + } + } +} + +fn write_loop( + rx: &Receiver, + events_file: File, + states_dir: &Path, + counters: &EventCounters, + discovery: Option<&Sender>, +) { + let mut events = BufWriter::new(events_file); + let mut previous_state: Option = None; + let mut state_id = 0_u64; + + while let Ok(raw) = rx.recv() { + counters.events.fetch_add(1, Ordering::Relaxed); + counters + .bytes + .fetch_add(raw.bytes.len() as u64, Ordering::Relaxed); + + let row = match serde_json::from_slice::(&raw.bytes) { + Ok(mut value) => { + let event = value.get_mut("event").map(Value::take); + let state = value.get_mut("state").map(Value::take); + + if let Some(state) = state + && previous_state.as_ref() != Some(&state) + { + state_id += 1; + write_state(states_dir, state_id, &state); + counters.states.fetch_add(1, Ordering::Relaxed); + + if let Some(discovery) = discovery { + let _ = discovery.send(Discovery::State(Arc::new(state.clone()))); + } + + previous_state = Some(state); + } + + EventRow { + seq: raw.seq, + recv_wall_us: raw.recv_wall_us, + recv_mach_ns: raw.recv_mach_ns, + event, + state_id: (state_id > 0).then_some(state_id), + parse_error: None, + raw: None, + } + } + Err(error) => { + counters.parse_errors.fetch_add(1, Ordering::Relaxed); + EventRow { + seq: raw.seq, + recv_wall_us: raw.recv_wall_us, + recv_mach_ns: raw.recv_mach_ns, + event: None, + state_id: (state_id > 0).then_some(state_id), + parse_error: Some(error.to_string()), + raw: Some(String::from_utf8_lossy(&raw.bytes).into_owned()), + } + } + }; + + if let Ok(line) = serde_json::to_string(&row) { + let _ = writeln!(events, "{line}"); + } + + // events are sparse; keep the file current so a crash loses nothing + if rx.is_empty() { + let _ = events.flush(); + } + } + + let _ = events.flush(); +} + +fn write_state(states_dir: &Path, id: u64, state: &Value) { + let path = states_dir.join(format!("{id:06}.json")); + if let Ok(text) = serde_json::to_string(state) { + let _ = std::fs::write(path, text); + } +} + +/// Asks komorebi for its current `State`, writes the reply to `path` as komorebi sent it, and +/// returns the parsed value. +/// +/// An empty reply is asked for again a few times (see [`crate::komorebi_link::query_state`]). The +/// file is written only after the reply has been parsed, so a reply that is not JSON leaves no +/// file behind. This is how the recorder writes `state-start.json` and `state-end.json`. +/// +/// This talks to a running komorebi, so it has no doctest. The query and its retries are covered +/// by `an_empty_state_reply_is_asked_again` in [`crate::komorebi_link`]'s unit tests. +/// +/// # Errors +/// +/// Returns an error if +/// +/// - komorebi cannot be reached (it is not running, or its socket cannot be read), +/// - komorebi answers with nothing every time it is asked (on komorebi for Windows it does that +/// while it is busy, paused, or showing another virtual desktop), +/// - the reply is not valid JSON, or +/// - `path` cannot be written. +/// +/// Asking again later is the workaround for the first two. +pub fn snapshot_state(path: &Path) -> eyre::Result { + let response = komorebi_link::query_state().map_err(|error| eyre::Report::new(error))?; + let value: Value = + serde_json::from_str(&response).wrap_err("komorebi returned invalid state JSON")?; + std::fs::write(path, &response)?; + Ok(value) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn state_ids_map_to_ordered_file_names() { + assert_eq!(state_file_name(1), "states/000001.json"); + assert_eq!(state_file_name(123_456), "states/123456.json"); + } + + #[test] + fn event_rows_roundtrip_and_skip_empty_fields() { + let row = EventRow { + seq: 3, + recv_wall_us: 10, + recv_mach_ns: 20, + event: Some(serde_json::json!({"type": "Show"})), + state_id: Some(2), + parse_error: None, + raw: None, + }; + let text = serde_json::to_string(&row).expect("serialize"); + assert!(!text.contains("parse_error")); + let back: EventRow = serde_json::from_str(&text).expect("parse"); + assert_eq!(back.seq, 3); + assert_eq!(back.state_id, Some(2)); + } + + #[test] + fn the_writer_dedupes_unchanged_states_and_tolerates_garbage() { + let dir = + std::env::temp_dir().join(format!("komorebi-recorder-events-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("states")).expect("dir"); + + let (tx, rx) = crossbeam_channel::unbounded(); + let notification = |seq: u64, state: &str| RawNotification { + seq, + recv_wall_us: seq as i64, + recv_mach_ns: seq * 1000, + bytes: format!(r#"{{"event":{{"type":"Show","content":[null,1]}},"state":{state}}}"#) + .into_bytes(), + }; + + tx.send(notification(1, r#"{"a":1}"#)).expect("send"); + tx.send(notification(2, r#"{"a":1}"#)).expect("send"); + tx.send(notification(3, r#"{"a":2}"#)).expect("send"); + tx.send(RawNotification { + seq: 4, + recv_wall_us: 4, + recv_mach_ns: 4000, + bytes: b"not json".to_vec(), + }) + .expect("send"); + drop(tx); + + let counters = EventCounters::default(); + write_loop( + &rx, + File::create(dir.join("events.jsonl")).expect("file"), + &dir.join("states"), + &counters, + None, + ); + + assert_eq!(counters.events.load(Ordering::Relaxed), 4); + assert_eq!( + counters.states.load(Ordering::Relaxed), + 2, + "an unchanged state is not stored twice" + ); + assert_eq!(counters.parse_errors.load(Ordering::Relaxed), 1); + + let rows: Vec = std::fs::read_to_string(dir.join("events.jsonl")) + .expect("events") + .lines() + .map(|line| serde_json::from_str(line).expect("row")) + .collect(); + assert_eq!( + rows.iter().map(|row| row.state_id).collect::>(), + vec![Some(1), Some(1), Some(2), Some(2)] + ); + assert!(rows[3].parse_error.is_some()); + assert_eq!(rows[3].raw.as_deref(), Some("not json")); + + assert!(dir.join("states/000001.json").exists()); + assert!(dir.join("states/000002.json").exists()); + assert!(!dir.join("states/000003.json").exists()); + } +} diff --git a/komorebi-recorder/src/sources/mod.rs b/komorebi-recorder/src/sources/mod.rs new file mode 100644 index 00000000..5c870fba --- /dev/null +++ b/komorebi-recorder/src/sources/mod.rs @@ -0,0 +1,34 @@ +//! The non-video capture sources: what komorebi and the operating system said while a recording +//! ran. +//! +//! Each source writes its own raw files into the session directory and never rewrites them +//! afterwards. The merged index over them (`timeline.jsonl`) only points into these files, so the +//! file names and record formats written here are part of the session format: a session recorded +//! by any build is read by every other, and a session recorded on one host is investigated on the +//! other. +//! +//! | module | files it writes | recorded on | +//! |---|---|---| +//! | [`komorebi_events`] | `events.jsonl` (one JSON object per line), `states/.json` | both hosts | +//! +//! The types that a source reports about itself to the manifest (`session.json`) are defined +//! beside it: `EventSummary`, `LogSegment`, `StreamInfo`, `KomorebiVersion` and `AppRecord`. +//! +//! # What this layer does not do +//! +//! - It does not understand komorebi's vocabulary. A source copies bytes and stamps times; reading +//! an event or a log line is the job of [`crate::analysis`], through the rules of the +//! [`Flavor`](crate::analysis::flavor::Flavor) that the manifest records. +//! - It does not index or merge. `timeline` builds the index when a recording ends, and `reindex` +//! can rebuild it from these files alone. +//! - It does not decide when to start or stop. The `recorder` starts every source, checkpoints +//! their counters into the manifest and stops them in order. +//! +//! # Clocks +//! +//! A source stamps what it receives on the master clock ([`crate::clock`]) wherever komorebi's own +//! text carries no time of its own. The `*_mach_ns` names in the records keep their historical +//! spelling: they hold nanoseconds on the host's master monotonic clock, and the manifest's +//! `host.clock` says which clock that was. + +pub mod komorebi_events; From 4724d831ba0076182ac3fa8f133c044fe525733e Mon Sep 17 00:00:00 2001 From: LGUG2Z Date: Tue, 27 Oct 2026 06:14:18 -0700 Subject: [PATCH 008/108] feat(session): the session directory A session is a directory and its manifest, `session.json`: the host, the komorebi flavor, the clock anchors, the log segments and the applications seen, written atomically. komorebi's own log is recorded as the exact byte range written while recording (`%TEMP%` on Windows, its data directory on a Mac), together with the version of the komorebi that wrote it. A generated README in each session says what every file is, quoting paths the way the host's shell reads them. --- Cargo.lock | 205 +++ dependencies.json | 16 +- docs/ARCHITECTURE.md | 14 + komorebi-recorder/Cargo.toml | 20 + komorebi-recorder/src/analysis/events.rs | 100 ++ komorebi-recorder/src/analysis/flavor/mod.rs | 9 + komorebi-recorder/src/lib.rs | 2 + komorebi-recorder/src/platform/macos/apps.rs | 95 ++ komorebi-recorder/src/platform/macos/mod.rs | 2 + .../src/platform/macos/process.rs | 58 + komorebi-recorder/src/platform/mod.rs | 4 + komorebi-recorder/src/platform/unsupported.rs | 35 + komorebi-recorder/src/platform/win/apps.rs | 102 ++ komorebi-recorder/src/platform/win/mod.rs | 2 + komorebi-recorder/src/platform/win/process.rs | 43 + komorebi-recorder/src/session.rs | 738 +++++++++++ .../src/sources/komorebi_events.rs | 8 + komorebi-recorder/src/sources/komorebi_log.rs | 1000 +++++++++++++++ .../src/sources/komorebi_version.rs | 252 ++++ komorebi-recorder/src/sources/managed_apps.rs | 825 ++++++++++++ komorebi-recorder/src/sources/mod.rs | 6 + komorebi-recorder/tests/fixtures/state.json | 1133 +++++++++++++++++ 22 files changed, 4668 insertions(+), 1 deletion(-) create mode 100644 komorebi-recorder/src/platform/macos/apps.rs create mode 100644 komorebi-recorder/src/platform/macos/process.rs create mode 100644 komorebi-recorder/src/platform/win/apps.rs create mode 100644 komorebi-recorder/src/platform/win/process.rs create mode 100644 komorebi-recorder/src/session.rs create mode 100644 komorebi-recorder/src/sources/komorebi_log.rs create mode 100644 komorebi-recorder/src/sources/komorebi_version.rs create mode 100644 komorebi-recorder/src/sources/managed_apps.rs create mode 100644 komorebi-recorder/tests/fixtures/state.json diff --git a/Cargo.lock b/Cargo.lock index e4217cf2..6a2abfd5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + [[package]] name = "anstream" version = "1.0.0" @@ -100,12 +109,41 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "cc" +version = "1.2.61" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +dependencies = [ + "find-msvc-tools", + "shlex", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + [[package]] name = "clap" version = "4.6.1" @@ -180,6 +218,12 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "crossbeam-channel" version = "0.5.15" @@ -254,12 +298,42 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + [[package]] name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -311,6 +385,30 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "id-arena" version = "2.3.0" @@ -347,15 +445,30 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.97" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1840c94c045fbcf8ba2812c95db44499f7c64910a912551aaaa541decebcacf" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + [[package]] name = "komorebi-recorder" version = "0.1.0" dependencies = [ + "chrono", "clap", "color-eyre", "crossbeam-channel", "dirs", "libc", + "objc2-core-foundation", + "objc2-core-graphics", "schemars", "serde", "serde_json_lenient", @@ -426,6 +539,35 @@ dependencies = [ "adler2", ] +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags", + "libc", + "objc2-core-foundation", +] + [[package]] name = "object" version = "0.37.3" @@ -549,6 +691,12 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "ryu" version = "1.0.23" @@ -661,6 +809,18 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "strsim" version = "0.11.1" @@ -830,6 +990,51 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df52b6d9b87e0c74c9edfa1eb2d9bf85e5d63515474513aa50fa181b3c4f5db1" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78b1041f495fb322e64aca85f5756b2172e35cd459376e67f2a6c9dffcedb103" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dcd0ff20416988a18ac686d4d4d0f6aae9ebf08a389ff5d29012b05af2a1b41" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.120" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49757b3c82ebf16c57d69365a142940b384176c24df52a087fb748e2085359ea" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" diff --git a/dependencies.json b/dependencies.json index e5d20f62..d95fe1eb 100644 --- a/dependencies.json +++ b/dependencies.json @@ -20,6 +20,7 @@ "backtrace 0.3.76 registry+https://github.com/rust-lang/crates.io-index", "bitflags 2.11.1 registry+https://github.com/rust-lang/crates.io-index", "cfg-if 1.0.4 registry+https://github.com/rust-lang/crates.io-index", + "chrono 0.4.44 registry+https://github.com/rust-lang/crates.io-index", "clap 4.6.1 registry+https://github.com/rust-lang/crates.io-index", "clap_builder 4.6.0 registry+https://github.com/rust-lang/crates.io-index", "clap_derive 4.6.1 registry+https://github.com/rust-lang/crates.io-index", @@ -27,6 +28,7 @@ "color-eyre 0.6.5 registry+https://github.com/rust-lang/crates.io-index", "color-spantrace 0.3.0 registry+https://github.com/rust-lang/crates.io-index", "colorchoice 1.0.5 registry+https://github.com/rust-lang/crates.io-index", + "core-foundation-sys 0.8.7 registry+https://github.com/rust-lang/crates.io-index", "crossbeam-channel 0.5.15 registry+https://github.com/rust-lang/crates.io-index", "crossbeam-utils 0.8.21 registry+https://github.com/rust-lang/crates.io-index", "dirs 6.0.0 registry+https://github.com/rust-lang/crates.io-index", @@ -38,12 +40,16 @@ "getrandom 0.4.2 registry+https://github.com/rust-lang/crates.io-index", "gimli 0.32.3 registry+https://github.com/rust-lang/crates.io-index", "heck 0.5.0 registry+https://github.com/rust-lang/crates.io-index", + "iana-time-zone 0.1.65 registry+https://github.com/rust-lang/crates.io-index", "indenter 0.3.4 registry+https://github.com/rust-lang/crates.io-index", "is_terminal_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", "itoa 1.0.18 registry+https://github.com/rust-lang/crates.io-index", "lazy_static 1.5.0 registry+https://github.com/rust-lang/crates.io-index", "libc 0.2.186 registry+https://github.com/rust-lang/crates.io-index", "miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index", + "num-traits 0.2.19 registry+https://github.com/rust-lang/crates.io-index", + "objc2-core-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index", + "objc2-core-graphics 0.3.2 registry+https://github.com/rust-lang/crates.io-index", "object 0.37.3 registry+https://github.com/rust-lang/crates.io-index", "once_cell 1.21.4 registry+https://github.com/rust-lang/crates.io-index", "once_cell_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", @@ -119,6 +125,7 @@ "backtrace 0.3.76 registry+https://github.com/rust-lang/crates.io-index", "bitflags 2.11.1 registry+https://github.com/rust-lang/crates.io-index", "cfg-if 1.0.4 registry+https://github.com/rust-lang/crates.io-index", + "chrono 0.4.44 registry+https://github.com/rust-lang/crates.io-index", "clap 4.6.1 registry+https://github.com/rust-lang/crates.io-index", "clap_builder 4.6.0 registry+https://github.com/rust-lang/crates.io-index", "clap_derive 4.6.1 registry+https://github.com/rust-lang/crates.io-index", @@ -126,6 +133,7 @@ "color-eyre 0.6.5 registry+https://github.com/rust-lang/crates.io-index", "color-spantrace 0.3.0 registry+https://github.com/rust-lang/crates.io-index", "colorchoice 1.0.5 registry+https://github.com/rust-lang/crates.io-index", + "core-foundation-sys 0.8.7 registry+https://github.com/rust-lang/crates.io-index", "crossbeam-channel 0.5.15 registry+https://github.com/rust-lang/crates.io-index", "crossbeam-utils 0.8.21 registry+https://github.com/rust-lang/crates.io-index", "dirs 6.0.0 registry+https://github.com/rust-lang/crates.io-index", @@ -137,6 +145,7 @@ "getrandom 0.4.2 registry+https://github.com/rust-lang/crates.io-index", "gimli 0.32.3 registry+https://github.com/rust-lang/crates.io-index", "heck 0.5.0 registry+https://github.com/rust-lang/crates.io-index", + "iana-time-zone 0.1.65 registry+https://github.com/rust-lang/crates.io-index", "indenter 0.3.4 registry+https://github.com/rust-lang/crates.io-index", "is_terminal_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", "itoa 1.0.18 registry+https://github.com/rust-lang/crates.io-index", @@ -145,6 +154,9 @@ "memchr 2.8.0 registry+https://github.com/rust-lang/crates.io-index", "memoffset 0.9.1 registry+https://github.com/rust-lang/crates.io-index", "miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index", + "num-traits 0.2.19 registry+https://github.com/rust-lang/crates.io-index", + "objc2-core-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index", + "objc2-core-graphics 0.3.2 registry+https://github.com/rust-lang/crates.io-index", "object 0.37.3 registry+https://github.com/rust-lang/crates.io-index", "once_cell 1.21.4 registry+https://github.com/rust-lang/crates.io-index", "once_cell_polyfill 1.70.2 registry+https://github.com/rust-lang/crates.io-index", @@ -219,7 +231,9 @@ [ "Zlib", [ - "miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index" + "miniz_oxide 0.8.9 registry+https://github.com/rust-lang/crates.io-index", + "objc2-core-foundation 0.3.2 registry+https://github.com/rust-lang/crates.io-index", + "objc2-core-graphics 0.3.2 registry+https://github.com/rust-lang/crates.io-index" ] ] ] diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7db49510..45b48568 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -63,6 +63,18 @@ refuses an implementor that misses one. A method with a default is a question mo doc of each says what the default is. `Rules::verified` is how a flavor says that its rules were written from komorebi's source and have not been checked against a recording of it: an analysis that reads such a session says so. +## The session + +A session is a directory. Its manifest, `session.json`, is the one file everything else is read through: the host and the +flavor, the clock anchors, the komorebi processes that were seen and how each was logging, the byte ranges of komorebi's +log that were recorded and the applications that were traced. It is written to a temporary file and renamed, so a reader +or a crash never sees half of one, and its `schema_version` is checked on every read: a manifest written by a newer +build is refused with an error that says so, rather than read wrongly. + +The files beside it are the raw records, and they are written as the data arrives and never rewritten: komorebi's log +(`komorebi.log`, the exact byte range recorded), its events (`events.jsonl`, with full `State` snapshots in `states/` +only when they changed), and what happened to the recording itself (`recorder.jsonl`). + ## Rules and the layer that enforces them | Rule | Enforced by | @@ -73,3 +85,5 @@ source and have not been checked against a recording of it: an analysis that rea | Code that reads komorebi's output asks `Rules` and does not match on `Flavor` | convention only | | Rules that were not checked against a recording of komorebi say so | the framework path: an analysis that reads such a session adds a warning | | A running recorder can be told from one that died | the OS: an exclusive lock on `recorder.lock`, held for as long as the recorder runs and dropped by the OS when the process ends however it ends | +| A manifest from a newer build is not read | a runtime error when the manifest is read, covered by `rejects_manifests_from_the_future` | +| A manifest is never half-written | write to a temporary file and rename | diff --git a/komorebi-recorder/Cargo.toml b/komorebi-recorder/Cargo.toml index 072b9166..9373c315 100644 --- a/komorebi-recorder/Cargo.toml +++ b/komorebi-recorder/Cargo.toml @@ -6,6 +6,7 @@ edition.workspace = true repository.workspace = true [dependencies] +chrono = { workspace = true } clap = { workspace = true } color-eyre = { workspace = true } crossbeam-channel = { workspace = true } @@ -16,6 +17,25 @@ serde_json = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] libc = "0.2" +objc2-core-foundation = { version = "0.3", default-features = false, features = [ + "std", + "CFArray", + "CFDictionary", + "CFNumber", + "CFString", + "CFCGTypes", +] } +objc2-core-graphics = { version = "0.3", default-features = false, features = [ + "std", + "libc", + "CGDirectDisplay", + "CGDisplayConfiguration", + "CGError", + "CGGeometry", + "CGRemoteOperation", + "CGSession", + "CGWindow", +] } [target.'cfg(windows)'.dependencies] uds_windows = "1" diff --git a/komorebi-recorder/src/analysis/events.rs b/komorebi-recorder/src/analysis/events.rs index eb621225..744c5986 100644 --- a/komorebi-recorder/src/analysis/events.rs +++ b/komorebi-recorder/src/analysis/events.rs @@ -20,7 +20,10 @@ //! that produced it is done by `blocks::pair`. Both read the events this module returns. use super::flavor::Rules; +use crate::session::Session; use crate::sources::komorebi_events::EventRow; +use color_eyre::eyre; +use color_eyre::eyre::WrapErr; use schemars::JsonSchema; use serde::Serialize; use serde_json::Value; @@ -234,3 +237,100 @@ impl EventRecord { } } } + +/// Returns every event of a session, in the order they were received, with the warnings from +/// reading them. +/// +/// The events are the lines of the session's `events.jsonl`, read with `rules` and sorted by +/// `recv_mach_ns`, then by `seq`. A session with no `events.jsonl` has no events, which is not an +/// error. A line that is not a valid row (as opposed to a row whose notification could not be +/// parsed, which is kept as an unreadable event) is skipped, and the warnings say how many were: +/// `N unreadable lines of events.jsonl were skipped`. Blank lines are ignored silently. +/// +/// # Errors +/// +/// Returns an error if `events.jsonl` exists and cannot be read: `could not read `, with +/// the I/O error as its cause. A missing file is not one of those cases. +/// +/// # Example +/// +/// Reading a session needs a session directory on disk, so this example is only compiled. It is +/// covered by `loading_sorts_by_receipt_and_skips_what_it_cannot_read` in this module's tests. +/// +/// ```rust,no_run +/// use komorebi_recorder::analysis::events; +/// use komorebi_recorder::analysis::flavor::Flavor; +/// use komorebi_recorder::session::Session; +/// +/// let session = Session::open("/path/to/session")?; +/// let (records, warnings) = events::load(&session, Flavor::Macos.rules())?; +/// for record in &records { +/// println!("#{} {}", record.seq, record.info.summary()); +/// } +/// assert!(warnings.is_empty()); +/// # Ok::<(), Box>(()) +/// ``` +pub fn load(session: &Session, rules: &dyn Rules) -> eyre::Result<(Vec, Vec)> { + let path = session.path("events.jsonl"); + let text = match std::fs::read_to_string(&path) { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + return Ok((Vec::new(), Vec::new())); + } + Err(error) => { + return Err(error).wrap_err_with(|| format!("could not read {}", path.display())); + } + }; + + let mut records = Vec::new(); + let mut skipped = 0_usize; + for line in text.lines().filter(|line| !line.trim().is_empty()) { + match serde_json::from_str::(line) { + Ok(row) => records.push(EventRecord::from_row(&row, rules)), + Err(_) => skipped += 1, + } + } + + records.sort_by_key(|record| (record.recv_mach_ns, record.seq)); + let warnings = if skipped > 0 { + vec![format!( + "{skipped} unreadable lines of events.jsonl were skipped" + )] + } else { + Vec::new() + }; + Ok((records, warnings)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::analysis::flavor::Flavor; + + #[test] + fn loading_sorts_by_receipt_and_skips_what_it_cannot_read() { + let dir = + std::env::temp_dir().join(format!("komorebi-recorder-events-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let session = Session::create(Some(dir.clone()), chrono::Utc::now()).expect("create"); + std::fs::write( + session.path("events.jsonl"), + concat!( + "{\"seq\":2,\"recv_wall_us\":0,\"recv_mach_ns\":20,\"event\":{\"type\":\"Retile\"}}\n", + "garbage\n", + "{\"seq\":1,\"recv_wall_us\":0,\"recv_mach_ns\":10,\"state_id\":4}\n", + ), + ) + .expect("events"); + + let (records, warnings) = load(&session, Flavor::Macos.rules()).expect("load"); + assert_eq!( + records.iter().map(|record| record.seq).collect::>(), + [1, 2] + ); + assert_eq!(records[0].state_id, Some(4)); + assert_eq!(records[0].info.family, Family::Unknown); + assert_eq!(records[1].info.name, "Retile"); + assert_eq!(warnings.len(), 1); + } +} diff --git a/komorebi-recorder/src/analysis/flavor/mod.rs b/komorebi-recorder/src/analysis/flavor/mod.rs index a4ea7a19..e7fab16a 100644 --- a/komorebi-recorder/src/analysis/flavor/mod.rs +++ b/komorebi-recorder/src/analysis/flavor/mod.rs @@ -484,6 +484,15 @@ pub trait Rules: Sync { mod tests { use super::*; + #[test] + fn a_session_with_no_flavor_recorded_is_komorebi_for_mac() { + let info: crate::session::KomorebiInfo = serde_json::from_str( + r#"{"running":false,"pid":null,"observed_levels":[],"data_dir":""}"#, + ) + .expect("an old manifest's komorebi section"); + assert_eq!(info.flavor, Flavor::Macos); + } + #[test] fn flavors_are_spelled_in_lowercase_and_the_host_has_one() { assert_eq!( diff --git a/komorebi-recorder/src/lib.rs b/komorebi-recorder/src/lib.rs index 3fc0f631..b6fed000 100644 --- a/komorebi-recorder/src/lib.rs +++ b/komorebi-recorder/src/lib.rs @@ -47,6 +47,7 @@ //! | [`incidents`] | what happened to the recording itself, as a log of its own | //! | [`lock`] | telling a running recorder from one that died | //! | [`sources`] | the sources that write raw files into the session | +//! | [`session`] | the session directory and its manifest | //! //! # What is not supported //! @@ -65,5 +66,6 @@ pub mod incidents; pub mod komorebi_link; pub mod lock; pub mod platform; +pub mod session; pub mod sources; pub mod timecode; diff --git a/komorebi-recorder/src/platform/macos/apps.rs b/komorebi-recorder/src/platform/macos/apps.rs new file mode 100644 index 00000000..c5a62530 --- /dev/null +++ b/komorebi-recorder/src/platform/macos/apps.rs @@ -0,0 +1,95 @@ +//! Which process owns a window id, from the window server. +//! +//! [`window_owners`] calls `CGWindowListCopyWindowInfo(OptionAll, ...)`. `OptionAll` matters: +//! komorebi's own wrapper only lists on-screen windows, which would miss every window on another +//! space. +//! +//! The same function exists on Windows, where it enumerates top-level windows and a window id is a +//! window handle; see [`window_owners`] for how they differ. + +use crate::sources::managed_apps::WindowOwner; +use objc2_core_foundation::CFArray; +use objc2_core_foundation::CFDictionary; +use objc2_core_foundation::CFNumber; +use objc2_core_foundation::CFString; +use objc2_core_graphics::CGWindowListCopyWindowInfo; +use objc2_core_graphics::CGWindowListOption; +use objc2_core_graphics::kCGNullWindowID; +use objc2_core_graphics::kCGWindowLayer; +use objc2_core_graphics::kCGWindowNumber; +use objc2_core_graphics::kCGWindowOwnerName; +use objc2_core_graphics::kCGWindowOwnerPID; +use std::collections::HashMap; +use std::ptr::NonNull; + +fn dictionary_value(dictionary: &CFDictionary, key: &CFString) -> Option> { + // SAFETY: `key` is a valid CFString and the dictionary is live + let value = unsafe { CFDictionary::value(dictionary, NonNull::from(key).as_ptr().cast()) }; + NonNull::new(value.cast_mut()).map(NonNull::cast) +} + +/// Returns every window the window server knows about (all spaces, on-screen or not), keyed by +/// window id, with the process that owns each. +/// +/// The key is the window server's window number, which is what komorebi for Mac calls a window's +/// `id`. Each [`WindowOwner`] has the owning process id (`kCGWindowOwnerPID`), the owner's name +/// (`kCGWindowOwnerName`, empty if the window server gave none) and the window's layer +/// (`kCGWindowLayer`, `0` for an ordinary application window). A window with no number or no owning +/// process id is left out. The map is empty if the window server cannot be asked, as in a sandboxed +/// build with no window server. +/// +/// # Host differences +/// +/// On Windows the keys are window handles (`HWND` values), the name is the owning program's file +/// name without `.exe`, and the layer is always `0`. On a host that can only investigate a session +/// the map is always empty. +/// +/// This asks the machine it runs on, so it has no doctest. It is covered by +/// `window_server_listing_works` in the unit tests of `crate::sources::managed_apps`. +#[must_use] +pub fn window_owners() -> HashMap { + let mut owners = HashMap::new(); + let Some(list) = CGWindowListCopyWindowInfo(CGWindowListOption::OptionAll, kCGNullWindowID) + else { + return owners; + }; + + let list: &CFArray = &list; + for index in 0..list.count() { + // SAFETY: every element of the window list is a CFDictionary, and `index` is in bounds + let Some(dictionary) = + (unsafe { list.value_at_index(index).cast::().as_ref() }) + else { + continue; + }; + + // SAFETY: values under these keys have these types per the CGWindow documentation + let (number, pid, name, layer) = unsafe { + ( + dictionary_value::(dictionary, kCGWindowNumber) + .and_then(|number| number.as_ref().as_i64()), + dictionary_value::(dictionary, kCGWindowOwnerPID) + .and_then(|pid| pid.as_ref().as_i32()), + dictionary_value::(dictionary, kCGWindowOwnerName) + .map(|name| name.as_ref().to_string()), + dictionary_value::(dictionary, kCGWindowLayer) + .and_then(|layer| layer.as_ref().as_i64()), + ) + }; + + if let (Some(number), Some(pid)) = (number, pid) + && let Ok(number) = u32::try_from(number) + { + owners.insert( + number, + WindowOwner { + pid, + name: name.unwrap_or_default(), + layer: layer.unwrap_or_default(), + }, + ); + } + } + + owners +} diff --git a/komorebi-recorder/src/platform/macos/mod.rs b/komorebi-recorder/src/platform/macos/mod.rs index 89dd8331..cc43b9cc 100644 --- a/komorebi-recorder/src/platform/macos/mod.rs +++ b/komorebi-recorder/src/platform/macos/mod.rs @@ -14,7 +14,9 @@ //! Nothing here is public API except through the facade: this module is private, and the facade //! re-exports its submodules under the same names on every host. +pub mod apps; pub mod clock; +pub mod process; pub mod shell; /// Does nothing: a Mac needs nothing done once at the start of `main`. (Windows makes the process diff --git a/komorebi-recorder/src/platform/macos/process.rs b/komorebi-recorder/src/platform/macos/process.rs new file mode 100644 index 00000000..f0902661 --- /dev/null +++ b/komorebi-recorder/src/platform/macos/process.rs @@ -0,0 +1,58 @@ +//! Starting and stopping child processes the way macOS wants it. +//! +//! The recorder runs `log stream` children (`crate::sources::oslog`) and, from the recorder window, +//! the viewer. [`isolate`] starts one so that a terminal's Ctrl-C reaches only the recorder, +//! [`terminate`] asks it to stop and [`was_terminated`] tells a child that was asked to stop from +//! one that failed. The same three functions exist on Windows, where the means differ. + +use std::process::Child; +use std::process::Command; +use std::process::ExitStatus; + +/// Puts the child in its own process group, so a terminal Ctrl-C reaches only the recorder, which +/// can then drain and stop these children in order instead of losing their tail. +/// +/// This must be called before the child is spawned. It returns `command` for chaining. On Windows +/// the child also gets no console window of its own. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::platform::process::{isolate, terminate, was_terminated}; +/// use std::process::Command; +/// +/// let mut command = Command::new("sleep"); +/// command.arg("30"); +/// let mut child = isolate(&mut command).spawn()?; +/// +/// // asked to stop with SIGTERM, which `was_terminated` recognizes +/// terminate(&mut child); +/// let status = child.wait()?; +/// assert!(!status.success()); +/// assert!(was_terminated(&status)); +/// # Ok::<(), Box>(()) +/// ``` +pub fn isolate(command: &mut Command) -> &mut Command { + std::os::unix::process::CommandExt::process_group(command, 0) +} + +/// Asks a child to stop, as politely as the OS allows (`SIGTERM`), so it flushes what it buffered. +/// +/// This only sends the signal: it does not wait for the child, so the caller must `wait` for it, +/// and does not report a failure to signal it. On Windows there is no polite signal, and the child +/// is killed. +pub fn terminate(child: &mut Child) { + // SAFETY: signalling a child process we spawned and still own + unsafe { + libc::kill(child.id() as i32, libc::SIGTERM); + } +} + +/// Returns `true` if and only if the child ended because [`terminate`] asked it to (it was +/// killed by `SIGTERM`), which is not a failure. +/// +/// On Windows this is always `false`: Windows reports no signal, so it cannot tell. +#[must_use] +pub fn was_terminated(status: &ExitStatus) -> bool { + std::os::unix::process::ExitStatusExt::signal(status) == Some(libc::SIGTERM) +} diff --git a/komorebi-recorder/src/platform/mod.rs b/komorebi-recorder/src/platform/mod.rs index 0f1ebf38..ed39d858 100644 --- a/komorebi-recorder/src/platform/mod.rs +++ b/komorebi-recorder/src/platform/mod.rs @@ -7,6 +7,8 @@ //! |---|---| //! | [`clock`] | the master monotonic clock and its tick size | //! | [`shell`] | the OS version, and (for the recorder window) showing a folder and asking for one | +//! | [`apps`] | which process owns a window id | +//! | [`process`] | stopping a child process | //! //! A third host, `unsupported`, builds on anything else so that the investigation commands (which //! only read a session directory) still work on a Linux agent box; recording there says so. @@ -42,7 +44,9 @@ mod win; #[cfg(windows)] use win as imp; +pub use imp::apps; pub use imp::clock; +pub use imp::process; pub use imp::shell; /// Things the OS wants done once, first thing in `main`: on Windows, becoming DPI aware so that diff --git a/komorebi-recorder/src/platform/unsupported.rs b/komorebi-recorder/src/platform/unsupported.rs index 28f79691..b23f88f5 100644 --- a/komorebi-recorder/src/platform/unsupported.rs +++ b/komorebi-recorder/src/platform/unsupported.rs @@ -4,6 +4,18 @@ /// Nothing to do first. pub fn init() {} +/// Which process owns a window: nothing is known on this host. +pub mod apps { + use crate::sources::managed_apps::WindowOwner; + use std::collections::HashMap; + + /// Returns no windows: this host has no window server to ask. + #[must_use] + pub fn window_owners() -> HashMap { + HashMap::new() + } +} + /// The master clock, which here is Rust's monotonic [`Instant`]. pub mod clock { use crate::clock::Timebase; @@ -35,6 +47,29 @@ pub mod clock { } } +/// Starting and stopping child processes, with nothing host-specific to do. +pub mod process { + use std::process::Child; + use std::process::Command; + use std::process::ExitStatus; + + /// Returns `command` as it is: there is nothing to isolate it with. + pub fn isolate(command: &mut Command) -> &mut Command { + command + } + + /// Kills `child`. + pub fn terminate(child: &mut Child) { + let _ = child.kill(); + } + + /// Returns `false`: this host reports no signal. + #[must_use] + pub fn was_terminated(_status: &ExitStatus) -> bool { + false + } +} + /// Showing a folder, asking for one, and naming the OS, with what this host can offer. pub mod shell { use std::path::Path; diff --git a/komorebi-recorder/src/platform/win/apps.rs b/komorebi-recorder/src/platform/win/apps.rs new file mode 100644 index 00000000..bc170c9b --- /dev/null +++ b/komorebi-recorder/src/platform/win/apps.rs @@ -0,0 +1,102 @@ +//! Which process owns a window id: `GetWindowThreadProcessId` on its handle. +//! +//! komorebi for Windows identifies a window by its handle, so unlike on macOS the window list is +//! only needed to find out who owns each handle and what that process is called. [`window_owners`] +//! is the same function that macOS provides. + +use crate::sources::managed_apps::WindowOwner; +use std::collections::HashMap; +use windows::Win32::Foundation::CloseHandle; +use windows::Win32::Foundation::HWND; +use windows::Win32::Foundation::LPARAM; +use windows::Win32::System::Threading::OpenProcess; +use windows::Win32::System::Threading::PROCESS_NAME_WIN32; +use windows::Win32::System::Threading::PROCESS_QUERY_LIMITED_INFORMATION; +use windows::Win32::System::Threading::QueryFullProcessImageNameW; +use windows::Win32::UI::WindowsAndMessaging::EnumWindows; +use windows::Win32::UI::WindowsAndMessaging::GetWindowThreadProcessId; +use windows::core::BOOL; +use windows::core::PWSTR; + +/// The file name of the program a process is running, without `.exe`. +pub(crate) fn process_name(pid: u32) -> Option { + // SAFETY: the handle is closed again, and the buffer outlives the call that fills it + unsafe { + let process = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid).ok()?; + let mut buffer = [0_u16; 1024]; + let mut length = buffer.len() as u32; + let queried = QueryFullProcessImageNameW( + process, + PROCESS_NAME_WIN32, + PWSTR(buffer.as_mut_ptr()), + &raw mut length, + ); + let _ = CloseHandle(process); + queried.ok()?; + + let path = String::from_utf16_lossy(&buffer[..length as usize]); + let file = path.rsplit(['\\', '/']).next()?; + Some( + file.strip_suffix(".exe") + .or_else(|| file.strip_suffix(".EXE")) + .unwrap_or(file) + .to_string(), + ) + } +} + +unsafe extern "system" fn collect(window: HWND, found: LPARAM) -> BOOL { + // SAFETY: `found` is the address of the vector `window_owners` passed to `EnumWindows` + let found = unsafe { &mut *(found.0 as *mut Vec<(u32, u32)>) }; + let mut pid = 0_u32; + // SAFETY: `window` is a handle `EnumWindows` just gave us + let thread = unsafe { GetWindowThreadProcessId(window, Some(&raw mut pid)) }; + if thread != 0 && pid != 0 { + // a window handle is a 32-bit value even in a 64-bit process + found.push((window.0 as usize as u32, pid)); + } + + BOOL(1) +} + +/// Returns every top-level window that `EnumWindows` reports, keyed by its handle, with the process +/// that owns each. +/// +/// The key is the window handle (`HWND`) as a `u32`: a handle holds a 32-bit value even in a +/// 64-bit process, and it is the number komorebi for Windows calls a window's `hwnd`. Each +/// [`WindowOwner`] has the owning process id (`0` if it does not fit in an `i32`), the program's +/// file name without `.exe` (empty if the process cannot be opened for a query) and the layer, +/// which is always `0`. A window whose owner cannot be found is left out. The program's name is +/// looked up once for each process. +/// +/// # Host differences +/// +/// On macOS the keys are the window server's window numbers, the name is the owner name the window +/// server gives, the layer is the window's real layer, and windows on other spaces are included. +/// +/// This asks the machine it runs on, so it has no doctest; nothing tests it automatically. +#[must_use] +pub fn window_owners() -> HashMap { + let mut found: Vec<(u32, u32)> = Vec::new(); + // SAFETY: `found` outlives the call and `collect` only touches it during it + let _ = unsafe { EnumWindows(Some(collect), LPARAM(&raw mut found as isize)) }; + + let mut names: HashMap = HashMap::new(); + found + .into_iter() + .map(|(handle, pid)| { + let name = names + .entry(pid) + .or_insert_with(|| process_name(pid).unwrap_or_default()) + .clone(); + ( + handle, + WindowOwner { + pid: i32::try_from(pid).unwrap_or(0), + name, + layer: 0, + }, + ) + }) + .collect() +} diff --git a/komorebi-recorder/src/platform/win/mod.rs b/komorebi-recorder/src/platform/win/mod.rs index bffd4cbe..1c946bfe 100644 --- a/komorebi-recorder/src/platform/win/mod.rs +++ b/komorebi-recorder/src/platform/win/mod.rs @@ -21,7 +21,9 @@ //! `just check-windows`, and the checklist for running it on a real machine is //! `docs/dev/windows-checkpoints.md`. +pub mod apps; pub mod clock; +pub mod process; pub mod shell; /// Does what Windows needs once, first thing in `main`: makes the process per-monitor DPI aware diff --git a/komorebi-recorder/src/platform/win/process.rs b/komorebi-recorder/src/platform/win/process.rs new file mode 100644 index 00000000..1c49c698 --- /dev/null +++ b/komorebi-recorder/src/platform/win/process.rs @@ -0,0 +1,43 @@ +//! Starting and stopping child processes the way Windows wants it. +//! +//! The recorder window launches the viewer, and ffmpeg is run for some video work. [`isolate`] +//! starts a child so that a terminal's Ctrl-C reaches only the recorder and no console window +//! flashes, [`terminate`] stops it and [`was_terminated`] tells a child that was stopped from one +//! that failed. The same three functions exist on macOS, where a child can be asked to stop. + +use std::os::windows::process::CommandExt; +use std::process::Child; +use std::process::Command; +use std::process::ExitStatus; + +const CREATE_NEW_PROCESS_GROUP: u32 = 0x0000_0200; +const CREATE_NO_WINDOW: u32 = 0x0800_0000; + +/// Starts the child in its own process group, so a terminal Ctrl-C reaches only the recorder, and +/// without a console window of its own, so that the recorder window launching `ffmpeg` does not +/// flash one. +/// +/// This must be called before the child is spawned (it sets the creation flags +/// `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW`, replacing any set before). It returns `command` +/// for chaining. +pub fn isolate(command: &mut Command) -> &mut Command { + command.creation_flags(CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW) +} + +/// Stops a child, without giving it a chance to flush. +/// +/// Windows has no polite signal for a process without a window, so this is `TerminateProcess`: +/// whatever the child had buffered is lost. A failure to stop it is not reported. On macOS the +/// child is sent `SIGTERM` and can flush. +pub fn terminate(child: &mut Child) { + let _ = child.kill(); +} + +/// Returns `true` if and only if the child ended because [`terminate`] asked it to. +/// +/// Windows reports no signal, so this cannot tell, and always returns `false`. On macOS it is +/// `true` for a child that was stopped by the signal [`terminate`] sends. +#[must_use] +pub fn was_terminated(_status: &ExitStatus) -> bool { + false +} diff --git a/komorebi-recorder/src/session.rs b/komorebi-recorder/src/session.rs new file mode 100644 index 00000000..11250b9f --- /dev/null +++ b/komorebi-recorder/src/session.rs @@ -0,0 +1,738 @@ +//! The on-disk session directory and its manifest (`session.json`). +//! +//! Raw data is never duplicated or rewritten: `timeline.jsonl` only *references* byte ranges in +//! the raw files, so streams stay separate and can be reconciled or re-indexed later. + +use crate::analysis::flavor::Flavor; +use crate::clock::Anchor; +use crate::clock::Timebase; +use crate::platform::Host; +use crate::sources::komorebi_events::EventSummary; +use crate::sources::komorebi_log::DetectedLevel; +use crate::sources::komorebi_log::LogSegment; +use crate::sources::komorebi_version::KomorebiVersion; +use crate::sources::managed_apps::AppRecord; +use chrono::DateTime; +use chrono::Utc; +use color_eyre::eyre; +use color_eyre::eyre::WrapErr; +use serde::Deserialize; +use serde::Serialize; +#[cfg(test)] +use serde_json::Value; +use std::path::Path; +use std::path::PathBuf; + +/// The version of the manifest format this build writes and the newest it reads. [`Session::manifest`] refuses a +/// manifest with a greater one. +pub const SCHEMA_VERSION: u32 = 1; + +/// The recorder is (or died while) recording; the manifest is a checkpoint. +pub const STATUS_RECORDING: &str = "recording"; +/// Stopped normally and fully indexed. +pub const STATUS_COMPLETE: &str = "complete"; +/// The recorder died; `reindex` rebuilt the session from what was on disk. +pub const STATUS_RECOVERED: &str = "recovered"; + +fn complete_status() -> String { + STATUS_COMPLETE.to_string() +} +/// The name of the manifest in a session's directory. +pub const MANIFEST_FILE: &str = "session.json"; +/// The name of the time-sorted index of every record in a session. It points into the raw files, which it never +/// replaces, so it can be rebuilt from them. +pub const TIMELINE_FILE: &str = "timeline.jsonl"; + +/// A point in time on both clocks. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct Moment { + /// Nanoseconds on the master clock. + /// + pub mach_ns: u64, + /// Microseconds since the Unix epoch on the wall clock, read together with `mach_ns`. + pub wall_us: i64, +} + +impl Moment { + #[must_use] + /// Returns the current time on both clocks, read together as one [`Anchor`]. + pub fn now() -> Self { + let anchor = Anchor::sample(); + Self { + mach_ns: anchor.mach_ns, + wall_us: anchor.wall_us, + } + } +} + +/// What the recorder knew about komorebi: which one it was, whether and as which process it ran, and how it was +/// logging. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct KomorebiInfo { + /// Which komorebi this was: they differ in what their events and log say. Sessions from + /// before this was recorded were all of komorebi for Mac. + #[serde(default)] + pub flavor: Flavor, + /// Whether a komorebi process was found, when the recording started or later in it. + pub running: bool, + /// The komorebi process seen most recently, if any. + pub pid: Option, + /// Every komorebi process seen during the recording, in order (it can be restarted). + #[serde(default)] + pub pids: Vec, + /// The log level komorebi was running at, and how that was found out, for the process in `pid`. + pub log_level: Option, + /// Which komorebi each of `pids` was, as its binary reported: what a log line means depends on + /// the source it was built from. + #[serde(default)] + pub versions: Vec, + /// The name this recorder registered as with komorebi; komorebi's DEBUG log names it when it + /// pushes each notification, which ties an event to its handler exactly. + #[serde(default)] + pub subscriber: Option, + /// Levels that actually appear in the recorded slice of komorebi's log. + pub observed_levels: Vec, + /// komorebi's data directory, where its sockets are. + pub data_dir: String, + /// The directory komorebi's plaintext log was read from, where that is not `data_dir` (komorebi + /// for Windows logs into the temp directory). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_dir: Option, +} + +/// The outcome of the self-test that checks the video against the clocks. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Calibration { + /// How the readings were taken. + pub method: String, + /// The readings the result is computed from. + pub samples: Vec, + /// The largest absolute error among the samples, in milliseconds. + pub max_abs_error_ms: f64, + /// The largest error that still passes, in milliseconds. + pub tolerance_ms: f64, + /// Whether there were samples and `max_abs_error_ms` is within `tolerance_ms`. + pub passed: bool, +} + +/// One reading of the on-screen clock: where in the video it was taken, and what the clock showed against what the +/// session predicted. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CalibrationSample { + /// Where in the video the reading was taken, in seconds. + pub video_s: f64, + /// The master-clock time the session predicts for that moment, at millisecond resolution. + pub predicted_mach_ns: u64, + /// The master-clock time the on-screen clock showed in that frame, at millisecond resolution. + pub observed_mach_ns: u64, + /// Observed minus predicted, in milliseconds, wrapped to the period of the on-screen clock. + pub error_ms: f64, +} + +/// The manifest of a session (`session.json`): everything the other files in the directory need to be read, and the +/// summary of what the recording was. +/// +/// A manifest is written when a recording starts and rewritten at checkpoints while it runs, so one with `status` +/// [`STATUS_RECORDING`] is either a recording in progress or one whose recorder died. [`Manifest::host`] says what it was +/// recorded on, and `komorebi.flavor` which komorebi it recorded. +/// +/// # Example +/// +/// ```rust +/// use komorebi_recorder::clock::Anchor; +/// use komorebi_recorder::session::Manifest; +/// use komorebi_recorder::session::Moment; +/// +/// let anchor = Anchor::sample(); +/// let start = Moment { mach_ns: anchor.mach_ns, wall_us: anchor.wall_us }; +/// let manifest = Manifest::new(start, vec![anchor]); +/// +/// // the first frame was captured a second after the recording started +/// let mut manifest = manifest; +/// manifest.video_t0_mach_ns = Some(start.mach_ns + 1_000_000_000); +/// assert_eq!(manifest.video_seconds(start.mach_ns + 3_500_000_000), 2.5); +/// assert_eq!(manifest.mach_at_video_seconds(2.5), start.mach_ns + 3_500_000_000); +/// ``` +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Manifest { + /// The version of this format ([`SCHEMA_VERSION`]) the manifest was written with. + pub schema_version: u32, + /// `recording`, `complete` or `recovered`. + #[serde(default = "complete_status")] + pub status: String, + /// The version of `komorebi-recorder` that wrote the session. + pub recorder_version: String, + /// What the session was recorded on. Absent in sessions from before Windows support, which + /// were all recorded on a Mac: use [`Manifest::host`], which knows that. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + /// The macOS version of a Mac host. Superseded by `host.os_version`; still written on a Mac so + /// that older builds of the tool read the session, and read from sessions that only have it. + #[serde(default)] + pub macos_version: Option, + /// The komorebi that was recorded. + pub komorebi: KomorebiInfo, + /// What one tick of the host clock is worth (`numer / denom` nanoseconds). + pub timebase: Timebase, + /// Paired readings of the wall and master clocks taken while recording, which map komorebi's log lines onto the + /// master clock (see [`crate::clock::ClockMap`]). + pub anchors: Vec, + /// When the recording started. + pub start: Moment, + /// When the recording stopped, if it did: `None` while it runs and in a session whose recorder died before + /// `reindex` recovered it. + pub stop: Option, + /// Session video t=0 on the mach clock: the earliest first frame across displays. + pub video_t0_mach_ns: Option, + /// When video capture was stopped on the mach clock. + pub video_end_mach_ns: Option, + /// The byte ranges of komorebi's log that were recorded, which together make `komorebi.log`. + pub log_segments: Vec, + /// The applications that own windows komorebi manages, which each get an OS trace stream of their own on a Mac. + pub apps: Vec, + /// What was recorded of komorebi's subscriber events. + pub events: EventSummary, + /// `host-clock` when frame timestamps were verified to be on the mach clock. + pub sync_quality: String, + /// The result of `selftest`, if it was run on this session. + pub calibration: Option, + /// Things the recorder wants the reader to know about the recording, in words. + pub warnings: Vec, +} + +impl Manifest { + #[must_use] + /// Returns the manifest of a recording that has just started, on this host: its host and komorebi flavor are the + /// ones this process runs on, its status is [`STATUS_RECORDING`] and it has nothing recorded yet. + pub fn new(start: Moment, anchors: Vec) -> Self { + let host = Host::current(); + Self { + schema_version: SCHEMA_VERSION, + status: STATUS_RECORDING.to_string(), + recorder_version: env!("CARGO_PKG_VERSION").to_string(), + macos_version: host.is_macos().then(|| host.os_version.clone()).flatten(), + host: Some(host), + komorebi: KomorebiInfo { + flavor: Flavor::of_host(), + ..KomorebiInfo::default() + }, + timebase: crate::clock::timebase(), + anchors, + start, + stop: None, + video_t0_mach_ns: None, + video_end_mach_ns: None, + log_segments: Vec::new(), + apps: Vec::new(), + events: EventSummary::default(), + sync_quality: "host-clock".to_string(), + calibration: None, + warnings: Vec::new(), + } + } + + /// What this session was recorded on. + #[must_use] + pub fn host(&self) -> Host { + self.host + .clone() + .unwrap_or_else(|| Host::legacy_macos(self.macos_version.clone())) + } + + /// The master clock value that is `t = 0` in video time. Falls back to the recording start if + /// no frame was ever captured, so timelines remain usable for the non-video sources. + #[must_use] + pub fn video_t0(&self) -> u64 { + self.video_t0_mach_ns.unwrap_or(self.start.mach_ns) + } + + #[must_use] + /// Returns the video time, in seconds, of a master-clock time. It is negative before the first frame. + pub fn video_seconds(&self, mach_ns: u64) -> f64 { + (i128::from(mach_ns) - i128::from(self.video_t0())) as f64 / 1e9 + } + + #[must_use] + /// Returns the master-clock time of `video_s` seconds into the video, which is zero for a time before the clock + /// started. + pub fn mach_at_video_seconds(&self, video_s: f64) -> u64 { + let ns = i128::from(self.video_t0()) + (video_s * 1e9).round() as i128; + ns.max(0) as u64 + } + + /// The span of video time covered by the recording, `(0, duration)`. The end is the moment + /// video capture stopped (frames are variable-rate, so the last frame can be earlier). + #[must_use] + pub fn video_span(&self) -> (f64, f64) { + let end = self + .video_end_mach_ns + .or(self.stop.map(|stop| stop.mach_ns)) + .map_or(0.0, |end| self.video_seconds(end)); + + (0.0, end.max(0.0)) + } +} + +/// A session directory on disk. +#[derive(Debug, Clone)] +pub struct Session { + /// The directory the session is in. + pub dir: PathBuf, +} + +/// The default parent of session directories: komorebi's data dir, under `recordings/`. +#[must_use] +pub fn default_recordings_dir() -> PathBuf { + crate::komorebi_link::data_dir().join("recordings") +} + +impl Session { + /// Creates a fresh session directory: `base` if given, otherwise + /// `/recordings//`. + /// + /// # Errors + /// + /// Returns an error if the directory already contains a session, or cannot be created. + pub fn create(base: Option, now: DateTime) -> eyre::Result { + let dir = base.unwrap_or_else(|| { + default_recordings_dir().join(now.format("%Y%m%dT%H%M%SZ").to_string()) + }); + + if dir.join(MANIFEST_FILE).exists() { + eyre::bail!( + "{} already contains a session; choose a different --out", + dir.display() + ); + } + + for sub in ["oslog/apps", "states"] { + std::fs::create_dir_all(dir.join(sub)) + .wrap_err_with(|| format!("could not create {}", dir.join(sub).display()))?; + } + + Ok(Self { dir }) + } + + /// Opens the session in `dir`. + /// + /// # Errors + /// + /// Returns an error if `dir` has no manifest: it is not a session. + pub fn open(dir: impl Into) -> eyre::Result { + let dir = dir.into(); + if !dir.join(MANIFEST_FILE).is_file() { + eyre::bail!( + "{} is not a recording session (no {MANIFEST_FILE})", + dir.display() + ); + } + + Ok(Self { dir }) + } + + #[must_use] + /// Returns `relative` inside the session's directory. + pub fn path(&self, relative: impl AsRef) -> PathBuf { + self.dir.join(relative) + } + + /// Reads the session's manifest. + /// + /// # Errors + /// + /// Returns an error if the manifest cannot be read, is not a valid manifest, or was written by a newer + /// `komorebi-recorder` than this one ([`SCHEMA_VERSION`] is greater than what this build reads). + pub fn manifest(&self) -> eyre::Result { + let path = self.path(MANIFEST_FILE); + let text = std::fs::read_to_string(&path) + .wrap_err_with(|| format!("could not read {}", path.display()))?; + let manifest: Manifest = serde_json::from_str(&text) + .wrap_err_with(|| format!("{} is not a valid session manifest", path.display()))?; + + if manifest.schema_version > SCHEMA_VERSION { + eyre::bail!( + "{} was written by a newer komorebi-recorder (schema {}, this build reads up to {})", + path.display(), + manifest.schema_version, + SCHEMA_VERSION + ); + } + + Ok(manifest) + } + + /// Writes the manifest. It is written to a temporary file and renamed over the old one, so a reader or a crash never + /// sees a half-written manifest. + /// + /// # Errors + /// + /// Returns an error if the manifest cannot be serialized or the files cannot be written. + pub fn write_manifest(&self, manifest: &Manifest) -> eyre::Result<()> { + let path = self.path(MANIFEST_FILE); + let temporary = self.path(format!("{MANIFEST_FILE}.tmp")); + std::fs::write(&temporary, serde_json::to_string_pretty(manifest)?)?; + // rename so a reader (or a crash) never sees a half-written manifest + std::fs::rename(&temporary, &path)?; + Ok(()) + } + + /// Writes the generated `README.md` for this session (see [`readme`]). + /// + /// # Errors + /// + /// Returns an error if the file cannot be written. + pub fn write_readme(&self, manifest: &Manifest) -> eyre::Result<()> { + std::fs::write(self.path("README.md"), readme(&self.dir, manifest))?; + Ok(()) + } +} + +/// The generated `README.md`: what every file is and the exact commands to query this session. +#[must_use] +pub fn readme(dir: &Path, manifest: &Manifest) -> String { + let session = shell_quote(&dir.display().to_string()); + let host = manifest.host(); + let (_, duration) = manifest.video_span(); + let mut out = String::new(); + + out.push_str("# komorebi-recorder session\n\n"); + out.push_str(&format!( + "Recorded with komorebi-recorder {} on {}.\nVideo duration: about {:.1}s. Video time `00:00.000` is the first captured frame.\n\n", + manifest.recorder_version, + manifest.host().describe(), + duration + )); + + out.push_str("## Investigating a bug\n\n"); + out.push_str(&format!( + "Start with what the user marked and described, then what the recording shows around it:\n\n\ + ```sh\n\ + komorebi-recorder brief {session}\n\ + komorebi-recorder guide # how to investigate, step by step\n\ + komorebi-recorder view {session} # the same, in a window you can play and scrub\n\ + ```\n\n\ + Every command takes `--json`. `annotate` adds a bug region (a start, an end and what is wrong)\n\ + to `annotations.jsonl`; `state`, `diff`, `events`, `trace`, `window`, `code`, `search`,\n\ + `changes` and `sheet` dig in.\n\n" + )); + + out.push_str("## How to read it\n\n"); + out.push_str( + "Every second of video maps to the exact records from every other source. Use the\n\ + `komorebi-recorder` CLI from this binary (this directory is self-contained):\n\n", + ); + out.push_str(&format!( + "```sh\n\ + # what happened around 12.5s into the video? (komorebi log + events + OS trace, +-1s)\n\ + komorebi-recorder at {session} 12.5 --window 1s\n\ + # narrow to one app's OS trace, or one source\n\ + komorebi-recorder at {session} 00:12.500 --app Ghostty\n\ + komorebi-recorder at {session} 12.5 --source events\n\ + # a span\n\ + komorebi-recorder range {session} 10 15\n\ + # a per-second digest of the whole recording\n\ + komorebi-recorder summary {session}\n\ + # reverse: a log line / event / UTC time -> video timecode\n\ + komorebi-recorder locate {session} --log-line 120\n\ + komorebi-recorder locate {session} --event-seq 7\n\ + komorebi-recorder locate {session} --utc 2026-10-26T19:43:35.062613Z\n\ + # extract PNG frames to look at (needs ffmpeg)\n\ + komorebi-recorder frames {session} 12.5 14.0\n\ + # rebuild timeline.jsonl from the raw files\n\ + komorebi-recorder reindex {session}\n\ + ```\n\n\ + Add `--json` to any query for machine-readable output.\n\n" + )); + + out.push_str("## Files\n\n"); + out.push_str("| File | What it is |\n|---|---|\n"); + out.push_str("| `session.json` | Manifest: versions, displays, clock anchors, log segments, OS-trace streams and apps, warnings |\n"); + out.push_str("| `video-[-].mp4` | Screen recording of one display. Variable frame rate: frames only exist when the screen changed. A display that was unplugged, whose stream stopped, or that changed resolution has several numbered segments, each a complete file; `session.json` lists every segment with its time span |\n"); + out.push_str("| `frames-[-].csv` | Every frame's host-clock timestamp (`pts_mach_ns`) and when it was received |\n"); + out.push_str("| `annotations.jsonl` | Bug regions (a start, an end and what is wrong), notes and findings, appended by the viewer and `annotate`. Never rewritten |\n"); + out.push_str("| `recorder.jsonl` | What happened to the recording environment: displays connected/disconnected, sleep/wake, screen lock, restarts of log streams or komorebi. Shown as the `recorder` source |\n"); + out.push_str("| `komorebi.log` | The exact byte slice of komorebi's own log for this recording (UTC timestamps) |\n"); + out.push_str("| `events.jsonl` | Every komorebi subscriber notification: `{seq, recv_wall_us, recv_mach_ns, event, state_id}` |\n"); + out.push_str("| `states/.json` | Full komorebi `State`, stored only when it changed (referenced by `state_id`) |\n"); + out.push_str( + "| `state-start.json`, `state-end.json` | `State` when recording started / stopped |\n", + ); + out.push_str(os_trace_rows(&host)); + out.push_str("| `timeline.jsonl` | Merged index of every record, sorted by time, pointing into the raw files |\n\n"); + + out.push_str("## Gaps are expected\n\n"); + out.push_str( + "Docking, undocking, sleep, wake and screen lock change what can be recorded. The recorder keeps\n\ + going through all of them: a display that disappears has its video finalized on the spot and a new\n\ + segment starts when it returns, and a `log stream` or komorebi that goes away is picked up again.\n\ + Whatever happened is in `recorder.jsonl` (`komorebi-recorder at