diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9aeeda9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,155 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +# A new push supersedes the run before it. That matters more here than in most +# repositories: this one is private, and a minute on a macOS runner is billed at +# ten times a Linux minute, so an abandoned run is ten times as expensive to +# leave running. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + # rustfmt only honours this repository's `brace_style = "AlwaysNextLine"` on + # the nightly channel, and nightly rustfmt changes its own output from build + # to build. The date is pinned so a formatting failure always means someone's + # code changed and never that the toolchain drifted underneath it. To format + # exactly the way this job checks: + # + # rustup toolchain install nightly-2026-08-10 --profile minimal --component rustfmt + # cargo +nightly-2026-08-10 fmt --all + # + # Moving the pin forward is a normal, deliberate commit: bump it here, run the + # command above, and commit whatever the new nightly reformats. + FMT_TOOLCHAIN: nightly-2026-08-10 + # Corepack must never stop to ask a question on a runner with no terminal. + COREPACK_ENABLE_DOWNLOAD_PROMPT: 0 + +jobs: + ci: + name: format, lint, build, test + # trdr is a macOS-only product — the file locking, the LOCAL_PEERCRED + # peer-uid check on the CLI socket, and every line of Tauri and AppKit code + # only exist for Darwin — so a green Linux run would be evidence about a + # platform this software never runs on. + runs-on: macos-latest + # A hung test on a 10x-billed runner is the expensive failure mode, so the + # job is capped well below the six-hour default. The headroom is for a cold + # cache once the Tauri crate joins the workspace and several hundred crates + # have to be compiled from scratch; a warm run is a few minutes. + timeout-minutes: 45 + + steps: + - uses: actions/checkout@v7 + + - name: Install the Rust toolchains + # Stable does the linting, the building and the testing. The pinned + # nightly exists for `cargo fmt` and nothing else. + id: rust + run: | + rustup toolchain install stable --profile minimal --component clippy + rustup default stable + rustup toolchain install "$FMT_TOOLCHAIN" --profile minimal --component rustfmt + rustc --version + cargo clippy --version + cargo "+$FMT_TOOLCHAIN" fmt --version + echo "version=$(rustc --version | cut -d' ' -f2)" >> "$GITHUB_OUTPUT" + + - name: Check formatting + # First, because it is the cheapest thing that can fail and it compiles + # nothing. rustfmt.toml at the repository root is the only configuration + # in play; this job passes no formatting flags of its own. + run: cargo "+$FMT_TOOLCHAIN" fmt --all --check + + - name: Cache cargo + uses: actions/cache@v6 + with: + path: | + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + # Keyed on the resolved dependency set and on the compiler that built + # the artifacts, so a new stable release starts a fresh target + # directory instead of layering onto objects it cannot reuse. + # + # Parallel tracks change Cargo.lock constantly, which is what the + # prefix fallback is for: a changed lockfile restores the last good + # cache and rebuilds the difference rather than starting from empty. + key: cargo-${{ runner.os }}-${{ steps.rust.outputs.version }}-${{ hashFiles('Cargo.lock') }} + restore-keys: | + cargo-${{ runner.os }}-${{ steps.rust.outputs.version }}- + cargo-${{ runner.os }}- + + - name: Check trdr-core's dependency boundary + # Early, because it is seconds of dependency resolution and it answers a + # question worth answering before ten minutes of compiling: has the pure + # domain crate quietly gained something that does I/O? See the script. + run: ./ci/check-core-deps.sh + + - uses: actions/setup-node@v7 + with: + # package.json asks for >=22.12.0. + node-version: '22' + + - name: Activate pnpm + # `corepack prepare --activate` with no argument reads the exact version + # out of the `packageManager` field of package.json, so the pinned pnpm + # lives in one place instead of being repeated here. + run: | + corepack enable + corepack prepare --activate + pnpm --version + + - name: Locate the pnpm store + id: pnpm-store + run: echo "path=$(pnpm store path | tail -n 1)" >> "$GITHUB_OUTPUT" + + - name: Cache the pnpm store + uses: actions/cache@v6 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: pnpm-${{ runner.os }}-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + pnpm-${{ runner.os }}- + + - name: Install JavaScript dependencies + # `--frozen-lockfile` is the point of committing pnpm-lock.yaml: a + # dependency added to a package.json without regenerating the lockfile + # fails here rather than resolving to something nobody reviewed. + run: pnpm install --frozen-lockfile + + - name: Typecheck the desktop app + run: pnpm --filter @trdr/desktop typecheck + + - name: Build the desktop app + # This runs before the Rust steps on purpose. The Tauri crate that lands + # as apps/desktop/src-tauri embeds the built frontend at compile time + # through `tauri::generate_context!`, so apps/desktop/dist has to exist + # before cargo touches the workspace — otherwise clippy and the tests + # fail on a missing directory rather than on anything real. + run: pnpm --filter @trdr/desktop build + + - name: Lint + # `--workspace --all-targets` rather than a list of packages, so a crate + # a parallel track adds to the workspace is linted the day it lands. + # clippy.toml at the root is the only configuration in play. + # + # No system prerequisites are installed for the Tauri crate: WebKit and + # AppKit come with the macOS SDK on the runner image, and checking, + # linting and testing a Tauri crate needs no display server. A Linux + # runner would have needed libwebkit2gtk installed here. + run: cargo clippy --workspace --all-targets -- -D warnings + + - name: Test + # The synthetic ingest fixture in fixtures/synthetic/ is consumed here, + # by crates/trdr-core/tests/synthetic_ingest_bundle.rs. + run: cargo test --workspace diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..c0705c8 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,4889 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "Inflector" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe438c63458706e03479442743baae6c88256498e6431708f6dfc520a26515d3" + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[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.61.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.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "brotli" +version = "8.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.13.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "cookie" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a373e3602691c3cdea496d2f0ee5935151e6168fe87739483c463db1b2f2f87" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "ctor" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +dependencies = [ + "ctor-proc-macro", + "dtor", +] + +[[package]] +name = "ctor-proc-macro" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.119", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dbus" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ab69f03cc8c4340c9c8e315114e1658e6775a9b16a04357973aa21cec22b32e" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.20", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[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.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser", + "foldhash", + "html5ever", + "precomputed-hash", + "selectors", + "tendril", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dtor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +dependencies = [ + "dtor-proc-macro", +] + +[[package]] +name = "dtor-proc-macro" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 1.1.4+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[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.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "rand_core", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.13.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashlink" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32069d97bb81e38fa67eab65e3393bf804bb85969f2bc06bf13f64aef5aba248" +dependencies = [ + "hashbrown 0.17.1", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[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 0.62.2", +] + +[[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 = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[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 = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link 0.2.1", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.13.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libredox" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" +dependencies = [ + "libc", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "muda" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-data" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-core-image" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-location" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-text" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image", + "objc2-core-location", + "objc2-core-text", + "objc2-foundation", + "objc2-quartz-core", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +dependencies = [ + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[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 = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros", + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.13.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[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 2.0.20", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "rsqlite-vfs" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" +dependencies = [ + "hashbrown 0.16.1", + "thiserror 2.0.20", +] + +[[package]] +name = "rusqlite" +version = "0.40.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" +dependencies = [ + "bitflags 2.13.1", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink", + "libsqlite3-sys", + "smallvec", + "sqlite-wasm-rs", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.119", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.13.1", + "cssparser", + "derive_more", + "log", + "new_debug_unreachable", + "phf", + "phf_codegen", + "precomputed-hash", + "rustc-hash", + "servo_arc", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[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 2.0.119", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_with" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +dependencies = [ + "base64 0.22.1", + "bs58", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "jiff", + "schemars 0.9.0", + "schemars 1.2.2", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "specta" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38f9a30cbcbb7011f1da7d73483983bf838af123883e45f2b36ed76328df9c50" +dependencies = [ + "paste", + "rustc_version", + "specta-macros", +] + +[[package]] +name = "specta-macros" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ce14957ecc2897f1f848b8255b6531d13ddf49cbcf506b7c2c9fb1d005593bb" +dependencies = [ + "Inflector", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "specta-serde" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8a72b755ddb8949fd8f17c5db43f0e8a806ea587d9bc602ee3f73240c00029" +dependencies = [ + "specta", + "specta-macros", +] + +[[package]] +name = "specta-typescript" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "639404ee95557f2f8b7e4cb773ffefd45304c7ab8ba21ac83b69051595e083c0" +dependencies = [ + "specta", +] + +[[package]] +name = "specta-util" +version = "0.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29b1fc02b446f7244a92924fe68c0555921209f1d342990cd1539e9138e69502" +dependencies = [ + "specta", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.35.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1c93047acf68669466a34690ac58cca7010bd1b201e1ec86f1fd0a75d3dd4a9" +dependencies = [ + "bitflags 2.13.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dbus", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "once_cell", + "parking_lot", + "percent-encoding", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7eeb6d99155545da6150a1795945f16ac9c178deb2a5f2e74d776107bd5849" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "667b20e2726d572dea2de7370da16e188eb06008faf9a92fab7cdc46791190b5" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "specta", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.20", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc9ce40b16101cb6ea63d3e221567affd1c3a9205f95d7bc574941a10636b632" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08279169ff42f8fc45a1dbc9dcae888893ba95288142e5880c59b93a26d2cfc5" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.119", + "tauri-utils", + "thiserror 2.0.20", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8b394794f399a421811d06966343e7933fcae92d59f5180b9388d1174497a45" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-runtime" +version = "2.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0b4bc95aed361b0019067d189a1174a603d460d0f6c72606512d59fc9c12ec8" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e6fac707727b7a2f48e4ded90976324267371073edbb415ffb73bb0458d203f" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-specta" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee080f36d2ac17ce2f3a82fb53f02d664e8345457de51b56dad3c394dacc41a2" +dependencies = [ + "heck 0.5.0", + "serde", + "serde_json", + "specta", + "specta-serde", + "specta-typescript", + "specta-util", + "tauri", + "tauri-specta-macros", + "thiserror 2.0.20", +] + +[[package]] +name = "tauri-specta-macros" +version = "2.0.0-rc.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a59dfdce06c98d8d211619bea5fdb39486d8a8c558e12b2d2ce255972320012" +dependencies = [ + "darling", + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tauri-utils" +version = "2.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e176a18e67764923c4f1ce66f25ae4abe5f688384d5eb1a0fa6c77f3d90f887" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dom_query", + "dunce", + "glob", + "http", + "infer", + "json-patch", + "log", + "memchr", + "phf", + "plist", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.20", + "toml 1.1.4+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc65d45c68858bfe420dd29e834b5d15dbecf8a07a8a16cf4d532c7b1f69d4b6" +dependencies = [ + "dunce", + "embed-resource", + "toml 1.1.4+spec-1.1.0", +] + +[[package]] +name = "tendril" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +dependencies = [ + "new_debug_unreachable", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml" +version = "1.1.4+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 1.0.4", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.4", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[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", +] + +[[package]] +name = "tray-icon" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "045979e3f037cd18ad1cb2a419dfda133c5c29c9f3453370079f2255d46c257e" +dependencies = [ + "crossbeam-channel", + "dirs", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.18.1", + "serde", + "thiserror 2.0.20", + "windows-sys 0.61.2", +] + +[[package]] +name = "trdr-cli" +version = "0.0.0" +dependencies = [ + "clap", + "serde_json", + "trdr-core", + "trdr-runtime", +] + +[[package]] +name = "trdr-core" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "specta", + "thiserror 2.0.20", + "ulid", +] + +[[package]] +name = "trdr-desktop" +version = "0.0.0" +dependencies = [ + "serde", + "serde_json", + "specta", + "specta-typescript", + "tauri", + "tauri-build", + "tauri-specta", + "thiserror 2.0.20", + "trdr-core", + "trdr-runtime", +] + +[[package]] +name = "trdr-runtime" +version = "0.0.0" +dependencies = [ + "libc", + "rusqlite", + "serde", + "serde_json", + "sha2", + "thiserror 2.0.20", + "trdr-core", + "ulid", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "ulid" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947dde63b6d514cc5e044edad4e0ca7261afd1099d16c83d942cb2b2f348689c" +dependencies = [ + "rand", + "web-time", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[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.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.20", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[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 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "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 2.0.119", +] + +[[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 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[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.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[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", + "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-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[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_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[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_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[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_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.55.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "186f9871daa55fd9c016578b810d149de58367113db7fb72b462d2323ce19514" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.20", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c18dab6 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,64 @@ +[workspace] +resolver = "2" +members = [ + "crates/trdr-core", + "crates/trdr-runtime", + "crates/trdr-cli", + "apps/desktop/src-tauri" +] +# Members are listed one by one rather than globbed, so nothing joins the +# workspace by accident. `apps/desktop/src-tauri` is the Tauri composition root: +# it owns the window, the capability file, and the typed commands, and it is the +# only member that links a GUI toolkit. +# +# `spikes/*` each declare an empty `[workspace]` table of their own and are +# excluded here as well, so a spike never resolves against these versions. +exclude = ["spikes"] + +[workspace.package] +version = "0.0.0" +edition = "2021" +license = "AGPL-3.0-only" +repository = "https://github.com/fxylabs/trdr" +publish = false + +# Every crate takes its shared dependencies from here with `workspace = true`. +# serde and specta are the pair that must never drift: tauri-specta 2.0.0-rc.25 +# depends on `specta =2.0.0-rc.25`, so specta is pinned to that exact version and +# the later tauri-specta track can add it without a second specta in the tree. +[workspace.dependencies] +trdr-core = { path = "crates/trdr-core" } +trdr-runtime = { path = "crates/trdr-runtime" } + +clap = { version = "4.6", features = ["derive"] } +# The one place the runtime is allowed to call libc: the writer lease is an +# `flock` on a descriptor and the socket checks its peer's uid, and neither has a +# safe equivalent in std. +libc = "0.2" +# `bundled` compiles SQLite into the binary rather than linking the one macOS +# ships, which is what section 6 means by not using the system SQLite. The +# version that comes with it is checked at test time, not assumed. +rusqlite = { version = "0.40", features = ["bundled"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +# Migration checksums (section 6.1). The same hash section 7.2 fixes for the +# domain, so there is one hash in the product rather than two. +sha2 = "0.10" +specta = { version = "=2.0.0-rc.25", features = ["derive"] } +# The TypeScript emitter behind the generated bindings. Pinned exactly for the +# same reason specta is: it reads specta's own data types, so a minor bump on one +# side and not the other is a type error nobody asked for. +specta-typescript = "=0.0.12" +# `specta` is what lets a command take an injected argument — `tauri::State` and +# friends — without that argument appearing in the generated TypeScript. Tauri +# implements specta's `FunctionArg` for them under this feature and answers "no +# type"; without it, a command that reads app state does not compile. +tauri = { version = "2.11", features = ["specta"] } +tauri-build = { version = "2.5", features = [] } +# Turns the registered commands into both the Tauri handler and the TypeScript +# bindings, from one list, so the two cannot describe different surfaces. +tauri-specta = { version = "=2.0.0-rc.25", features = ["derive", "typescript"] } +thiserror = "2.0" +# Default features off drops `rand`, so the domain crate gets the ULID codec +# without gaining a way to read the clock. Generation belongs to the runtime. +ulid = { version = "3.0", default-features = false } diff --git a/apps/desktop/index.html b/apps/desktop/index.html new file mode 100644 index 0000000..6380116 --- /dev/null +++ b/apps/desktop/index.html @@ -0,0 +1,12 @@ + + + + + + trdr + + +
+ + + diff --git a/apps/desktop/package.json b/apps/desktop/package.json new file mode 100644 index 0000000..9ca0fc0 --- /dev/null +++ b/apps/desktop/package.json @@ -0,0 +1,34 @@ +{ + "name": "@trdr/desktop", + "version": "0.0.0", + "description": "The trdr desktop application", + "license": "AGPL-3.0-only", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --build && vite build", + "preview": "vite preview", + "typecheck": "tsc --build", + "test": "vitest run", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.11.1", + "react": "^19.2.8", + "react-dom": "^19.2.8", + "react-router": "^8.3.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.11.4", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.3", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", + "@vitejs/plugin-react": "^6.0.5", + "jsdom": "^30.0.1", + "typescript": "^5.9.3", + "vite": "^8.2.1", + "vitest": "^4.1.10" + } +} diff --git a/apps/desktop/src-tauri/Cargo.toml b/apps/desktop/src-tauri/Cargo.toml new file mode 100644 index 0000000..8f1e235 --- /dev/null +++ b/apps/desktop/src-tauri/Cargo.toml @@ -0,0 +1,37 @@ +[package] +name = "trdr-desktop" +description = "The Tauri host for the trdr desktop application" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +# The library holds everything; the binary is four lines that call into it. That +# split is what lets the command surface and the generated bindings be covered by +# ordinary `cargo test`, which is where the binding drift check lives. +[lib] +name = "trdr_desktop_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { workspace = true } + +[dependencies] +serde = { workspace = true } +serde_json = { workspace = true } +specta = { workspace = true } +specta-typescript = { workspace = true } +tauri = { workspace = true } +tauri-specta = { workspace = true } +thiserror = { workspace = true } +trdr-core = { workspace = true } +# The composition root is where the app's effects come from: the writer lease, +# the bundled SQLite, the workspace directory, and the socket the CLI talks to. +trdr-runtime = { workspace = true } + +[dev-dependencies] +# `tauri/test` is what makes the capability file testable: it runs the real +# access-control resolution against a mock runtime, so a permission that is +# missing from `capabilities/main.json` fails a test rather than a click. +tauri = { workspace = true, features = ["test"] } diff --git a/apps/desktop/src-tauri/build.rs b/apps/desktop/src-tauri/build.rs new file mode 100644 index 0000000..8b29fbe --- /dev/null +++ b/apps/desktop/src-tauri/build.rs @@ -0,0 +1,22 @@ +//! Declares the app's own commands to Tauri's access-control list. +//! +//! This is the load-bearing part of `docs/FOUNDATION_DESIGN.md` section 9.1's +//! rule that the WebView reaches only commands the app wrote. Naming the +//! commands here makes Tauri generate an application ACL manifest, and the +//! presence of that manifest flips app commands from "always callable" to +//! "callable only where a capability file allows them". Without this call the +//! capability file would describe the plugin surface and say nothing at all +//! about `ping` or `bootstrap_get`, and both would be reachable from any +//! WebView the app ever opens. +//! +//! The list must stay in step with `commands::COMMANDS` and with the permissions +//! in `capabilities/main.json`; `tests/capability.rs` is what checks that it +//! does. + +fn main() +{ + let attributes = tauri_build::Attributes::new() + .app_manifest(tauri_build::AppManifest::new().commands(&["ping", "bootstrap_get"])); + + tauri_build::try_build(attributes).expect("failed to run the Tauri build script"); +} diff --git a/apps/desktop/src-tauri/capabilities/main.json b/apps/desktop/src-tauri/capabilities/main.json new file mode 100644 index 0000000..7539226 --- /dev/null +++ b/apps/desktop/src-tauri/capabilities/main.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "main-window", + "description": "Everything the main WebView is allowed to call. The list is two application commands and nothing else. There is deliberately no `core:default`: that one line would hand the screen the window's geometry and title, path resolution, event listening, WebView enumeration, and the bundle identifier, none of which any screen needs to be given because the host already knows them. `docs/FOUNDATION_DESIGN.md` section 9.1 requires the capability to enable only the commands the app owns, and the empty space here is the point — a command becomes reachable by being added, never by being inherited. `tests/capability.rs` fails if it stops being true.", + "windows": ["main"], + "permissions": ["allow-ping", "allow-bootstrap-get"] +} diff --git a/apps/desktop/src-tauri/icons/icon.png b/apps/desktop/src-tauri/icons/icon.png new file mode 100644 index 0000000..8db80e9 Binary files /dev/null and b/apps/desktop/src-tauri/icons/icon.png differ diff --git a/apps/desktop/src-tauri/permissions/autogenerated/bootstrap_get.toml b/apps/desktop/src-tauri/permissions/autogenerated/bootstrap_get.toml new file mode 100644 index 0000000..1569da6 --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/bootstrap_get.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-bootstrap-get" +description = "Enables the bootstrap_get command without any pre-configured scope." +commands.allow = ["bootstrap_get"] + +[[permission]] +identifier = "deny-bootstrap-get" +description = "Denies the bootstrap_get command without any pre-configured scope." +commands.deny = ["bootstrap_get"] diff --git a/apps/desktop/src-tauri/permissions/autogenerated/ping.toml b/apps/desktop/src-tauri/permissions/autogenerated/ping.toml new file mode 100644 index 0000000..641fabf --- /dev/null +++ b/apps/desktop/src-tauri/permissions/autogenerated/ping.toml @@ -0,0 +1,11 @@ +# Automatically generated - DO NOT EDIT! + +[[permission]] +identifier = "allow-ping" +description = "Enables the ping command without any pre-configured scope." +commands.allow = ["ping"] + +[[permission]] +identifier = "deny-ping" +description = "Denies the ping command without any pre-configured scope." +commands.deny = ["ping"] diff --git a/apps/desktop/src-tauri/src/bin/export-bindings.rs b/apps/desktop/src-tauri/src/bin/export-bindings.rs new file mode 100644 index 0000000..ddfe9fa --- /dev/null +++ b/apps/desktop/src-tauri/src/bin/export-bindings.rs @@ -0,0 +1,23 @@ +//! Writes the TypeScript bindings the React app imports. +//! +//! Run after changing a command signature or the command list: +//! +//! ```text +//! cargo run -p trdr-desktop --bin export-bindings +//! ``` +//! +//! `cargo test --workspace` fails until the committed file matches what this +//! produces, so forgetting to run it is caught rather than shipped. + +fn main() +{ + match trdr_desktop_lib::bindings::export() + { + Ok(path) => println!("wrote {}", path.display()), + Err(error) => + { + eprintln!("could not write the bindings: {error}"); + std::process::exit(1); + } + } +} diff --git a/apps/desktop/src-tauri/src/bindings.rs b/apps/desktop/src-tauri/src/bindings.rs new file mode 100644 index 0000000..e8ee092 --- /dev/null +++ b/apps/desktop/src-tauri/src/bindings.rs @@ -0,0 +1,99 @@ +//! Where the TypeScript the React app imports comes from. +//! +//! The React app does not hand-write the shape of a command. It imports +//! [`BINDINGS_FILE`], which this module generates from the same command list +//! Tauri dispatches on, so a change to a Rust signature is a change to the +//! TypeScript by construction rather than by anyone remembering. +//! +//! Generated code that is committed drifts the moment someone edits Rust and +//! does not re-run the generator. Two things stop that here: +//! +//! - [`generate`] produces the file's contents, and `tests/bindings.rs` fails +//! when the committed file differs from it. `cargo test --workspace` is +//! therefore the drift check. +//! - `cargo run -p trdr-desktop --bin export-bindings` writes the file. The +//! failing test says so, so the fix is in front of whoever hits it. + +use crate::builder; +use specta_typescript::Typescript; +use std::path::{Path, PathBuf}; + +/// The generated file's name inside the React source directory. +pub const BINDINGS_FILE: &str = "bindings.ts"; + +/// What the header of the generated file must say, so nobody edits it by hand. +const HEADER: &str = "\ +// Generated by tauri-specta from the commands in `src-tauri/src/commands.rs`. +// Do not edit: run `cargo run -p trdr-desktop --bin export-bindings` instead. +// `cargo test --workspace` fails when this file and those commands disagree. +"; + +/// The React source directory the generated file belongs in. +pub fn react_source_directory() -> PathBuf +{ + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("src-tauri always sits inside the desktop app directory") + .join("src") +} + +/// The absolute path of the generated file. +/// +/// Built by walking up rather than by joining `../src`, so the result has no +/// `..` component in it and comparing it against another path means what it +/// looks like it means. +pub fn bindings_path() -> PathBuf +{ + react_source_directory().join(BINDINGS_FILE) +} + +/// The TypeScript the current command list produces. +/// +/// tauri-specta writes to a file rather than returning a string, so this +/// generates into a temporary directory and reads the result back. That keeps +/// the drift test and the exporter running the exact same code path — a check +/// that reimplements what it checks is a check that agrees with itself and not +/// with the build. +pub fn generate() -> Result> +{ + let directory = std::env::temp_dir().join(format!( + "trdr-bindings-{}-{:?}", + std::process::id(), + std::thread::current().id() + )); + std::fs::create_dir_all(&directory)?; + + let scratch = directory.join("bindings.ts"); + // Why the exporter is allowed to write a 64-bit integer as `number`: + // + // One `i64` crosses this boundary — `ErrorParam::Integer`, which carries the + // counts and limits an error message renders. specta refuses those by + // default because `JSON.parse` silently truncates past 2^53, and it is right + // to. Here the truncation is not something this flag introduces: the value + // already travels as a JSON number, so a WebView receives a JavaScript + // `number` whatever the type declaration says. `number` describes what + // arrives; `bigint` would describe something that does not. + // + // What the flag does risk is the next 64-bit field, which would be typed + // lossily without anyone deciding to. `tests/bindings.rs` pins the exported + // type list for that reason: a new type reaching the WebView fails a test, + // and whoever updates the list is the person who has to look at this. + let generated = builder::commands::() + .dangerously_cast_bigints_to_number() + .export(Typescript::default().header(HEADER), &scratch); + + // The temporary directory goes away whether the export worked or not, so a + // failed run does not leave a stale file for the next one to read. + let contents = generated.and_then(|()| Ok(std::fs::read_to_string(&scratch)?)); + std::fs::remove_dir_all(&directory)?; + + Ok(contents?) +} + +/// Writes the generated TypeScript over the committed file. +pub fn export() -> Result> +{ + let path = bindings_path(); + std::fs::write(&path, generate()?)?; + Ok(path) +} diff --git a/apps/desktop/src-tauri/src/builder.rs b/apps/desktop/src-tauri/src/builder.rs new file mode 100644 index 0000000..b0384da --- /dev/null +++ b/apps/desktop/src-tauri/src/builder.rs @@ -0,0 +1,29 @@ +//! The one place the command list is written down. +//! +//! Tauri's dispatch table and the TypeScript the WebView imports both come out +//! of [`commands`]. Registering a command in one and not the other is what makes +//! a "typed" bridge untyped in exactly one spot, so there is no second list to +//! forget: `collect_commands!` feeds the handler and the exporter alike. + +use tauri_specta::{collect_commands, Builder}; + +/// The commands the main WebView may call. +/// +/// Adding one here is three edits, and the tests refuse the first two on their +/// own: the handler goes in [`crate::commands::COMMANDS`], its name goes in +/// `build.rs` so Tauri generates a permission for it, and that permission goes +/// in `capabilities/main.json` so the main window is actually allowed to call +/// it. A command registered here and left out of the capability file is +/// reachable from nowhere; a command left out of `build.rs` is reachable from +/// everywhere. +/// +/// Generic over the runtime so that the tests can build the same command list +/// against Tauri's mock runtime. A test that registered its own list would prove +/// something about the test and nothing about the app. +pub fn commands() -> Builder +{ + Builder::::new().commands(collect_commands![ + crate::commands::ping, + crate::commands::bootstrap_get + ]) +} diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs new file mode 100644 index 0000000..78ad7ed --- /dev/null +++ b/apps/desktop/src-tauri/src/commands.rs @@ -0,0 +1,374 @@ +//! Every command the main WebView can call. +//! +//! `docs/FOUNDATION_DESIGN.md` section 9.1 fixes the whole list of commands the +//! screen will eventually reach, and `trdr_core::ui::UiCommand` writes that list +//! down. This module implements two of them. The rest are not stubbed out here: +//! an unimplemented command that is registered is still a reachable command, and +//! the capability file is only as narrow as the surface behind it. +//! +//! Three rules hold for everything in this module, and the tests alongside are +//! what keep them holding. +//! +//! # A command answers with an envelope, never with a `Result` +//! +//! Tauri turns `Err` into a rejected promise, which would give trdr a second +//! error channel beside [`trdr_core::ErrorEnvelope`] — and a screen that has to +//! read failures out of two places will eventually read one of them wrong. So +//! every command returns [`UiResponseEnvelope`] infallibly, and the failure case +//! is the `error` arm of [`trdr_core::ui::UiOutcome`] carrying a real error +//! envelope. The generated TypeScript follows — the promise resolves to the +//! envelope and never rejects. +//! +//! # The Rust name and the wire name are checked against each other +//! +//! A Tauri command name is a Rust identifier, so `bootstrap.get` from section +//! 9.1 is spelled `bootstrap_get` here. That is a translation, and a translation +//! that nothing checks is a translation that drifts, so +//! [`Command::wire_method`] carries the section 9.1 spelling and a test asserts +//! it against `UiCommand::method()`. +//! +//! # A handler reads, it does not discover +//! +//! Where the workspace is, which one it is, and what schema its database is at +//! are decided once, by [`crate::startup`], before any window exists. A handler +//! reads that decision out of [`BootstrapState`] rather than going to look for +//! itself. Two things follow: no command can open a second database or take a +//! second lease by accident, and a test can hand a handler a state it made up +//! instead of a real product root — which is why nothing in this file's tests +//! goes near `~/.trdr`. +//! +//! # Why each command has a named response type +//! +//! A handler here returns `PingResponse`, not `UiResponseEnvelope`, and +//! that is working around a bug rather than expressing a design. tauri-specta +//! 2.0.0-rc.25 drops the concrete type arguments when it splits a generic return +//! type into its serialize and deserialize forms, and emits +//! `UiResponseEnvelope_Serialize` with `T` unbound — TypeScript that does not +//! compile. The same instantiation in field position is substituted correctly, +//! so a `#[serde(transparent)]` newtype puts the generic one level down and the +//! generated binding comes out as `UiResponseEnvelope_Serialize`. +//! +//! `transparent` means the bytes on the wire are the envelope's own and nothing +//! is added, and [`tests::a_named_response_is_the_envelope_and_nothing_more`] +//! holds that. When tauri-specta substitutes return-position generics, these two +//! types can be deleted and the handlers can name the envelope directly, with no +//! change to what the WebView receives. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use trdr_core::id::{RequestId, WorkspaceId}; +use trdr_core::ui::UiResponseEnvelope; +use trdr_core::PROTOCOL_VERSION; + +/// The version of the app itself, as the crate manifest states it. +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// What start-up settled, for the commands that answer from it. +/// +/// Managed by Tauri, so a handler receives it as `State` and cannot construct +/// one — which is the point. The values are read once, from the workspace this +/// process opened under the writer lease, and nothing that runs later can +/// disagree with them without the app having restarted. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BootstrapState +{ + /// The portable identity in this workspace's `workspace.json`. + pub workspace_id: WorkspaceId, + /// Where the workspace directory is. + pub workspace_path: PathBuf, + /// The schema version the open database reported. + pub schema_version: i32 +} + +/// One registered command, named on both sides of the boundary. +/// +/// The Tauri handler, the generated bindings, the capability file, and the build +/// script each need this command's name in a slightly different form. Keeping +/// the forms together in one value is what lets a test compare them instead of a +/// reader remembering to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Command +{ + /// The Rust function, which is also the name Tauri dispatches on. + pub handler: &'static str, + /// The permission that must appear in `capabilities/main.json` for the main + /// WebView to be allowed to call it. + pub permission: &'static str, + /// The name section 9.1 gives this command, or `None` where the command is + /// this crate's own rather than part of that contract. + pub wire_method: Option<&'static str> +} + +/// Every command registered with Tauri, and nothing else. +/// +/// The order matches `collect_commands!` in [`crate::builder`]. +pub const COMMANDS: &[Command] = &[ + Command { + handler: "ping", + permission: "allow-ping", + wire_method: None + }, + Command { + handler: "bootstrap_get", + permission: "allow-bootstrap-get", + wire_method: Some("bootstrap.get") + } +]; + +/// What [`ping`] answers with. +/// +/// Deliberately not part of `trdr-core`: this is a liveness check on the bridge +/// between the WebView and the host, not a contract between the app and the CLI. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct Pong +{ + /// The protocol version this build speaks, so the screen learns it from the + /// same round-trip that proved the bridge works. + pub protocol_version: u16 +} + +/// The envelope [`ping`] answers with, named so the bindings can describe it. +/// +/// Transparent: this is `UiResponseEnvelope` on the wire and in the +/// generated TypeScript alike. See the module documentation for why the name +/// exists at all. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] +#[serde(transparent)] +pub struct PingResponse(pub UiResponseEnvelope); + +/// A typed round-trip, and the proof that the bridge carries validated types. +/// +/// The argument is a [`RequestId`], not a string. That is what makes this a real +/// test of the boundary rather than a test of `invoke`: `RequestId` crosses to +/// TypeScript as a plain `string`, and is parsed back into a 26-character ULID +/// on the way in, so a WebView that sends anything else is refused by +/// deserialisation before this function is entered. +#[tauri::command] +#[specta::specta] +pub fn ping(id: RequestId) -> PingResponse +{ + PingResponse(UiResponseEnvelope::ok( + id, + Pong { + protocol_version: PROTOCOL_VERSION + } + )) +} + +/// What the app needs before it can show anything (section 9.1's `bootstrap.get`). +/// +/// Five fields, and the list is meant to stay short. Section 11 gives every +/// screen its own query model; this is only what has to be true before the first +/// screen can be drawn at all — which workspace is open, what build is running, +/// and what the two versions on the wire are. +/// +/// # The workspace path, and why a screen is allowed to see one +/// +/// Section 9.1 forbids a screen from *passing* a path: it names a location with +/// a [`trdr_core::id::ScopedPathHandle`] the host minted after a native picker, +/// so it can never name a file the user did not choose. Being told where the +/// open workspace is, is the other direction and a different question. The host +/// chose it, the person is entitled to know it, and the alternative — a screen +/// that cannot say which workspace it is showing — is worse. What stays out of +/// reach is the general ability to resolve paths, which is why the capability +/// file grants no `core:path` permission and `tests/capability.rs` checks. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct BootstrapModel +{ + /// The IPC protocol version this build speaks. + pub protocol_version: u16, + /// The app's own version. + pub app_version: String, + /// The portable identity of the workspace that is open. + pub workspace_id: WorkspaceId, + /// Where that workspace is on disk. + pub workspace_path: String, + /// The schema version of its database. + pub schema_version: i32 +} + +/// The envelope [`bootstrap_get`] answers with. +/// +/// Transparent, for the reason the module documentation gives. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] +#[serde(transparent)] +pub struct BootstrapResponse(pub UiResponseEnvelope); + +/// Answers `bootstrap.get` from the workspace this process opened at start-up. +#[tauri::command] +#[specta::specta] +pub fn bootstrap_get(id: RequestId, state: tauri::State<'_, BootstrapState>) -> BootstrapResponse +{ + BootstrapResponse(UiResponseEnvelope::ok(id, bootstrap_model(&state))) +} + +/// The model, built from the state, with no Tauri around it. +/// +/// Split out so the shape can be tested without a running app. The path is +/// rendered with `display` rather than serialised as a `PathBuf`: a path that is +/// not valid UTF-8 makes serde fail, and a bootstrap that cannot answer is worse +/// than one that answers with a path spelled with a replacement character. +fn bootstrap_model(state: &BootstrapState) -> BootstrapModel +{ + BootstrapModel { + protocol_version: PROTOCOL_VERSION, + app_version: APP_VERSION.to_owned(), + workspace_id: state.workspace_id, + workspace_path: state.workspace_path.display().to_string(), + schema_version: state.schema_version + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + use trdr_core::ui::{UiCommand, UiOutcome}; + + const REQUEST: &str = "01KZNNR5X818P3J6ENYKSADP8W"; + const WORKSPACE: &str = "01KZNP0GQ3X8ARK9DQ489Z7WJ8"; + + fn request() -> RequestId + { + REQUEST.parse().unwrap() + } + + /// A state no product root was consulted for. Every test in this module + /// answers from one of these, so none of them can reach `~/.trdr`. + pub(super) fn state() -> BootstrapState + { + BootstrapState { + workspace_id: WORKSPACE.parse().unwrap(), + workspace_path: PathBuf::from("/private/tmp/trdr-t/x/workspaces/default"), + schema_version: 1 + } + } + + #[test] + fn ping_answers_the_request_it_was_given() + { + let PingResponse(answer) = ping(request()); + + assert_eq!(answer.id, request()); + assert_eq!(answer.v.get(), PROTOCOL_VERSION); + assert_eq!( + answer.outcome, + UiOutcome::Ok(Pong { + protocol_version: PROTOCOL_VERSION + }) + ); + } + + #[test] + fn bootstrap_reports_the_workspace_start_up_opened() + { + let state = state(); + let model = bootstrap_model(&state); + + assert_eq!(model.protocol_version, PROTOCOL_VERSION); + assert_eq!(model.app_version, APP_VERSION); + assert_eq!(model.workspace_id, state.workspace_id); + assert_eq!( + model.workspace_path, + "/private/tmp/trdr-t/x/workspaces/default" + ); + assert_eq!(model.schema_version, 1); + } + + /// The id is what the screen shows and what a backup carries, so it has to + /// survive the crossing as the same 26 characters rather than as whatever a + /// `Debug` impl would print. + #[test] + fn the_workspace_id_crosses_as_the_text_it_is_written_as() + { + let json = serde_json::to_value(bootstrap_model(&state())).unwrap(); + + assert_eq!(json["workspace_id"], WORKSPACE); + } + + /// The translation between a Rust identifier and section 9.1's dotted name + /// is the kind of thing that is right once and wrong after the next rename. + #[test] + fn a_wire_method_is_the_one_the_command_contract_names() + { + let known: Vec<&str> = [ + UiCommand::BootstrapGet, + UiCommand::TodayGet, + UiCommand::CollectorsList, + UiCommand::StrategiesList, + UiCommand::JobsGet + ] + .iter() + .map(|command| command.method()) + .collect(); + + for command in COMMANDS + { + let Some(method) = command.wire_method + else + { + continue; + }; + + assert!( + known.contains(&method), + "{method} is not a command section 9.1 defines" + ); + assert_eq!( + method.replace('.', "_"), + command.handler, + "the handler name and the wire name disagree" + ); + } + } + + /// A permission identifier Tauri does not generate is a permission that + /// silently does nothing, so the spelling rule is asserted rather than + /// trusted: `allow-` followed by the handler name with underscores turned + /// into dashes. + #[test] + fn a_permission_is_spelled_the_way_tauri_generates_it() + { + for command in COMMANDS + { + assert_eq!( + command.permission, + format!("allow-{}", command.handler.replace('_', "-")) + ); + } + } + + #[test] + fn a_response_reaches_the_webview_as_the_envelope_the_design_fixes() + { + let answer = + BootstrapResponse(UiResponseEnvelope::ok(request(), bootstrap_model(&state()))); + let json = serde_json::to_value(answer).unwrap(); + + assert_eq!(json["v"], 1); + assert_eq!(json["id"], REQUEST); + assert_eq!(json["outcome"]["status"], "ok"); + assert_eq!(json["outcome"]["value"]["workspace_id"], WORKSPACE); + } + + /// The named response types exist to work around a generator bug, so the one + /// thing that must never become true of them is that they change the wire. + /// Drop `#[serde(transparent)]` from either and this fails. + #[test] + fn a_named_response_is_the_envelope_and_nothing_more() + { + let PingResponse(inner) = ping(request()); + assert_eq!( + serde_json::to_value(ping(request())).unwrap(), + serde_json::to_value(&inner).unwrap() + ); + + let envelope = UiResponseEnvelope::ok(request(), bootstrap_model(&state())); + assert_eq!( + serde_json::to_value(BootstrapResponse(envelope.clone())).unwrap(), + serde_json::to_value(&envelope).unwrap() + ); + } +} diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs new file mode 100644 index 0000000..b36aa8a --- /dev/null +++ b/apps/desktop/src-tauri/src/lib.rs @@ -0,0 +1,81 @@ +//! The trdr desktop host. +//! +//! This crate is the composition root of the application half of trdr: it owns +//! the main window, the capability file that says what that window may call, and +//! the typed commands behind it. It owns no domain logic — that is +//! `trdr-core` — and no effects — those are `trdr-runtime`'s. +//! +//! # What starting it does +//! +//! [`startup`] brings the runtime up before any window exists: it resolves the +//! product root, takes the single writer lease, opens the workspace database and +//! runs migration 0 if the file is new, creates `workspace.json` on a first run, +//! and binds the Unix socket the `trdr` CLI talks to. Only then is a window +//! created. A failure at any of those steps is written to standard error as a +//! sentence and an error envelope, and the process exits without a window — see +//! [`startup`] for why that is the whole of the second-instance behaviour. +//! +//! # How the WebView is confined +//! +//! `build.rs` declares this crate's commands to Tauri's access-control list. +//! That declaration is what makes application commands subject to the ACL at +//! all: with no application manifest, Tauri treats every `#[tauri::command]` as +//! callable from any WebView, and the capability file would only ever have been +//! describing plugins. With it, `capabilities/main.json` is the complete answer +//! to what the main window can reach, and it lists two commands. +//! +//! No Tauri plugin is linked into this binary. There is no filesystem, shell, +//! SQL, HTTP, dialog, or clipboard command to expose, so `docs/FOUNDATION_DESIGN.md` +//! section 9.1's rule about generic plugin commands is held by the dependency +//! list and not only by the capability file. + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +pub mod bindings; +pub mod builder; +pub mod commands; +pub mod startup; + +/// Builds the application, ready to run or to drive from a test. +/// +/// The window itself comes from `tauri.conf.json`, so a test that builds this +/// gets the same window label and the same capability file the shipped app does. +pub fn app() -> tauri::Builder +{ + tauri::Builder::default().invoke_handler(builder::commands::().invoke_handler()) +} + +/// Runs the desktop app: runtime first, then the window. +/// +/// # Panics +/// +/// If the window cannot be created. Every condition the app can anticipate is +/// handled before this point and exits without a panic; a window that cannot be +/// created is the operating system refusing, and there is nothing left to do. +pub fn run() +{ + let runtime = match startup::product_root().and_then(startup::AppRuntime::start) + { + Ok(runtime) => runtime, + Err(error) => startup::report_and_exit(&error) + }; + + app() + // Two managed values from one: the commands read the small settled facts + // and never see the lease, the connection, or the socket. + .manage(runtime.bootstrap().clone()) + .manage(runtime) + .build(tauri::generate_context!()) + .expect("failed to start the trdr desktop app") + .run(|handle, event| { + // Not left to `Drop`. The event loop underneath Tauri ends the + // process on some paths, and a destructor that does not run is a + // socket file that outlives the app that bound it. + if matches!(event, tauri::RunEvent::Exit) + { + use tauri::Manager as _; + handle.state::().shut_down(); + } + }); +} diff --git a/apps/desktop/src-tauri/src/main.rs b/apps/desktop/src-tauri/src/main.rs new file mode 100644 index 0000000..b4ea6af --- /dev/null +++ b/apps/desktop/src-tauri/src/main.rs @@ -0,0 +1,9 @@ +// Release builds on Windows would otherwise open a console window behind the +// app. trdr ships on macOS, but the attribute costs nothing and stops a Windows +// build from being surprising if one is ever made. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() +{ + trdr_desktop_lib::run(); +} diff --git a/apps/desktop/src-tauri/src/startup.rs b/apps/desktop/src-tauri/src/startup.rs new file mode 100644 index 0000000..6f1197e --- /dev/null +++ b/apps/desktop/src-tauri/src/startup.rs @@ -0,0 +1,502 @@ +//! Bringing the runtime up, in the one order that is safe, before any window. +//! +//! `docs/FOUNDATION_DESIGN.md` section 14's stop condition asks for an app that +//! holds the single writer lease and a CLI that finds the same workspace and +//! runtime. That is four steps, and their order is not a convention someone has +//! to remember — each one produces the value the next one asks for: +//! +//! ```text +//! product root where state lives, from the environment or ~/.trdr +//! → writer lease this process is now the writer, and can prove it +//! → database opened under the lease; migration 0 runs on a fresh file +//! → workspace workspace.json created on a first run (section 5.1) +//! → socket server bound with a bridge that answers `app.status` +//! ``` +//! +//! # Failing closed +//! +//! Every step happens before `tauri::Builder::run`, so a failure at any of them +//! means no window is ever created. The app writes the failure to standard +//! error — a sentence for a person, then the [`ErrorEnvelope`] as one JSON line +//! for anything reading it — and exits non-zero. +//! +//! That is the whole of the second-instance behaviour, and it is deliberately +//! the simplest thing that is honest. A second copy launched while the first is +//! running is refused by the lease at step two, so it never opens the database, +//! never binds over the running app's socket, and never puts up a window that +//! looks like the app but cannot write. macOS will not launch a second copy of a +//! bundled `.app` by double-click at all — it activates the running one — so the +//! case this reaches is a binary started from a terminal or a script, and +//! standard error is where such a caller is already looking. +//! +//! # Nothing slow on this path +//! +//! M1 found that a synchronous six-second read on start-up is indistinguishable +//! from a hung app. Everything here is bounded and local: taking an `flock`, +//! opening SQLite and running one `CREATE TABLE`, writing a 130-byte JSON file, +//! and binding a Unix socket. There is no network, no Keychain prompt, and no +//! directory walk. The socket's accept loop is the only thread this starts, and +//! it is started rather than waited on. Anything later that is not bounded — +//! reading a workspace's objects, testing a collector's credential — belongs +//! behind a command the screen can show progress for, not here. + +use crate::commands::BootstrapState; +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use trdr_core::error::{ErrorCode, ErrorEnvelope, ErrorParam, Retryability}; +use trdr_core::socket::AppStatusResult; +use trdr_runtime::clock::SystemClock; +use trdr_runtime::db::{Database, DbError}; +use trdr_runtime::ids::UlidGenerator; +use trdr_runtime::root::{LeaseError, ProductRoot, RootError}; +use trdr_runtime::socket::{AppBridge, ServerError, ServerHandle, SocketServer}; +use trdr_runtime::workspace::{Workspace, WorkspaceError}; + +/// The version of the app itself, as the crate manifest states it. +const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The environment variable that moves the product root somewhere else. +/// +/// This is how a test, a second checkout, or a support session runs the app +/// against a directory that is not the one a person's real workspace is in. +/// `trdr-runtime` deliberately has no such fallback — [`ProductRoot`] has to be +/// told where it is — so the whole of "where does this build keep its state" +/// is this one function and the `--product-root` flag the CLI already has. +pub const PRODUCT_ROOT_VARIABLE: &str = "TRDR_PRODUCT_ROOT"; + +/// Where this process keeps its state. +pub fn product_root() -> Result +{ + product_root_from(std::env::var_os(PRODUCT_ROOT_VARIABLE)) +} + +/// The same decision, with the environment handed in so a test can make it. +fn product_root_from(setting: Option) -> Result +{ + match setting.filter(|value| !value.is_empty()) + { + Some(path) => Ok(ProductRoot::at(PathBuf::from(path))), + None => Ok(ProductRoot::for_current_user()?) + } +} + +/// The running app's hold on the machine: the lease, the database, the +/// workspace, and the socket. +/// +/// Owning this value is what makes the process the app. It is handed to Tauri as +/// managed state so that its lifetime is the window's, and +/// [`AppRuntime::shut_down`] is called on the way out. +pub struct AppRuntime +{ + bootstrap: BootstrapState, + workspace: Workspace, + status: Arc, + /// `Option` so that shutting down can take the handle out and drop it. The + /// handle's own `Drop` stops the accept loop, joins it, and unlinks the + /// socket file, so what shutting down needs is to make that happen at a + /// moment of its choosing rather than to reimplement it. + server: Mutex> +} + +impl AppRuntime +{ + /// Runs the four steps, or says which one failed. + /// + /// A failure anywhere releases everything taken so far: the lease lives in + /// an `Arc` that is dropped when this function returns, and dropping it + /// unlocks the `flock`. So a refused start leaves no lease behind for the + /// next attempt to trip over. + pub fn start(root: ProductRoot) -> Result + { + let lease = Arc::new(root.acquire_writer_lease()?); + let database = Database::open(Arc::clone(&lease))?; + let workspace = Workspace::open_default(&lease, &SystemClock, &UlidGenerator)?; + + let schema_version = database.schema_version()?; + let bootstrap = BootstrapState { + workspace_id: workspace.id(), + workspace_path: workspace.path().to_path_buf(), + schema_version + }; + + let status = Arc::new(Status { + product_root: root.path().to_path_buf(), + workspace_path: workspace.path().to_path_buf(), + database: Mutex::new(database), + schema_version_at_open: schema_version + }); + + let server = SocketServer::bind(lease, Arc::clone(&status) as Arc)?.spawn(); + + Ok(Self { + bootstrap, + workspace, + status, + server: Mutex::new(Some(server)) + }) + } + + /// What `bootstrap.get` answers from. + pub fn bootstrap(&self) -> &BootstrapState + { + &self.bootstrap + } + + /// The workspace this app has open. + pub fn workspace(&self) -> &Workspace + { + &self.workspace + } + + /// The same answer the CLI gets over the socket. + pub fn status(&self) -> AppStatusResult + { + self.status.app_status() + } + + /// Stops serving and removes the socket file. + /// + /// Called from `RunEvent::Exit` rather than left to `Drop`, because the + /// event loop underneath Tauri ends the process itself on some paths, and a + /// destructor that never runs is a socket file that outlives its app. + /// + /// It does not release the writer lease, and deliberately so. The lease is + /// held by an open descriptor, and the kernel drops it when the process + /// ends — which is the same thing that happens to a process that is killed. + /// Leaving it to that means the lease has one release path rather than two, + /// and the one it has is the one that also covers the crash. The socket file + /// is the only thing that needs saying out loud, because nothing collects it + /// automatically. + /// + /// Safe to call more than once; the second call finds nothing to stop. + pub fn shut_down(&self) + { + if let Ok(mut server) = self.server.lock() + { + drop(server.take()); + } + } +} + +/// The [`AppBridge`] the socket server answers `app.status` through. +struct Status +{ + product_root: PathBuf, + workspace_path: PathBuf, + /// Held open for the life of the app. It carries the writer lease, so this + /// is also what stops the lease from being released while the app is up. + database: Mutex, + /// What the database said its schema version was when it was opened. + schema_version_at_open: i32 +} + +impl Status +{ + /// The schema version, read back out of the open database. + /// + /// `app.status` has one result shape and no error arm (section 9.2), so a + /// connection that cannot answer falls back to what it said when it was + /// opened. The two can only differ if the connection has since failed, and + /// this build changes the schema exactly once — during the open — so the + /// fallback is not a stale guess but the same number. + fn schema_version(&self) -> i64 + { + self.database + .lock() + .ok() + .and_then(|database| database.schema_version().ok()) + .map_or(i64::from(self.schema_version_at_open), i64::from) + } +} + +impl AppBridge for Status +{ + fn app_status(&self) -> AppStatusResult + { + AppStatusResult { + app_version: APP_VERSION.to_owned(), + pid: std::process::id(), + // Not read from anywhere: `AppRuntime::start` could not have built + // this value without the lease, and the database below is still + // holding it. + holds_writer_lease: true, + product_root: self.product_root.clone(), + workspace_path: self.workspace_path.clone(), + schema_version: self.schema_version() + } + } +} + +/// The app could not be brought up. +#[derive(Debug, thiserror::Error)] +pub enum StartupError +{ + /// The product root could not be located or prepared. + #[error(transparent)] + Root(#[from] RootError), + /// The writer lease could not be taken, most often because another copy of + /// the app holds it. + #[error(transparent)] + Lease(#[from] LeaseError), + /// The database could not be opened or migrated. + #[error(transparent)] + Database(#[from] DbError), + /// The workspace could not be created or read. + #[error(transparent)] + Workspace(#[from] WorkspaceError), + /// The socket could not be bound. + #[error(transparent)] + Server(#[from] ServerError) +} + +impl StartupError +{ + /// Whether another live process already holds the writer lease. + pub fn is_lease_held(&self) -> bool + { + matches!(self, Self::Lease(LeaseError::Held { .. })) + } + + /// The same failure in the one shape every surface renders (section 12). + /// + /// Section 12's code set is closed, and it has no start-up family, so every + /// failure here is placed in an existing one and says which start-up step it + /// was in a `reason` parameter. The placements, and why: + /// + /// - The lease being held is `DB_BUSY` — literally "the database is held by + /// someone else right now", which is what a second copy has run into. + /// - A workspace or database written by a later build is `DATA_UNSUPPORTED`: + /// the data is of a kind this version does not support, and section 6.1 + /// forbids opening it rather than guessing. + /// - Damage, drift, and a missing recovery point stay in the `DB_` family, + /// which is where the design already puts them. + /// - Everything else is the environment refusing to give the app a place to + /// run, and reports as `APP_NOT_RUNNING` — which is also what `trdr app + /// status` will say a moment later, and for the same reason. The CLI + /// already uses that code with `reason=home_unknown` for the identical + /// case. + /// + /// No sentence crosses this boundary; the wording is [`report`]'s. + pub fn to_envelope(&self) -> ErrorEnvelope + { + let (code, reason) = match self + { + Self::Root(RootError::HomeUnknown) => (ErrorCode::AppNotRunning, "home_unknown"), + Self::Root(RootError::Prepare { .. }) => + { + (ErrorCode::AppNotRunning, "product_root_unusable") + } + Self::Lease(LeaseError::Held { .. }) => (ErrorCode::DbBusy, "writer_lease_held"), + Self::Lease(LeaseError::Open { .. } | LeaseError::Root(_)) => + { + (ErrorCode::AppNotRunning, "writer_lease_unusable") + } + Self::Database(error) => database_reason(error), + Self::Workspace(WorkspaceError::Manifest { source, .. }) => match source + { + trdr_core::workspace::ManifestError::UnsupportedSchemaVersion { .. } => + { + (ErrorCode::DataUnsupported, "workspace_schema_unsupported") + } + trdr_core::workspace::ManifestError::Unreadable => + { + (ErrorCode::DbIntegrity, "workspace_manifest_unreadable") + } + }, + Self::Workspace(_) => (ErrorCode::AppNotRunning, "workspace_unusable"), + Self::Server(_) => (ErrorCode::AppNotRunning, "socket_unavailable") + }; + + ErrorEnvelope::new(code) + .with_param("reason", ErrorParam::literal(reason)) + .with_retryability(Retryability::No) + } +} + +/// Where in section 12 a database failure belongs, and what it was. +fn database_reason(error: &DbError) -> (ErrorCode, &'static str) +{ + match error + { + DbError::SchemaFromNewerBuild { .. } => + { + (ErrorCode::DataUnsupported, "schema_from_newer_build") + } + DbError::MigrationDrift { .. } => (ErrorCode::DbMigration, "migration_drift"), + DbError::UnknownAppliedMigration { .. } => + { + (ErrorCode::DbMigration, "migration_from_a_newer_build") + } + DbError::RecoveryPointRequired { .. } => + { + (ErrorCode::DbMigration, "recovery_point_required") + } + DbError::ForeignKeyViolations { .. } => (ErrorCode::DbMigration, "foreign_key_violations"), + DbError::IntegrityCheckFailed => (ErrorCode::DbIntegrity, "integrity_check_failed"), + DbError::NotATrdrDatabase { .. } => (ErrorCode::DbIntegrity, "not_a_trdr_database"), + DbError::SqliteTooOld { .. } => (ErrorCode::DbIntegrity, "bundled_sqlite_too_old"), + DbError::WalRefused { .. } => (ErrorCode::DbIntegrity, "write_ahead_logging_refused"), + DbError::ForeignKeysRefused => (ErrorCode::DbIntegrity, "foreign_keys_refused"), + DbError::Permissions { .. } => (ErrorCode::AppNotRunning, "state_files_unwritable"), + DbError::Root(_) => (ErrorCode::AppNotRunning, "product_root_unusable"), + DbError::Sqlite(_) => (ErrorCode::DbIntegrity, "database_unopenable") + } +} + +/// Writes the failure where a caller will see it, and ends the process. +/// +/// Two lines, on standard error, in this order: a sentence a person can act on, +/// and the envelope as JSON for anything parsing the output. The sentence lives +/// here rather than in `trdr-runtime` because section 3.1 puts user-facing +/// wording in the surface, and this crate is a surface. +pub fn report_and_exit(error: &StartupError) -> ! +{ + let envelope = error.to_envelope(); + + eprintln!("trdr could not start: {}", sentence(error)); + + if let Ok(json) = serde_json::to_string(&envelope) + { + eprintln!("{json}"); + } + + std::process::exit(1) +} + +/// What to tell the person who started the app. +fn sentence(error: &StartupError) -> &'static str +{ + match error + { + _ if error.is_lease_held() => + { + "another copy of trdr is already running and holds the writer lease. \ + Quit it and try again." + } + StartupError::Root(RootError::HomeUnknown) => + { + "the home directory could not be found, so there is nowhere to keep \ + its state." + } + StartupError::Root(_) | StartupError::Lease(_) => + { + "its state directory could not be prepared." + } + StartupError::Database(_) => + { + "the workspace database could not be opened. Run `trdr app status` \ + for the code, and do not delete anything in the workspace." + } + StartupError::Workspace(_) => "the workspace could not be read.", + StartupError::Server(_) => "its command socket could not be opened." + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + use trdr_runtime::test_support::scratch_root; + + #[test] + fn the_product_root_comes_from_the_environment_when_it_says_so() + { + let chosen = product_root_from(Some(OsString::from("/private/tmp/trdr-t/chosen"))) + .expect("an explicit root is always usable"); + + assert_eq!( + chosen.path(), + std::path::Path::new("/private/tmp/trdr-t/chosen") + ); + } + + /// An empty variable is a variable someone exported and did not fill in. + /// Treating it as "the root is the empty path" would put the whole product + /// in the working directory. + #[test] + fn an_empty_setting_falls_back_rather_than_naming_the_current_directory() + { + let fallback = product_root_from(Some(OsString::new())).expect("a home is set here"); + + assert!(fallback.path().ends_with(".trdr"), "{fallback:?}"); + assert!(fallback.path().is_absolute()); + } + + /// Nothing in this test touches the filesystem: a [`ProductRoot`] is a path + /// and creating one creates no directory. The whole of this crate's test + /// suite reaches `~/.trdr` at no other point, which is checked in + /// `tests/startup.rs`. + #[test] + fn without_a_setting_the_root_is_the_one_section_fifteen_fixes() + { + let default = product_root_from(None).expect("a home is set here"); + + assert_eq!(default.path().file_name().unwrap(), ".trdr"); + } + + #[test] + fn a_second_start_against_a_held_root_is_refused_as_a_busy_database() + { + let root = scratch_root("startup-second"); + let first = AppRuntime::start(root.clone()).expect("the first start should work"); + + let Err(refused) = AppRuntime::start(root) + else + { + panic!("a second start took a lease the first one is holding"); + }; + + assert!(refused.is_lease_held()); + assert_eq!(refused.to_envelope().code, ErrorCode::DbBusy); + assert_eq!( + refused.to_envelope().params.get("reason"), + Some(&ErrorParam::literal("writer_lease_held")) + ); + + first.shut_down(); + } + + /// Every failure that can come out of start-up renders as a code section 12 + /// defines, carrying a reason. The point is not any single mapping but that + /// none of them falls through to something unnamed. + #[test] + fn every_startup_failure_renders_as_a_code_and_a_reason() + { + let failures = [ + StartupError::Root(RootError::HomeUnknown), + StartupError::Lease(LeaseError::Held { + path: PathBuf::from("/private/tmp/t/run/writer.lock") + }), + StartupError::Database(DbError::IntegrityCheckFailed), + StartupError::Database(DbError::MigrationDrift { id: 0 }), + StartupError::Database(DbError::SchemaFromNewerBuild { + found: 9, + supported: 1 + }), + StartupError::Workspace(WorkspaceError::Manifest { + path: PathBuf::from("/private/tmp/t/workspaces/default/workspace.json"), + source: trdr_core::workspace::ManifestError::Unreadable + }), + StartupError::Server(ServerError::PathTooLong { + found: 200, + limit: 103 + }) + ]; + + for failure in &failures + { + let envelope = failure.to_envelope(); + + assert!( + envelope.params.contains_key("reason"), + "{failure:?} carries no reason" + ); + assert!( + trdr_core::ErrorCode::ALL.contains(&envelope.code), + "{failure:?} rendered as a code section 12 does not define" + ); + assert!(!sentence(failure).is_empty()); + } + } +} diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json new file mode 100644 index 0000000..2bee9ab --- /dev/null +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -0,0 +1,40 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "trdr", + "version": "0.0.0", + "identifier": "com.fxylabs.trdr", + "mainBinaryName": "trdr-desktop", + "build": { + "beforeDevCommand": "pnpm --filter @trdr/desktop dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "pnpm --filter @trdr/desktop build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "main", + "title": "trdr", + "width": 1280, + "height": 820, + "minWidth": 960, + "minHeight": 600 + } + ], + "security": { + "csp": "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost; base-uri 'none'; form-action 'none'; object-src 'none'; frame-ancestors 'none'", + "devCsp": "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' ipc: http://ipc.localhost http://localhost:1420 ws://localhost:1420; base-uri 'none'; form-action 'none'; object-src 'none'; frame-ancestors 'none'", + "freezePrototype": true + }, + "withGlobalTauri": false + }, + "bundle": { + "active": false, + "targets": ["app", "dmg"], + "category": "Finance", + "copyright": "Copyright (c) fxylabs", + "macOS": { + "minimumSystemVersion": "13.0" + } + } +} diff --git a/apps/desktop/src-tauri/tests/bindings.rs b/apps/desktop/src-tauri/tests/bindings.rs new file mode 100644 index 0000000..b0c6f3d --- /dev/null +++ b/apps/desktop/src-tauri/tests/bindings.rs @@ -0,0 +1,187 @@ +//! The check that stops the committed TypeScript from drifting off the commands. +//! +//! `apps/desktop/src/bindings.ts` is generated and committed, which is the +//! arrangement that lets the React build stay a plain `vite build` with no Rust +//! toolchain in it. The cost of that arrangement is a file that can silently +//! describe a command signature the host no longer has, and this test is what +//! pays it: `cargo test --workspace` regenerates the bindings and fails on any +//! difference. + +use std::path::Path; +use trdr_desktop_lib::{bindings, commands}; + +#[test] +fn the_committed_bindings_are_what_the_commands_generate() +{ + let path = bindings::bindings_path(); + let generated = bindings::generate().expect("could not generate the bindings"); + + let committed = std::fs::read_to_string(&path).unwrap_or_else(|error| { + panic!( + "{} could not be read ({error}). Run `cargo run -p trdr-desktop --bin \ + export-bindings`.", + path.display() + ) + }); + + assert_eq!( + committed, + generated, + "\n{} is out of date with the commands in src/commands.rs.\nRun `cargo run -p \ + trdr-desktop --bin export-bindings` and commit the result.\n", + path.display() + ); +} + +/// The generated file is only useful to the React app if the React app can find +/// it, and a generator pointed at the wrong directory fails quietly — it writes +/// a perfectly good file nobody imports. +#[test] +fn the_bindings_land_inside_the_react_source_tree() +{ + let path = bindings::bindings_path(); + let source = Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("src-tauri always has a parent") + .join("src"); + + assert!( + path.starts_with(&source), + "{} is not under {}", + path.display(), + source.display() + ); +} + +/// The whole point of a typed bridge is that a validated Rust newtype arrives in +/// TypeScript as something TypeScript can actually hold. `RequestId` is a +/// 26-character ULID in Rust; if it crossed as a reference to a generated type +/// called `String` the bindings would not typecheck, which is the failure this +/// asserts against directly rather than waiting for `tsc` to find it. +#[test] +fn a_validated_id_crosses_as_a_plain_string() +{ + let generated = bindings::generate().expect("could not generate the bindings"); + + assert!( + generated.contains("id: string"), + "the request id did not cross as a string:\n{generated}" + ); + assert!( + !generated.contains("String"), + "a Rust type name leaked into the bindings:\n{generated}" + ); +} + +/// A generic return type comes out of tauri-specta 2.0.0-rc.25 with its type +/// arguments dropped — `UiResponseEnvelope_Serialize`, with `T` bound to +/// nothing. `tsc` catches it, but only once someone runs the frontend build, and +/// the message it gives points at generated code rather than at the Rust that +/// caused it. This says so directly. See `commands.rs` for the workaround. +#[test] +fn no_command_returns_an_unbound_generic() +{ + let generated = bindings::generate().expect("could not generate the bindings"); + + // Only the command signatures. A `` in a type declaration is a generic + // parameter being introduced, which is correct and common; a `` in the + // return type of an `invoke` call is one being used without ever having been. + let calls = generated + .lines() + .filter(|line| line.contains("__TAURI_INVOKE<")) + .collect::>(); + + assert_eq!(calls.len(), 2, "expected one line per registered command"); + + for line in calls + { + assert!( + !line.contains(""), + "a command returns an unbound type parameter:\n{line}" + ); + } +} + +/// The registered handlers are exactly the ones `commands::COMMANDS` describes. +/// +/// This is the last of the four places a command has to appear, and the only one +/// left without a check. `build.rs` and the capability file are held together by +/// tauri-build, which refuses a permission identifier it did not generate; +/// `tests/config.rs` holds the capability file against `COMMANDS`. What was +/// missing is the list `collect_commands!` actually registers — a handler added +/// to `COMMANDS` and forgotten in `builder.rs` would leave a permission granted +/// for a command that does not exist, and nothing would have said so. +/// +/// The generated bindings are the readable form of that registration, so they +/// are what gets compared. +#[test] +fn the_registered_handlers_are_the_ones_the_command_list_names() +{ + let generated = bindings::generate().expect("could not generate the bindings"); + + let mut registered: Vec<&str> = generated + .lines() + .filter_map(|line| line.split("__TAURI_INVOKE<").nth(1)) + .filter_map(|rest| rest.split('"').nth(1)) + .collect(); + registered.sort_unstable(); + + let mut expected: Vec<&str> = commands::COMMANDS + .iter() + .map(|command| command.handler) + .collect(); + expected.sort_unstable(); + + assert_eq!(registered, expected); +} + +/// The exported type list, pinned. +/// +/// Two things ride on this. The obvious one is that the WebView's whole type +/// surface stays something a person can read in one screen. The other is the +/// `dangerously_cast_bigints_to_number` setting in `src/bindings.rs`: it applies +/// to every type that crosses, so the moment a new one does, someone has to come +/// here, update the list, and — in doing so — see the note explaining what they +/// have just accepted for any 64-bit field it carries. +#[test] +fn the_exported_type_surface_is_the_one_that_was_reviewed() +{ + let generated = bindings::generate().expect("could not generate the bindings"); + + let mut exported: Vec<&str> = generated + .lines() + .filter_map(|line| line.strip_prefix("export type ")) + .filter_map(|rest| rest.split([' ', '<', '=']).next()) + .collect(); + exported.sort_unstable(); + + assert_eq!( + exported, + [ + "BootstrapModel", + "BootstrapResponse", + "BootstrapResponse_Deserialize", + "BootstrapResponse_Serialize", + "EnvelopeVersion", + "ErrorCode", + "ErrorEnvelope", + "ErrorEnvelope_Deserialize", + "ErrorEnvelope_Serialize", + "ErrorParam", + "PingResponse", + "PingResponse_Deserialize", + "PingResponse_Serialize", + "Pong", + "Retryability", + "UiOutcome", + "UiOutcome_Deserialize", + "UiOutcome_Serialize", + "UiResponseEnvelope", + "UiResponseEnvelope_Deserialize", + "UiResponseEnvelope_Serialize", + "UpstreamStatus", + "UpstreamStatus_Deserialize", + "UpstreamStatus_Serialize" + ] + ); +} diff --git a/apps/desktop/src-tauri/tests/capability.rs b/apps/desktop/src-tauri/tests/capability.rs new file mode 100644 index 0000000..6f4d87d --- /dev/null +++ b/apps/desktop/src-tauri/tests/capability.rs @@ -0,0 +1,306 @@ +//! What the main WebView can and cannot reach, checked against the real thing. +//! +//! `capabilities/main.json` is a JSON file, and a JSON file that nothing +//! exercises is a claim rather than a control. These tests build the app from +//! the shipped `tauri.conf.json`, open the window the shipped config declares, +//! and push messages through the same IPC path a WebView uses — so what they +//! prove is what the access-control list actually resolves to, not what the file +//! looks like. +//! +//! Two failures they exist to catch: +//! +//! - A capability that quietly grows. `core:default` alone would hand the screen +//! window manipulation, path resolution, event emission, and tray and menu +//! control. [`the_webview_cannot_reach_a_core_plugin_command`] fails the +//! moment any of that is granted. +//! - An application command that is registered but not declared in `build.rs`. +//! Tauri only applies the access-control list to app commands when an +//! application manifest exists, so dropping that declaration would silently +//! make every command callable from every WebView, and +//! [`an_unregistered_application_command_is_refused`] is what notices. + +use std::path::PathBuf; +use tauri::ipc::CallbackFn; +use tauri::test::{get_ipc_response, mock_builder, INVOKE_KEY}; +use tauri::utils::config::WindowConfig; +use tauri::webview::InvokeRequest; +use tauri::{App, WebviewWindow, WebviewWindowBuilder}; +use trdr_desktop_lib::commands::BootstrapState; + +const REQUEST: &str = "01KZNNR5X818P3J6ENYKSADP8W"; +const WORKSPACE: &str = "01KZNP0GQ3X8ARK9DQ489Z7WJ8"; + +/// What start-up would have settled, made up here instead. +/// +/// These tests are about which commands the main WebView can reach, and a real +/// product root would add a writer lease, a SQLite file, and a bound socket to +/// every one of them without making any of them prove more. Nothing in this file +/// touches `~/.trdr`; `tests/startup.rs` is where the real runtime is exercised, +/// against a scratch root. +fn bootstrap_state() -> BootstrapState +{ + BootstrapState { + workspace_id: WORKSPACE.parse().expect("a sample workspace id"), + workspace_path: PathBuf::from("/private/tmp/trdr-t/capability/workspaces/default"), + schema_version: 1 + } +} + +/// The app as it ships, on the runtime the tests can drive. +fn app() -> App +{ + mock_builder() + .invoke_handler(trdr_desktop_lib::builder::commands().invoke_handler()) + .manage(bootstrap_state()) + .build(tauri::generate_context!()) + .expect("the shipped config should build") +} + +/// The window `tauri.conf.json` declares, opened the way the app opens it. +/// +/// Built from the config rather than from a literal label, because the label is +/// what ties this window to the capability file — a test that hard-coded `"main"` +/// would keep passing after the config renamed the window out from under it. +fn main_window(app: &App) -> WebviewWindow +{ + let config: &WindowConfig = app + .config() + .app + .windows + .first() + .expect("the config declares the main window"); + + assert_eq!( + config.label, "main", + "the capability file names this window" + ); + + WebviewWindowBuilder::from_config(app.handle(), config) + .expect("the window config should be usable") + .build() + .expect("the window should open") +} + +fn request(command: &str, body: serde_json::Value) -> InvokeRequest +{ + InvokeRequest { + cmd: command.to_owned(), + callback: CallbackFn(0), + error: CallbackFn(1), + url: "tauri://localhost".parse().unwrap(), + body: body.into(), + headers: Default::default(), + invoke_key: INVOKE_KEY.to_owned() + } +} + +fn call(command: &str, body: serde_json::Value) -> Result +{ + let app = app(); + let window = main_window(&app); + + get_ipc_response(&window, request(command, body)) + .map(|ok| ok.deserialize().expect("a command answers with JSON")) +} + +#[test] +fn the_webview_can_ping_the_host() +{ + let answer = call("ping", serde_json::json!({ "id": REQUEST })).expect("ping is allowed"); + + assert_eq!(answer["v"], 1); + assert_eq!(answer["id"], REQUEST); + assert_eq!(answer["outcome"]["status"], "ok"); + assert_eq!(answer["outcome"]["value"]["protocol_version"], 1); +} + +#[test] +fn the_webview_can_ask_for_the_bootstrap_model() +{ + let answer = + call("bootstrap_get", serde_json::json!({ "id": REQUEST })).expect("bootstrap is allowed"); + + assert_eq!(answer["outcome"]["status"], "ok"); + assert_eq!(answer["outcome"]["value"]["protocol_version"], 1); + assert_eq!(answer["outcome"]["value"]["workspace_id"], WORKSPACE); + assert_eq!(answer["outcome"]["value"]["schema_version"], 1); +} + +/// The state a command reads is injected by the host, and a WebView that sends +/// a field by that name changes nothing. +/// +/// Worth pinning rather than assuming, because the two plausible behaviours look +/// the same from the Rust side and are very different from the screen's: Tauri +/// takes the arguments it knows out of the message and ignores the rest, so the +/// extra field is dropped — it is not an error, and it is also not a second way +/// to tell the handler which workspace it is looking at. +#[test] +fn a_webview_cannot_talk_a_command_out_of_the_state_it_was_given() +{ + let answer = call( + "bootstrap_get", + serde_json::json!({ + "id": REQUEST, + "state": { + "workspace_id": "01KZNNR5X818P3J6ENYKSADP8W", + "workspace_path": "/etc", + "schema_version": 9 + } + }) + ) + .expect("an unknown field is ignored, not refused"); + + assert_eq!(answer["outcome"]["value"]["workspace_id"], WORKSPACE); + assert_eq!(answer["outcome"]["value"]["schema_version"], 1); + assert_ne!(answer["outcome"]["value"]["workspace_path"], "/etc"); +} + +/// The capability grants no `core:` permission at all, so every one of these is +/// refused. +/// +/// Every command listed is a member of `core:default`, which is the one line +/// that would most plausibly be added to the capability file by someone copying +/// a Tauri example. That makes each of these a live canary rather than a +/// decoration: adding `core:default` turns this test red, which was checked by +/// doing it. +#[test] +fn the_webview_cannot_reach_a_core_plugin_command() +{ + let mut reachable = Vec::new(); + + for (command, body) in [ + // The window's own title and geometry, from `core:window:default`. + ("plugin:window|title", serde_json::json!({})), + ("plugin:window|inner_size", serde_json::json!({})), + // Path resolution, from `core:path:default` — how a screen would learn + // where the home directory is without ever being told. + ( + "plugin:path|resolve_directory", + serde_json::json!({ "directory": 20 }) + ), + // Events, from `core:event:default`. The design keeps Rust to UI events + // for job and process state, and the track that adds them adds the + // permission with them. + ( + "plugin:event|listen", + serde_json::json!({ + "event": "job-state", + "target": { "kind": "Any" }, + "handler": 0 + }) + ), + // Enumerating the app's WebViews, from `core:webview:default`. + ("plugin:webview|get_all_webviews", serde_json::json!({})), + // The bundle identifier and version, from `core:app:default`. + ("plugin:app|version", serde_json::json!({})), + ("plugin:app|identifier", serde_json::json!({})) + ] + { + if call(command, body).is_ok() + { + reachable.push(command); + } + } + + // Collected rather than asserted one at a time, so a capability that grew + // reports everything it opened up instead of the first thing alphabetically. + assert!( + reachable.is_empty(), + "these were reachable from the main WebView: {reachable:?}" + ); +} + +/// Beyond `core:default`, the commands a second WebView or a window rename would +/// be built out of. None of these is in any default set, so they are refused +/// twice over — but they are the ones whose reachability would undo the +/// per-window capability entirely, so they are named rather than assumed. +#[test] +fn the_webview_cannot_create_another_webview_or_retitle_the_window() +{ + for (command, body) in [ + ( + "plugin:webview|create_webview_window", + serde_json::json!({ "options": {} }) + ), + ("plugin:window|create", serde_json::json!({ "options": {} })), + ( + "plugin:window|set_title", + serde_json::json!({ "value": "x" }) + ), + ("plugin:window|close", serde_json::json!({})) + ] + { + assert!( + call(command, body).is_err(), + "{command} was reachable from the main WebView" + ); + } +} + +/// A command the app never registered, in the shape a plugin command takes. This +/// is the whole class the design excludes by name: shell, filesystem, SQL. +#[test] +fn an_unregistered_application_command_is_refused() +{ + for command in [ + "plugin:fs|read_text_file", + "plugin:shell|execute", + "plugin:sql|select", + "plugin:dialog|open", + "plugin:http|fetch", + "today_get" + ] + { + assert!( + call(command, serde_json::json!({})).is_err(), + "{command} answered, and nothing registered it" + ); + } +} + +/// The argument is a validated id, so the parse is part of the boundary rather +/// than part of the handler. A WebView that sends anything else never reaches +/// the function body. +#[test] +fn a_request_id_that_is_not_a_ulid_never_reaches_the_handler() +{ + for id in [ + serde_json::json!("../../etc/passwd"), + serde_json::json!("01KZNNR5X818P3J6ENYKSADP8"), + serde_json::json!(""), + serde_json::json!(7), + serde_json::json!(null) + ] + { + assert!( + call("ping", serde_json::json!({ "id": id })).is_err(), + "{id} was accepted as a request id" + ); + } +} + +/// The capability names one window. A second window — however it came to +/// exist — inherits nothing, because a capability applies to the labels it +/// lists and this one lists `main`. +#[test] +fn a_window_the_capability_does_not_name_can_call_nothing() +{ + let app = app(); + let other = WebviewWindowBuilder::new( + app.handle(), + "inspector", + tauri::WebviewUrl::App("index.html".into()) + ) + .build() + .expect("the window should open"); + + let refused = get_ipc_response( + &other, + request("ping", serde_json::json!({ "id": REQUEST })) + ); + + assert!( + refused.is_err(), + "a window outside the capability reached a command" + ); +} diff --git a/apps/desktop/src-tauri/tests/config.rs b/apps/desktop/src-tauri/tests/config.rs new file mode 100644 index 0000000..0ae3b35 --- /dev/null +++ b/apps/desktop/src-tauri/tests/config.rs @@ -0,0 +1,207 @@ +//! The settings Tauri and Vite each hold half of. +//! +//! Two configuration files describe one dev server and one build output, and +//! neither can see the other. When they disagree the result is not a build +//! failure — it is a window that comes up blank, or a packaged app that ships +//! the previous build's assets. Both are found by hand, late. +//! +//! So the pairs are asserted here. The Rust side reads `tauri.conf.json` through +//! the same types Tauri does, and the Vite side is read as text: `vite.config.ts` +//! is TypeScript and nothing in a Rust test can evaluate it, but the values in +//! question are literals and matching on them is enough to notice a change. + +use std::path::{Path, PathBuf}; +use tauri::utils::config::{Config, FrontendDist}; + +/// The dev server port, written down once here and checked against both files. +const DEV_PORT: u16 = 1420; + +fn desktop_directory() -> PathBuf +{ + Path::new(env!("CARGO_MANIFEST_DIR")) + .parent() + .expect("src-tauri sits inside the desktop app directory") + .to_path_buf() +} + +fn config() -> Config +{ + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("tauri.conf.json"); + let text = std::fs::read_to_string(path).expect("tauri.conf.json should be readable"); + serde_json::from_str(&text).expect("tauri.conf.json should parse as a Tauri config") +} + +fn vite_config() -> String +{ + std::fs::read_to_string(desktop_directory().join("vite.config.ts")) + .expect("vite.config.ts should be readable") +} + +#[test] +fn the_dev_url_is_the_port_vite_is_pinned_to() +{ + let url = config().build.dev_url.expect("a devUrl is configured"); + + assert_eq!(url.port(), Some(DEV_PORT)); + assert_eq!(url.host_str(), Some("localhost")); + + let vite = vite_config(); + assert!( + vite.contains(&format!("port: {DEV_PORT}")), + "vite.config.ts does not pin port {DEV_PORT}" + ); + + // Without `strictPort`, Vite moves to the next free port when this one is + // taken and says so in a line nobody reads, and the window then points at a + // server that is not there. + assert!( + vite.contains("strictPort: true"), + "vite.config.ts lets the dev server move off its port" + ); +} + +#[test] +fn the_frontend_dist_is_where_vite_writes() +{ + let config = config(); + + let Some(FrontendDist::Directory(dist)) = config.build.frontend_dist + else + { + panic!("frontendDist should be a directory"); + }; + + let resolved = Path::new(env!("CARGO_MANIFEST_DIR")).join(&dist); + let expected = desktop_directory().join("dist"); + + assert_eq!( + resolved.canonicalize().ok(), + expected.canonicalize().ok(), + "frontendDist ({}) is not where vite.config.ts writes ({})", + resolved.display(), + expected.display() + ); + assert!( + vite_config().contains("outDir: \"dist\""), + "vite.config.ts does not write to `dist`" + ); +} + +/// The identifier is a settled decision (`01kznp0gq3x8ark9dq489z7wj8`), and it is +/// also the Keychain service name, so changing it silently orphans every stored +/// credential on a user's machine. +#[test] +fn the_bundle_identifier_is_the_settled_one() +{ + assert_eq!(config().identifier, "com.fxylabs.trdr"); +} + +/// The crate has two binaries — the app and the bindings exporter — and left to +/// itself the Tauri CLI packaged the exporter. It reported success while doing +/// it, so the only sign was one line of build output naming the wrong file, and +/// once bundling is switched on the result would be a shipped `.app` containing +/// a program that writes TypeScript and exits. +#[test] +fn the_packaged_binary_is_the_app_and_not_the_bindings_exporter() +{ + assert_eq!(config().main_binary_name.as_deref(), Some("trdr-desktop")); +} + +/// A content security policy that has quietly become permissive is not visible +/// in a running app. These are the directives whose absence would matter. +#[test] +fn the_content_security_policy_stays_restrictive() +{ + let config = config(); + let csp = config + .app + .security + .csp + .expect("a CSP is configured") + .to_string(); + + for directive in [ + // Nothing loads unless a later directive names it. + "default-src 'none'", + // No inline script, which is what makes anything injected into a screen + // inert rather than executable. + "script-src 'self'", + // No outbound network from the WebView. The host does the network, and + // section 9.1 is the only way to ask it to. + "connect-src 'self' ipc: http://ipc.localhost", + "object-src 'none'", + "frame-ancestors 'none'", + "base-uri 'none'", + "form-action 'none'" + ] + { + assert!( + csp.contains(directive), + "the CSP no longer says {directive}" + ); + } + + assert!( + !csp.contains("script-src 'self' 'unsafe-inline'"), + "the shipped CSP allows inline script" + ); + assert!(!csp.contains("unsafe-eval"), "the shipped CSP allows eval"); + + // The dev policy is looser — Vite injects its client inline and talks over a + // websocket — but it is a separate string precisely so that looseness cannot + // reach a packaged build. + let dev = config + .app + .security + .dev_csp + .expect("a dev CSP is configured") + .to_string(); + + assert!(dev.contains(&format!("ws://localhost:{DEV_PORT}"))); + assert!(!dev.contains("unsafe-eval"), "the dev CSP allows eval"); +} + +/// `withGlobalTauri` puts the whole API object on `window`. It is off, so the +/// only route to the host is the generated bindings, which import `invoke` +/// directly. +#[test] +fn the_api_is_not_published_on_the_window_object() +{ + assert!(!config().app.with_global_tauri); +} + +/// The capability file grants the registered commands, and grants nothing else. +/// +/// `tests/capability.rs` proves what is reachable by reaching for it, which is +/// the stronger check but can only ever cover the commands someone thought to +/// name. This one reads the file and compares it against the command list, so an +/// entry nobody anticipated — a plugin, a `core:` set, a permission for a +/// command that was deleted — fails without having to have been predicted. +#[test] +fn the_capability_grants_the_registered_commands_and_nothing_else() +{ + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("capabilities/main.json"); + let text = std::fs::read_to_string(path).expect("the capability file should be readable"); + let capability: serde_json::Value = + serde_json::from_str(&text).expect("the capability file should be JSON"); + + let granted: Vec<&str> = capability["permissions"] + .as_array() + .expect("permissions is a list") + .iter() + .map(|value| value.as_str().expect("a permission is a string")) + .collect(); + + let expected: Vec<&str> = trdr_desktop_lib::commands::COMMANDS + .iter() + .map(|command| command.permission) + .collect(); + + assert_eq!(granted, expected); + + // The capability applies to the labels it lists. One window, named once. + assert_eq!( + capability["windows"].as_array().map(Vec::as_slice), + Some([serde_json::Value::from("main")].as_slice()) + ); +} diff --git a/apps/desktop/src-tauri/tests/startup.rs b/apps/desktop/src-tauri/tests/startup.rs new file mode 100644 index 0000000..e59510f --- /dev/null +++ b/apps/desktop/src-tauri/tests/startup.rs @@ -0,0 +1,238 @@ +//! What actually happens when the app starts, against a real product root. +//! +//! Section 14's stop condition is about four things being true at once: the app +//! and the CLI find the same workspace and runtime, the app holds the single +//! writer lease, the socket answers, and a second copy cannot. The other test +//! files in this crate cover the window and the command surface with nothing +//! behind them; this one runs [`AppRuntime::start`] for real — an `flock`, a +//! SQLite file with migration 0 in it, a `workspace.json`, and a bound Unix +//! socket — and then asks the runtime's own client the same question `trdr app +//! status` asks. +//! +//! Every root here comes from [`scratch_root`], which lives under `/private/tmp` +//! and asserts it is not inside the home directory. Nothing in this file, or +//! anywhere else in this crate's tests, can reach the `~/.trdr` a person is +//! using: [`ProductRoot::for_current_user`] is called from exactly one place in +//! the app, and [`the_only_route_to_the_real_product_root_is_the_app_itself`] +//! is what keeps that true. + +use std::path::Path; +use trdr_core::workspace::WorkspaceManifest; +use trdr_desktop_lib::startup::AppRuntime; +use trdr_runtime::root::{mode_of, ProductRoot, PRIVATE_FILE_MODE}; +use trdr_runtime::socket::AppClient; +use trdr_runtime::test_support::scratch_root; + +/// What the app reports about itself, read the way the CLI reads it. +fn status_over_the_socket(root: &ProductRoot) -> trdr_core::socket::AppStatusResult +{ + AppClient::connect(root) + .expect("the app should be listening") + .app_status() + .expect("the app should answer") +} + +#[test] +fn a_first_run_leaves_a_workspace_a_database_and_a_socket() +{ + let root = scratch_root("app-first-run"); + let app = AppRuntime::start(root.clone()).expect("a first run should come up"); + + let manifest_path = root.default_workspace_dir().join("workspace.json"); + let manifest = WorkspaceManifest::from_json( + &std::fs::read_to_string(&manifest_path).expect("the manifest should be there") + ) + .expect("the manifest should be readable"); + + assert_eq!(manifest.workspace_id, app.workspace().id()); + assert_eq!(mode_of(&manifest_path).unwrap(), PRIVATE_FILE_MODE); + assert!(root.default_database_path().exists()); + assert!(root.socket_path().exists()); + + app.shut_down(); +} + +/// The stop condition in one assertion: what the app thinks it has open is what +/// a caller on the socket is told, and both are the root the app was started +/// against. +#[test] +fn the_app_and_a_caller_on_the_socket_find_the_same_workspace() +{ + let root = scratch_root("app-same-workspace"); + let app = AppRuntime::start(root.clone()).expect("it should come up"); + + let over_the_socket = status_over_the_socket(&root); + + assert_eq!(over_the_socket, app.status()); + assert_eq!(over_the_socket.product_root, root.path()); + assert_eq!(over_the_socket.workspace_path, root.default_workspace_dir()); + assert_eq!(over_the_socket.pid, std::process::id()); + assert!(over_the_socket.holds_writer_lease); + assert_eq!(over_the_socket.schema_version, 1); + + app.shut_down(); +} + +/// `bootstrap.get`'s answer and `app.status`'s answer are produced by different +/// code on different sides of the process, and a screen that disagreed with the +/// CLI about which workspace is open would be the confusing kind of wrong. +#[test] +fn the_screen_and_the_cli_are_told_the_same_workspace() +{ + let root = scratch_root("app-agree"); + let app = AppRuntime::start(root.clone()).expect("it should come up"); + + let bootstrap = app.bootstrap().clone(); + let status = status_over_the_socket(&root); + + assert_eq!(bootstrap.workspace_path, status.workspace_path); + assert_eq!(i64::from(bootstrap.schema_version), status.schema_version); + assert_eq!(bootstrap.workspace_id, app.workspace().id()); + + app.shut_down(); +} + +/// The single writer lease, which is what makes any of the above safe. +#[test] +fn a_second_app_cannot_start_beside_the_first() +{ + let root = scratch_root("app-single-writer"); + let first = AppRuntime::start(root.clone()).expect("the first should come up"); + + let Err(refused) = AppRuntime::start(root.clone()) + else + { + panic!("two apps took the writer lease at once"); + }; + assert!(refused.is_lease_held()); + + // And the first is still serving: a refused second start must not have + // unlinked the socket or disturbed the lease on its way out. + assert!(status_over_the_socket(&root).holds_writer_lease); + + // Dropping the runtime, not shutting it down: `shut_down` stops the socket + // and leaves the lease to the process ending, which in a test is never. + drop(first); + + let next = AppRuntime::start(root).expect("the lease should be free once the first has gone"); + assert!(next.status().holds_writer_lease); +} + +/// A refused start must leave nothing behind. If it did, the failure would be +/// sticky: the app would refuse to start from then on, with no process holding +/// anything. +#[test] +fn a_refused_start_releases_everything_it_took() +{ + let root = scratch_root("app-clean-refusal"); + let holder = AppRuntime::start(root.clone()).expect("the first should come up"); + + for _ in 0..3 + { + assert!(AppRuntime::start(root.clone()).is_err()); + } + + drop(holder); + + let after = AppRuntime::start(root).expect("three refusals should have left no residue"); + assert!(after.status().holds_writer_lease); +} + +/// The crash case. A process killed outright leaves its socket file behind, and +/// the kernel drops its `flock` — so the next start finds a file it must clear +/// and a lease it may take. Simulated here by leaving the file in place, which +/// is exactly the state a `SIGKILL` leaves the directory in. +#[test] +fn a_socket_file_left_by_a_dead_app_does_not_stop_the_next_one() +{ + let root = scratch_root("app-stale-socket"); + let crashed = AppRuntime::start(root.clone()).expect("the first should come up"); + let socket = root.socket_path(); + + // Drop the runtime without shutting it down, then put the file back: the + // combination is what a killed process leaves, since its destructors never + // ran but its descriptors were closed by the kernel. + drop(crashed); + std::fs::write(&socket, b"").expect("a leftover socket file could not be staged"); + assert!(socket.exists()); + + let next = AppRuntime::start(root.clone()).expect("a stale socket should not block a start"); + + assert!(status_over_the_socket(&root).holds_writer_lease); + next.shut_down(); +} + +/// Both ways out remove the socket file: the explicit one Tauri's exit event +/// takes, and the destructor, for anything that drops the runtime instead. +#[test] +fn either_way_out_removes_the_socket_file() +{ + let root = scratch_root("app-shutdown"); + let app = AppRuntime::start(root.clone()).expect("it should come up"); + assert!(root.socket_path().exists()); + + app.shut_down(); + assert!( + !root.socket_path().exists(), + "the socket outlived the app that bound it" + ); + + // Twice is not a failure: `RunEvent::Exit` is not the only path there. + app.shut_down(); + drop(app); + + // What a caller sees afterwards is the ordinary "no app is running" case. + assert!(AppClient::connect(&root).is_err()); + + let dropped = AppRuntime::start(root.clone()).expect("the lease is free again"); + assert!(root.socket_path().exists()); + drop(dropped); + assert!( + !root.socket_path().exists(), + "a dropped app kept its socket" + ); +} + +/// The guard the brief asks for, as a test rather than a habit. +/// +/// `~/.trdr` is named in exactly one place in this crate — the fallback in +/// `startup::product_root`, which is reached only when `TRDR_PRODUCT_ROOT` is +/// unset — and no test calls it. A handler that started resolving its own root, +/// or a test helper that reached for `for_current_user`, would show up here as a +/// second mention. +#[test] +fn the_only_route_to_the_real_product_root_is_the_app_itself() +{ + let source = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let mut mentions = Vec::new(); + + let mut pending = vec![source.clone()]; + + while let Some(directory) = pending.pop() + { + for entry in std::fs::read_dir(&directory).expect("the source tree should be readable") + { + let path = entry.expect("a directory entry").path(); + + if path.is_dir() + { + pending.push(path); + continue; + } + + if path.extension().is_some_and(|kind| kind == "rs") + && std::fs::read_to_string(&path) + .unwrap_or_default() + .contains("for_current_user") + { + mentions.push(path); + } + } + } + + assert_eq!( + mentions, + [source.join("startup.rs")], + "the real product root is reachable from somewhere new" + ); +} diff --git a/apps/desktop/src/App.tsx b/apps/desktop/src/App.tsx new file mode 100644 index 0000000..21fe771 --- /dev/null +++ b/apps/desktop/src/App.tsx @@ -0,0 +1,26 @@ +import { RouterProvider, createHashRouter } from "react-router"; + +import { routes } from "./shell/routes"; + +/** + * Why React Router, and why the hash. + * + * React Router is here for one feature: layout routes. A parent route with an + * `` stays mounted while its children swap, which is exactly the + * lifetime the terminal host needs and the thing a router without nesting cannot + * give without a portal. + * + * The hash, because in a packaged build the WebView loads from `tauri://` and + * asks that protocol for whatever path is in the URL. There is no server behind + * it to fall back to `index.html`, so a reload on `/lab` would ask for a file + * that was never built. With `#/lab` the path the protocol sees is always + * `index.html`, and the route lives in the fragment, which is never requested. + * The URL stays a real one, which matters later: the `trdr` CLI's `ui.open` + * (section 9.2) needs somewhere to point. + */ +const router = createHashRouter(routes); + +export function App() +{ + return ; +} diff --git a/apps/desktop/src/bindings.ts b/apps/desktop/src/bindings.ts new file mode 100644 index 0000000..2f0b3ca --- /dev/null +++ b/apps/desktop/src/bindings.ts @@ -0,0 +1,381 @@ +// Generated by tauri-specta from the commands in `src-tauri/src/commands.rs`. +// Do not edit: run `cargo run -p trdr-desktop --bin export-bindings` instead. +// `cargo test --workspace` fails when this file and those commands disagree. + +// This file has been generated by Tauri Specta. Do not edit this file manually. + +import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core"; + +/** Commands */ +export const commands = { + /** + * A typed round-trip, and the proof that the bridge carries validated types. + * + * The argument is a [`RequestId`], not a string. That is what makes this a real + * test of the boundary rather than a test of `invoke`: `RequestId` crosses to + * TypeScript as a plain `string`, and is parsed back into a 26-character ULID + * on the way in, so a WebView that sends anything else is refused by + * deserialisation before this function is entered. + */ + ping: (id: string) => __TAURI_INVOKE("ping", { id }), + /** Answers `bootstrap.get` from the workspace this process opened at start-up. */ + bootstrapGet: (id: string) => __TAURI_INVOKE("bootstrap_get", { id }), +}; + +/* Types */ +/** + * What the app needs before it can show anything (section 9.1's `bootstrap.get`). + * + * Five fields, and the list is meant to stay short. Section 11 gives every + * screen its own query model; this is only what has to be true before the first + * screen can be drawn at all — which workspace is open, what build is running, + * and what the two versions on the wire are. + * + * # The workspace path, and why a screen is allowed to see one + * + * Section 9.1 forbids a screen from *passing* a path: it names a location with + * a [`trdr_core::id::ScopedPathHandle`] the host minted after a native picker, + * so it can never name a file the user did not choose. Being told where the + * open workspace is, is the other direction and a different question. The host + * chose it, the person is entitled to know it, and the alternative — a screen + * that cannot say which workspace it is showing — is worse. What stays out of + * reach is the general ability to resolve paths, which is why the capability + * file grants no `core:path` permission and `tests/capability.rs` checks. + */ +export type BootstrapModel = { + /** The IPC protocol version this build speaks. */ + protocol_version: number, + /** The app's own version. */ + app_version: string, + /** The portable identity of the workspace that is open. */ + workspace_id: string, + /** Where that workspace is on disk. */ + workspace_path: string, + /** The schema version of its database. */ + schema_version: number, +}; + +/** + * The envelope [`bootstrap_get`] answers with. + * + * Transparent, for the reason the module documentation gives. + */ +export type BootstrapResponse = BootstrapResponse_Serialize | BootstrapResponse_Deserialize; + +/** + * The envelope [`bootstrap_get`] answers with. + * + * Transparent, for the reason the module documentation gives. + */ +export type BootstrapResponse_Deserialize = UiResponseEnvelope_Deserialize; + +/** + * The envelope [`bootstrap_get`] answers with. + * + * Transparent, for the reason the module documentation gives. + */ +export type BootstrapResponse_Serialize = UiResponseEnvelope_Serialize; + +/** + * The `v` field of a versioned message. + * + * A value of this type can only exist for a version this binary supports, so + * code downstream of parsing never has to re-check it. + */ +export type EnvelopeVersion = number; + +/** + * Every failure trdr can report. + * + * The set is closed and comes from section 12. A new kind of failure gets a new + * variant here rather than a new sentence somewhere, and the exhaustive match in + * [`ErrorCode::family`] means adding one is a compile error until it has been + * placed in a family. + */ +export type ErrorCode = +/** No credential is stored for this source. */ +"AUTH_MISSING" | +/** + * The stored credential was refused by the source. + * + * Distinct from [`ErrorCode::UpstreamUnavailable`] on purpose: this one is + * the user's to fix. + */ +"AUTH_INVALID" | +/** The source is refusing further calls for now. */ +"AUTH_RATE_LIMITED" | +/** The source could not be reached, or refused to serve. */ +"UPSTREAM_UNAVAILABLE" | +/** The source did not answer in time. */ +"UPSTREAM_TIMEOUT" | +/** The source answered with something trdr cannot read. */ +"UPSTREAM_MALFORMED" | +/** A bundle did not match its declared schema. */ +"BUNDLE_SCHEMA" | +/** A bundle file did not match its recorded hash. */ +"BUNDLE_HASH" | +/** A bundle was larger than the limits allow. */ +"BUNDLE_SIZE" | +/** A bundle record contradicts one already stored. */ +"BUNDLE_CONFLICT" | +/** The data needed for this does not cover the range asked for. */ +"DATA_INCOMPLETE" | +/** The data is of a kind this version does not support. */ +"DATA_UNSUPPORTED" | +/** A value would have been used before it could have been known. */ +"DATA_FUTURE_LEAK" | +/** Stored data failed its own consistency check. */ +"DATA_INTEGRITY" | +/** A strategy file could not be read as a strategy. */ +"STRATEGY_SYNTAX" | +/** A strategy asked for a rule this engine does not implement. */ +"STRATEGY_UNSUPPORTED_RULE" | +/** A strategy named a period that cannot be run. */ +"STRATEGY_INVALID_PERIOD" | +/** The database is held by someone else right now. */ +"DB_BUSY" | +/** A schema migration could not be applied. */ +"DB_MIGRATION" | +/** The database failed its integrity check. */ +"DB_INTEGRITY" | +/** There is no room left on the disk. */ +"DB_DISK_FULL" | +/** A backup package could not be authenticated or decrypted. */ +"BACKUP_AUTHENTICATION" | +/** A backup package failed a checksum. */ +"BACKUP_CHECKSUM" | +/** A backup package is from a version this build cannot read. */ +"BACKUP_UNSUPPORTED_VERSION" | +/** A backup package is missing part of itself. */ +"BACKUP_INCOMPLETE" | +/** The app is not running, so this cannot be done. */ +"APP_NOT_RUNNING" | +/** The message did not fit the protocol. */ +"APP_PROTOCOL_VERSION" | +/** The caller is not allowed to ask for this. */ +"APP_PERMISSION" | +/** A person rejected the request. */ +"APPROVAL_REJECTED" | +/** The request was not answered in time. */ +"APPROVAL_EXPIRED" | +/** What was approved is no longer what would happen. */ +"APPROVAL_STALE" | +/** The requester went away before the answer came. */ +"APPROVAL_DISCONNECTED" | +/** The agent executable is not where it was expected. */ +"TERMINAL_EXECUTABLE_MISSING" | +/** The agent process could not be started. */ +"TERMINAL_SPAWN" | +/** The agent process ended. */ +"TERMINAL_EXITED"; + +/** + * A failure, in the form every surface receives it. + * + * Built through [`ErrorEnvelope::new`] and the `with_*` methods so that the + * version is always set and the parameter rules always run. + */ +export type ErrorEnvelope = ErrorEnvelope_Serialize | ErrorEnvelope_Deserialize; + +/** + * A failure, in the form every surface receives it. + * + * Built through [`ErrorEnvelope::new`] and the `with_*` methods so that the + * version is always set and the parameter rules always run. + */ +export type ErrorEnvelope_Deserialize = { + /** Protocol version, the same field the IPC frames carry. */ + v: EnvelopeVersion, + /** + * The request this failure answers, when it answers one. + * + * Absent for a failure that happened outside a request, such as one during + * start-up. + */ + id?: string | null, + /** What went wrong, as a value a caller can branch on. */ + code: ErrorCode, + /** Named values the surface may put into its own sentence. */ + params?: { [key in string]: ErrorParam }, + /** Whether trying again could work. */ + retryability: Retryability, + /** Points at the full cause chain in the local log. */ + cause_chain_id?: string | null, + /** What the upstream said, when an upstream said anything. */ + upstream?: UpstreamStatus_Deserialize | null, +}; + +/** + * A failure, in the form every surface receives it. + * + * Built through [`ErrorEnvelope::new`] and the `with_*` methods so that the + * version is always set and the parameter rules always run. + */ +export type ErrorEnvelope_Serialize = { + /** Protocol version, the same field the IPC frames carry. */ + v: EnvelopeVersion, + /** + * The request this failure answers, when it answers one. + * + * Absent for a failure that happened outside a request, such as one during + * start-up. + */ + id?: string | null, + /** What went wrong, as a value a caller can branch on. */ + code: ErrorCode, + /** Named values the surface may put into its own sentence. */ + params?: { [key in string]: ErrorParam }, + /** Whether trying again could work. */ + retryability: Retryability, + /** Points at the full cause chain in the local log. */ + cause_chain_id?: string | null, + /** What the upstream said, when an upstream said anything. */ + upstream?: UpstreamStatus_Serialize | null, +}; + +/** + * A value an error carries for a surface to put into its own sentence. + * + * The variants are small on purpose. There is no "any JSON" variant, because + * that is how an upstream response body ends up on a screen. + */ +export type ErrorParam = +/** A short piece of text, at most [`MAX_PARAM_TEXT_BYTES`] bytes. */ +{ type: "text"; value: string } | +/** A whole number, such as a count or a limit. */ +{ type: "integer"; value: number } | +/** A flag. */ +{ type: "boolean"; value: boolean }; + +/** + * The envelope [`ping`] answers with, named so the bindings can describe it. + * + * Transparent: this is `UiResponseEnvelope` on the wire and in the + * generated TypeScript alike. See the module documentation for why the name + * exists at all. + */ +export type PingResponse = PingResponse_Serialize | PingResponse_Deserialize; + +/** + * The envelope [`ping`] answers with, named so the bindings can describe it. + * + * Transparent: this is `UiResponseEnvelope` on the wire and in the + * generated TypeScript alike. See the module documentation for why the name + * exists at all. + */ +export type PingResponse_Deserialize = UiResponseEnvelope_Deserialize; + +/** + * The envelope [`ping`] answers with, named so the bindings can describe it. + * + * Transparent: this is `UiResponseEnvelope` on the wire and in the + * generated TypeScript alike. See the module documentation for why the name + * exists at all. + */ +export type PingResponse_Serialize = UiResponseEnvelope_Serialize; + +/** + * What [`ping`] answers with. + * + * Deliberately not part of `trdr-core`: this is a liveness check on the bridge + * between the WebView and the host, not a contract between the app and the CLI. + */ +export type Pong = { + /** + * The protocol version this build speaks, so the screen learns it from the + * same round-trip that proved the bridge works. + */ + protocol_version: number, +}; + +/** Whether trying the same thing again could work. */ +export type Retryability = +/** Retrying changes nothing; something has to change first. */ +{ kind: "no" } | +/** Retrying now is reasonable. */ +{ kind: "immediate" } | +/** Retrying is reasonable after waiting. */ +{ kind: "after_seconds"; +/** How long to wait. */ +seconds: number }; + +/** Either a result or an error envelope, never both and never neither. */ +export type UiOutcome = UiOutcome_Serialize | UiOutcome_Deserialize; + +/** Either a result or an error envelope, never both and never neither. */ +export type UiOutcome_Deserialize = +/** The command succeeded, and this is what it produced. */ +{ status: "ok"; value: T } | +/** The command failed. */ +{ status: "error"; value: ErrorEnvelope_Deserialize }; + +/** Either a result or an error envelope, never both and never neither. */ +export type UiOutcome_Serialize = +/** The command succeeded, and this is what it produced. */ +{ status: "ok"; value: T } | +/** The command failed. */ +{ status: "error"; value: ErrorEnvelope_Serialize }; + +/** The answer to a [`UiCommandEnvelope`]. */ +export type UiResponseEnvelope = UiResponseEnvelope_Serialize | UiResponseEnvelope_Deserialize; + +/** The answer to a [`UiCommandEnvelope`]. */ +export type UiResponseEnvelope_Deserialize = { + /** Protocol version. */ + v: EnvelopeVersion, + /** The command this answers. */ + id: string, + /** How it went. */ + outcome: UiOutcome_Deserialize, +}; + +/** The answer to a [`UiCommandEnvelope`]. */ +export type UiResponseEnvelope_Serialize = { + /** Protocol version. */ + v: EnvelopeVersion, + /** The command this answers. */ + id: string, + /** How it went. */ + outcome: UiOutcome_Serialize, +}; + +/** + * What an upstream said, kept beside trdr's own code. + * + * This is the status line and the upstream's own short code, never a header or + * a body. It exists so that a `401` from a broker and an unreachable broker + * stay two different facts all the way to the screen, and so that a support + * question can be answered without turning logging back on. + */ +export type UpstreamStatus = UpstreamStatus_Serialize | UpstreamStatus_Deserialize; + +/** + * What an upstream said, kept beside trdr's own code. + * + * This is the status line and the upstream's own short code, never a header or + * a body. It exists so that a `401` from a broker and an unreachable broker + * stay two different facts all the way to the screen, and so that a support + * question can be answered without turning logging back on. + */ +export type UpstreamStatus_Deserialize = { + /** The HTTP status, when the exchange got far enough to have one. */ + http_status?: number | null, + /** The upstream's own short status code, when it publishes one. */ + upstream_code?: string | null, +}; + +/** + * What an upstream said, kept beside trdr's own code. + * + * This is the status line and the upstream's own short code, never a header or + * a body. It exists so that a `401` from a broker and an unreachable broker + * stay two different facts all the way to the screen, and so that a support + * question can be answered without turning logging back on. + */ +export type UpstreamStatus_Serialize = { + /** The HTTP status, when the exchange got far enough to have one. */ + http_status?: number | null, + /** The upstream's own short status code, when it publishes one. */ + upstream_code?: string | null, +}; + diff --git a/apps/desktop/src/ipc/requestId.test.ts b/apps/desktop/src/ipc/requestId.test.ts new file mode 100644 index 0000000..881db23 --- /dev/null +++ b/apps/desktop/src/ipc/requestId.test.ts @@ -0,0 +1,92 @@ +import { expect, test } from "vitest"; + +import { REQUEST_ID_LENGTH, newRequestId } from "./requestId"; + +/** + * The rules `trdr_core::id::parse_ulid` applies, restated here. + * + * Twenty-six characters of Crockford base32, and a first character of `0` to + * `7`. The last rule is the one worth spelling out: the encoding has room for + * 130 bits and a ULID is 128, so `8` through `Z` in the leading position name a + * value that does not fit, and Rust refuses it rather than truncating. An id + * this file produced that failed it would be refused at the IPC boundary, which + * is the failure this test exists to make impossible. + */ +const ACCEPTED_BY_THE_HOST = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/; + +test("an id is the shape the host parses", () => +{ + const id = newRequestId(); + + expect(id).toHaveLength(REQUEST_ID_LENGTH); + expect(id).toMatch(ACCEPTED_BY_THE_HOST); +}); + +/** + * Not a fixed sample: the leading character depends on the clock, and the check + * that matters is that no clock this side can produce makes an id Rust refuses. + */ +test("no instant a clock can name produces an id the host would refuse", () => +{ + const instants = [ + 0, + 1, + Date.now(), + // The last millisecond 48 bits can hold, and past it. + 2 ** 48 - 1, + 2 ** 48, + 2 ** 53, + // Values a caller should never pass, which must still not produce + // something malformed. + -1, + Number.NaN, + Number.POSITIVE_INFINITY + ]; + + for (const instant of instants) + { + expect(newRequestId(instant), `at ${instant}`).toMatch(ACCEPTED_BY_THE_HOST); + } +}); + +test("the timestamp is the leading ten characters and sorts by time", () => +{ + const earlier = newRequestId(1_786_356_000_000); + const later = newRequestId(1_786_356_000_001); + + expect(earlier.slice(0, 10) < later.slice(0, 10)).toBe(true); + expect(newRequestId(1_786_356_000_000).slice(0, 10)).toBe(earlier.slice(0, 10)); +}); + +/** + * The whole point of the random tail. Two commands issued in one tick — which + * is what mounting the shell does — must not correlate to each other. + */ +test("two ids minted in the same millisecond are different", () => +{ + const now = 1_786_356_000_000; + const minted = new Set(Array.from({ length: 2_000 }, () => newRequestId(now))); + + expect(minted.size).toBe(2_000); +}); + +/** + * A generator that leans on one part of the alphabet is a generator whose + * randomness is narrower than it looks. Eighty bits over 2 000 draws touches + * every one of the 32 characters with overwhelming probability. + */ +test("the random tail uses the whole alphabet", () => +{ + const seen = new Set(); + + for (let draw = 0; draw < 2_000; draw += 1) + { + for (const character of newRequestId().slice(10)) + { + seen.add(character); + } + } + + expect(seen.size).toBe(32); + expect([...seen].some((character) => "ILOU".includes(character))).toBe(false); +}); diff --git a/apps/desktop/src/ipc/requestId.ts b/apps/desktop/src/ipc/requestId.ts new file mode 100644 index 0000000..0754d19 --- /dev/null +++ b/apps/desktop/src/ipc/requestId.ts @@ -0,0 +1,110 @@ +/** + * Minting the request id every command carries. + * + * # Why the WebView mints it and the host does not + * + * `UiCommandEnvelope` in `trdr-core` puts three fields on every command — a + * protocol version, a request id, and the command itself — and the id is the + * caller's. It has to be: it is what lets the side that is *waiting* name what + * it is waiting for. A host-minted id arrives with the answer and is therefore + * useless for anything the caller wanted it for — correlating a retry, matching + * a log line to a click, naming the request in an approval sheet the screen + * raised. Section 9.2 requires a repeated id to get the same terminal result + * rather than a second execution, and only the caller knows which of its + * requests is a repeat of which. + * + * The alternative was to drop the parameter and have the host mint one. It + * would be less code here and would also delete the boundary this scaffold + * exists to prove: `ping` takes a `RequestId`, not a string, so a WebView that + * sends anything but 26 characters of Crockford base32 is refused by + * deserialisation before the handler is entered. That refusal is what makes the + * bridge typed rather than merely generated, and it needs a caller that supplies + * an id to be a refusal of anything. + * + * # What this is not + * + * It is not a general ULID library, and nothing outside this file should treat + * it as one. Monotonicity within a millisecond is not implemented, because + * nothing here depends on two ids made in the same millisecond being ordered — + * they only have to be different, and 80 bits of randomness is what makes them + * so. If a later feature needs a sortable id, it belongs in Rust, where + * `trdr-runtime` already mints them properly. + */ + +/** + * Crockford base32, which is the ULID alphabet. + * + * `I`, `L`, `O`, and `U` are missing on purpose: the first three cannot be + * misread as `1` and `0`, and the fourth cannot appear inside an unfortunate + * word. `trdr_core::id` parses exactly this alphabet. + */ +const ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"; + +/** How many characters carry the timestamp. 10 × 5 bits, holding 48. */ +const TIME_CHARACTERS = 10; + +/** How many carry the random tail. 16 × 5 bits, which is exactly 80. */ +const RANDOM_CHARACTERS = 16; + +/** The largest instant 48 bits can name, in milliseconds. */ +const LAST_INSTANT = 2 ** 48 - 1; + +/** How long every id is. `trdr_core::id::ID_LENGTH` says the same number. */ +export const REQUEST_ID_LENGTH = TIME_CHARACTERS + RANDOM_CHARACTERS; + +/** + * A fresh request id. + * + * @param now - The instant to stamp it with, for a test that wants a known + * prefix. Callers in the app leave it out. + */ +export function newRequestId(now: number = Date.now()): string +{ + return encodeTime(now) + encodeRandom(); +} + +/** + * The instant, as ten characters, most significant first. + * + * Division rather than bit shifting, because JavaScript's bitwise operators + * truncate to 32 bits and a millisecond timestamp needs 41. Integer division of + * a value below 2^53 is exact, so nothing is lost. + */ +function encodeTime(milliseconds: number): string +{ + let remaining = Number.isFinite(milliseconds) + ? Math.min(Math.max(Math.floor(milliseconds), 0), LAST_INSTANT) + : 0; + let text = ""; + + for (let written = 0; written < TIME_CHARACTERS; written += 1) + { + text = ALPHABET[remaining % 32] + text; + remaining = Math.floor(remaining / 32); + } + + return text; +} + +/** + * Eighty bits of randomness, as sixteen characters. + * + * The low five bits of each byte are taken. That is uniform without rejection + * sampling because 256 is a whole multiple of 32 — every one of the 32 values + * comes from exactly eight of the 256 bytes. Taking `byte % 30`, or slicing a + * value out of `Math.random`, would not be. + */ +function encodeRandom(): string +{ + const bytes = new Uint8Array(RANDOM_CHARACTERS); + crypto.getRandomValues(bytes); + + let text = ""; + + for (const byte of bytes) + { + text += ALPHABET[byte & 31]; + } + + return text; +} diff --git a/apps/desktop/src/main.tsx b/apps/desktop/src/main.tsx new file mode 100644 index 0000000..bf84426 --- /dev/null +++ b/apps/desktop/src/main.tsx @@ -0,0 +1,18 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; + +import { App } from "./App"; +import "./shell/shell.css"; + +const root = document.getElementById("root"); + +if (root === null) +{ + throw new Error("index.html is missing its root element"); +} + +createRoot(root).render( + + + +); diff --git a/apps/desktop/src/screens/Lab.tsx b/apps/desktop/src/screens/Lab.tsx new file mode 100644 index 0000000..b9937fd --- /dev/null +++ b/apps/desktop/src/screens/Lab.tsx @@ -0,0 +1,11 @@ +import { Placeholder } from "./Placeholder"; + +/** + * Where a strategy is drafted and backtested. + * + * A placeholder. Its model is `lab.draft.get` in section 9.1. + */ +export function Lab() +{ + return ; +} diff --git a/apps/desktop/src/screens/Placeholder.tsx b/apps/desktop/src/screens/Placeholder.tsx new file mode 100644 index 0000000..8446e0f --- /dev/null +++ b/apps/desktop/src/screens/Placeholder.tsx @@ -0,0 +1,26 @@ +/** What a screen needs to say while it is still a placeholder. */ +type PlaceholderProps = { + /** The screen's name, as the navigation spells it. */ + readonly title: string; + /** The section 9.1 command that will supply this screen's model. */ + readonly command: string; +}; + +/** + * A screen that exists so the shell can be navigated, and says so. + * + * Naming the command each screen is waiting on keeps this honest: it is the + * difference between a placeholder and an empty state, and it means nobody has + * to read the route table to find out what is missing. + */ +export function Placeholder({ title, command }: PlaceholderProps) +{ + return ( +
+

{title}

+

+ Waiting on {command}. +

+
+ ); +} diff --git a/apps/desktop/src/screens/Strategies.tsx b/apps/desktop/src/screens/Strategies.tsx new file mode 100644 index 0000000..cfcd836 --- /dev/null +++ b/apps/desktop/src/screens/Strategies.tsx @@ -0,0 +1,11 @@ +import { Placeholder } from "./Placeholder"; + +/** + * Every strategy the user has registered. + * + * A placeholder. Its model is `strategies.list` in section 9.1. + */ +export function Strategies() +{ + return ; +} diff --git a/apps/desktop/src/screens/Today.tsx b/apps/desktop/src/screens/Today.tsx new file mode 100644 index 0000000..709e758 --- /dev/null +++ b/apps/desktop/src/screens/Today.tsx @@ -0,0 +1,12 @@ +import { Placeholder } from "./Placeholder"; + +/** + * What happened since the user last looked. + * + * A placeholder. The model behind it is `today.get` in section 9.1, and the + * track that implements that command is the one that fills this in. + */ +export function Today() +{ + return ; +} diff --git a/apps/desktop/src/shell/AppShell.test.tsx b/apps/desktop/src/shell/AppShell.test.tsx new file mode 100644 index 0000000..cbd9ec9 --- /dev/null +++ b/apps/desktop/src/shell/AppShell.test.tsx @@ -0,0 +1,251 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { RouterProvider, createMemoryRouter } from "react-router"; +import { afterEach, beforeEach, expect, test, vi } from "vitest"; + +import type { BootstrapResponse_Serialize, PingResponse_Serialize } from "../bindings"; +import { commands } from "../bindings"; +import { paths, routes } from "./routes"; +import { TERMINAL_HOST_ID } from "./TerminalHost"; + +/** + * The host, replaced. + * + * Everything below `commands` is Tauri's `invoke`, which needs a WebView with a + * host behind it and has none here. What is being tested is what the shell does + * with an answer, so the answer is supplied; that the real host gives one is + * `src-tauri/tests/capability.rs`'s job, and that the two meet is the live smoke + * run's. + */ +vi.mock("../bindings", () => ({ + commands: { + ping: vi.fn(), + bootstrapGet: vi.fn() + } +})); + +const WORKSPACE = "01KZNP0GQ3X8ARK9DQ489Z7WJ8"; +const REQUEST = "01KZNNR5X818P3J6ENYKSADP8W"; + +const pong: PingResponse_Serialize = { + v: 1, + id: REQUEST, + outcome: { status: "ok", value: { protocol_version: 1 } } +}; + +const bootstrap: BootstrapResponse_Serialize = { + v: 1, + id: REQUEST, + outcome: { + status: "ok", + value: { + protocol_version: 1, + app_version: "9.9.9", + workspace_id: WORKSPACE, + workspace_path: "/private/tmp/trdr-t/x/workspaces/default", + schema_version: 1 + } + } +}; + +beforeEach(() => +{ + vi.mocked(commands.ping).mockResolvedValue(pong); + vi.mocked(commands.bootstrapGet).mockResolvedValue(bootstrap); +}); + +afterEach(() => +{ + document.body.innerHTML = ""; + vi.resetAllMocks(); +}); + +/** + * The shell, mounted through the same route table the app uses. + * + * A memory router rather than the hash router, because the assertion is about + * what survives a navigation and not about how the URL is written down. + * + * Mounting waits for the host round trip to settle. Not for the assertion's + * sake — the tests below are about navigation — but because a state update + * landing after a test has finished belongs to no test at all, and is the kind + * of thing that fails once a week on someone else's machine. + */ +async function renderShell() +{ + const router = createMemoryRouter(routes, { initialEntries: [paths.today] }); + const view = render(); + + await screen.findByText(WORKSPACE); + + return view; +} + +/** The one id a command was called with, or a failure saying it was not. */ +function theIdItWasCalledWith(calls: readonly (readonly [string])[]): string +{ + const [call] = calls; + + if (call === undefined) + { + throw new Error("the command was never called"); + } + + expect(calls).toHaveLength(1); + + return call[0]; +} + +function terminalHost(): HTMLElement +{ + const host = document.getElementById(TERMINAL_HOST_ID); + + if (host === null) + { + throw new Error("the terminal host is not in the document"); + } + + return host; +} + +test("the three sections are reachable", async () => +{ + const user = userEvent.setup(); + await renderShell(); + + expect(screen.getByRole("heading", { name: "Today" })).toBeDefined(); + + await user.click(screen.getByRole("link", { name: "Lab" })); + expect(screen.getByRole("heading", { name: "Lab" })).toBeDefined(); + + await user.click(screen.getByRole("link", { name: "Strategies" })); + expect(screen.getByRole("heading", { name: "Strategies" })).toBeDefined(); +}); + +/** + * The one thing section 14 item 3 asks of the terminal host. + * + * What is asserted is node identity, not presence. A host that unmounted and + * remounted would still be findable by id after every navigation and would still + * be useless: whatever the terminal attached to the old node — the canvas, the + * scrollback, the process on the other end of it — went away with it. + * + * The sentinel is that attachment, in miniature. Something outside React writes + * into the node, exactly as xterm will, and then the test navigates through every + * screen and back. If the sentinel is still there, React never replaced the node. + */ +test("the terminal host keeps its node across every navigation", async () => +{ + const user = userEvent.setup(); + await renderShell(); + + const host = terminalHost(); + const sentinel = document.createElement("span"); + sentinel.textContent = "attached out of band, the way a terminal attaches"; + host.append(sentinel); + + for (const section of ["Lab", "Strategies", "Today", "Lab"]) + { + await user.click(screen.getByRole("link", { name: section })); + + expect(terminalHost()).toBe(host); + expect(host.contains(sentinel)).toBe(true); + } + + expect(host.isConnected).toBe(true); +}); + +/** + * The host is empty until the track that owns the PTY fills it. A screen that + * started rendering into it would take the node's contents with it on the next + * navigation, which is the failure the test above is about, arriving by a + * different door. + */ +test("the terminal host starts empty and belongs to the layout", async () => +{ + await renderShell(); + + expect(terminalHost().childElementCount).toBe(0); + expect(screen.getByRole("region", { name: "Agent terminal" })).toBeDefined(); +}); + +/** + * Section 14's stop condition, from this side: the shell asks the host and puts + * what came back on the screen. + * + * The workspace id is asserted rather than merely present, because a status line + * rendering a constant would look identical to one rendering a response. + */ +test("the shell shows the workspace the host answered with", async () => +{ + await renderShell(); + + const status = screen.getByRole("status"); + + expect(status.textContent).toContain(WORKSPACE); + expect(status.textContent).toContain("9.9.9"); + expect(commands.ping).toHaveBeenCalledTimes(1); + expect(commands.bootstrapGet).toHaveBeenCalledTimes(1); +}); + +/** + * The two commands are two requests, and the id is what says so. Reusing one + * would be the screen claiming they were the same request — which section 9.2 + * gives a meaning to: the same terminal result rather than a second execution. + */ +test("each command carries an id the host would accept, and its own", async () => +{ + await renderShell(); + + const ids = [ + theIdItWasCalledWith(vi.mocked(commands.ping).mock.calls), + theIdItWasCalledWith(vi.mocked(commands.bootstrapGet).mock.calls) + ]; + + for (const id of ids) + { + expect(id).toMatch(/^[0-7][0-9A-HJKMNP-TV-Z]{25}$/); + } + + expect(ids[0]).not.toBe(ids[1]); +}); + +/** + * A host that is not there says so. The failure this rules out is the quiet + * one: a status line that stays on its loading text forever, which looks like a + * slow app rather than a broken bridge. + */ +test("a host that cannot be reached is named rather than left loading", async () => +{ + vi.mocked(commands.ping).mockRejectedValue(new Error("there is no host here")); + + const router = createMemoryRouter(routes, { initialEntries: [paths.today] }); + render(); + + const status = await screen.findByText("APP_NOT_RUNNING"); + + expect(status).toBeDefined(); + expect(commands.bootstrapGet).not.toHaveBeenCalled(); +}); + +/** + * A refusal is an envelope, not a rejected promise (see `commands.rs`), and the + * shell has to read the code out of it rather than treat the response as a + * success because it arrived. + */ +test("a refused command is read out of the envelope it came in", async () => +{ + vi.mocked(commands.bootstrapGet).mockResolvedValue({ + v: 1, + id: REQUEST, + outcome: { + status: "error", + value: { v: 1, code: "DB_BUSY", retryability: { kind: "no" } } + } + }); + + const router = createMemoryRouter(routes, { initialEntries: [paths.today] }); + render(); + + expect(await screen.findByText("DB_BUSY")).toBeDefined(); +}); diff --git a/apps/desktop/src/shell/AppShell.tsx b/apps/desktop/src/shell/AppShell.tsx new file mode 100644 index 0000000..30c17f0 --- /dev/null +++ b/apps/desktop/src/shell/AppShell.tsx @@ -0,0 +1,53 @@ +import { NavLink, Outlet } from "react-router"; + +import { navigation } from "./routes"; +import { StatusLine } from "./StatusLine"; +import { TerminalHost } from "./TerminalHost"; + +/** + * The frame every screen is shown inside. + * + * This is a layout route, so React Router mounts it once and then only replaces + * what `` renders. Two things depend on that and nothing else: + * + * - The terminal host below the outlet keeps its DOM node across navigation. See + * `TerminalHost` for why that is not a detail, and `AppShell.test.tsx` for the + * test that holds it. + * - The navigation is rendered once rather than rebuilt per screen. + * - The status line asks the host once, on mount, rather than once per screen. + * It is the only thing in the shell that talks to Rust, and it is here because + * this is the component whose lifetime is the window's. + * + * The layout is placeholder. Colours, spacing, and type come from the UI kit in + * `design/ui-kit`, and the work of actually applying it is a later unit; what is + * here is the minimum that makes the three regions distinguishable on screen. + */ +export function AppShell() +{ + return ( +
+ + +
+ +
+ + + + +
+ ); +} diff --git a/apps/desktop/src/shell/StatusLine.tsx b/apps/desktop/src/shell/StatusLine.tsx new file mode 100644 index 0000000..21dd0c1 --- /dev/null +++ b/apps/desktop/src/shell/StatusLine.tsx @@ -0,0 +1,53 @@ +import type { HostStatus } from "./useHostStatus"; +import { useHostStatus } from "./useHostStatus"; + +/** + * The one line that says which workspace this window is looking at. + * + * Minimal on purpose. Section 14's stop condition asks for evidence that the + * WebView round-trips a typed command and gets real values back, and the + * smallest honest evidence is the workspace id and the build version rendered + * from the response rather than from a constant. Everything else a status bar + * eventually carries — job state, collector health, the terminal's process — + * arrives with the track that owns it. + * + * `role="status"` rather than a plain element: the content changes after the + * first paint, and an assistive technology should hear that it did without the + * change stealing focus. + */ +export function StatusLine() +{ + const status = useHostStatus(); + + return ( +
+ +
+ ); +} + +function Content({ status }: { readonly status: HostStatus }) +{ + if (status.kind === "connecting") + { + return asking the host…; + } + + if (status.kind === "unreachable") + { + return ( + + the host did not answer {status.code} + + ); + } + + return ( + <> + workspace + {status.workspaceId} + trdr + {status.appVersion} + + ); +} diff --git a/apps/desktop/src/shell/TerminalHost.tsx b/apps/desktop/src/shell/TerminalHost.tsx new file mode 100644 index 0000000..e396a49 --- /dev/null +++ b/apps/desktop/src/shell/TerminalHost.tsx @@ -0,0 +1,30 @@ +import { memo } from "react"; + +/** The id the host container carries, so a test and a later terminal can find it. */ +export const TERMINAL_HOST_ID = "terminal-host"; + +/** + * The container the agent terminal will attach to. + * + * Empty on purpose, and it stays empty until the track that owns the PTY brings + * xterm with it. What this component contributes now is the one property that + * cannot be added afterwards: a DOM node that outlives navigation. + * + * A terminal is not a view of state that can be thrown away and rebuilt. It is a + * live process on the other end of a pipe, with scrollback, a cursor, and a + * program that is mid-sentence. Unmounting the node it is attached to loses all + * of that, and no amount of caching further up puts it back. So the node is + * created once, by the layout route, and every screen change happens beside it. + * + * `React.memo` is not what keeps it mounted — reconciliation is, because + * `AppShell` renders this in the same position on every render. The memo only + * spares it the re-render, which matters once something expensive is attached. + */ +export const TerminalHost = memo(function TerminalHost() +{ + return ( +
+
+
+ ); +}); diff --git a/apps/desktop/src/shell/routes.tsx b/apps/desktop/src/shell/routes.tsx new file mode 100644 index 0000000..9bd9b02 --- /dev/null +++ b/apps/desktop/src/shell/routes.tsx @@ -0,0 +1,45 @@ +import type { RouteObject } from "react-router"; + +import { Lab } from "../screens/Lab"; +import { Strategies } from "../screens/Strategies"; +import { Today } from "../screens/Today"; +import { AppShell } from "./AppShell"; + +/** Where each screen lives, in one place, so a link and a route cannot disagree. */ +export const paths = { + today: "/", + lab: "/lab", + strategies: "/strategies" +} as const; + +/** What the navigation shows, in the order it shows it. */ +export const navigation = [ + { path: paths.today, label: "Today" }, + { path: paths.lab, label: "Lab" }, + { path: paths.strategies, label: "Strategies" } +] as const; + +/** + * The route table. + * + * The shape is the load-bearing part, not the contents. `AppShell` is a layout + * route: it has no path of its own, and every screen is a child of it. React + * Router swaps what `` renders and leaves the element around it alone, + * so the terminal host inside `AppShell` is mounted once for the life of the + * window rather than once per navigation. Section 14 item 3 calls that host + * persistent, and this nesting is what makes it so. + * + * Exported rather than inlined into the router so the tests can mount the same + * table through a memory router. A test that declared its own routes would be + * proving something about its own tree. + */ +export const routes: RouteObject[] = [ + { + element: , + children: [ + { path: paths.today, element: }, + { path: paths.lab, element: }, + { path: paths.strategies, element: } + ] + } +]; diff --git a/apps/desktop/src/shell/shell.css b/apps/desktop/src/shell/shell.css new file mode 100644 index 0000000..a81c459 --- /dev/null +++ b/apps/desktop/src/shell/shell.css @@ -0,0 +1,129 @@ +/* + * Enough layout to see the three regions, and no more. + * + * The real visual system is `design/ui-kit` — its tokens, its components, its + * type scale — and applying it is a later unit with visual review attached. What + * is here exists so the shell can be looked at and navigated: a nav, a screen + * area, and a terminal host that occupies real space rather than collapsing to + * nothing. The few colours are lifted from `design/ui-kit/tokens.v2.json` so that + * this placeholder is at least not a different palette from the one that lands. + */ + +:root +{ + --shell-surface: #171c22; /* graphite-950 */ + --shell-chrome: #20262d; /* graphite-900 */ + --shell-line: #35404a; /* graphite-700 */ + --shell-terminal: #0d1116; /* graphite-1000 */ + --shell-text: #edf1f3; /* neutral-100 */ + --shell-muted: #929ba5; /* neutral-500 */ + --shell-accent: #c8f24c; /* lime-500 */ + --shell-gap: 16px; +} + +html, +body, +#root +{ + height: 100%; +} + +body +{ + margin: 0; + background: var(--shell-surface); + color: var(--shell-text); + font-family: ui-sans-serif, system-ui, sans-serif; +} + +.shell +{ + display: grid; + grid-template-rows: auto minmax(0, 1fr) minmax(0, 2fr) auto; + height: 100%; +} + +.shell__nav +{ + display: flex; + gap: var(--shell-gap); + padding: var(--shell-gap); + background: var(--shell-chrome); + border-bottom: 1px solid var(--shell-line); +} + +.shell__link +{ + color: var(--shell-muted); + text-decoration: none; +} + +.shell__link--active +{ + color: var(--shell-accent); +} + +.shell__screen +{ + overflow: auto; + padding: var(--shell-gap); +} + +.placeholder__title +{ + margin: 0 0 8px; + font-size: 20px; + font-weight: 600; +} + +.placeholder__note +{ + margin: 0; + color: var(--shell-muted); +} + +/* + * The host keeps its own space whether or not anything is attached to it. A + * region that collapses when empty and reappears when filled is a region that + * resizes the terminal the moment it starts, which is the one thing a PTY + * notices. + */ +.terminal-host +{ + border-top: 1px solid var(--shell-line); + background: var(--shell-terminal); + min-height: 0; +} + +.terminal-host__surface +{ + height: 100%; +} + +/* + * The status line keeps its height in every state, so the shell does not shift + * under the pointer when the host answers. + */ +.shell__status +{ + display: flex; + gap: 8px; + align-items: baseline; + padding: 6px var(--shell-gap); + background: var(--shell-chrome); + border-top: 1px solid var(--shell-line); + font-size: 12px; + line-height: 20px; +} + +.shell__status-label, +.shell__status-note +{ + color: var(--shell-muted); +} + +.shell__status-value +{ + color: var(--shell-text); + font-family: ui-monospace, SFMono-Regular, monospace; +} diff --git a/apps/desktop/src/shell/useHostStatus.ts b/apps/desktop/src/shell/useHostStatus.ts new file mode 100644 index 0000000..8b0d8bf --- /dev/null +++ b/apps/desktop/src/shell/useHostStatus.ts @@ -0,0 +1,101 @@ +import { useEffect, useState } from "react"; + +import type { ErrorCode } from "../bindings"; +import { commands } from "../bindings"; +import { newRequestId } from "../ipc/requestId"; + +/** + * What the shell knows about the host it is running inside. + * + * Three states and no fourth. In particular there is no "ready, but some fields + * are missing": either the round trip finished and the workspace is known, or + * it did not and the screen says so. A status line that half-renders is how a + * broken host comes to look like a working one. + */ +export type HostStatus = + | { readonly kind: "connecting" } + | { + readonly kind: "ready"; + readonly workspaceId: string; + readonly appVersion: string; + } + | { readonly kind: "unreachable"; readonly code: ErrorCode }; + +/** + * The round trip section 14's stop condition asks for. + * + * Two commands, in this order and for two different reasons. `ping` proves the + * bridge carries a validated type — the id below is parsed into a ULID on the + * Rust side before the handler is entered, so a bridge that is merely connected + * but not typed fails here. `bootstrap.get` then asks the question a screen + * actually has, and answers it out of the workspace the host opened under the + * writer lease. + * + * Both ids are minted here rather than reused. They correlate two separate + * requests, and a screen that sent one id twice would be claiming they were the + * same request. + */ +async function askTheHost(): Promise +{ + const pong = await commands.ping(newRequestId()); + + if (pong.outcome.status !== "ok") + { + return { kind: "unreachable", code: pong.outcome.value.code }; + } + + const bootstrap = await commands.bootstrapGet(newRequestId()); + + if (bootstrap.outcome.status !== "ok") + { + return { kind: "unreachable", code: bootstrap.outcome.value.code }; + } + + return { + kind: "ready", + workspaceId: bootstrap.outcome.value.workspace_id, + appVersion: bootstrap.outcome.value.app_version + }; +} + +/** + * Asks the host once, when the shell mounts. + * + * A command returns an error envelope rather than rejecting, so the `catch` + * below is not the ordinary failure path — it is the case where there is no + * host to answer at all: a WebView with no Tauri behind it, or a command the + * capability file does not grant. `APP_NOT_RUNNING` is the code section 12 has + * for exactly that, and it is what `trdr app status` says in the same + * situation. + */ +export function useHostStatus(): HostStatus +{ + const [status, setStatus] = useState({ kind: "connecting" }); + + useEffect(() => + { + let listening = true; + + const settle = (answer: HostStatus) => + { + if (listening) + { + setStatus(answer); + } + }; + + askTheHost() + .catch((): HostStatus => ({ kind: "unreachable", code: "APP_NOT_RUNNING" })) + .then(settle); + + // React runs an effect twice in development's strict mode, and a WebView + // that navigated away mid-flight would otherwise set state on a torn + // down component. The second answer is dropped rather than raced. + return () => + { + listening = false; + }; + }, []); + + return status; +} diff --git a/apps/desktop/tsconfig.app.json b/apps/desktop/tsconfig.app.json new file mode 100644 index 0000000..3462926 --- /dev/null +++ b/apps/desktop/tsconfig.app.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "types": ["vite/client"], + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "exactOptionalPropertyTypes": true, + "verbatimModuleSyntax": true, + "isolatedModules": true, + "skipLibCheck": true, + + "noEmit": true, + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo" + }, + "include": ["src"] +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json new file mode 100644 index 0000000..f6df6c7 --- /dev/null +++ b/apps/desktop/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/apps/desktop/tsconfig.node.json b/apps/desktop/tsconfig.node.json new file mode 100644 index 0000000..c89f306 --- /dev/null +++ b/apps/desktop/tsconfig.node.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "skipLibCheck": true, + + "noEmit": true, + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo" + }, + "include": ["vite.config.ts"] +} diff --git a/apps/desktop/vite.config.ts b/apps/desktop/vite.config.ts new file mode 100644 index 0000000..d79e2c6 --- /dev/null +++ b/apps/desktop/vite.config.ts @@ -0,0 +1,34 @@ +/// +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()], + + // Tauri starts this server and then points the WebView at a fixed address, + // so moving to another port when 1420 is taken would leave the window + // looking at nothing. `tauri.conf.json`'s `devUrl` is the other half of this + // pair and has to say the same number. + clearScreen: false, + server: { + port: 1420, + strictPort: true + }, + + build: { + // macOS 13 is the floor `tauri.conf.json` sets, and its WebView is + // Safari 16. Targeting 15 leaves a version of room and keeps esbuild + // from emitting syntax the packaged WebView would only fail on at + // runtime — where a browser-based `vite preview` would never show it. + target: "safari15", + // Read by `frontendDist` in `tauri.conf.json`. + outDir: "dist", + emptyOutDir: true + }, + + test: { + environment: "jsdom", + include: ["src/**/*.test.{ts,tsx}"], + css: true + } +}); diff --git a/ci/check-core-deps.sh b/ci/check-core-deps.sh new file mode 100755 index 0000000..d10d6c1 --- /dev/null +++ b/ci/check-core-deps.sh @@ -0,0 +1,84 @@ +#!/usr/bin/env bash +# +# Holds `trdr-core` to its dependency boundary. +# +# `crates/trdr-core` is the domain crate: identity, the error envelope, and the +# two IPC contracts. It is pure by design, and its Cargo.toml says so in a +# comment — no Tauri, no tokio, no rusqlite, no reqwest, nothing that reaches +# the filesystem, the network, the clock, or the OS. A comment is not a gate, +# though, and the crate that everything else depends on is exactly the one where +# an accidental I/O dependency would be least visible in review. +# +# So the boundary is checked rather than described. The five crates below are +# the whole of `trdr-core`'s normal dependency tree; their own transitive +# dependencies come along with them and are not the subject here. Dev- and +# build-dependencies are deliberately out of scope: they never ship, which is +# why `--edges normal` is the right edge set. +# +# Run it from anywhere in the repository. + +set -euo pipefail + +ALLOWED=$( + cat <<'CRATES' +serde +serde_json +specta +thiserror +ulid +CRATES +) + +cd "$(dirname "$0")/.." + +# `--prefix none` drops the tree glyphs and leaves one `name version` line per +# crate, starting with trdr-core itself. `--locked` refuses to quietly update +# Cargo.lock, so a lockfile that does not match the manifests fails here too. +# +# trdr-core is dropped inside awk rather than by a following `grep -v`: with +# `pipefail` set, a grep that matches nothing exits 1, so a core that had lost +# every dependency would kill the script instead of reporting what went missing. +actual=$( + cargo tree --package trdr-core --edges normal --depth 1 --prefix none --locked | + awk 'NF && $1 != "trdr-core" { print $1 }' | + sort -u +) + +expected=$(printf '%s\n' "$ALLOWED" | sort -u) + +if [ "$actual" = "$expected" ]; then + echo "trdr-core depends on exactly the five crates it is allowed to depend on." + exit 0 +fi + +added=$(comm -13 <(printf '%s\n' "$expected") <(printf '%s\n' "$actual")) +removed=$(comm -23 <(printf '%s\n' "$expected") <(printf '%s\n' "$actual")) + +echo "trdr-core's dependency boundary moved." >&2 +echo >&2 + +if [ -n "$added" ]; then + echo "Added, and not on the allowlist:" >&2 + printf '%s\n' "$added" | awk '{ print " " $0 }' >&2 + echo >&2 + echo "If the new crate performs I/O — filesystem, network, clock, OS, a" >&2 + echo "database, or a UI toolkit — it does not belong in trdr-core. Move" >&2 + echo "the code that needs it to trdr-runtime, which is where effects" >&2 + echo "live, and leave the value types behind in core." >&2 + echo >&2 + echo "If it is genuinely a pure data crate and the boundary should grow," >&2 + echo "that is a design decision, not a build fix. Get it agreed, then add" >&2 + echo "the crate to the ALLOWED list in ci/check-core-deps.sh in the same" >&2 + echo "commit that adds the dependency, so the two are reviewed together." >&2 +fi + +if [ -n "$removed" ]; then + [ -n "$added" ] && echo >&2 + echo "On the allowlist, but no longer depended on:" >&2 + printf '%s\n' "$removed" | awk '{ print " " $0 }' >&2 + echo >&2 + echo "If dropping it was intended, remove it from the ALLOWED list in" >&2 + echo "ci/check-core-deps.sh." >&2 +fi + +exit 1 diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..e62a337 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,11 @@ +# trdr has one error type, `trdr_core::ErrorEnvelope`, and it is a wire value: +# a code, named parameters, retryability, a cause chain id, and what the upstream +# said. Section 12 of docs/FOUNDATION_DESIGN.md is what fixes those fields, and +# together they come to 144 bytes. +# +# The default `result_large_err` threshold is 128, so every `Result<_, +# ErrorEnvelope>` in the codebase would otherwise have to be `Result<_, +# Box>`. Boxing the one error type everywhere buys 16 bytes on a +# path that is already the failure path, and costs a `Box` in every signature a +# reader has to look at. The threshold is raised once, here, instead. +large-error-threshold = 192 diff --git a/crates/trdr-cli/Cargo.toml b/crates/trdr-cli/Cargo.toml new file mode 100644 index 0000000..2f83c4e --- /dev/null +++ b/crates/trdr-cli/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "trdr-cli" +description = "the trdr command line interface" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[[bin]] +name = "trdr" +path = "src/main.rs" + +[dependencies] +trdr-core.workspace = true +trdr-runtime.workspace = true +clap.workspace = true +serde_json.workspace = true diff --git a/crates/trdr-cli/src/main.rs b/crates/trdr-cli/src/main.rs new file mode 100644 index 0000000..03d0319 --- /dev/null +++ b/crates/trdr-cli/src/main.rs @@ -0,0 +1,344 @@ +//! The `trdr` command line interface. +//! +//! The CLI's job (`docs/FOUNDATION_DESIGN.md` sections 3.1 and 9.2) is to ask a +//! running app for things over a Unix socket, to read and compute locally when +//! the app is closed, and to render the same structured result either as text +//! for a person or as JSON for whatever is reading it. It never looks up a +//! credential value, never registers a strategy by itself, and never writes to +//! the database while the app is running. +//! +//! `trdr app status` is the first command to make the round trip. It connects to +//! `/run/app.sock`, sends the `app.status` frame, and prints what +//! comes back. When nothing is listening there is no app to ask, and section 9.2 +//! calls that `APP_NOT_RUNNING` rather than a socket error shown to a person. +//! +//! The words live on this side. An [`ErrorEnvelope`] carries a code and named +//! parameters and never a sentence, so that no payload can reach a screen by +//! being put into a message somewhere further down (section 12). + +use clap::{Parser, Subcommand, ValueEnum}; +use std::io::Write; +use std::path::PathBuf; +use trdr_core::error::{ErrorCode, ErrorEnvelope, ErrorParam}; +use trdr_core::socket::AppStatusResult; +use trdr_runtime::root::ProductRoot; +use trdr_runtime::socket::AppClient; + +/// Read a structured result, write it the way the caller asked for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] +enum Format +{ + /// Sentences, for a person. + Text, + /// One JSON object, for a program. + Json +} + +/// Local-first trading research terminal for macOS. +#[derive(Debug, Parser)] +#[command(name = "trdr", version, about, long_about = None)] +struct Cli +{ + /// How to write the result. + #[arg(long, value_enum, default_value_t = Format::Text, global = true)] + format: Format, + + /// The directory trdr keeps its state in. Defaults to `~/.trdr`. + #[arg(long, value_name = "PATH", global = true)] + product_root: Option, + + #[command(subcommand)] + command: Command +} + +/// The top-level command groups. +#[derive(Debug, Subcommand)] +enum Command +{ + /// The running trdr app. + App + { + #[command(subcommand)] + command: AppCommand + } +} + +/// Commands about the app itself. +#[derive(Debug, Subcommand)] +enum AppCommand +{ + /// Whether an app is running, and what it is holding. + Status +} + +/// Everything a command can produce when it worked. +#[derive(Debug)] +enum Report +{ + /// What a running app said about itself. + AppStatus(Box) +} + +fn main() +{ + let cli = Cli::parse(); + + match run(&cli) + { + Ok(report) => print_report(&report, cli.format), + Err(envelope) => + { + print_error(&envelope, cli.format); + std::process::exit(1); + } + } +} + +/// Runs the command, producing either a result or the one error shape. +fn run(cli: &Cli) -> Result +{ + let root = match &cli.product_root + { + Some(path) => ProductRoot::at(path), + None => ProductRoot::for_current_user().map_err(|_| { + ErrorEnvelope::new(ErrorCode::AppNotRunning) + .with_param("reason", ErrorParam::literal("home_unknown")) + })? + }; + + match cli.command + { + Command::App { + command: AppCommand::Status + } => app_status(&root) + } +} + +/// Asks the running app about itself. +fn app_status(root: &ProductRoot) -> Result +{ + let mut client = AppClient::connect(root).map_err(|error| error.to_envelope())?; + let status = client.app_status().map_err(|error| error.to_envelope())?; + + Ok(Report::AppStatus(Box::new(status))) +} + +/// Writes a result: JSON to standard output, sentences to standard output. +fn print_report(report: &Report, format: Format) +{ + let Report::AppStatus(status) = report; + + match format + { + Format::Json => match serde_json::to_string(status.as_ref()) + { + Ok(json) => println!("{json}"), + Err(_) => print_error( + &ErrorEnvelope::new(ErrorCode::AppProtocolVersion) + .with_param("reason", ErrorParam::literal("result_unserialisable")), + Format::Text + ) + }, + Format::Text => println!("{}", render_status(status)) + } +} + +/// Writes the envelope in the requested form: JSON to standard output for a +/// program to read, sentences to standard error for a person. +fn print_error(envelope: &ErrorEnvelope, format: Format) +{ + match format + { + Format::Json => match serde_json::to_string(envelope) + { + Ok(json) => println!("{json}"), + Err(error) => eprintln!("the result could not be written as JSON: {error}") + }, + Format::Text => + { + let mut stderr = std::io::stderr(); + let _ = writeln!(stderr, "{}", render_error(envelope)); + } + } +} + +/// Turns a status into the lines a person reads. +fn render_status(status: &AppStatusResult) -> String +{ + let lease = match status.holds_writer_lease + { + true => "held", + false => "not held" + }; + + format!( + "the trdr app is running\n \ + version {}\n \ + pid {}\n \ + writer lease {lease}\n \ + product root {}\n \ + workspace {}\n \ + schema version {}", + status.app_version, + status.pid, + status.product_root.display(), + status.workspace_path.display(), + status.schema_version + ) +} + +/// Turns an envelope into the words a person reads. +/// +/// The wording lives on this side, never in the envelope, so that the CLI and +/// the app can say the same thing differently and neither can leak a payload +/// into a sentence (section 12). +fn render_error(envelope: &ErrorEnvelope) -> String +{ + let mut text = format!("error: {}", wire_code(envelope.code)); + + if let Some(detail) = detail(envelope.code) + { + text.push_str(&format!("\n {detail}")); + } + + if let Some(ErrorParam::Text(reason)) = envelope.params.get("reason") + { + text.push_str(&format!("\n ({reason})")); + } + + text +} + +/// The sentence for a code this build can produce. +/// +/// The table is short because this build produces few codes. Filling it in +/// happens as each command lands; until then an unmapped code still prints its +/// own name rather than nothing. +fn detail(code: ErrorCode) -> Option<&'static str> +{ + match code + { + ErrorCode::AppNotRunning => Some("the trdr app is not running."), + ErrorCode::AppProtocolVersion => Some("the app answered something this build cannot read."), + ErrorCode::AppPermission => Some("the app refused this request."), + _ => None + } +} + +/// The code as it is spelled on the wire. +fn wire_code(code: ErrorCode) -> String +{ + match serde_json::to_string(&code) + { + Ok(json) => json.trim_matches('"').to_owned(), + Err(_) => format!("{:?}", code.family()) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + use trdr_core::error::ErrorFamily; + use trdr_runtime::test_support::scratch_root; + + fn sample_status() -> AppStatusResult + { + AppStatusResult { + app_version: "0.0.0".to_owned(), + pid: 4321, + holds_writer_lease: true, + product_root: PathBuf::from("/private/tmp/t"), + workspace_path: PathBuf::from("/private/tmp/t/workspaces/default"), + schema_version: 1 + } + } + + #[test] + fn the_argument_parser_is_well_formed() + { + use clap::CommandFactory; + Cli::command().debug_assert(); + } + + #[test] + fn app_status_parses_with_either_format() + { + for arguments in [ + vec!["trdr", "app", "status"], + vec!["trdr", "app", "status", "--format", "json"], + vec!["trdr", "--format", "json", "app", "status"], + vec!["trdr", "app", "status", "--product-root", "/private/tmp/t"] + ] + { + assert!(Cli::try_parse_from(arguments).is_ok()); + } + + assert!(Cli::try_parse_from(["trdr", "app", "status", "--format", "yaml"]).is_err()); + assert!(Cli::try_parse_from(["trdr", "app"]).is_err()); + } + + #[test] + fn a_product_root_nothing_is_serving_answers_that_no_app_can_be_reached() + { + let root = scratch_root("cli-not-running"); + let envelope = app_status(&root).expect_err("nothing is listening there"); + + assert_eq!(envelope.code, ErrorCode::AppNotRunning); + assert_eq!(envelope.code.family(), ErrorFamily::App); + assert_eq!( + envelope.params.get("reason"), + Some(&ErrorParam::literal("no_socket")) + ); + } + + #[test] + fn the_json_form_of_a_failure_is_the_envelope_itself() + { + let root = scratch_root("cli-json"); + let envelope = app_status(&root).expect_err("nothing is listening there"); + let json = serde_json::to_string(&envelope).unwrap(); + + assert_eq!( + serde_json::from_str::(&json).unwrap(), + envelope + ); + assert!(json.contains("\"code\":\"APP_NOT_RUNNING\"")); + } + + #[test] + fn the_text_form_of_a_failure_names_the_code_and_explains_it() + { + let text = render_error( + &ErrorEnvelope::new(ErrorCode::AppNotRunning) + .with_param("reason", ErrorParam::literal("no_socket")) + ); + + assert!(text.contains("APP_NOT_RUNNING")); + assert!(text.contains("not running")); + assert!(text.contains("no_socket")); + } + + #[test] + fn the_text_form_of_a_status_says_what_the_app_is_holding() + { + let text = render_status(&sample_status()); + + assert!(text.contains("running")); + assert!(text.contains("4321")); + assert!(text.contains("held")); + assert!(text.contains("/private/tmp/t/workspaces/default")); + } + + #[test] + fn the_json_form_of_a_status_is_the_result_itself() + { + let status = sample_status(); + let json = serde_json::to_string(&status).unwrap(); + + assert_eq!( + serde_json::from_str::(&json).unwrap(), + status + ); + } +} diff --git a/crates/trdr-cli/tests/app_status.rs b/crates/trdr-cli/tests/app_status.rs new file mode 100644 index 0000000..9f9e092 --- /dev/null +++ b/crates/trdr-cli/tests/app_status.rs @@ -0,0 +1,153 @@ +//! `trdr app status`, from the built binary to a real socket and back. +//! +//! The Tauri app does not exist yet, so what stands in for it is the runtime's +//! own socket server started against a scratch product root. That is the whole +//! path section 9.2 describes apart from who is holding the other end: a real +//! process runs the real binary, which connects to a real Unix socket and reads a +//! real typed answer. + +use std::process::{Command, Output}; +use std::sync::Arc; +use trdr_core::socket::AppStatusResult; +use trdr_core::ErrorEnvelope; +use trdr_runtime::root::ProductRoot; +use trdr_runtime::socket::{AppBridge, ServerHandle, SocketServer}; +use trdr_runtime::test_support::scratch_root; + +/// The status an app with this workspace open would give. +struct Standing +{ + root: ProductRoot +} + +impl AppBridge for Standing +{ + fn app_status(&self) -> AppStatusResult + { + AppStatusResult { + app_version: "0.0.0".to_owned(), + pid: std::process::id(), + holds_writer_lease: true, + product_root: self.root.path().to_path_buf(), + workspace_path: self.root.default_workspace_dir(), + schema_version: 1 + } + } +} + +/// Starts a server standing in for the app. +fn start(root: &ProductRoot) -> ServerHandle +{ + let lease = Arc::new( + root.acquire_writer_lease() + .expect("the lease should be free") + ); + let bridge = Arc::new(Standing { root: root.clone() }); + + SocketServer::bind(lease, bridge) + .expect("the socket should bind") + .spawn() +} + +/// Runs the real `trdr` binary against a product root. +fn trdr(root: &ProductRoot, arguments: &[&str]) -> Output +{ + Command::new(env!("CARGO_BIN_EXE_trdr")) + .args(arguments) + .arg("--product-root") + .arg(root.path()) + .output() + .expect("the trdr binary could not be run") +} + +#[test] +fn app_status_prints_what_the_running_app_said() +{ + let root = scratch_root("cli-status-json"); + let server = start(&root); + + let output = trdr(&root, &["app", "status", "--format", "json"]); + + assert!( + output.status.success(), + "trdr app status failed: {output:?}" + ); + + let status: AppStatusResult = serde_json::from_slice(&output.stdout) + .unwrap_or_else(|error| panic!("the output was not a status: {error}")); + + assert_eq!(status.product_root, root.path()); + assert_eq!(status.workspace_path, root.default_workspace_dir()); + assert!(status.holds_writer_lease); + assert_eq!(status.schema_version, 1); + + server.stop(); +} + +#[test] +fn the_text_form_names_what_the_app_is_holding() +{ + let root = scratch_root("cli-status-text"); + let server = start(&root); + + let output = trdr(&root, &["app", "status"]); + let text = String::from_utf8_lossy(&output.stdout); + + assert!( + output.status.success(), + "trdr app status failed: {output:?}" + ); + assert!(text.contains("running"), "unexpected output: {text}"); + assert!( + text.contains(&root.default_workspace_dir().display().to_string()), + "unexpected output: {text}" + ); + + server.stop(); +} + +// The behaviour stage 0 established, kept: with no app to ask, the answer is the +// envelope, on standard output, and the exit status is 1. +#[test] +fn with_no_app_listening_the_json_form_is_the_app_not_running_envelope() +{ + let root = scratch_root("cli-absent-json"); + let output = trdr(&root, &["app", "status", "--format", "json"]); + + assert_eq!(output.status.code(), Some(1)); + + let envelope: ErrorEnvelope = serde_json::from_slice(&output.stdout) + .unwrap_or_else(|error| panic!("the output was not an envelope: {error}")); + + assert_eq!(envelope.code, trdr_core::ErrorCode::AppNotRunning); +} + +#[test] +fn with_no_app_listening_the_text_form_goes_to_standard_error() +{ + let root = scratch_root("cli-absent-text"); + let output = trdr(&root, &["app", "status"]); + + assert_eq!(output.status.code(), Some(1)); + assert!(output.stdout.is_empty(), "a failure was written to stdout"); + assert!(String::from_utf8_lossy(&output.stderr).contains("APP_NOT_RUNNING")); +} + +// The app stopping is not a different answer from the app never having been +// there, which is what makes the socket file irrelevant to the question. +#[test] +fn an_app_that_stops_goes_back_to_app_not_running() +{ + let root = scratch_root("cli-stopped"); + let server = start(&root); + + assert!(trdr(&root, &["app", "status"]).status.success()); + + server.stop(); + + let output = trdr(&root, &["app", "status", "--format", "json"]); + let envelope: ErrorEnvelope = serde_json::from_slice(&output.stdout).unwrap(); + + assert_eq!(output.status.code(), Some(1)); + assert_eq!(envelope.code, trdr_core::ErrorCode::AppNotRunning); +} diff --git a/crates/trdr-core/Cargo.toml b/crates/trdr-core/Cargo.toml new file mode 100644 index 0000000..b65123f --- /dev/null +++ b/crates/trdr-core/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "trdr-core" +description = "trdr domain core: identity, error envelope, and the two IPC contracts" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +# This crate is pure. It must never gain Tauri, tokio, rusqlite, reqwest, or any +# other crate that touches the filesystem, the network, the clock, or the OS. +# serde_json is data, not I/O: the socket contract is JSON and the strict parser +# lives here. +[dependencies] +serde.workspace = true +serde_json.workspace = true +specta.workspace = true +thiserror.workspace = true +ulid.workspace = true diff --git a/crates/trdr-core/src/envelope.rs b/crates/trdr-core/src/envelope.rs new file mode 100644 index 0000000..ec5d3e9 --- /dev/null +++ b/crates/trdr-core/src/envelope.rs @@ -0,0 +1,154 @@ +//! The version field every trdr message carries. +//! +//! `docs/FOUNDATION_DESIGN.md` section 9.2 makes the protocol version and the +//! request id mandatory on every frame, and requires an unknown version to be +//! refused rather than guessed at. That refusal has to be a value a caller can +//! branch on, so the check lives here and produces [`UnsupportedVersion`], not a +//! free-text parse failure. + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; + +/// The only wire version this binary speaks. +pub const PROTOCOL_VERSION: u16 = 1; + +/// The `v` field of a versioned message. +/// +/// A value of this type can only exist for a version this binary supports, so +/// code downstream of parsing never has to re-check it. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, specta::Type)] +pub struct EnvelopeVersion(u16); + +impl EnvelopeVersion +{ + /// The version this binary writes. + pub const CURRENT: Self = Self(PROTOCOL_VERSION); + + /// Accepts a raw version number, or reports the one it saw. + pub fn supported(raw: u16) -> Result + { + if raw == PROTOCOL_VERSION + { + Ok(Self(raw)) + } + else + { + Err(UnsupportedVersion { + found: raw, + supported: PROTOCOL_VERSION + }) + } + } + + /// The raw number, for putting back on the wire. + pub const fn get(self) -> u16 + { + self.0 + } +} + +impl Default for EnvelopeVersion +{ + fn default() -> Self + { + Self::CURRENT + } +} + +impl fmt::Debug for EnvelopeVersion +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result + { + write!(f, "EnvelopeVersion({})", self.0) + } +} + +impl fmt::Display for EnvelopeVersion +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result + { + write!(f, "{}", self.0) + } +} + +impl Serialize for EnvelopeVersion +{ + fn serialize(&self, serializer: S) -> Result + { + self.0.serialize(serializer) + } +} + +/// Deserialising is the second gate on the version. +/// +/// The frame parsers in [`crate::socket`] check the version first so that the +/// caller gets [`UnsupportedVersion`] with the number in it. This impl exists so +/// that a message deserialised by any other route — a Tauri command argument, +/// for instance — still cannot carry a version this binary does not speak. +impl<'de> Deserialize<'de> for EnvelopeVersion +{ + fn deserialize>(deserializer: D) -> Result + { + let raw = u16::deserialize(deserializer)?; + Self::supported(raw).map_err(D::Error::custom) + } +} + +/// A message arrived with a protocol version this binary does not speak. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("unsupported protocol version {found}, this build speaks {supported}")] +pub struct UnsupportedVersion +{ + /// The version the message claimed. + pub found: u16, + /// The version this binary speaks. + pub supported: u16 +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn current_version_round_trips_as_a_bare_number() + { + let json = serde_json::to_string(&EnvelopeVersion::CURRENT).unwrap(); + assert_eq!(json, "1"); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + EnvelopeVersion::CURRENT + ); + } + + #[test] + fn an_unsupported_version_is_a_typed_error() + { + let refused = EnvelopeVersion::supported(2).unwrap_err(); + assert_eq!( + refused, + UnsupportedVersion { + found: 2, + supported: 1 + } + ); + } + + #[test] + fn deserialising_refuses_an_unsupported_version() + { + assert!(serde_json::from_str::("2").is_err()); + assert!(serde_json::from_str::("0").is_err()); + } + + #[test] + fn debug_shows_the_version_and_nothing_else() + { + assert_eq!( + format!("{:?}", EnvelopeVersion::CURRENT), + "EnvelopeVersion(1)" + ); + assert_eq!(EnvelopeVersion::CURRENT.to_string(), "1"); + } +} diff --git a/crates/trdr-core/src/error.rs b/crates/trdr-core/src/error.rs new file mode 100644 index 0000000..8acf534 --- /dev/null +++ b/crates/trdr-core/src/error.rs @@ -0,0 +1,643 @@ +//! The one shape every trdr failure takes. +//! +//! `docs/FOUNDATION_DESIGN.md` section 12 fixes the families of error code and +//! says an error exposes a code, safe message parameters, whether retrying is +//! worthwhile, and an id pointing at the cause chain in the local log — and +//! nothing else. There is deliberately no free-text message field on +//! [`ErrorEnvelope`]: the screen and the CLI each render their own words from +//! the code, so an error can never carry a sentence that leaked an HTTP body, a +//! credential, an account number, or terminal bytes. +//! +//! The other rule this file exists to enforce is that one failure does not stand +//! in for another. A client that folds every non-success upstream reply into a +//! single "request failed" makes "your key is wrong" and "the broker is down" +//! indistinguishable, and the person reading the screen cannot tell whether to +//! fix something or wait. So [`ErrorCode`] keeps those apart, and +//! [`UpstreamStatus`] keeps what the upstream actually said next to it. + +use crate::envelope::EnvelopeVersion; +use crate::id::{CauseChainId, RequestId}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; + +/// The longest text a single error parameter may carry. +/// +/// Parameters name things — a collector, a file, a count. Anything longer than +/// this is a payload, and payloads do not belong in an error. +pub const MAX_PARAM_TEXT_BYTES: usize = 256; + +/// The longest an upstream's own status string may be. +pub const MAX_UPSTREAM_CODE_BYTES: usize = 64; + +/// A failure, in the form every surface receives it. +/// +/// Built through [`ErrorEnvelope::new`] and the `with_*` methods so that the +/// version is always set and the parameter rules always run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct ErrorEnvelope +{ + /// Protocol version, the same field the IPC frames carry. + pub v: EnvelopeVersion, + /// The request this failure answers, when it answers one. + /// + /// Absent for a failure that happened outside a request, such as one during + /// start-up. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// What went wrong, as a value a caller can branch on. + pub code: ErrorCode, + /// Named values the surface may put into its own sentence. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub params: BTreeMap, + /// Whether trying again could work. + pub retryability: Retryability, + /// Points at the full cause chain in the local log. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cause_chain_id: Option, + /// What the upstream said, when an upstream said anything. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream: Option +} + +impl ErrorEnvelope +{ + /// A failure with the given code, not retryable, carrying nothing else. + pub fn new(code: ErrorCode) -> Self + { + Self { + v: EnvelopeVersion::CURRENT, + id: None, + code, + params: BTreeMap::new(), + retryability: Retryability::No, + cause_chain_id: None, + upstream: None + } + } + + /// Ties this failure to the request that produced it. + pub fn with_request(mut self, id: RequestId) -> Self + { + self.id = Some(id); + self + } + + /// Adds one named value for the surface to render. + pub fn with_param(mut self, name: impl Into, value: ErrorParam) -> Self + { + self.params.insert(name.into(), value); + self + } + + /// Says whether and when retrying is worth it. + pub fn with_retryability(mut self, retryability: Retryability) -> Self + { + self.retryability = retryability; + self + } + + /// Points at the cause chain kept in the local log. + pub fn with_cause_chain(mut self, id: CauseChainId) -> Self + { + self.cause_chain_id = Some(id); + self + } + + /// Keeps what the upstream reported alongside trdr's own code. + pub fn with_upstream(mut self, upstream: UpstreamStatus) -> Self + { + self.upstream = Some(upstream); + self + } +} + +/// A value an error carries for a surface to put into its own sentence. +/// +/// The variants are small on purpose. There is no "any JSON" variant, because +/// that is how an upstream response body ends up on a screen. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(tag = "type", content = "value", rename_all = "snake_case")] +pub enum ErrorParam +{ + /// A short piece of text, at most [`MAX_PARAM_TEXT_BYTES`] bytes. + Text(String), + /// A whole number, such as a count or a limit. + Integer(i64), + /// A flag. + Boolean(bool) +} + +impl ErrorParam +{ + /// Accepts short text, or refuses it for being payload-sized. + pub fn text(value: impl Into) -> Result + { + let value = value.into(); + + match value.len() <= MAX_PARAM_TEXT_BYTES + { + true => Ok(Self::Text(value)), + false => Err(ParamTooLong { + found: value.len(), + limit: MAX_PARAM_TEXT_BYTES + }) + } + } + + /// Text from a string the program itself wrote. + /// + /// For the fixed words the code supplies, where the length is not in + /// question and a `Result` would only be noise at the call site. + pub fn literal(value: &'static str) -> Self + { + debug_assert!( + value.len() <= MAX_PARAM_TEXT_BYTES, + "a literal parameter is over the limit" + ); + Self::Text(value.to_owned()) + } +} + +/// A parameter was longer than an error parameter is allowed to be. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +#[error("parameter is {found} bytes, the limit is {limit}")] +pub struct ParamTooLong +{ + /// How long the text was. + pub found: usize, + /// The limit it passed. + pub limit: usize +} + +/// Whether trying the same thing again could work. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum Retryability +{ + /// Retrying changes nothing; something has to change first. + No, + /// Retrying now is reasonable. + Immediate, + /// Retrying is reasonable after waiting. + AfterSeconds + { + /// How long to wait. + seconds: u32 + } +} + +/// What an upstream said, kept beside trdr's own code. +/// +/// This is the status line and the upstream's own short code, never a header or +/// a body. It exists so that a `401` from a broker and an unreachable broker +/// stay two different facts all the way to the screen, and so that a support +/// question can be answered without turning logging back on. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct UpstreamStatus +{ + /// The HTTP status, when the exchange got far enough to have one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http_status: Option, + /// The upstream's own short status code, when it publishes one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_code: Option +} + +impl UpstreamStatus +{ + /// Records an HTTP status on its own. + pub const fn http(status: u16) -> Self + { + Self { + http_status: Some(status), + upstream_code: None + } + } + + /// Adds the upstream's own code, if it is short and plain enough to be one. + /// + /// The check is not a secret filter — it is the boundary that stops a + /// response body from being passed off as a status code. + pub fn with_upstream_code(mut self, code: impl Into) + -> Result + { + let code = code.into(); + + if code.is_empty() || code.len() > MAX_UPSTREAM_CODE_BYTES + { + return Err(UpstreamCodeError::Length { found: code.len() }); + } + + if !code + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') + { + return Err(UpstreamCodeError::Charset); + } + + self.upstream_code = Some(code); + Ok(self) + } +} + +/// An upstream status code was not shaped like a status code. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum UpstreamCodeError +{ + /// Empty, or longer than [`MAX_UPSTREAM_CODE_BYTES`]. + #[error("expected 1 to {MAX_UPSTREAM_CODE_BYTES} bytes, found {found}")] + Length + { + /// How many bytes arrived. + found: usize + }, + /// Held something other than letters, digits, `-`, `_`, and `.`. + #[error("contains a character that is not a letter, digit, '-', '_', or '.'")] + Charset +} + +/// The group a code belongs to (section 12). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ErrorFamily +{ + /// Credentials. + Auth, + /// An external source. + Upstream, + /// An ingest bundle. + Bundle, + /// The data itself. + Data, + /// A strategy specification. + Strategy, + /// The local database. + Db, + /// A backup package. + Backup, + /// The running app and the protocol to it. + App, + /// A human approval. + Approval, + /// The agent terminal. + Terminal +} + +impl ErrorFamily +{ + /// The prefix every code in this family carries on the wire. + pub const fn as_str(self) -> &'static str + { + match self + { + Self::Auth => "AUTH", + Self::Upstream => "UPSTREAM", + Self::Bundle => "BUNDLE", + Self::Data => "DATA", + Self::Strategy => "STRATEGY", + Self::Db => "DB", + Self::Backup => "BACKUP", + Self::App => "APP", + Self::Approval => "APPROVAL", + Self::Terminal => "TERMINAL" + } + } +} + +/// Every failure trdr can report. +/// +/// The set is closed and comes from section 12. A new kind of failure gets a new +/// variant here rather than a new sentence somewhere, and the exhaustive match in +/// [`ErrorCode::family`] means adding one is a compile error until it has been +/// placed in a family. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, specta::Type, +)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ErrorCode +{ + /// No credential is stored for this source. + AuthMissing, + /// The stored credential was refused by the source. + /// + /// Distinct from [`ErrorCode::UpstreamUnavailable`] on purpose: this one is + /// the user's to fix. + AuthInvalid, + /// The source is refusing further calls for now. + AuthRateLimited, + /// The source could not be reached, or refused to serve. + UpstreamUnavailable, + /// The source did not answer in time. + UpstreamTimeout, + /// The source answered with something trdr cannot read. + UpstreamMalformed, + /// A bundle did not match its declared schema. + BundleSchema, + /// A bundle file did not match its recorded hash. + BundleHash, + /// A bundle was larger than the limits allow. + BundleSize, + /// A bundle record contradicts one already stored. + BundleConflict, + /// The data needed for this does not cover the range asked for. + DataIncomplete, + /// The data is of a kind this version does not support. + DataUnsupported, + /// A value would have been used before it could have been known. + DataFutureLeak, + /// Stored data failed its own consistency check. + DataIntegrity, + /// A strategy file could not be read as a strategy. + StrategySyntax, + /// A strategy asked for a rule this engine does not implement. + StrategyUnsupportedRule, + /// A strategy named a period that cannot be run. + StrategyInvalidPeriod, + /// The database is held by someone else right now. + DbBusy, + /// A schema migration could not be applied. + DbMigration, + /// The database failed its integrity check. + DbIntegrity, + /// There is no room left on the disk. + DbDiskFull, + /// A backup package could not be authenticated or decrypted. + BackupAuthentication, + /// A backup package failed a checksum. + BackupChecksum, + /// A backup package is from a version this build cannot read. + BackupUnsupportedVersion, + /// A backup package is missing part of itself. + BackupIncomplete, + /// The app is not running, so this cannot be done. + AppNotRunning, + /// The message did not fit the protocol. + AppProtocolVersion, + /// The caller is not allowed to ask for this. + AppPermission, + /// A person rejected the request. + ApprovalRejected, + /// The request was not answered in time. + ApprovalExpired, + /// What was approved is no longer what would happen. + ApprovalStale, + /// The requester went away before the answer came. + ApprovalDisconnected, + /// The agent executable is not where it was expected. + TerminalExecutableMissing, + /// The agent process could not be started. + TerminalSpawn, + /// The agent process ended. + TerminalExited +} + +impl ErrorCode +{ + /// Every code, in the order section 12 lists them. + pub const ALL: &'static [ErrorCode] = &[ + Self::AuthMissing, + Self::AuthInvalid, + Self::AuthRateLimited, + Self::UpstreamUnavailable, + Self::UpstreamTimeout, + Self::UpstreamMalformed, + Self::BundleSchema, + Self::BundleHash, + Self::BundleSize, + Self::BundleConflict, + Self::DataIncomplete, + Self::DataUnsupported, + Self::DataFutureLeak, + Self::DataIntegrity, + Self::StrategySyntax, + Self::StrategyUnsupportedRule, + Self::StrategyInvalidPeriod, + Self::DbBusy, + Self::DbMigration, + Self::DbIntegrity, + Self::DbDiskFull, + Self::BackupAuthentication, + Self::BackupChecksum, + Self::BackupUnsupportedVersion, + Self::BackupIncomplete, + Self::AppNotRunning, + Self::AppProtocolVersion, + Self::AppPermission, + Self::ApprovalRejected, + Self::ApprovalExpired, + Self::ApprovalStale, + Self::ApprovalDisconnected, + Self::TerminalExecutableMissing, + Self::TerminalSpawn, + Self::TerminalExited + ]; + + /// The family this code belongs to. + pub const fn family(self) -> ErrorFamily + { + match self + { + Self::AuthMissing | Self::AuthInvalid | Self::AuthRateLimited => ErrorFamily::Auth, + Self::UpstreamUnavailable | Self::UpstreamTimeout | Self::UpstreamMalformed => + { + ErrorFamily::Upstream + } + Self::BundleSchema | Self::BundleHash | Self::BundleSize | Self::BundleConflict => + { + ErrorFamily::Bundle + } + Self::DataIncomplete + | Self::DataUnsupported + | Self::DataFutureLeak + | Self::DataIntegrity => ErrorFamily::Data, + Self::StrategySyntax | Self::StrategyUnsupportedRule | Self::StrategyInvalidPeriod => + { + ErrorFamily::Strategy + } + Self::DbBusy | Self::DbMigration | Self::DbIntegrity | Self::DbDiskFull => + { + ErrorFamily::Db + } + Self::BackupAuthentication + | Self::BackupChecksum + | Self::BackupUnsupportedVersion + | Self::BackupIncomplete => ErrorFamily::Backup, + Self::AppNotRunning | Self::AppProtocolVersion | Self::AppPermission => + { + ErrorFamily::App + } + Self::ApprovalRejected + | Self::ApprovalExpired + | Self::ApprovalStale + | Self::ApprovalDisconnected => ErrorFamily::Approval, + Self::TerminalExecutableMissing | Self::TerminalSpawn | Self::TerminalExited => + { + ErrorFamily::Terminal + } + } + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn every_code_serialises_to_its_own_string() + { + let mut seen = std::collections::BTreeSet::new(); + + for code in ErrorCode::ALL + { + let wire = serde_json::to_string(code).unwrap(); + assert!( + seen.insert(wire.clone()), + "two codes share the wire form {wire}" + ); + } + + assert_eq!(seen.len(), ErrorCode::ALL.len()); + } + + #[test] + fn a_code_carries_its_family_as_a_prefix() + { + for code in ErrorCode::ALL + { + let wire = serde_json::to_string(code).unwrap(); + let wire = wire.trim_matches('"'); + let prefix = format!("{}_", code.family().as_str()); + assert!( + wire.starts_with(&prefix), + "{wire} is not in family {prefix}" + ); + } + } + + #[test] + fn the_documented_spellings_are_the_ones_on_the_wire() + { + for (code, wire) in [ + (ErrorCode::AppNotRunning, "\"APP_NOT_RUNNING\""), + (ErrorCode::AuthRateLimited, "\"AUTH_RATE_LIMITED\""), + (ErrorCode::DataFutureLeak, "\"DATA_FUTURE_LEAK\""), + (ErrorCode::ApprovalStale, "\"APPROVAL_STALE\""), + ( + ErrorCode::BackupUnsupportedVersion, + "\"BACKUP_UNSUPPORTED_VERSION\"" + ), + ( + ErrorCode::TerminalExecutableMissing, + "\"TERMINAL_EXECUTABLE_MISSING\"" + ) + ] + { + assert_eq!(serde_json::to_string(&code).unwrap(), wire); + assert_eq!(serde_json::from_str::(wire).unwrap(), code); + } + } + + #[test] + fn a_rejected_key_and_an_unreachable_source_stay_apart() + { + // The failure this test exists for: a client that turns every non-2xx + // reply into one error makes these two the same event. + let bad_key = ErrorEnvelope::new(ErrorCode::AuthInvalid).with_upstream( + UpstreamStatus::http(401) + .with_upstream_code("EGW00121") + .unwrap() + ); + let source_down = ErrorEnvelope::new(ErrorCode::UpstreamUnavailable) + .with_upstream(UpstreamStatus::http(503)) + .with_retryability(Retryability::AfterSeconds { seconds: 30 }); + + assert_ne!(bad_key.code, source_down.code); + assert_ne!(bad_key.retryability, source_down.retryability); + assert_eq!(bad_key.upstream.unwrap().http_status, Some(401)); + assert_eq!(source_down.upstream.unwrap().upstream_code, None); + } + + #[test] + fn an_envelope_round_trips() + { + let envelope = ErrorEnvelope::new(ErrorCode::AuthRateLimited) + .with_request("01KZNNR5X818P3J6ENYKSADP8W".parse().unwrap()) + .with_param("collector", ErrorParam::text("trdr.kis").unwrap()) + .with_param("remaining", ErrorParam::Integer(0)) + .with_retryability(Retryability::AfterSeconds { seconds: 60 }) + .with_cause_chain("01KZNP0GQ3X8ARK9DQ489Z7WJ8".parse().unwrap()) + .with_upstream(UpstreamStatus::http(429)); + + let json = serde_json::to_string(&envelope).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + envelope + ); + assert!(json.contains("\"v\":1")); + assert!(json.contains("\"code\":\"AUTH_RATE_LIMITED\"")); + } + + #[test] + fn an_empty_envelope_leaves_out_what_it_does_not_have() + { + let json = serde_json::to_string(&ErrorEnvelope::new(ErrorCode::DbBusy)).unwrap(); + assert_eq!( + json, + "{\"v\":1,\"code\":\"DB_BUSY\",\"retryability\":{\"kind\":\"no\"}}" + ); + } + + #[test] + fn an_envelope_refuses_a_field_it_does_not_know() + { + let json = + "{\"v\":1,\"code\":\"DB_BUSY\",\"retryability\":{\"kind\":\"no\"},\"message\":\"x\"}"; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn an_envelope_refuses_a_version_it_does_not_speak() + { + let json = "{\"v\":2,\"code\":\"DB_BUSY\",\"retryability\":{\"kind\":\"no\"}}"; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn a_payload_sized_parameter_is_refused() + { + let body = "x".repeat(MAX_PARAM_TEXT_BYTES + 1); + assert_eq!( + ErrorParam::text(body), + Err(ParamTooLong { + found: MAX_PARAM_TEXT_BYTES + 1, + limit: MAX_PARAM_TEXT_BYTES + }) + ); + assert!(ErrorParam::text("x".repeat(MAX_PARAM_TEXT_BYTES)).is_ok()); + } + + #[test] + fn an_upstream_code_that_is_really_a_body_is_refused() + { + let status = UpstreamStatus::http(500); + assert_eq!( + status.clone().with_upstream_code("{\"msg\":\"boom\"}"), + Err(UpstreamCodeError::Charset) + ); + assert_eq!( + status + .clone() + .with_upstream_code("x".repeat(MAX_UPSTREAM_CODE_BYTES + 1)), + Err(UpstreamCodeError::Length { + found: MAX_UPSTREAM_CODE_BYTES + 1 + }) + ); + assert_eq!( + status.with_upstream_code(""), + Err(UpstreamCodeError::Length { found: 0 }) + ); + } +} diff --git a/crates/trdr-core/src/id.rs b/crates/trdr-core/src/id.rs new file mode 100644 index 0000000..5fc17eb --- /dev/null +++ b/crates/trdr-core/src/id.rs @@ -0,0 +1,478 @@ +//! Identifiers, all of them ULIDs. +//! +//! trdr needs a handful of identities that survive a backup, cross a socket, and +//! sit inside a file name or a Keychain account string without being escaped. +//! One representation answers all of it: a ULID, written as 26 characters of +//! Crockford base32. +//! +//! Why this and not a UUID or a path: +//! +//! - It sorts by creation time as plain text, which is what a manifest, a +//! recovery point directory, and a log line all want. +//! - Its alphabet is `0-9` and `A-Z` minus `I`, `L`, `O`, `U`, so it never +//! collides with the `:` that separates the fields of a Keychain account name +//! (`docs/FOUNDATION_DESIGN.md` section 5.1), never needs quoting in a path, +//! and cannot be misread between the digit and the letter. +//! - It is fixed length, so a parsed identifier is also a bounded one — nothing +//! arriving over the socket can turn into an unbounded string. +//! - The first 48 bits are the creation time, so an id is self-describing +//! without a lookup. +//! +//! Generating one needs a clock and a random source, which this crate must not +//! have. Values are built here from a [`Ulid`] the runtime supplies, and parsed +//! from text; that keeps `IdGenerator` a seam the runtime owns, as +//! `docs/FOUNDATION_DESIGN.md` section 13 requires. + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; +use std::str::FromStr; + +pub use ulid::Ulid; + +/// The number of characters in the canonical text form of every id here. +pub const ID_LENGTH: usize = 26; + +/// Text handed in was not a ULID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum IdParseError +{ + /// The text was not exactly [`ID_LENGTH`] characters long. + #[error("expected {ID_LENGTH} characters, found {found}")] + Length + { + /// How many characters arrived. + found: usize + }, + /// The text held a character outside the Crockford base32 alphabet. + #[error("contains a character outside the ULID alphabet")] + Alphabet, + /// The text was 26 valid characters but named a number above 128 bits. + /// + /// The encoding has room for 130 bits, so `ZZZZ…` is spellable and is not a + /// ULID. Accepting it would let two different strings mean one identifier. + #[error("names a value larger than the 128 bits of a ULID")] + Overflow +} + +/// Declares one ULID-backed identifier type with its full text and serde +/// behaviour, so that no two ids in trdr can disagree about parsing. +macro_rules! ulid_id +{ + ($(#[$attr:meta])* $name:ident) => { + $(#[$attr])* + /// + /// Text form is 26 characters of Crockford base32. Parsing accepts + /// either case and always renders back in upper case. + #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct $name(Ulid); + + impl $name + { + /// Wraps a ULID the runtime generated. + pub const fn from_ulid(value: Ulid) -> Self + { + Self(value) + } + + /// The ULID inside, for a caller that needs its timestamp. + pub const fn into_ulid(self) -> Ulid + { + self.0 + } + } + + impl FromStr for $name + { + type Err = IdParseError; + + fn from_str(text: &str) -> Result + { + parse_ulid(text).map(Self) + } + } + + impl fmt::Display for $name + { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result + { + let mut buffer = [0u8; ID_LENGTH]; + f.write_str(self.0.array_to_str(&mut buffer)) + } + } + + /// Written out deliberately: the whole value is the identifier, and it + /// carries nothing private, so `Debug` shows the same 26 characters + /// `Display` does rather than the ULID's internal integer. + impl fmt::Debug for $name + { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result + { + write!(f, concat!(stringify!($name), "({})"), self) + } + } + + impl Serialize for $name + { + fn serialize(&self, serializer: S) -> Result + { + let mut buffer = [0u8; ID_LENGTH]; + serializer.serialize_str(self.0.array_to_str(&mut buffer)) + } + } + + impl<'de> Deserialize<'de> for $name + { + fn deserialize>(deserializer: D) -> Result + { + let text = String::deserialize(deserializer)?; + text.parse().map_err(D::Error::custom) + } + } + + /// TypeScript sees a plain string — `str` rather than `String`, so + /// the generated type is `string` and not a named alias. The validation + /// is the Rust side's job and does not survive the crossing, which is + /// why every id arriving from the WebView is parsed again here. + impl specta::Type for $name + { + fn definition(types: &mut specta::Types) -> specta::datatype::DataType + { + ::definition(types) + } + } + }; +} + +/// Parses the canonical text form, rejecting the two strings the underlying +/// codec would otherwise accept and silently change. +fn parse_ulid(text: &str) -> Result +{ + if text.len() != ID_LENGTH + { + return Err(IdParseError::Length { + found: text.chars().count() + }); + } + + let value = Ulid::from_string(text).map_err(|error| match error + { + ulid::DecodeError::InvalidLength => IdParseError::Length { + found: text.chars().count() + }, + ulid::DecodeError::InvalidChar => IdParseError::Alphabet + })?; + + // The alphabet is checked by now, so a first character above `7` is a value + // that does not fit. The codec keeps the low 128 bits of a 130-bit encoding + // and would take `ZZZZ…` and print it back as `7ZZZ…`; something that does + // not survive its own round trip is not an identifier. + match text.as_bytes()[0] + { + b'0'..=b'7' => Ok(value), + _ => Err(IdParseError::Overflow) + } +} + +ulid_id! { + /// The portable logical identity of a workspace. + /// + /// It is written into `workspace.json` and travels with a backup, so two + /// copies of one workspace on two Macs share it. The per-machine identity + /// that Keychain items hang off is a different value and is not this one + /// (`docs/FOUNDATION_DESIGN.md` section 5.1). + WorkspaceId +} + +ulid_id! { + /// Correlates one request with its one response. + /// + /// Mandatory on every socket frame (section 9.2). A repeated id must get the + /// same terminal result rather than a second execution, which is why it is a + /// value the app can key on and not a free-form string. + RequestId +} + +ulid_id! { + /// Ties an error to the chain of causes recorded in the local log. + /// + /// The chain itself stays in the log. Only this id crosses to a screen or a + /// terminal, so no upstream body or credential can ride along with it. + CauseChainId +} + +ulid_id! { + /// One pending human approval (section 9.3). + /// + /// The host mints it when it raises the native sheet, and the response names + /// it. Nothing else can answer an approval. + ApprovalRequestId +} + +ulid_id! { + /// A path the user picked, as the WebView is allowed to refer to it. + /// + /// Section 9.1 forbids the WebView from passing paths as strings. The native + /// picker hands the host a real location and the host hands the WebView one + /// of these; the mapping never leaves the host, so a screen cannot name a + /// file the user did not choose. + ScopedPathHandle +} + +/// A user-facing identifier, such as the `low-vol-v1` in section 9.2's +/// `ui.open` example. +/// +/// This is the name a person gave a strategy in their own file, so it is not a +/// ULID. It is still a value that arrives from outside and gets used to look +/// things up, so its shape is fixed here: 1 to 64 bytes, ASCII letters, digits, +/// `-`, `_`, and `.`, starting with a letter or a digit. That rules out path +/// separators, `..`, leading dashes, whitespace, and control characters. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct ResourceId(String); + +/// The longest a [`ResourceId`] may be. +pub const RESOURCE_ID_MAX_BYTES: usize = 64; + +/// Text handed in was not a usable resource identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum ResourceIdError +{ + /// Empty, or longer than [`RESOURCE_ID_MAX_BYTES`]. + #[error("expected 1 to {RESOURCE_ID_MAX_BYTES} bytes, found {found}")] + Length + { + /// How many bytes arrived. + found: usize + }, + /// Held a byte outside letters, digits, `-`, `_`, and `.`. + #[error("contains a character that is not a letter, digit, '-', '_', or '.'")] + Charset, + /// Did not start with a letter or a digit. + #[error("must start with a letter or a digit")] + Start +} + +impl ResourceId +{ + /// Checks the text and keeps it, or says which rule it broke. + pub fn parse(text: impl Into) -> Result + { + let text = text.into(); + + if text.is_empty() || text.len() > RESOURCE_ID_MAX_BYTES + { + return Err(ResourceIdError::Length { found: text.len() }); + } + + if !text + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') + { + return Err(ResourceIdError::Charset); + } + + match text.as_bytes()[0].is_ascii_alphanumeric() + { + true => Ok(Self(text)), + false => Err(ResourceIdError::Start) + } + } + + /// The text, unchanged from what was accepted. + pub fn as_str(&self) -> &str + { + &self.0 + } +} + +impl FromStr for ResourceId +{ + type Err = ResourceIdError; + + fn from_str(text: &str) -> Result + { + Self::parse(text) + } +} + +impl fmt::Display for ResourceId +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result + { + f.write_str(&self.0) + } +} + +impl fmt::Debug for ResourceId +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result + { + write!(f, "ResourceId({})", self.0) + } +} + +impl Serialize for ResourceId +{ + fn serialize(&self, serializer: S) -> Result + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for ResourceId +{ + fn deserialize>(deserializer: D) -> Result + { + let text = String::deserialize(deserializer)?; + Self::parse(text).map_err(D::Error::custom) + } +} + +impl specta::Type for ResourceId +{ + fn definition(types: &mut specta::Types) -> specta::datatype::DataType + { + ::definition(types) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + const SAMPLE: &str = "01KZNNR5X818P3J6ENYKSADP8W"; + + #[test] + fn canonical_text_round_trips_through_the_type() + { + let id: WorkspaceId = SAMPLE.parse().unwrap(); + assert_eq!(id.to_string(), SAMPLE); + assert_eq!(SAMPLE.parse::().unwrap(), id); + } + + #[test] + fn a_value_round_trips_through_text() + { + let id = WorkspaceId::from_ulid(Ulid(0x0192_3f5c_7d81_1234_5678_9abc_def0_1234)); + assert_eq!(id.to_string().parse::().unwrap(), id); + } + + #[test] + fn round_trips_through_json() + { + let id: RequestId = SAMPLE.parse().unwrap(); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, format!("\"{SAMPLE}\"")); + assert_eq!(serde_json::from_str::(&json).unwrap(), id); + } + + #[test] + fn lower_case_is_accepted_and_normalised_upward() + { + let lower: WorkspaceId = SAMPLE.to_lowercase().parse().unwrap(); + assert_eq!(lower.to_string(), SAMPLE); + assert_eq!(lower, SAMPLE.parse::().unwrap()); + } + + #[test] + fn the_smallest_and_largest_ulids_survive_a_round_trip() + { + for text in ["00000000000000000000000000", "7ZZZZZZZZZZZZZZZZZZZZZZZZZ"] + { + assert_eq!(text.parse::().unwrap().to_string(), text); + } + } + + #[test] + fn a_value_the_codec_would_silently_truncate_is_refused() + { + // Both of these decode to a smaller number in the underlying codec and + // would print back as something else. + for text in ["ZZZZZZZZZZZZZZZZZZZZZZZZZZ", "8ZZZZZZZZZZZZZZZZZZZZZZZZZ"] + { + assert_eq!(text.parse::(), Err(IdParseError::Overflow)); + } + } + + #[test] + fn the_wrong_length_is_refused() + { + assert_eq!( + "".parse::(), + Err(IdParseError::Length { found: 0 }) + ); + assert_eq!( + format!("{SAMPLE}0").parse::(), + Err(IdParseError::Length { found: 27 }) + ); + } + + #[test] + fn the_ambiguous_letters_are_refused() + { + for letter in ['I', 'L', 'O', 'U', '-', '/'] + { + let text = format!("{}{letter}", &SAMPLE[..ID_LENGTH - 1]); + assert_eq!(text.parse::(), Err(IdParseError::Alphabet)); + } + } + + #[test] + fn a_non_ulid_string_is_refused_by_serde_too() + { + assert!(serde_json::from_str::("\"low-vol-v1\"").is_err()); + assert!(serde_json::from_str::("1").is_err()); + } + + #[test] + fn debug_prints_the_identifier_itself() + { + let id: ApprovalRequestId = SAMPLE.parse().unwrap(); + assert_eq!(format!("{id:?}"), format!("ApprovalRequestId({SAMPLE})")); + } + + #[test] + fn each_id_type_is_its_own_type() + { + // Not a runtime assertion so much as a compile-time one: a workspace id + // and a request id are both ULIDs and must not be interchangeable. + let workspace: WorkspaceId = SAMPLE.parse().unwrap(); + let request: RequestId = SAMPLE.parse().unwrap(); + assert_eq!(workspace.into_ulid(), request.into_ulid()); + } + + #[test] + fn a_resource_id_round_trips() + { + let id = ResourceId::parse("low-vol-v1").unwrap(); + assert_eq!(id.as_str(), "low-vol-v1"); + let json = serde_json::to_string(&id).unwrap(); + assert_eq!(json, "\"low-vol-v1\""); + assert_eq!(serde_json::from_str::(&json).unwrap(), id); + } + + #[test] + fn a_resource_id_refuses_what_would_reach_the_filesystem() + { + assert_eq!( + ResourceId::parse("../etc/passwd"), + Err(ResourceIdError::Charset) + ); + assert_eq!(ResourceId::parse(".."), Err(ResourceIdError::Start)); + assert_eq!(ResourceId::parse("a/b"), Err(ResourceIdError::Charset)); + assert_eq!(ResourceId::parse("a\0b"), Err(ResourceIdError::Charset)); + assert_eq!(ResourceId::parse("a b"), Err(ResourceIdError::Charset)); + assert_eq!(ResourceId::parse("-lead"), Err(ResourceIdError::Start)); + assert_eq!( + ResourceId::parse(""), + Err(ResourceIdError::Length { found: 0 }) + ); + assert_eq!( + ResourceId::parse("x".repeat(RESOURCE_ID_MAX_BYTES + 1)), + Err(ResourceIdError::Length { + found: RESOURCE_ID_MAX_BYTES + 1 + }) + ); + } +} diff --git a/crates/trdr-core/src/lib.rs b/crates/trdr-core/src/lib.rs new file mode 100644 index 0000000..b195605 --- /dev/null +++ b/crates/trdr-core/src/lib.rs @@ -0,0 +1,41 @@ +//! The trdr domain core. +//! +//! What lives here is the part of trdr that has no opinion about screens, +//! databases, or the operating system: identity, the shape of an error, and the +//! two inter-process contracts described in `docs/FOUNDATION_DESIGN.md` +//! section 9. Everything in this crate is a value that can be computed, parsed, +//! and compared without touching the outside world. +//! +//! At this stage the crate defines contracts only. No command is handled here, +//! and no handler will ever live here — the runtime owns effects. +//! +//! # Module map +//! +//! | module | contract | +//! |---|---| +//! | [`envelope`] | the `v` field every versioned message carries | +//! | [`error`] | the error envelope and its stable code set (section 12) | +//! | [`id`] | ULID-backed identifiers, including the portable workspace id | +//! | [`socket`] | `trdr` CLI ↔ running app frames (section 9.2) | +//! | [`time`] | the one way an instant is written down (section 7.2) | +//! | [`ui`] | React WebView ↔ Tauri host commands (section 9.1) | +//! | [`workspace`] | the `workspace.json` manifest (section 5.1) | + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +pub mod envelope; +pub mod error; +pub mod id; +pub mod socket; +pub mod time; +pub mod ui; +pub mod workspace; + +pub use envelope::{EnvelopeVersion, PROTOCOL_VERSION}; +pub use error::{ErrorCode, ErrorEnvelope, ErrorFamily, ErrorParam, Retryability, UpstreamStatus}; +pub use id::{ + ApprovalRequestId, CauseChainId, RequestId, ResourceId, ScopedPathHandle, WorkspaceId +}; +pub use time::Timestamp; +pub use workspace::WorkspaceManifest; diff --git a/crates/trdr-core/src/socket.rs b/crates/trdr-core/src/socket.rs new file mode 100644 index 0000000..c6807be --- /dev/null +++ b/crates/trdr-core/src/socket.rs @@ -0,0 +1,1047 @@ +//! What the `trdr` CLI says to a running app, and what comes back (section 9.2). +//! +//! The protocol is one JSON object per line over a Unix socket: +//! +//! ```json +//! {"v":1,"id":"...","method":"ui.open","params":{"resource":"strategy","id":"low-vol-v1"}} +//! {"v":1,"id":"...","ok":true,"result":{"status":"opened"}} +//! ``` +//! +//! Section 9.2 requires the version and the request id on every frame and +//! requires an unknown method, an unknown field, or an unknown version to be +//! refused rather than ignored. Refusing quietly is the failure mode that +//! matters: a field that gets dropped is a request that did something other than +//! what was asked. So parsing here is deliberately strict and deliberately not a +//! plain `serde_json::from_str` — [`SocketRequest::from_json_line`] is the only +//! way in, it checks the version before anything else, and each refusal is a +//! [`FrameError`] variant rather than a message. +//! +//! These types are Rust on both ends and never cross into TypeScript, which is +//! why — unlike [`crate::ui`] — they carry no `specta` derives. The one thing +//! they share with the WebView side is [`ErrorEnvelope`]. + +use crate::envelope::EnvelopeVersion; +use crate::error::{ErrorCode, ErrorEnvelope, ErrorParam}; +use crate::id::{IdParseError, RequestId, ResourceId}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// The most a single frame may weigh. +/// +/// A line arrives from another process, so it needs a ceiling somewhere. One +/// mebibyte is far above any request in the initial method list. +pub const MAX_FRAME_BYTES: usize = 1024 * 1024; + +/// The longest an unknown method name is echoed back in an error. +const MAX_ECHOED_METHOD_BYTES: usize = 64; + +/// A frame could not be taken at face value. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum FrameError +{ + /// The line was longer than [`MAX_FRAME_BYTES`]. + #[error("frame is {found} bytes, the limit is {limit}")] + TooLarge + { + /// How long the line was. + found: usize, + /// The limit it passed. + limit: usize + }, + /// The line was not JSON, was not an object, or held a field this build does + /// not know. + /// + /// Carries a position rather than the text, so that nothing a caller sent + /// gets copied into an error and from there into a log. + #[error("frame is malformed at line {line}, column {column}")] + Malformed + { + /// Line the parser stopped at. + line: usize, + /// Column the parser stopped at. + column: usize + }, + /// The frame named a protocol version this build does not speak. + #[error("unsupported protocol version {found}, this build speaks {supported}")] + UnsupportedVersion + { + /// The version the frame claimed. + found: u16, + /// The version this build speaks. + supported: u16 + }, + /// The request id was not a ULID. + #[error("request id is not usable: {0}")] + InvalidRequestId(#[from] IdParseError), + /// The frame named a method this build does not have. + #[error("unknown method")] + UnknownMethod + { + /// The name that was asked for, cut to a length safe to keep. + method: String + }, + /// The method exists but its parameters did not fit. + #[error("parameters do not fit method {method}")] + InvalidParams + { + /// The method whose parameters did not fit. + method: &'static str + }, + /// A response claimed success and carried an error, or the other way round. + #[error("response says ok={ok} but carries the other half")] + InconsistentOutcome + { + /// What the frame claimed. + ok: bool + } +} + +impl FrameError +{ + /// The same refusal as the envelope that goes back over the socket. + /// + /// Every frame failure is a protocol failure, and section 12 keeps the `APP` + /// family to three codes, so the code is the same for all of them and the + /// parameters say which one it was. The distinction stays available to code + /// through the variants of this type. + pub fn to_envelope(&self, id: Option) -> ErrorEnvelope + { + let mut envelope = ErrorEnvelope::new(ErrorCode::AppProtocolVersion) + .with_param("reason", ErrorParam::literal(self.reason())); + + if let Some(id) = id + { + envelope = envelope.with_request(id); + } + + match self + { + Self::TooLarge { found, limit } => envelope + .with_param("found", ErrorParam::Integer(*found as i64)) + .with_param("limit", ErrorParam::Integer(*limit as i64)), + Self::UnsupportedVersion { found, supported } => envelope + .with_param("found", ErrorParam::Integer(i64::from(*found))) + .with_param("supported", ErrorParam::Integer(i64::from(*supported))), + Self::UnknownMethod { method } => match ErrorParam::text(method) + { + Ok(param) => envelope.with_param("method", param), + Err(_) => envelope + }, + Self::InvalidParams { method } => + { + envelope.with_param("method", ErrorParam::literal(method)) + } + Self::Malformed { .. } + | Self::InvalidRequestId(_) + | Self::InconsistentOutcome { .. } => envelope + } + } + + /// A fixed word naming which refusal this is. + const fn reason(&self) -> &'static str + { + match self + { + Self::TooLarge { .. } => "frame_too_large", + Self::Malformed { .. } => "malformed_frame", + Self::UnsupportedVersion { .. } => "unsupported_version", + Self::InvalidRequestId(_) => "invalid_request_id", + Self::UnknownMethod { .. } => "unknown_method", + Self::InvalidParams { .. } => "invalid_params", + Self::InconsistentOutcome { .. } => "inconsistent_outcome" + } + } +} + +impl From for FrameError +{ + fn from(error: serde_json::Error) -> Self + { + Self::Malformed { + line: error.line(), + column: error.column() + } + } +} + +/// What a socket method does, so that routing does not have to read the name. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SocketMethodKind +{ + /// Answers from state that already exists and writes nothing. + Read, + /// Asks the running app to show something. Only a running app can. + UiRequest, + /// Changes durable state, and through this socket the app is what changes + /// it — the CLI never writes behind a running app's back (section 6). + Mutation +} + +/// Something the app can bring to the front. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum UiResource +{ + /// A registered strategy. + Strategy, + /// A finished backtest. + Backtest +} + +/// Which thing to open (the example in section 9.2). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct UiOpenParams +{ + /// What kind of thing. + pub resource: UiResource, + /// Which one. + pub id: ResourceId +} + +/// An ingest bundle on disk. +/// +/// A path, not a handle: the CLI is trusted to name a file (section 2), whereas +/// the WebView is not. The host still runs the path, symlink, and size checks in +/// section 8.2 before reading anything. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct BundlePathParams +{ + /// Where the bundle directory is. + pub bundle_path: PathBuf +} + +/// A backup package on disk. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct PackagePathParams +{ + /// Where the package is. + pub package_path: PathBuf +} + +/// Where a new backup should be written. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct DestinationPathParams +{ + /// Where to write it. + pub destination_path: PathBuf +} + +/// A user's strategy file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct StrategyPathParams +{ + /// Where the `.trdr.yaml` file is. + pub strategy_path: PathBuf +} + +/// What `app.status` answers with. +/// +/// Section 9.2 calls for a bounded read result with no secret and no raw account +/// payload in it, and section 6 says one process holds the writer lease, so this +/// is the running app naming itself, the state it has open, and whether it is the +/// writer. Two paths are in it because "which workspace is this app on" is the +/// question a person asks when two copies are installed; a path is not a secret, +/// and the socket only ever answers the uid that owns it. +/// +/// It carries no `specta` derives for the same reason nothing else in this file +/// does: both ends are Rust. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct AppStatusResult +{ + /// The version of the running app. + pub app_version: String, + /// The process id of the running app. + pub pid: u32, + /// Whether that process holds the single writer lease (section 6). + pub holds_writer_lease: bool, + /// The product root it is running against. + pub product_root: PathBuf, + /// The workspace it has open. + pub workspace_path: PathBuf, + /// The database schema version in that workspace. + pub schema_version: i64 +} + +/// Every method the CLI can call on a running app. +/// +/// Methods whose parameters are not yet fixed are marked. The track that +/// implements one adds its parameters with it; nothing is invented here that the +/// design does not already pin down. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "method", content = "params")] +pub enum SocketMethod +{ + /// Whether an app is there, and what it is holding. + #[serde(rename = "app.status")] + AppStatus, + /// Bring something up on screen. + #[serde(rename = "ui.open")] + UiOpen(UiOpenParams), + /// Bring the app itself to the front. + #[serde(rename = "ui.focus")] + UiFocus, + /// The latest normalised account snapshot. Never the raw response, never a + /// token, never an account number (sections 5.2 and 9.2). + #[serde(rename = "account.inspect")] + AccountInspect, + /// What data is present for a range. Parameters land with the data track. + #[serde(rename = "data.coverage")] + DataCoverage, + /// Validate and commit a bundle. + #[serde(rename = "ingest.request")] + IngestRequest(BundlePathParams), + /// Run a backtest. Parameters land with the engine track. + #[serde(rename = "backtest.run")] + BacktestRun, + /// Look at a finished backtest. Parameters land with the engine track. + #[serde(rename = "backtest.inspect")] + BacktestInspect, + /// Ask for a portable backup. + #[serde(rename = "backup.create.request")] + BackupCreateRequest(DestinationPathParams), + /// Check a package without restoring it. + #[serde(rename = "backup.verify")] + BackupVerify(PackagePathParams), + /// Ask to restore a package into a new workspace. + #[serde(rename = "workspace.restore.request")] + WorkspaceRestoreRequest(PackagePathParams), + /// Read a strategy file and report what it means. + #[serde(rename = "strategy.inspect")] + StrategyInspect(StrategyPathParams), + /// Ask a person to approve registering a strategy (section 9.3). + /// + /// There is no method that registers without the round trip, and there is no + /// method that bypasses the sheet. + #[serde(rename = "strategy.register.request")] + StrategyRegisterRequest(StrategyPathParams) +} + +impl SocketMethod +{ + /// Every method name, in the order section 9.2 lists them. + pub const NAMES: &'static [&'static str] = &[ + "app.status", + "ui.open", + "ui.focus", + "account.inspect", + "data.coverage", + "ingest.request", + "backtest.run", + "backtest.inspect", + "backup.create.request", + "backup.verify", + "workspace.restore.request", + "strategy.inspect", + "strategy.register.request" + ]; + + /// Whether a name is one this build has. + pub fn is_known(name: &str) -> bool + { + Self::NAMES.contains(&name) + } + + /// The name this method goes by on the wire. + pub const fn name(&self) -> &'static str + { + match self + { + Self::AppStatus => "app.status", + Self::UiOpen(_) => "ui.open", + Self::UiFocus => "ui.focus", + Self::AccountInspect => "account.inspect", + Self::DataCoverage => "data.coverage", + Self::IngestRequest(_) => "ingest.request", + Self::BacktestRun => "backtest.run", + Self::BacktestInspect => "backtest.inspect", + Self::BackupCreateRequest(_) => "backup.create.request", + Self::BackupVerify(_) => "backup.verify", + Self::WorkspaceRestoreRequest(_) => "workspace.restore.request", + Self::StrategyInspect(_) => "strategy.inspect", + Self::StrategyRegisterRequest(_) => "strategy.register.request" + } + } + + /// What this method does, for whoever routes it. + pub const fn kind(&self) -> SocketMethodKind + { + match self + { + Self::AppStatus + | Self::AccountInspect + | Self::DataCoverage + | Self::BacktestInspect + | Self::BackupVerify(_) + | Self::StrategyInspect(_) => SocketMethodKind::Read, + Self::UiOpen(_) | Self::UiFocus => SocketMethodKind::UiRequest, + Self::IngestRequest(_) + | Self::BacktestRun + | Self::BackupCreateRequest(_) + | Self::WorkspaceRestoreRequest(_) + | Self::StrategyRegisterRequest(_) => SocketMethodKind::Mutation + } + } +} + +/// One request line. +/// +/// There is no `Deserialize` for this type on purpose. Everything comes in +/// through [`SocketRequest::from_json_line`], so the strict checks cannot be +/// skipped by reaching for `serde_json` directly. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SocketRequest +{ + /// Protocol version. + pub v: EnvelopeVersion, + /// Correlates this request with its response. + pub id: RequestId, + /// What is being asked for. + pub method: SocketMethod +} + +/// The exact set of fields a request line may have. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RequestWire +{ + v: u16, + id: String, + method: String, + #[serde(default)] + params: Option +} + +impl SocketRequest +{ + /// A request at the current protocol version. + pub fn new(id: RequestId, method: SocketMethod) -> Self + { + Self { + v: EnvelopeVersion::CURRENT, + id, + method + } + } + + /// Writes the request as one line, newline included. + pub fn to_json_line(&self) -> Result + { + #[derive(Serialize)] + struct Out<'a> + { + v: EnvelopeVersion, + id: &'a RequestId, + #[serde(flatten)] + method: &'a SocketMethod + } + + let mut line = serde_json::to_string(&Out { + v: self.v, + id: &self.id, + method: &self.method + })?; + line.push('\n'); + Ok(line) + } + + /// Reads one line, refusing everything section 9.2 says to refuse. + /// + /// The version is checked before the method, so a peer speaking a later + /// protocol is told that and not that its method is unknown. + pub fn from_json_line(line: &str) -> Result + { + let wire: RequestWire = parse_line(line)?; + let v = check_version(wire.v)?; + let id: RequestId = wire.id.parse()?; + let method = parse_method(&wire.method, wire.params)?; + + Ok(Self { v, id, method }) + } +} + +/// One response line. +/// +/// Generic over what a successful result carries. Each method's result type +/// arrives with the method; until then the default is raw JSON. +#[derive(Debug, Clone, PartialEq)] +pub struct SocketResponse +{ + /// Protocol version. + pub v: EnvelopeVersion, + /// The request this answers. + pub id: RequestId, + /// How it went. + pub outcome: SocketOutcome +} + +/// Either a result or an error envelope, never both and never neither. +#[derive(Debug, Clone, PartialEq)] +pub enum SocketOutcome +{ + /// It worked, and this is what came back. + Ok(R), + /// It did not. + Error(ErrorEnvelope) +} + +/// The exact set of fields a response line may have. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct ResponseWire +{ + v: u16, + id: String, + ok: bool, + #[serde(default)] + result: Option, + #[serde(default)] + error: Option +} + +impl SocketResponse +{ + /// A successful answer at the current protocol version. + pub fn ok(id: RequestId, result: R) -> Self + { + Self { + v: EnvelopeVersion::CURRENT, + id, + outcome: SocketOutcome::Ok(result) + } + } + + /// A failed one. + pub fn error(id: RequestId, error: ErrorEnvelope) -> Self + { + Self { + v: EnvelopeVersion::CURRENT, + id, + outcome: SocketOutcome::Error(error) + } + } +} + +impl SocketResponse +{ + /// Writes the response as one line, newline included. + pub fn to_json_line(&self) -> Result + { + #[derive(Serialize)] + struct Out<'a, R> + { + v: EnvelopeVersion, + id: &'a RequestId, + ok: bool, + #[serde(skip_serializing_if = "Option::is_none")] + result: Option<&'a R>, + #[serde(skip_serializing_if = "Option::is_none")] + error: Option<&'a ErrorEnvelope> + } + + let (ok, result, error) = match &self.outcome + { + SocketOutcome::Ok(result) => (true, Some(result), None), + SocketOutcome::Error(error) => (false, None, Some(error)) + }; + + let mut line = serde_json::to_string(&Out { + v: self.v, + id: &self.id, + ok, + result, + error + })?; + line.push('\n'); + Ok(line) + } +} + +impl SocketResponse +{ + /// Reads one line, refusing a frame that claims one outcome and carries the + /// other. + pub fn from_json_line(line: &str) -> Result + { + let wire: ResponseWire = parse_line(line)?; + let v = check_version(wire.v)?; + let id: RequestId = wire.id.parse()?; + + let outcome = match (wire.ok, wire.result, wire.error) + { + (true, Some(result), None) => SocketOutcome::Ok(serde_json::from_value(result)?), + (false, None, Some(error)) => SocketOutcome::Error(error), + (ok, _, _) => return Err(FrameError::InconsistentOutcome { ok }) + }; + + Ok(Self { v, id, outcome }) + } +} + +/// Reads one line into its wire shape, with the size limit applied first. +fn parse_line(line: &str) -> Result +{ + if line.len() > MAX_FRAME_BYTES + { + return Err(FrameError::TooLarge { + found: line.len(), + limit: MAX_FRAME_BYTES + }); + } + + Ok(serde_json::from_str(line.trim_end_matches(['\n', '\r']))?) +} + +/// Turns the raw version number into a checked one. +fn check_version(raw: u16) -> Result +{ + EnvelopeVersion::supported(raw).map_err(|error| FrameError::UnsupportedVersion { + found: error.found, + supported: error.supported + }) +} + +/// Puts the method name and its parameters back together, telling a method this +/// build does not have apart from parameters that do not fit it. +fn parse_method(name: &str, params: Option) -> Result +{ + if !SocketMethod::is_known(name) + { + let cut = name + .char_indices() + .map(|(i, _)| i) + .nth(MAX_ECHOED_METHOD_BYTES); + + return Err(FrameError::UnknownMethod { + method: name[..cut.unwrap_or(name.len())].to_owned() + }); + } + + let mut fields = serde_json::Map::new(); + fields.insert( + "method".to_owned(), + serde_json::Value::String(name.to_owned()) + ); + + if let Some(params) = params + { + fields.insert("params".to_owned(), params); + } + + serde_json::from_value(serde_json::Value::Object(fields)).map_err(|_| { + let known = SocketMethod::NAMES.iter().find(|known| **known == name); + FrameError::InvalidParams { + method: known.copied().unwrap_or("unknown") + } + }) +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::envelope::PROTOCOL_VERSION; + + const REQUEST: &str = "01KZNNR5X818P3J6ENYKSADP8W"; + + fn every_method() -> Vec + { + let package = PackagePathParams { + package_path: PathBuf::from("/tmp/a.trdrbackup") + }; + let strategy = StrategyPathParams { + strategy_path: PathBuf::from("/tmp/a.trdr.yaml") + }; + + vec![ + SocketMethod::AppStatus, + SocketMethod::UiOpen(UiOpenParams { + resource: UiResource::Strategy, + id: ResourceId::parse("low-vol-v1").unwrap() + }), + SocketMethod::UiFocus, + SocketMethod::AccountInspect, + SocketMethod::DataCoverage, + SocketMethod::IngestRequest(BundlePathParams { + bundle_path: PathBuf::from("/tmp/my-bundle") + }), + SocketMethod::BacktestRun, + SocketMethod::BacktestInspect, + SocketMethod::BackupCreateRequest(DestinationPathParams { + destination_path: PathBuf::from("/tmp/out") + }), + SocketMethod::BackupVerify(package.clone()), + SocketMethod::WorkspaceRestoreRequest(package), + SocketMethod::StrategyInspect(strategy.clone()), + SocketMethod::StrategyRegisterRequest(strategy), + ] + } + + #[test] + fn the_line_in_the_design_document_parses_to_what_it_says() + { + let line = "{\"v\":1,\"id\":\"01KZNNR5X818P3J6ENYKSADP8W\",\"method\":\"ui.open\",\ + \"params\":{\"resource\":\"strategy\",\"id\":\"low-vol-v1\"}}"; + let request = SocketRequest::from_json_line(line).unwrap(); + + assert_eq!(request.v, EnvelopeVersion::CURRENT); + assert_eq!(request.id, REQUEST.parse::().unwrap()); + assert_eq!( + request.method, + SocketMethod::UiOpen(UiOpenParams { + resource: UiResource::Strategy, + id: ResourceId::parse("low-vol-v1").unwrap() + }) + ); + } + + #[test] + fn every_method_round_trips_through_a_line() + { + for method in every_method() + { + let request = SocketRequest::new(REQUEST.parse().unwrap(), method); + let line = request.to_json_line().unwrap(); + + assert!(line.ends_with('\n')); + assert_eq!(line.matches('\n').count(), 1); + assert_eq!(SocketRequest::from_json_line(&line).unwrap(), request); + } + } + + #[test] + fn a_method_name_is_the_one_the_design_fixes() + { + let names: Vec<&str> = every_method().iter().map(|m| m.name()).collect(); + assert_eq!(names, SocketMethod::NAMES); + + for method in every_method() + { + let value = serde_json::to_value(&method).unwrap(); + assert_eq!(value["method"], method.name()); + } + } + + #[test] + fn each_method_is_on_a_known_side_of_the_write_boundary() + { + let of_kind = |kind: SocketMethodKind| -> Vec<&'static str> { + every_method() + .iter() + .filter(|m| m.kind() == kind) + .map(|m| m.name()) + .collect() + }; + + assert_eq!( + of_kind(SocketMethodKind::Read), + [ + "app.status", + "account.inspect", + "data.coverage", + "backtest.inspect", + "backup.verify", + "strategy.inspect" + ] + ); + assert_eq!( + of_kind(SocketMethodKind::UiRequest), + ["ui.open", "ui.focus"] + ); + assert_eq!( + of_kind(SocketMethodKind::Mutation), + [ + "ingest.request", + "backtest.run", + "backup.create.request", + "workspace.restore.request", + "strategy.register.request" + ] + ); + } + + #[test] + fn a_version_this_build_does_not_speak_is_a_typed_refusal() + { + let line = format!("{{\"v\":2,\"id\":\"{REQUEST}\",\"method\":\"app.status\"}}"); + assert_eq!( + SocketRequest::from_json_line(&line), + Err(FrameError::UnsupportedVersion { + found: 2, + supported: PROTOCOL_VERSION + }) + ); + } + + #[test] + fn the_version_is_checked_before_the_method() + { + // A peer one version ahead will be sending methods this build has never + // heard of. It should be told about the version, not the method. + let line = format!("{{\"v\":9,\"id\":\"{REQUEST}\",\"method\":\"quantum.leap\"}}"); + assert!(matches!( + SocketRequest::from_json_line(&line), + Err(FrameError::UnsupportedVersion { .. }) + )); + } + + #[test] + fn an_unknown_method_is_refused_and_named_back_in_bounded_form() + { + let line = format!("{{\"v\":1,\"id\":\"{REQUEST}\",\"method\":\"db.query\"}}"); + assert_eq!( + SocketRequest::from_json_line(&line), + Err(FrameError::UnknownMethod { + method: "db.query".to_owned() + }) + ); + + let long = "x".repeat(4096); + let line = format!("{{\"v\":1,\"id\":\"{REQUEST}\",\"method\":\"{long}\"}}"); + + match SocketRequest::from_json_line(&line) + { + Err(FrameError::UnknownMethod { method }) => assert_eq!(method.len(), 64), + other => panic!("expected an unknown method, got {other:?}") + } + } + + #[test] + fn an_unknown_field_is_refused_rather_than_dropped() + { + let line = + format!("{{\"v\":1,\"id\":\"{REQUEST}\",\"method\":\"app.status\",\"dry_run\":false}}"); + assert!(matches!( + SocketRequest::from_json_line(&line), + Err(FrameError::Malformed { .. }) + )); + } + + #[test] + fn an_unknown_field_inside_parameters_is_refused_too() + { + let line = format!( + "{{\"v\":1,\"id\":\"{REQUEST}\",\"method\":\"ui.open\",\ + \"params\":{{\"resource\":\"strategy\",\"id\":\"low-vol-v1\",\"force\":true}}}}" + ); + assert_eq!( + SocketRequest::from_json_line(&line), + Err(FrameError::InvalidParams { method: "ui.open" }) + ); + } + + #[test] + fn missing_parameters_are_refused() + { + let line = format!("{{\"v\":1,\"id\":\"{REQUEST}\",\"method\":\"ui.open\"}}"); + assert_eq!( + SocketRequest::from_json_line(&line), + Err(FrameError::InvalidParams { method: "ui.open" }) + ); + } + + #[test] + fn a_method_that_takes_nothing_refuses_parameters() + { + let line = format!( + "{{\"v\":1,\"id\":\"{REQUEST}\",\"method\":\"app.status\",\"params\":{{\"verbose\":true}}}}" + ); + assert_eq!( + SocketRequest::from_json_line(&line), + Err(FrameError::InvalidParams { + method: "app.status" + }) + ); + } + + #[test] + fn a_value_with_a_newline_in_it_still_makes_one_line() + { + // The protocol is one object per line, so a path a user chose has to be + // escaped rather than allowed to end the frame early. + let request = SocketRequest::new( + REQUEST.parse().unwrap(), + SocketMethod::IngestRequest(BundlePathParams { + bundle_path: PathBuf::from("/tmp/a\nb") + }) + ); + let line = request.to_json_line().unwrap(); + + assert_eq!(line.matches('\n').count(), 1); + assert!(line.ends_with('\n')); + assert_eq!(SocketRequest::from_json_line(&line).unwrap(), request); + } + + #[test] + fn a_request_id_that_is_not_a_ulid_is_refused() + { + let line = "{\"v\":1,\"id\":\"1\",\"method\":\"app.status\"}"; + assert_eq!( + SocketRequest::from_json_line(line), + Err(FrameError::InvalidRequestId(IdParseError::Length { + found: 1 + })) + ); + } + + #[test] + fn an_oversized_frame_is_refused_before_it_is_parsed() + { + let line = format!( + "{{\"v\":1,\"id\":\"{REQUEST}\",\"pad\":\"{}\"}}", + "x".repeat(MAX_FRAME_BYTES) + ); + assert!(matches!( + SocketRequest::from_json_line(&line), + Err(FrameError::TooLarge { + limit: MAX_FRAME_BYTES, + .. + }) + )); + } + + #[test] + fn a_malformed_frame_reports_a_position_and_not_its_contents() + { + let secret = "sk-do-not-echo-this"; + let line = + format!("{{\"v\":1,\"id\":\"{REQUEST}\",\"method\":\"app.status\",\"k\":\"{secret}\""); + + match SocketRequest::from_json_line(&line) + { + Err(error) => assert!(!format!("{error}").contains(secret)), + Ok(_) => panic!("a truncated frame should not parse") + } + } + + #[test] + fn a_response_round_trips_either_way() + { + let id: RequestId = REQUEST.parse().unwrap(); + let ok: SocketResponse = SocketResponse::ok(id, serde_json::json!({"status": "opened"})); + let line = ok.to_json_line().unwrap(); + + assert_eq!( + line, + format!("{{\"v\":1,\"id\":\"{REQUEST}\",\"ok\":true,\"result\":{{\"status\":\"opened\"}}}}\n") + ); + assert_eq!( + SocketResponse::::from_json_line(&line).unwrap(), + ok + ); + + let failed: SocketResponse = + SocketResponse::error(id, ErrorEnvelope::new(ErrorCode::AppNotRunning)); + let line = failed.to_json_line().unwrap(); + assert_eq!( + SocketResponse::::from_json_line(&line).unwrap(), + failed + ); + } + + #[test] + fn a_response_that_claims_one_outcome_and_carries_the_other_is_refused() + { + for line in [ + format!("{{\"v\":1,\"id\":\"{REQUEST}\",\"ok\":true}}"), + format!( + "{{\"v\":1,\"id\":\"{REQUEST}\",\"ok\":true,\"result\":{{}},\ + \"error\":{{\"v\":1,\"code\":\"DB_BUSY\",\"retryability\":{{\"kind\":\"no\"}}}}}}" + ), + format!("{{\"v\":1,\"id\":\"{REQUEST}\",\"ok\":false}}") + ] + { + assert!(matches!( + SocketResponse::::from_json_line(&line), + Err(FrameError::InconsistentOutcome { .. }) + )); + } + } + + #[test] + fn an_app_status_result_round_trips_and_refuses_a_field_it_does_not_know() + { + let id: RequestId = REQUEST.parse().unwrap(); + let status = AppStatusResult { + app_version: "0.0.0".to_owned(), + pid: 4321, + holds_writer_lease: true, + product_root: PathBuf::from("/private/tmp/root"), + workspace_path: PathBuf::from("/private/tmp/root/workspaces/default"), + schema_version: 1 + }; + + let line = SocketResponse::ok(id, status.clone()) + .to_json_line() + .unwrap(); + let read = SocketResponse::::from_json_line(&line).unwrap(); + + assert_eq!(read.outcome, SocketOutcome::Ok(status)); + + let extra = line.replace("\"pid\":4321", "\"pid\":4321,\"token\":\"x\""); + assert!(SocketResponse::::from_json_line(&extra).is_err()); + } + + #[test] + fn a_typed_result_is_read_as_that_type() + { + #[derive(Debug, PartialEq, Serialize, Deserialize)] + struct Status + { + running: bool + } + + let id: RequestId = REQUEST.parse().unwrap(); + let response = SocketResponse::ok(id, Status { running: true }); + let line = response.to_json_line().unwrap(); + + assert_eq!( + SocketResponse::::from_json_line(&line).unwrap(), + response + ); + assert!(SocketResponse::::from_json_line(&line).is_err()); + } + + #[test] + fn every_refusal_becomes_an_envelope_that_says_which_one_it_was() + { + let id: RequestId = REQUEST.parse().unwrap(); + let refusals = [ + FrameError::TooLarge { found: 2, limit: 1 }, + FrameError::Malformed { line: 1, column: 2 }, + FrameError::UnsupportedVersion { + found: 2, + supported: 1 + }, + FrameError::InvalidRequestId(IdParseError::Alphabet), + FrameError::UnknownMethod { + method: "db.query".to_owned() + }, + FrameError::InvalidParams { method: "ui.open" }, + FrameError::InconsistentOutcome { ok: true } + ]; + + let mut reasons = std::collections::BTreeSet::new(); + + for refusal in refusals + { + let envelope = refusal.to_envelope(Some(id)); + + assert_eq!(envelope.code, ErrorCode::AppProtocolVersion); + assert_eq!(envelope.id, Some(id)); + + match envelope.params.get("reason") + { + Some(ErrorParam::Text(reason)) => assert!(reasons.insert(reason.clone())), + other => panic!("expected a reason, got {other:?}") + } + } + + assert_eq!(reasons.len(), 7); + } +} diff --git a/crates/trdr-core/src/time.rs b/crates/trdr-core/src/time.rs new file mode 100644 index 0000000..f41ee6e --- /dev/null +++ b/crates/trdr-core/src/time.rs @@ -0,0 +1,318 @@ +//! The one way trdr writes an instant down. +//! +//! Section 7.2 of `docs/FOUNDATION_DESIGN.md` requires a canonical form for +//! every datetime that goes into a hash, and section 8.1 shows it: RFC 3339 in +//! UTC, `2026-08-10T10:00:00Z`. Two spellings of one instant would give two +//! hashes for one backtest, so this type accepts exactly one spelling per +//! instant and refuses local offsets, lower-case `t`, and a missing zone. +//! +//! What this is not: a clock, a calendar, or arithmetic. It holds text that has +//! been checked, and reading the current time stays a runtime seam. The meaning +//! of the four timestamp fields in section 7.1 — observed, available, effective, +//! recorded — belongs to the domain track that introduces them. + +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use std::fmt; +use std::str::FromStr; + +/// An instant in UTC, in RFC 3339 form. +#[derive(Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Timestamp(String); + +/// Text was not a canonical UTC timestamp. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum TimestampError +{ + /// Not shaped like `YYYY-MM-DDTHH:MM:SSZ`, with optional fractional seconds. + #[error("expected the form YYYY-MM-DDTHH:MM:SS[.fff]Z")] + Shape, + /// Shaped right, but naming a date or time that does not exist. + #[error("names a date or time that does not exist")] + OutOfRange, + /// Carried a zone other than `Z`. + /// + /// A local offset is a second spelling of an instant that already has one. + #[error("must be written in UTC, ending in 'Z'")] + NotUtc +} + +impl Timestamp +{ + /// Checks the text and keeps it, or says which rule it broke. + pub fn parse(text: impl Into) -> Result + { + let text = text.into(); + check(&text)?; + Ok(Self(text)) + } + + /// The text, unchanged from what was accepted. + pub fn as_str(&self) -> &str + { + &self.0 + } +} + +/// Runs every rule the form has to satisfy. +fn check(text: &str) -> Result<(), TimestampError> +{ + let body = text.strip_suffix('Z').ok_or(TimestampError::NotUtc)?; + let (date_time, fraction) = match body.split_once('.') + { + Some((head, tail)) => (head, Some(tail)), + None => (body, None) + }; + + if let Some(fraction) = fraction + { + let digits = fraction.len(); + + if !(1..=9).contains(&digits) || !fraction.bytes().all(|b| b.is_ascii_digit()) + { + return Err(TimestampError::Shape); + } + } + + let (date, time) = date_time.split_once('T').ok_or(TimestampError::Shape)?; + check_date(date)?; + check_time(time) +} + +/// Checks `YYYY-MM-DD`, including how long the month actually is. +fn check_date(date: &str) -> Result<(), TimestampError> +{ + let parts: Vec<&str> = date.split('-').collect(); + + if parts.len() != 3 || [4, 2, 2] != [parts[0].len(), parts[1].len(), parts[2].len()] + { + return Err(TimestampError::Shape); + } + + let year = number(parts[0])?; + let month = number(parts[1])?; + let day = number(parts[2])?; + + match (1..=12).contains(&month) && (1..=days_in_month(year, month)).contains(&day) + { + true => Ok(()), + false => Err(TimestampError::OutOfRange) + } +} + +/// Checks `HH:MM:SS`, allowing second 60 for a leap second. +fn check_time(time: &str) -> Result<(), TimestampError> +{ + let parts: Vec<&str> = time.split(':').collect(); + + if parts.len() != 3 || parts.iter().any(|part| part.len() != 2) + { + return Err(TimestampError::Shape); + } + + let hour = number(parts[0])?; + let minute = number(parts[1])?; + let second = number(parts[2])?; + + match hour <= 23 && minute <= 59 && second <= 60 + { + true => Ok(()), + false => Err(TimestampError::OutOfRange) + } +} + +/// Reads a run of ASCII digits, refusing anything else a number parser would +/// otherwise take, such as `+1` or a Unicode digit. +fn number(text: &str) -> Result +{ + match text.bytes().all(|b| b.is_ascii_digit()) + { + true => text.parse().map_err(|_| TimestampError::Shape), + false => Err(TimestampError::Shape) + } +} + +/// How many days that month has, in that year. +fn days_in_month(year: u32, month: u32) -> u32 +{ + match month + { + 1 | 3 | 5 | 7 | 8 | 10 | 12 => 31, + 4 | 6 | 9 | 11 => 30, + 2 if year.is_multiple_of(4) && (!year.is_multiple_of(100) || year.is_multiple_of(400)) => + { + 29 + } + 2 => 28, + _ => 0 + } +} + +impl FromStr for Timestamp +{ + type Err = TimestampError; + + fn from_str(text: &str) -> Result + { + Self::parse(text) + } +} + +impl fmt::Display for Timestamp +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result + { + f.write_str(&self.0) + } +} + +impl fmt::Debug for Timestamp +{ + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result + { + write!(f, "Timestamp({})", self.0) + } +} + +impl Serialize for Timestamp +{ + fn serialize(&self, serializer: S) -> Result + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Timestamp +{ + fn deserialize>(deserializer: D) -> Result + { + let text = String::deserialize(deserializer)?; + Self::parse(text).map_err(D::Error::custom) + } +} + +impl specta::Type for Timestamp +{ + fn definition(types: &mut specta::Types) -> specta::datatype::DataType + { + ::definition(types) + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn the_documented_form_round_trips() + { + let text = "2026-08-10T10:00:00Z"; + let stamp = Timestamp::parse(text).unwrap(); + assert_eq!(stamp.as_str(), text); + assert_eq!( + serde_json::to_string(&stamp).unwrap(), + format!("\"{text}\"") + ); + assert_eq!( + serde_json::from_str::(&format!("\"{text}\"")).unwrap(), + stamp + ); + } + + #[test] + fn fractional_seconds_are_allowed() + { + assert!(Timestamp::parse("2026-08-10T10:00:00.123Z").is_ok()); + assert!(Timestamp::parse("2026-08-10T10:00:00.123456789Z").is_ok()); + assert_eq!( + Timestamp::parse("2026-08-10T10:00:00.Z"), + Err(TimestampError::Shape) + ); + } + + #[test] + fn a_second_spelling_of_one_instant_is_refused() + { + assert_eq!( + Timestamp::parse("2026-08-08T00:00:00+09:00"), + Err(TimestampError::NotUtc) + ); + assert_eq!( + Timestamp::parse("2026-08-10t10:00:00Z"), + Err(TimestampError::Shape) + ); + assert_eq!( + Timestamp::parse("2026-08-10 10:00:00Z"), + Err(TimestampError::Shape) + ); + assert_eq!( + Timestamp::parse("2026-08-10T10:00:00"), + Err(TimestampError::NotUtc) + ); + } + + #[test] + fn a_date_that_does_not_exist_is_refused() + { + assert_eq!( + Timestamp::parse("2026-02-29T00:00:00Z"), + Err(TimestampError::OutOfRange) + ); + assert!(Timestamp::parse("2024-02-29T00:00:00Z").is_ok()); + assert!(Timestamp::parse("2000-02-29T00:00:00Z").is_ok()); + assert_eq!( + Timestamp::parse("1900-02-29T00:00:00Z"), + Err(TimestampError::OutOfRange) + ); + assert_eq!( + Timestamp::parse("2026-13-01T00:00:00Z"), + Err(TimestampError::OutOfRange) + ); + assert_eq!( + Timestamp::parse("2026-00-01T00:00:00Z"), + Err(TimestampError::OutOfRange) + ); + assert_eq!( + Timestamp::parse("2026-04-31T00:00:00Z"), + Err(TimestampError::OutOfRange) + ); + } + + #[test] + fn a_time_that_does_not_exist_is_refused() + { + assert_eq!( + Timestamp::parse("2026-08-10T24:00:00Z"), + Err(TimestampError::OutOfRange) + ); + assert_eq!( + Timestamp::parse("2026-08-10T10:60:00Z"), + Err(TimestampError::OutOfRange) + ); + assert!(Timestamp::parse("2026-06-30T23:59:60Z").is_ok()); + assert_eq!( + Timestamp::parse("2026-08-10T10:00:61Z"), + Err(TimestampError::OutOfRange) + ); + } + + #[test] + fn a_shape_a_lenient_parser_would_take_is_refused() + { + for text in [ + "2026-8-10T10:00:00Z", + "26-08-10T10:00:00Z", + "2026-08-10T10:00Z", + "Z" + ] + { + assert_eq!(Timestamp::parse(text), Err(TimestampError::Shape), "{text}"); + } + assert_eq!( + Timestamp::parse("2026-08-+1T10:00:00Z"), + Err(TimestampError::Shape) + ); + assert_eq!(Timestamp::parse(""), Err(TimestampError::NotUtc)); + } +} diff --git a/crates/trdr-core/src/ui.rs b/crates/trdr-core/src/ui.rs new file mode 100644 index 0000000..4178de5 --- /dev/null +++ b/crates/trdr-core/src/ui.rs @@ -0,0 +1,618 @@ +//! What the React WebView is allowed to ask the Tauri host for (section 9.1). +//! +//! The WebView has no Keychain, no database, no filesystem, and no network. It +//! has this list. Every command below is one the app wrote and registered by +//! name; the generic shell, filesystem, and SQL plugin commands are never +//! exposed, so this enum is the whole surface a screen can reach. +//! +//! Two things are load-bearing here beyond the names: +//! +//! - A screen never passes a path. It passes a [`ScopedPathHandle`], which the +//! host minted after the user picked something in a native panel. The mapping +//! from handle to real location stays in the host, so a screen cannot name a +//! file the user did not choose. +//! - Reading and intending are separated by [`CommandKind`], not by convention. +//! Section 2 requires the boundary between a read and a durable change to be +//! fixed per command, and [`UiCommand::kind`] is where it is fixed. +//! +//! Stage 0 defines the shapes. No command is handled here, and several carry no +//! parameters yet — those are marked, and the track that implements a command +//! adds its parameters with it. + +use crate::envelope::EnvelopeVersion; +use crate::error::ErrorEnvelope; +use crate::id::{ApprovalRequestId, RequestId, ResourceId, ScopedPathHandle}; +use serde::{Deserialize, Serialize}; + +/// A command from the WebView, with the version and request id every trdr +/// message carries. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct UiCommandEnvelope +{ + /// Protocol version. + pub v: EnvelopeVersion, + /// Correlates this command with its response. + pub id: RequestId, + /// What is being asked for. + pub command: UiCommand +} + +impl UiCommandEnvelope +{ + /// An envelope at the current protocol version. + pub fn new(id: RequestId, command: UiCommand) -> Self + { + Self { + v: EnvelopeVersion::CURRENT, + id, + command + } + } +} + +/// The answer to a [`UiCommandEnvelope`]. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct UiResponseEnvelope +{ + /// Protocol version. + pub v: EnvelopeVersion, + /// The command this answers. + pub id: RequestId, + /// How it went. + pub outcome: UiOutcome +} + +impl UiResponseEnvelope +{ + /// A successful answer. + pub fn ok(id: RequestId, value: T) -> Self + { + Self { + v: EnvelopeVersion::CURRENT, + id, + outcome: UiOutcome::Ok(value) + } + } + + /// A failed one. + pub fn error(id: RequestId, error: ErrorEnvelope) -> Self + { + Self { + v: EnvelopeVersion::CURRENT, + id, + outcome: UiOutcome::Error(error) + } + } +} + +/// Either a result or an error envelope, never both and never neither. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] +#[serde(tag = "status", content = "value", rename_all = "snake_case")] +pub enum UiOutcome +{ + /// The command succeeded, and this is what it produced. + Ok(T), + /// The command failed. + Error(ErrorEnvelope) +} + +/// Whether a command only looks, or intends a change. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "snake_case")] +pub enum CommandKind +{ + /// Returns a query model and changes nothing. + Read, + /// Asks for something to happen: a job, a native sheet, a process. + Intent +} + +/// One of the three sources trdr collects from itself. +/// +/// User-written collectors are not here. They do not run inside the app and do +/// not have commands; they write a bundle and it goes through ingest (section +/// 8.3). KRX is not here either, and section 1 keeps it that way. +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, specta::Type, +)] +pub enum CollectorId +{ + /// Korea Investment & Securities. + #[serde(rename = "trdr.kis")] + Kis, + /// OpenDART, the disclosure service. + #[serde(rename = "trdr.opendart")] + OpenDart, + /// ECOS, the Bank of Korea statistics service. + #[serde(rename = "trdr.ecos")] + Ecos +} + +/// How a person answered an approval sheet (section 9.3). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalDecision +{ + /// Let it happen. + Approve, + /// Do not. + Reject +} + +/// Names one of the three collectors. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct CollectorParams +{ + /// Which collector. + pub collector: CollectorId +} + +/// Names a strategy the user has. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct StrategyParams +{ + /// The strategy's user-facing id. + pub strategy: ResourceId +} + +/// Names a location the user picked in a native panel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct PathHandleParams +{ + /// The handle the host minted for that location. + pub path: ScopedPathHandle +} + +/// Answers one pending approval. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct ApprovalRespondParams +{ + /// Which request is being answered. + pub request: ApprovalRequestId, + /// The answer. + pub decision: ApprovalDecision +} + +/// Keystrokes for the agent terminal. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct TerminalInputParams +{ + /// Raw bytes, base64 encoded. + /// + /// Bytes, not text: section 1 says the agent's output is a byte stream and + /// nothing on the way through interprets it. The same holds going in. + pub data_base64: String +} + +/// A new terminal size. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct TerminalResizeParams +{ + /// Columns. + pub cols: u16, + /// Rows. + pub rows: u16 +} + +/// Every command the WebView can send. +/// +/// On the wire a command is `{"method": "...", "params": {...}}`, with `params` +/// left out entirely where a command has none. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, specta::Type)] +#[serde(tag = "method", content = "params", deny_unknown_fields)] +pub enum UiCommand +{ + /// What the app needs before it can show anything. + #[serde(rename = "bootstrap.get")] + BootstrapGet, + /// The Today screen's model. + #[serde(rename = "today.get")] + TodayGet, + /// Every collector and its credential state. + #[serde(rename = "collectors.list")] + CollectorsList, + /// The current strategy draft as the Lab sees it. + /// + /// Parameters land with the Lab track. + #[serde(rename = "lab.draft.get")] + LabDraftGet, + /// A finished backtest. + /// + /// Parameters land with the Lab track. + #[serde(rename = "backtest.get")] + BacktestGet, + /// Every registered strategy. + #[serde(rename = "strategies.list")] + StrategiesList, + /// One registered strategy in detail. + #[serde(rename = "strategy.get")] + StrategyGet(StrategyParams), + /// Running and recent jobs. + #[serde(rename = "jobs.get")] + JobsGet, + /// Backups this workspace knows about. + #[serde(rename = "backup.list")] + BackupList, + /// Raise the native sheet for a collector's credentials. + /// + /// The secret goes from a secure field straight to the Keychain. The WebView + /// only ever learns the resulting state (section 10). + #[serde(rename = "collector.credential.open_native_sheet")] + CollectorCredentialOpenNativeSheet(CollectorParams), + /// Try the stored credential against the source. + #[serde(rename = "collector.test")] + CollectorTest(CollectorParams), + /// Collect from the source. + #[serde(rename = "collector.collect")] + CollectorCollect(CollectorParams), + /// Pick a workspace. + /// + /// Takes nothing: it opens the native panel that produces the handle. + #[serde(rename = "workspace.choose")] + WorkspaceChoose, + /// Validate a bundle and report what committing it would do, changing + /// nothing (section 8.2). + #[serde(rename = "ingest.dry_run")] + IngestDryRun(PathHandleParams), + /// Commit a bundle in one transaction. + #[serde(rename = "ingest.commit")] + IngestCommit(PathHandleParams), + /// Run a backtest. + /// + /// Parameters land with the Lab track. + #[serde(rename = "backtest.run")] + BacktestRun, + /// Write a portable backup to the chosen place. + #[serde(rename = "backup.create")] + BackupCreate(PathHandleParams), + /// Check a backup package without restoring it. + #[serde(rename = "backup.verify")] + BackupVerify(PathHandleParams), + /// Raise the native sheet that walks through restoring a backup. + #[serde(rename = "workspace.restore.open_native_sheet")] + WorkspaceRestoreOpenNativeSheet(PathHandleParams), + /// Answer a pending approval. + #[serde(rename = "approval.respond")] + ApprovalRespond(ApprovalRespondParams), + /// Start the agent terminal. + /// + /// Which executable to run is a setting, not a parameter a screen supplies. + #[serde(rename = "terminal.start")] + TerminalStart, + /// Send bytes to the agent. + #[serde(rename = "terminal.input")] + TerminalInput(TerminalInputParams), + /// Tell the agent the terminal changed size. + #[serde(rename = "terminal.resize")] + TerminalResize(TerminalResizeParams), + /// Stop the agent and start it again. + #[serde(rename = "terminal.restart")] + TerminalRestart +} + +impl UiCommand +{ + /// Whether this command reads or intends a change. + /// + /// The match is exhaustive, so a command added later cannot be registered + /// without someone deciding which side of the boundary it is on. + pub const fn kind(&self) -> CommandKind + { + match self + { + Self::BootstrapGet + | Self::TodayGet + | Self::CollectorsList + | Self::LabDraftGet + | Self::BacktestGet + | Self::StrategiesList + | Self::StrategyGet(_) + | Self::JobsGet + | Self::BackupList => CommandKind::Read, + Self::CollectorCredentialOpenNativeSheet(_) + | Self::CollectorTest(_) + | Self::CollectorCollect(_) + | Self::WorkspaceChoose + | Self::IngestDryRun(_) + | Self::IngestCommit(_) + | Self::BacktestRun + | Self::BackupCreate(_) + | Self::BackupVerify(_) + | Self::WorkspaceRestoreOpenNativeSheet(_) + | Self::ApprovalRespond(_) + | Self::TerminalStart + | Self::TerminalInput(_) + | Self::TerminalResize(_) + | Self::TerminalRestart => CommandKind::Intent + } + } + + /// The name this command goes by on the wire and in the capability file. + pub const fn method(&self) -> &'static str + { + match self + { + Self::BootstrapGet => "bootstrap.get", + Self::TodayGet => "today.get", + Self::CollectorsList => "collectors.list", + Self::LabDraftGet => "lab.draft.get", + Self::BacktestGet => "backtest.get", + Self::StrategiesList => "strategies.list", + Self::StrategyGet(_) => "strategy.get", + Self::JobsGet => "jobs.get", + Self::BackupList => "backup.list", + Self::CollectorCredentialOpenNativeSheet(_) => "collector.credential.open_native_sheet", + Self::CollectorTest(_) => "collector.test", + Self::CollectorCollect(_) => "collector.collect", + Self::WorkspaceChoose => "workspace.choose", + Self::IngestDryRun(_) => "ingest.dry_run", + Self::IngestCommit(_) => "ingest.commit", + Self::BacktestRun => "backtest.run", + Self::BackupCreate(_) => "backup.create", + Self::BackupVerify(_) => "backup.verify", + Self::WorkspaceRestoreOpenNativeSheet(_) => "workspace.restore.open_native_sheet", + Self::ApprovalRespond(_) => "approval.respond", + Self::TerminalStart => "terminal.start", + Self::TerminalInput(_) => "terminal.input", + Self::TerminalResize(_) => "terminal.resize", + Self::TerminalRestart => "terminal.restart" + } + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + const REQUEST: &str = "01KZNNR5X818P3J6ENYKSADP8W"; + const HANDLE: &str = "01KZNP0GQ3X8ARK9DQ489Z7WJ8"; + + /// One of every command, so that the tests below cover the whole surface + /// rather than a sample of it. + fn every_command() -> Vec + { + let path = PathHandleParams { + path: HANDLE.parse().unwrap() + }; + let collector = CollectorParams { + collector: CollectorId::Kis + }; + + vec![ + UiCommand::BootstrapGet, + UiCommand::TodayGet, + UiCommand::CollectorsList, + UiCommand::LabDraftGet, + UiCommand::BacktestGet, + UiCommand::StrategiesList, + UiCommand::StrategyGet(StrategyParams { + strategy: ResourceId::parse("low-vol-v1").unwrap() + }), + UiCommand::JobsGet, + UiCommand::BackupList, + UiCommand::CollectorCredentialOpenNativeSheet(collector), + UiCommand::CollectorTest(collector), + UiCommand::CollectorCollect(collector), + UiCommand::WorkspaceChoose, + UiCommand::IngestDryRun(path), + UiCommand::IngestCommit(path), + UiCommand::BacktestRun, + UiCommand::BackupCreate(path), + UiCommand::BackupVerify(path), + UiCommand::WorkspaceRestoreOpenNativeSheet(path), + UiCommand::ApprovalRespond(ApprovalRespondParams { + request: HANDLE.parse().unwrap(), + decision: ApprovalDecision::Approve + }), + UiCommand::TerminalStart, + UiCommand::TerminalInput(TerminalInputParams { + data_base64: "bHM=".to_owned() + }), + UiCommand::TerminalResize(TerminalResizeParams { + cols: 120, + rows: 40 + }), + UiCommand::TerminalRestart, + ] + } + + #[test] + fn every_command_round_trips() + { + for command in every_command() + { + let json = serde_json::to_string(&command).unwrap(); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + command, + "{json}" + ); + } + } + + #[test] + fn the_method_name_is_the_tag_on_the_wire() + { + for command in every_command() + { + let value = serde_json::to_value(&command).unwrap(); + assert_eq!(value["method"], command.method()); + } + } + + #[test] + fn the_command_list_is_the_one_the_design_fixes() + { + let methods: Vec<&str> = every_command().iter().map(|c| c.method()).collect(); + let reads: Vec<&str> = every_command() + .iter() + .filter(|c| c.kind() == CommandKind::Read) + .map(|c| c.method()) + .collect(); + let intents: Vec<&str> = every_command() + .iter() + .filter(|c| c.kind() == CommandKind::Intent) + .map(|c| c.method()) + .collect(); + + assert_eq!( + reads, + [ + "bootstrap.get", + "today.get", + "collectors.list", + "lab.draft.get", + "backtest.get", + "strategies.list", + "strategy.get", + "jobs.get", + "backup.list" + ] + ); + assert_eq!( + intents, + [ + "collector.credential.open_native_sheet", + "collector.test", + "collector.collect", + "workspace.choose", + "ingest.dry_run", + "ingest.commit", + "backtest.run", + "backup.create", + "backup.verify", + "workspace.restore.open_native_sheet", + "approval.respond", + "terminal.start", + "terminal.input", + "terminal.resize", + "terminal.restart" + ] + ); + assert_eq!(methods.len(), reads.len() + intents.len()); + } + + #[test] + fn a_command_with_parameters_carries_them_where_the_shape_says() + { + let command = UiCommand::StrategyGet(StrategyParams { + strategy: ResourceId::parse("low-vol-v1").unwrap() + }); + assert_eq!( + serde_json::to_string(&command).unwrap(), + "{\"method\":\"strategy.get\",\"params\":{\"strategy\":\"low-vol-v1\"}}" + ); + } + + #[test] + fn an_unknown_method_is_refused() + { + assert!(serde_json::from_str::("{\"method\":\"db.query\"}").is_err()); + assert!(serde_json::from_str::( + "{\"method\":\"strategy.get\",\"params\":{\"strategy\":\"../secrets\"}}" + ) + .is_err()); + } + + #[test] + fn a_command_that_takes_nothing_refuses_parameters() + { + assert!(serde_json::from_str::( + "{\"method\":\"today.get\",\"params\":{\"since\":\"2026-01-01\"}}" + ) + .is_err()); + } + + #[test] + fn a_field_beside_the_method_is_refused() + { + assert!( + serde_json::from_str::("{\"method\":\"today.get\",\"force\":true}").is_err() + ); + } + + #[test] + fn an_unknown_parameter_is_refused() + { + assert!(serde_json::from_str::( + "{\"method\":\"terminal.resize\",\"params\":{\"cols\":80,\"rows\":24,\"pixels\":1}}" + ) + .is_err()); + } + + #[test] + fn an_envelope_round_trips() + { + let envelope = UiCommandEnvelope::new(REQUEST.parse().unwrap(), UiCommand::TodayGet); + let json = serde_json::to_string(&envelope).unwrap(); + assert_eq!( + json, + format!("{{\"v\":1,\"id\":\"{REQUEST}\",\"command\":{{\"method\":\"today.get\"}}}}") + ); + assert_eq!( + serde_json::from_str::(&json).unwrap(), + envelope + ); + } + + /// The type that crosses to TypeScript has to be describable, not just + /// derivable. tauri-specta generates the bindings in a later track; this is + /// what catches a shape it could not have exported, now rather than then. + #[test] + fn the_types_the_webview_sees_can_be_described() + { + use specta::datatype::{DataType, Primitive}; + use specta::Type as _; + + let mut types = specta::Types::default(); + + UiCommandEnvelope::definition(&mut types); + UiResponseEnvelope::::definition(&mut types); + ErrorEnvelope::definition(&mut types); + crate::workspace::WorkspaceManifest::definition(&mut types); + + // Every id and timestamp is a validated Rust type and a plain string on + // the other side, which is why they are parsed again on the way back in. + for described in [ + crate::id::WorkspaceId::definition(&mut types), + crate::id::ScopedPathHandle::definition(&mut types), + ResourceId::definition(&mut types), + crate::time::Timestamp::definition(&mut types) + ] + { + assert!(matches!(described, DataType::Primitive(Primitive::str))); + } + } + + #[test] + fn a_response_round_trips_either_way() + { + let ok: UiResponseEnvelope = UiResponseEnvelope::ok(REQUEST.parse().unwrap(), 7); + let json = serde_json::to_string(&ok).unwrap(); + assert_eq!( + serde_json::from_str::>(&json).unwrap(), + ok + ); + assert!(json.contains("\"status\":\"ok\"")); + + let failed: UiResponseEnvelope = UiResponseEnvelope::error( + REQUEST.parse().unwrap(), + ErrorEnvelope::new(crate::error::ErrorCode::DbBusy) + ); + let json = serde_json::to_string(&failed).unwrap(); + assert_eq!( + serde_json::from_str::>(&json).unwrap(), + failed + ); + assert!(json.contains("\"status\":\"error\"")); + } +} diff --git a/crates/trdr-core/src/workspace.rs b/crates/trdr-core/src/workspace.rs new file mode 100644 index 0000000..5b7ac3d --- /dev/null +++ b/crates/trdr-core/src/workspace.rs @@ -0,0 +1,169 @@ +//! `workspace.json`, the file that says which workspace this directory is. +//! +//! Section 5.1 of `docs/FOUNDATION_DESIGN.md` puts three things in it: the +//! portable workspace id, a schema version, and when it was created. The id is +//! portable on purpose — it travels inside a backup, so a restored copy on +//! another Mac is recognisably the same workspace. The per-machine identity that +//! Keychain items hang off is a different value, kept in `~/.trdr/config.json`, +//! and is deliberately not in this file: that is what stops a restored copy from +//! inheriting another machine's stored credentials by name. +//! +//! Nothing here reads or writes the file. Locating it, opening it, and taking +//! the writer lease are the runtime's job. + +use crate::id::WorkspaceId; +use crate::time::Timestamp; +use serde::{Deserialize, Serialize}; + +/// The schema version this build writes into a new `workspace.json`. +pub const WORKSPACE_SCHEMA_V1: &str = "trdr.workspace/v1"; + +/// Which version of the workspace file this is. +/// +/// A separate version from the database schema, the ingest bundle, and the +/// backup package, as section 6.1 requires. They move independently. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +pub enum WorkspaceSchemaVersion +{ + /// The first version. + #[serde(rename = "trdr.workspace/v1")] + V1 +} + +/// The contents of `workspace.json`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, specta::Type)] +#[serde(deny_unknown_fields, rename_all = "snake_case")] +pub struct WorkspaceManifest +{ + /// Which version of this file's own schema is in use. + pub schema_version: WorkspaceSchemaVersion, + /// The portable identity of this workspace. + pub workspace_id: WorkspaceId, + /// When the workspace was created. + pub created_at: Timestamp +} + +impl WorkspaceManifest +{ + /// A manifest for a workspace being created now. + pub fn new(workspace_id: WorkspaceId, created_at: Timestamp) -> Self + { + Self { + schema_version: WorkspaceSchemaVersion::V1, + workspace_id, + created_at + } + } + + /// Reads a manifest, telling an unreadable file apart from one written by a + /// version this build does not know. + /// + /// The difference matters to the person holding the workspace: the first is + /// damage, the second is a newer trdr, and section 6.1 forbids opening + /// something newer rather than guessing at it. + pub fn from_json(text: &str) -> Result + { + #[derive(Deserialize)] + struct SchemaProbe + { + schema_version: String + } + + let probe: SchemaProbe = + serde_json::from_str(text).map_err(|_| ManifestError::Unreadable)?; + + if probe.schema_version != WORKSPACE_SCHEMA_V1 + { + return Err(ManifestError::UnsupportedSchemaVersion { + found: probe.schema_version + }); + } + + serde_json::from_str(text).map_err(|_| ManifestError::Unreadable) + } +} + +/// `workspace.json` could not be taken at face value. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ManifestError +{ + /// The file is not a workspace manifest, or one of its values is not valid. + #[error("the workspace manifest could not be read")] + Unreadable, + /// The file names a schema version this build does not know. + #[error("workspace schema version {found} is not one this build knows")] + UnsupportedSchemaVersion + { + /// The version the file claimed. + found: String + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + + fn sample() -> WorkspaceManifest + { + WorkspaceManifest::new( + "01KZNNR5X818P3J6ENYKSADP8W".parse().unwrap(), + Timestamp::parse("2026-08-10T10:00:00Z").unwrap() + ) + } + + #[test] + fn a_manifest_round_trips() + { + let json = serde_json::to_string(&sample()).unwrap(); + assert_eq!( + json, + "{\"schema_version\":\"trdr.workspace/v1\",\ + \"workspace_id\":\"01KZNNR5X818P3J6ENYKSADP8W\",\ + \"created_at\":\"2026-08-10T10:00:00Z\"}" + ); + assert_eq!(WorkspaceManifest::from_json(&json).unwrap(), sample()); + } + + #[test] + fn a_newer_schema_version_is_told_apart_from_damage() + { + let newer = "{\"schema_version\":\"trdr.workspace/v2\",\ + \"workspace_id\":\"01KZNNR5X818P3J6ENYKSADP8W\",\ + \"created_at\":\"2026-08-10T10:00:00Z\"}"; + assert_eq!( + WorkspaceManifest::from_json(newer), + Err(ManifestError::UnsupportedSchemaVersion { + found: "trdr.workspace/v2".to_owned() + }) + ); + assert_eq!( + WorkspaceManifest::from_json("{"), + Err(ManifestError::Unreadable) + ); + } + + #[test] + fn a_manifest_with_an_unusable_value_is_refused() + { + for json in [ + // A workspace id that is not a ULID. + "{\"schema_version\":\"trdr.workspace/v1\",\"workspace_id\":\"default\",\ + \"created_at\":\"2026-08-10T10:00:00Z\"}", + // A timestamp with a local offset. + "{\"schema_version\":\"trdr.workspace/v1\",\ + \"workspace_id\":\"01KZNNR5X818P3J6ENYKSADP8W\",\ + \"created_at\":\"2026-08-10T19:00:00+09:00\"}", + // A field this build does not know. + "{\"schema_version\":\"trdr.workspace/v1\",\ + \"workspace_id\":\"01KZNNR5X818P3J6ENYKSADP8W\",\ + \"created_at\":\"2026-08-10T10:00:00Z\",\"local_instance_id\":\"x\"}" + ] + { + assert_eq!( + WorkspaceManifest::from_json(json), + Err(ManifestError::Unreadable) + ); + } + } +} diff --git a/crates/trdr-core/tests/synthetic_ingest_bundle.rs b/crates/trdr-core/tests/synthetic_ingest_bundle.rs new file mode 100644 index 0000000..302f8b4 --- /dev/null +++ b/crates/trdr-core/tests/synthetic_ingest_bundle.rs @@ -0,0 +1,472 @@ +//! Reads the one synthetic ingest bundle in `fixtures/synthetic/`. +//! +//! Section 8.1 of `docs/FOUNDATION_DESIGN.md` fixes the shape of an ingest +//! bundle — a `bundle.json` manifest beside NDJSON record files — and section +//! 5.2 requires every persisted object to carry a SHA-256 and a source +//! manifest. This test is what makes that requirement real for the fixture: it +//! recomputes the digest of every record file from the bytes on disk and +//! compares it against the digest the manifest declares. A manifest whose +//! hashes nobody recomputes is decoration. +//! +//! What this test is not: the ingest pipeline. There is no schema crate, no +//! collector and no database here, and the record types below are local to this +//! file on purpose. When the ingest track lands its real types, this fixture is +//! the first thing they should be pointed at, and these local structs go away. +//! +//! Every value in the bundle is synthetic and derived from a rule stated in +//! `daily_bars_follow_the_documented_synthetic_rule`. The market is `SYN`, the +//! instruments are `SYN0001` and `SYN0002`, and the upstream host is under the +//! `.invalid` top-level domain that RFC 2606 reserves so it can never resolve. +//! No real venue, ticker, account or price appears in this repository. + +use serde::Deserialize; +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; +use trdr_core::id::Ulid; +use trdr_core::Timestamp; + +/// The trading days the fixture covers, in the order the generator emitted them. +const DAYS: [&str; 5] = [ + "2026-08-03", + "2026-08-04", + "2026-08-05", + "2026-08-06", + "2026-08-07" +]; + +/// The `bundle.json` manifest of section 8.1. +/// +/// `deny_unknown_fields` mirrors the ingest rule that an unknown field is +/// refused rather than silently dropped, so a stray key in the fixture fails +/// here instead of being ignored. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Manifest +{ + schema_version: String, + bundle_id: String, + source: Source, + collector: Collector, + collected_at: String, + files: Vec +} + +/// Where the records came from, as the person who built the bundle declares it. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Source +{ + id: String, + upstream_url: String, + terms_url: String, + use_basis: String +} + +/// What produced the bundle. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Collector +{ + name: String, + version: String +} + +/// One record file, with the digest the manifest commits to. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct FileEntry +{ + entity: String, + path: String, + records: usize, + sha256: String +} + +/// One NDJSON line: the record envelope of section 8.1. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Envelope +{ + entity: String, + key: serde_json::Map, + observed_at: String, + available_at: String, + revision: u32, + payload: serde_json::Map +} + +/// The bundle directory, resolved from this crate rather than the working directory. +fn bundle_dir() -> PathBuf +{ + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/synthetic/ingest-bundle-v1") +} + +/// Reads and parses `bundle.json`. +fn manifest() -> Manifest +{ + let path = bundle_dir().join("bundle.json"); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); + + serde_json::from_str(&text) + .unwrap_or_else(|error| panic!("{} is not a valid manifest: {error}", path.display())) +} + +/// Reads one record file into parsed envelopes. +fn envelopes(entry: &FileEntry) -> Vec +{ + let path = bundle_dir().join(&entry.path); + let text = std::fs::read_to_string(&path) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); + + text.lines() + .enumerate() + .map(|(index, line)| { + serde_json::from_str(line).unwrap_or_else(|error| { + panic!( + "{}:{} is not a valid envelope: {error}", + entry.path, + index + 1 + ) + }) + }) + .collect() +} + +/// Reads the numeric suffix of a synthetic symbol, so `SYN0002` is instrument 2. +fn symbol_index(symbol: &str) -> u64 +{ + symbol + .strip_prefix("SYN") + .and_then(|digits| digits.parse().ok()) + .unwrap_or_else(|| panic!("{symbol} is not a synthetic symbol")) +} + +/// Reads one string field out of a record's key or payload. +fn field<'a>(map: &'a serde_json::Map, name: &str) -> &'a str +{ + map.get(name) + .and_then(serde_json::Value::as_str) + .unwrap_or_else(|| panic!("expected a string field {name}")) +} + +#[test] +fn sha256_matches_the_published_test_vectors() +{ + // The three vectors from FIPS 180-4: empty, one block, and two blocks. They + // are what licenses the digests this file computes for the fixture below. + assert_eq!( + sha256::hex(b""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256::hex(b"abc"), + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + assert_eq!( + sha256::hex(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq"), + "248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1" + ); +} + +#[test] +fn declared_file_digests_match_the_bytes_on_disk() +{ + let manifest = manifest(); + assert!(!manifest.files.is_empty(), "the manifest lists no files"); + + for entry in &manifest.files + { + let path = bundle_dir().join(&entry.path); + let bytes = std::fs::read(&path) + .unwrap_or_else(|error| panic!("cannot read {}: {error}", path.display())); + let computed = sha256::hex(&bytes); + + assert_eq!( + computed, entry.sha256, + "{} hashes to {computed}, but bundle.json declares {}. Either the \ + records changed without the manifest being updated, or the \ + manifest is wrong; regenerate both together.", + entry.path, entry.sha256 + ); + + let lines = std::fs::read_to_string(&path) + .expect("already read once") + .lines() + .count(); + assert_eq!( + lines, entry.records, + "{} record count disagrees", + entry.path + ); + } +} + +#[test] +fn the_manifest_declares_a_synthetic_source() +{ + let manifest = manifest(); + + assert_eq!(manifest.schema_version, "trdr.ingest/v1"); + Ulid::from_string(&manifest.bundle_id).expect("bundle_id is a ULID"); + Timestamp::parse(manifest.collected_at.clone()).expect("collected_at is canonical UTC"); + + // Section 8.2 reserves `trdr.*` for built-in collectors, so anything this + // repository ships as a fixture has to live in the `user.*` namespace. + assert_eq!(manifest.source.id, "user.synthetic"); + assert_eq!(manifest.source.use_basis, "synthetic"); + assert_eq!(manifest.collector.name, "trdr-synthetic-fixture"); + assert_eq!(manifest.collector.version, "1.0.0"); + + for url in [&manifest.source.upstream_url, &manifest.source.terms_url] + { + assert!( + url.starts_with("https://fixtures.invalid/"), + "{url} points somewhere that can resolve; a fixture must name a \ + host under the reserved .invalid domain" + ); + } +} + +#[test] +fn every_record_envelope_is_well_formed() +{ + let manifest = manifest(); + let mut seen = BTreeSet::new(); + + for entry in &manifest.files + { + let records = envelopes(entry); + assert_eq!( + records.len(), + entry.records, + "{} record count disagrees", + entry.path + ); + + for (index, record) in records.iter().enumerate() + { + let where_ = format!("{}:{}", entry.path, index + 1); + + assert_eq!( + record.entity, entry.entity, + "{where_} declares another entity" + ); + assert!(record.revision >= 1, "{where_} has revision 0"); + assert!(!record.key.is_empty(), "{where_} has an empty natural key"); + assert!(!record.payload.is_empty(), "{where_} has an empty payload"); + + // Section 7.2 wants one spelling per instant, and `Timestamp` is + // the type that decides what that spelling is. + Timestamp::parse(record.observed_at.clone()) + .unwrap_or_else(|error| panic!("{where_} observed_at: {error}")); + Timestamp::parse(record.available_at.clone()) + .unwrap_or_else(|error| panic!("{where_} available_at: {error}")); + + // Same source, key and revision may appear once. A second copy is + // the conflict case, not a fixture. + let identity = format!( + "{}|{}|{}", + record.entity, + serde_json::to_string(&record.key).expect("a key serialises"), + record.revision + ); + assert!(seen.insert(identity), "{where_} repeats a key and revision"); + } + } +} + +#[test] +fn daily_bars_follow_the_documented_synthetic_rule() +{ + // The rule, in full: for instrument `s` (1-based, from the symbol suffix) + // on trading day `d` (1-based, from DAYS), + // + // open = 10000 * s + 100 * d + // high = open + 250 + // low = open - 150 + // close = open + 50 + // volume = 1000 * s * d + // + // Prices are KRW integers written as strings, per section 7.2. Anyone can + // regenerate the file from this paragraph, which is the point: no value in + // it was observed anywhere. + let manifest = manifest(); + let entry = manifest + .files + .iter() + .find(|entry| entry.entity == "daily_bar") + .expect("the bundle carries daily bars"); + + let records = envelopes(entry); + let mut corrections = 0; + + for record in &records + { + let symbol = symbol_index(field(&record.key, "symbol")); + let date = field(&record.key, "date"); + let day = DAYS + .iter() + .position(|candidate| *candidate == date) + .unwrap_or_else(|| panic!("{date} is outside the fixture's trading days")) + as u64 + + 1; + + assert_eq!(field(&record.key, "market"), "SYN"); + + let open = 10000 * symbol + 100 * day; + assert_eq!(field(&record.payload, "open"), open.to_string()); + assert_eq!(field(&record.payload, "high"), (open + 250).to_string()); + assert_eq!(field(&record.payload, "low"), (open - 150).to_string()); + assert_eq!(field(&record.payload, "close"), (open + 50).to_string()); + + // The one revision-2 record restates its bar with a corrected volume, + // so the fixture carries the append-a-new-revision case and not only + // the happy path. + let volume = 1000 * symbol * day; + match record.revision + { + 1 => assert_eq!(field(&record.payload, "volume"), volume.to_string()), + 2 => + { + corrections += 1; + assert_eq!(field(&record.payload, "volume"), (volume + 7).to_string()); + } + other => panic!("revision {other} is not part of the fixture") + } + } + + assert_eq!( + corrections, 1, + "the fixture carries exactly one corrected bar" + ); +} + +/// A SHA-256 for this test only. +/// +/// It is here so that checking the fixture's digests costs `trdr-core` no +/// dependency at all — not even a dev-dependency, in a crate whose whole point +/// is a dependency boundary narrow enough to audit at a glance. It is proven +/// against the FIPS 180-4 vectors above. +/// +/// It is not a production hash. The hash boundary of section 7.2 — input and +/// output hashes for a backtest, and content addressing for stored objects — +/// belongs to the runtime and must use a reviewed implementation. +mod sha256 +{ + /// The first 32 bits of the fractional parts of the cube roots of the first + /// 64 primes. + const ROUND_CONSTANTS: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2 + ]; + + /// The first 32 bits of the fractional parts of the square roots of the + /// first eight primes. + const INITIAL_STATE: [u32; 8] = [ + 0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, + 0x5be0cd19 + ]; + + /// The digest of `message`, lower-case hexadecimal. + pub fn hex(message: &[u8]) -> String + { + digest(message) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() + } + + /// Pads the message and runs it through the compression function. + fn digest(message: &[u8]) -> [u8; 32] + { + let bit_length = (message.len() as u64) * 8; + let mut padded = message.to_vec(); + padded.push(0x80); + + while padded.len() % 64 != 56 + { + padded.push(0); + } + + padded.extend_from_slice(&bit_length.to_be_bytes()); + + let mut state = INITIAL_STATE; + + for block in padded.chunks_exact(64) + { + compress(&mut state, block); + } + + let mut digest = [0u8; 32]; + + for (slot, word) in digest.chunks_exact_mut(4).zip(state.iter()) + { + slot.copy_from_slice(&word.to_be_bytes()); + } + + digest + } + + /// One 64-byte block, folded into the running state. + fn compress(state: &mut [u32; 8], block: &[u8]) + { + let mut schedule = [0u32; 64]; + + for (slot, word) in schedule.iter_mut().zip(block.chunks_exact(4)) + { + *slot = u32::from_be_bytes(word.try_into().expect("a four-byte word")); + } + + for index in 16..64 + { + let previous = schedule[index - 15]; + let recent = schedule[index - 2]; + let s0 = previous.rotate_right(7) ^ previous.rotate_right(18) ^ (previous >> 3); + let s1 = recent.rotate_right(17) ^ recent.rotate_right(19) ^ (recent >> 10); + + schedule[index] = schedule[index - 16] + .wrapping_add(s0) + .wrapping_add(schedule[index - 7]) + .wrapping_add(s1); + } + + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut h] = *state; + + for (constant, word) in ROUND_CONSTANTS.iter().zip(schedule.iter()) + { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let choose = (e & f) ^ ((!e) & g); + let temp1 = h + .wrapping_add(s1) + .wrapping_add(choose) + .wrapping_add(*constant) + .wrapping_add(*word); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let majority = (a & b) ^ (a & c) ^ (b & c); + let temp2 = s0.wrapping_add(majority); + + h = g; + g = f; + f = e; + e = d.wrapping_add(temp1); + d = c; + c = b; + b = a; + a = temp1.wrapping_add(temp2); + } + + for (slot, value) in state.iter_mut().zip([a, b, c, d, e, f, g, h]) + { + *slot = slot.wrapping_add(value); + } + } +} diff --git a/crates/trdr-runtime/Cargo.toml b/crates/trdr-runtime/Cargo.toml new file mode 100644 index 0000000..50cca5f --- /dev/null +++ b/crates/trdr-runtime/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "trdr-runtime" +description = "trdr runtime: storage, credentials, collectors, terminal, and app IPC" +version.workspace = true +edition.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +# Everything that touches the outside world belongs here rather than in +# trdr-core. At stage 0 this crate is a set of empty modules, one per track, so +# the dependency it will need arrives with the track that needs it. +[dependencies] +trdr-core.workspace = true +libc.workspace = true +rusqlite.workspace = true +serde.workspace = true +serde_json.workspace = true +sha2.workspace = true +thiserror.workspace = true +# `std` is what pulls in `rand` and gives `Ulid::new` a clock and a random tail. +# trdr-core takes the same crate with default features off so that the domain can +# read a ULID without being able to mint one; minting belongs to this crate, so +# the feature is turned back on here. +ulid = { workspace = true, features = ["std"] } diff --git a/crates/trdr-runtime/src/clock.rs b/crates/trdr-runtime/src/clock.rs new file mode 100644 index 0000000..cc95737 --- /dev/null +++ b/crates/trdr-runtime/src/clock.rs @@ -0,0 +1,209 @@ +//! Reading the current instant, behind the seam section 13 calls `Clock`. +//! +//! `trdr-core` can hold a [`Timestamp`] and check that it is written the one way +//! section 7.2 allows, but it cannot produce one: reading a clock is an effect, +//! and the domain crate has no way to reach the outside world. So the clock is a +//! trait here, the operating system's clock is one implementation of it, and a +//! test substitutes its own (see [`crate::test_support::FixedClock`]). +//! +//! # Why the calendar arithmetic is written out +//! +//! Turning a count of seconds into `2026-08-11T07:14:03Z` needs a proleptic +//! Gregorian calendar, and the obvious way to get one is another dependency. The +//! conversion is about twenty lines of well-known integer arithmetic, it has no +//! configuration, no locale, and no time zone database behind it, and every case +//! it can produce is covered below. A dependency here would be larger than the +//! thing it replaced. + +use std::time::{SystemTime, UNIX_EPOCH}; +use trdr_core::time::Timestamp; + +/// How many seconds a day has. No leap seconds: `SystemTime` does not have them +/// either, so introducing one here would invent a difference rather than record +/// it. +const SECONDS_PER_DAY: i64 = 86_400; + +/// Days from the Unix epoch back to `0000-01-01`, the earliest date a +/// [`Timestamp`] can spell with four digits of year. +const FIRST_DAY: i64 = -719_528; + +/// Days from the Unix epoch forward to `9999-12-31`, the latest one. +const LAST_DAY: i64 = 2_932_896; + +/// Where the current instant comes from. +/// +/// `Send + Sync` because the app hands one to start-up and start-up is not the +/// only thread that will eventually want the time. +pub trait Clock: Send + Sync +{ + /// Now, in the one form section 7.2 fixes. + fn now(&self) -> Timestamp; +} + +/// The operating system's clock. +#[derive(Debug, Clone, Copy, Default)] +pub struct SystemClock; + +impl Clock for SystemClock +{ + fn now(&self) -> Timestamp + { + timestamp_at(SystemTime::now()) + } +} + +/// The instant a [`SystemTime`] names, written the way section 7.2 fixes. +/// +/// A machine whose clock is set outside the years 0 to 9999 gets the nearest +/// instant inside them rather than a failure. That is not a guess about what the +/// user meant: it is the only value of this type that exists, and refusing to +/// start over a clock nobody can read is worse than recording a bound. +pub fn timestamp_at(instant: SystemTime) -> Timestamp +{ + let seconds = match instant.duration_since(UNIX_EPOCH) + { + Ok(since) => since.as_secs() as i64, + Err(before) => -(before.duration().as_secs() as i64) + }; + + let days = seconds + .div_euclid(SECONDS_PER_DAY) + .clamp(FIRST_DAY, LAST_DAY); + let within_day = seconds.rem_euclid(SECONDS_PER_DAY); + let (year, month, day) = civil_from_days(days); + + let text = format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + within_day / 3600, + (within_day / 60) % 60, + within_day % 60 + ); + + // Unreachable by construction: the day is clamped into a range whose ends + // are asserted below, and the time of day is a remainder of 86 400. The + // parse is still here rather than a raw constructor because `Timestamp` has + // no other way in, which is the property that keeps section 7.2 true. + Timestamp::parse(text).expect("the clock renders a canonical timestamp") +} + +/// The civil date a day number names, counting from the Unix epoch. +/// +/// Howard Hinnant's `civil_from_days`, which is exact for the whole range of +/// `i64` days and needs no table. +fn civil_from_days(days: i64) -> (i64, i64, i64) +{ + // Shift the epoch to 0000-03-01, which puts the leap day at the end of the + // year and makes the month arithmetic a single division. + let shifted = days + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted.rem_euclid(146_097); + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let shifted_month = (5 * day_of_year + 2) / 153; + + let day = day_of_year - (153 * shifted_month + 2) / 5 + 1; + let month = match shifted_month < 10 + { + true => shifted_month + 3, + false => shifted_month - 9 + }; + let year = year_of_era + era * 400 + i64::from(month <= 2); + + (year, month, day) +} + +#[cfg(test)] +mod tests +{ + use super::*; + use std::time::Duration; + + fn at(unix_seconds: i64) -> Timestamp + { + match unix_seconds >= 0 + { + true => timestamp_at(UNIX_EPOCH + Duration::from_secs(unix_seconds as u64)), + false => timestamp_at(UNIX_EPOCH - Duration::from_secs(-unix_seconds as u64)) + } + } + + #[test] + fn the_epoch_is_written_the_way_section_seven_fixes() + { + assert_eq!(at(0).as_str(), "1970-01-01T00:00:00Z"); + } + + #[test] + fn a_known_instant_matches_the_one_the_design_uses_as_its_example() + { + // `date -u -j -f '%Y-%m-%dT%H:%M:%SZ' 2026-08-10T10:00:00Z +%s` + assert_eq!(at(1_786_356_000).as_str(), "2026-08-10T10:00:00Z"); + } + + #[test] + fn a_leap_day_is_a_day() + { + // 2024-02-29T12:34:56Z. + assert_eq!(at(1_709_210_096).as_str(), "2024-02-29T12:34:56Z"); + } + + #[test] + fn an_instant_before_the_epoch_counts_backwards() + { + assert_eq!(at(-1).as_str(), "1969-12-31T23:59:59Z"); + assert_eq!(at(-SECONDS_PER_DAY).as_str(), "1969-12-31T00:00:00Z"); + } + + /// The two constants the clamp rests on, checked rather than trusted. If + /// either is wrong the clamp lets through a year `Timestamp` cannot spell, + /// and [`timestamp_at`] panics on a machine nobody was looking at. + #[test] + fn the_clamp_ends_are_the_dates_they_claim_to_be() + { + assert_eq!(civil_from_days(FIRST_DAY), (0, 1, 1)); + assert_eq!(civil_from_days(LAST_DAY), (9999, 12, 31)); + } + + #[test] + fn a_clock_set_outside_the_years_a_timestamp_can_spell_is_bounded() + { + assert_eq!( + at(FIRST_DAY * SECONDS_PER_DAY - 1).as_str(), + "0000-01-01T23:59:59Z" + ); + assert_eq!( + at(LAST_DAY * SECONDS_PER_DAY + SECONDS_PER_DAY * 400).as_str(), + "9999-12-31T00:00:00Z" + ); + } + + /// Every day from 1969 to 2100 rendered and parsed back, so the arithmetic + /// is covered across century and leap boundaries rather than at four points. + #[test] + fn every_day_across_a_century_and_a_half_renders_something_canonical() + { + let mut previous = String::new(); + + for day in -366..47_500 + { + let rendered = at(day * SECONDS_PER_DAY + 43_200).as_str().to_owned(); + + assert!( + rendered > previous, + "{rendered} did not come after {previous}" + ); + assert!(rendered.ends_with("T12:00:00Z"), "{rendered}"); + previous = rendered; + } + } + + #[test] + fn the_system_clock_answers_with_a_time_in_this_century() + { + let now = SystemClock.now(); + + assert!(now.as_str() > "2020-01-01T00:00:00Z", "{now:?}"); + assert!(now.as_str() < "2100-01-01T00:00:00Z", "{now:?}"); + } +} diff --git a/crates/trdr-runtime/src/collectors.rs b/crates/trdr-runtime/src/collectors.rs new file mode 100644 index 0000000..be274a8 --- /dev/null +++ b/crates/trdr-runtime/src/collectors.rs @@ -0,0 +1,12 @@ +//! The three collectors trdr ships with. +//! +//! What lands here (section 8.3): KIS, OpenDART, and ECOS behind one interface — +//! metadata, test, plan, collect — each producing bundles that go through the +//! same canonical ingest path a user-written collector's bundle does. +//! +//! A collector never commits a row itself, its cursor advances only after the +//! transaction that used it succeeded, and the KIS account snapshot goes through +//! the sensitive-data normaliser rather than the public object store. There is +//! no KRX collector, and section 1 keeps it that way. +//! +//! Nothing is implemented yet. diff --git a/crates/trdr-runtime/src/db.rs b/crates/trdr-runtime/src/db.rs new file mode 100644 index 0000000..b869f47 --- /dev/null +++ b/crates/trdr-runtime/src/db.rs @@ -0,0 +1,831 @@ +//! The bundled SQLite database, opened by whoever holds the writer lease. +//! +//! Section 6 of `docs/FOUNDATION_DESIGN.md` puts two rules above everything else +//! here. The first is that the app and the CLI are never both the writer, and the +//! second is that migrations run only under the writer lease. Both are enforced +//! by the shape of this module rather than by remembering to check: the only way +//! to get a [`Database`] is to hand [`Database::open`] a [`WriterLease`], and the +//! only way to get one of those is to take it (see [`crate::root`]). +//! +//! SQLite itself is compiled into the binary rather than borrowed from macOS, so +//! that the version and its compile-time options are ours and do not change under +//! a system update. Section 6 requires 3.51.3 or newer; [`sqlite_version`] +//! reports what actually got linked, [`Database::open`] refuses to run on +//! anything older, and a test asserts it so a dependency bump cannot quietly walk +//! the version backwards. +//! +//! # Opening, in the order section 6.1 fixes +//! +//! ```text +//! writer lease held (a value of type WriterLease says so) +//! → WAL, foreign keys, busy timeout +//! → application id and schema version checked +//! → PRAGMA quick_check +//! → pending migrations run in one transaction +//! → checksum, app version and time recorded +//! → foreign_key_check + quick_check +//! → commit +//! ``` + +use crate::root::{ProductRoot, RootError, WriterLease, PRIVATE_FILE_MODE}; +use rusqlite::{Connection, OpenFlags}; +use sha2::{Digest, Sha256}; +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// The oldest SQLite section 6 allows. +pub const REQUIRED_SQLITE_VERSION: (u32, u32, u32) = (3, 51, 3); + +/// Marks the file as a trdr database, so that a file that is merely valid SQLite +/// is not opened as one. +/// +/// The four bytes of `TRDR`, which is what `sqlite3 file.db` and `file(1)` will +/// show for the application id. +pub const APPLICATION_ID: i32 = 0x5452_4452; + +/// The schema version this build writes once every migration it knows has run. +/// +/// One past the highest migration id, so a database from a newer build is +/// recognisable by a number this build has never written. +pub const LATEST_SCHEMA_VERSION: i32 = 1; + +/// How long a writer waits on a locked database before giving up. +pub const BUSY_TIMEOUT_MS: u64 = 5_000; + +/// The SQLite that was compiled into this binary. +pub fn sqlite_version() -> &'static str +{ + rusqlite::version() +} + +/// Whether the linked SQLite is new enough for section 6. +pub fn sqlite_version_is_supported() -> bool +{ + let mut parts = sqlite_version().split('.').map(str::parse::); + let mut next = || parts.next().and_then(Result::ok).unwrap_or(0); + + (next(), next(), next()) >= REQUIRED_SQLITE_VERSION +} + +/// One step of the schema, as this build has it. +struct Migration +{ + /// Its place in the order. Ids are dense and never reused. + id: i64, + /// The statements it runs. + sql: &'static str +} + +/// Migration 0: the bookkeeping every later migration is recorded in. +/// +/// `STRICT` makes SQLite enforce the declared types rather than accept anything +/// in any column, and `applied_at_ms` is Unix milliseconds rather than the +/// domain's RFC 3339 text on purpose — this is the runtime's own ledger, it is +/// never hashed, and an integer needs no calendar arithmetic to write. +const MIGRATION_0: &str = "\ +CREATE TABLE schema_migrations ( + id INTEGER PRIMARY KEY, + checksum TEXT NOT NULL, + app_version TEXT NOT NULL, + applied_at_ms INTEGER NOT NULL +) STRICT; +"; + +/// Every migration this build has, in order. +const MIGRATIONS: &[Migration] = &[Migration { + id: 0, + sql: MIGRATION_0 +}]; + +/// A migration that has already run, as the database records it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppliedMigration +{ + /// Which migration. + pub id: i64, + /// The SHA-256 of the SQL that ran, in lower-case hexadecimal. + pub checksum: String, + /// The build that ran it. + pub app_version: String, + /// When it ran, in Unix milliseconds. + pub applied_at_ms: i64 +} + +/// The canonical SQLite database of one workspace. +/// +/// Holds the writer lease for as long as it lives, which is what stops the lease +/// from being released while a connection is still open. +pub struct Database +{ + connection: Connection, + path: PathBuf, + lease: Arc +} + +impl Database +{ + /// Opens the database of the lease holder's default workspace, running any + /// migration it needs. + /// + /// The lease is the argument, and there is no other constructor, so "open the + /// database without being the writer" is not something a caller can express. + /// + /// ``` + /// # use std::sync::Arc; + /// # use trdr_runtime::db::Database; + /// # use trdr_runtime::test_support::scratch_root; + /// let root = scratch_root("doc-open"); + /// let lease = Arc::new(root.acquire_writer_lease().unwrap()); + /// let database = Database::open(lease).unwrap(); + /// + /// assert_eq!(database.schema_version().unwrap(), 1); + /// ``` + /// + /// Without a lease there is nothing to call: + /// + /// ```compile_fail + /// # use trdr_runtime::db::Database; + /// # use trdr_runtime::test_support::scratch_root; + /// let root = scratch_root("doc-no-lease"); + /// let database = Database::open(root.default_database_path()).unwrap(); + /// ``` + pub fn open(lease: Arc) -> Result + { + if !sqlite_version_is_supported() + { + return Err(DbError::SqliteTooOld { + found: sqlite_version(), + required: REQUIRED_SQLITE_VERSION + }); + } + + let root = lease.root().clone(); + root.prepare()?; + + let path = root.default_database_path(); + let connection = Connection::open_with_flags( + &path, + OpenFlags::SQLITE_OPEN_READ_WRITE + | OpenFlags::SQLITE_OPEN_CREATE + | OpenFlags::SQLITE_OPEN_NO_MUTEX + )?; + + apply_pragmas(&connection)?; + check_identity(&connection)?; + quick_check(&connection)?; + migrate(&connection)?; + + // After the pragmas, not before: turning on write-ahead logging is what + // creates the `-wal` and `-shm` files, so tightening them earlier would + // find nothing to tighten. + make_state_files_private(&path)?; + + Ok(Self { + connection, + path, + lease + }) + } + + /// Where the database file is. + pub fn path(&self) -> &Path + { + &self.path + } + + /// The root this database belongs to. + pub fn root(&self) -> &ProductRoot + { + self.lease.root() + } + + /// The schema version recorded in the file. + pub fn schema_version(&self) -> Result + { + Ok(self + .connection + .query_row("PRAGMA user_version", [], |row| row.get(0))?) + } + + /// Every migration that has run, oldest first. + pub fn applied_migrations(&self) -> Result, DbError> + { + let mut statement = self.connection.prepare( + "SELECT id, checksum, app_version, applied_at_ms FROM schema_migrations ORDER BY id" + )?; + let rows = statement.query_map([], |row| { + Ok(AppliedMigration { + id: row.get(0)?, + checksum: row.get(1)?, + app_version: row.get(2)?, + applied_at_ms: row.get(3)? + }) + })?; + + Ok(rows.collect::, _>>()?) + } + + /// What one pragma says, as text, for the tests that check section 6 was + /// honoured. + /// + /// A pragma answers with whatever type it likes, so the value is read + /// untyped and rendered rather than asked for as a `String`. + #[cfg(test)] + fn pragma(&self, name: &str) -> Result + { + let value: rusqlite::types::Value = + self.connection + .query_row(&format!("PRAGMA {name}"), [], |row| row.get(0))?; + + Ok(match value + { + rusqlite::types::Value::Integer(number) => number.to_string(), + rusqlite::types::Value::Real(number) => number.to_string(), + rusqlite::types::Value::Text(text) => text, + rusqlite::types::Value::Null | rusqlite::types::Value::Blob(_) => String::new() + }) + } +} + +/// Sets the pragmas section 6 names. +/// +/// `journal_mode` is a property of the file and survives; `foreign_keys` and +/// `busy_timeout` are properties of a connection and have to be set on every +/// open, which is why they are here and not in a migration. +fn apply_pragmas(connection: &Connection) -> Result<(), DbError> +{ + let mode: String = connection.query_row("PRAGMA journal_mode = WAL", [], |row| row.get(0))?; + + if !mode.eq_ignore_ascii_case("wal") + { + return Err(DbError::WalRefused { found: mode }); + } + + connection.pragma_update(None, "foreign_keys", "ON")?; + connection.busy_timeout(std::time::Duration::from_millis(BUSY_TIMEOUT_MS))?; + + let foreign_keys: i32 = connection.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?; + + match foreign_keys + { + 1 => Ok(()), + _ => Err(DbError::ForeignKeysRefused) + } +} + +/// Refuses a file that is not this build's database (section 6.1). +fn check_identity(connection: &Connection) -> Result<(), DbError> +{ + let application_id: i32 = + connection.query_row("PRAGMA application_id", [], |row| row.get(0))?; + + match application_id + { + // A database this build has never written to. Claiming it is safe only + // while it is still empty, which `user_version` of 0 and an empty schema + // together mean. + 0 if is_empty(connection)? => + { + connection.pragma_update(None, "application_id", APPLICATION_ID)?; + } + id if id == APPLICATION_ID => + {} + found => return Err(DbError::NotATrdrDatabase { found }) + } + + let schema_version: i32 = connection.query_row("PRAGMA user_version", [], |row| row.get(0))?; + + match schema_version > LATEST_SCHEMA_VERSION + { + // Section 6.1: a database from a newer build is not opened for writing + // and is never downgraded. + true => Err(DbError::SchemaFromNewerBuild { + found: schema_version, + supported: LATEST_SCHEMA_VERSION + }), + false => Ok(()) + } +} + +/// Whether the file holds no schema of its own yet. +fn is_empty(connection: &Connection) -> Result +{ + let objects: i64 = + connection.query_row("SELECT count(*) FROM sqlite_schema", [], |row| row.get(0))?; + + Ok(objects == 0) +} + +/// The startup integrity check section 6 requires. +fn quick_check(connection: &Connection) -> Result<(), DbError> +{ + let outcome: String = connection.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?; + + match outcome.eq_ignore_ascii_case("ok") + { + true => Ok(()), + false => Err(DbError::IntegrityCheckFailed) + } +} + +/// Runs whatever this build has that the file does not, in one transaction. +/// +/// Re-running is a no-op: a migration whose id is already recorded is skipped, so +/// opening an up-to-date database writes nothing. +fn migrate(connection: &Connection) -> Result<(), DbError> +{ + let applied = read_applied(connection)?; + + for (id, checksum) in &applied + { + let Some(migration) = MIGRATIONS.iter().find(|migration| migration.id == *id) + else + { + return Err(DbError::UnknownAppliedMigration { id: *id }); + }; + + // Section 6.1: a migration whose text moved after it ran is drift, and + // drift is refused rather than reconciled. + if checksum != &checksum_of(migration.sql) + { + return Err(DbError::MigrationDrift { id: *id }); + } + } + + let pending: Vec<&Migration> = MIGRATIONS + .iter() + .filter(|migration| !applied.iter().any(|(id, _)| *id == migration.id)) + .collect(); + + if pending.is_empty() + { + return Ok(()); + } + + // Section 6.1 puts a verified recovery point between an existing database and + // a migration, and the backup track has not built one yet. Refusing is the + // honest state: a fresh database has nothing to recover, so migration 0 runs, + // and the first migration against a database holding real data will not run + // until the recovery point exists. + if !applied.is_empty() + { + return Err(DbError::RecoveryPointRequired { + pending: pending.iter().map(|migration| migration.id).collect() + }); + } + + let transaction = connection.unchecked_transaction()?; + + for migration in pending + { + transaction.execute_batch(migration.sql)?; + transaction.execute( + "INSERT INTO schema_migrations (id, checksum, app_version, applied_at_ms) \ + VALUES (?1, ?2, ?3, ?4)", + rusqlite::params![ + migration.id, + checksum_of(migration.sql), + env!("CARGO_PKG_VERSION"), + now_ms() + ] + )?; + } + + // `PRAGMA user_version` takes no bound parameter. The value is a constant of + // this build, so there is nothing here a caller could shape. + transaction.execute_batch(&format!("PRAGMA user_version = {LATEST_SCHEMA_VERSION}"))?; + + let violations: i64 = + transaction.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + + if violations != 0 + { + return Err(DbError::ForeignKeyViolations { found: violations }); + } + + let outcome: String = transaction.query_row("PRAGMA quick_check(1)", [], |row| row.get(0))?; + + if !outcome.eq_ignore_ascii_case("ok") + { + return Err(DbError::IntegrityCheckFailed); + } + + Ok(transaction.commit()?) +} + +/// The migrations the file records, or none at all when the ledger is not there +/// yet. +fn read_applied(connection: &Connection) -> Result, DbError> +{ + let ledger_exists: i64 = connection.query_row( + "SELECT count(*) FROM sqlite_schema WHERE type = 'table' AND name = 'schema_migrations'", + [], + |row| row.get(0) + )?; + + if ledger_exists == 0 + { + return Ok(Vec::new()); + } + + let mut statement = + connection.prepare("SELECT id, checksum FROM schema_migrations ORDER BY id")?; + let rows = statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?; + + Ok(rows.collect::, _>>()?) +} + +/// The SHA-256 of a migration's text, in lower-case hexadecimal. +fn checksum_of(sql: &str) -> String +{ + let digest = Sha256::digest(sql.as_bytes()); + let mut text = String::with_capacity(digest.len() * 2); + + for byte in digest + { + use std::fmt::Write as _; + let _ = write!(text, "{byte:02x}"); + } + + text +} + +/// Now, in Unix milliseconds. +fn now_ms() -> i64 +{ + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|since| since.as_millis() as i64) + .unwrap_or_default() +} + +/// Makes the database and the files SQLite keeps beside it private to their +/// owner (section 5.1). +/// +/// The `-wal` and `-shm` files are created by SQLite when WAL is turned on, so +/// they are tightened again after the pragmas run rather than only here. +fn make_state_files_private(database: &Path) -> Result<(), DbError> +{ + for path in state_file_paths(database) + { + if !path.exists() + { + continue; + } + + fs::set_permissions(&path, fs::Permissions::from_mode(PRIVATE_FILE_MODE)).map_err( + |source| DbError::Permissions { + path: path.clone(), + source + } + )?; + } + + Ok(()) +} + +/// The database and the two files SQLite manages next to it. +fn state_file_paths(database: &Path) -> Vec +{ + let mut paths = vec![database.to_path_buf()]; + + for suffix in ["-wal", "-shm"] + { + let mut name = database.as_os_str().to_os_string(); + name.push(suffix); + paths.push(PathBuf::from(name)); + } + + paths +} + +/// The database could not be opened, migrated, or trusted. +#[derive(Debug, thiserror::Error)] +pub enum DbError +{ + /// The SQLite compiled into this binary is older than section 6 allows. + #[error("bundled SQLite is {found}, section 6 requires {}.{}.{} or newer", required.0, required.1, required.2)] + SqliteTooOld + { + /// What was linked. + found: &'static str, + /// What is required. + required: (u32, u32, u32) + }, + /// The file is valid SQLite but is not a trdr database. + #[error("application id {found:#x} is not trdr's {APPLICATION_ID:#x}")] + NotATrdrDatabase + { + /// The id the file carried. + found: i32 + }, + /// The file was written by a later build. + #[error("schema version {found} is newer than the {supported} this build knows")] + SchemaFromNewerBuild + { + /// The version in the file. + found: i32, + /// The newest this build writes. + supported: i32 + }, + /// The file records a migration this build does not have. + #[error("migration {id} was applied by a build this one does not know")] + UnknownAppliedMigration + { + /// Which one. + id: i64 + }, + /// A migration's text changed after it ran. + #[error("migration {id} does not match the one in this build")] + MigrationDrift + { + /// Which one. + id: i64 + }, + /// There is a migration to run and no verified recovery point to run it + /// after (section 6.1). + #[error("a recovery point is required before migrations {pending:?} can run")] + RecoveryPointRequired + { + /// The migrations that are waiting. + pending: Vec + }, + /// SQLite would not switch the file to write-ahead logging. + #[error("journal mode is {found}, not wal")] + WalRefused + { + /// The mode SQLite reported. + found: String + }, + /// SQLite would not enforce foreign keys. + #[error("foreign key enforcement could not be turned on")] + ForeignKeysRefused, + /// `quick_check` reported damage. + #[error("the database did not pass its integrity check")] + IntegrityCheckFailed, + /// A migration left rows that break a foreign key. + #[error("{found} foreign key violations after migrating")] + ForeignKeyViolations + { + /// How many. + found: i64 + }, + /// A state file could not be made private. + #[error("{path} could not be made private: {source}")] + Permissions + { + /// The file. + path: PathBuf, + /// What the operating system said. + #[source] + source: std::io::Error + }, + /// The directories the database lives in could not be prepared. + #[error(transparent)] + Root(#[from] RootError), + /// SQLite refused something. + #[error(transparent)] + Sqlite(#[from] rusqlite::Error) +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::root::mode_of; + use crate::test_support::scratch_root; + + fn open(root: &ProductRoot) -> Database + { + let lease = Arc::new( + root.acquire_writer_lease() + .expect("the lease should be free") + ); + Database::open(lease).expect("the database should open") + } + + // The version check the brief asks for, as a test rather than a comment: a + // dependency bump that walks SQLite backwards fails here. + #[test] + fn the_bundled_sqlite_is_new_enough_for_section_six() + { + assert!( + sqlite_version_is_supported(), + "bundled SQLite is {}, section 6 requires {:?} or newer", + sqlite_version(), + REQUIRED_SQLITE_VERSION + ); + + // Printed so the version is in the test output and does not have to be + // taken on trust. + eprintln!("bundled SQLite version: {}", sqlite_version()); + } + + #[test] + fn the_version_comparison_reads_each_part_as_a_number() + { + // The comparison must not be lexicographic: "3.9.0" is older than + // "3.51.3" even though it sorts after it. + let older = ("3.9.0", (3u32, 51, 3)); + let parsed: Vec = older + .0 + .split('.') + .map(|part| part.parse().unwrap()) + .collect(); + + assert!((parsed[0], parsed[1], parsed[2]) < older.1); + } + + #[test] + fn a_new_database_comes_up_migrated_and_identified() + { + let root = scratch_root("db-fresh"); + let database = open(&root); + + assert_eq!(database.schema_version().unwrap(), LATEST_SCHEMA_VERSION); + assert_eq!(database.path(), root.default_database_path()); + + let applied = database.applied_migrations().unwrap(); + assert_eq!(applied.len(), 1); + assert_eq!(applied[0].id, 0); + assert_eq!(applied[0].checksum, checksum_of(MIGRATION_0)); + assert_eq!(applied[0].app_version, env!("CARGO_PKG_VERSION")); + assert!(applied[0].applied_at_ms > 0); + } + + #[test] + fn section_six_s_pragmas_are_on() + { + let root = scratch_root("db-pragmas"); + let database = open(&root); + + assert!(database + .pragma("journal_mode") + .unwrap() + .eq_ignore_ascii_case("wal")); + assert_eq!(database.pragma("foreign_keys").unwrap(), "1"); + assert_eq!( + database.pragma("busy_timeout").unwrap(), + BUSY_TIMEOUT_MS.to_string() + ); + assert_eq!( + database.pragma("application_id").unwrap(), + APPLICATION_ID.to_string() + ); + } + + #[test] + fn opening_an_already_migrated_database_writes_nothing() + { + let root = scratch_root("db-rerun"); + let first = open(&root); + let applied = first.applied_migrations().unwrap(); + drop(first); + + let second = open(&root); + + assert_eq!(second.applied_migrations().unwrap(), applied); + assert_eq!(second.schema_version().unwrap(), LATEST_SCHEMA_VERSION); + } + + #[test] + fn a_file_that_is_not_a_trdr_database_is_refused() + { + let root = scratch_root("db-foreign"); + root.prepare().unwrap(); + + let foreign = Connection::open(root.default_database_path()).unwrap(); + foreign + .pragma_update(None, "application_id", 0x1234_5678) + .unwrap(); + foreign + .execute_batch("CREATE TABLE other (a INTEGER)") + .unwrap(); + drop(foreign); + + let lease = Arc::new(root.acquire_writer_lease().unwrap()); + + assert!(matches!( + Database::open(lease), + Err(DbError::NotATrdrDatabase { found: 0x1234_5678 }) + )); + } + + #[test] + fn a_database_from_a_newer_build_is_not_opened_for_writing() + { + let root = scratch_root("db-newer"); + drop(open(&root)); + + let ahead = Connection::open(root.default_database_path()).unwrap(); + ahead + .execute_batch(&format!( + "PRAGMA user_version = {}", + LATEST_SCHEMA_VERSION + 7 + )) + .unwrap(); + drop(ahead); + + let lease = Arc::new(root.acquire_writer_lease().unwrap()); + + assert!(matches!( + Database::open(lease), + Err(DbError::SchemaFromNewerBuild { found, .. }) if found == LATEST_SCHEMA_VERSION + 7 + )); + } + + #[test] + fn a_migration_whose_text_moved_is_refused_as_drift() + { + let root = scratch_root("db-drift"); + drop(open(&root)); + + let tampered = Connection::open(root.default_database_path()).unwrap(); + tampered + .execute( + "UPDATE schema_migrations SET checksum = 'moved' WHERE id = 0", + [] + ) + .unwrap(); + drop(tampered); + + let lease = Arc::new(root.acquire_writer_lease().unwrap()); + + assert!(matches!( + Database::open(lease), + Err(DbError::MigrationDrift { id: 0 }) + )); + } + + #[test] + fn a_migration_from_a_build_this_one_does_not_have_is_refused() + { + let root = scratch_root("db-unknown-migration"); + drop(open(&root)); + + let ahead = Connection::open(root.default_database_path()).unwrap(); + ahead + .execute( + "INSERT INTO schema_migrations (id, checksum, app_version, applied_at_ms) \ + VALUES (9, 'x', '9.9.9', 1)", + [] + ) + .unwrap(); + drop(ahead); + + let lease = Arc::new(root.acquire_writer_lease().unwrap()); + + assert!(matches!( + Database::open(lease), + Err(DbError::UnknownAppliedMigration { id: 9 }) + )); + } + + #[test] + fn the_database_and_the_files_beside_it_are_private_to_their_owner() + { + let root = scratch_root("db-modes"); + let database = open(&root); + + for path in state_file_paths(database.path()) + { + if path.exists() + { + assert_eq!( + mode_of(&path).unwrap(), + PRIVATE_FILE_MODE, + "{} is not private", + path.display() + ); + } + } + } + + #[test] + fn a_checksum_is_the_sha_256_of_the_migration_text() + { + // The empty string's SHA-256, so the encoding itself is pinned and not + // just checked against itself. + assert_eq!( + checksum_of(""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_ne!(checksum_of(MIGRATION_0), checksum_of("")); + } + + #[test] + fn migration_ids_are_dense_and_in_order() + { + for (position, migration) in MIGRATIONS.iter().enumerate() + { + assert_eq!(migration.id, position as i64); + } + + assert_eq!(LATEST_SCHEMA_VERSION as usize, MIGRATIONS.len()); + } +} diff --git a/crates/trdr-runtime/src/ids.rs b/crates/trdr-runtime/src/ids.rs new file mode 100644 index 0000000..36bf3e3 --- /dev/null +++ b/crates/trdr-runtime/src/ids.rs @@ -0,0 +1,68 @@ +//! Minting identifiers, behind the seam section 13 calls `IdGenerator`. +//! +//! Every identity in trdr is a ULID, and `trdr-core` deliberately cannot make +//! one: it takes the `ulid` crate with default features off, which drops the +//! clock and the random source and leaves only the codec. So the domain can read +//! and compare an id, and this crate is where one comes from. +//! +//! The trait exists for the same reason the clock's does. A workspace id ends up +//! in a file a test wants to assert on, and an assertion against a value that is +//! different every run is not an assertion; [`crate::test_support::CountingIds`] +//! is the substitute that makes it one. + +use trdr_core::id::Ulid; + +/// Where a fresh identifier comes from. +pub trait IdGenerator: Send + Sync +{ + /// A ULID nothing else has been given. + fn generate(&self) -> Ulid; +} + +/// The real one: 48 bits of the system clock and 80 bits of randomness. +#[derive(Debug, Clone, Copy, Default)] +pub struct UlidGenerator; + +impl IdGenerator for UlidGenerator +{ + fn generate(&self) -> Ulid + { + Ulid::generate() + } +} + +#[cfg(test)] +mod tests +{ + use super::*; + use std::collections::BTreeSet; + use trdr_core::id::WorkspaceId; + + #[test] + fn a_generated_id_is_one_the_domain_will_accept_back() + { + let id = WorkspaceId::from_ulid(UlidGenerator.generate()); + + assert_eq!( + id.to_string().parse::().expect("re-readable"), + id + ); + } + + #[test] + fn two_generated_ids_are_different_and_sort_by_when_they_were_made() + { + let ids: Vec = (0..1_000).map(|_| UlidGenerator.generate()).collect(); + let distinct: BTreeSet = ids.iter().map(|id| id.0).collect(); + + assert_eq!(distinct.len(), ids.len(), "a generated id repeated"); + + // The timestamp is the high 48 bits, so ids made in one loop are + // non-decreasing when compared as text, which is the property a manifest + // and a recovery point directory both rely on. + let first = WorkspaceId::from_ulid(ids[0]).to_string(); + let last = WorkspaceId::from_ulid(ids[ids.len() - 1]).to_string(); + + assert!(first[..10] <= last[..10], "{first} then {last}"); + } +} diff --git a/crates/trdr-runtime/src/keychain.rs b/crates/trdr-runtime/src/keychain.rs new file mode 100644 index 0000000..15612b7 --- /dev/null +++ b/crates/trdr-runtime/src/keychain.rs @@ -0,0 +1,12 @@ +//! Credentials, by handle only. +//! +//! What lands here (sections 5.1, 8.3, and 10): Keychain items under service +//! `com.fxylabs.trdr`, keyed by `::`, and +//! the handle a collector uses to make a call without ever seeing the secret. +//! +//! The local instance id is per machine, which is what stops a restored backup +//! or another Mac from inheriting an existing machine's stored secrets by name. +//! A secret value never leaves this module: the WebView and the CLI learn only +//! `missing`, `untested`, `valid`, `invalid`, or `rate-limited`. +//! +//! Nothing is implemented yet. diff --git a/crates/trdr-runtime/src/lib.rs b/crates/trdr-runtime/src/lib.rs new file mode 100644 index 0000000..f6e4b5e --- /dev/null +++ b/crates/trdr-runtime/src/lib.rs @@ -0,0 +1,72 @@ +//! Everything in trdr that touches the outside world. +//! +//! `docs/FOUNDATION_DESIGN.md` section 4 puts one direction on the dependencies: +//! the app and the CLI both go through this crate, this crate goes to +//! [`trdr_core`], and nothing goes back. The core decides what is true; the +//! runtime is where SQLite, the Keychain, the network, the terminal, and the +//! socket actually happen. +//! +//! The modules are named one per track, so that two tracks are never editing the +//! same file and the shape of the crate does not have to be argued about again: +//! +//! | module | what lands there | design section | +//! |---|---|---| +//! | [`root`] | the product root, its layout, and the writer lease | 5.1, 6 | +//! | [`workspace`] | the workspace directory and its manifest | 5.1 | +//! | [`db`] | bundled SQLite and its migrations | 6 | +//! | [`keychain`] | credential handles, never secret values | 5.1, 8.3 | +//! | [`pty`] | the agent child process and its byte stream | 3 | +//! | [`socket`] | the Unix socket server the CLI talks to | 9.2 | +//! | [`collectors`] | the KIS, OpenDART, and ECOS collectors | 8.3 | +//! +//! Two of section 13's seams are small enough to be a module each rather than a +//! track: [`clock`] is where the current instant comes from, and [`ids`] is +//! where a ULID is minted. Both are traits with one real implementation and one +//! stand-in in [`test_support`], and both exist because `trdr-core` is built so +//! that it cannot reach a clock or a random source. +//! +//! Two rules hold across all of them, from section 3.1: this crate never writes +//! user-facing wording, and it never interprets what the agent printed. +//! +//! # Starting a runtime +//! +//! The order is not a convention that has to be remembered. Taking the writer +//! lease produces a value, and both the database and the socket ask for it, so +//! there is no way to express them in the wrong order: +//! +//! ```no_run +//! use std::sync::Arc; +//! use trdr_runtime::clock::SystemClock; +//! use trdr_runtime::db::Database; +//! use trdr_runtime::ids::UlidGenerator; +//! use trdr_runtime::root::ProductRoot; +//! use trdr_runtime::socket::{AppBridge, SocketServer}; +//! use trdr_runtime::workspace::Workspace; +//! # fn start(bridge: Arc) -> Result<(), Box> { +//! let root = ProductRoot::for_current_user()?; +//! let lease = Arc::new(root.acquire_writer_lease()?); +//! +//! let database = Database::open(Arc::clone(&lease))?; +//! let workspace = Workspace::open_default(&lease, &SystemClock, &UlidGenerator)?; +//! let server = SocketServer::bind(Arc::clone(&lease), bridge)?.spawn(); +//! # let _ = (database, workspace, server); +//! # Ok(()) +//! # } +//! ``` + +#![deny(missing_docs)] + +pub mod clock; +pub mod collectors; +pub mod db; +pub mod ids; +pub mod keychain; +pub mod pty; +pub mod root; +pub mod socket; +pub mod test_support; +pub mod workspace; + +// Re-exported so that everything downstream sees one version of the domain +// types rather than depending on trdr-core separately and drifting. +pub use trdr_core; diff --git a/crates/trdr-runtime/src/pty.rs b/crates/trdr-runtime/src/pty.rs new file mode 100644 index 0000000..2b3d150 --- /dev/null +++ b/crates/trdr-runtime/src/pty.rs @@ -0,0 +1,10 @@ +//! The agent's terminal. +//! +//! What lands here (sections 3 and 9.1): spawning one Claude Code or Codex child +//! under a pseudo-terminal, owning it, and moving bytes both ways. +//! +//! The bytes are bytes. Section 1 is explicit that agent output is shown as a +//! raw stream and is never read as product state, and section 11 forbids +//! converting between terminal content and a query model in either direction. +//! +//! Nothing is implemented yet. diff --git a/crates/trdr-runtime/src/root.rs b/crates/trdr-runtime/src/root.rs new file mode 100644 index 0000000..f6a65af --- /dev/null +++ b/crates/trdr-runtime/src/root.rs @@ -0,0 +1,422 @@ +//! Where trdr keeps its state on disk, and the lease that makes one process the +//! writer. +//! +//! Section 5.1 of `docs/FOUNDATION_DESIGN.md` fixes the layout. The product root +//! is `~/.trdr` in production, but every path in this module hangs off a +//! [`ProductRoot`] value that the caller supplies, so a test never has to write +//! into the directory a person is actually using. +//! +//! ```text +//! / +//! run/ +//! app.sock the socket the CLI talks to +//! writer.lock the single writer lease +//! workspaces/ +//! default/ +//! trdr.sqlite3 canonical state +//! ``` +//! +//! The lease is the part worth reading carefully. It is an advisory `flock` held +//! on an open descriptor, and it is deliberately not a pid written into a file. +//! A pid file cannot answer the only question that matters after a crash — +//! is the holder still alive — because the recorded pid is either stale or has +//! been handed to an unrelated process, and reading the file cannot tell those +//! apart. The kernel drops an `flock` when the descriptor closes, and a process +//! that dies closes every descriptor it held, so the answer comes from the +//! kernel rather than from a guess. + +use std::fs::{self, File, OpenOptions}; +use std::io; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::os::unix::io::AsRawFd; +use std::path::{Path, PathBuf}; + +/// The mode section 5.1 fixes for the product root and every directory in it. +pub const PRIVATE_DIR_MODE: u32 = 0o700; + +/// The mode section 5.1 fixes for a state file. +pub const PRIVATE_FILE_MODE: u32 = 0o600; + +/// The workspace a first run creates (section 15). +pub const DEFAULT_WORKSPACE_NAME: &str = "default"; + +/// The canonical database inside a workspace (section 5.1). +pub const DATABASE_FILE_NAME: &str = "trdr.sqlite3"; + +/// The directory trdr keeps its runtime state and workspaces in. +/// +/// Every path this crate opens is derived from one of these, and the only way to +/// make one is to say where it is. Production says `~/.trdr` through +/// [`ProductRoot::for_current_user`]; a test says a scratch directory through +/// [`ProductRoot::at`]. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProductRoot +{ + path: PathBuf +} + +impl ProductRoot +{ + /// A product root at a path the caller chose. + pub fn at(path: impl Into) -> Self + { + Self { path: path.into() } + } + + /// The production root, `~/.trdr`. + /// + /// The one function in this crate that reads a path out of the environment. + /// Nothing else falls back to it, so a caller that forgot to say where its + /// state lives gets a compile error rather than a live workspace. + pub fn for_current_user() -> Result + { + match std::env::var_os("HOME") + { + Some(home) if !home.is_empty() => Ok(Self::at(PathBuf::from(home).join(".trdr"))), + _ => Err(RootError::HomeUnknown) + } + } + + /// Where this root is. + pub fn path(&self) -> &Path + { + &self.path + } + + /// `run/`, which holds the socket and the lease. + pub fn run_dir(&self) -> PathBuf + { + self.path.join("run") + } + + /// `run/app.sock`. + pub fn socket_path(&self) -> PathBuf + { + self.run_dir().join("app.sock") + } + + /// `run/writer.lock`. + pub fn lease_path(&self) -> PathBuf + { + self.run_dir().join("writer.lock") + } + + /// `workspaces/`. + pub fn workspaces_dir(&self) -> PathBuf + { + self.path.join("workspaces") + } + + /// `workspaces/default/`, the workspace a first run creates. + pub fn default_workspace_dir(&self) -> PathBuf + { + self.workspaces_dir().join(DEFAULT_WORKSPACE_NAME) + } + + /// `workspaces/default/trdr.sqlite3`. + pub fn default_database_path(&self) -> PathBuf + { + self.default_workspace_dir().join(DATABASE_FILE_NAME) + } + + /// Creates the directories this root needs, each private to its owner. + /// + /// Safe to call on a root that already exists, and it is worth calling every + /// time: `create_dir_all` succeeds on an existing directory without touching + /// its mode, and the umask decides the mode of a new one, so a directory that + /// was once readable by everyone would stay that way if the mode were only + /// set at creation. + pub fn prepare(&self) -> Result<(), RootError> + { + for directory in [ + self.path.clone(), + self.run_dir(), + self.workspaces_dir(), + self.default_workspace_dir() + ] + { + make_private_dir(&directory)?; + } + + Ok(()) + } + + /// Takes the single writer lease, or says who has it. + /// + /// Holding the returned value is what makes this process the writer. Dropping + /// it, or dying, releases the lease. + pub fn acquire_writer_lease(&self) -> Result + { + self.prepare()?; + + let path = self.lease_path(); + let file = OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .mode(PRIVATE_FILE_MODE) + .open(&path) + .map_err(|source| LeaseError::Open { + path: path.clone(), + source + })?; + + // `mode` above only applies to a file this call created, so a lock file + // left behind by an older build with a looser mode is tightened here. + fs::set_permissions(&path, fs::Permissions::from_mode(PRIVATE_FILE_MODE)).map_err( + |source| LeaseError::Open { + path: path.clone(), + source + } + )?; + + // SAFETY: `file` owns a valid descriptor for the whole call, and `flock` + // reads nothing through it. The lock rides on this open file description, + // which is why `WriterLease` keeps the `File` rather than the path. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } != 0 + { + let error = io::Error::last_os_error(); + + return match error.kind() + { + io::ErrorKind::WouldBlock => Err(LeaseError::Held { path }), + _ => Err(LeaseError::Open { + path, + source: error + }) + }; + } + + Ok(WriterLease { + file, + root: self.clone() + }) + } +} + +/// Creates one directory, private to its owner, whether or not it existed. +fn make_private_dir(path: &Path) -> Result<(), RootError> +{ + fs::create_dir_all(path).map_err(|source| RootError::Prepare { + path: path.to_path_buf(), + source + })?; + + fs::set_permissions(path, fs::Permissions::from_mode(PRIVATE_DIR_MODE)).map_err(|source| { + RootError::Prepare { + path: path.to_path_buf(), + source + } + }) +} + +/// The permission bits on a path, without the file type. +pub fn mode_of(path: &Path) -> io::Result +{ + Ok(fs::metadata(path)?.permissions().mode() & 0o777) +} + +/// The single writer lease of sections 5.1 and 6, held for as long as this value +/// lives. +/// +/// There is no way to build one except by taking it, which is what lets the rest +/// of the crate ask for a `&WriterLease` and know the caller really is the +/// writer. +#[derive(Debug)] +pub struct WriterLease +{ + file: File, + root: ProductRoot +} + +impl WriterLease +{ + /// The root this lease was taken against. + pub fn root(&self) -> &ProductRoot + { + &self.root + } + + /// The lock file the lease is held on. + pub fn path(&self) -> PathBuf + { + self.root.lease_path() + } +} + +impl Drop for WriterLease +{ + fn drop(&mut self) + { + // Closing `file` would release the lock on its own. Unlocking first is + // here to say so out loud, and to release it before any other descriptor + // this process may hold on the same file is closed. + // + // SAFETY: the descriptor is still open and owned by `self.file`. + unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) }; + } +} + +/// The product root could not be found or prepared. +#[derive(Debug, thiserror::Error)] +pub enum RootError +{ + /// `HOME` was not set, so `~/.trdr` cannot be named. + #[error("HOME is not set, so the product root cannot be located")] + HomeUnknown, + /// A directory could not be created or made private. + #[error("{path} could not be prepared: {source}")] + Prepare + { + /// The directory that failed. + path: PathBuf, + /// What the operating system said. + #[source] + source: io::Error + } +} + +/// The writer lease could not be taken. +#[derive(Debug, thiserror::Error)] +pub enum LeaseError +{ + /// Another live process holds it. + /// + /// Not "the file exists" — the kernel was asked and said the lock is held. + #[error("another trdr process holds the writer lease on {path}")] + Held + { + /// The lock file. + path: PathBuf + }, + /// The lock file could not be opened or locked for another reason. + #[error("the writer lease on {path} could not be taken: {source}")] + Open + { + /// The lock file. + path: PathBuf, + /// What the operating system said. + #[source] + source: io::Error + }, + /// The directories the lease lives in could not be prepared. + #[error(transparent)] + Root(#[from] RootError) +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::test_support::scratch_root; + + #[test] + fn every_runtime_path_hangs_off_the_root_it_was_given() + { + let root = ProductRoot::at("/somewhere/else"); + + assert_eq!(root.path(), Path::new("/somewhere/else")); + assert_eq!( + root.socket_path(), + Path::new("/somewhere/else/run/app.sock") + ); + assert_eq!( + root.lease_path(), + Path::new("/somewhere/else/run/writer.lock") + ); + assert_eq!( + root.default_database_path(), + Path::new("/somewhere/else/workspaces/default/trdr.sqlite3") + ); + } + + #[test] + fn preparing_a_root_leaves_every_directory_private_to_its_owner() + { + let root = scratch_root("root-modes"); + root.prepare().expect("the root should be preparable"); + + for directory in [ + root.path().to_path_buf(), + root.run_dir(), + root.workspaces_dir(), + root.default_workspace_dir() + ] + { + assert_eq!( + mode_of(&directory).expect("the directory should exist"), + PRIVATE_DIR_MODE, + "{} is not private", + directory.display() + ); + } + } + + #[test] + fn a_directory_that_was_left_open_is_tightened_on_the_next_start() + { + let root = scratch_root("root-tighten"); + fs::create_dir_all(root.run_dir()).expect("the run directory could not be made"); + fs::set_permissions(root.run_dir(), fs::Permissions::from_mode(0o755)) + .expect("the mode could not be loosened"); + + root.prepare().expect("the root should be preparable"); + assert_eq!(mode_of(&root.run_dir()).unwrap(), PRIVATE_DIR_MODE); + } + + #[test] + fn a_lease_file_is_readable_only_by_its_owner() + { + let root = scratch_root("lease-mode"); + let lease = root + .acquire_writer_lease() + .expect("the lease should be free"); + + assert_eq!(mode_of(&lease.path()).unwrap(), PRIVATE_FILE_MODE); + } + + // The crash case, in the small. A released lease is takeable again, which is + // the same kernel path a dying process takes when its descriptors close. + #[test] + fn a_released_lease_can_be_taken_again() + { + let root = scratch_root("lease-release"); + let first = root + .acquire_writer_lease() + .expect("the lease should be free"); + drop(first); + + root.acquire_writer_lease() + .expect("a released lease should be takeable"); + } + + #[test] + fn a_lease_taken_against_one_root_says_which_root_that_was() + { + let root = scratch_root("lease-root"); + let lease = root + .acquire_writer_lease() + .expect("the lease should be free"); + + assert_eq!(lease.root(), &root); + } + + // Exclusion between processes is what the lease is for, and `flock` is per + // open file description rather than per process — a second open in this same + // process is allowed to succeed. So the exclusion proof lives in + // `tests/writer_lease.rs`, which spawns a real child, and this test only + // records why it is not here. + #[test] + fn exclusion_is_proven_between_processes_and_not_within_one() + { + let root = scratch_root("lease-same-process"); + let _first = root + .acquire_writer_lease() + .expect("the lease should be free"); + + // Whatever the second attempt in this process does, it says nothing about + // exclusion; the assertion is only that it does not panic or hang. + let _second = root.acquire_writer_lease(); + } +} diff --git a/crates/trdr-runtime/src/socket.rs b/crates/trdr-runtime/src/socket.rs new file mode 100644 index 0000000..5682af6 --- /dev/null +++ b/crates/trdr-runtime/src/socket.rs @@ -0,0 +1,1021 @@ +//! The Unix socket the `trdr` CLI talks to, and the client half that talks to it. +//! +//! Section 9.2 of `docs/FOUNDATION_DESIGN.md` fixes the protocol: one JSON object +//! per line, a version and a request id on every frame, and an unknown method, +//! field, or version refused rather than ignored. All of that lives in +//! [`trdr_core::socket`]; this module is the socket, the framing, and who is +//! allowed to speak. +//! +//! # Who may connect +//! +//! Two checks, not one. The socket file is `0600` inside a `0700` directory, and +//! every accepted connection is asked for its peer's uid through `getpeereid`. +//! The first is a property of a path and stops being true if a descriptor +//! outlives a mode change or the socket ever moves; the second is recorded by the +//! kernel when the peer connects and cannot be restated by the caller. +//! +//! # Which socket file is stale +//! +//! The writer lease decides, and nothing else does. Whoever holds the lease is +//! the only live app, so a socket file it finds at bind time was left behind by +//! one that died, and unlinking it is safe. The tempting alternative — connect to +//! the socket and see whether anything answers — is worse in both directions: an +//! app that is alive but wedged answers nothing and would be declared dead, and a +//! path whose inode was replaced answers something else entirely. [`bind`] takes +//! a [`WriterLease`] for exactly this reason: the unlink is not reachable without +//! one. + +use crate::root::{ProductRoot, WriterLease, PRIVATE_FILE_MODE}; +use std::io::{self, Read, Write}; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::io::AsRawFd; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::thread::{self, JoinHandle}; +use std::time::Duration; +use trdr_core::error::{ErrorCode, ErrorEnvelope, ErrorParam, Retryability}; +use trdr_core::socket::{ + AppStatusResult, FrameError, SocketMethod, SocketOutcome, SocketRequest, SocketResponse, + MAX_FRAME_BYTES +}; +use trdr_core::RequestId; +use ulid::Ulid; + +/// The most a `sockaddr_un` path may be on macOS, without its terminating zero. +/// +/// `sun_path` is 104 bytes. A path over the limit fails at `bind` with an error +/// about the address rather than about the length, which is why it is checked +/// here and reported as itself. +pub const MAX_SOCKET_PATH_BYTES: usize = 103; + +/// How long a connected client may leave the server waiting for its frame. +/// +/// A caller that connects and then says nothing costs one thread until this +/// elapses, and then the connection is dropped. The bound is deliberately short: +/// a request the CLI already decided to send is one write away, and section 9.3's +/// long wait belongs to the approval round trip, which will hold its own +/// connection open on purpose. +pub const REQUEST_READ_TIMEOUT: Duration = Duration::from_secs(5); + +/// How long the server will wait for a client to take its answer. +pub const RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(5); + +/// How long the CLI waits for an answer before giving up on a wedged app. +pub const CLIENT_READ_TIMEOUT: Duration = Duration::from_secs(30); + +/// How often the accept loop looks at the stop flag. +const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(50); + +/// What the socket server needs from the app around it. +/// +/// The test seam section 13 calls `AppBridge`. Everything below it is reachable +/// from `cargo test` without a window, a WebView, or an NSApplication, which is +/// what lets the protocol be proven before the app exists. +pub trait AppBridge: Send + Sync + 'static +{ + /// Answers `app.status` (section 9.2). + fn app_status(&self) -> AppStatusResult; +} + +/// The Unix socket server of section 9.2, bound and not yet accepting. +pub struct SocketServer +{ + listener: UnixListener, + served: Arc, + path: PathBuf, + lease: Arc +} + +/// What each connection thread needs, shared between them. +struct Served +{ + bridge: Arc, + /// The only uid this socket answers. Read once at bind, so a later change to + /// the process's identity cannot widen it. + owner_uid: libc::uid_t +} + +impl SocketServer +{ + /// Binds `/run/app.sock`, clearing a socket file a dead app + /// left behind. + /// + /// The lease is the argument because clearing that file is only safe for its + /// holder, and because the app that answers questions about the database has + /// to be the one holding it. + pub fn bind(lease: Arc, bridge: Arc) -> Result + { + let root = lease.root().clone(); + root.prepare().map_err(|source| ServerError::Prepare { + source: Box::new(source) + })?; + + let path = root.socket_path(); + check_socket_path(&path)?; + + // Only reachable with a lease in hand: see the module documentation. + clear_stale_socket(&lease, &path)?; + + let listener = UnixListener::bind(&path).map_err(|source| ServerError::Bind { + path: path.clone(), + source + })?; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(PRIVATE_FILE_MODE)) + .map_err(|source| ServerError::Bind { + path: path.clone(), + source + })?; + listener + .set_nonblocking(true) + .map_err(|source| ServerError::Bind { + path: path.clone(), + source + })?; + + // SAFETY: `getuid` reads this process's own identity and cannot fail. + let owner_uid = unsafe { libc::getuid() }; + + Ok(Self { + listener, + served: Arc::new(Served { bridge, owner_uid }), + path, + lease + }) + } + + /// Where the socket is. + pub fn path(&self) -> &Path + { + &self.path + } + + /// Starts answering, on a thread of its own. + /// + /// Dropping the returned handle stops the loop, waits for it, and removes the + /// socket file — which is safe to do because the handle still holds the lease. + pub fn spawn(self) -> ServerHandle + { + let stop = Arc::new(AtomicBool::new(false)); + let path = self.path.clone(); + let lease = Arc::clone(&self.lease); + let loop_stop = Arc::clone(&stop); + let thread = thread::spawn(move || self.accept_until_stopped(&loop_stop)); + + ServerHandle { + stop, + thread: Some(thread), + path, + _lease: lease + } + } + + /// Accepts connections until the flag is set. + fn accept_until_stopped(self, stop: &AtomicBool) + { + while !stop.load(Ordering::Relaxed) + { + match self.listener.accept() + { + Ok((stream, _)) => + { + let served = Arc::clone(&self.served); + thread::spawn(move || served.converse(stream)); + } + // Nothing waiting. Sleeping on the descriptor rather than on the + // clock keeps the loop from spinning without making the stop flag + // wait for a connection that may never come. + Err(error) if error.kind() == io::ErrorKind::WouldBlock => + { + wait_readable(self.listener.as_raw_fd(), ACCEPT_POLL_INTERVAL); + } + // An accept that failed for another reason says nothing about the + // next one. Pausing first stops a permanent failure from turning + // into a busy loop. + Err(_) => thread::sleep(ACCEPT_POLL_INTERVAL) + } + } + } +} + +impl Served +{ + /// Reads frames from one connection and answers them until it ends. + fn converse(&self, stream: UnixStream) + { + // POSIX does not pass O_NONBLOCK to an accepted socket, but saying so + // costs nothing and makes the timeouts below mean what they say. + let _ = stream.set_nonblocking(false); + let _ = stream.set_read_timeout(Some(REQUEST_READ_TIMEOUT)); + let _ = stream.set_write_timeout(Some(RESPONSE_WRITE_TIMEOUT)); + + // Before a byte is read. A connection from another user is closed + // without an answer: it is told nothing, because there is nothing it is + // entitled to know. + match peer_uid(&stream) + { + Ok(uid) if uid == self.owner_uid => + {} + _ => return + } + + let Ok(mut out) = stream.try_clone() + else + { + return; + }; + let mut frames = FrameReader::new(stream); + + loop + { + let response = match frames.next_frame() + { + Ok(Some(line)) => self.answer(&line), + // The peer finished speaking, or stopped without finishing a + // frame, or sent more than a frame may weigh. None of those leave + // anything worth saying on a stream that is already over. + Ok(None) | Err(_) => return + }; + + let Ok(line) = response.to_json_line() + else + { + return; + }; + + if out.write_all(line.as_bytes()).is_err() + { + return; + } + } + } + + /// Turns one line into the line that answers it. + fn answer(&self, line: &str) -> SocketResponse + { + let request = match SocketRequest::from_json_line(line) + { + Ok(request) => request, + // A frame that could not be read has no usable request id, so the + // refusal carries none. `FrameError` already knows how to say which + // rule was broken without echoing what was sent. + Err(refusal) => return refused(refusal) + }; + + match &request.method + { + SocketMethod::AppStatus => match serde_json::to_value(self.bridge.app_status()) + { + Ok(result) => SocketResponse::ok(request.id, result), + Err(_) => SocketResponse::error( + request.id, + ErrorEnvelope::new(ErrorCode::AppProtocolVersion) + .with_request(request.id) + .with_param("reason", ErrorParam::literal("result_unserialisable")) + ) + }, + // Every other method by name, so that adding one to the protocol is a + // compile error here rather than a method that silently does nothing. + method @ (SocketMethod::UiOpen(_) + | SocketMethod::UiFocus + | SocketMethod::AccountInspect + | SocketMethod::DataCoverage + | SocketMethod::IngestRequest(_) + | SocketMethod::BacktestRun + | SocketMethod::BacktestInspect + | SocketMethod::BackupCreateRequest(_) + | SocketMethod::BackupVerify(_) + | SocketMethod::WorkspaceRestoreRequest(_) + | SocketMethod::StrategyInspect(_) + | SocketMethod::StrategyRegisterRequest(_)) => SocketResponse::error( + request.id, + ErrorEnvelope::new(ErrorCode::AppProtocolVersion) + .with_request(request.id) + .with_param("reason", ErrorParam::literal("method_unavailable")) + .with_param("method", ErrorParam::literal(method.name())) + ) + } + } +} + +/// A response to a frame that could not be read far enough to have an id. +/// +/// The id on the wire is not optional, so a refusal that has none borrows a fresh +/// one rather than inventing a shape the protocol does not have. The caller can +/// still tell the frame was refused, and by which rule, from the envelope. +fn refused(refusal: FrameError) -> SocketResponse +{ + let id = fresh_request_id(); + SocketResponse::error(id, refusal.to_envelope(Some(id))) +} + +/// The server, running, with the socket file and the lease still held. +pub struct ServerHandle +{ + stop: Arc, + thread: Option>, + path: PathBuf, + /// Kept so the lease cannot be released while the socket is still bound. + _lease: Arc +} + +impl ServerHandle +{ + /// Where the socket is. + pub fn path(&self) -> &Path + { + &self.path + } + + /// Stops accepting and waits for the loop to finish. + pub fn stop(self) + { + drop(self); + } +} + +impl Drop for ServerHandle +{ + fn drop(&mut self) + { + self.stop.store(true, Ordering::Relaxed); + + if let Some(thread) = self.thread.take() + { + let _ = thread.join(); + } + + // Safe for the same reason the unlink at bind time is: this handle still + // holds the lease, so nothing else is serving on this path. + let _ = std::fs::remove_file(&self.path); + } +} + +/// The client half: what `trdr` uses to ask a running app something. +pub struct AppClient +{ + out: UnixStream, + frames: FrameReader +} + +impl AppClient +{ + /// Connects to the app serving this product root. + /// + /// A socket that is not there, or that nothing is listening on, is the + /// ordinary "no app is running" case rather than a failure to report. + pub fn connect(root: &ProductRoot) -> Result + { + let path = root.socket_path(); + + if path.as_os_str().len() > MAX_SOCKET_PATH_BYTES + { + return Err(ClientError::PathTooLong { + found: path.as_os_str().len(), + limit: MAX_SOCKET_PATH_BYTES + }); + } + + let stream = UnixStream::connect(&path).map_err(|_| ClientError::NotRunning)?; + stream + .set_read_timeout(Some(CLIENT_READ_TIMEOUT)) + .map_err(ClientError::Io)?; + + let out = stream.try_clone().map_err(ClientError::Io)?; + + Ok(Self { + out, + frames: FrameReader::new(stream) + }) + } + + /// Asks `app.status` and reads the typed answer. + pub fn app_status(&mut self) -> Result + { + let request = SocketRequest::new(fresh_request_id(), SocketMethod::AppStatus); + let line = request.to_json_line().map_err(|_| { + ClientError::Frame(FrameError::InvalidParams { + method: "app.status" + }) + })?; + + self.out + .write_all(line.as_bytes()) + .map_err(|_| ClientError::NotRunning)?; + + let answer = match self.frames.next_frame() + { + Ok(Some(answer)) => answer, + Ok(None) => return Err(ClientError::NoAnswer), + Err(FrameReadError::Io(error)) => return Err(ClientError::Io(error)), + Err(_) => return Err(ClientError::NoAnswer) + }; + + let response: SocketResponse = + SocketResponse::from_json_line(&answer).map_err(ClientError::Frame)?; + + match response.outcome + { + // The id is checked on a result and not on a refusal. A frame the + // server could not read far enough to find an id in still has to be + // answered, and the protocol has no response without one, so a + // refusal is allowed to name an id this side never sent. + SocketOutcome::Ok(status) if response.id == request.id => Ok(status), + SocketOutcome::Ok(_) => Err(ClientError::MismatchedResponse), + SocketOutcome::Error(envelope) => Err(ClientError::Refused(Box::new(envelope))) + } + } +} + +/// A request id for a frame this process is sending. +/// +/// The runtime is where a ULID is minted; `trdr-core` can read one but has no +/// clock and no randomness with which to make one. +fn fresh_request_id() -> RequestId +{ + RequestId::from_ulid(Ulid::generate()) +} + +/// Splits a byte stream into newline-delimited frames. +/// +/// Doing this rather than reaching for `BufRead::lines` is not style. `lines` +/// grows its `String` until it meets a newline, so a peer that sends a gigabyte +/// without one is a memory cost this process pays; the buffer here refuses at +/// [`MAX_FRAME_BYTES`], which is the same limit `trdr-core` applies to a frame it +/// has already received. It also has to survive both halves of the same problem: +/// one frame arriving in several reads, and several frames arriving in one. +struct FrameReader +{ + source: R, + buffer: Vec, + finished: bool +} + +impl FrameReader +{ + /// Reads frames out of `source`. + fn new(source: R) -> Self + { + Self { + source, + buffer: Vec::new(), + finished: false + } + } + + /// The next whole frame, or `None` once the peer has finished cleanly. + fn next_frame(&mut self) -> Result, FrameReadError> + { + loop + { + if let Some(end) = self.buffer.iter().position(|byte| *byte == b'\n') + { + let mut frame: Vec = self.buffer.drain(..=end).collect(); + frame.pop(); + + return String::from_utf8(frame) + .map(Some) + .map_err(|_| FrameReadError::NotUtf8); + } + + if self.buffer.len() > MAX_FRAME_BYTES + { + return Err(FrameReadError::TooLarge { + limit: MAX_FRAME_BYTES + }); + } + + if self.finished + { + return match self.buffer.is_empty() + { + true => Ok(None), + // The protocol is one object per line. Bytes with no newline + // after them are half a frame, not a lenient one. + false => Err(FrameReadError::Truncated) + }; + } + + let mut chunk = [0u8; 4096]; + + match self.source.read(&mut chunk) + { + Ok(0) => self.finished = true, + Ok(read) => self.buffer.extend_from_slice(&chunk[..read]), + Err(error) if error.kind() == io::ErrorKind::Interrupted => + {} + Err(error) => return Err(FrameReadError::Io(error)) + } + } + } +} + +/// A frame could not be read off the stream. +#[derive(Debug, thiserror::Error)] +enum FrameReadError +{ + /// More than [`MAX_FRAME_BYTES`] arrived with no newline in it. + #[error("a frame passed the {limit} byte limit before it ended")] + TooLarge + { + /// The limit that was passed. + limit: usize + }, + /// The stream ended in the middle of a frame. + #[error("the stream ended in the middle of a frame")] + Truncated, + /// The bytes were not text. + #[error("a frame was not valid UTF-8")] + NotUtf8, + /// The read itself failed, which includes the read timing out. + #[error(transparent)] + Io(io::Error) +} + +/// The uid of the process on the other end. +/// +/// `getpeereid` reads what the kernel recorded when the peer connected, so a +/// caller can neither claim an identity nor hand the connection to another user +/// afterwards. +fn peer_uid(stream: &UnixStream) -> io::Result +{ + let mut uid: libc::uid_t = 0; + let mut gid: libc::gid_t = 0; + + // SAFETY: the descriptor is owned by `stream` and open for the call, and both + // out-parameters are live for its duration. + if unsafe { libc::getpeereid(stream.as_raw_fd(), &mut uid, &mut gid) } != 0 + { + return Err(io::Error::last_os_error()); + } + + Ok(uid) +} + +/// Waits for a descriptor to have something on it, or for the timeout. +fn wait_readable(descriptor: std::os::unix::io::RawFd, timeout: Duration) +{ + let mut watched = libc::pollfd { + fd: descriptor, + events: libc::POLLIN, + revents: 0 + }; + + // SAFETY: one initialised `pollfd` is passed with a length of one, and the + // descriptor is owned by the listener for the whole call. + unsafe { libc::poll(&mut watched, 1, timeout.as_millis() as libc::c_int) }; +} + +/// Refuses a socket path that will not fit in a `sockaddr_un`. +fn check_socket_path(path: &Path) -> Result<(), ServerError> +{ + match path.as_os_str().len() > MAX_SOCKET_PATH_BYTES + { + true => Err(ServerError::PathTooLong { + found: path.as_os_str().len(), + limit: MAX_SOCKET_PATH_BYTES + }), + false => Ok(()) + } +} + +/// Removes a socket file left behind by an app that is no longer running. +/// +/// Takes the lease rather than reading it, so that this cannot be called from +/// anywhere that is not the writer. See the module documentation for why the +/// lease is the right thing to ask. +fn clear_stale_socket(_lease: &WriterLease, socket: &Path) -> Result<(), ServerError> +{ + match std::fs::remove_file(socket) + { + Ok(()) => Ok(()), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), + Err(source) => Err(ServerError::ClearStaleSocket { + path: socket.to_path_buf(), + source + }) + } +} + +/// The socket could not be bound. +#[derive(Debug, thiserror::Error)] +pub enum ServerError +{ + /// The path is longer than a `sockaddr_un` can hold. + #[error("the socket path is {found} bytes, the limit is {limit}")] + PathTooLong + { + /// How long it was. + found: usize, + /// The limit it passed. + limit: usize + }, + /// The directories under the product root could not be prepared. + #[error("the run directory could not be prepared: {source}")] + Prepare + { + /// What went wrong. + #[source] + source: Box + }, + /// A socket file left by a dead app could not be removed. + #[error("{path} could not be removed: {source}")] + ClearStaleSocket + { + /// The leftover file. + path: PathBuf, + /// What the operating system said. + #[source] + source: io::Error + }, + /// The socket could not be created or made private. + #[error("{path} could not be bound: {source}")] + Bind + { + /// The socket path. + path: PathBuf, + /// What the operating system said. + #[source] + source: io::Error + } +} + +/// Asking a running app did not work. +#[derive(Debug, thiserror::Error)] +pub enum ClientError +{ + /// Nothing is listening, so no app is running. + #[error("no app is listening on the socket")] + NotRunning, + /// The socket path is longer than a `sockaddr_un` can hold. + #[error("the socket path is {found} bytes, the limit is {limit}")] + PathTooLong + { + /// How long it was. + found: usize, + /// The limit it passed. + limit: usize + }, + /// The app closed the connection without answering. + #[error("the app closed the connection without answering")] + NoAnswer, + /// The answer belonged to a different request. + #[error("the answer did not name the request that was sent")] + MismatchedResponse, + /// The answer did not obey the protocol. + #[error(transparent)] + Frame(FrameError), + /// The app answered, and the answer was a refusal. + #[error("the app refused the request")] + Refused(Box), + /// The connection itself failed. + #[error(transparent)] + Io(io::Error) +} + +impl ClientError +{ + /// The same failure in the one shape every surface renders (section 12). + /// + /// Everything that means "there is no app to ask" becomes `APP_NOT_RUNNING` + /// and says which one it was in a parameter, the way `trdr-core` already + /// distinguishes its seven frame refusals under one code. No sentence crosses + /// this boundary; the words belong to whichever surface is speaking. + pub fn to_envelope(&self) -> ErrorEnvelope + { + match self + { + Self::Refused(envelope) => (**envelope).clone(), + Self::Frame(refusal) => refusal.to_envelope(None), + Self::NotRunning => not_running("no_socket"), + Self::Io(_) => not_running("connection_failed"), + Self::NoAnswer => not_running("no_answer"), + Self::PathTooLong { found, limit } => not_running("socket_path_too_long") + .with_param("found", ErrorParam::Integer(*found as i64)) + .with_param("limit", ErrorParam::Integer(*limit as i64)), + Self::MismatchedResponse => ErrorEnvelope::new(ErrorCode::AppProtocolVersion) + .with_param("reason", ErrorParam::literal("response_id_mismatch")) + } + } +} + +/// `APP_NOT_RUNNING`, saying which way the app turned out not to be there. +fn not_running(reason: &'static str) -> ErrorEnvelope +{ + ErrorEnvelope::new(ErrorCode::AppNotRunning) + .with_param("reason", ErrorParam::literal(reason)) + .with_retryability(Retryability::No) +} + +#[cfg(test)] +mod tests +{ + use super::*; + + #[test] + fn a_frame_split_across_reads_is_put_back_together() + { + let pieces: Vec<&[u8]> = vec![b"{\"v\":1,", b"\"id\":\"x\"}", b"\n"]; + let mut reader = FrameReader::new(Trickle::new(pieces)); + + assert_eq!( + reader.next_frame().unwrap().as_deref(), + Some("{\"v\":1,\"id\":\"x\"}") + ); + assert_eq!(reader.next_frame().unwrap(), None); + } + + #[test] + fn several_frames_arriving_in_one_read_are_taken_apart() + { + let mut reader = FrameReader::new(Trickle::new(vec![b"one\ntwo\nthree\n"])); + + assert_eq!(reader.next_frame().unwrap().as_deref(), Some("one")); + assert_eq!(reader.next_frame().unwrap().as_deref(), Some("two")); + assert_eq!(reader.next_frame().unwrap().as_deref(), Some("three")); + assert_eq!(reader.next_frame().unwrap(), None); + } + + #[test] + fn a_carriage_return_is_left_for_the_parser_to_trim() + { + let mut reader = FrameReader::new(Trickle::new(vec![b"one\r\n"])); + + assert_eq!(reader.next_frame().unwrap().as_deref(), Some("one\r")); + } + + #[test] + fn a_stream_that_stops_mid_frame_is_not_treated_as_a_frame() + { + let mut reader = FrameReader::new(Trickle::new(vec![b"{\"v\":1}"])); + + assert!(matches!( + reader.next_frame(), + Err(FrameReadError::Truncated) + )); + } + + // The reason this reader exists rather than `BufRead::lines`: the limit has + // to bite while the bytes are arriving, not after a whole line has been kept. + #[test] + fn bytes_with_no_newline_are_refused_at_the_frame_limit() + { + let mut reader = FrameReader::new(Flood); + + assert!(matches!( + reader.next_frame(), + Err(FrameReadError::TooLarge { .. }) + )); + } + + #[test] + fn a_frame_that_is_not_text_is_refused() + { + let mut reader = FrameReader::new(Trickle::new(vec![b"\xff\xfe\n"])); + + assert!(matches!(reader.next_frame(), Err(FrameReadError::NotUtf8))); + } + + #[test] + fn this_process_is_recognised_as_the_owner_of_its_own_connection() + { + let (here, there) = UnixStream::pair().expect("a socket pair could not be made"); + + // SAFETY: `getuid` reads this process's own identity and cannot fail. + let mine = unsafe { libc::getuid() }; + + assert_eq!(peer_uid(&here).unwrap(), mine); + assert_eq!(peer_uid(&there).unwrap(), mine); + } + + // The rejection branch itself, not just the uid reader. The expected owner is + // set to a uid this process does not have, which is the only way to reach the + // branch without a second user account. + #[test] + fn a_connection_from_another_user_is_closed_without_an_answer() + { + // SAFETY: `getuid` reads this process's own identity and cannot fail. + let mine = unsafe { libc::getuid() }; + let served = Arc::new(Served { + bridge: Arc::new(FixedStatus), + owner_uid: mine.wrapping_add(1) + }); + + let (server_side, mut client_side) = + UnixStream::pair().expect("a socket pair could not be made"); + // Set before the peer goes away: a socket whose other end has closed no + // longer accepts its options being changed. + client_side + .set_read_timeout(Some(Duration::from_secs(5))) + .expect("the read timeout could not be set"); + + let worker = thread::spawn(move || served.converse(server_side)); + let _ = client_side.write_all( + SocketRequest::new(fresh_request_id(), SocketMethod::AppStatus) + .to_json_line() + .unwrap() + .as_bytes() + ); + + worker.join().expect("the connection thread panicked"); + + let mut answer = Vec::new(); + let _ = client_side.read_to_end(&mut answer); + + assert!( + answer.is_empty(), + "a foreign uid was told something: {answer:?}" + ); + } + + #[test] + fn a_connection_from_this_user_is_answered() + { + // SAFETY: `getuid` reads this process's own identity and cannot fail. + let served = Arc::new(Served { + bridge: Arc::new(FixedStatus), + owner_uid: unsafe { libc::getuid() } + }); + + let (server_side, client_side) = + UnixStream::pair().expect("a socket pair could not be made"); + thread::spawn(move || served.converse(server_side)); + + let mut out = client_side.try_clone().unwrap(); + let request = SocketRequest::new(fresh_request_id(), SocketMethod::AppStatus); + out.write_all(request.to_json_line().unwrap().as_bytes()) + .unwrap(); + + let mut frames = FrameReader::new(client_side); + let line = frames + .next_frame() + .unwrap() + .expect("an answer was expected"); + let response = SocketResponse::::from_json_line(&line).unwrap(); + + assert_eq!(response.id, request.id); + assert!(matches!(response.outcome, SocketOutcome::Ok(_))); + } + + #[test] + fn a_method_this_build_does_not_serve_is_refused_by_name() + { + let served = Served { + bridge: Arc::new(FixedStatus), + // SAFETY: `getuid` reads this process's own identity and cannot fail. + owner_uid: unsafe { libc::getuid() } + }; + + let request = SocketRequest::new(fresh_request_id(), SocketMethod::UiFocus); + let response = served.answer(request.to_json_line().unwrap().trim_end()); + + match response.outcome + { + SocketOutcome::Error(envelope) => + { + assert_eq!(envelope.code, ErrorCode::AppProtocolVersion); + assert_eq!( + envelope.params.get("method"), + Some(&ErrorParam::literal("ui.focus")) + ); + } + SocketOutcome::Ok(_) => panic!("a method this build has not built answered") + } + } + + #[test] + fn a_frame_this_build_cannot_read_is_refused_rather_than_ignored() + { + let served = Served { + bridge: Arc::new(FixedStatus), + // SAFETY: `getuid` reads this process's own identity and cannot fail. + owner_uid: unsafe { libc::getuid() } + }; + + for line in [ + "{", + "{\"v\":9,\"id\":\"01KZNNR5X818P3J6ENYKSADP8W\",\"method\":\"app.status\"}" + ] + { + match served.answer(line).outcome + { + SocketOutcome::Error(envelope) => + { + assert_eq!(envelope.code, ErrorCode::AppProtocolVersion) + } + SocketOutcome::Ok(_) => panic!("`{line}` was accepted") + } + } + } + + /// A bridge with nothing behind it, for the protocol tests. + struct FixedStatus; + + impl AppBridge for FixedStatus + { + fn app_status(&self) -> AppStatusResult + { + AppStatusResult { + app_version: "0.0.0".to_owned(), + pid: std::process::id(), + holds_writer_lease: true, + product_root: PathBuf::from("/private/tmp/t"), + workspace_path: PathBuf::from("/private/tmp/t/workspaces/default"), + schema_version: 1 + } + } + } + + #[test] + fn a_path_a_sockaddr_cannot_hold_is_refused_as_itself() + { + let long = PathBuf::from("/private/tmp").join("x".repeat(MAX_SOCKET_PATH_BYTES)); + + assert!(matches!( + check_socket_path(&long), + Err(ServerError::PathTooLong { limit, .. }) if limit == MAX_SOCKET_PATH_BYTES + )); + assert!(check_socket_path(Path::new("/private/tmp/t/run/app.sock")).is_ok()); + } + + #[test] + fn every_way_of_not_reaching_an_app_renders_as_one_code() + { + for error in [ + ClientError::NotRunning, + ClientError::NoAnswer, + ClientError::Io(io::Error::from(io::ErrorKind::BrokenPipe)), + ClientError::PathTooLong { + found: 200, + limit: MAX_SOCKET_PATH_BYTES + } + ] + { + let envelope = error.to_envelope(); + + assert_eq!(envelope.code, ErrorCode::AppNotRunning); + assert!(envelope.params.contains_key("reason")); + } + } + + #[test] + fn a_refusal_from_the_app_is_passed_through_unchanged() + { + let envelope = ErrorEnvelope::new(ErrorCode::DbBusy); + let error = ClientError::Refused(Box::new(envelope.clone())); + + assert_eq!(error.to_envelope(), envelope); + } + + /// A reader that hands over one prepared piece per call, so a test can choose + /// how a frame is split. + struct Trickle + { + pieces: std::collections::VecDeque> + } + + impl Trickle + { + fn new(pieces: Vec<&[u8]>) -> Self + { + Self { + pieces: pieces.into_iter().map(<[u8]>::to_vec).collect() + } + } + } + + impl Read for Trickle + { + fn read(&mut self, out: &mut [u8]) -> io::Result + { + let Some(piece) = self.pieces.pop_front() + else + { + return Ok(0); + }; + + let taken = piece.len().min(out.len()); + out[..taken].copy_from_slice(&piece[..taken]); + + if taken < piece.len() + { + self.pieces.push_front(piece[taken..].to_vec()); + } + + Ok(taken) + } + } + + /// A reader with no end and no newline in it. + struct Flood; + + impl Read for Flood + { + fn read(&mut self, out: &mut [u8]) -> io::Result + { + out.fill(b'x'); + Ok(out.len()) + } + } +} diff --git a/crates/trdr-runtime/src/test_support.rs b/crates/trdr-runtime/src/test_support.rs new file mode 100644 index 0000000..a9388c6 --- /dev/null +++ b/crates/trdr-runtime/src/test_support.rs @@ -0,0 +1,134 @@ +//! Scratch product roots and the stand-ins for the seams section 13 names. +//! +//! # Scratch roots +//! +//! [`scratch_root`] exists for one reason that is easy to get wrong. A macOS +//! `sockaddr_un` has 104 bytes for its path, and the per-user temporary +//! directory `std::env::temp_dir` hands back looks like +//! `/var/folders/9k/2b1n.../T/`, which is most of that budget before a test has +//! named anything. A socket bound under it fails with an error about the address +//! rather than about the length, and the failure only appears on the machine +//! whose temporary path happens to be long. Roots made here live directly under +//! `/private/tmp`, so the whole path stays around forty bytes. +//! +//! # Doubles +//! +//! [`FixedClock`] and [`CountingIds`] are the other half of the [`crate::clock`] +//! and [`crate::ids`] seams. A workspace manifest is two values a test cannot +//! predict — an instant and a random id — and these are what turn it into a file +//! whose exact bytes an assertion can name. +//! +//! Everything here is public so that the integration tests of this crate, of +//! `trdr-cli`, and of the desktop app share one definition rather than four +//! copies. None of it is part of the product, and nothing outside a test should +//! call it. + +use crate::clock::Clock; +use crate::ids::IdGenerator; +use crate::root::ProductRoot; +use std::path::PathBuf; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use trdr_core::id::Ulid; +use trdr_core::time::Timestamp; + +/// Where scratch roots go. Deliberately short; see the module documentation. +const SCRATCH_PARENT: &str = "/private/tmp/trdr-t"; + +/// Tells two roots made in the same process apart. +static COUNTER: AtomicU32 = AtomicU32::new(0); + +/// An empty product root for a test to write into, removed if it was left over. +/// +/// The name only has to be readable in a failure; the process id and a counter +/// are what keep two test binaries, or two tests in one binary, apart. +pub fn scratch_root(name: &str) -> ProductRoot +{ + let serial = COUNTER.fetch_add(1, Ordering::Relaxed); + let path = + PathBuf::from(SCRATCH_PARENT).join(format!("{name}-{}-{serial}", std::process::id())); + + let _ = std::fs::remove_dir_all(&path); + std::fs::create_dir_all(&path).expect("a scratch root could not be made"); + + // The guard the brief asks for, stated where every test goes through it: a + // scratch root is never allowed to be inside the directory holding a person's + // real workspace. + if let Some(home) = std::env::var_os("HOME").filter(|home| !home.is_empty()) + { + assert!( + !path.starts_with(PathBuf::from(home)), + "a scratch root must never be inside the home directory" + ); + } + + ProductRoot::at(path) +} + +/// A clock stopped at one instant. +#[derive(Debug, Clone)] +pub struct FixedClock +{ + at: Timestamp +} + +impl FixedClock +{ + /// A clock that always answers with this instant. + /// + /// # Panics + /// + /// If the text is not a canonical timestamp. A test that wrote one wrong + /// should hear about it where it wrote it. + pub fn at(text: &str) -> Self + { + Self { + at: Timestamp::parse(text).expect("a fixed clock needs a canonical timestamp") + } + } +} + +impl Clock for FixedClock +{ + fn now(&self) -> Timestamp + { + self.at.clone() + } +} + +/// An id generator that counts, so a test knows what it produced. +/// +/// The values are ULIDs by type and by text, and they are deliberately not ULIDs +/// by construction: nothing here reads a clock, so `CountingIds::from(1)` gives +/// the same 26 characters on every machine and in every run. +#[derive(Debug)] +pub struct CountingIds +{ + first: u64, + next: AtomicU64 +} + +impl CountingIds +{ + /// A generator whose first id is this number. + pub fn from(first: u64) -> Self + { + Self { + first, + next: AtomicU64::new(first) + } + } + + /// How many ids have been handed out since it was made. + pub fn issued(&self) -> u64 + { + self.next.load(Ordering::Relaxed) - self.first + } +} + +impl IdGenerator for CountingIds +{ + fn generate(&self) -> Ulid + { + Ulid(u128::from(self.next.fetch_add(1, Ordering::Relaxed))) + } +} diff --git a/crates/trdr-runtime/src/workspace.rs b/crates/trdr-runtime/src/workspace.rs new file mode 100644 index 0000000..2b4fd94 --- /dev/null +++ b/crates/trdr-runtime/src/workspace.rs @@ -0,0 +1,343 @@ +//! The workspace directory on disk, and the `workspace.json` that identifies it. +//! +//! Section 5.1 of `docs/FOUNDATION_DESIGN.md` says a first run creates +//! `~/.trdr/workspaces/default` and puts a manifest in it: a portable workspace +//! id, the manifest's own schema version, and when the workspace was made. The +//! shape of that file is [`WorkspaceManifest`], which lives in `trdr-core` +//! because it is a value; creating one needs a clock and an id generator, which +//! is why the reading and writing live here. +//! +//! # Why this takes the lease +//! +//! [`Workspace::open_default`] asks for a [`WriterLease`] for the same reason +//! [`crate::db::Database::open`] does. A first run writes a file that decides +//! what this workspace *is*, and two processes doing that at once would leave +//! one of them holding an id the file no longer carries. Taking the lease is the +//! only way to get the argument, so the race cannot be written down. +//! +//! # What a first run does not create +//! +//! `~/.trdr/config.json` — the selected workspace and the per-machine instance +//! id Keychain items hang off — is section 5.1's other file and is not written +//! here. It belongs to the credential track, and inventing a local instance id +//! before anything stores a credential under it would be a value with no +//! meaning behind it. + +use crate::clock::Clock; +use crate::ids::IdGenerator; +use crate::root::{RootError, WriterLease, PRIVATE_FILE_MODE}; +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::os::unix::fs::OpenOptionsExt; +use std::path::{Path, PathBuf}; +use trdr_core::id::WorkspaceId; +use trdr_core::workspace::{ManifestError, WorkspaceManifest}; + +/// The file that says which workspace a directory is (section 5.1). +pub const MANIFEST_FILE_NAME: &str = "workspace.json"; + +/// One workspace directory, opened. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Workspace +{ + path: PathBuf, + manifest: WorkspaceManifest +} + +impl Workspace +{ + /// Opens the lease holder's default workspace, creating it on a first run. + /// + /// ``` + /// # use std::sync::Arc; + /// # use trdr_runtime::clock::SystemClock; + /// # use trdr_runtime::ids::UlidGenerator; + /// # use trdr_runtime::test_support::scratch_root; + /// # use trdr_runtime::workspace::Workspace; + /// let root = scratch_root("doc-workspace"); + /// let lease = root.acquire_writer_lease().unwrap(); + /// + /// let first = Workspace::open_default(&lease, &SystemClock, &UlidGenerator).unwrap(); + /// let again = Workspace::open_default(&lease, &SystemClock, &UlidGenerator).unwrap(); + /// + /// // A second open reads the manifest rather than minting a second identity. + /// assert_eq!(first.id(), again.id()); + /// ``` + pub fn open_default( + lease: &WriterLease, + clock: &dyn Clock, + ids: &dyn IdGenerator + ) -> Result + { + let root = lease.root(); + root.prepare()?; + + let path = root.default_workspace_dir(); + let manifest_path = path.join(MANIFEST_FILE_NAME); + + let manifest = match fs::read_to_string(&manifest_path) + { + Ok(text) => + { + WorkspaceManifest::from_json(&text).map_err(|source| WorkspaceError::Manifest { + path: manifest_path.clone(), + source + })? + } + Err(error) if error.kind() == io::ErrorKind::NotFound => + { + let manifest = + WorkspaceManifest::new(WorkspaceId::from_ulid(ids.generate()), clock.now()); + write_manifest(&manifest_path, &manifest)?; + manifest + } + Err(source) => + { + return Err(WorkspaceError::Read { + path: manifest_path, + source + }) + } + }; + + Ok(Self { path, manifest }) + } + + /// Where the workspace directory is. + pub fn path(&self) -> &Path + { + &self.path + } + + /// Where its manifest is. + pub fn manifest_path(&self) -> PathBuf + { + self.path.join(MANIFEST_FILE_NAME) + } + + /// The portable identity in that manifest. + pub fn id(&self) -> WorkspaceId + { + self.manifest.workspace_id + } + + /// The manifest itself. + pub fn manifest(&self) -> &WorkspaceManifest + { + &self.manifest + } +} + +/// Writes a manifest so that no reader can ever see half of one. +/// +/// The bytes go to a temporary file beside the destination and are renamed over +/// it, because `rename` within a directory is atomic: a reader sees either the +/// old name or the new contents, never a file that is being filled in. The +/// temporary name carries the process id so that a crashed run leaves something +/// identifiable rather than colliding with the next one. +fn write_manifest(path: &Path, manifest: &WorkspaceManifest) -> Result<(), WorkspaceError> +{ + let json = serde_json::to_vec(manifest).map_err(|_| WorkspaceError::Unserialisable)?; + + let directory = path.parent().unwrap_or(Path::new(".")); + let staging = directory.join(format!(".{MANIFEST_FILE_NAME}.{}.tmp", std::process::id())); + + let write = || -> io::Result<()> { + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(PRIVATE_FILE_MODE) + .open(&staging)?; + + file.write_all(&json)?; + // Before the rename, not after: a rename that lands before the contents + // reach the disk is exactly the empty-file-after-a-crash case the + // staging file was supposed to prevent. + file.sync_all()?; + drop(file); + + fs::rename(&staging, path) + }; + + write().map_err(|source| { + let _ = fs::remove_file(&staging); + WorkspaceError::Write { + path: path.to_path_buf(), + source + } + }) +} + +/// The workspace could not be opened or created. +#[derive(Debug, thiserror::Error)] +pub enum WorkspaceError +{ + /// The directories under the product root could not be prepared. + #[error(transparent)] + Root(#[from] RootError), + /// The manifest exists and could not be read off the disk. + #[error("{path} could not be read: {source}")] + Read + { + /// The manifest. + path: PathBuf, + /// What the operating system said. + #[source] + source: io::Error + }, + /// The manifest was read and is not one this build can use. + #[error("{path} is not a workspace manifest this build can use: {source}")] + Manifest + { + /// The manifest. + path: PathBuf, + /// Which rule it broke. + #[source] + source: ManifestError + }, + /// The manifest could not be written. + #[error("{path} could not be written: {source}")] + Write + { + /// The manifest. + path: PathBuf, + /// What the operating system said. + #[source] + source: io::Error + }, + /// A manifest this build built could not be turned into JSON. + /// + /// Not reachable from any value [`WorkspaceManifest::new`] produces; it is + /// here so that serialisation has an outcome rather than an `unwrap`. + #[error("the workspace manifest could not be written as JSON")] + Unserialisable +} + +#[cfg(test)] +mod tests +{ + use super::*; + use crate::clock::SystemClock; + use crate::ids::UlidGenerator; + use crate::root::mode_of; + use crate::test_support::{scratch_root, CountingIds, FixedClock}; + use trdr_core::workspace::WorkspaceSchemaVersion; + + #[test] + fn a_first_run_creates_the_manifest_the_design_describes() + { + let root = scratch_root("ws-first-run"); + let lease = root.acquire_writer_lease().expect("the lease is free"); + let clock = FixedClock::at("2026-08-11T09:30:00Z"); + let ids = CountingIds::from(7); + + let workspace = + Workspace::open_default(&lease, &clock, &ids).expect("a first run should create one"); + + assert_eq!(workspace.path(), root.default_workspace_dir()); + assert_eq!( + workspace.manifest().schema_version, + WorkspaceSchemaVersion::V1 + ); + assert_eq!( + workspace.manifest().created_at.as_str(), + "2026-08-11T09:30:00Z" + ); + + // The file on disk, not the value in memory: what is asserted is that + // the manifest was actually written, and written as itself. + let text = fs::read_to_string(workspace.manifest_path()).expect("the manifest is there"); + assert_eq!( + WorkspaceManifest::from_json(&text).expect("re-readable"), + *workspace.manifest() + ); + } + + #[test] + fn the_manifest_is_private_to_its_owner() + { + let root = scratch_root("ws-modes"); + let lease = root.acquire_writer_lease().expect("the lease is free"); + let workspace = + Workspace::open_default(&lease, &SystemClock, &UlidGenerator).expect("created"); + + assert_eq!( + mode_of(&workspace.manifest_path()).unwrap(), + PRIVATE_FILE_MODE + ); + assert_eq!(mode_of(workspace.path()).unwrap(), 0o700); + } + + /// The property that makes a workspace id worth having: it is minted once + /// and read every time after that. A second start that minted a new id would + /// silently disown every backup taken under the old one. + #[test] + fn a_second_start_reads_the_identity_rather_than_minting_another() + { + let root = scratch_root("ws-stable-id"); + let lease = root.acquire_writer_lease().expect("the lease is free"); + let ids = CountingIds::from(1); + + let first = Workspace::open_default(&lease, &SystemClock, &ids).expect("created"); + let second = Workspace::open_default(&lease, &SystemClock, &ids).expect("reopened"); + + assert_eq!(first.id(), second.id()); + assert_eq!(first.manifest(), second.manifest()); + assert_eq!(ids.issued(), 1, "the second start minted an id"); + } + + #[test] + fn a_manifest_from_a_newer_build_is_told_apart_from_a_damaged_one() + { + let root = scratch_root("ws-newer"); + let lease = root.acquire_writer_lease().expect("the lease is free"); + root.prepare().unwrap(); + let path = root.default_workspace_dir().join(MANIFEST_FILE_NAME); + + fs::write( + &path, + "{\"schema_version\":\"trdr.workspace/v2\",\ + \"workspace_id\":\"01KZNNR5X818P3J6ENYKSADP8W\",\ + \"created_at\":\"2026-08-10T10:00:00Z\"}" + ) + .unwrap(); + + assert!(matches!( + Workspace::open_default(&lease, &SystemClock, &UlidGenerator), + Err(WorkspaceError::Manifest { + source: ManifestError::UnsupportedSchemaVersion { .. }, + .. + }) + )); + + fs::write(&path, "{ half a file").unwrap(); + + assert!(matches!( + Workspace::open_default(&lease, &SystemClock, &UlidGenerator), + Err(WorkspaceError::Manifest { + source: ManifestError::Unreadable, + .. + }) + )); + } + + /// The staging file is an implementation detail, and it is one that would be + /// noticed by a person opening the workspace in Finder. It must not survive + /// a successful write. + #[test] + fn writing_the_manifest_leaves_nothing_beside_it() + { + let root = scratch_root("ws-no-litter"); + let lease = root.acquire_writer_lease().expect("the lease is free"); + let workspace = + Workspace::open_default(&lease, &SystemClock, &UlidGenerator).expect("created"); + + let names: Vec = fs::read_dir(workspace.path()) + .unwrap() + .map(|entry| entry.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + + assert_eq!(names, [MANIFEST_FILE_NAME]); + } +} diff --git a/crates/trdr-runtime/tests/socket_server.rs b/crates/trdr-runtime/tests/socket_server.rs new file mode 100644 index 0000000..6708990 --- /dev/null +++ b/crates/trdr-runtime/tests/socket_server.rs @@ -0,0 +1,317 @@ +//! The socket server, over a real socket, against a scratch product root. +//! +//! What these cover that the unit tests in `src/socket.rs` cannot: binding a real +//! path, the stale socket file a dead app leaves behind, and the exclusion +//! between a server that is running and one that tries to start beside it. + +use std::io::{Read, Write}; +use std::os::unix::net::UnixStream; +use std::sync::Arc; +use std::time::Duration; +use trdr_core::socket::{ + AppStatusResult, SocketMethod, SocketOutcome, SocketRequest, SocketResponse +}; +use trdr_core::ErrorCode; +use trdr_runtime::root::{mode_of, ProductRoot, PRIVATE_FILE_MODE}; +use trdr_runtime::socket::{ + AppBridge, AppClient, ClientError, ServerHandle, SocketServer, REQUEST_READ_TIMEOUT +}; +use trdr_runtime::test_support::scratch_root; + +/// A bridge that answers with a fixed status. +struct Fixed; + +impl AppBridge for Fixed +{ + fn app_status(&self) -> AppStatusResult + { + AppStatusResult { + app_version: "0.0.0".to_owned(), + pid: std::process::id(), + holds_writer_lease: true, + product_root: std::path::PathBuf::from("/private/tmp/t"), + workspace_path: std::path::PathBuf::from("/private/tmp/t/workspaces/default"), + schema_version: 1 + } + } +} + +/// Takes the lease and starts a server on it. +fn start(root: &ProductRoot) -> ServerHandle +{ + let lease = Arc::new( + root.acquire_writer_lease() + .expect("the lease should be free") + ); + + SocketServer::bind(lease, Arc::new(Fixed)) + .expect("the socket should bind") + .spawn() +} + +#[test] +fn the_cli_client_gets_a_typed_answer_over_a_real_socket() +{ + let root = scratch_root("sock-round-trip"); + let server = start(&root); + + let mut client = AppClient::connect(&root).expect("the client should connect"); + let status = client.app_status().expect("the app should answer"); + + assert_eq!(status.pid, std::process::id()); + assert!(status.holds_writer_lease); + assert_eq!(status.schema_version, 1); + + server.stop(); +} + +#[test] +fn the_socket_is_reachable_only_by_its_owner() +{ + let root = scratch_root("sock-modes"); + let server = start(&root); + + assert_eq!(mode_of(server.path()).unwrap(), PRIVATE_FILE_MODE); + assert_eq!(mode_of(&root.run_dir()).unwrap(), 0o700); + + server.stop(); +} + +// The pitfall the brief names, over a real connection: a frame that arrives in +// pieces is still one frame. +#[test] +fn a_request_split_across_writes_is_answered_once() +{ + let root = scratch_root("sock-split"); + let server = start(&root); + + let request = SocketRequest::new(fresh_id(), SocketMethod::AppStatus); + let line = request.to_json_line().unwrap(); + let mut stream = connect(&root); + + for byte in line.as_bytes() + { + stream + .write_all(&[*byte]) + .expect("a byte could not be sent"); + std::thread::sleep(Duration::from_micros(200)); + } + + let answers = read_answers(stream, 1); + + assert_eq!(answers.len(), 1); + assert_eq!(answers[0].id, request.id); + + server.stop(); +} + +// The other half: several frames in one write are several answers, in order. +#[test] +fn several_requests_in_one_write_are_answered_in_order() +{ + let root = scratch_root("sock-coalesced"); + let server = start(&root); + + let requests: Vec = (0..3) + .map(|_| SocketRequest::new(fresh_id(), SocketMethod::AppStatus)) + .collect(); + let batch: String = requests + .iter() + .map(|request| request.to_json_line().unwrap()) + .collect(); + + let mut stream = connect(&root); + stream + .write_all(batch.as_bytes()) + .expect("the batch could not be sent"); + + let answers = read_answers(stream, requests.len()); + + assert_eq!( + answers.iter().map(|answer| answer.id).collect::>(), + requests + .iter() + .map(|request| request.id) + .collect::>() + ); + + server.stop(); +} + +// A caller that connects and says nothing must not hold the server open for +// ever. The bound is `REQUEST_READ_TIMEOUT`, and what the caller sees is the +// connection closing rather than an answer. +#[test] +fn a_client_that_says_nothing_is_let_go_and_does_not_wedge_the_server() +{ + let root = scratch_root("sock-silent"); + let server = start(&root); + + let mut silent = UnixStream::connect(root.socket_path()).expect("the client should connect"); + silent + .set_read_timeout(Some(REQUEST_READ_TIMEOUT * 4)) + .unwrap(); + + // While it sits there, the server still answers everyone else. + let mut client = AppClient::connect(&root).expect("the client should connect"); + client.app_status().expect("the app should still answer"); + + // And the silent one is let go rather than held for ever. Reading returns + // zero bytes once the server has closed its end. + let mut nothing = [0u8; 1]; + + assert_eq!( + silent.read(&mut nothing).ok(), + Some(0), + "a client that said nothing was never let go" + ); + + server.stop(); +} + +#[test] +fn a_method_this_build_does_not_serve_is_refused_over_the_socket() +{ + let root = scratch_root("sock-unavailable"); + let server = start(&root); + + let request = SocketRequest::new(fresh_id(), SocketMethod::UiFocus); + let mut stream = connect(&root); + stream + .write_all(request.to_json_line().unwrap().as_bytes()) + .expect("the request could not be sent"); + + let answers = read_answers(stream, 1); + + match &answers[0].outcome + { + SocketOutcome::Error(envelope) => + { + assert_eq!(envelope.code, ErrorCode::AppProtocolVersion) + } + SocketOutcome::Ok(_) => panic!("a method this build has not built answered") + } + + server.stop(); +} + +// The stale socket case end to end, and the reason the lease decides it: after +// an app dies its socket file is still on disk, and the next start has to bind +// over it without ever asking the file whether anyone is home. +#[test] +fn a_socket_file_left_by_a_dead_app_does_not_block_the_next_start() +{ + let root = scratch_root("sock-stale"); + let lease = Arc::new(root.acquire_writer_lease().unwrap()); + let server = SocketServer::bind(Arc::clone(&lease), Arc::new(Fixed)).unwrap(); + let path = server.path().to_path_buf(); + + // Drop the listener without the handle that would tidy up, which is what a + // process that died leaves behind. + drop(server); + assert!( + path.exists(), + "this test needs the socket file to be left behind" + ); + + let server = SocketServer::bind(lease, Arc::new(Fixed)) + .expect("a socket file left by a dead app blocked the next start") + .spawn(); + + let mut client = AppClient::connect(&root).expect("the client should connect"); + client.app_status().expect("the new server should answer"); + + server.stop(); +} + +#[test] +fn a_second_app_cannot_bind_while_the_first_holds_the_lease() +{ + let root = scratch_root("sock-single"); + let server = start(&root); + + assert!( + root.acquire_writer_lease().is_err(), + "two apps held the writer lease at once" + ); + + server.stop(); +} + +#[test] +fn a_client_with_nothing_to_talk_to_says_the_app_is_not_running() +{ + let root = scratch_root("sock-absent"); + + assert!(matches!( + AppClient::connect(&root), + Err(ClientError::NotRunning) + )); + + // A socket file with nothing listening on it is the same answer, not a + // different one: the file is not what decides. + root.prepare().unwrap(); + std::fs::write(root.socket_path(), b"").unwrap(); + + assert!(matches!( + AppClient::connect(&root), + Err(ClientError::NotRunning) + )); +} + +// The `sun_path` limit, from the client side. The server side of the same check +// is a unit test, because a root this long cannot be made to bind. +#[test] +fn a_socket_path_longer_than_a_sockaddr_is_refused_as_itself() +{ + let root = ProductRoot::at(format!("/private/tmp/{}", "x".repeat(120))); + + assert!(matches!( + AppClient::connect(&root), + Err(ClientError::PathTooLong { .. }) + )); +} + +/// Connects straight to the socket, for the tests that speak the protocol by +/// hand rather than through [`AppClient`]. +fn connect(root: &ProductRoot) -> UnixStream +{ + let stream = UnixStream::connect(root.socket_path()).expect("the client could not connect"); + stream + .set_read_timeout(Some(Duration::from_secs(15))) + .unwrap(); + stream +} + +/// Reads until the expected number of answers has arrived, then to the end. +fn read_answers(stream: UnixStream, expected: usize) -> Vec> +{ + let mut text = String::new(); + let mut reader = stream; + + while text.matches('\n').count() < expected + { + let mut chunk = [0u8; 1024]; + + match reader.read(&mut chunk) + { + Ok(0) => break, + Ok(read) => text.push_str(&String::from_utf8_lossy(&chunk[..read])), + Err(error) => panic!("no answer arrived: {error}") + } + } + + text.lines() + .filter(|line| !line.trim().is_empty()) + .map(|line| { + SocketResponse::from_json_line(line) + .unwrap_or_else(|error| panic!("the answer was not readable: {error}")) + }) + .collect() +} + +/// A request id, minted the way the runtime mints one. +fn fresh_id() -> trdr_core::RequestId +{ + trdr_core::RequestId::from_ulid(ulid::Ulid::generate()) +} diff --git a/crates/trdr-runtime/tests/writer_lease.rs b/crates/trdr-runtime/tests/writer_lease.rs new file mode 100644 index 0000000..fdb5f87 --- /dev/null +++ b/crates/trdr-runtime/tests/writer_lease.rs @@ -0,0 +1,237 @@ +//! The writer lease, proven between real processes. +//! +//! `flock` is held by an open file description rather than by a process, so a +//! second `open` plus `flock` inside the same process is allowed to succeed on +//! some platforms and proves nothing. Threads do not help either — they share the +//! process. The only honest proof spawns another process, so these tests +//! re-execute this test binary with an environment variable telling the child +//! what to do, and read the answer from its exit status. +//! +//! Every test runs against a scratch product root under `/private/tmp`. Nothing +//! here can reach the `~/.trdr` a person is using. + +use std::io::{BufRead, BufReader}; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::sync::Arc; +use std::time::{Duration, Instant}; +use trdr_runtime::db::Database; +use trdr_runtime::root::{LeaseError, ProductRoot}; +use trdr_runtime::test_support::scratch_root; + +/// Where the child should try to take the lease. +const ROOT_VARIABLE: &str = "TRDR_TEST_LEASE_ROOT"; + +/// Set when the child should keep the lease rather than report and exit. +const HOLD_VARIABLE: &str = "TRDR_TEST_LEASE_HOLD"; + +/// The child took the lease. +const EXIT_TOOK: i32 = 10; + +/// The child was refused because someone else holds it. +const EXIT_REFUSED: i32 = 11; + +/// The child failed for some other reason. +const EXIT_FAILED: i32 = 12; + +/// What a holding child prints once it has the lease. +const HELD_LINE: &str = "held"; + +/// The child half of every test in this file. +/// +/// Ordinarily this is a test that does nothing. Re-executed with +/// `TRDR_TEST_LEASE_ROOT` set it becomes the second process, and its exit status +/// is the answer the parent reads. +#[test] +fn lease_child() +{ + let Some(root) = std::env::var_os(ROOT_VARIABLE) + else + { + return; + }; + + let holding = std::env::var_os(HOLD_VARIABLE).is_some(); + let root = ProductRoot::at(PathBuf::from(root)); + + match root.acquire_writer_lease() + { + Ok(lease) => + { + if !holding + { + std::process::exit(EXIT_TOOK); + } + + // Say so, then wait to be killed. Being killed is the point: it is + // how the test reaches the case where a holder dies without ever + // releasing anything. + println!("{HELD_LINE}"); + std::thread::sleep(Duration::from_secs(120)); + drop(lease); + std::process::exit(EXIT_TOOK); + } + Err(LeaseError::Held { .. }) => std::process::exit(EXIT_REFUSED), + Err(_) => std::process::exit(EXIT_FAILED) + } +} + +#[test] +fn a_second_process_is_refused_while_the_first_holds_the_lease() +{ + let root = scratch_root("lease-two-processes"); + let held = root + .acquire_writer_lease() + .expect("the lease should be free"); + + assert_eq!( + run_child(&root), + EXIT_REFUSED, + "a second process took a lease that was already held" + ); + + drop(held); + + assert_eq!( + run_child(&root), + EXIT_TOOK, + "a released lease should be takeable by another process" + ); +} + +// The crash case, for real. A holder that is killed never runs a destructor and +// never writes anything, and the next start still gets the lease, because the +// kernel released it when the descriptor closed. A pid written into a file could +// not have produced this answer. +#[test] +fn a_lease_held_by_a_process_that_is_killed_is_released_by_the_kernel() +{ + let root = scratch_root("lease-killed"); + let mut holder = spawn_holder(&root); + + assert_eq!( + run_child(&root), + EXIT_REFUSED, + "the holder is alive, so nothing else should get the lease" + ); + + holder.kill().expect("the holder could not be killed"); + holder.wait().expect("the holder could not be reaped"); + + // The lock file is still there, and still says nothing about who holds it. + assert!(root.lease_path().exists()); + + assert_eq!( + run_child(&root), + EXIT_TOOK, + "a lease left by a killed process blocked the next one" + ); +} + +// The order the brief asks for, from the other side: the database is opened +// through a lease, so a process that cannot take the lease cannot open the +// database either. There is no second door. +#[test] +fn a_process_without_the_lease_cannot_open_the_database() +{ + let root = scratch_root("lease-before-db"); + let first = Arc::new( + root.acquire_writer_lease() + .expect("the lease should be free") + ); + let database = Database::open(Arc::clone(&first)).expect("the database should open"); + + assert_eq!(database.schema_version().unwrap(), 1); + assert_eq!( + run_child(&root), + EXIT_REFUSED, + "a second process could have taken the lease and opened the database" + ); +} + +#[test] +fn the_database_opens_again_once_the_lease_moves_on() +{ + let root = scratch_root("lease-handover"); + let lease = Arc::new( + root.acquire_writer_lease() + .expect("the lease should be free") + ); + let database = Database::open(Arc::clone(&lease)).expect("the database should open"); + let applied = database.applied_migrations().unwrap(); + + drop(database); + drop(lease); + + let again = Arc::new( + root.acquire_writer_lease() + .expect("the lease should be free") + ); + let database = Database::open(again).expect("the database should open again"); + + assert_eq!( + database.applied_migrations().unwrap(), + applied, + "re-opening applied a migration that had already run" + ); +} + +/// Runs the child once and gives back its exit status. +fn run_child(root: &ProductRoot) -> i32 +{ + let output = child_command(root) + .output() + .expect("the child process could not be run"); + + output.status.code().unwrap_or_else(|| { + panic!( + "the child was killed by a signal: {}", + String::from_utf8_lossy(&output.stderr) + ) + }) +} + +/// Starts a child that takes the lease and keeps it, returning once it has. +fn spawn_holder(root: &ProductRoot) -> Child +{ + let mut child = child_command(root) + .env(HOLD_VARIABLE, "1") + .stdout(Stdio::piped()) + .spawn() + .expect("the holding child could not be started"); + + let stdout = child + .stdout + .take() + .expect("the child has no standard output"); + let deadline = Instant::now() + Duration::from_secs(30); + + for line in BufReader::new(stdout).lines() + { + match line + { + Ok(line) if line.contains(HELD_LINE) => return child, + Ok(_) => + {} + Err(error) => panic!("the child's output could not be read: {error}") + } + + assert!(Instant::now() < deadline, "the child never took the lease"); + } + + panic!("the child ended without taking the lease"); +} + +/// This test binary, told to run the child test and nothing else. +fn child_command(root: &ProductRoot) -> Command +{ + let mut command = + Command::new(std::env::current_exe().expect("this test binary could not be located")); + + command + .args(["lease_child", "--exact", "--nocapture", "--test-threads=1"]) + .env(ROOT_VARIABLE, root.path()) + .env_remove(HOLD_VARIABLE); + + command +} diff --git a/fixtures/synthetic/ingest-bundle-v1/bundle.json b/fixtures/synthetic/ingest-bundle-v1/bundle.json new file mode 100644 index 0000000..1b7f8d2 --- /dev/null +++ b/fixtures/synthetic/ingest-bundle-v1/bundle.json @@ -0,0 +1,29 @@ +{ + "schema_version": "trdr.ingest/v1", + "bundle_id": "01kzns0000000000synthet1c0", + "source": { + "id": "user.synthetic", + "upstream_url": "https://fixtures.invalid/synthetic", + "terms_url": "https://fixtures.invalid/synthetic/terms", + "use_basis": "synthetic" + }, + "collector": { + "name": "trdr-synthetic-fixture", + "version": "1.0.0" + }, + "collected_at": "2026-08-10T10:00:00Z", + "files": [ + { + "entity": "daily_bar", + "path": "records/daily_bar.ndjson", + "records": 11, + "sha256": "d72740848622e8f1c5b5baae90a4e7b0436b853a22b948c090d68dda19e70ade" + }, + { + "entity": "macro_observation", + "path": "records/macro_observation.ndjson", + "records": 6, + "sha256": "42adad5864521f406744f1e784d28bab6eefcded358b87e98aee6c77a6f80b67" + } + ] +} diff --git a/fixtures/synthetic/ingest-bundle-v1/records/daily_bar.ndjson b/fixtures/synthetic/ingest-bundle-v1/records/daily_bar.ndjson new file mode 100644 index 0000000..ade64f1 --- /dev/null +++ b/fixtures/synthetic/ingest-bundle-v1/records/daily_bar.ndjson @@ -0,0 +1,11 @@ +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0001","date":"2026-08-03"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-03T15:00:00Z","revision":1,"payload":{"open":"10100","high":"10350","low":"9950","close":"10150","volume":"1000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0001","date":"2026-08-04"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-04T15:00:00Z","revision":1,"payload":{"open":"10200","high":"10450","low":"10050","close":"10250","volume":"2000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0001","date":"2026-08-05"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-05T15:00:00Z","revision":1,"payload":{"open":"10300","high":"10550","low":"10150","close":"10350","volume":"3000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0001","date":"2026-08-06"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-06T15:00:00Z","revision":1,"payload":{"open":"10400","high":"10650","low":"10250","close":"10450","volume":"4000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0001","date":"2026-08-07"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-07T15:00:00Z","revision":1,"payload":{"open":"10500","high":"10750","low":"10350","close":"10550","volume":"5000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0002","date":"2026-08-03"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-03T15:00:00Z","revision":1,"payload":{"open":"20100","high":"20350","low":"19950","close":"20150","volume":"2000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0002","date":"2026-08-04"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-04T15:00:00Z","revision":1,"payload":{"open":"20200","high":"20450","low":"20050","close":"20250","volume":"4000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0002","date":"2026-08-05"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-05T15:00:00Z","revision":1,"payload":{"open":"20300","high":"20550","low":"20150","close":"20350","volume":"6000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0002","date":"2026-08-06"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-06T15:00:00Z","revision":1,"payload":{"open":"20400","high":"20650","low":"20250","close":"20450","volume":"8000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0002","date":"2026-08-07"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-07T15:00:00Z","revision":1,"payload":{"open":"20500","high":"20750","low":"20350","close":"20550","volume":"10000"}} +{"entity":"daily_bar","key":{"market":"SYN","symbol":"SYN0001","date":"2026-08-05"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-05T15:00:00Z","revision":2,"payload":{"open":"10300","high":"10550","low":"10150","close":"10350","volume":"3007"}} diff --git a/fixtures/synthetic/ingest-bundle-v1/records/macro_observation.ndjson b/fixtures/synthetic/ingest-bundle-v1/records/macro_observation.ndjson new file mode 100644 index 0000000..fd07349 --- /dev/null +++ b/fixtures/synthetic/ingest-bundle-v1/records/macro_observation.ndjson @@ -0,0 +1,6 @@ +{"entity":"macro_observation","key":{"series_id":"SYN.POLICY.RATE.M","period":"2026-03"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-03-15T00:00:00Z","revision":1,"payload":{"value":"2.125","unit":"percent_per_annum"}} +{"entity":"macro_observation","key":{"series_id":"SYN.POLICY.RATE.M","period":"2026-04"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-04-15T00:00:00Z","revision":1,"payload":{"value":"2.250","unit":"percent_per_annum"}} +{"entity":"macro_observation","key":{"series_id":"SYN.POLICY.RATE.M","period":"2026-05"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-05-15T00:00:00Z","revision":1,"payload":{"value":"2.375","unit":"percent_per_annum"}} +{"entity":"macro_observation","key":{"series_id":"SYN.POLICY.RATE.M","period":"2026-06"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-06-15T00:00:00Z","revision":1,"payload":{"value":"2.500","unit":"percent_per_annum"}} +{"entity":"macro_observation","key":{"series_id":"SYN.POLICY.RATE.M","period":"2026-07"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-07-15T00:00:00Z","revision":1,"payload":{"value":"2.625","unit":"percent_per_annum"}} +{"entity":"macro_observation","key":{"series_id":"SYN.POLICY.RATE.M","period":"2026-08"},"observed_at":"2026-08-10T10:00:00Z","available_at":"2026-08-15T00:00:00Z","revision":1,"payload":{"value":"2.750","unit":"percent_per_annum"}} diff --git a/package.json b/package.json index 266fc8b..80fbc17 100644 --- a/package.json +++ b/package.json @@ -11,5 +11,15 @@ "url": "https://github.com/fxylabs/trdr/issues" }, "license": "AGPL-3.0-only", - "private": true + "private": true, + "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=22.12.0" + }, + "scripts": { + "build": "pnpm --recursive build", + "dev": "pnpm --filter @trdr/desktop dev", + "typecheck": "pnpm --recursive typecheck", + "test": "pnpm --recursive test" + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..496cd66 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,1406 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: {} + + apps/desktop: + dependencies: + '@tauri-apps/api': + specifier: ^2.11.1 + version: 2.11.1 + react: + specifier: ^19.2.8 + version: 19.2.8 + react-dom: + specifier: ^19.2.8 + version: 19.2.8(react@19.2.8) + react-router: + specifier: ^8.3.0 + version: 8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + devDependencies: + '@tauri-apps/cli': + specifier: ^2.11.4 + version: 2.11.4 + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.6.3 + version: 14.6.3(@testing-library/dom@10.4.1) + '@types/react': + specifier: ^19.2.18 + version: 19.2.18 + '@types/react-dom': + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^6.0.5 + version: 6.0.5(vite@8.2.1) + jsdom: + specifier: ^30.0.1 + version: 30.0.1 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vite: + specifier: ^8.2.1 + version: 8.2.1 + vitest: + specifier: ^4.1.10 + version: 4.1.10(jsdom@30.0.1)(vite@8.2.1) + +packages: + + '@asamuzakjp/css-color@6.0.7': + resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} + engines: {node: ^22.13.0 || >=24.0.0} + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@bramus/specificity@2.4.2': + resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==} + hasBin: true + + '@csstools/color-helpers@6.1.0': + resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} + engines: {node: '>=20.19.0'} + + '@csstools/css-calc@3.3.0': + resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-color-parser@4.1.10': + resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-parser-algorithms@4.0.0': + resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7': + resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==} + peerDependencies: + css-tree: ^3.2.1 + peerDependenciesMeta: + css-tree: + optional: true + + '@csstools/css-tokenizer@4.0.0': + resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} + engines: {node: '>=20.19.0'} + + '@exodus/bytes@1.15.1': + resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + peerDependencies: + '@noble/hashes': ^1.8.0 || ^2.0.0 + peerDependenciesMeta: + '@noble/hashes': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@oxc-project/types@0.143.0': + resolution: {integrity: sha512-u6JZdLBTLotrNC9Vd6vPssINdzcCzleKAH6EJKImQb7GtYvX5keN2dxkoK44stCc4tffE6QQRtZTXVSzsLUlWA==} + + '@rolldown/binding-android-arm64@1.2.3': + resolution: {integrity: sha512-zrJtHDcaZJ1Fp7xf4hNl+7seH9Cn/N5TwLYkhgXREtBwAd/jaqW3uqeHxpDugJLVICWg4eW44kOQEGJ1r6jCGw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.2.3': + resolution: {integrity: sha512-ieIiibVCp0tX7TLu2cafoNPv8wJyYi01ekXpbf8q2j7F4rGAhhXb/eQh7ge9DRBY78GwmRQtvjZDux7EDbA8kA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.2.3': + resolution: {integrity: sha512-Zh9tCon19eDXJoihx0rqKhMUlMYqzwj3aPsSuHmI4RWZh62dWUL+DJN4C5YQya5TcQBJU/Fe8+rY0jhXTQITqA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.2.3': + resolution: {integrity: sha512-nGbJWewA1wrXXZiQhjAT5rhibGfns5ZNkDVqxsO6zJ3f3YvpoDNNmGMSbbhLuXKjNScaBJVOAboztAWVespQMg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + resolution: {integrity: sha512-QNniJr5Kml0kDEB98jiDOJjXNroxIIi0IXIbdYzY26Xt1pVbeP62+KnoIZLwirOymX/0jDk/2gI/bNUv7A7OIw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + resolution: {integrity: sha512-TkqEAcmmvH3I/q4114NB4RVt6241Dao48pF45uLcFGrwAaIn0iITgTAKP/dLjbN0R4buJjGb91+UHSoFmpgIWw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-arm64-musl@1.2.3': + resolution: {integrity: sha512-NHqjnxpsndf4MPymxteFAWHHfkTL8HjWh1KB7z23ofZ6QO2euONuxDXjat69dKZRALnGypg8k8SsK8vZJoXv1Q==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + resolution: {integrity: sha512-6tbrbwfz5GB9DQ4Jwo6hy9v+vR31xZlvzZ6n5Xut6Hhx5PvrA9q/HsK8KMaYQp063iqZGXwNvZtYNLD7EM/x0w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + resolution: {integrity: sha512-oyuXxXmoZHjXC917IAPFAAv4wWAa0cM9afk8nx1+9/jNNOX1uPf8yDA6p7G0RypOfw/X0PQt5IfoquY1um+zSg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-gnu@1.2.3': + resolution: {integrity: sha512-TytMwF2KVGqP2tgd0I1OY0PAv78dZRAYcF5ssDzjM34SUXCED3uXvSd5+lHoC0bTD6eEdFz7LdQNCO1y0oVk9w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rolldown/binding-linux-x64-musl@1.2.3': + resolution: {integrity: sha512-/E9m3qstrJFVPoULV25mVQblSNExY2+kBsYe4sy0Tn0yOOgJ8wZbZt3KnRbF/XeU2Gl1STKUQnDNTqhIE5MD4A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rolldown/binding-openharmony-arm64@1.2.3': + resolution: {integrity: sha512-Kr0OcsoQI816i6HOl3vFHpd1K0eZyh76zgfj4c1nTyaTsd5r2Mj1lwM4R90y/qaCfmTn9eHy0SKwi98eitRxug==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + resolution: {integrity: sha512-hOtMwTqnME+/gJcH/PCZ0wn0zPUjiWOgkHpxbSJpfGKMezHltx1S7/k1SitzVa7Ww2cqrDDaFbZEhcJZO8o+Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.2.3': + resolution: {integrity: sha512-ekcqMMkI2PlhYnfzQnB/cEdYUVVJViWvoUyLrbzgDoi3Snfc1mVBwdnc306ufA5ejy8JSPjT2RlW1nQSjW7efg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tauri-apps/api@2.11.1': + resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + resolution: {integrity: sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tauri-apps/cli-darwin-x64@2.11.4': + resolution: {integrity: sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + resolution: {integrity: sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + resolution: {integrity: sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + resolution: {integrity: sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + resolution: {integrity: sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==} + engines: {node: '>= 10'} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + resolution: {integrity: sha512-2VRNWl84FOH0m2giiDkO2h0QXlcMJeX+zJDpI5kDIQAx6s+geF3v48F4DXfJez4GS/FdoDGnPnw1C2iYGbQ7bQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + resolution: {integrity: sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + resolution: {integrity: sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + resolution: {integrity: sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==} + engines: {node: '>= 10'} + cpu: [ia32] + os: [win32] + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + resolution: {integrity: sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tauri-apps/cli@2.11.4': + resolution: {integrity: sha512-R8xGtMpwyetawSqm9kYOuMmEqkhUbvcUy8n0aNXIxollKBLESUu5f4Fx+64hgASYm1H+jSWq6jCW6zqTnH6hqQ==} + engines: {node: '>= 10'} + hasBin: true + + '@testing-library/dom@10.4.1': + resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==} + engines: {node: '>=18'} + + '@testing-library/react@16.3.2': + resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 || ^19.0.0 + '@types/react-dom': ^18.0.0 || ^19.0.0 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@testing-library/user-event@14.6.3': + resolution: {integrity: sha512-6dBq67jT8lE+JTE8Exm02Kt6ze43hz1jdiSpSJwtTZiT1xQQ6b7nZYTTQ9njdArdU8XklOwaDp/AbT/eYSKF4g==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + + '@types/aria-query@5.0.4': + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} + peerDependencies: + '@types/react': ^19.2.0 + + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} + + '@vitejs/plugin-react@6.0.5': + resolution: {integrity: sha512-BOVzne/NL162sMdResB25mUv+vWMF5NoAjNf09TeGlE7ZpszZWSD3winycicLJw72yeVsoCn/2kOhEuCvEShMA==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + '@rolldown/plugin-babel': ^0.1.7 || ^0.2.0 + babel-plugin-react-compiler: ^1.0.0 + vite: ^8.0.0 + peerDependenciesMeta: + '@rolldown/plugin-babel': + optional: true + babel-plugin-react-compiler: + optional: true + + '@vitest/expect@4.1.10': + resolution: {integrity: sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==} + + '@vitest/mocker@4.1.10': + resolution: {integrity: sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.10': + resolution: {integrity: sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==} + + '@vitest/runner@4.1.10': + resolution: {integrity: sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==} + + '@vitest/snapshot@4.1.10': + resolution: {integrity: sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==} + + '@vitest/spy@4.1.10': + resolution: {integrity: sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==} + + '@vitest/utils@4.1.10': + resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + + aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + css-tree@3.2.1: + resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==} + engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + data-urls@7.0.0: + resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + + entities@8.0.0: + resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==} + engines: {node: '>=20.19.0'} + + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.4.0: + resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + html-encoding-sniffer@6.0.0: + resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} + engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} + peerDependencies: + canvas: ^3.2.3 + peerDependenciesMeta: + canvas: + optional: true + + lightningcss-android-arm64@1.33.0: + resolution: {integrity: sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.33.0: + resolution: {integrity: sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.33.0: + resolution: {integrity: sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.33.0: + resolution: {integrity: sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.33.0: + resolution: {integrity: sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.33.0: + resolution: {integrity: sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.33.0: + resolution: {integrity: sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.33.0: + resolution: {integrity: sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.33.0: + resolution: {integrity: sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.33.0: + resolution: {integrity: sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.33.0: + resolution: {integrity: sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.33.0: + resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==} + engines: {node: '>= 12.0.0'} + + lru-cache@11.5.2: + resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==} + engines: {node: 20 || >=22} + + lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + mdn-data@2.27.1: + resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} + engines: {node: '>=12.20.0'} + + parse5@8.0.1: + resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.5: + resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} + engines: {node: '>=12'} + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + + react-dom@19.2.8: + resolution: {integrity: sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ==} + peerDependencies: + react: ^19.2.8 + + react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + + react-router@8.3.0: + resolution: {integrity: sha512-qyPMvW83jGIct3yiieisxdk9M745anqhpIMKN5m1t6yBMfgVPpt77aHOqs5fUlEJRMCGffg9BaQLH9oPVOL7xQ==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + + react@19.2.8: + resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} + engines: {node: '>=0.10.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + rolldown@1.2.3: + resolution: {integrity: sha512-rn9wpmxplLf7NLNyCk9FyWh3FM43DbY8jOzCdEPzH7uflhTftRbCEpqi6Ly2osgoU8OwObtmavMbWLaWy4LX7A==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + + scheduler@0.27.0: + resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.2.0: + resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} + engines: {node: '>=18'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} + engines: {node: '>=14.0.0'} + + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} + + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} + hasBin: true + + tough-cookie@6.0.2: + resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==} + engines: {node: '>=16'} + + tr46@6.0.0: + resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==} + engines: {node: '>=20'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + + vite@8.2.1: + resolution: {integrity: sha512-EU/eS7BH3XROHh2YnBefjM6DBKA6ZeMZEYQbj7NLWg5wHYlhB8B/Mayd5XsgWq+NFYccDOTemRpdETWR6Ka/lw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.4.0 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.10: + resolution: {integrity: sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.10 + '@vitest/browser-preview': 4.1.10 + '@vitest/browser-webdriverio': 4.1.10 + '@vitest/coverage-istanbul': 4.1.10 + '@vitest/coverage-v8': 4.1.10 + '@vitest/ui': 4.1.10 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@8.0.1: + resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} + engines: {node: '>=20'} + + whatwg-mimetype@5.0.0: + resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==} + engines: {node: '>=20'} + + whatwg-url@16.0.1: + resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + + whatwg-url@17.1.0: + resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==} + engines: {node: ^22.14.0 || >=24.0.0} + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + +snapshots: + + '@asamuzakjp/css-color@6.0.7': + dependencies: + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + lru-cache: 11.5.2 + + '@asamuzakjp/dom-selector@8.3.2': + dependencies: + bidi-js: 1.0.3 + css-tree: 3.2.1 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/runtime@7.29.7': {} + + '@bramus/specificity@2.4.2': + dependencies: + css-tree: 3.2.1 + + '@csstools/color-helpers@6.1.0': {} + + '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/color-helpers': 6.1.0 + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)': + optionalDependencies: + css-tree: 3.2.1 + + '@csstools/css-tokenizer@4.0.0': {} + + '@exodus/bytes@1.15.1': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@oxc-project/types@0.143.0': {} + + '@rolldown/binding-android-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-arm64@1.2.3': + optional: true + + '@rolldown/binding-darwin-x64@1.2.3': + optional: true + + '@rolldown/binding-freebsd-x64@1.2.3': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.2.3': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.2.3': + optional: true + + '@rolldown/binding-linux-x64-musl@1.2.3': + optional: true + + '@rolldown/binding-openharmony-arm64@1.2.3': + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.2.3': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.2.3': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tauri-apps/api@2.11.1': {} + + '@tauri-apps/cli-darwin-arm64@2.11.4': + optional: true + + '@tauri-apps/cli-darwin-x64@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm-gnueabihf@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-arm64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-linux-riscv64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-gnu@2.11.4': + optional: true + + '@tauri-apps/cli-linux-x64-musl@2.11.4': + optional: true + + '@tauri-apps/cli-win32-arm64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-ia32-msvc@2.11.4': + optional: true + + '@tauri-apps/cli-win32-x64-msvc@2.11.4': + optional: true + + '@tauri-apps/cli@2.11.4': + optionalDependencies: + '@tauri-apps/cli-darwin-arm64': 2.11.4 + '@tauri-apps/cli-darwin-x64': 2.11.4 + '@tauri-apps/cli-linux-arm-gnueabihf': 2.11.4 + '@tauri-apps/cli-linux-arm64-gnu': 2.11.4 + '@tauri-apps/cli-linux-arm64-musl': 2.11.4 + '@tauri-apps/cli-linux-riscv64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-gnu': 2.11.4 + '@tauri-apps/cli-linux-x64-musl': 2.11.4 + '@tauri-apps/cli-win32-arm64-msvc': 2.11.4 + '@tauri-apps/cli-win32-ia32-msvc': 2.11.4 + '@tauri-apps/cli-win32-x64-msvc': 2.11.4 + + '@testing-library/dom@10.4.1': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/runtime': 7.29.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + picocolors: 1.1.1 + pretty-format: 27.5.1 + + '@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.1 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) + + '@testing-library/user-event@14.6.3(@testing-library/dom@10.4.1)': + dependencies: + '@testing-library/dom': 10.4.1 + + '@types/aria-query@5.0.4': {} + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/react-dom@19.2.4(@types/react@19.2.18)': + dependencies: + '@types/react': 19.2.18 + + '@types/react@19.2.18': + dependencies: + csstype: 3.2.3 + + '@vitejs/plugin-react@6.0.5(vite@8.2.1)': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.2.1 + + '@vitest/expect@4.1.10': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + chai: 6.2.2 + tinyrainbow: 3.1.1 + + '@vitest/mocker@4.1.10(vite@8.2.1)': + dependencies: + '@vitest/spy': 4.1.10 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.2.1 + + '@vitest/pretty-format@4.1.10': + dependencies: + tinyrainbow: 3.1.1 + + '@vitest/runner@4.1.10': + dependencies: + '@vitest/utils': 4.1.10 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + '@vitest/utils': 4.1.10 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.10': {} + + '@vitest/utils@4.1.10': + dependencies: + '@vitest/pretty-format': 4.1.10 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.1 + + ansi-regex@5.0.1: {} + + ansi-styles@5.2.0: {} + + aria-query@5.3.0: + dependencies: + dequal: 2.0.3 + + assertion-error@2.0.1: {} + + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + + chai@6.2.2: {} + + convert-source-map@2.0.0: {} + + cookie-es@3.1.1: {} + + css-tree@3.2.1: + dependencies: + mdn-data: 2.27.1 + source-map-js: 1.2.1 + + csstype@3.2.3: {} + + data-urls@7.0.0: + dependencies: + whatwg-mimetype: 5.0.0 + whatwg-url: 16.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + decimal.js@10.6.0: {} + + dequal@2.0.3: {} + + detect-libc@2.1.2: {} + + dom-accessibility-api@0.5.16: {} + + entities@8.0.0: {} + + es-module-lexer@2.3.1: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.4.0: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + fsevents@2.3.3: + optional: true + + html-encoding-sniffer@6.0.0: + dependencies: + '@exodus/bytes': 1.15.1 + transitivePeerDependencies: + - '@noble/hashes' + + is-potential-custom-element-name@1.0.1: {} + + js-tokens@4.0.0: {} + + jsdom@30.0.1: + dependencies: + '@asamuzakjp/css-color': 6.0.7 + '@asamuzakjp/dom-selector': 8.3.2 + '@bramus/specificity': 2.4.2 + '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) + '@exodus/bytes': 1.15.1 + css-tree: 3.2.1 + data-urls: 7.0.0 + decimal.js: 10.6.0 + html-encoding-sniffer: 6.0.0 + is-potential-custom-element-name: 1.0.1 + lru-cache: 11.5.2 + parse5: 8.0.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 6.0.2 + undici: 8.10.0 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 8.0.1 + whatwg-mimetype: 5.0.0 + whatwg-url: 17.1.0 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - '@noble/hashes' + + lightningcss-android-arm64@1.33.0: + optional: true + + lightningcss-darwin-arm64@1.33.0: + optional: true + + lightningcss-darwin-x64@1.33.0: + optional: true + + lightningcss-freebsd-x64@1.33.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.33.0: + optional: true + + lightningcss-linux-arm64-gnu@1.33.0: + optional: true + + lightningcss-linux-arm64-musl@1.33.0: + optional: true + + lightningcss-linux-x64-gnu@1.33.0: + optional: true + + lightningcss-linux-x64-musl@1.33.0: + optional: true + + lightningcss-win32-arm64-msvc@1.33.0: + optional: true + + lightningcss-win32-x64-msvc@1.33.0: + optional: true + + lightningcss@1.33.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.33.0 + lightningcss-darwin-arm64: 1.33.0 + lightningcss-darwin-x64: 1.33.0 + lightningcss-freebsd-x64: 1.33.0 + lightningcss-linux-arm-gnueabihf: 1.33.0 + lightningcss-linux-arm64-gnu: 1.33.0 + lightningcss-linux-arm64-musl: 1.33.0 + lightningcss-linux-x64-gnu: 1.33.0 + lightningcss-linux-x64-musl: 1.33.0 + lightningcss-win32-arm64-msvc: 1.33.0 + lightningcss-win32-x64-msvc: 1.33.0 + + lru-cache@11.5.2: {} + + lz-string@1.5.0: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + mdn-data@2.27.1: {} + + nanoid@3.3.18: {} + + obug@2.1.4: {} + + parse5@8.0.1: + dependencies: + entities: 8.0.0 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + pretty-format@27.5.1: + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + + punycode@2.3.1: {} + + react-dom@19.2.8(react@19.2.8): + dependencies: + react: 19.2.8 + scheduler: 0.27.0 + + react-is@17.0.2: {} + + react-router@8.3.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie-es: 3.1.1 + react: 19.2.8 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react@19.2.8: {} + + require-from-string@2.0.2: {} + + rolldown@1.2.3: + dependencies: + '@oxc-project/types': 0.143.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.2.3 + '@rolldown/binding-darwin-arm64': 1.2.3 + '@rolldown/binding-darwin-x64': 1.2.3 + '@rolldown/binding-freebsd-x64': 1.2.3 + '@rolldown/binding-linux-arm-gnueabihf': 1.2.3 + '@rolldown/binding-linux-arm64-gnu': 1.2.3 + '@rolldown/binding-linux-arm64-musl': 1.2.3 + '@rolldown/binding-linux-ppc64-gnu': 1.2.3 + '@rolldown/binding-linux-s390x-gnu': 1.2.3 + '@rolldown/binding-linux-x64-gnu': 1.2.3 + '@rolldown/binding-linux-x64-musl': 1.2.3 + '@rolldown/binding-openharmony-arm64': 1.2.3 + '@rolldown/binding-win32-arm64-msvc': 1.2.3 + '@rolldown/binding-win32-x64-msvc': 1.2.3 + + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + + scheduler@0.27.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.2.0: {} + + symbol-tree@3.2.4: {} + + tinybench@2.9.0: {} + + tinyexec@1.3.0: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinyrainbow@3.1.1: {} + + tldts-core@7.4.10: {} + + tldts@7.4.10: + dependencies: + tldts-core: 7.4.10 + + tough-cookie@6.0.2: + dependencies: + tldts: 7.4.10 + + tr46@6.0.0: + dependencies: + punycode: 2.3.1 + + typescript@5.9.3: {} + + undici@8.10.0: {} + + vite@8.2.1: + dependencies: + lightningcss: 1.33.0 + picomatch: 4.0.5 + postcss: 8.5.26 + rolldown: 1.2.3 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + + vitest@4.1.10(jsdom@30.0.1)(vite@8.2.1): + dependencies: + '@vitest/expect': 4.1.10 + '@vitest/mocker': 4.1.10(vite@8.2.1) + '@vitest/pretty-format': 4.1.10 + '@vitest/runner': 4.1.10 + '@vitest/snapshot': 4.1.10 + '@vitest/spy': 4.1.10 + '@vitest/utils': 4.1.10 + es-module-lexer: 2.3.1 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1 + why-is-node-running: 2.3.0 + optionalDependencies: + jsdom: 30.0.1 + transitivePeerDependencies: + - msw + + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@8.0.1: {} + + whatwg-mimetype@5.0.0: {} + + whatwg-url@16.0.1: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + whatwg-url@17.1.0: + dependencies: + '@exodus/bytes': 1.15.1 + tr46: 6.0.0 + webidl-conversions: 8.0.1 + transitivePeerDependencies: + - '@noble/hashes' + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..33b7655 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,12 @@ +# The JavaScript half of the repository. `apps/*` holds things that get built +# and shipped; `packages/*` holds what they share — the UI kit lands there in a +# later track, which is why the glob is here before the directory is. +packages: + - "apps/*" + - "packages/*" + +# pnpm 10 does not run a dependency's install scripts unless it is named here. +# esbuild is the one that needs to: Vite calls its binary, and the script is what +# puts that binary where it can be found. +onlyBuiltDependencies: + - esbuild diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..6224ff4 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,8 @@ +# trdr writes Rust with the opening brace of every block on its own line, the +# style the repository already used before the workspace existed. rustfmt only +# honours `brace_style` on the nightly channel, so formatting runs as +# `cargo +nightly fmt`. Nothing else about the build needs nightly: clippy, +# tests, and release builds all run on stable. +brace_style = "AlwaysNextLine" +trailing_comma = "Never" +control_brace_style = "AlwaysNextLine"