diff --git a/.env.example b/.env.example deleted file mode 100644 index 0812b70..0000000 --- a/.env.example +++ /dev/null @@ -1,15 +0,0 @@ -# There is no Redrob console key here: the shared Redrob Code daemon holds it and -# desktop AI reaches the console only through that daemon (see README, "Redrob AI -# configuration"). REDROB_RUN_DIR and REDROB_CODE_BIN point tests at a private daemon. - -# Three live connector tests are #[ignore] by default. Cargo does not load this -# file automatically: export disposable URLs in the shell, then run tests with -# `cargo test --locked -p redrob-core --all-features -- --ignored`. -# An explicitly run gate fails when its required URL is absent. -REDROB_TEST_POSTGRES_URL= -REDROB_TEST_MYSQL_URL= - -# The Mongo gate creates and drops only one UUID-named collection. Its database -# defaults to redrob_test when REDROB_TEST_MONGO_DATABASE is unset. -REDROB_TEST_MONGO_URL= -REDROB_TEST_MONGO_DATABASE=redrob_test diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 38f1022..d493011 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -13,18 +13,18 @@ ``` -npm run release:check -npm test -npm run build -npm run rust:fmt && npm run rust:clippy && npm run rust:test +python3 scripts/check-brand.py +cargo fmt --all -- --check +cargo clippy -p redrob-query -p redrob-query-core --all-targets -- -D warnings +cargo test -p redrob-query-core ``` ## Checklist - [ ] No credentials, connection strings, or real database contents in the diff, the tests, or the commit messages. -- [ ] Read-only guarantees preserved: no new desktop/native mutation command is - registered, and saved desktop profiles stay read-only. +- [ ] Read-only guarantees preserved: typed queries still go through the engine's + read-only gate, and writes only through reviewed edits and the structure forms. - [ ] If local-storage behaviour changed, `docs/SECURITY.md` says what is now stored. - [ ] If user-visible strings changed, they name Redrob Query — not DBeaver. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b797290..415068f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,159 +1,78 @@ +# The one pull-request workflow. `checks` runs the repository scripts: the brand-asset pins. `rust` +# runs formatting, clippy and the tests of OUR crates (the shell `redrob-query` and the engine +# `redrob-query-core`) with `--profile ci` (root Cargo.toml: every dependency at opt-level 0, no +# incremental), and is skipped when no Rust input changed or when the pull request targets main (that +# code already passed here on its way into develop). The release workflow does not repeat any of this; +# PR policy is the org-wide workflow. Adapted from redrob-canvas. name: CI -# Plain pull_request (never pull_request_target) and no secrets are consumed, -# so a pull request from a fork receives no credential of any kind. on: pull_request: - push: - branches: [main, develop] workflow_dispatch: permissions: contents: read concurrency: - group: ci-${{ github.event.pull_request.number || github.ref }} + group: ci-${{ github.ref }} cancel-in-progress: true +env: + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + RUST_BACKTRACE: 1 + OUR_CRATES: -p redrob-query -p redrob-query-core + jobs: - frontend: - name: Release metadata, types, tests, build - runs-on: ubuntu-24.04 - timeout-minutes: 20 + checks: + name: brand assets + runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: npm - - - name: Install locked JavaScript dependencies - run: npm ci - - # Asserts productName, identifier, licence and the version fan-out across - # package.json, Cargo.toml, tauri.conf.json and package-lock.json. - - name: Validate release metadata - run: npm run release:check - - # This job runs the checks individually rather than `npm run check`, so a guard wired only into - # that script would never run where the change is reviewed. Fails on a raw colour literal, a - # lucide import in source or in the manifest, a brand file whose hash moved, and a font-family - # that is not a system token. - - name: Check the design system - run: npm run check:design - - - name: Typecheck - run: npm run typecheck - - - name: Test - run: npm test - - - name: Build - run: npm run build + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.1 + - name: Brand assets match the design system pins + run: python3 scripts/check-brand.py rust: - name: Format, clippy, tests - runs-on: ubuntu-24.04 - timeout-minutes: 30 + name: fmt, clippy, test (profile ci) + # A release PR into main carries code that already passed here on its way into develop. + if: github.base_ref != 'main' + runs-on: ubuntu-latest + timeout-minutes: 90 steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # The Tauri shell links WebKitGTK; the redrob-core crate deliberately does not. - - name: Install Linux desktop dependencies - run: >- - sudo apt-get update && sudo apt-get install -y - libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev - libsoup-3.0-dev librsvg2-dev patchelf libssl-dev - - # Channel and components must match rust-toolchain.toml, which - # scripts/validate-release.mjs pins against Cargo rust-version. - - name: Set up Rust - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.1 + - name: Did any Rust input change? + id: rs + run: | + git fetch -q origin "${{ github.base_ref }}" --depth=1 + if git diff --quiet "origin/${{ github.base_ref }}"...HEAD -- '*.rs' '*.toml' Cargo.lock '*.json' .github/workflows/ci.yml; then + echo changed=false >> "$GITHUB_OUTPUT"; else echo changed=true >> "$GITHUB_OUTPUT"; fi + - name: System libraries for the desktop app + if: steps.rs.outputs.changed == 'true' + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ + libgl1-mesa-dev libgtk-3-dev libdbus-1-dev + - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + if: steps.rs.outputs.changed == 'true' with: - toolchain: 1.94.0 + toolchain: "1.95.0" components: rustfmt, clippy - - - name: Format - run: npm run rust:fmt - - - name: Clippy - run: npm run rust:clippy - - - name: Test - run: npm run rust:test - - # The live PostgreSQL and MySQL tests in redrob-core are #[ignore]d because they need a real - # server, and until this job nothing ever ran them: a write path checked only against SQLite and a - # hand-copied query is the gap the plan calls out. Service containers give each run fresh servers; - # only the core crate is built, so the WebKitGTK packages the shell needs are not installed here. - rust-live-databases: - name: Live PostgreSQL and MySQL tests - runs-on: ubuntu-24.04 - timeout-minutes: 30 - services: - postgres: - image: postgres:16.4 - env: - POSTGRES_PASSWORD: redrob - POSTGRES_DB: redrob - ports: ["5432:5432"] - options: >- - --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 20 - mysql: - image: mysql:8.4.2 - env: - MYSQL_ROOT_PASSWORD: redrob - MYSQL_DATABASE: redrob - ports: ["3306:3306"] - options: >- - --health-cmd "mysqladmin ping -h 127.0.0.1 -predrob" --health-interval 5s --health-timeout 5s --health-retries 30 - env: - # Throwaway credentials for the per-run service containers above; nothing else accepts them. - REDROB_TEST_POSTGRES_URL: postgres://postgres:redrob@127.0.0.1:5432/redrob - REDROB_TEST_MYSQL_URL: mysql://root:redrob@127.0.0.1:3306/redrob - steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Rust - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable + - uses: Swatinem/rust-cache@v2 + if: steps.rs.outputs.changed == 'true' with: - toolchain: 1.94.0 - - - name: Live database tests - run: cargo test --locked -p redrob-core -- --ignored --exact --nocapture - connection::tests::live_postgres_native_scalars_when_configured - connection::tests::live_mysql_native_scalars_when_configured - connection::tests::live_postgres_cell_edits_commit_together_or_not_at_all - connection::tests::live_mysql_cell_edits_commit_together_or_not_at_all - connection::tests::live_postgres_table_changes_create_and_extend_a_table - connection::tests::live_mysql_table_changes_create_and_extend_a_table - - # This job exists because its absence cost a failed release. The rustls advisory - # RUSTSEC-2026-0285 sat in Cargo.lock through every green pull request, because the - # only place cargo audit ran was the release workflow -- so the first thing that - # noticed was a tag. A dependency advisory should fail the pull request that could - # have fixed it, not the release that cannot. - rust-advisories: - name: Rust security advisories - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Set up Rust for security audit - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - with: - toolchain: stable - - - name: Install pinned cargo-audit - run: cargo +stable install cargo-audit --version 0.22.2 --locked - - # The Cargo workspace is at the repository root, not under src-tauri. - - name: Audit locked Rust dependencies - run: cargo +stable audit --file Cargo.lock + key: ci-profile + - name: Formatting + if: steps.rs.outputs.changed == 'true' + run: cargo fmt --all -- --check + - name: Clippy + if: steps.rs.outputs.changed == 'true' + run: | + # shellcheck disable=SC2086 # OUR_CRATES is a list of flags on purpose + cargo clippy $OUR_CRATES --all-targets --locked --profile ci -- -D warnings + - name: Tests + if: steps.rs.outputs.changed == 'true' + run: | + # shellcheck disable=SC2086 + cargo test $OUR_CRATES --locked --profile ci diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 3a52134..0000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,94 +0,0 @@ -name: E2E - -# The desktop app, driven the way a user drives it: add a SQLite connection, query a table, edit a -# cell, apply, run again. Unit and live-server tests run the core; this is the only check that the -# window, the bridge and the core write a change together -- and the last step reads the change -# back from the database file itself, not from the screen. - -on: - pull_request: - push: - branches: [develop, main] - -permissions: - contents: read - -jobs: - desktop: - name: Desktop edit and apply over WebDriver - runs-on: ubuntu-24.04 - timeout-minutes: 45 - steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - - name: Install Linux desktop and WebDriver dependencies - run: >- - sudo apt-get update && sudo apt-get install -y - libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev librsvg2-dev - patchelf libssl-dev webkit2gtk-driver xvfb sqlite3 - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: npm - - - name: Set up Rust - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - with: - toolchain: 1.94.0 - - - name: Install pinned tauri-driver - run: cargo install tauri-driver --version 2.1.0 --locked - - - name: Install locked JavaScript dependencies - run: npm ci - - - name: Build the debug app - run: npx tauri build --debug --no-bundle - - - name: Seed the database - run: sqlite3 "$RUNNER_TEMP/people.db" < e2e/seed.sql - - - name: Drive the desktop app - env: - APP: ${{ github.workspace }}/target/debug/redrob-query - REDROB_E2E_DB: ${{ runner.temp }}/people.db - SHOTS: ${{ github.workspace }}/e2e/screenshots - # A home of its own, so saved connections and the keyring fallback start empty. - HOME: ${{ runner.temp }}/e2e-home - # Software rendering on a virtual display; the DMA-BUF path needs a GPU the runner lacks. - WEBKIT_DISABLE_DMABUF_RENDERER: "1" - run: | - mkdir -p "$HOME" - xvfb-run --auto-servernum --server-args="-screen 0 1280x800x24" bash -c ' - tauri-driver --port 4444 > e2e/tauri-driver.log 2>&1 & - driver=$! - for _ in $(seq 1 50); do curl -fs http://127.0.0.1:4444/status >/dev/null && break; sleep 0.2; done - node e2e/run.mjs - status=$? - kill $driver - exit $status - ' - - - name: The changes are in the database file - run: | - name=$(sqlite3 "$RUNNER_TEMP/people.db" "SELECT name FROM people WHERE id = 1") - added=$(sqlite3 "$RUNNER_TEMP/people.db" "SELECT id || ':' || name || ':' || plan FROM people WHERE id <> 1") - rows=$(sqlite3 "$RUNNER_TEMP/people.db" "SELECT count(*) FROM people") - echo "row 1: $name / added: $added / rows: $rows" - test "$name" = "Ann Lee-Kim" && test "$rows" = "2" && test "$added" = "2:Cy:Free" - pets=$(sqlite3 "$RUNNER_TEMP/people.db" "SELECT group_concat(name || ' ' || type || ' ' || pk, ', ') FROM pragma_table_info('pets')") - echo "pets: $pets" - test "$pets" = "id INTEGER 1, name TEXT 0, born DATE 0" - - - name: Keep the screenshots and driver log - if: always() - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 - with: - name: e2e-desktop - path: | - e2e/screenshots - e2e/tauri-driver.log - if-no-files-found: warn diff --git a/.github/workflows/gitflow.yml b/.github/workflows/gitflow.yml deleted file mode 100644 index 6330bd0..0000000 --- a/.github/workflows/gitflow.yml +++ /dev/null @@ -1,116 +0,0 @@ -name: gitflow - -# Two rules this repository already had in prose, now checked. -# -# A rule nothing checks is a preference. Both of these were written down and both were broken anyway: -# branches appeared under a `kiro/` prefix that no document defines, and a hotfix landed on `main` whose -# back-merge into `develop` was never opened, leaving the DEFAULT branch unable to install for a month -# with nothing anywhere reporting it. -# -# Deliberately two jobs on two different triggers, because they answer different questions at different -# moments. A branch name can only be judged when a pull request exists. A missing back-merge can only be -# judged after `main` has moved. - -on: - pull_request: - # No branch filter. A filter that omits the default branch silently stops running, which is exactly - # what happened in a sibling repository when the default moved to `develop` and three workflows - # kept filtering on `[main, master]`. - types: [opened, reopened, edited, synchronize] - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -jobs: - branch-name: - name: branch name follows the convention - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - steps: - - name: Check the head branch name - env: - HEAD_REF: ${{ github.head_ref }} - BASE_REF: ${{ github.base_ref }} - run: | - set -euo pipefail - - # ALLOWED_TYPES is this repository's own list. Keep it in step with the branch table in the - # contributing or branching document; the document is the explanation, this is the gate. - ALLOWED_TYPES='feat fix chore docs test refactor perf release hotfix' - - # `develop` and `main` reach here through a promotion or a back-merge pull request, which are - # branches in their own right and are not named after a type. - case "$HEAD_REF" in - develop|main) echo "$HEAD_REF is a long-lived branch, not a working branch"; exit 0 ;; - esac - - type="${HEAD_REF%%/*}" - slug="${HEAD_REF#*/}" - - if [ "$type" = "$HEAD_REF" ]; then - echo "::error::branch '$HEAD_REF' has no type prefix. Use /, type one of: $ALLOWED_TYPES" - exit 1 - fi - - ok=false - for t in $ALLOWED_TYPES; do - [ "$type" = "$t" ] && ok=true && break - done - - if [ "$ok" != true ]; then - { - echo "### Branch name is not one of this repository's types" - echo - echo "\`$HEAD_REF\` uses the prefix \`$type\`. Allowed: $ALLOWED_TYPES" - echo - echo "A branch name says what the change is, not who or what made it. An agent or tool name" - echo "is not a type: rename the branch and reopen the pull request." - } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - echo "::error::branch prefix '$type' is not allowed. Use one of: $ALLOWED_TYPES" - exit 1 - fi - - if [ -z "$slug" ] || [ "$slug" = "$type" ]; then - echo "::error::branch '$HEAD_REF' has an empty slug. Use /." - exit 1 - fi - - echo "$HEAD_REF is a valid $type branch targeting $BASE_REF" - - back-merged: - name: main is contained in develop - if: github.event_name != 'pull_request' - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - # Full history: comparing two branches needs the commits, and a shallow clone answers with - # whatever it happens to have, which is worse than no answer. - fetch-depth: 0 - - - name: Compare main against develop - run: | - set -euo pipefail - if ! git rev-parse -q --verify origin/develop >/dev/null; then - echo "::notice::no develop branch; nothing to back-merge into" - exit 0 - fi - missing="$(git rev-list --count origin/develop..origin/main)" - if [ "$missing" -eq 0 ]; then - echo "main is contained in develop" - exit 0 - fi - { - echo "### \`main\` has $missing commit(s) \`develop\` does not" - echo - echo "Merge \`main\` back into \`develop\`. Until that lands, a branch cut from the default" - echo "branch is missing whatever these changed." - echo - git log --format='- `%h` %s' origin/develop..origin/main - } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" - git log --oneline origin/develop..origin/main - echo "::error::main is $missing commit(s) ahead of develop; back-merge it" - exit 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b364afb..3092a38 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,172 +1,229 @@ -name: Signed desktop release - -# Only a tag builds. There used to be a `release: [published]` trigger here for the CDN -# promotion job; with GitHub Releases as the channel itself, publishing the draft IS the -# promotion, so a second workflow run on that event would have nothing to do. +name: Release + +# The ONE release path: every platform is built on GitHub runners from the single cargo binary +# `redrob-query` (apps/redrob-query), Windows is signed through Azure Artifact Signing with the +# job's OpenID Connect login, macOS is signed and notarized with the organization's Developer ID, +# and the shared redrob-labs publish job creates the GitHub Release. +# +# gh workflow run release.yml --ref main -f tag=vX.Y.Z # the release: PUBLISHED as latest +# gh workflow run release.yml --ref -f dry_run=true # builds and signs; publishes nothing +# +# The parts every redrob-labs repository shares are not in this file; they are called: +# - redrob-labs/.github/actions/sign-windows Authenticode through Azure Artifact Signing +# - redrob-labs/.github/.github/workflows/release-publish.yml checksums, launcher manifest, release +# +# Rules this file encodes: +# - Windows: build, THEN `azure/login` (OIDC), THEN sign -- the login token is short-lived and the +# build is long; there is no certificate file and no client secret anywhere. The executable is +# signed BEFORE it is zipped and before the setup embeds that zip; the uninstaller and the setup +# are signed in their turn. `release` is the environment the organization's federated credential +# names; any other name is refused. +# - validate is the repository script plus `cargo fmt --check`; clippy and the tests ran on the +# pull request (.github/workflows/ci.yml) and are not repeated here; +# - the publish job runs only for a real tag, and only when every job before it succeeded; +# - the release is published as latest unless the version carries a pre-release suffix. +# +# There is no Node, no Tauri and no WebKitGTK here any more: 0.3.0 replaced the Tauri + React shell with an +# egui shell, so a leg is `cargo build --release -p redrob-query --target ` and a packaging +# script. The Linux leg needs the X11/Wayland/GL development headers egui, wgpu and rfd link against. +# +# Exactly THREE targets (tools/release_matrix.py is the list): +# macOS arm64 redrob-query--macos-arm64.zip + .dmg (Developer ID, notarized, stapled) +# Linux x86_64 redrob-query--linux-x86_64.tar.gz + .deb +# Windows x64 redrob-query--windows-x64-portable.zip + -setup.exe (Azure Artifact Signing) on: - push: - tags: ["v*.*.*"] + workflow_dispatch: + inputs: + tag: + description: "Release tag, vMAJOR.MINOR.PATCH, already pushed and on main (empty for a dry run)" + required: false + type: string + default: "" + dry_run: + description: "Dry run: build and sign the dispatched ref, publish nothing" + required: false + type: boolean + default: false permissions: contents: read concurrency: - group: release-${{ github.ref }} + group: release-${{ inputs.tag != '' && inputs.tag || github.ref }} cancel-in-progress: false +env: + PRODUCT: Redrob Query + PRODUCT_URL: https://github.com/${{ github.repository }} + CARGO_TERM_COLOR: always + CARGO_INCREMENTAL: 0 + jobs: validate: name: Validate release tag - if: github.event_name == 'push' runs-on: ubuntu-24.04 - timeout-minutes: 45 + timeout-minutes: 60 outputs: - # The build matrix and the updater platform keys the draft must carry, both derived from - # the single list in scripts/release-matrix.mjs. + # The version without the leading v, the commit every later job builds, and whether this is a + # dry run. For a tag the commit is the tag's; for a dry run it is the dispatched ref's. + version: ${{ steps.version.outputs.version }} + sha: ${{ steps.version.outputs.sha }} + dry_run: ${{ steps.resolve.outputs.dry_run }} + # The build matrix, the platform assets the release must carry, and the platform list the + # release notes promise -- all from the single list in tools/release_matrix.py. matrix: ${{ steps.release-matrix.outputs.matrix }} - platforms: ${{ steps.release-matrix.outputs.platforms }} - asset_suffixes: ${{ steps.release-matrix.outputs.asset_suffixes }} + archive_suffixes: ${{ steps.release-matrix.outputs.archive_suffixes }} + installer_suffixes: ${{ steps.release-matrix.outputs.installer_suffixes }} + notes_platforms: ${{ steps.release-matrix.outputs.notes_platforms }} windows_signing_ready: ${{ steps.release-matrix.outputs.windows_signing_ready }} steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - fetch-depth: 0 - - name: Require release tag commit on main - shell: bash + # A dry run builds whatever was dispatched; a release builds the tag it names. Resolved + # before the checkout so the checkout can take the result. + - name: Resolve what to build + id: resolve + env: + INPUT_TAG: ${{ inputs.tag }} + INPUT_DRY_RUN: ${{ inputs.dry_run }} run: | - git fetch --no-tags origin main:refs/remotes/origin/main - if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/main; then - echo "::error::Release tag commit must be reachable from origin/main" + set -euo pipefail + if [ "$INPUT_DRY_RUN" = "true" ]; then + echo "dry_run=true" >> "$GITHUB_OUTPUT" + echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT" + echo "::notice::dry run of $GITHUB_REF_NAME ($GITHUB_SHA): builds and signs, publishes nothing" + elif [ -n "$INPUT_TAG" ]; then + [[ "$INPUT_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] \ + || { echo "::error::not a release tag: $INPUT_TAG"; exit 1; } + echo "dry_run=false" >> "$GITHUB_OUTPUT" + echo "ref=refs/tags/$INPUT_TAG" >> "$GITHUB_OUTPUT" + else + echo "::error::give a tag (the release) or set dry_run (a rehearsal); neither was given" exit 1 fi - - name: Install Linux desktop dependencies - run: >- - sudo apt-get update && sudo apt-get install -y - libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev librsvg2-dev - patchelf libssl-dev - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.1 with: - node-version: 22 - cache: npm + ref: ${{ steps.resolve.outputs.ref }} + fetch-depth: 0 + # Decides which platforms this release builds. Windows is omitted, with a notice in the # run log, until the repository variable WINDOWS_SIGNING_READY is "true" -- see the - # script for why an unsigned Windows installer is not the fallback. + # script for why an unsigned Windows executable is not the fallback. - name: Resolve the release matrix id: release-matrix env: WINDOWS_SIGNING_READY: ${{ vars.WINDOWS_SIGNING_READY }} - run: node scripts/release-matrix.mjs - - name: Set up Rust - uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable - with: - toolchain: 1.94.0 - components: rustfmt, clippy - - name: Install locked dependencies - run: npm ci - - name: Run complete verification - run: npm run check - - rust-advisories: - name: Rust security advisories - if: github.event_name == 'push' - runs-on: ubuntu-24.04 - timeout-minutes: 15 - steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Set up Rust for security audit + run: python3 tools/release_matrix.py + + # The packaging step derives the version from the TAG, while the binary reports the one in + # Cargo.toml. Nothing made the two agree, so a tag could ship archives named one version + # holding a binary that calls itself another. Checked here, before anything is built. + # A dry run has no tag: it takes the crate version as it is and skips the tag checks. + - name: Require the tag on main, matching the workspace version and the changelog + id: version + env: + RELEASE_TAG: ${{ inputs.tag }} + DRY_RUN: ${{ steps.resolve.outputs.dry_run }} + shell: bash + run: | + set -euo pipefail + crate_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n1)" + test -n "$crate_version" + echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + if [ "$DRY_RUN" = "true" ]; then + echo "version=${crate_version}" >> "$GITHUB_OUTPUT" + echo "dry run: building Cargo.toml version ${crate_version} at $(git rev-parse --short HEAD)" + exit 0 + fi + git fetch --no-tags origin main:refs/remotes/origin/main + if ! git merge-base --is-ancestor HEAD origin/main; then + echo "::error::Release tag commit must be reachable from origin/main" + exit 1 + fi + tag_version="${RELEASE_TAG#v}" + if [ "$tag_version" != "$crate_version" ]; then + echo "::error::Tag v${tag_version} does not match Cargo.toml version ${crate_version}" + exit 1 + fi + # The release notes say "See CHANGELOG", so a release whose version has no entry there + # ships notes that describe nothing. + if ! grep -q "^## ${tag_version} " CHANGELOG.md; then + echo "::error::CHANGELOG.md has no '## ${tag_version}' section" + exit 1 + fi + if grep -q "^## ${tag_version} - unreleased" CHANGELOG.md; then + echo "::error::CHANGELOG.md still marks ${tag_version} as unreleased" + exit 1 + fi + echo "version=${tag_version}" >> "$GITHUB_OUTPUT" + + # The repository script, as the CI `checks` job runs it: the brand-asset pins. + - name: Brand assets match the design system pins + run: python3 scripts/check-brand.py + + - name: Rust toolchain (rust-toolchain.toml) uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: - toolchain: stable - - name: Install pinned cargo-audit - run: cargo +stable install cargo-audit --version 0.22.2 --locked - # This repository's Cargo workspace is at the root, not under src-tauri. - - name: Audit locked Rust dependencies - run: cargo +stable audit --file Cargo.lock + toolchain: "1.95.0" + components: rustfmt + + - name: Formatting + run: | + # clippy and the tests already gated the commit on its pull request (ci.yml); a release + # re-running them cost 14 minutes per dispatch for no new information. + cargo fmt --all -- --check build: name: Build ${{ matrix.label }} - if: github.event_name == 'push' - needs: [validate, rust-advisories] + needs: [validate] + runs-on: ${{ matrix.runner }} + timeout-minutes: 120 + # `release` is the environment the organization's federated credential names + # (repo:redrob-labs/:environment:release); any other name is refused by Azure. + environment: release permissions: - contents: write - environment: production-release + contents: read + # The OIDC token `azure/login` exchanges for the Artifact Signing credential (Windows leg). + id-token: write strategy: fail-fast: false - max-parallel: 1 matrix: - # Generated by scripts/release-matrix.mjs in the validate job, so the Windows leg can - # be excluded while Authenticode signing has no certificate, and so the updater - # platform keys the verify job requires come from the same single list. + # Generated by tools/release_matrix.py in the validate job, so the Windows leg can be + # excluded while Authenticode signing is not configured, and so the asset names the + # verify job demands and the platform list the release notes promise come from that + # same single list. include: ${{ fromJSON(needs.validate.outputs.matrix) }} - runs-on: ${{ matrix.platform }} - timeout-minutes: 90 + env: + VERSION: ${{ needs.validate.outputs.version }} + TARGET: ${{ matrix.target }} + # Plain IDs (not credentials); here so the login step can be skipped when signing is not set up. + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} steps: - - name: Check out source - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - name: Checkout + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.3.1 with: - fetch-depth: 0 + ref: ${{ needs.validate.outputs.sha }} - - name: Install Linux desktop dependencies - if: runner.os == 'Linux' - # desktop-file-utils: the AppImage repack step below runs appimagetool, which refuses to - # pack without `desktop-file-validate` on PATH (measured on the v0.2.1 Linux leg). - run: >- - sudo apt-get update && sudo apt-get install -y - libwebkit2gtk-4.1-dev libjavascriptcoregtk-4.1-dev libsoup-3.0-dev librsvg2-dev - patchelf libssl-dev desktop-file-utils - - - name: Set up Node.js - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 - with: - node-version: 22 - cache: npm - - - name: Set up Rust + - name: Rust toolchain (rust-toolchain.toml) uses: dtolnay/rust-toolchain@6bed0761d98439e5a578e2877258200ad565ba87 # stable with: - toolchain: 1.94.0 + toolchain: "1.95.0" targets: ${{ matrix.target }} - - name: Install locked JavaScript dependencies - run: npm ci - - - name: Require release signing material - shell: bash - env: - TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_SIGNING_PUBLIC_KEY }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - run: | - test -n "$TAURI_UPDATER_PUBLIC_KEY" - test -n "$TAURI_SIGNING_PRIVATE_KEY" - test -n "$TAURI_SIGNING_PRIVATE_KEY_PASSWORD" + - uses: Swatinem/rust-cache@v2 + with: + key: release-${{ matrix.slug }} - - name: Require Apple signing and notarization credentials - if: runner.os == 'macOS' - shell: bash - env: - APPLE_CODESIGN_CERT_P12_BASE64: ${{ secrets.APPLE_CODESIGN_CERT_P12_BASE64 }} - APPLE_CODESIGN_CERT_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} - APPLE_NOTARY_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }} - APPLE_NOTARY_API_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} - APPLE_NOTARY_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} + - name: Install Linux build dependencies + if: runner.os == 'Linux' run: | - set -euo pipefail - for name in \ - APPLE_CODESIGN_CERT_P12_BASE64 \ - APPLE_CODESIGN_CERT_PASSWORD \ - APPLE_NOTARY_API_KEY_P8_BASE64 \ - APPLE_NOTARY_API_KEY_ID \ - APPLE_NOTARY_API_ISSUER_ID; do - test -n "${!name:-}" - done - printf '%s' "$APPLE_NOTARY_API_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8" - chmod 600 "$RUNNER_TEMP/AuthKey.p8" - printf 'APPLE_API_KEY_PATH=%s\n' "$RUNNER_TEMP/AuthKey.p8" >> "$GITHUB_ENV" + sudo apt-get update + sudo apt-get install -y --no-install-recommends \ + libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ + libgl1-mesa-dev libgtk-3-dev libdbus-1-dev dpkg-dev + # macOS: import the Developer ID certificate into a throwaway keychain and read the + # identity back out, rather than assuming its name. - name: Import Apple Developer ID certificate if: runner.os == 'macOS' shell: bash @@ -175,6 +232,8 @@ jobs: APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CODESIGN_CERT_PASSWORD }} run: | set -euo pipefail + test -n "$APPLE_CERTIFICATE" + test -n "$APPLE_CERTIFICATE_PASSWORD" KEYCHAIN_PASSWORD="$(openssl rand -hex 24)" printf '%s' "$APPLE_CERTIFICATE" | base64 --decode > "$RUNNER_TEMP/redrob-certificate.p12" security create-keychain -p "$KEYCHAIN_PASSWORD" "$RUNNER_TEMP/redrob-build.keychain" @@ -186,380 +245,312 @@ jobs: CERT_ID="$(security find-identity -v -p codesigning "$RUNNER_TEMP/redrob-build.keychain" | awk -F'"' 'NR==1 {print $2}')" test -n "$CERT_ID" printf 'APPLE_SIGNING_IDENTITY=%s\n' "$CERT_ID" >> "$GITHUB_ENV" + rm -f "$RUNNER_TEMP/redrob-certificate.p12" - - name: Import Windows code-signing certificate - if: runner.os == 'Windows' - shell: pwsh + - name: Prepare Apple notarization key + if: runner.os == 'macOS' + shell: bash env: - WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }} - WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }} + APPLE_NOTARY_API_KEY_P8_BASE64: ${{ secrets.APPLE_NOTARY_API_KEY_P8_BASE64 }} + APPLE_NOTARY_API_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} + APPLE_NOTARY_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} run: | - $ErrorActionPreference = "Stop" - if (-not $env:WIN_CSC_LINK -or -not $env:WIN_CSC_KEY_PASSWORD) { - throw "Windows signing secrets are required" - } + set -euo pipefail + for name in APPLE_NOTARY_API_KEY_P8_BASE64 APPLE_NOTARY_API_KEY_ID APPLE_NOTARY_API_ISSUER_ID; do + test -n "${!name:-}" + done + printf '%s' "$APPLE_NOTARY_API_KEY_P8_BASE64" | base64 --decode > "$RUNNER_TEMP/AuthKey.p8" + chmod 600 "$RUNNER_TEMP/AuthKey.p8" - $certificatePath = Join-Path $env:RUNNER_TEMP "redrob-certificate.pfx" - $link = $env:WIN_CSC_LINK.Trim() - if ($link -match '^https://') { - try { - Invoke-WebRequest -Uri $link -OutFile $certificatePath -UseBasicParsing - } catch { - throw "Failed to download the Windows signing certificate over HTTPS" - } - } else { - if ($link -match '^data:[^,]*;base64,') { - $payload = $link.Substring($link.IndexOf(',') + 1) - } else { - $payload = $link - } - $payload = $payload -replace '\s', '' - if ($payload -notmatch '^[A-Za-z0-9+/]+={0,2}$') { - throw "WIN_CSC_LINK must be raw Base64 PFX data, a data:*;base64 URI, or an HTTPS URL" - } - try { - [IO.File]::WriteAllBytes($certificatePath, [Convert]::FromBase64String($payload)) - } catch { - throw "WIN_CSC_LINK contains invalid Base64 certificate data" - } - } + - name: Build + shell: bash + env: + MACOSX_DEPLOYMENT_TARGET: "12.0" + # The Windows exe must carry its icon and version info (build.rs); a silent fallback is refused. + REDROB_QUERY_REQUIRE_WINRES: "1" + run: cargo build --release --locked -p redrob-query --target "$TARGET" + + # The tests ran on the pull request; here the binary has to at least answer --version, which + # proves it links and starts without a display. + - name: Smoke test the binary + shell: bash + run: | + set -euo pipefail + bin="target/$TARGET/release/redrob-query" + [ "$RUNNER_OS" = "Windows" ] && bin="$bin.exe" + test -x "$bin" + "$bin" --version + + # One staging tree per platform: bin/ and the licence set (LICENSE, NOTICE, + # UPSTREAM_NOTICES.md). The licence texts travel with every archive (GPL-3.0-or-later, section 4). + - name: Stage + shell: bash + run: | + set -euo pipefail + stage="$RUNNER_TEMP/stage" + python3 tools/release/stage.py --target "$TARGET" --version "$VERSION" --out "$stage" + ls -R "$stage" | head -50 - $password = ConvertTo-SecureString $env:WIN_CSC_KEY_PASSWORD -AsPlainText -Force - $imported = @(Import-PfxCertificate -FilePath $certificatePath -CertStoreLocation Cert:\CurrentUser\My -Password $password) - $codeSigningOid = "1.3.6.1.5.5.7.3.3" - # Read the EKU out of the X.509 extension itself, not out of PowerShell's - # EnhancedKeyUsageList. That list's ObjectId is already a STRING, so the - # `.ObjectId.Value` this used to compare is always $null and the `-contains` never - # matched -- the filter rejected the organisation's certificate, which does carry - # Code Signing. The diagnostic printed `eku=Code Signing ()` and "EKU is not Code - # Signing" in the same breath, which is the shape of a check reading the wrong - # property rather than a certificate that is wrong. - function Get-EkuOid([Security.Cryptography.X509Certificates.X509Certificate2]$candidate) { - $oids = @() - foreach ($extension in $candidate.Extensions) { - if ($extension.Oid.Value -ne "2.5.29.37") { continue } - $eku = $extension -as [Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension] - if ($null -eq $eku) { continue } - foreach ($usage in $eku.EnhancedKeyUsages) { - $oids += if ($usage.FriendlyName) { "$($usage.FriendlyName) ($($usage.Value))" } else { $usage.Value } - } - } - return $oids - } - $certificate = $imported | Where-Object { - $_.HasPrivateKey -and - $_.NotBefore.ToUniversalTime() -le [DateTime]::UtcNow -and - $_.NotAfter.ToUniversalTime() -gt [DateTime]::UtcNow -and - ((Get-EkuOid $_) -match [regex]::Escape($codeSigningOid)) - } | Sort-Object NotAfter -Descending | Select-Object -First 1 - if (-not $certificate -or -not $certificate.Thumbprint) { - # One message for four independent conditions is not diagnosable: an expired - # certificate, a certificate with no private key, and one issued for the wrong - # purpose all read the same. Report what each imported certificate actually is - # so the operator knows whether to renew it or replace it. Only public metadata - # is printed -- never the key, the password, or the PFX bytes. - Write-Host "Imported $($imported.Count) certificate(s); none usable for code signing:" - foreach ($candidate in $imported) { - $ekus = @(Get-EkuOid $candidate) - $reasons = @() - if (-not $candidate.HasPrivateKey) { $reasons += "no private key" } - if ($candidate.NotBefore.ToUniversalTime() -gt [DateTime]::UtcNow) { $reasons += "not valid until $($candidate.NotBefore.ToUniversalTime().ToString('u'))" } - if ($candidate.NotAfter.ToUniversalTime() -le [DateTime]::UtcNow) { $reasons += "EXPIRED $($candidate.NotAfter.ToUniversalTime().ToString('u'))" } - if ($ekus.Count -eq 0) { $reasons += "no Enhanced Key Usage at all" } - elseif ($ekus -notmatch [regex]::Escape($codeSigningOid)) { $reasons += "EKU is not Code Signing" } - Write-Host " subject=$($candidate.Subject)" - Write-Host " validity=$($candidate.NotBefore.ToUniversalTime().ToString('u')) .. $($candidate.NotAfter.ToUniversalTime().ToString('u'))" - Write-Host " hasPrivateKey=$($candidate.HasPrivateKey) eku=$(if ($ekus.Count) { $ekus -join ', ' } else { '(none)' })" - Write-Host " unusable because: $($reasons -join '; ')" - } - throw "WIN_CSC_LINK holds no certificate usable for Authenticode signing. See the per-certificate reasons above; the secret needs a PFX with a private key, an unexpired validity window, and the Code Signing EKU ($codeSigningOid)." - } - # The organisation's code-signing certificate is SELF-SIGNED. Measured, not assumed: - # the chain terminates in "a root certificate which is not trusted by the trust - # provider", and a sibling product that signs with this same secret says so in its - # own workflow and accepts NotTrusted there. - # - # So an untrusted or incomplete root is expected and is NOT a defect to fail on. - # Everything else still is -- a revoked certificate, one outside its validity window, - # a broken signature, a bad usage. Those are listed explicitly rather than the check - # being deleted, because deleting it would also stop catching a revoked certificate. - # - # WHAT THIS BUYS AND WHAT IT DOES NOT. It buys tamper-evidence and a stable publisher - # identity: the installer cannot be modified without breaking the signature. It does - # NOT buy trust -- Windows SmartScreen still warns the user that the publisher is - # unknown, exactly as it would for an unsigned installer. Removing that warning needs - # an OV or EV certificate from a CA whose root ships in Windows; no change to this - # workflow can substitute for one. - $chain = [Security.Cryptography.X509Certificates.X509Chain]::new() - $chain.ChainPolicy.RevocationMode = [Security.Cryptography.X509Certificates.X509RevocationMode]::Online - $chain.ChainPolicy.RevocationFlag = [Security.Cryptography.X509Certificates.X509RevocationFlag]::ExcludeRoot - $built = $chain.Build($certificate) - $tolerated = @("UntrustedRoot", "PartialChain", "RevocationStatusUnknown", "OfflineRevocation") - $fatal = @($chain.ChainStatus | Where-Object { $tolerated -notcontains $_.Status.ToString() }) - if ($fatal.Count -gt 0) { - $chainErrors = ($fatal | ForEach-Object { "$($_.Status): $($_.StatusInformation.Trim())" }) -join "; " - throw "Windows code-signing certificate chain validation failed: $chainErrors" - } - if (-not $built) { - Write-Host "::warning::certificate chain is not publicly trusted ($(($chain.ChainStatus | ForEach-Object { $_.Status.ToString() }) -join ', ')). The installer is signed and tamper-evident, but Windows will still warn that the publisher is unknown until an OV/EV certificate replaces this one." - } - "WIN_CSC_CERTIFICATE_THUMBPRINT=$($certificate.Thumbprint)" | Out-File -FilePath $env:GITHUB_ENV -Append - Remove-Item -LiteralPath $certificatePath -Force + # The version Finder, the About box and Gatekeeper show is read from the bundle after it is + # built, not trusted from the script that wrote it. + - name: Require the bundle version to match the workspace version (macOS) + if: runner.os == 'macOS' + shell: bash + run: | + set -euo pipefail + plist="$RUNNER_TEMP/stage/Redrob Query.app/Contents/Info.plist" + crate_version="$(sed -n 's/^version = "\(.*\)"$/\1/p' Cargo.toml | head -n1)" + for key in CFBundleShortVersionString CFBundleVersion; do + bundle_version="$(plutil -extract "$key" raw -o - "$plist")" + if [ "$bundle_version" != "$crate_version" ]; then + echo "::error::Info.plist $key is $bundle_version, Cargo.toml is $crate_version" + exit 1 + fi + done - - name: Prepare signed updater configuration + - name: Sign, notarize and staple (macOS) + if: runner.os == 'macOS' + shell: bash env: - TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_SIGNING_PUBLIC_KEY }} - WIN_CSC_CERTIFICATE_THUMBPRINT: ${{ env.WIN_CSC_CERTIFICATE_THUMBPRINT }} - run: npm run release:prepare + APPLE_NOTARY_API_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} + APPLE_NOTARY_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} + run: | + set -euo pipefail + app="$RUNNER_TEMP/stage/Redrob Query.app" + + codesign --force --deep --options runtime --timestamp \ + --sign "$APPLE_SIGNING_IDENTITY" "$app" + codesign --verify --deep --strict --verbose=2 "$app" + + # notarytool takes an archive, not a bundle; ditto --keepParent preserves the + # .app directory structure that a plain zip mangles. + ditto -c -k --keepParent "$app" "$RUNNER_TEMP/notarize.zip" + xcrun notarytool submit "$RUNNER_TEMP/notarize.zip" \ + --key "$RUNNER_TEMP/AuthKey.p8" \ + --key-id "$APPLE_NOTARY_API_KEY_ID" \ + --issuer "$APPLE_NOTARY_API_ISSUER_ID" \ + --wait + + # Stapling and validating both look the ticket up in Apple's CloudKit service, and that + # lookup drops ("The network connection was lost", error 68) after notarization was + # Accepted. Each network step is retried with backoff; a real refusal fails every attempt. + retry() { + local i + for i in 1 2 3 4 5 6; do + if "$@"; then return 0; fi + [ "$i" -lt 6 ] || { echo "::error::failed after 6 attempts: $*"; return 1; } + echo "::warning::attempt $i of 6 failed: $*; retrying in $((i * 20))s" + sleep $((i * 20)) + done + } + retry xcrun stapler staple "$app" + retry xcrun stapler validate "$app" + # Gatekeeper's own verdict, which is what a user's machine actually applies. + retry spctl --assess --type execute --verbose=4 "$app" + rm -f "$RUNNER_TEMP/notarize.zip" + + # The login comes after the build on purpose: the OpenID Connect token is short-lived and a + # long build left signtool without a usable credential (it then waited on a browser). + - name: Log in to Azure (OIDC) + if: runner.os == 'Windows' && env.AZURE_CLIENT_ID != '' + uses: azure/login@v3 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} - - name: Build, sign, and upload installers - uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f # action-v1.0.0 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - APPLE_SIGNING_IDENTITY: ${{ env.APPLE_SIGNING_IDENTITY }} - APPLE_API_KEY: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} - APPLE_API_ISSUER: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} - APPLE_API_KEY_PATH: ${{ env.APPLE_API_KEY_PATH }} + # The executable is signed BEFORE it is zipped: the portable zip and the setup both carry it + # as is. + - name: Sign the executable (Windows) + if: runner.os == 'Windows' + uses: redrob-labs/.github/actions/sign-windows@main with: - tagName: v__VERSION__ - releaseName: Redrob Query v__VERSION__ - releaseBody: See CHANGELOG.md for release notes. Installers and updater bundles are signed; the release remains a draft until platform smoke tests are approved. - releaseDraft: true - prerelease: false - uploadUpdaterJson: true - updaterJsonPreferNsis: true - args: --target ${{ matrix.target }} --bundles ${{ matrix.bundles }} --config src-tauri/tauri.release.conf.json - - # The default bundler ships the build host's libwayland in the AppImage, and on Mesa 25+ that - # makes WebKitWebProcess abort with EGL_BAD_PARAMETER: the window opens and stays blank - # (tauri-apps/tauri#15665). Measured on v0.2.0 before this step existed -- strace on the web - # process, then the same AppImage with only those four libraries removed rendered with no - # environment overrides. Repacking changes the bytes, so the script also re-signs and patches - # latest.json; a stale signature still looks signed and breaks every future update. - - name: Repack the AppImage without bundled Wayland, and re-sign it - if: runner.os == 'Linux' + files: ${{ runner.temp }}/stage/bin/redrob-query.exe + description: ${{ env.PRODUCT }} + url: ${{ env.PRODUCT_URL }} + endpoint: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + account: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT }} + cert-profile: ${{ secrets.ARTIFACT_SIGNING_CERT_PROFILE }} + + - name: Package + id: package + shell: bash + run: | + set -euo pipefail + out="$RUNNER_TEMP/artifacts" + mkdir -p "$out" + base="redrob-query-${VERSION}-${{ matrix.slug }}" + + case "$RUNNER_OS" in + macOS) + # Archive the whole staging tree, not just the .app, so the licences and notices + # travel with the binary. ditto (not zip/cp) because it preserves the code + # signature's extended attributes and the stapled notarization ticket. + mv "$RUNNER_TEMP/stage" "$RUNNER_TEMP/${base}" + ( cd "$RUNNER_TEMP" && ditto -c -k --sequesterRsrc --keepParent "$base" "$out/${base}.zip" ) + ;; + Windows) + # `-portable`: the unpacked, signed app, which Redrob Launcher unzips and runs in place. + powershell -NoProfile -Command \ + "Compress-Archive -Path '$RUNNER_TEMP/stage/*' -DestinationPath '$out/${base}-portable.zip' -Force" + ;; + *) + tar -C "$RUNNER_TEMP/stage" -czf "$out/${base}.tar.gz" . + # The same tree as a package for apt; the system's own uninstall removes it. + python3 tools/release/make_deb.py "$RUNNER_TEMP/stage" "$VERSION" "$out/${base}.deb" + # The licence set once, from Linux, rather than three times under the same name. + cp LICENSE NOTICE UPSTREAM_NOTICES.md "$out/" + ;; + esac + + printf 'base=%s\n' "$base" >> "$GITHUB_OUTPUT" + printf 'dir=%s\n' "$out" >> "$GITHUB_OUTPUT" + ls -la "$out" + + # The disk image most people download: the stapled app, an Applications link, the uninstaller + # and the licences, on the branded window (installer/macos/). The image is signed, notarized and + # stapled in its own right, so Gatekeeper accepts it before anyone opens the app inside. + - name: Disk image (macOS) + if: runner.os == 'macOS' shell: bash env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} - TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} - RELEASE_TARGET: ${{ matrix.target }} - # Pinned by digest: this binary rewrites a signed release artifact. - APPIMAGETOOL_URL: https://github.com/AppImage/appimagetool/releases/download/1.9.0/appimagetool-x86_64.AppImage - APPIMAGETOOL_SHA256: 46fdd785094c7f6e545b61afcfb0f3d98d8eab243f644b4b17698c01d06083d1 + BASE: ${{ steps.package.outputs.base }} + APPLE_NOTARY_API_KEY_ID: ${{ secrets.APPLE_NOTARY_API_KEY_ID }} + APPLE_NOTARY_API_ISSUER_ID: ${{ secrets.APPLE_NOTARY_API_ISSUER_ID }} run: | set -euo pipefail - tool="$RUNNER_TEMP/appimagetool" - curl -fsSL -o "$tool" "$APPIMAGETOOL_URL" - echo "${APPIMAGETOOL_SHA256} ${tool}" | sha256sum -c - - chmod +x "$tool" - # This is a Cargo workspace rooted at the repo, so the bundle is NOT under src-tauri/; - # ask cargo, the same way the Windows signature step does. - target_root="$(cargo metadata --format-version 1 --no-deps | jq -r .target_directory)" - # Runners have no FUSE; both the tool and the AppImage run through their own extractor. - export APPIMAGE_EXTRACT_AND_RUN=1 - bash scripts/resign-linux-appimage.sh \ - "${target_root}/${RELEASE_TARGET}/release/bundle" "${GITHUB_REF_NAME}" "$tool" - - - name: Verify Windows installer signatures and timestamps + trap 'rm -f "$RUNNER_TEMP/AuthKey.p8"' EXIT + # A venv: the runner's Homebrew Python is "externally managed" (PEP 668) and refuses + # `pip install --user`. + python3 -m venv "$RUNNER_TEMP/dmgbuild-venv" + "$RUNNER_TEMP/dmgbuild-venv/bin/python" -m pip install --quiet "dmgbuild==1.6.5" + export PATH="$RUNNER_TEMP/dmgbuild-venv/bin:$PATH" # make-dmg.sh runs `python3 -m dmgbuild` + dmg="$RUNNER_TEMP/artifacts/${BASE}.dmg" + bash installer/macos/make-dmg.sh \ + --app "$RUNNER_TEMP/$BASE/Redrob Query.app" \ + --licenses "$RUNNER_TEMP/$BASE/share/redrob-query/licenses" \ + --version "$VERSION" --sign-identity "$APPLE_SIGNING_IDENTITY" --out "$dmg" + xcrun notarytool submit "$dmg" \ + --key "$RUNNER_TEMP/AuthKey.p8" \ + --key-id "$APPLE_NOTARY_API_KEY_ID" \ + --issuer "$APPLE_NOTARY_API_ISSUER_ID" \ + --wait + # The same retry as the app's staple, above (each step is its own shell). + retry() { + local i + for i in 1 2 3 4 5 6; do + if "$@"; then return 0; fi + [ "$i" -lt 6 ] || { echo "::error::failed after 6 attempts: $*"; return 1; } + echo "::warning::attempt $i of 6 failed: $*; retrying in $((i * 20))s" + sleep $((i * 20)) + done + } + retry xcrun stapler staple "$dmg" + retry xcrun stapler validate "$dmg" + retry spctl --assess --type open --context context:primary-signature --verbose=4 "$dmg" + + # The setup carries the signed zip above and the uninstaller, so it is built after signing: + # uninstaller first, signed, then embedded; the setup last, signed. installer/windows/README.md. + - name: Build the uninstaller (Windows) if: runner.os == 'Windows' shell: pwsh - env: - RELEASE_TARGET: ${{ matrix.target }} - WIN_CSC_CERTIFICATE_THUMBPRINT: ${{ env.WIN_CSC_CERTIFICATE_THUMBPRINT }} run: | $ErrorActionPreference = "Stop" - # Ask cargo where its target directory is. This step used to hardcode - # "src-tauri/target/...", which is where a Tauri app's build output lives when src-tauri - # holds its own crate -- but this repository's Cargo WORKSPACE root is the repository - # root, with src-tauri as a member, so the bundles land in /target. The step is - # Windows-only, so the wrong path was never exercised until the first Windows leg, which - # failed with "Cannot find path ... src-tauri\target\...\bundle because it does not - # exist" while the bundles sat in \target\...\bundle\{msi,nsis}. - # - # Derived rather than corrected in place: moving src-tauri in or out of the workspace - # would silently break a second hardcoded path the same way. - $targetRoot = (cargo metadata --format-version 1 --no-deps | ConvertFrom-Json).target_directory - if (-not $targetRoot) { throw "cargo metadata did not report a target_directory" } - $bundlePath = Join-Path $targetRoot "$env:RELEASE_TARGET/release/bundle" - if (-not (Test-Path -LiteralPath $bundlePath)) { - throw "No bundle directory at $bundlePath (cargo target_directory: $targetRoot)" - } - $installers = @(Get-ChildItem -LiteralPath $bundlePath -Recurse -File | Where-Object { $_.Extension -in '.exe', '.msi' }) - if (-not ($installers.Extension -contains '.exe') -or -not ($installers.Extension -contains '.msi')) { - throw "Expected both signed NSIS and MSI installers" + $work = Join-Path $env:RUNNER_TEMP "setup" + python installer/windows/build.py --out $work --arch x64 --version $env:VERSION --uninstaller-only + if ($LASTEXITCODE -ne 0) { throw "uninstaller build failed ($LASTEXITCODE)" } + if (-not (Test-Path -LiteralPath (Join-Path $work "uninstall/Uninstall Redrob Query.exe"))) { + throw "uninstaller was not built" } - # A self-signed signer makes Get-AuthenticodeSignature report NotTrusted or - # UnknownError rather than Valid, on a runner that does not carry our root. Those two - # are accepted; HashMismatch and NotSigned are not, so an unsigned or modified - # installer still fails here. The signer thumbprint and the countersignature are - # asserted unconditionally -- those say it is OUR certificate and that the signing - # time is attested, neither of which depends on the root being trusted. - $accepted = @("Valid", "NotTrusted", "UnknownError") - foreach ($installer in $installers) { - $signature = Get-AuthenticodeSignature -LiteralPath $installer.FullName - if ($accepted -notcontains $signature.Status.ToString()) { - throw "Installer signature is not usable for $($installer.Name): $($signature.Status) ($($signature.StatusMessage))" - } - if (-not $signature.SignerCertificate) { - throw "Installer carries no Authenticode signer certificate: $($installer.Name)" - } - if ($signature.SignerCertificate.Thumbprint -ne $env:WIN_CSC_CERTIFICATE_THUMBPRINT) { - throw "Installer was signed by an unexpected certificate: $($installer.Name)" - } - if (-not $signature.TimeStamperCertificate) { - throw "Installer signature carries no trusted timestamp: $($installer.Name)" - } - Write-Host "$($installer.Name) signed by $($signature.SignerCertificate.Subject) status=$($signature.Status)" + + - name: Sign the uninstaller (Windows) + if: runner.os == 'Windows' + uses: redrob-labs/.github/actions/sign-windows@main + with: + files: ${{ runner.temp }}/setup/uninstall/*.exe + description: ${{ env.PRODUCT }} + url: ${{ env.PRODUCT_URL }} + endpoint: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + account: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT }} + cert-profile: ${{ secrets.ARTIFACT_SIGNING_CERT_PROFILE }} + + - name: Build the setup (Windows) + if: runner.os == 'Windows' + shell: pwsh + env: + BASE: ${{ steps.package.outputs.base }} + run: | + $ErrorActionPreference = "Stop" + $work = Join-Path $env:RUNNER_TEMP "setup" + $zip = Join-Path $env:RUNNER_TEMP "artifacts/$env:BASE-portable.zip" + $setupName = "$env:BASE-setup.exe" + $uninstaller = Join-Path $work "uninstall/Uninstall Redrob Query.exe" + python installer/windows/build.py --out $work --arch x64 --version $env:VERSION --payload $zip ` + --uninstaller $uninstaller --name $setupName + if ($LASTEXITCODE -ne 0) { throw "setup build failed ($LASTEXITCODE)" } + $setup = Join-Path $work $setupName + # The setup must carry exactly the zip that ships beside it; a stale payload would install + # a different build than the one its checksum file describes. + if ((Get-Item -LiteralPath $setup).Length -le (Get-Item -LiteralPath $zip).Length) { + throw "setup is not larger than the zip it should embed" } + Copy-Item -LiteralPath $setup -Destination (Join-Path $env:RUNNER_TEMP "artifacts/$setupName") + - name: Sign the setup (Windows) + if: runner.os == 'Windows' + uses: redrob-labs/.github/actions/sign-windows@main + with: + files: ${{ runner.temp }}/artifacts/*-setup.exe + description: ${{ env.PRODUCT }} + url: ${{ env.PRODUCT_URL }} + endpoint: ${{ secrets.ARTIFACT_SIGNING_ENDPOINT }} + account: ${{ secrets.ARTIFACT_SIGNING_ACCOUNT }} + cert-profile: ${{ secrets.ARTIFACT_SIGNING_CERT_PROFILE }} + + # `release-*` is the prefix the shared publish job collects; the checksums and the launcher + # manifest are written there, over every platform's files at once. + - uses: actions/upload-artifact@v4 + with: + name: release-${{ matrix.slug }} + path: ${{ steps.package.outputs.dir }}/* + if-no-files-found: error + + - name: Remove the temporary keychain (macOS) + if: always() && runner.os == 'macOS' + run: | + if [ -f "$RUNNER_TEMP/redrob-build.keychain" ]; then security delete-keychain "$RUNNER_TEMP/redrob-build.keychain" || true; fi - # The CDN promotion job that used to live here is gone: GitHub Releases is now the - # update channel itself, so there is nothing to copy anywhere. What that job also - # did, though, was VERIFY the published channel -- so its checks are re-pointed - # here rather than dropped. Without them the classic failure is silent: the app - # installs fine and never updates again, because `latest.json` is missing or its - # platform URLs point somewhere a client cannot reach. verify-release-assets: - name: Updater contract on the draft release - if: github.event_name == 'push' + name: Signed builds and licence files accompany the release needs: [validate, build] runs-on: ubuntu-24.04 timeout-minutes: 15 permissions: - # write, not read: this job does not merely inspect the manifest, it corrects the - # placeholder URLs tauri-action necessarily wrote while the release was a draft. - contents: write + contents: read steps: - - name: Check out the tag - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - # Redrob Launcher reads one small file beside the installers, `redrob-launcher.json`, to - # learn that this release reads its shared account file -- so signing in to the launcher is - # all a user needs. Written by scripts/launcher-release-manifest.mjs (pinned by its test) and - # uploaded here, in the one job that runs after every platform leg, so it is attached exactly - # once. `--clobber` keeps a re-run from failing on the asset it uploaded last time. The rename - # step that follows fails the release if the file is missing. - - name: Attach the Redrob Launcher release manifest - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.ref_name }} - shell: bash - run: | - set -euo pipefail - node scripts/launcher-release-manifest.mjs "$RUNNER_TEMP/release-assets/redrob-launcher.json" - gh release upload "${RELEASE_TAG}" "$RUNNER_TEMP/release-assets/redrob-launcher.json" \ - --clobber --repo "${GITHUB_REPOSITORY}" - - # Tauri names its bundles `Redrob Query__.`; Redrob Launcher installs - # only `redrob-query---.`. The installers (and their detached .sig - # files) are renamed in place by asset id, so bytes and signatures are untouched, and the - # manifest rewrite below resolves ids to the NEW names. This runs first for that reason. - # The step also fails the release if any installer is left with a name the launcher cannot - # read -- see scripts/launcher-asset-name.mjs, pinned by scripts/launcher-asset-name.test.ts. - - name: Rename installers to the names Redrob Launcher installs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.ref_name }} - shell: bash - run: | - set -euo pipefail - gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "$RUNNER_TEMP/releases.json" - release_json="$(jq --arg tag "${RELEASE_TAG}" 'map(select(.tag_name == $tag)) | first // empty' "$RUNNER_TEMP/releases.json")" - if [ -z "$release_json" ]; then - echo "::error::No release found for ${RELEASE_TAG}. A draft is only listed to a caller with push access, so check this job's contents permission if the draft does exist." - exit 1 - fi - printf '%s' "$release_json" > "$RUNNER_TEMP/release.before-rename.json" - node scripts/rename-release-assets.mjs "$RUNNER_TEMP/release.before-rename.json" \ - "${GITHUB_REPOSITORY}" "${RELEASE_TAG}" - - # tauri-action uploads latest.json while the release is a DRAFT, and a draft asset has - # no public download URL yet -- GitHub only mints /releases/download// at - # publication -- so the manifest carries REST asset URLs - # (https://api.github.com/repos/.../releases/assets/) instead. Those would break the - # updater: that endpoint serves the asset's JSON metadata unless the caller sends - # `Accept: application/octet-stream`, which a plain updater fetch does not. The published - # URL is fully determined by the tag and the asset name, so it is written now and is - # correct the moment the draft is published. - - name: Point the updater manifest at the published download URLs - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.ref_name }} - shell: bash - run: | - set -euo pipefail - # A DRAFT release is NOT on the tags endpoint: GET /releases/tags/ answers 404 - # until publication, because a draft has no published tag to resolve. Since this job - # exists to inspect the draft, it has to find it in the listing, which does include - # drafts for a caller with push access. - gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "$RUNNER_TEMP/releases.json" - release_json="$(jq --arg tag "${RELEASE_TAG}" 'map(select(.tag_name == $tag)) | first // empty' "$RUNNER_TEMP/releases.json")" - if [ -z "$release_json" ]; then - echo "::error::No release found for ${RELEASE_TAG}. A draft is only listed to a caller with push access, so check this job's contents permission if the draft does exist." - exit 1 - fi - printf '%s' "$release_json" > release.json - manifest_id="$(printf '%s' "$release_json" | jq -r '.assets[] | select(.name == "latest.json") | .id')" - if [ -z "$manifest_id" ] || [ "$manifest_id" = "null" ]; then - echo "::error::latest.json is not attached to ${RELEASE_TAG}; nothing to rewrite" - exit 1 - fi - gh api -H "Accept: application/octet-stream" \ - "repos/${GITHUB_REPOSITORY}/releases/assets/${manifest_id}" > latest.json - node scripts/rewrite-updater-urls.mjs latest.json release.json \ - "${GITHUB_REPOSITORY}" "${RELEASE_TAG}" - # Replace in place: an asset name is unique per release, so the old one must go - # before the corrected manifest can take its name. - gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/${manifest_id}" - gh release upload "${RELEASE_TAG}" latest.json --repo "${GITHUB_REPOSITORY}" - - - name: Assert the draft release carries a usable updater manifest + # A missing platform is a release that silently ships less than its notes claim, and a + # binary without its licence texts breaks the one thing MIT and Apache ask for. Both fail + # here, BEFORE the release is published. + - uses: actions/download-artifact@v4 + with: + pattern: release-* + path: artifacts + merge-multiple: true + + - name: Assert every platform and every licence artifact is present env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.ref_name }} - REQUIRED_PLATFORMS: ${{ needs.validate.outputs.platforms }} - REQUIRED_ASSET_SUFFIXES: ${{ needs.validate.outputs.asset_suffixes }} + REQUIRED_ARCHIVES: ${{ needs.validate.outputs.archive_suffixes }} ${{ needs.validate.outputs.installer_suffixes }} shell: bash run: | set -euo pipefail - - # The release is still a draft at this point, which is deliberate: draft - # assets are invisible to `releases/latest/download/...`, so QA happens - # before any installed client can see the version. - # A DRAFT release is NOT on the tags endpoint: GET /releases/tags/ answers 404 - # until publication, because a draft has no published tag to resolve. Since this job - # exists to inspect the draft, it has to find it in the listing, which does include - # drafts for a caller with push access. - gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "$RUNNER_TEMP/releases.json" - release_json="$(jq --arg tag "${RELEASE_TAG}" 'map(select(.tag_name == $tag)) | first // empty' "$RUNNER_TEMP/releases.json")" - if [ -z "$release_json" ]; then - echo "::error::No release found for ${RELEASE_TAG}. A draft is only listed to a caller with push access, so check this job's contents permission if the draft does exist." - exit 1 - fi - draft="$(printf '%s' "$release_json" | jq -r '.draft')" - if [ "$draft" != "true" ]; then - echo "::error::Release ${RELEASE_TAG} must still be a draft when the build finishes" - exit 1 - fi - - names="$(printf '%s' "$release_json" | jq -r '.assets[].name')" - echo "Assets on ${RELEASE_TAG}:" + names="$(ls -1 artifacts)" + echo "Assets built:" printf ' %s\n' $names - # latest.json IS the feed on GitHub Releases. Missing it does not fail an - # install, it silently freezes every existing client on its current version. - if ! printf '%s\n' $names | grep -qx 'latest.json'; then - echo "::error::latest.json is not attached; published clients would never see this release" - exit 1 - fi - - # Suffixes come from the same generator that built the matrix, so a bundle type no - # leg produces is not demanded. Glob matching rather than a regex keeps the list - # free of escaping, since it crosses a job output boundary. - for suffix in ${REQUIRED_ASSET_SUFFIXES}; do + # Platform archives and installers come from the same generator that built the matrix, + # so a leg that was deliberately not built is not demanded here, and one that WAS built + # cannot be quietly missing. + for suffix in ${REQUIRED_ARCHIVES}; do found=0 for name in $names; do case "$name" in @@ -567,89 +558,48 @@ jobs: esac done if [ "$found" -ne 1 ]; then - echo "::error::no asset ending in ${suffix} -- a built platform produced no installer" + echo "::error::no asset ending in ${suffix}; a platform this run built produced no archive" exit 1 fi done - manifest_id="$(printf '%s' "$release_json" | jq -r '.assets[] | select(.name == "latest.json") | .id')" - gh api -H 'Accept: application/octet-stream' \ - "repos/${GITHUB_REPOSITORY}/releases/assets/${manifest_id}" > latest.json - - version="$(jq -r '.version' latest.json)" - if [ "v${version}" != "${RELEASE_TAG}" ]; then - echo "::error::latest.json version ${version} does not match tag ${RELEASE_TAG}" - exit 1 - fi - - # Every architecture a build leg produced must be present, and every URL must - # point at this repository's own release assets. A URL left pointing at a - # decommissioned host is exactly the shape of failure this job exists to catch. - # - # The list comes from the same generator that built the matrix, so a platform that - # was deliberately not built is not demanded here, and a platform that WAS built - # cannot be quietly missing from the manifest. - for platform in ${REQUIRED_PLATFORMS}; do - url="$(jq -r --arg p "$platform" '.platforms[$p].url // empty' latest.json)" - sig="$(jq -r --arg p "$platform" '.platforms[$p].signature // empty' latest.json)" - if [ -z "$url" ] || [ -z "$sig" ]; then - echo "::error::latest.json is missing a signed entry for ${platform}" + # The licence artifacts are unconditional. The checksums and redrob-launcher.json are + # the publish job's: it writes both over this same set of files. + for pattern in \ + '^LICENSE$' \ + '^NOTICE$' \ + '^UPSTREAM_NOTICES\.md$'; do + if ! printf '%s\n' $names | grep -qE "$pattern"; then + echo "::error::no asset matching ${pattern}; the licence texts and notices must accompany every binary" exit 1 fi - case "$url" in - "https://github.com/${GITHUB_REPOSITORY}/releases/download/"*) ;; - *) - echo "::error::${platform} url is not a release asset of this repository: ${url}" - exit 1 - ;; - esac done - echo "latest.json covers every built platform (${REQUIRED_PLATFORMS}), is signed, and points only at this repository's release assets." - - # "Has a signature" is not "has the right signature". A signature over different bytes passes - # the check above and fails on every user's machine at the first update check -- and the - # AppImage repack step rewrites bytes after tauri-action signed them, so this is a live risk, - # not a theoretical one. Measured before adding this: the published v0.2.0 signature is VALID - # for the published AppImage and INVALID for the same file repacked. - - name: Verify every updater signature against the bytes it covers - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - RELEASE_TAG: ${{ github.ref_name }} - TAURI_UPDATER_PUBLIC_KEY: ${{ vars.TAURI_SIGNING_PUBLIC_KEY }} - shell: bash - run: | - set -euo pipefail - test -n "$TAURI_UPDATER_PUBLIC_KEY" - gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" > "$RUNNER_TEMP/releases.json" - release_json="$(jq --arg tag "${RELEASE_TAG}" 'map(select(.tag_name == $tag)) | first // empty' "$RUNNER_TEMP/releases.json")" - manifest_id="$(printf '%s' "$release_json" | jq -r '.assets[] | select(.name == "latest.json") | .id')" - gh api -H 'Accept: application/octet-stream' \ - "repos/${GITHUB_REPOSITORY}/releases/assets/${manifest_id}" > "$RUNNER_TEMP/latest.json" - - dl="$RUNNER_TEMP/updater-assets" - mkdir -p "$dl" - checked=0 - # One line per platform entry: " ". Signatures are base64, - # so they never contain a space. - while read -r platform asset signature; do - asset_id="$(printf '%s' "$release_json" | jq -r --arg n "$asset" '.assets[] | select(.name == $n) | .id')" - if [ -z "$asset_id" ]; then - echo "::error::${platform} names ${asset}, which is not attached to ${RELEASE_TAG}" - exit 1 - fi - [ -f "$dl/$asset" ] || gh api -H 'Accept: application/octet-stream' \ - "repos/${GITHUB_REPOSITORY}/releases/assets/${asset_id}" > "$dl/$asset" - if ! result="$(node scripts/verify-updater-signature.mjs "$TAURI_UPDATER_PUBLIC_KEY" "$signature" "$dl/$asset")"; then - echo "::error::${platform}: ${result} (${asset})" - exit 1 - fi - echo "${platform}: ${result} (${asset})" - checked=$((checked + 1)) - done < <(jq -r '.platforms | to_entries[] | "\(.key) \(.value.url | split("/") | last) \(.value.signature)"' "$RUNNER_TEMP/latest.json") - - if [ "$checked" -eq 0 ]; then - echo "::error::latest.json has no platform entries; nothing was verified" + if printf '%s\n' $names | grep -q '\.unsigned\.'; then + echo "::error::an unsigned archive is among the assets" exit 1 fi - echo "Every one of ${checked} updater signatures verifies against the bytes it names." + + echo "Every built platform asset (${REQUIRED_ARCHIVES}) and the licence files are present." + + # The shared redrob-labs publish job: SHA256SUMS.txt over every asset, redrob-launcher.json (this + # app reads the launcher's shared account file directly), and the release v created + # and PUBLISHED as latest (prerelease when the version carries a `-`). Only for a real tag, never + # for a dry run, and only when every job before it succeeded. `tag` and `target` are passed + # explicitly because this workflow is dispatched from a branch: github.sha is the branch head, not + # the tag's commit. The assets reach it as the `release-*` artifacts every job above uploaded + # (release-linux-x86_64, release-macos-arm64, release-windows-x64), which the shared + # job merges into one directory before writing the sums -- the same hand-over redrob-canvas uses. + publish: + needs: [validate, build, verify-release-assets] + if: always() && needs.validate.outputs.dry_run != 'true' && !contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && !contains(needs.*.result, 'skipped') + uses: redrob-labs/.github/.github/workflows/release-publish.yml@main + permissions: + contents: write + with: + version: ${{ needs.validate.outputs.version }} + title: Redrob Query + launcher_manifest: true + publish: true + tag: ${{ inputs.tag }} + target: ${{ needs.validate.outputs.sha }} diff --git a/.gitignore b/.gitignore index 6366a8b..1d2849e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,27 +1,14 @@ -# Dependencies -node_modules/ -target/ - # Build outputs -dist/ -# Third-party plugin files, placed by scripts/install-plugin-assets.mjs from the pinned npm -# packages. Generated, not authored -- committing 1.8 MB of build output would put a megabyte-scale -# binary diff in history on every plugin version bump. -public/plugins/ -src-tauri/target/ +target/ *.log # Local environment and secrets .env .env.* -!.env.example *.db *.db-shm *.db-wal -# Generated only inside a release runner by scripts/prepare-release-config.mjs -src-tauri/tauri.release.conf.json - # Editors and OS .DS_Store .idea/ @@ -32,19 +19,6 @@ src-tauri/tauri.release.conf.json .agents/tasks/ .kiro/ -# Demo and generated review artifacts -.demo-recorder/ -semantic-review/ - -# Test outputs -coverage/ -test-results/ -playwright-report/ - -# A Beekeeper Studio checkout to port from. 284 MB of GPLv3 source plus 544 KB -# under a COMMERCIAL licence that forbids use here, so the whole tree stays out of -# git and scripts/check-upstream-paths.mjs asserts that separately. Written with a -# trailing slash to match a DIRECTORY, and the probe in that script uses the -# trailing slash too -- `git check-ignore upstream` misses it when the directory -# does not exist locally, which is exactly the state a fresh clone is in. +# A Beekeeper Studio checkout to port from. 284 MB of GPLv3 source plus 544 KB under a COMMERCIAL +# licence that forbids use here, so the whole tree stays out of git. /upstream/ diff --git a/AGENTS.md b/AGENTS.md index c9578d8..070b2a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,156 +1,103 @@ # AGENTS.md -Operating notes for an agent working in `redrob-labs/redrob-query`. Everything here was read -from the tree or measured against the live repository, not assumed. +Operating notes for an agent working in `redrob-labs/redrob-query`. Everything here was read from +the tree or measured against the live repository, not assumed. ## What this repository is -Redrob Query is a JVM-free desktop database client for PostgreSQL, MySQL, SQLite, and MongoDB, -built as a Tauri 2 shell around a React 19 front end and a Rust core crate. Version 0.1 ships a -read-only desktop workspace: mutation commands are not registered with Tauri at all, so -"read-only" is enforced by the absence of an IPC command rather than by a runtime check. The same -front end also runs in a plain browser against deterministic in-memory fixtures, which is how the -product demos without a database or anyone's credentials. +Redrob Query is a native desktop database client for PostgreSQL, MySQL, SQLite and MongoDB: a Rust +egui/wgpu shell (`apps/redrob-query`) over a Rust engine crate (`crates/redrob-query-core`). Version +0.3.0 replaced the Tauri 2 + React 19 shell outright; there is no TypeScript, no Node, no browser +demo and no plugin host any more. The shape of the repository follows `redrob-labs/redrob-canvas`. -The workflow is informed by DBeaver Community, but there is no DBeaver, Eclipse, OSGi, JDBC, or -Java code here. Keep it that way: user-visible strings name Redrob Query. +The workflow is informed by DBeaver Community and parts of the engine were ported from Beekeeper +Studio Community Edition (GPL-3.0-or-later, which is why this tree is GPL). There is no DBeaver, +Eclipse, OSGi, JDBC or Java code here. Keep it that way: user-visible strings name Redrob Query. ## Layout | Path | What lives there | | --- | --- | -| `src/` | The React 19 front end, 26 files. `components/` holds the panels (Navigator, QueryEditor, ResultGrid, AiPanel, CommandPalette), `store/` holds the Zustand workspace store and its localStorage persistence, `api/bridge.ts` is the single seam between the UI and either Tauri IPC or the browser DemoBridge, `domain/types.ts` is the shared type surface, `test/setup.ts` is the vitest jsdom setup. | -| `crates/redrob-core/` | The Rust core: `connection.rs`, `query.rs`, `profile_store.rs`, `secret.rs`, `ai.rs`, `models.rs`, `error.rs`. SQLx drives PostgreSQL/MySQL/SQLite, the `mongodb` crate drives Mongo, `reqwest` with rustls carries AI traffic. `publish = false`. | -| `src-tauri/` | The desktop shell. `src/commands.rs` holds 11 `#[tauri::command]` functions and `src/lib.rs` registers exactly those 11 in `generate_handler!`. `capabilities/default.json` grants only `core:default` to the `main` window. | -| `src-tauri/gen/schemas/` | Tauri-generated ACL and capability schemas, four JSON files, and they are committed. Do not hand-edit them; they are regenerated by the Tauri build. | -| `scripts/` | Four Node ESM scripts: `validate-release.mjs` (the release-metadata validator wired to `npm run release:check`), `prepare-release-config.mjs`, `release-matrix.mjs`, `rewrite-updater-urls.mjs`. | -| `docs/` | `ARCHITECTURE.md`, `SECURITY.md`, `DEMO.md`, `DBEAVER-PORTING-MAP.md`. | -| `dist/`, `target/`, `node_modules/` | Build output and dependencies, all three ignored by `.gitignore` and untracked. `git ls-files dist` returns nothing even though the directory exists locally. | - -Documentation is bilingual: `README.md` and `CONTRIBUTING.md` have `.ko.md` siblings. Change both -or neither. +| `apps/redrob-query/` | The shell. `main.rs` (window, icon, `--version`), `app.rs` (panels and event routing), `profiles.rs` (tree + connection form), `schema.rs` (lazy schema browser), `editor.rs` (tabs, run / format / save), `highlight.rs` (SQL/MQL lexer, formatter, starters), `grid.rs` (virtualised result grid, staging, exports), `changes.rs` (staged edits -> `CellEditSet`), `structure.rs` (keys/indexes + DDL forms), `mongo.rs` (request strip), `ai_dock.rs` (Ask), `account.rs` (shared account watcher), `workspace.rs` (tabs/history persistence), `services.rs` (config dir), `theme.rs` (the only colour literals). `build.rs` embeds the Windows icon and version info. | +| `crates/redrob-query-core/` | The engine: `connection.rs` (`DataService`), `query.rs` (read-only classification), `models.rs`, `profile_store.rs`, `secret.rs` (OS keyring), `cell_edits.rs`, `ddl.rs`, `keys.rs` (PK/FK/index readers), `account.rs` (the launcher's shared `account.json`), `ai.rs` (console-direct chat), `workspace.rs` (the `Workspace` facade the shell drives, one method per former Tauri command, answered through `Pending`). `publish = false`. | +| `brand/SOURCES.json` | sha256 pins of every brand asset against the design system; written by `scripts/build-brand-assets.py`, checked by `scripts/check-brand.py`. Never edit by hand. | +| `resources/icons`, `resources/brand` | Generated brand assets (hicolor PNGs, `.ico`, `.icns`, SVGs, lockups, splash). Owned by the brand pipeline. | +| `installer/windows/` | The bilingual (English / 한국어) Win32 setup and uninstaller, ported from redrob-launcher; `build.py` compiles it on a Windows runner. `installer/macos/` holds the dmg settings and the uninstaller script. | +| `tools/release/` | `stage.py` (per-platform tree: `bin/`, licences, the `.app`), `make_deb.py`; `tools/release_matrix.py` is the one list of the three targets. | +| `docs/` | `ARCHITECTURE.md`, `SECURITY.md`, `DBEAVER-PORTING-MAP.md`, `compatibility.md`, `dialect-verification.md`, `beekeeper-porting-boundary.md`, `upstream-sources.toml`. Some of these still describe the Tauri shell's paths; the engine descriptions hold. | +| `NOTICE`, `UPSTREAM_NOTICES.md`, `LICENSE` | Attribution. Upstream lines are never replaced, only added to. | + +Documentation is bilingual: `README.md` and `CONTRIBUTING.md` have `.ko.md` siblings. Change both or +neither. ## Toolchain and commands -Node with npm is the JavaScript package manager, pinned by `"packageManager": "npm@11.4.2"` with -`engines` requiring Node >= 22.12.0 and npm >= 11.0.0. Rust comes from rustup, and -`rust-toolchain.toml` pins channel `1.94.0` with `clippy` and `rustfmt`. The Cargo workspace root is -the repository root, not `src-tauri`; its members are `crates/redrob-core` and `src-tauri`. Install -JavaScript dependencies with `npm ci`, never `npm install`, so the lockfile decides. - -Commands run and their exit codes, on this checkout at `origin/develop` (`8c1dbb5`): +Rust comes from rustup; `rust-toolchain.toml` pins channel `1.95.0` with `clippy` and `rustfmt`, and +`rust-version` in `Cargo.toml` matches it. The workspace members are `crates/*` and `apps/*`. On Linux +the shell needs `libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev +libgtk-3-dev libdbus-1-dev`. Run desktop binaries on a display; `redrob-query --version` needs none. -| Command | Exit code | Note | -| --- | --- | --- | -| `npm run release:check` | 0 | 0.12s. Printed "Release metadata is synchronized for Redrob Query 0.2.0." | -| `npm run typecheck` | 0 | `tsc --noEmit -p tsconfig.app.json`. | -| `npm test` | 0 | vitest, 4 files, 74 tests passed, 11.8s. | -| `npm run build` | 0 | Runs typecheck then `vite build`, 1.5s. | -| `npm run rust:fmt` | 127 | `cargo: not found`. No rustup, cargo, or rustc on this host, so nothing Rust was verified locally. | +The gate, as CI runs it (`.github/workflows/ci.yml`, pull requests only): -`npm run check` is the whole gate in one command: `release:check`, `test`, `build`, `rust:fmt`, -`rust:clippy`, `rust:test`. Run it before asking for review. If Rust is not installed, say so in the -pull request rather than implying the Rust half passed. +```bash +python3 scripts/check-brand.py +cargo fmt --all -- --check +cargo clippy -p redrob-query -p redrob-query-core --all-targets --locked --profile ci -- -D warnings +cargo test -p redrob-query -p redrob-query-core --locked --profile ci +``` -Three connector tests in `redrob-core` are `#[ignore]` and need live databases. Cargo does not read -`.env`, so export the URLs from `.env.example` in your shell and run -`cargo test --locked -p redrob-core --all-features -- --ignored`. Skipping them is normal; claiming -they passed without exporting the URLs is not. +`--profile ci` compiles every dependency at opt-level 0 (root `Cargo.toml`); the `dev` profile +optimises dependencies at level 2, which makes a cold test run take far longer. Clippy runs with +`all` and `pedantic` warned and `-D warnings`, so a pedantic lint fails the build. `unsafe_code` is +forbidden workspace-wide. The release workflow repeats only `cargo fmt --check` and the brand check. -## Branch model and gitflow +Three connector tests in the core are `#[ignore]` and need live databases +(`REDROB_TEST_POSTGRES_URL`, `REDROB_TEST_MYSQL_URL`, `REDROB_TEST_MONGO_URL`). Skipping them is +normal; claiming they passed without exporting the URLs is not. -`develop` is the default branch and the base of every pull request. The exception open right now is -`release/v0.2.0`, which targets `main` -- that is what a release promotion -should do. `main` is released state and moves -only by merging `develop` (through a `release/*` or `hotfix/*` branch) into it. Release tags are cut -from `main`. +## Branch model -A hotfix branches from `main`, merges into `main`, releases, and THEN `main` is merged back into -`develop`. That last step is the one people drop, and dropping it is a real failure: the sibling -repository redrob-code spent a month with a lockfile its own default branch could not install from -because the back-merge never happened. Check the current state before you trust it: +`develop` is the default branch and the base of every pull request. `main` is released state and +moves only by a pull request from `develop` (a `release/` bump lands on `develop` first). +Release tags are cut from `main`; the release workflow refuses a tag that is not reachable from +`origin/main`, that does not equal the workspace version, or whose version has no `## ` +section in `CHANGELOG.md`. After a release, `main` is merged back into `develop`. Check before you +trust it: ```bash git rev-list --count origin/develop..origin/main # commits on main that develop lacks git rev-list --count origin/main..origin/develop # commits on develop that main lacks ``` -Both printed `0` when this file was written, so the two branches were identical and nothing was -owed in either direction. A nonzero first number means a release or hotfix has not been merged back -into `develop` and every branch cut from `develop` is missing it. - -One part of the convention is machine-checked: the release workflow fetches `origin/main` and fails -the tag unless `git merge-base --is-ancestor "$GITHUB_SHA" origin/main` holds, so tagging straight -off `develop` fails instead of shipping. Which branch a pull request comes FROM is not checkable by -a ruleset, so "only `release/*` and `hotfix/*` merge into `main`" rests on reviewers. - -Working branches are `/` off `develop`, types `feat`, `fix`, `chore`, `docs`, `test`, -`refactor`, `perf`. Merges are squash-only and the branch is deleted, so the squash body is the -pull request body. - -`.github/workflows/gitflow.yml` now checks both of those conventions instead of leaving them to -reviewers. Its allowed prefixes are `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `perf`, -`release`, and `hotfix`: the first seven are the working types above, and `release` and `hotfix` are -added because both documents give those branches a job (they are the only branches that may merge -into `main`, and a back-merge pull request rides on one), so a check that rejected them would fail -a legitimate pull request. A pull request from any other prefix, an agent or tool name for instance, -fails the `branch name follows the convention` job, and `develop` and `main` are exempt because a -promotion or back-merge branch is not named after a type. On every push to `main` the -`main is contained in develop` job counts `origin/develop..origin/main` and fails when it is -nonzero, which is the missing back-merge that cost the sibling repository a month of a broken -default branch. Neither job name collides with a required check, so nothing new gates a merge until -someone adds it to branch protection. - -## Protection, and what is actually enforced - -Both `develop` and `main` are protected the same way, read from the branch-protection API: - -- pull requests only, no direct pushes; -- force pushes blocked, deletion blocked; -- `required_approving_review_count` is 0, so a pull request can merge with no review; -- `enforce_admins` is off, so an admin is not bound by any of the above; -- required checks are `Format, clippy, tests` and `Release metadata, types, tests, build`. - -`Rust security advisories` is deliberately NOT required. An advisory check goes red when someone -else publishes a CVE, which has nothing to do with the change under review, and a required check -that a third party can turn red blocks every unrelated merge. Read it, do not gate on it. - -CI runs from `.github/workflows/ci.yml`, on `pull_request` and on pushes to `main` and `develop`. -Note what protection still does NOT give you: `required_approving_review_count` is 0 and -`enforce_admins` is off, so a pull request whose required checks pass can merge with no review, and -an admin is not bound by any of it. Read the checks yourself rather than waiting for GitHub to stop -you. - -`CONTRIBUTING.md` says both CI jobs "are required before a merge". The workflow now has three jobs, -not two, and only two of them are required, so correct that sentence when you next touch it. Trust -the branch-protection API over the prose either way. - -The CI workflow is deliberately `pull_request`, never `pull_request_target`, and consumes no -secrets, so a fork's pull request gets an identical run with no credential. Do not "fix" that by -switching the trigger. +Working branches are `/` off `develop`. Merges into `develop` are squash merges with an +explicit commit message; the branch is deleted afterwards. PR policy is the organisation's reusable +workflow (`.github/workflows/pr-policy.yml`). ## What an agent gets wrong first -- **`npm run release:check` is a real gate, not a formality.** `scripts/validate-release.mjs` - asserts the version is identical across `package.json`, the Cargo workspace, `tauri.conf.json`, - and both `package-lock.json` version fields; that `rust-version` in `Cargo.toml` matches the - `rust-toolchain.toml` channel; that `productName` is `Redrob Query` and the identifier is - `ai.redrob.query`; that the window label is `main` and `devUrl` is `http://127.0.0.1:1420`. Bump - a version in one file and this fails. -- **Every icon in `src-tauri/icons` must be RGBA.** The validator reads byte 25 of each PNG (the - IHDR colour type) and demands 6, because `tauri::generate_context!` panics at COMPILE TIME on a - non-RGBA icon. One RGB PNG means the desktop app cannot be built at all, and that shipped once. -- **Adding a Tauri command is a security change.** The read-only guarantee is that no mutation - command exists in the 11 registered in `src-tauri/src/lib.rs`. Registering one silently removes - the guarantee the README states. -- **GitHub Actions are SHA-pinned** in both workflows and updated by Dependabot's - `github-actions` ecosystem. Keep the `# vX.Y.Z` comment next to the SHA when you touch one. -- **`unsafe_code = "forbid"`** at the workspace level, with clippy `all` and `pedantic` warned. - CI runs clippy with `-D warnings`, so a pedantic lint fails the build. -- **`src-tauri/tauri.release.conf.json` is gitignored on purpose**: it is generated inside a - release runner by `scripts/prepare-release-config.mjs`. Do not create it locally and commit it. -- **Do not commit anything from `.env`.** `.env.example` is the only env file tracked, and database - secrets in the desktop app live in the OS keyring. This app holds no Redrob console key: the shared - Redrob Code daemon does (`PUT /auth/redrob`), and `crates/redrob-core/src/ai.rs` reaches the console - only through it (`redrob-engine-client`). There is no `REDROB_API_KEY` any more. Do not add a direct - console path for chat, do not store a key, and do not print one. +- **Brand assets are generated and pinned.** Editing anything under `resources/icons`, + `resources/brand` or `installer/*/res/icon.png|lockup.png` by hand fails `check-brand.py`. Rebuild + with `scripts/build-brand-assets.py --design-system ` (needs + ImageMagick 7) and commit `brand/SOURCES.json` with the outputs. +- **The read-only guarantee is the engine's gate, not the shell's.** `DataService::execute_query` + accepts read-only statements only; writes go through `apply_cell_edits` (one transaction, every + statement must change exactly one row) and `apply_table_change` (a fixed type list). Do not add a + write path that bypasses them. +- **No sign-in UI.** The account is Redrob Launcher's shared `account.json` + (`crates/redrob-query-core/src/account.rs`): read on every request, never written, never logged or + shown. `REDROB_API_KEY` and this app's own `account.json` win over it. Do not store a key in the + keyring or print one. The one allowed action is opening the launcher's page. +- **The AI path is console-direct.** `ai.rs` posts to `{apiBaseUrl}/chat/completions` with a bearer + key and model `auto` and surfaces the console's `error.message`. There is no Redrob Code daemon in + between any more; do not reintroduce `redrob-engine-client`. +- **Colour literals live in `theme.rs` only**, named after their design-system tokens. The shell + otherwise paints from egui's theme. +- **Workspace persistence is bounded.** 30 tabs, 50 history entries, query text only, written + atomically to `workspace.json`; `REDROB_QUERY_NO_PREFS` turns it off. Never persist result rows, + passwords or prompts. +- **Release assets must keep the launcher's names**: `redrob-query---[-portable|-setup].` + from `tools/release_matrix.py`. The Windows installed product is ProductName `Redrob Query`, + executable `bin\redrob-query.exe`, Settings > Apps key `RedrobQuery`. +- **GitHub Actions are SHA-pinned** in the workflows. Keep the `# vX.Y.Z` comment next to the SHA. +- **Do not commit secrets.** Database passwords live in the OS keyring; nothing in the tree should + hold one. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0469d8b..5664472 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,34 @@ The release workflow's notes point here, so this file is what a download's notes follow the `vMAJOR.MINOR.PATCH` tags that trigger a release; while the major is 0 a minor bump is where behaviour may change. +## 0.3.0 - 2026-10-16 + +### Changed + +- **Redrob Query is a native Rust app.** The Tauri 2 + React 19 shell, the browser demo, the + Beekeeper plugin host and the ER-diagram plugin are gone; the window is egui 0.36 on wgpu + (`apps/redrob-query`) over the same engine, renamed `redrob-query-core`. Profiles, schema browser, + tabbed SQL / Mongo JSON editor (colouring, Ctrl+Enter, Ctrl+Shift+F, Ctrl+S, starter queries, + read-only batches), virtualised result grid (page sort / filter, column visibility, TSV / JSON + export, JSON document view), reviewed cell edits and row deletes through the Changes panel, + structure panel with primary and foreign keys and indexes, create-table / add-column forms, and + the Ask dock are all native. Workspace persistence keeps 30 tabs and 50 history entries in + `workspace.json` (off with `REDROB_QUERY_NO_PREFS`). +- **Ask talks to the Redrob console directly** with the account Redrob Launcher signed this + computer in with (`account.json`, read on every request and watched while the app is open), as + the launcher's shared-account contract asks. The Redrob Code daemon path, the pasted-key dialog + and the keyring key migration are gone; the console's own error message is shown when it refuses. +- **Key and index metadata moved into the engine** (`keys.rs`): PostgreSQL catalog, MySQL + `information_schema`, SQLite table-valued pragmas, MongoDB index list. +- **Release and repository shape follow redrob-canvas.** One `Release` workflow builds the three + targets from the cargo binary -- Linux tar.gz + deb, macOS arm64 dmg + zip (signed, notarized, + stapled), Windows x64 portable zip + a bilingual (English / 한국어) setup (Authenticode-signed) -- + with `SHA256SUMS.txt` and `redrob-launcher.json` from the shared publish job. Brand assets are + built from the design system and pinned in `brand/SOURCES.json`. CI is one thin pull-request + workflow: the brand check plus fmt, clippy and the tests of our crates with `--profile ci`. +- The Tauri updater feed (`latest.json`) is no longer produced; Redrob Launcher installs from the + release assets. + ## 0.2.4 - 2026-10-14 ### Fixed diff --git a/CONTRIBUTING.ko.md b/CONTRIBUTING.ko.md index 5d69ff3..d2b9d01 100644 --- a/CONTRIBUTING.ko.md +++ b/CONTRIBUTING.ko.md @@ -54,17 +54,21 @@ ruleset은 pull request가 *어느* 브랜치에서 왔는지를 표현할 수 ```bash git switch develop && git pull -git switch -c release/v0.2.0 -# bump the version, update the changelog, run the release check -# open a pull request into main and merge it, then tag main: +git switch -c release/0.3.0 +# Cargo.toml의 버전을 올리고 CHANGELOG 절을 쓰고 게이트를 돌립니다 +# develop으로 풀 리퀘스트를 열어 머지하고, develop -> main 풀 리퀘스트(머지 커밋)를 머지한 뒤 main에 태그를 뗍니다: git switch main && git pull -git tag -a v0.2.0 -m "Redrob Query v0.2.0" -git push origin v0.2.0 -# bring main's release commit back so develop does not fall behind: -git switch -c chore/sync-main-to-develop main -# open a pull request into develop +git tag -a v0.3.0 -m "Redrob Query v0.3.0" +git push origin refs/tags/v0.3.0:refs/tags/v0.3.0 +gh workflow run release.yml --ref main -f tag=v0.3.0 +# 그다음 main을 develop으로 다시 머지합니다 ``` +태그는 `Cargo.toml`의 워크스페이스 버전과 같아야 하고 `CHANGELOG.md`에 `## ` 절이 있어야 +합니다. 모든 플랫폼 레그가 빌드·서명·검증을 마치면 릴리스는 바로 **발행**됩니다 — 승인할 초안이 +없습니다. 릴리스 없이 파이프라인만 돌려 보려면 **Release** 워크플로를 `dry_run=true`로 실행하세요. +지정한 ref를 빌드·서명하고 아무것도 발행하지 않습니다. + hotfix도 같은 형태이며, `hotfix/*`를 `develop`이 아니라 `main`에서 떼고 양쪽으로 머지합니다. 저장소를 포크하고, 브랜치를 자기 포크에 push한 뒤 거기서 pull request를 여십시오. 기여하는 데 @@ -76,52 +80,44 @@ hotfix도 같은 형태이며, `hotfix/*`를 `develop`이 아니라 `main`에서 git switch develop && git pull git switch -c fix/short-description -npm install -npm run typecheck -npm run test -npm run build - cargo fmt --all -cargo clippy --all-targets -- -D warnings +cargo clippy -p redrob-query -p redrob-query-core --all-targets -- -D warnings +cargo test -p redrob-query-core +python3 scripts/check-brand.py -npm run release:check # the release validator, before tagging +cargo run -p redrob-query # 앱 실행. 내장 Demo SQLite 프로필은 서버가 필요 없습니다 -# open a pull request into develop +# develop으로 풀 리퀘스트를 엽니다 ``` -브라우저 데모는 의도적으로 결정적인 인메모리 샘플 데이터를 씁니다. 데이터베이스 없이, 누구의 -자격증명도 없이 제품을 보여주기 위해서입니다. 오늘 찍은 스크린샷이 다음 달에 찍은 것과 같도록 -결정적으로 유지하십시오. +내장 Demo SQLite 프로필은 결정적인 샘플 데이터가 든 인메모리 데이터베이스입니다. 의도적인 것입니다. +서버도, 누구의 자격 증명도 없이 제품을 보여 주기 위해서입니다. 오늘 찍은 스크린샷이 다음 달에 찍은 것과 +같도록 결정적으로 유지하세요. ### 커밋 -제목은 명령형으로 씁니다. 본문에는 _왜_ 를 적고, 로컬에 저장되는 것이나 앱이 쓸 수 있는 범위를 -건드리는 변경이면 그 사실을 본문에 밝히십시오. +제목은 명령형으로. 본문에는 _왜_ 바꿨는지를 쓰고, 로컬에 저장되는 것이나 앱이 쓸 수 있는 범위가 바뀌면 +그 사실을 거기에 적습니다. ## 브랜딩 -`src/assets/`의 마크는 Redrob Cowork가 함께 배포하는 것과 같은 Redrob 마크이며, 앱 아이콘은 -그것으로 생성됩니다. 비슷하게 보이는 그림으로 대체하는 것이 제품에 로고가 두 개 생기는 경로입니다. +모든 브랜드 파일(`resources/icons`, `resources/brand`, 설치 프로그램의 `res/`)은 +`scripts/build-brand-assets.py`가 디자인 시스템 배포물에서 만들고 `brand/SOURCES.json`에 sha256으로 +고정합니다. 비슷하게 그린 그림으로 바꾸는 것이 제품에 로고가 둘 생기는 경로이고, +`scripts/check-brand.py`가 그것을 거부합니다. ## CI가 검사하는 것 -`.github/workflows/ci.yml`은 모든 pull request와 `main`, `develop`으로의 push에서 돕니다. 시크릿을 -전혀 쓰지 않으므로 포크에서 온 pull request도 여기 브랜치와 완전히 같은 실행을 받습니다. 잡은 둘이고, -둘 다 머지 전에 필수입니다. +`.github/workflows/ci.yml`은 모든 풀 리퀘스트에서 돕니다. 시크릿을 쓰지 않으므로 포크에서 온 풀 리퀘스트도 +여기 브랜치와 똑같이 실행됩니다. 작업은 둘입니다. | 검사 | 실행하는 것 | | --- | --- | -| **릴리스 메타데이터, 타입, 테스트, 빌드** | `npm run release:check`, `npm run typecheck`, `npm test`, `npm run build` | -| **포맷, clippy, 테스트** | `npm run rust:fmt`, `npm run rust:clippy` (`-D warnings`), `npm run rust:test` | - -알아둘 만한 것은 `npm run release:check`입니다. 여러 파일로 퍼져 있어 조용히 틀어지는 것들을 -고정합니다. `package.json`, `Cargo.toml`, `tauri.conf.json`, `package-lock.json`의 버전, Cargo의 -`rust-version`과 `rust-toolchain.toml`의 일치, Tauri의 `productName`과 `identifier`, 그리고 -`src-tauri/icons/` 아래 모든 PNG가 RGBA인지입니다. 마지막 항목은 겉모습 문제가 아닙니다. -`tauri::generate_context!`는 RGBA가 아닌 아이콘에서 컴파일 타임에 panic하므로, RGB 아이콘 하나면 -데스크톱 앱이 빌드되지 않습니다. +| **brand assets** | `python3 scripts/check-brand.py` | +| **fmt, clippy, test (profile ci)** | `cargo fmt --all -- --check`, `cargo clippy -p redrob-query -p redrob-query-core --all-targets --locked --profile ci -- -D warnings`, 같은 크레이트의 `cargo test`. Rust 입력이 바뀌지 않았거나 풀 리퀘스트가 `main`을 향하면 건너뜁니다 | -`npm run check`는 이 전체를 한 명령으로 로컬에서 돌립니다. +릴리스 워크플로(`.github/workflows/release.yml`)는 브랜드 검사와 `cargo fmt --check`만 다시 돌리고 세 +타깃을 빌드·서명·게시합니다. ## 의존성 업데이트 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4cf0995..4a2bf5c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -53,17 +53,22 @@ from `origin/main`, so tagging straight off `develop` fails instead of shipping. ```bash git switch develop && git pull -git switch -c release/v0.2.0 -# bump the version, update the changelog, run the release check -# open a pull request into main and merge it, then tag main: +git switch -c release/0.3.0 +# bump the version in Cargo.toml, write the CHANGELOG section, run the gate +# open a pull request into develop and merge it; then a pull request develop -> main +# (merge commit) and tag main: git switch main && git pull -git tag -a v0.2.0 -m "Redrob Query v0.2.0" -git push origin v0.2.0 -# bring main's release commit back so develop does not fall behind: -git switch -c chore/sync-main-to-develop main -# open a pull request into develop +git tag -a v0.3.0 -m "Redrob Query v0.3.0" +git push origin refs/tags/v0.3.0:refs/tags/v0.3.0 +gh workflow run release.yml --ref main -f tag=v0.3.0 +# then merge main back into develop ``` +The tag must equal the workspace version in `Cargo.toml` and `CHANGELOG.md` must carry a +`## ` section; the release is **published** when every platform leg has built, signed and +verified -- there is no draft to approve. To exercise the pipeline without releasing, run the +**Release** workflow with `dry_run=true`: it builds and signs the dispatched ref and publishes nothing. + A hotfix is the same shape with `hotfix/*` cut from `main` rather than `develop`, and it merges into both. @@ -76,22 +81,19 @@ need write access to contribute, and pull requests from forks run CI with no rep git switch develop && git pull git switch -c fix/short-description -npm install -npm run typecheck -npm run test -npm run build - cargo fmt --all -cargo clippy --all-targets -- -D warnings +cargo clippy -p redrob-query -p redrob-query-core --all-targets -- -D warnings +cargo test -p redrob-query-core +python3 scripts/check-brand.py -npm run release:check # the release validator, before tagging +cargo run -p redrob-query # the app; the built-in Demo SQLite profile needs no server # open a pull request into develop ``` -The browser demo is backed by deterministic in-memory sample data, on purpose: it demonstrates the -product without a database and without anybody's credentials. Keep it deterministic, so a screenshot -taken today matches one taken next month. +The built-in Demo SQLite profile is an in-memory database with deterministic sample data, on +purpose: it demonstrates the product without a server and without anybody's credentials. Keep it +deterministic, so a screenshot taken today matches one taken next month. ### Commits @@ -100,29 +102,23 @@ or what the app is allowed to write, say so there. ## Branding -The mark in `src/assets/` is the Redrob mark, the same one Redrob Cowork ships, and the app icons are -generated from it. Replacing it with a drawing that merely resembles it is how a product ends up with -two logos. +Every brand file (`resources/icons`, `resources/brand`, the installers' `res/`) is built from the +design system's published files by `scripts/build-brand-assets.py` and pinned by sha256 in +`brand/SOURCES.json`. Replacing one with a drawing that merely resembles it is how a product ends up +with two logos, and `scripts/check-brand.py` refuses it. ## What CI checks -`.github/workflows/ci.yml` runs on every pull request and on pushes to `main` and `develop`. It consumes no -secrets, so a pull request from a fork gets exactly the same run as one from a branch here. Two jobs, -and both are required before a merge: +`.github/workflows/ci.yml` runs on every pull request. It consumes no secrets, so a pull request from +a fork gets exactly the same run as one from a branch here. Two jobs: | Check | What it runs | | --- | --- | -| **Release metadata, types, tests, build** | `npm run release:check`, `npm run typecheck`, `npm test`, `npm run build` | -| **Format, clippy, tests** | `npm run rust:fmt`, `npm run rust:clippy` (`-D warnings`), `npm run rust:test` | - -`npm run release:check` is the one worth knowing about. It pins the things that fan out across -several files and go wrong silently: the version in `package.json`, `Cargo.toml`, `tauri.conf.json` -and `package-lock.json`; the Cargo `rust-version` against `rust-toolchain.toml`; the Tauri -`productName` and `identifier`; and that every PNG under `src-tauri/icons/` is RGBA. The last one is -not cosmetic — `tauri::generate_context!` panics at compile time on a non-RGBA icon, so a single RGB -icon means the desktop app does not build. +| **brand assets** | `python3 scripts/check-brand.py` | +| **fmt, clippy, test (profile ci)** | `cargo fmt --all -- --check`, `cargo clippy -p redrob-query -p redrob-query-core --all-targets --locked --profile ci -- -D warnings`, `cargo test` of the same crates. Skipped when no Rust input changed or when the pull request targets `main` | -`npm run check` runs the whole set locally in one command. +The release workflow (`.github/workflows/release.yml`) repeats only the brand check and +`cargo fmt --check`, then builds, signs and publishes the three targets. ## Dependency updates diff --git a/Cargo.lock b/Cargo.lock index 128b74f..e5a8863 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,121 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "enumn", + "serde", + "uuid", +] + +[[package]] +name = "accesskit_atspi_common" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8c61bee90b42a772d39d06a740207dc71a4e780004ace1db8d99fb1baaa954" +dependencies = [ + "accesskit", + "accesskit_consumer 0.36.0", + "atspi-common", + "phf", + "serde", + "zvariant", +] + +[[package]] +name = "accesskit_consumer" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e0d7e25d06f4dc21d1774d67146e9e80d6789216cbd4d1e88185b0095dba60" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + +[[package]] +name = "accesskit_macos" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" +dependencies = [ + "accesskit", + "accesskit_consumer 0.38.0", + "hashbrown 0.16.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "accesskit_unix" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b016ca8db0ea0ea2ceff29a9d6240391492d960716aa471967c00e8cc8cb197c" +dependencies = [ + "accesskit", + "accesskit_atspi_common", + "async-channel", + "async-executor", + "async-task", + "atspi", + "futures-lite", + "futures-util", + "serde", + "zbus", +] + +[[package]] +name = "accesskit_windows" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +dependencies = [ + "accesskit", + "accesskit_consumer 0.35.0", + "hashbrown 0.16.1", + "static_assertions", + "windows", + "windows-core", +] + +[[package]] +name = "accesskit_winit" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +dependencies = [ + "accesskit", + "accesskit_macos", + "accesskit_unix", + "accesskit_windows", + "raw-window-handle", + "winit", +] + [[package]] name = "adler2" version = "2.0.1" @@ -17,6 +132,7 @@ dependencies = [ "cfg-if", "getrandom 0.3.4", "once_cell", + "serde", "version_check", "zerocopy", ] @@ -31,25 +147,35 @@ dependencies = [ ] [[package]] -name = "alloc-no-stdlib" -version = "2.0.4" +name = "allocator-api2" +version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] -name = "alloc-stdlib" -version = "0.2.4" +name = "android-activity" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ - "alloc-no-stdlib", + "android-properties", + "bitflags 2.13.2", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.21", ] [[package]] -name = "allocator-api2" -version = "0.2.21" +name = "android-properties" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" [[package]] name = "android_system_properties" @@ -61,43 +187,225 @@ dependencies = [ ] [[package]] -name = "anyhow" -version = "1.0.104" +name = "anstream" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] [[package]] -name = "async-trait" -version = "0.1.92" +name = "anstyle" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" +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 = "arboard" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aeb9553e62312c8756a5979254fc6d7da8bbeb5986a1e17037fab932f7037917" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "windows-sys 0.61.2", + "x11rb", +] + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix 1.1.5", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix 1.1.5", +] + +[[package]] +name = "async-recursion" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] -name = "atk" -version = "0.18.2" +name = "async-signal" +version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" dependencies = [ - "atk-sys", - "glib", - "libc", + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix 1.1.5", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", ] [[package]] -name = "atk-sys" -version = "0.18.2" +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "proc-macro2", + "quote", + "syn 3.0.9", ] [[package]] @@ -116,16 +424,47 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "autocfg" -version = "1.5.1" +name = "atspi" +version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" +checksum = "c77886257be21c9cd89a4ae7e64860c6f0eefca799bb79127913052bd0eefb3d" +dependencies = [ + "atspi-common", + "atspi-proxies", +] [[package]] -name = "base64" -version = "0.21.7" +name = "atspi-common" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20c5617155740c98003016429ad13fe43ce7a77b007479350a9f8bf95a29f63d" +dependencies = [ + "enumflags2", + "serde", + "static_assertions", + "zbus", + "zbus-lockstep", + "zbus-lockstep-macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "atspi-proxies" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2230e48787ed3eb4088996eab66a32ca20c0b67bbd4fd6cdfe79f04f1f04c9fc" +dependencies = [ + "atspi-common", + "serde", + "zbus", +] + +[[package]] +name = "autocfg" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "base64" @@ -141,9 +480,9 @@ checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "bigdecimal" -version = "0.4.10" +version = "0.4.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +checksum = "8fa3f3d8cbf4dffcfe4991de61d012bef509a409ecbe9dd41049bfe32b4d4653" dependencies = [ "autocfg", "libm", @@ -154,18 +493,18 @@ dependencies = [ [[package]] name = "bit-set" -version = "0.8.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" dependencies = [ "bit-vec", ] [[package]] name = "bit-vec" -version = "0.8.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" [[package]] name = "bitflags" @@ -175,9 +514,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ "serde_core", ] @@ -214,41 +553,33 @@ dependencies = [ [[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" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", + "objc2 0.5.3", ] [[package]] -name = "brotli-decompressor" -version = "5.0.3" +name = "block2" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", + "objc2 0.6.5", ] [[package]] -name = "bs58" -version = "0.5.1" +name = "blocking" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +checksum = "a70e4329df6cb94385eed412ec92375c3cdd8a6e502493d1229b6414e4036dfa" dependencies = [ - "tinyvec", + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", ] [[package]] @@ -263,7 +594,7 @@ dependencies = [ "getrandom 0.2.17", "getrandom 0.3.4", "hex", - "indexmap 2.14.2", + "indexmap", "js-sys", "once_cell", "rand 0.9.5", @@ -285,6 +616,20 @@ name = "bytemuck" version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] [[package]] name = "byteorder" @@ -292,131 +637,101 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[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" +name = "calloop" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.13.1", - "cairo-sys-rs", - "glib", - "libc", - "once_cell", + "bitflags 2.13.2", + "log", + "polling", + "rustix 0.38.44", + "slab", "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" +name = "calloop" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb1307f12aa967b5a58416e87b3653360e0fd614a016b6e970db08fecbb1b80d" +checksum = "43b32313e937c8721c9d17ee9bf60699a6402b4b7c2cb6ac1bee0ffcaa4b4e5e" dependencies = [ - "serde_core", + "bitflags 2.13.2", + "polling", + "rustix 1.1.5", + "tracing", ] [[package]] -name = "cargo-platform" -version = "0.1.9" +name = "calloop-wayland-source" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" dependencies = [ - "serde", + "calloop 0.13.0", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", ] [[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" +name = "calloop-wayland-source" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +checksum = "138efcf0940a02ebf0cc8d1eff41a1682a46b431630f4c52450d6265876021fa" dependencies = [ - "serde", - "toml 0.9.12+spec-1.1.0", + "calloop 0.14.5", + "rustix 1.1.5", + "wayland-backend", + "wayland-client", ] [[package]] name = "cc" -version = "1.4.5" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] [[package]] -name = "cesu8" -version = "1.1.0" +name = "cfg-if" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] -name = "cfb" -version = "0.7.3" +name = "cfg_aliases" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" -dependencies = [ - "byteorder", - "fnv", - "uuid", -] +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] -name = "cfg-expr" -version = "0.15.8" +name = "cgl" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" dependencies = [ - "smallvec", - "target-lexicon", + "libc", ] -[[package]] -name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - [[package]] name = "chacha20" version = "0.10.2" @@ -439,7 +754,16 @@ dependencies = [ "num-traits", "serde", "wasm-bindgen", - "windows-link 0.2.1", + "windows-link", +] + +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", ] [[package]] @@ -448,16 +772,51 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[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.8" +version = "4.6.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +checksum = "4356eba6976435498d05a311ee9fc486da3bc81c200d3f0d7dd9703b41913f92" dependencies = [ "bytes", "memchr", ] +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "const-oid" version = "0.10.2" @@ -493,16 +852,6 @@ dependencies = [ "unicode-segmentation", ] -[[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.9.4" @@ -531,12 +880,12 @@ checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" [[package]] name = "core-graphics" -version = "0.25.0" +version = "0.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", + "bitflags 1.3.2", + "core-foundation 0.9.4", "core-graphics-types", "foreign-types", "libc", @@ -544,12 +893,12 @@ dependencies = [ [[package]] name = "core-graphics-types" -version = "0.2.0" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" dependencies = [ - "bitflags 2.13.1", - "core-foundation 0.10.1", + "bitflags 1.3.2", + "core-foundation 0.9.4", "libc", ] @@ -588,9 +937,9 @@ checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" [[package]] name = "crc32fast" -version = "1.5.1" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8498c871161e1742aaa9d52551b2d6ebdd4c3d45a3be423e3728f33b955be550" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] @@ -642,9 +991,9 @@ checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" [[package]] name = "crypto-common" -version = "0.1.7" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" dependencies = [ "generic-array", "typenum", @@ -660,58 +1009,25 @@ dependencies = [ ] [[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" +name = "ctutils" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "352d39c2f7bef1d6ad73db6f5160efcaed66d94ef8c6c573a8410c00bf909a98" +checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574" dependencies = [ - "ctor-proc-macro", - "dtor", + "cmov", ] [[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - -[[package]] -name = "ctutils" -version = "0.4.2" +name = "cursor-icon" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" -dependencies = [ - "cmov", -] +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ "darling_core", "darling_macro", @@ -719,26 +1035,26 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ "darling_core", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -747,22 +1063,11 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" -[[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" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1" dependencies = [ "bitflags 1.3.2", "defmt-macros", @@ -770,33 +1075,30 @@ dependencies = [ [[package]] name = "defmt-macros" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366" dependencies = [ "defmt-parser", "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] name = "defmt-parser" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0" dependencies = [ - "thiserror 2.0.20", + "thiserror 2.0.21", ] [[package]] name = "deranged" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "serde_core", -] +checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e" [[package]] name = "derive-syn-parse" @@ -811,13 +1113,13 @@ dependencies = [ [[package]] name = "derive-where" -version = "1.6.1" +version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d08b3a0bcc0d079199cd476b2cae8435016ec11d1c0986c6901c5ac223041534" +checksum = "2e2b94854e8576378ccda7c8de8a66ed8b4e8acbd2c50ec3418ea6c8aaf4b567" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -850,7 +1152,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", - "crypto-common 0.1.7", + "crypto-common 0.1.6", ] [[package]] @@ -866,25 +1168,10 @@ dependencies = [ ] [[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" +name = "dispatch" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" -dependencies = [ - "libc", - "option-ext", - "redox_users", - "windows-sys 0.61.2", -] +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" [[package]] name = "dispatch2" @@ -892,10 +1179,10 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.13.1", - "block2", + "bitflags 2.13.2", + "block2 0.6.2", "libc", - "objc2", + "objc2 0.6.5", ] [[package]] @@ -906,149 +1193,322 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] -name = "dlopen2" -version = "0.8.2" +name = "dlib" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "dlopen2_derive", - "libc", - "once_cell", - "winapi", + "libloading", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0656a7530b9e2841ee7a5f983965bdd9840c32f4012f759cfd44b912cced6b34" +dependencies = [ + "bytemuck", + "emath", + "serde", +] + +[[package]] +name = "eframe" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55ad41fac6e3149abb4866e93df37dbd33baa95db525c3368c944584e95a5dc2" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "egui-wgpu", + "egui-winit", + "egui_glow", + "glutin", + "glutin-winit", + "image", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "parking_lot", + "percent-encoding", + "pollster 1.0.1", + "profiling", + "raw-window-handle", + "ron", + "serde", + "static_assertions", + "wasm-bindgen", + "web-sys", + "web-time", + "wgpu", + "windows-sys 0.61.2", + "winit", +] + +[[package]] +name = "egui" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc938cc27cd911415e1e4d151bc56ff9df063d8db081374e87aeb805ea74b2ba" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.2", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "ron", + "serde", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-wgpu" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cef1f6d1bba40b09a4d0d62cb822b81030922cfbf65c5373398c3a3caafc5bd" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "egui", + "epaint", + "log", + "profiling", + "thiserror 2.0.21", + "type-map", + "web-time", + "wgpu", + "winit", +] + +[[package]] +name = "egui-winit" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98466000559d66ba4db786a30448b01483946add4188e7f10f522a27b4fb89cc" +dependencies = [ + "accesskit_winit", + "arboard", + "bytemuck", + "egui", + "log", + "objc2 0.6.5", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "serde", + "smithay-clipboard", + "web-time", + "winit", ] [[package]] -name = "dlopen2_derive" -version = "0.4.3" +name = "egui_extras" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +checksum = "05cbdc785020861553f230f23d330ba1fb72613b9d8e0e457f3bdc8ba6db6dcf" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "ahash", + "egui", + "enum-map", + "itertools", + "log", + "profiling", ] [[package]] -name = "dom_query" -version = "0.27.0" +name = "egui_glow" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +checksum = "606ed4167daf341e5753d703285c4c1d562324b2c07aa8f405cd7d066fe58f3b" dependencies = [ - "bit-set", - "cssparser", - "foldhash", - "html5ever", - "precomputed-hash", - "selectors", - "tendril", + "bytemuck", + "egui", + "glow", + "log", + "profiling", + "winit", ] [[package]] -name = "dotenvy" -version = "0.15.7" +name = "either" +version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be" +dependencies = [ + "serde", +] [[package]] -name = "dpi" -version = "0.1.2" +name = "emath" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +checksum = "52c8d4141cf0f60fb2f7aefaf352d3ffa5171ef0ab09731c64391801e95e48f1" dependencies = [ + "bytemuck", "serde", ] [[package]] -name = "dtoa" -version = "1.0.11" +name = "endi" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" [[package]] -name = "dtoa-short" -version = "0.3.5" +name = "enum-map" +version = "2.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +checksum = "6866f3bfdf8207509a033af1a75a7b08abda06bbaaeae6669323fd5a097df2e9" dependencies = [ - "dtoa", + "enum-map-derive", ] [[package]] -name = "dtor" -version = "0.3.0" +name = "enum-map-derive" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1057d6c64987086ff8ed0fd3fbf377a6b7d205cc7715868cd401705f715cbe4" +checksum = "f282cfdfe92516eb26c2af8589c274c7c17681f5ecc03c18255fe741c6aa64eb" dependencies = [ - "dtor-proc-macro", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "dtor-proc-macro" -version = "0.0.6" +name = "enumflags2" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] [[package]] -name = "dunce" -version = "1.0.5" +name = "enumflags2_derive" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "dyn-clone" -version = "1.0.20" +name = "enumn" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +checksum = "2f9ed6b3789237c8a0c1c505af1c7eb2c560df6186f01b098c3a1064ea532f38" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] -name = "either" -version = "1.18.0" +name = "env_filter" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" dependencies = [ - "serde", + "log", + "regex", ] [[package]] -name = "embed-resource" -version = "3.0.11" +name = "env_logger" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fbfdaacccebec3b28e4866b8973543c7647797db5ada1bdab552e48fe665fbbd" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" dependencies = [ - "cc", - "memchr", - "rustc_version", - "toml 1.1.5+spec-1.1.0", - "vswhom", - "winreg", + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", ] [[package]] -name = "embed_plist" -version = "1.2.2" +name = "epaint" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +checksum = "a0ec0308dc23130fc1a7d4623a8534c2ed069c6dc84e258ee4f3de99615aa1a3" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "serde", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] [[package]] -name = "equivalent" -version = "1.0.2" +name = "epaint_default_fonts" +version = "0.36.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +checksum = "773fa9c96dd0dbef887e39d0ed6177f141cce4d68e6041df77570aa3702dfa13" [[package]] -name = "erased-serde" -version = "0.4.10" +name = "equivalent" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" [[package]] name = "errno" @@ -1060,6 +1520,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "etcetera" version = "0.11.0" @@ -1070,6 +1536,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + [[package]] name = "event-listener" version = "5.4.2" @@ -1080,12 +1555,28 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + [[package]] name = "fastrand" version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1096,20 +1587,16 @@ dependencies = [ ] [[package]] -name = "field-offset" -version = "0.3.6" +name = "fearless_simd" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" -dependencies = [ - "memoffset", - "rustc_version", -] +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" [[package]] name = "find-msvc-tools" -version = "0.1.12" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" @@ -1133,18 +1620,22 @@ dependencies = [ "spin", ] -[[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 = "font-types" +version = "0.12.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "835ae9d781464a79026cde387f9cbc650c9a4909d7cf7e658b127e3c35d2f35d" +dependencies = [ + "bytemuck", + "serde", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -1163,7 +1654,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1241,6 +1732,19 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-macro" version = "0.3.34" @@ -1249,7 +1753,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] @@ -1281,112 +1785,23 @@ dependencies = [ ] [[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" +name = "generic-array" +version = "0.14.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2" dependencies = [ - "gdk-sys", - "glib-sys", - "libc", - "system-deps", - "x11", + "typenum", + "version_check", ] [[package]] -name = "generic-array" -version = "0.14.7" +name = "gethostname" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" dependencies = [ - "typenum", - "version_check", + "rustix 1.1.5", + "windows-link", ] [[package]] @@ -1431,158 +1846,156 @@ dependencies = [ ] [[package]] -name = "gio" -version = "0.18.4" +name = "gl_generator" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" dependencies = [ - "futures-channel", - "futures-core", - "futures-io", - "futures-util", - "gio-sys", - "glib", - "libc", - "once_cell", - "pin-project-lite", + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown 0.17.1", + "log", + "peniko", + "skrifa", "smallvec", - "thiserror 1.0.69", + "vello_common", ] [[package]] -name = "gio-sys" -version = "0.18.1" +name = "glow" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +checksum = "29038e1c483364cc6bb3cf78feee1816002e127c331a1eec55a4d202b9e1adb5" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", - "winapi", + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", ] [[package]] -name = "glib" -version = "0.18.5" +name = "glutin" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +checksum = "12124de845cacfebedff80e877bb37b5b75c34c5a4c89e47e1cdd67fb6041325" 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", + "bitflags 2.13.2", + "cfg_aliases", + "cgl", + "dispatch2", + "glutin_egl_sys", + "glutin_glx_sys", + "glutin_wgl_sys", + "libloading", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "once_cell", - "smallvec", - "thiserror 1.0.69", + "raw-window-handle", + "wayland-sys", + "windows-sys 0.52.0", + "x11-dl", ] [[package]] -name = "glib-macros" -version = "0.18.5" +name = "glutin-winit" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +checksum = "85edca7075f8fc728f28cb8fbb111a96c3b89e930574369e3e9c27eb75d3788f" dependencies = [ - "heck 0.4.1", - "proc-macro-crate 2.0.2", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", + "cfg_aliases", + "glutin", + "raw-window-handle", + "winit", ] [[package]] -name = "glib-sys" -version = "0.18.1" +name = "glutin_egl_sys" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +checksum = "4c4680ba6195f424febdc3ba46e7a42a0e58743f2edb115297b86d7f8ecc02d2" dependencies = [ - "libc", - "system-deps", + "gl_generator", + "windows-sys 0.52.0", ] [[package]] -name = "glob" -version = "0.3.4" +name = "glutin_glx_sys" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +checksum = "8a7bb2938045a88b612499fbcba375a77198e01306f52272e692f8c1f3751185" +dependencies = [ + "gl_generator", + "x11-dl", +] [[package]] -name = "gobject-sys" -version = "0.18.0" +name = "glutin_wgl_sys" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e" dependencies = [ - "glib-sys", - "libc", - "system-deps", + "gl_generator", ] [[package]] -name = "gtk" -version = "0.18.2" +name = "gpu-allocator" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" dependencies = [ - "atk", - "cairo-rs", - "field-offset", - "futures-channel", - "gdk", - "gdk-pixbuf", - "gio", - "glib", - "gtk-sys", - "gtk3-macros", - "libc", - "pango", - "pkg-config", + "ash", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.21", + "windows", ] [[package]] -name = "gtk-sys" -version = "0.18.2" +name = "guillotiere" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" dependencies = [ - "atk-sys", - "cairo-sys-rs", - "gdk-pixbuf-sys", - "gdk-sys", - "gio-sys", - "glib-sys", - "gobject-sys", - "libc", - "pango-sys", - "system-deps", + "euclid", ] [[package]] -name = "gtk3-macros" -version = "0.18.2" +name = "half" +version = "2.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" dependencies = [ - "proc-macro-crate 1.3.1", - "proc-macro-error", - "proc-macro2", - "quote", - "syn 2.0.119", + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", ] [[package]] -name = "hashbrown" -version = "0.12.3" +name = "harfrust" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "read-fonts", + "smallvec", +] [[package]] name = "hashbrown" @@ -1600,6 +2013,9 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] [[package]] name = "hashlink" @@ -1610,18 +2026,18 @@ dependencies = [ "hashbrown 0.16.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 = "hermit-abi" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e17592d60ebacc7d5e169f4663c5f84f9161cc90328abcfe8456f41e4dfcb284" + [[package]] name = "hex" version = "0.4.3" @@ -1630,9 +2046,9 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" [[package]] name = "hickory-net" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084e7bd6a377435d568f652153e571b50970d7ccc1d1eeec0519f834632287e1" +checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d" dependencies = [ "async-trait", "cfg-if", @@ -1643,9 +2059,9 @@ dependencies = [ "hickory-proto", "idna", "ipnet", - "jni 0.22.4", - "rand 0.10.2", - "thiserror 2.0.20", + "jni", + "rand 0.10.3", + "thiserror 2.0.21", "tinyvec", "tokio", "tracing", @@ -1654,19 +2070,19 @@ dependencies = [ [[package]] name = "hickory-proto" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e2da0694c15b44c6f68a6b05e0233617008c54080e31d6eb848d858a9c5b38d" +checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d" dependencies = [ "data-encoding", "idna", "ipnet", - "jni 0.22.4", + "jni", "once_cell", "prefix-trie", - "rand 0.10.2", + "rand 0.10.3", "ring", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "url", @@ -1674,9 +2090,9 @@ dependencies = [ [[package]] name = "hickory-resolver" -version = "0.26.2" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e4f9f4603319422d482738f3f6fe5aac03157fdbfed1cd85a3ff45adb09072f" +checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981" dependencies = [ "cfg-if", "futures-util", @@ -1684,16 +2100,16 @@ dependencies = [ "hickory-proto", "ipconfig", "ipnet", - "jni 0.22.4", + "jni", "moka", "ndk-context", "once_cell", "parking_lot", - "rand 0.10.2", + "rand 0.10.3", "resolv-conf", "smallvec", "system-configuration", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", ] @@ -1716,16 +2132,6 @@ dependencies = [ "digest 0.11.3", ] -[[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" @@ -1767,22 +2173,21 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hybrid-array" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "1.11.1" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" dependencies = [ "atomic-waker", "bytes", - "futures-channel", "futures-core", "http", "http-body", @@ -1796,14 +2201,15 @@ dependencies = [ [[package]] name = "hyper-rustls" -version = "0.27.9" +version = "0.27.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" dependencies = [ "http", "hyper", "hyper-util", "rustls", + "rustls-native-certs", "tokio", "tokio-rustls", "tower-service", @@ -1812,25 +2218,28 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bytes", "futures-channel", "futures-util", "http", "http-body", + "httparse", "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1845,7 +2254,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core", ] [[package]] @@ -1857,16 +2266,6 @@ 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.3.0" @@ -1978,14 +2377,17 @@ dependencies = [ ] [[package]] -name = "indexmap" -version = "1.9.3" +name = "image" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ - "autocfg", - "hashbrown 0.12.3", - "serde", + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png", + "tiff", ] [[package]] @@ -1996,17 +2398,6 @@ checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" 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]] @@ -2018,7 +2409,7 @@ dependencies = [ "socket2", "widestring", "windows-registry", - "windows-result 0.4.1", + "windows-result", "windows-sys 0.61.2", ] @@ -2032,65 +2423,56 @@ dependencies = [ ] [[package]] -name = "itoa" -version = "1.0.18" +name = "is_terminal_polyfill" +version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] -name = "javascriptcore-rs" -version = "1.1.2" +name = "itertools" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ - "bitflags 1.3.2", - "glib", - "javascriptcore-rs-sys", + "either", ] [[package]] -name = "javascriptcore-rs-sys" -version = "1.1.1" +name = "itoa" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" -dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", -] +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.35" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +checksum = "b2b005715dcbeb0089a3c0dab99f2ff1cc3b2525323552703d648585d342a383" 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" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" dependencies = [ "defmt", + "log", ] [[package]] name = "jiff-static" -version = "0.2.35" +version = "0.2.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +checksum = "2cc9817253cf7c7ee4684451bd327e88d6f3658014e54a29198625590650695c" dependencies = [ "jiff-core", "proc-macro2", @@ -2098,37 +2480,6 @@ dependencies = [ "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" version = "0.22.4" @@ -2141,9 +2492,9 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.20", + "thiserror 2.0.21", "walkdir", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2188,47 +2539,24 @@ dependencies = [ ] [[package]] -name = "js-sys" -version = "0.3.105" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e" -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" +name = "jobserver" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "serde", - "serde_json", + "getrandom 0.4.3", + "libc", ] [[package]] -name = "keyboard-types" -version = "0.7.0" +name = "js-sys" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ - "bitflags 2.13.1", - "serde", - "unicode-segmentation", + "cfg-if", + "futures-util", + "wasm-bindgen", ] [[package]] @@ -2247,52 +2575,48 @@ dependencies = [ ] [[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" +name = "khronos-egl" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ - "gtk-sys", + "libc", "libloading", - "once_cell", + "pkg-config", ] [[package]] -name = "libc" -version = "0.2.189" +name = "khronos_api" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] -name = "libdbus-sys" -version = "0.2.7" +name = "kurbo" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" dependencies = [ - "pkg-config", + "arrayvec", + "euclid", + "polycool", + "smallvec", ] +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + [[package]] name = "libloading" -version = "0.7.4" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" dependencies = [ "cfg-if", - "winapi", + "windows-link", ] [[package]] @@ -2303,34 +2627,49 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.23" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8d8f1ea3f21fd3405dcaf6c9b5c1630af9afc422d9073ea39c5f6d6c772e08ed" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ + "bitflags 2.13.2", "libc", + "plain", + "redox_syscall 0.9.4", ] [[package]] name = "libsqlite3-sys" -version = "0.30.1" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" dependencies = [ "cc", "pkg-config", "vcpkg", ] +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + [[package]] name = "linux-keyutils" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "libc", ] +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -2343,6 +2682,12 @@ version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +[[package]] +name = "litrs" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" + [[package]] name = "lock_api" version = "0.4.14" @@ -2360,9 +2705,9 @@ checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "macro_magic" @@ -2412,17 +2757,6 @@ dependencies = [ "syn 2.0.119", ] -[[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 = "md-5" version = "0.11.0" @@ -2439,6 +2773,15 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + [[package]] name = "memoffset" version = "0.9.1" @@ -2448,12 +2791,6 @@ 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" @@ -2476,9 +2813,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -2522,12 +2859,12 @@ checksum = "851fac73f7fe22f6a3ab87f720ce509cae7c9fd08e7dd27866cc232dee07ccf4" [[package]] name = "mongodb" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd64784a1fdbcf2a334717446edc583061159067a2e7cc326ee567ecf035f07b" +checksum = "af84443ae9878f59a6818686f339064479f42cdf293c4c637d8fc9c0a8c5eed6" dependencies = [ "base64 0.22.1", - "bitflags 2.13.1", + "bitflags 2.13.2", "bson", "derive-where", "derive_more", @@ -2552,12 +2889,12 @@ dependencies = [ "serde_bytes", "serde_with", "sha1", - "sha2 0.11.0", + "sha2 0.11.1", "socket2", "stringprep", "strsim", "take_mut", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-rustls", "tokio-util", @@ -2568,9 +2905,9 @@ dependencies = [ [[package]] name = "mongodb-internal-macros" -version = "3.9.0" +version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f243f9039382cd06378f13980b79700236468b6fb22f24abfa99783e9569f1a3" +checksum = "504024bbb83ab1bf1512007f7288b54d3a6343043f3f619c26a153346773598d" dependencies = [ "macro_magic", "proc-macro2", @@ -2579,24 +2916,51 @@ dependencies = [ ] [[package]] -name = "muda" -version = "0.19.3" +name = "moxcms" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1dd04e60bc0b07438a6771710ee1698f98f6ebbc7f89b61264af1563b8aeb878" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ - "crossbeam-channel", - "dpi", - "gtk", - "keyboard-types", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", + "num-traits", + "pxfm", +] + +[[package]] +name = "naga" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a616d2fb8c89516ac2723a581f69d6c18576046bed761bd6b305e5618e6ae130" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.2", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", "once_cell", - "png 0.18.1", - "serde", - "thiserror 2.0.20", - "windows-sys 0.61.2", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.21", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "590afbf58a6f4f62873cd5cff4468061844bafa1cdf399cc954537c22d768d49" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "thiserror 2.0.21", ] [[package]] @@ -2605,7 +2969,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "jni-sys 0.3.1", "log", "ndk-sys", @@ -2630,10 +2994,10 @@ dependencies = [ ] [[package]] -name = "new_debug_unreachable" -version = "1.0.6" +name = "nohash-hasher" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" [[package]] name = "num-bigint" @@ -2667,6 +3031,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2685,20 +3050,51 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate", "proc-macro2", "quote", "syn 2.0.119", ] +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d5490aaf8f1d7cf7688dfa9b0ce07900e168852c45cd2c03f534dfd27cfd0b" +dependencies = [ + "objc-sys", + "objc2-encode", +] + [[package]] name = "objc2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8" dependencies = [ "objc2-encode", - "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.2", + "block2 0.5.1", + "libc", + "objc2 0.5.3", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", ] [[package]] @@ -2707,32 +3103,48 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2", + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", "objc2-core-foundation", - "objc2-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", ] [[package]] name = "objc2-cloud-kit" -version = "0.3.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-foundation", + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", ] [[package]] name = "objc2-core-data" -version = "0.3.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "objc2", - "objc2-foundation", + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", ] [[package]] @@ -2741,9 +3153,9 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", - "objc2", + "objc2 0.6.5", ] [[package]] @@ -2752,81 +3164,126 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "dispatch2", - "objc2", + "objc2 0.6.5", "objc2-core-foundation", "objc2-io-surface", ] [[package]] name = "objc2-core-image" -version = "0.3.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5d563b38d2b97209f8e861173de434bd0214cf020e3423a52624cd1d989f006" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" dependencies = [ - "objc2", - "objc2-foundation", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] name = "objc2-core-location" -version = "0.3.2" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "objc2", - "objc2-foundation", + "bitflags 2.13.2", + "block2 0.5.1", + "dispatch", + "libc", + "objc2 0.5.3", ] [[package]] -name = "objc2-core-text" +name = "objc2-foundation" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.13.1", - "objc2", + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", "objc2-core-foundation", - "objc2-core-graphics", ] [[package]] -name = "objc2-encode" -version = "4.1.0" +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", +] [[package]] -name = "objc2-exception-helper" -version = "0.1.1" +name = "objc2-metal" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "cc", + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", ] [[package]] -name = "objc2-foundation" +name = "objc2-metal" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2", - "objc2-core-foundation", + "bitflags 2.13.2", + "block2 0.6.2", + "objc2 0.6.5", + "objc2-foundation 0.3.2", ] [[package]] -name = "objc2-io-surface" -version = "0.3.2" +name = "objc2-quartz-core" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.13.1", - "objc2", - "objc2-core-foundation", + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", ] [[package]] @@ -2835,55 +3292,79 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.13.1", - "objc2", + "bitflags 2.13.2", + "objc2 0.6.5", "objc2-core-foundation", - "objc2-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.3", + "objc2-foundation 0.2.2", ] [[package]] name = "objc2-ui-kit" -version = "0.3.2" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2", + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", "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-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core 0.2.2", + "objc2-symbols", + "objc2-uniform-type-identifiers", "objc2-user-notifications", ] [[package]] -name = "objc2-user-notifications" +name = "objc2-ui-kit" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9df9128cbbfef73cda168416ccf7f837b62737d748333bfe9ab71c245d76613e" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.2", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" dependencies = [ - "objc2", - "objc2-foundation", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-foundation 0.2.2", ] [[package]] -name = "objc2-web-kit" -version = "0.3.2" +name = "objc2-user-notifications" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.13.1", - "block2", - "objc2", - "objc2-app-kit", - "objc2-core-foundation", - "objc2-foundation", + "bitflags 2.13.2", + "block2 0.5.1", + "objc2 0.5.3", + "objc2-core-location", + "objc2-foundation 0.2.2", ] [[package]] @@ -2897,34 +3378,44 @@ dependencies = [ ] [[package]] -name = "option-ext" -version = "0.2.0" +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" [[package]] -name = "pango" -version = "0.18.3" +name = "orbclient" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ - "gio", - "glib", "libc", - "once_cell", - "pango-sys", + "libredox", ] [[package]] -name = "pango-sys" -version = "0.18.0" +name = "ordered-float" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +checksum = "8c7c9e0d9b23589f26070720bac724174bfec1083e82f7854cdd0267518343c0" dependencies = [ - "glib-sys", - "gobject-sys", - "libc", - "system-deps", + "num-traits", +] + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", ] [[package]] @@ -2951,9 +3442,9 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", - "windows-link 0.2.1", + "windows-link", ] [[package]] @@ -2965,6 +3456,19 @@ dependencies = [ "digest 0.11.3", ] +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2982,16 +3486,6 @@ dependencies = [ "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" @@ -3024,12 +3518,43 @@ dependencies = [ "siphasher", ] +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "pin-project-lite" version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + [[package]] name = "pkg-config" version = "0.3.34" @@ -3037,25 +3562,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] -name = "plist" -version = "1.10.1" +name = "plain" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2896bade328c13f7042a297ea5ac5b0951f6cf989dea5f32c2fd98da398195cb" -dependencies = [ - "base64 0.23.1", - "indexmap 2.14.2", - "quick-xml", - "serde", - "time", -] +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" [[package]] name = "png" -version = "0.17.16" +version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.2", "crc32fast", "fdeflate", "flate2", @@ -3063,16 +3581,38 @@ dependencies = [ ] [[package]] -name = "png" -version = "0.18.1" +name = "polling" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" dependencies = [ - "bitflags 2.13.1", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide 0.8.9", + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.5", + "windows-sys 0.61.2", +] + +[[package]] +name = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "pollster" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", ] [[package]] @@ -3101,9 +3641,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -3114,12 +3654,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "precomputed-hash" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" - [[package]] name = "prefix-trie" version = "0.8.4" @@ -3132,91 +3666,71 @@ dependencies = [ ] [[package]] -name = "proc-macro-crate" -version = "1.3.1" +name = "presser" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" -dependencies = [ - "once_cell", - "toml_edit 0.19.15", -] +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" [[package]] name = "proc-macro-crate" -version = "2.0.2" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_datetime 0.6.3", - "toml_edit 0.20.2", + "toml_edit", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "unicode-ident", ] [[package]] -name = "proc-macro-error" -version = "1.0.4" +name = "profiling" +version = "1.0.18" 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", -] +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" [[package]] -name = "proc-macro-error-attr" -version = "1.0.4" +name = "pxfm" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" -dependencies = [ - "proc-macro2", - "quote", - "version_check", -] +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "quick-error" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.42.0" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b1177fdf999d2321d3fb46ff47159d9c1fb9ad66a4879f8c50a0b504615e9b" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", "pin-project-lite", "quinn-proto", "quinn-udp", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "socket2", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tracing", "web-time", @@ -3231,14 +3745,14 @@ dependencies = [ "bytes", "getrandom 0.4.3", "lru-slab", - "rand 0.10.2", + "rand 0.10.3", "rand_pcg", "ring", - "rustc-hash", + "rustc-hash 2.1.3", "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.20", + "thiserror 2.0.21", "tinyvec", "tracing", "web-time", @@ -3246,9 +3760,9 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +checksum = "af66907df18639dcf4db56ca65490cabc4b27a97dbadd96f2926cca73298f016" dependencies = [ "cfg_aliases", "libc", @@ -3297,9 +3811,9 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" dependencies = [ "chacha20", "getrandom 0.4.3", @@ -3340,6 +3854,12 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3347,93 +3867,96 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" [[package]] -name = "redox_syscall" -version = "0.5.18" +name = "raw-window-metal" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" dependencies = [ - "bitflags 2.13.1", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", ] [[package]] -name = "redox_users" -version = "0.5.2" +name = "read-fonts" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" dependencies = [ - "getrandom 0.2.17", - "libredox", - "thiserror 2.0.20", + "bytemuck", + "font-types", + "once_cell", ] [[package]] -name = "redrob-core" -version = "0.2.4" +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" dependencies = [ - "base64 0.22.1", - "bigdecimal", - "chrono", - "fs2", - "futures-util", - "keyring", - "mongodb", - "redrob-engine-client", - "reqwest 0.12.28", - "secrecy", - "serde", - "serde_json", - "sqlx", - "tempfile", - "thiserror 2.0.20", - "tokio", - "url", - "uuid", + "bitflags 1.3.2", ] [[package]] -name = "redrob-engine-client" -version = "0.1.0" -source = "git+https://github.com/redrob-labs/redrob-code?tag=v0.6.3#ef3e511afb1107fd0066fa95cb29256b7d020d3c" +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "base64 0.22.1", - "dirs", - "log", - "serde", - "serde_json", - "ureq", + "bitflags 2.13.2", +] + +[[package]] +name = "redox_syscall" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "737970939a87c6fa31e7acad13307bccbb017a073b695b6089a2c484f929e20e" +dependencies = [ + "bitflags 2.13.2", ] [[package]] name = "redrob-query" -version = "0.2.4" +version = "0.3.0" dependencies = [ - "redrob-core", + "arboard", + "eframe", + "egui", + "egui_extras", + "env_logger", + "log", + "redrob-query-core", + "rfd", "serde", "serde_json", - "tauri", - "tauri-build", "tokio", "uuid", + "webbrowser", + "winresource", ] [[package]] -name = "ref-cast" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" -dependencies = [ - "ref-cast-impl", -] - -[[package]] -name = "ref-cast-impl" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" +name = "redrob-query-core" +version = "0.3.0" dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.5", + "base64 0.22.1", + "bigdecimal", + "chrono", + "fs2", + "futures-util", + "keyring", + "mongodb", + "reqwest", + "secrecy", + "serde", + "serde_json", + "sqlx", + "tempfile", + "thiserror 2.0.21", + "tokio", + "url", + "uuid", ] [[package]] @@ -3465,6 +3988,12 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + [[package]] name = "reqwest" version = "0.12.28" @@ -3487,6 +4016,7 @@ dependencies = [ "pin-project-lite", "quinn", "rustls", + "rustls-native-certs", "rustls-pki-types", "serde", "serde_json", @@ -3501,51 +4031,44 @@ dependencies = [ "url", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.4.2", + "wasm-streams", "web-sys", "webpki-roots", ] [[package]] -name = "reqwest" -version = "0.13.4" +name = "resolv-conf" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" + +[[package]] +name = "rfd" +version = "0.17.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" dependencies = [ - "base64 0.22.1", - "bytes", - "futures-core", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", + "block2 0.6.2", + "dispatch2", "js-sys", + "libc", "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", "percent-encoding", - "pin-project-lite", - "serde", - "serde_json", - "sync_wrapper", - "tokio", - "tokio-util", - "tower", - "tower-http", - "tower-service", - "url", + "pollster 0.4.0", + "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", - "wasm-streams 0.5.0", + "wayland-backend", + "wayland-client", + "wayland-protocols", "web-sys", + "windows-sys 0.61.2", ] -[[package]] -name = "resolv-conf" -version = "0.7.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7" - [[package]] name = "ring" version = "0.17.14" @@ -3560,6 +4083,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "ron" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" +dependencies = [ + "bitflags 2.13.2", + "once_cell", + "serde", + "serde_derive", + "typeid", + "unicode-ident", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + [[package]] name = "rustc-hash" version = "2.1.3" @@ -3587,14 +4130,27 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.2", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "errno", "libc", - "linux-raw-sys", + "linux-raw-sys 0.12.1", "windows-sys 0.61.2", ] @@ -3613,6 +4169,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework 3.7.0", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3656,55 +4224,19 @@ dependencies = [ ] [[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" +name = "schannel" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ - "dyn-clone", - "ref-cast", - "serde", - "serde_json", + "windows-sys 0.61.2", ] [[package]] -name = "schemars_derive" -version = "0.8.22" +name = "scoped-tls" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" -dependencies = [ - "proc-macro2", - "quote", - "serde_derive_internals", - "syn 2.0.119", -] +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" [[package]] name = "scopeguard" @@ -3728,7 +4260,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "core-foundation-sys", "libc", @@ -3741,7 +4273,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3759,33 +4291,16 @@ dependencies = [ ] [[package]] -name = "selectors" -version = "0.36.1" +name = "self_cell" +version = "1.3.0" 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", -] +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" [[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" @@ -3797,18 +4312,6 @@ dependencies = [ "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_bytes" version = "0.11.19" @@ -3836,27 +4339,17 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", -] - -[[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", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ - "indexmap 2.14.2", + "foldhash", + "indexmap", "itoa", "memchr", "serde", @@ -3872,23 +4365,14 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", -] - -[[package]] -name = "serde_spanned" -version = "0.6.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" -dependencies = [ - "serde", + "syn 3.0.9", ] [[package]] name = "serde_spanned" -version = "1.1.1" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" dependencies = [ "serde_core", ] @@ -3907,66 +4391,24 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.22.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ - "base64 0.22.1", - "bs58", - "chrono", - "hex", - "indexmap 1.9.3", - "indexmap 2.14.2", - "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" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" 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", + "syn 3.0.9", ] [[package]] @@ -3993,9 +4435,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -4042,9 +4484,19 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" + +[[package]] +name = "skrifa" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] [[package]] name = "slab" @@ -4052,71 +4504,104 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" -version = "1.16.0" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9be42f50aa861c555654aa3a37f52f4b1074bacf4e48fe0ef7fa584e80f1f0f" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" dependencies = [ "serde", ] [[package]] -name = "socket2" -version = "0.6.5" +name = "smithay-client-toolkit" +version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ + "bitflags 2.13.2", + "calloop 0.13.0", + "calloop-wayland-source 0.3.0", + "cursor-icon", "libc", - "windows-sys 0.61.2", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", ] [[package]] -name = "softbuffer" -version = "0.4.8" +name = "smithay-client-toolkit" +version = "0.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +checksum = "0512da38f5e2b31201a93524adb8d3136276fa4fe4aafab4e1f727a82b534cc0" 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", + "bitflags 2.13.2", + "calloop 0.14.5", + "calloop-wayland-source 0.4.1", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 1.1.5", + "thiserror 2.0.21", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-experimental", + "wayland-protocols-misc", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smithay-clipboard" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71704c03f739f7745053bde45fa203a46c58d25bc5c4efba1d9a60e9dba81226" +dependencies = [ + "libc", + "smithay-client-toolkit 0.20.0", + "wayland-backend", ] [[package]] -name = "soup3" -version = "0.5.0" +name = "smol_str" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" dependencies = [ - "futures-channel", - "gio", - "glib", - "libc", - "soup3-sys", + "serde", ] [[package]] -name = "soup3-sys" -version = "0.5.0" +name = "socket2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ - "gio-sys", - "glib-sys", - "gobject-sys", "libc", - "system-deps", + "windows-sys 0.61.2", ] [[package]] @@ -4128,6 +4613,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.2", +] + [[package]] name = "sqlx" version = "0.9.0" @@ -4162,7 +4656,7 @@ dependencies = [ "futures-util", "hashbrown 0.16.1", "hashlink", - "indexmap 2.14.2", + "indexmap", "log", "memchr", "percent-encoding", @@ -4171,7 +4665,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.20", + "thiserror 2.0.21", "tokio", "tokio-stream", "tracing", @@ -4202,7 +4696,7 @@ dependencies = [ "cfg-if", "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "proc-macro2", "quote", @@ -4225,7 +4719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" dependencies = [ "bigdecimal", - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "bytes", "chrono", @@ -4240,9 +4734,9 @@ dependencies = [ "percent-encoding", "serde", "sha1", - "sha2 0.11.0", + "sha2 0.11.1", "sqlx-core", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "uuid", ] @@ -4256,7 +4750,7 @@ dependencies = [ "atoi", "base64 0.22.1", "bigdecimal", - "bitflags 2.13.1", + "bitflags 2.13.2", "byteorder", "chrono", "crc", @@ -4273,14 +4767,14 @@ dependencies = [ "md-5", "memchr", "num-bigint", - "rand 0.10.2", + "rand 0.10.3", "serde", "serde_json", - "sha2 0.11.0", + "sha2 0.11.1", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "uuid", "whoami", @@ -4306,7 +4800,7 @@ dependencies = [ "percent-encoding", "serde", "sqlx-core", - "thiserror 2.0.20", + "thiserror 2.0.21", "tracing", "url", "uuid", @@ -4319,28 +4813,10 @@ 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" +name = "static_assertions" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" -dependencies = [ - "phf_generator", - "phf_shared", - "proc-macro2", - "quote", -] +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" [[package]] name = "stringprep" @@ -4360,31 +4836,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" [[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "swift-rs" -version = "1.0.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e45c444e496845d3f2a351146bff59aae4975b2280238df1dfaa0c7d1846f38e" -dependencies = [ - "base64 0.21.7", - "serde", - "serde_json", -] - -[[package]] -name = "syn" -version = "1.0.109" +name = "subtle" +version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" -dependencies = [ - "proc-macro2", - "unicode-ident", -] +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" @@ -4399,9 +4854,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.5" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -4419,13 +4874,13 @@ dependencies = [ [[package]] name = "synstructure" -version = "0.13.2" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", ] [[package]] @@ -4434,7 +4889,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4449,19 +4904,6 @@ dependencies = [ "libc", ] -[[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 = "tagptr" version = "0.2.0" @@ -4474,282 +4916,12 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" -[[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 0.10.1", - "core-graphics", - "crossbeam-channel", - "dbus", - "dispatch2", - "dlopen2", - "dpi", - "gdkwayland-sys", - "gdkx11-sys", - "gtk", - "jni 0.21.1", - "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 = "tap" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" -[[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 0.21.1", - "libc", - "log", - "mime", - "muda", - "objc2", - "objc2-app-kit", - "objc2-foundation", - "objc2-ui-kit", - "objc2-web-kit", - "percent-encoding", - "plist", - "raw-window-handle", - "reqwest 0.13.4", - "serde", - "serde_json", - "serde_repr", - "serialize-to-javascript", - "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 0.10.9", - "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 0.21.1", - "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 0.21.1", - "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-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.5+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.5+spec-1.1.0", -] - [[package]] name = "tempfile" version = "3.27.0" @@ -4757,19 +4929,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.3.4", + "getrandom 0.4.3", "once_cell", - "rustix", + "rustix 1.1.5", "windows-sys 0.61.2", ] [[package]] -name = "tendril" -version = "0.5.1" +name = "termcolor" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ - "new_debug_unreachable", + "winapi-util", ] [[package]] @@ -4783,11 +4955,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.20", + "thiserror-impl 2.0.21", ] [[package]] @@ -4803,13 +4975,27 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.20" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", +] + +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", ] [[package]] @@ -4863,24 +5049,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cf0ded5c4e56918d8f8a339e1bb67d038d3bc6d144ac407904015ba2e4cde9b" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" dependencies = [ "bytes", "libc", @@ -4900,14 +5077,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "tokio-rustls" -version = "0.26.5" +version = "0.26.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" dependencies = [ "rustls", "tokio", @@ -4926,9 +5103,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -4941,123 +5118,54 @@ dependencies = [ [[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.2", - "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.5+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c0ba9680044b4ce98d391a62094047eada0d64860b80166c39f4a6b5640785" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ - "indexmap 2.14.2", + "indexmap", "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_spanned", + "toml_datetime", "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", + "winnow", ] [[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" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" 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.2", - "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.2", - "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" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ - "indexmap 2.14.2", - "toml_datetime 1.1.1+spec-1.1.0", + "indexmap", + "toml_datetime", "toml_parser", - "winnow 1.0.4", + "winnow", ] [[package]] name = "toml_parser" -version = "1.1.3+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ - "winnow 1.0.4", + "winnow", ] [[package]] name = "toml_writer" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" +checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" [[package]] name = "tower" @@ -5080,7 +5188,7 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.1", + "bitflags 2.13.2", "bytes", "futures-util", "http", @@ -5136,34 +5244,21 @@ 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 = "try-lock" version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + [[package]] name = "typed-builder" version = "0.22.0" @@ -5197,44 +5292,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[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" +name = "uds_windows" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ - "unic-common", + "memoffset", + "tempfile", + "windows-sys 0.61.2", ] [[package]] @@ -5243,11 +5308,17 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-normalization" @@ -5270,6 +5341,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unicode-xid" version = "0.2.6" @@ -5282,31 +5359,6 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" -[[package]] -name = "ureq" -version = "3.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" -dependencies = [ - "base64 0.23.1", - "log", - "percent-encoding", - "ureq-proto", - "utf8-zero", -] - -[[package]] -name = "ureq-proto" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" -dependencies = [ - "base64 0.23.1", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -5320,35 +5372,23 @@ dependencies = [ "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-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[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.26.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -5363,36 +5403,37 @@ 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" +name = "vello_common" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.21", +] [[package]] -name = "vswhom" +name = "vello_cpu" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" dependencies = [ - "libc", - "vswhom-sys", + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "vello_common", ] [[package]] -name = "vswhom-sys" -version = "0.1.3" +name = "version_check" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" -dependencies = [ - "cc", - "libc", -] +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] name = "walkdir" @@ -5406,9 +5447,9 @@ dependencies = [ [[package]] name = "want" -version = "0.3.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" dependencies = [ "try-lock", ] @@ -5430,9 +5471,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -5443,19 +5484,20 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.78" +version = "0.4.79" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +checksum = "3cbab34de2d982e9b48e18d216d04c4a6f641066ff19ffb699980f591ee3610e" dependencies = [ "js-sys", + "tokio", "wasm-bindgen", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5463,22 +5505,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.128" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] @@ -5497,23 +5539,145 @@ dependencies = [ ] [[package]] -name = "wasm-streams" -version = "0.5.0" +name = "wayland-backend" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" dependencies = [ - "futures-util", - "js-sys", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", + "cc", + "downcast-rs", + "rustix 1.1.5", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix 1.1.5", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.2", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.5", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-experimental" +version = "20250721.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40a1f863128dcaaec790d7b4b396cc9b9a7a079e878e18c47e6c2d2c5a8dcbb1" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-misc" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9567599ef23e09b8dad6e429e5738d4509dfc46b3b21f32841a304d16b29c8" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", ] [[package]] name = "web-sys" -version = "0.3.105" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4" dependencies = [ "js-sys", "wasm-bindgen", @@ -5530,104 +5694,217 @@ dependencies = [ ] [[package]] -name = "web_atoms" -version = "0.2.6" +name = "webbrowser" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3" +checksum = "62c35be770821a214dbc362fc26908c853e776c0004294d0b10b8a6bad582f94" dependencies = [ - "phf", - "phf_codegen", - "string_cache", - "string_cache_codegen", + "jni", + "log", + "ndk-context", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "url", + "web-sys", ] [[package]] -name = "webkit2gtk" -version = "2.0.2" +name = "webpki-roots" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ - "bitflags 1.3.2", - "cairo-rs", - "gdk", - "gdk-sys", - "gio", - "gio-sys", - "glib", - "glib-sys", - "gobject-sys", - "gtk", - "gtk-sys", - "javascriptcore-rs", - "libc", + "rustls-pki-types", +] + +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + +[[package]] +name = "wgpu" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527ccdf43dd5b2e8676eed9984ce00e2bbb0a1b85b70c1969dcb6cd2eb55ab9e" +dependencies = [ + "arrayvec", + "bitflags 2.13.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "14c018fce9b6270aa203c2fdd56f3cce996713534bd757e4ea58c8560b121f14" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.2", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga", + "naga-types", "once_cell", - "soup3", - "webkit2gtk-sys", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.21", + "wgpu-core-deps-apple", + "wgpu-core-deps-emscripten", + "wgpu-core-deps-wasm", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", ] [[package]] -name = "webkit2gtk-sys" -version = "2.0.2" +name = "wgpu-core-deps-apple" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +checksum = "061f3d319a40d39d00b1ecc2c33b89fe21d4e6fe01859df3500a3a8ecccd6b68" 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", + "wgpu-hal", ] [[package]] -name = "webpki-roots" -version = "1.0.9" +name = "wgpu-core-deps-emscripten" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" +checksum = "d98b86cf4abf524a902dd35f18ca6a3f08fc2ae9847c8f10b48e30491b1f0b86" dependencies = [ - "rustls-pki-types", + "wgpu-hal", ] [[package]] -name = "webview2-com" -version = "0.38.2" +name = "wgpu-core-deps-wasm" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +checksum = "d260ca2ca8adc654f513a869bf3cac0b47f5b8d3f87803d6942015f8de597aae" dependencies = [ - "webview2-com-macros", - "webview2-com-sys", - "windows", - "windows-core 0.61.2", - "windows-implement", - "windows-interface", + "wgpu-hal", ] [[package]] -name = "webview2-com-macros" -version = "0.8.1" +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +checksum = "7586165fd5f6d881cb9ce4bb71f40d6caab2c0f1837e3fc1d9788a197fb6004f" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "wgpu-hal", ] [[package]] -name = "webview2-com-sys" -version = "0.38.2" +name = "wgpu-hal" +version = "30.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +checksum = "b6b7fb58561a792bc237628ba0792e332de418fefe145f13b5ed8201e6d52f58" dependencies = [ - "thiserror 2.0.20", + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.13.2", + "block2 0.6.2", + "bytemuck", + "cfg-if", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", + "log", + "naga", + "naga-types", + "ndk-sys", + "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "static_assertions", + "thiserror 2.0.21", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge", + "wgpu-types", "windows", - "windows-core 0.61.2", + "windows-core", + "windows-result", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f62e73117bb7a62bfd9c5a5841438a823f6566c6442a808ee269d2d055c081" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "30.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99dad6f1fbdbbdb4c278a6508b059d44688f5cebddf78d005a46a31340269286" +dependencies = [ + "bitflags 2.13.2", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", + "static_assertions", + "web-sys", ] [[package]] @@ -5668,59 +5945,30 @@ dependencies = [ ] [[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", -] +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 = "windows" -version = "0.61.3" +version = "0.62.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" dependencies = [ "windows-collections", - "windows-core 0.61.2", + "windows-core", "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" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" dependencies = [ - "windows-implement", - "windows-interface", - "windows-link 0.1.3", - "windows-result 0.3.4", - "windows-strings 0.4.2", + "windows-core", ] [[package]] @@ -5731,19 +5979,19 @@ checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" dependencies = [ "windows-implement", "windows-interface", - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] name = "windows-future" -version = "0.2.1" +version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core", + "windows-link", "windows-threading", ] @@ -5769,12 +6017,6 @@ dependencies = [ "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" @@ -5783,12 +6025,12 @@ checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] name = "windows-numerics" -version = "0.2.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" dependencies = [ - "windows-core 0.61.2", - "windows-link 0.1.3", + "windows-core", + "windows-link", ] [[package]] @@ -5797,18 +6039,9 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" dependencies = [ - "windows-link 0.2.1", - "windows-result 0.4.1", - "windows-strings 0.5.1", -] - -[[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", + "windows-link", + "windows-result", + "windows-strings", ] [[package]] @@ -5817,16 +6050,7 @@ 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", + "windows-link", ] [[package]] @@ -5835,16 +6059,7 @@ 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", + "windows-link", ] [[package]] @@ -5880,22 +6095,7 @@ 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", + "windows-link", ] [[package]] @@ -5920,7 +6120,7 @@ version = "0.53.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ - "windows-link 0.2.1", + "windows-link", "windows_aarch64_gnullvm 0.53.1", "windows_aarch64_msvc 0.53.1", "windows_i686_gnu 0.53.1", @@ -5933,28 +6133,13 @@ dependencies = [ [[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" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" dependencies = [ - "windows-link 0.2.1", + "windows-link", ] -[[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" @@ -5967,12 +6152,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" -[[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" @@ -5985,12 +6164,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" -[[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" @@ -6015,12 +6188,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" -[[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" @@ -6033,12 +6200,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" -[[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" @@ -6051,12 +6212,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" -[[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" @@ -6069,12 +6224,6 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" -[[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" @@ -6088,20 +6237,56 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] -name = "winnow" -version = "0.5.40" +name = "winit" +version = "0.30.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" dependencies = [ - "memchr", + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.2", + "block2 0.5.1", + "bytemuck", + "calloop 0.13.0", + "cfg_aliases", + "concurrent-queue", + "core-foundation 0.9.4", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.3", + "objc2-app-kit 0.2.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "smithay-client-toolkit 0.19.2", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", ] -[[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" @@ -6112,13 +6297,13 @@ dependencies = [ ] [[package]] -name = "winreg" -version = "0.55.0" +name = "winresource" +version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +checksum = "a2bc6d36480b829272a5bdabf08e6dca8004761c1da7e35efef800540c66221b" dependencies = [ - "cfg-if", - "windows-sys 0.59.0", + "toml", + "version_check", ] [[package]] @@ -6133,50 +6318,6 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" -[[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 0.21.1", - "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 0.10.9", - "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 = "wyz" version = "0.5.1" @@ -6187,26 +6328,68 @@ dependencies = [ ] [[package]] -name = "x11" +name = "x11-dl" version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" dependencies = [ "libc", + "once_cell", "pkg-config", ] [[package]] -name = "x11-dl" -version = "2.21.0" +name = "x11rb" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ + "as-raw-xcb-connection", + "gethostname", "libc", + "libloading", "once_cell", - "pkg-config", + "rustix 1.1.5", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.2", + "dlib", + "log", + "once_cell", + "xkeysym", ] +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "xml-rs" +version = "0.8.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" + [[package]] name = "yoke" version = "0.8.3" @@ -6220,30 +6403,136 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] +[[package]] +name = "zbus" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix 1.1.5", + "serde", + "serde_repr", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus-lockstep" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6998de05217a084b7578728a9443d04ea4cd80f2a0839b8d78770b76ccd45863" +dependencies = [ + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus-lockstep-macros" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10da05367f3a7b7553c8cdf8fa91aee6b64afebe32b51c95177957efc47ca3a0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "zbus-lockstep", + "zbus_xml", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2990635d09ade6df1868f72f8cac69a876a90981e8bd3c40b1be413f8dc88f40" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e" +dependencies = [ + "serde", + "winnow", + "zvariant", +] + +[[package]] +name = "zbus_xml" +version = "5.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1586c021a01ca0a9216dcd874e546382e156a5cbab5fab6cb5f10087e22682a" +dependencies = [ + "serde", + "winnow", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zcheapstr" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1afec51604565183aeb5c54c20aeab286120d4e4460f7f76e3e8bb8c0d99473" +dependencies = [ + "serde", +] + [[package]] name = "zerocopy" -version = "0.8.56" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.56" +version = "0.8.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" dependencies = [ "proc-macro2", "quote", @@ -6261,21 +6550,21 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.9", "synstructure", ] [[package]] name = "zeroize" -version = "1.9.0" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" [[package]] name = "zerotrie" @@ -6307,17 +6596,73 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.5", + "syn 3.0.9", ] [[package]] name = "zlib-rs" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] + +[[package]] +name = "zvariant" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1d34c27cc6cdd1f458427519dd6b8612f7b7e3f7b9a0b2355d041dda9869147" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow", + "zcheapstr", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "864155e69b4352db0c7f374917bf45d1e0c8d17659c8b3dbf9795f3673f8c497" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 3.0.9", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "4.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad0294361a320b694a328460dc73add56c306150f5cb6bfafc44446120008a3" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 3.0.9", + "winnow", +] diff --git a/Cargo.toml b/Cargo.toml index 6c5faa8..11bbff5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,16 +1,46 @@ [workspace] -members = ["crates/redrob-core", "src-tauri"] -resolver = "2" +resolver = "3" +members = ["crates/*", "apps/*"] [workspace.package] -version = "0.2.4" +version = "0.3.0" edition = "2024" license = "GPL-3.0-or-later" -rust-version = "1.94" +rust-version = "1.95" authors = ["Janghoon Lee (Redrob) and contributors"] repository = "https://github.com/redrob-labs/redrob-query" homepage = "https://github.com/redrob-labs/redrob-query" +[workspace.dependencies] +arboard = { version = "3.6", default-features = false } +base64 = "0.22" +bigdecimal = "0.4" +chrono = { version = "0.4", features = ["serde"] } +eframe = { version = "0.36", default-features = false } +egui = { version = "0.36", default-features = false } +egui_extras = { version = "0.36", default-features = false } +env_logger = { version = "0.11", default-features = false, features = ["auto-color", "humantime"] } +fs2 = "0.4" +futures-util = "0.3" +keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } +log = "0.4" +mongodb = "3" +# `rustls-tls` bundles the webpki roots; `rustls-tls-native-roots` adds the OS store, so a corporate +# TLS-inspecting proxy's root (installed on Windows, not in webpki) still verifies; `system-proxy` +# reads the OS proxy settings, which `default-features = false` had switched off. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "rustls-tls-native-roots", "system-proxy", "stream"] } +rfd = "0.17" +secrecy = { version = "0.10", features = ["serde"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "postgres", "mysql", "sqlite", "chrono", "bigdecimal", "uuid", "json"] } +tempfile = "3" +thiserror = "2" +tokio = { version = "1", features = ["sync", "time", "rt-multi-thread", "macros"] } +url = { version = "2", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } +webbrowser = "1" + [workspace.lints.rust] unsafe_code = "forbid" @@ -20,3 +50,27 @@ pedantic = { level = "warn", priority = -1 } module_name_repetitions = "allow" missing_errors_doc = "allow" missing_panics_doc = "allow" +too_many_lines = "allow" + +[profile.dev.package."*"] +opt-level = 2 + +[profile.dev] +opt-level = 1 + +# CI gates compile with `--profile ci`: no optimisation anywhere (the dev profile optimises every +# dependency at level 2, which is what makes a cold `cargo test` take a quarter of an hour). +[profile.ci] +inherits = "dev" +opt-level = 0 +debug = "line-tables-only" +incremental = false + +[profile.ci.package."*"] +opt-level = 0 + +[profile.release] +lto = "thin" +codegen-units = 1 +panic = "abort" +strip = true diff --git a/DESIGN_SYSTEM_PIN.json b/DESIGN_SYSTEM_PIN.json deleted file mode 100644 index 5e160ba..0000000 --- a/DESIGN_SYSTEM_PIN.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "_why": [ - "@redrob-labs/ui deliberately ships no logo file: its Mark component takes src and darkSrc, so the", - "host supplies the artwork. There is no version to resolve, so these are pinned by sha256 and by", - "their path in the design system delivery.", - "", - "What the hash is FOR. 10-logo.md: the artwork is 'Never redrawn, restretched, recolored or", - "otherwise modified', and lists recoloring, substituting the gradient and adding an effect under", - "Incorrect usage. A brand file is the one asset here that a well-meaning edit ruins silently: nudge", - "the blue, flatten the gradient, re-export smaller, and every gate still passes because no build", - "reads a PNG's pixels. The hash is what fails.", - "", - "This repo shipped src/assets/redrob-mark-512.png, whose sha256 matched none of the five symbol", - "variants in the delivery and which carried no alpha channel -- a hand-made approximation. It is", - "deleted; both canonical grounds are vendored here instead." - ], - "package": "@redrob-labs/ui", - "version": "1.0.2", - "brand_assets": [ - { - "file": "src/assets/brand/redrob-symbol.png", - "delivery_path": "assets/Symbol/redrob-symbol.png", - "ground": "white and light", - "sha256": "6069834a96076b2dee6d26f189f0e8b09513f15012cfad49498ac4e0a049e09e" - }, - { - "file": "src/assets/brand/redrob-symbol-solid-white.png", - "delivery_path": "assets/Symbol/redrob-symbol-solid-white.png", - "ground": "redrob-black and dark", - "sha256": "78a8224f61f44a6bc91a2f3479b4082e63b30e1dc898d55a4c6118d15191daba" - } - ], - "product_band": { - "_why": [ - "20-color.md closes the product spectrum at seven and states there is no eighth product colour.", - "10-logo.md's way out is that a new product ships as a feature of the product whose band it fits.", - "Query is a developer tool over a database, which is Code's band." - ], - "band": "code", - "tile": "#02A252" - } -} diff --git a/README.ko.md b/README.ko.md index 2e54fe8..d64285d 100644 --- a/README.ko.md +++ b/README.ko.md @@ -2,153 +2,164 @@ [English](./README.md) · **한국어** -Redrob Query는 Rust 1.94, Tauri 2, React 19, TypeScript로 만든 JVM 없는 AI 보조 데이터베이스 작업공간입니다. PostgreSQL, MySQL, SQLite, MongoDB용 데스크톱 작업공간으로, 기본은 읽기만 하고 허락한 연결에서만 고칩니다. 결정적인 인메모리 샘플 데이터만으로 동작하는 브라우저 데모도 제공합니다. +Redrob Query는 PostgreSQL, MySQL, SQLite, MongoDB를 위한 에이전트형 데이터베이스 클라이언트입니다. +네이티브 창 하나(Rust, wgpu 위의 egui)에서 기본은 읽기만 하고, 허락한 곳에서만 변경을 검토한 뒤 +고치며, 옆에 있는 에이전트가 쿼리 초안을 써 줍니다. JVM도, 브라우저 런타임도, 웹 데모도 없습니다. +로그인은 [Redrob Launcher](https://github.com/redrob-labs/redrob-launcher)에서 한 번만 하면 이 앱이 +그 계정을 읽습니다. -워크플로는 DBeaver Community를 참고했지만 Redrob Query는 처음부터 새로 구현한 것입니다. DBeaver 소스 코드나 브랜딩이 들어 있지 않고, Eclipse RCP·OSGi·JDBC·Java·JVM 런타임도 없습니다. +워크플로는 DBeaver Community를 참고했고, 엔진의 일부는 Beekeeper Studio Community Edition에서 +포팅했습니다([NOTICE](NOTICE), [UPSTREAM_NOTICES.md](UPSTREAM_NOTICES.md) 참고). Eclipse RCP·OSGi· +JDBC·Java·Electron은 들어 있지 않습니다. ## 출하 범위 | 영역 | 기능 | |---|---| -| 프로필 | 사용자 프로필 생성·테스트·편집·연결·연결 해제·삭제. 내장 프로필은 변경 불가 | -| PostgreSQL | 신원을 검증하는 TLS, 메타데이터, 한계가 정해진 SQL 읽기, 네이티브 스칼라 디코딩, 검토 후 편집 | -| MySQL | 신원을 검증하는 TLS, 메타데이터, 한계가 정해진 SQL 읽기, unsigned/decimal/시간/BIT 디코딩, 검토 후 편집 | -| SQLite | 편집을 허락하기 전에는 읽기 전용(`mode=ro`)으로 여는 파일 프로필, 메타데이터, 가드가 걸린 읽기, 검토 후 편집. 네이티브 데모는 `:memory:` 사용 | -| MongoDB | 표준/SRV 프로필, 별도 `authSource`, 컬렉션, 최대 25개 문서 필드 샘플링, 엄격한 `find`/`aggregate`/explain JSON | -| 결과 | 25/50/100/250행 페이징, 타입이 있는 가상 스크롤 행, 현재 페이지 정렬·필터, 열 표시 설정, 열·행 메뉴, 상세 패널, 실행 메시지, 눈에 보이는 CSV 내보내기 | -| 편집 | 연결마다 "Allow edits"를 켜기 전에는 꺼져 있습니다. 셀 고치기, 행 추가·삭제, 표 만들기·열 추가. 모든 변경은 쌓아 두고 검토한 뒤 한 트랜잭션으로 적용하며, 문장마다 정확히 한 행만 바꾸지 않으면 전부 되돌립니다. 고칠 수 없는 결과는 이유를 보여 줍니다 | -| 쿼리 작업공간 | 연결이 소유하는 SQL/MQL 탭, 엔진별 시작 쿼리, 저장된 쿼리(Save / Ctrl+S), 읽기 전용 문장 여러 개를 한 번에 실행하고 결과 고르기, 고정한 표, 로컬 데스크톱 복원, 한계가 정해진 실행 이력, 포매팅, 단축키 | -| Redrob AI | 모델 `auto`를 통한 엔진 인식 SQL/MQL 생성과 설명 | -| 브라우저 데모 | PostgreSQL/MySQL/SQLite/Mongo 샘플 워크플로, 로컬 AI 응답, 원자적 인메모리 관계형 편집 리뷰 | - -이 릴리스는 SQL Server를 지원하지 않습니다. 연결 UI에 없고, 프로필 검증과 AI 검증 양쪽에서 거부합니다. - -데스크톱 연결은 그 연결에서 "Allow edits"를 켜기 전까지 읽기 전용입니다. 켜도 쌓인 변경을 검토하고 Apply를 누르기 전에는 아무것도 쓰지 않습니다. 셀 수정은 값을 바인딩한 `UPDATE … WHERE <기본 키> = ?`가 되고, 행 추가·삭제도 같은 방식이며, 전체가 한 트랜잭션으로 실행되어 어느 한 문장이라도 정확히 한 행을 바꾸지 않으면 되돌립니다. 결과에 단일 열 기본 키가 들어 있는, 표 하나를 그대로 읽는 쿼리만 고칠 수 있습니다. 브라우저 데모의 편집은 프로필별 인메모리 픽스처를 바꾸며, 새로고침하면 사라집니다. +| 프로필 | 내장 **Demo SQLite**(`:memory:`, 오프라인에서 동작)와 사용자 프로필: 생성·테스트·편집·연결·연결 해제·삭제. 비밀번호는 파일이 아니라 OS 키링에 저장 | +| PostgreSQL / MySQL | 신원을 검증하는 TLS, 스키마별로 묶인 표와 뷰, 열, 기본 키·외래 키, 인덱스, 한계가 정해진 읽기 전용 SQL, 검토 후 편집 | +| SQLite | 파일 프로필(편집을 허락하기 전에는 `mode=ro`), 표, 열, 테이블 값 pragma로 읽는 키와 인덱스, 검토 후 편집 | +| MongoDB | 표준/SRV 프로필, `authSource`, 25개 문서 필드 샘플링이 붙은 컬렉션, 엄격한 `find` / `aggregate` / `explain` 요청 본문, 컬렉션 인덱스, JSON 문서 보기 | +| 편집기 | 연결별 탭, SQL과 Mongo JSON 색칠, Ctrl+Enter로 선택 영역 또는 커서가 있는 문장 실행(읽기 전용 배치는 문장 하나씩), Ctrl+Shift+F 포매팅, Ctrl+S로 텍스트를 파일에 저장, 엔진별 시작 쿼리, Mongo 요청 스트립 | +| 결과 | 가상 스크롤 행, 가져온 페이지 안에서 정렬·필터, 열 표시 설정, 셀 복사, TSV / JSON 복사·저장, 탭별 행 한도 | +| 편집 | 셀을 더블클릭해 새 값을 쌓고, 행 메뉴에서 삭제하고, Changes 패널에서 쌓인 변경을 검토한 뒤 Apply가 한 트랜잭션으로 실행합니다. 어느 문장이라도 정확히 한 행만 바꾸지 않으면 전부 되돌립니다. 단일 열 기본 키가 있는 표 하나의 결과와 읽기 전용이 아닌 프로필이 필요합니다 | +| 구조 | PK / FK / not-null 표시가 붙은 열, 외래 키, 인덱스. 고정된 타입 목록 위의 폼으로 표 만들기·열 추가 | +| Ask | 원하는 쿼리를 말로 설명하면 활성 탭의 텍스트만 맥락으로 함께 가고, 결과 행과 자격 증명은 절대 가지 않습니다. 답의 ` ```sql ` / ` ```mql ` 블록은 한 번 눌러 편집기에 넣습니다 | +| 작업공간 | 시작할 때 최대 30개 탭과 50개 이력을 복원(`workspace.json`), 창 위치는 `ui.ron`. `REDROB_QUERY_NO_PREFS=1`이면 아무것도 남기지 않음 | + +이 릴리스는 SQL Server를 지원하지 않습니다. 편집기에 입력한 쓰기 문장은 엔진의 읽기 전용 게이트가 +거부합니다. 쓰기 경로는 Changes 패널과 구조 폼 둘뿐입니다. ## 구조 ```text -React 19 + Monaco + TanStack Virtual - │ - Zustand 작업공간 상태 - │ DataBridge - ┌─────┴───────────┐ - DemoBridge Tauri IPC 허용목록 - (브라우저 메모리) │ - redrob-core (Rust) - ┌─────────┼──────────┐ - SQLx 구체 구현 MongoDB Redrob AI - PG/MySQL/SQLite 드라이버 HTTPS 클라이언트 +apps/redrob-query (wgpu 위의 eframe / egui 0.36) + profiles · schema · editor · grid · changes · structure · Ask 도크 + │ Workspace (옛 명령 하나당 메서드 하나, Pending 핸들로 답) +crates/redrob-query-core + DataService ── SQLx (PostgreSQL / MySQL / SQLite) · mongodb · reqwest (콘솔) + query.rs 읽기 전용 분류 · cell_edits.rs · ddl.rs · keys.rs · profile_store.rs · secret.rs + account.rs: Redrob Launcher의 공용 account.json ``` -[아키텍처](docs/ARCHITECTURE.md), [보안](docs/SECURITY.md), [데모 안내](docs/DEMO.md), [DBeaver 워크플로 대응표](docs/DBEAVER-PORTING-MAP.md)를 함께 보세요. - -## 빠른 시작: 브라우저 데모 - -브라우저 데모는 데이터베이스나 Redrob 서비스에 접속하지 않고, 작업공간을 저장하지도 않습니다. - -```bash -npm ci -npm run dev -``` +[아키텍처](docs/ARCHITECTURE.md), [보안](docs/SECURITY.md), [DBeaver 워크플로 맵](docs/DBEAVER-PORTING-MAP.md)을 +참고하세요. -`http://127.0.0.1:1420`을 엽니다. 데모는 연결된 **Acme Warehouse** 샘플 프로필로 시작합니다. 결정적인 메타데이터와 쿼리, 기본 50행 크기에서 두 페이지 분량의 결과, 로컬 AI 응답, CSV 내보내기, 프로필 수명주기 시뮬레이션, 리뷰를 거치는 인메모리 관계형 편집을 지원합니다. +## 설치 -요구 사항: Node.js 22.12+, npm 11+. +모든 릴리스는 Redrob Launcher가 읽는 이름으로 다음을 싣습니다. -## 데스크톱 개발 +| 플랫폼 | 자산 | +|---|---| +| Linux x86_64 | `redrob-query--linux-x86_64.tar.gz`, `redrob-query--linux-x86_64.deb` | +| macOS arm64 | `redrob-query--macos-arm64.dmg`, `redrob-query--macos-arm64.zip` (서명·공증·스테이플) | +| Windows x64 | `redrob-query--windows-x64-setup.exe` (영어 + 한국어 설치 프로그램), `redrob-query--windows-x64-portable.zip` (서명) | -1. Node.js 22.12+, npm 11+, 그리고 [Tauri v2 사전 요구 사항](https://v2.tauri.app/start/prerequisites/)의 플랫폼 패키지를 설치합니다. -2. rustup으로 Rust를 설치합니다. `rust-toolchain.toml`이 Rust 1.94.0과 rustfmt, Clippy를 고정합니다. -3. 의존성을 설치하고 Tauri를 실행합니다. +여기에 `SHA256SUMS.txt`와 `redrob-launcher.json`이 더해집니다. Windows 설치 프로그램은 사용자별로 +`%LOCALAPPDATA%\Programs\Redrob Query`에 설치하며, 설치된 제품은 **ProductName `Redrob Query`**, +실행 파일은 **`bin\redrob-query.exe`**, 설정 > 앱 항목은 `RedrobQuery`(게시자 `Redrob`)입니다. Redrob +Launcher가 이 이름으로 설치 여부를 알아봅니다. 자세한 내용: [installer/windows/README.md](installer/windows/README.md). -```bash -npm ci -npm run tauri dev -``` +## 소스에서 빌드 -현재 호스트 플랫폼용 패키지 빌드: +Rust는 rustup으로 설치합니다. `rust-toolchain.toml`이 1.95.0과 rustfmt, Clippy를 고정합니다. Linux에서는 +셸이 X11 / Wayland / GL 개발 헤더와 GTK 3(파일 대화상자)에 링크합니다. ```bash -npm run tauri build +sudo apt-get install -y libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ + libgl1-mesa-dev libgtk-3-dev libdbus-1-dev +cargo run --release -p redrob-query ``` -Linux에서는 Tauri가 요구하는 WebKitGTK 4.1, JavaScriptCoreGTK 4.1, libsoup 3, librsvg 개발 패키지가 필요합니다. 프런트엔드 프로덕션 빌드와 모든 `redrob-core` 검사는 그 패키지 없이도 돌아가고, `npm run tauri build`만 필요합니다. - -`src-tauri/icons/`에 표준 Tauri PNG·ICNS·ICO·iOS·Android 아이콘이 들어 있습니다. 모두 RGBA여야 합니다 — `tauri::generate_context!`가 RGBA가 아닌 아이콘에서 컴파일 타임에 패닉하므로, `npm run release:check`가 이를 단정합니다. - -서명된 인스톨러는 **Signed desktop release** 워크플로만 만듭니다. `vMAJOR.MINOR.PATCH` 태그를 푸시하면 Linux x64·macOS Intel·macOS Apple Silicon·Windows x64를 빌드하고, 업데이터 번들에 서명하고, macOS 빌드를 공증한 뒤 `latest.json`을 포함한 전부를 **초안** GitHub Release에 올립니다. 그 초안을 발행하는 것이 곧 릴리스입니다 — GitHub Releases가 업데이트 채널이라 `releases/latest/download/latest.json`은 사람이 발행한 버전만 서비스합니다. 자격 증명이 없으면 서명 없는 산출물을 발행하는 대신 릴리스를 중단합니다. - -## 데스크톱 로컬 작업공간 데이터 +`redrob-query --version`은 버전을 출력하고 끝나며 디스플레이가 필요 없습니다. -데스크톱 모드는 열린 쿼리 탭 최대 30개와 첫 페이지가 성공한 실행 이력 50개를 버전이 붙은 렌더러 로컬 스토리지에 저장합니다. 저장되는 것은 쿼리 텍스트와 탭·이력 메타데이터뿐이고, 결과 행·데이터베이스 자격 증명·AI 프롬프트는 절대 저장하지 않습니다. 빈 탭 초안도 유효하며 복원됩니다. 실행 이력은 첫 페이지에서 성공적으로 끝난, 비어 있지 않은 쿼리만 남깁니다. 쿼리 텍스트는 평문이고 그 안에 민감한 리터럴이 들어 있을 수 있으므로, 이력 메뉴에 **저장 중단 및 로컬 데이터 삭제**가 있습니다. 이 opt-out은 별도로 저장되어 재시작 후에도 사용자가 명시적으로 다시 켤 때까지 꺼진 상태로 유지됩니다. 잘못된·중복·엔진 불일치·과대·손상된 항목은 거부하며, 저장이 실패하면 성공했다고 말하지 않고 실패를 표시합니다. - -브라우저 데모의 탭과 이력은 현재 페이지 세션 동안만 메모리에 남습니다. 데스크톱 상태를 선택·복원·로드하는 것이 자동 연결이나 자동 쿼리 실행을 일으키는 일은 없습니다. - -## 검증 +### 검증 ```bash -npm run release:check -npm run typecheck -npm test -npm run test:coverage -npm run build -npm audit --audit-level=low cargo fmt --all -- --check -cargo clippy --locked -p redrob-core --all-targets --all-features -- -D warnings -cargo test --locked -p redrob-core --all-features -cargo audit --file Cargo.lock +cargo clippy -p redrob-query -p redrob-query-core --all-targets --locked -- -D warnings +cargo test -p redrob-query-core --locked +python3 scripts/check-brand.py ``` -`npm run release:check`는 npm·Cargo·Tauri·락파일·Rust 툴체인 버전, 제품명, 번들 식별자, 창 라벨, 개발 URL 메타데이터, 그리고 아이콘이 모두 RGBA인지 검증합니다. 커버리지 하한은 statements/lines 85%, branches 75%, functions 65%입니다. - -기본 Rust 코어 실행은 격리된 테스트만 돌리고 외부 커넥터 테스트 3개는 ignored로 보고합니다. 이건 실제 서버에 대한 증거가 아닙니다. 그 게이트를 돌리려면 버려도 되는 서비스 URL을 넣고 명시적으로 opt-in 하세요. +기본 테스트는 격리되어 있고 라이브 서버가 필요한 커넥터 테스트 세 개를 ignored로 보고합니다. 돌리려면 +일회용 서비스 URL을 내보내고 명시적으로 켭니다. ```bash export REDROB_TEST_POSTGRES_URL='postgresql://…' export REDROB_TEST_MYSQL_URL='mysql://…' export REDROB_TEST_MONGO_URL='mongodb://…' export REDROB_TEST_MONGO_DATABASE='redrob_test' # 선택 -cargo test --locked -p redrob-core --all-features -- --ignored +cargo test -p redrob-query-core --locked -- --ignored ``` -명시적으로 실행한 라이브 테스트는 필요한 URL이 없으면 실패합니다. 프로덕션 자격 증명을 쓰지 마세요. Mongo 게이트는 UUID 이름의 컬렉션만 만들고 지웁니다. Rust는 `.env` 파일을 자동으로 읽지 않습니다. +### 브랜드 자산 -`npm run check`는 워크스페이스 전체 Rust 검사까지 포함하므로 Linux에서는 네이티브 Tauri 시스템 패키지가 필요합니다. +`resources/icons`, `resources/brand`, 설치 프로그램의 `res/` 아래 모든 파일은 +`scripts/build-brand-assets.py --design-system <경로>`가 디자인 시스템 배포물에서 만들고 +`brand/SOURCES.json`에 sha256으로 고정합니다. `scripts/check-brand.py`(CI와 릴리스)가 손으로 고친 파일이나 +고정되지 않은 파일을 거부합니다. -## 보안 요약 +### 릴리스 -- 비밀이 아닌 프로필 메타데이터는 평문 `connections.json`에 저장하고, 비밀번호와 Redrob 키는 OS 키링을 씁니다. -- 비밀을 담지 않는 fsync 저널, 준비 단계 키링 항목, 배타적 프로필 락이 갱신을 복구 가능하게 만들고 안전하지 않은 복구나 동시 소유 상황에서 닫힌 방향으로 실패하게 합니다. -- 직접 쓰는 쿼리는 읽기만 합니다. 한 번 실행에 읽기 전용 문장만 받으며(여러 개는 전부 읽기 전용일 때만, 하나씩 따로 실행), PostgreSQL/MySQL은 읽기 전용 트랜잭션을, SQLite는 닫힌 방향으로 실패하는 PRAGMA 허용목록을 씁니다. 쓰기는 편집을 허락한 연결에서 검토한 편집으로만 일어납니다. -- Mongo 요청은 엄격하고 한계가 있습니다. aggregate의 `$out`과 `$merge`는 재귀적으로 거부합니다. 메타데이터는 최대 25개 문서에서 최상위 타입·존재·널 허용 힌트를 병합한 것이며 완전한 스키마가 아닙니다. -- PostgreSQL/MySQL TLS를 켜면 공개 신뢰 루트로 인증서 신원을 검증합니다. 사설·자체 서명 CA 설정은 제공하지 않습니다. -- 데스크톱 AI는 프롬프트와 선택적으로 현재 쿼리를 Redrob에 보냅니다. 결과 행과 자격 증명은 첨부하지 않지만, 사용자가 그 텍스트에 직접 넣은 민감한 리터럴은 전송됩니다. +**Release** 워크플로(`gh workflow run release.yml --ref main -f tag=vX.Y.Z`)가 cargo 바이너리 하나로 세 +타깃을 빌드하고, Windows는 작업의 OpenID Connect 로그인으로 Azure Artifact Signing에서 서명하고, macOS는 +조직의 Developer ID로 서명·공증하며, 공용 `redrob-labs/.github` 게시 작업이 `SHA256SUMS.txt`와 +`redrob-launcher.json`을 쓰고 GitHub Release를 latest로 게시합니다. 태그는 `main` 위에 있어야 하고 +`Cargo.toml`의 워크스페이스 버전과 같아야 하며 [CHANGELOG.md](CHANGELOG.md)에 `## ` 절이 있어야 +합니다. `-f dry_run=true`는 브랜치를 빌드·서명만 하고 아무것도 게시하지 않습니다. -전체 [보안 모델과 한계](docs/SECURITY.md)를 읽어 보세요. +## 로컬 데이터 -## 현재 경계 +| 파일 | 내용 | +|---|---| +| `connections.json` | 비밀이 없는 프로필 메타데이터(엔진의 파일, 원자적, 저널링) | +| `account.json` | 런처의 Connect나 사용자가 써 둔 이 앱 자체의 Redrob 로그인. 공용 파일보다 우선 | +| `workspace.json` | 열린 탭(쿼리 텍스트만)과 실행 이력. 결과 행·비밀번호·프롬프트는 저장하지 않음 | +| `ui.ron` | eframe의 창 위치와 패널 크기 | -- 자유 입력 쓰기 SQL은 없습니다. 변경은 검토하는 편집과 표 만들기 폼으로만 합니다. 표에 단일 열 기본 키가 있어야 고칠 수 있고, MongoDB 결과는 고칠 수 없습니다. -- 페이징은 서버·브리지 단위로 한계가 있고, 정렬과 필터는 현재 로드된 페이지에만 적용됩니다. -- 사설 CA·클라이언트 인증서 UI, SSH 터널, 클라우드 인증 플러그인, 스크립트 실행기, ER 다이어그램, 관리 도구 모음, 데이터 전송 파이프라인, 비교·마이그레이션 도구, 드라이버 마켓플레이스, SQL Server 커넥터는 없습니다. -- Mongo 샘플 메타데이터는 한계가 있는 힌트이며, 컬렉션 스키마를 확정적으로 추론한 것이 아닙니다. -- 외부 PostgreSQL/MySQL/Mongo와 Redrob API 연동은 사용자가 제공하는 서비스·자격 증명이 필요하고, 브라우저 데모는 그것을 검증하지 않습니다. +디렉터리는 Windows `%APPDATA%\Redrob Query`, macOS `~/Library/Application Support/Redrob Query`, 그 외 +`$XDG_CONFIG_HOME/redrob-query`(`~/.config/redrob-query`)이며 `REDROB_QUERY_CONFIG_DIR`로 바꿀 수 있습니다. +쿼리 텍스트는 평문이라 민감한 리터럴이 들어 있을 수 있습니다. 작업공간과 창 상태를 남기지 않으려면 +`REDROB_QUERY_NO_PREFS=1`을 설정하세요. -## Redrob AI 설정 +## 보안 요약 -**Redrob Code가 필요합니다.** 데스크톱 AI는 이 컴퓨터에 하나 있는 공유 Redrob Code 데몬(`~/.redrob/run/server.json`; 없으면 `redrob service start`로 띄움)의 다음 경로에 말합니다. +- 비밀이 없는 프로필 메타데이터는 평문 `connections.json`에, 비밀번호는 OS 키링에 저장합니다. +- 비밀이 없는 fsync 저널, 단계적 키링 항목, 배타적 프로필 잠금으로 갱신을 복구 가능하게 하고, 안전하지 + 않은 복구나 동시 소유 상황에서는 닫힌 쪽으로 실패합니다. +- 입력한 쿼리는 읽기만 합니다. 매 실행은 읽기 전용 문장만 받고(여러 개는 전부 읽기 전용일 때만, 각각 따로 + 실행), PostgreSQL / MySQL은 읽기 전용 트랜잭션, SQLite는 닫힌 쪽으로 실패하는 PRAGMA 허용목록을 + 씁니다. 쓰기는 읽기 전용이 아닌 프로필에서, 모든 값을 바인딩해, 검토한 편집과 구조 폼으로만 일어납니다. +- Mongo 요청은 엄격하고 한계가 있습니다. aggregate의 `$out`과 `$merge`는 재귀적으로 거부합니다. +- PostgreSQL / MySQL TLS를 켜면 공개 신뢰 루트로 인증서 신원을 검증합니다. +- Ask는 프롬프트와 활성 탭의 텍스트를 계정의 bearer 키로 Redrob 콘솔에 보냅니다. 결과 행과 자격 증명은 + 붙지 않지만, 두 텍스트에 직접 입력한 민감한 리터럴은 전송됩니다. -```text -/v1/chat/completions -``` +전체 [보안 모델과 한계](docs/SECURITY.md)를 읽어 보세요. + +## Redrob 계정 -모든 요청에 데몬 자체의 인증, `x-redrob-app: query`, 실행마다 하나인 `x-redrob-session`이 실리고, 모델은 `auto`입니다. Redrob 콘솔 키는 데몬이 보관하고 데몬이 그 키로 콘솔을 부릅니다. 이 앱은 자체 키를 갖지 않고 콘솔로 직접 가는 경로도 없습니다 -- 마스킹이 적용되는 곳이 한 곳이 되도록. 로그인은 한 번만: Redrob Code에서, Redrob Launcher에서, 또는 여기서. **Redrob 설정**은 붙여 넣은 키를 엔진에 넘기고(`PUT /auth/redrob`), 엔진이 로그인돼 있는지 `GET /provider`에서 읽어 엔진이 쓰는 키가 런처의 것이면 **Redrob Launcher로 로그인됨**이라고 말합니다. Redrob Code가 없으면 설정 대화상자와 모든 AI 요청이 "Redrob Code is not installed. Install it with Redrob Launcher."와 [런처 최신 릴리스](https://github.com/redrob-labs/redrob-launcher/releases/latest) 링크를 보입니다. 이전 버전의 이 앱이 OS 키링에 저장했던 키는 한 번 엔진에 넘기고 지웁니다. `REDROB_API_KEY`와 런처 공유 계정 파일 읽기는 없어졌습니다. 데몬 레코드와 바이너리는 `redrob-engine-client`(redrob-code `docs/LOCAL-ENGINE-API.md` 3절)가 찾고, `REDROB_RUN_DIR`와 `REDROB_CODE_BIN`은 테스트를 비공개 데몬으로 향하게 합니다. 브라우저 데모의 AI는 로컬이고 결정적이며, 데모에 입력한 키는 보관하거나 전송하지 않습니다. +Ask는 Redrob Launcher가 이 컴퓨터를 로그인시킨 계정을 씁니다. `$XDG_CONFIG_HOME/redrob-shared`, +`~/Library/Application Support/ai.redrob.shared`, `%APPDATA%\Redrob\Shared`(`REDROB_SHARED_DIR`로 변경)의 +`account.json`을 런처의 `docs/shared-account.md` 계약대로 요청마다 읽고, 앱이 열려 있는 동안 지켜봅니다. +환경 변수 `REDROB_API_KEY`, 그다음 이 앱 자체의 `account.json`이 그보다 우선합니다. 키는 +`{apiBaseUrl}/chat/completions`에 모델 `auto`로 bearer 토큰으로만 보내며, 콘솔이 거부하면 콘솔의 메시지를 +그대로 보여 줍니다. 계정이 없으면 Ask 도크가 그렇다고 말하고 런처를 열어 줍니다. 이 앱에는 자체 로그인이 +없습니다. -## 기여 +## 현재 경계 -[CONTRIBUTING.md](CONTRIBUTING.md)에 브랜치 모델, 필수 검사, 그리고 실수로 깨기 쉬운 두 가지 규칙이 있습니다. +- 자유 형식 쓰기 SQL은 없습니다. 변경은 검토한 편집 집합과 표 폼으로만 갑니다. 고치려면 단일 열 기본 키가 + 필요하고, MongoDB 결과와 복합 키는 아직 고칠 수 없습니다. +- 정렬과 필터는 데이터베이스가 아니라 가져온 페이지(탭의 한도까지)에만 적용됩니다. +- 사용자 CA·클라이언트 인증서 UI, SSH 터널, ER 다이어그램, 데이터 전송 파이프라인, 비교·마이그레이션 도구, + 드라이버 마켓플레이스, SQL Server 커넥터는 없습니다. +- Mongo 샘플 메타데이터는 한계가 있는 힌트이지 스키마가 아닙니다. -## 라이선스와 귀속 +## 라이선스와 저작자 표시 -Redrob Query는 [GNU General Public License 버전 3 이상](LICENSE)으로 배포됩니다. Beekeeper Studio Community Edition 소스를 이식하기 시작하면서 Apache-2.0에서 옮겼습니다 — 그쪽이 GPL-3.0-or-later이므로 더 강한 카피레프트가 코드와 함께 따라옵니다. 귀속과 상표 유보는 [NOTICE](NOTICE)를 보세요. 서드파티 구성요소는 각자의 라이선스를 따릅니다. +Redrob Query는 [GNU General Public License, version 3 or later](LICENSE)로 배포합니다. GPL-3.0-or-later인 +Beekeeper Studio Community Edition 소스를 들여오기 전까지는 Apache-2.0이었고, 더 강한 카피레프트가 코드와 +함께 옵니다. 저작자 표시와 상표 유보는 [NOTICE](NOTICE), 상류 코드 고지는 +[UPSTREAM_NOTICES.md](UPSTREAM_NOTICES.md)를 보세요. 서드파티 구성 요소는 각자의 라이선스를 따릅니다. diff --git a/README.md b/README.md index 9885af7..bc2cdc1 100644 --- a/README.md +++ b/README.md @@ -2,149 +2,170 @@ **English** · [한국어](./README.ko.md) -Redrob Query is a JVM-free, AI-assisted database workspace built with Rust 1.94, Tauri 2, React 19, and TypeScript. It is a desktop workspace for PostgreSQL, MySQL, SQLite, and MongoDB that reads by default and edits only where you allow it, plus an interactive browser demo backed only by deterministic in-memory sample data. +Redrob Query is an agentic database client for PostgreSQL, MySQL, SQLite and MongoDB: one native +window (Rust, egui on wgpu) that reads by default, edits only where you allow it after you have +reviewed the change, and drafts queries with an agent beside you. There is no JVM, no browser +runtime and no web demo. Signing in happens once, in [Redrob Launcher](https://github.com/redrob-labs/redrob-launcher); +this app reads that account. -The workflow is informed by DBeaver Community, but Redrob Query is a clean implementation. It includes no DBeaver source code or branding and no Eclipse RCP, OSGi, JDBC, Java, or JVM runtime. +The workflow is informed by DBeaver Community and parts of the engine were ported from Beekeeper +Studio Community Edition (see [NOTICE](NOTICE) and [UPSTREAM_NOTICES.md](UPSTREAM_NOTICES.md)). It +includes no Eclipse RCP, OSGi, JDBC, Java or Electron. -## Shipped scope +## What ships | Area | Capability | |---|---| -| Profiles | Create, test, edit, connect, disconnect, and remove user profiles; built-in profiles are immutable | -| PostgreSQL | Identity-verifying TLS, metadata, bounded SQL reads, native scalar decoding, reviewed edits | -| MySQL | Identity-verifying TLS, metadata, bounded SQL reads, unsigned/decimal/temporal/BIT decoding, reviewed edits | -| SQLite | File profiles that open read-only (`mode=ro`) unless edits are allowed, metadata, guarded reads, reviewed edits; native demo uses `:memory:` | -| MongoDB | Standard/SRV profiles, separate `authSource`, collections, bounded 25-document field sampling, strict `find`/`aggregate`/explain JSON | -| Results | 25/50/100/250-row paging, typed virtualized rows, current-page sort/filter, column visibility, column and row menus, a details panel, execution messages, and visible CSV export | -| Editing | Off per connection until "Allow edits" is ticked. Edit cells, add and delete rows, create a table or add a column; every change is staged, reviewed and applied in one transaction that rolls back unless each statement changes exactly one row. A result that cannot be edited says why | -| Query workspace | Connection-owned SQL/MQL tabs, engine starters, saved queries (Save / Ctrl+S), several read-only statements in one run with a result picker, pinned tables, local desktop restoration, bounded execution history, formatting, and shortcuts | -| Redrob AI | Engine-aware SQL/MQL generation and explanation through model `auto` | -| Browser demo | Sample PostgreSQL/MySQL/SQLite/Mongo workflows, local AI responses, and atomic in-memory relational edit review | - -SQL Server is not supported in this release: it is absent from the connection UI and rejected by both profile and AI validation paths. - -Desktop connections are read-only until you tick "Allow edits" on the connection. Even then nothing is written until you review the staged changes and press Apply: cell edits become `UPDATE … WHERE = ?` with bound values, rows are inserted and deleted the same way, and the whole set runs in one transaction that is undone if any statement does not change exactly one row. Only a plain read of one table with a single-column primary key in the result can be edited. Browser-demo edits change a per-profile in-memory fixture; reload discards them. +| Profiles | Built-in **Demo SQLite** (`:memory:`, works offline) plus your own profiles: create, test, edit, connect, disconnect, remove. Passwords live in the OS keyring, never in a file | +| PostgreSQL / MySQL | Identity-verifying TLS, schema-grouped tables and views, columns, primary and foreign keys, indexes, bounded read-only SQL, reviewed edits | +| SQLite | File profiles (read-only `mode=ro` until edits are allowed), tables, columns, keys and indexes through the table-valued pragmas, reviewed edits | +| MongoDB | Standard and SRV profiles, `authSource`, collections with 25-document field sampling, strict `find` / `aggregate` / `explain` request bodies, collection indexes, a JSON document view | +| Editor | Tabs per connection, SQL and Mongo JSON colouring, Ctrl+Enter runs the selection or the statement under the cursor (a read-only batch runs statement by statement), Ctrl+Shift+F formats, Ctrl+S saves the text to a file, per-engine starter queries, a Mongo request strip | +| Results | Virtualised rows, sort and filter on the fetched page, column visibility, cell copy, TSV / JSON copy and save, a row limit per tab | +| Editing | Double-click a cell to stage a new value, delete rows from the row menu, review the staged set in the Changes panel, Apply runs it in one transaction that rolls back unless every statement changes exactly one row. Needs a single-table result with a one-column primary key on a profile that is not read-only | +| Structure | Columns with PK / FK / not-null flags, foreign keys, indexes; create a table or add a column from a form over a fixed type list | +| Ask | Describe the query you want; the active tab's text goes along as context, result rows and credentials never do. Fenced `sql` / `mql` blocks in the answer insert into the editor with one click | +| Workspace | Up to 30 tabs and 50 history entries restored on start (`workspace.json`), window geometry in `ui.ron`; `REDROB_QUERY_NO_PREFS=1` keeps nothing | + +SQL Server is not supported in this release. Writes typed into the editor are refused by the engine's +read-only gate; the only write paths are the Changes panel and the structure forms. ## Architecture ```text -React 19 + Monaco + TanStack Virtual - │ - Zustand workspace state - │ DataBridge - ┌─────┴───────────┐ - DemoBridge Tauri IPC allowlist - (browser memory) │ - redrob-core (Rust) - ┌─────────┼──────────┐ - SQLx concrete MongoDB Redrob AI - PG/MySQL/SQLite driver HTTPS client +apps/redrob-query (eframe / egui 0.36 on wgpu) + profiles · schema · editor · grid · changes · structure · Ask dock + │ Workspace (one method per former command, answered by Pending handles) +crates/redrob-query-core + DataService ── SQLx (PostgreSQL / MySQL / SQLite) · mongodb · reqwest (console) + query.rs read-only classification · cell_edits.rs · ddl.rs · keys.rs · profile_store.rs · secret.rs + account.rs: Redrob Launcher's shared account.json ``` -See [Architecture](docs/ARCHITECTURE.md), [Security](docs/SECURITY.md), [Demo guide](docs/DEMO.md), and the [DBeaver workflow map](docs/DBEAVER-PORTING-MAP.md). - -## Quick start: browser demo - -The browser demo does not connect to a database or Redrob service and does not persist its workspace. - -```bash -npm ci -npm run dev -``` +See [Architecture](docs/ARCHITECTURE.md), [Security](docs/SECURITY.md) and the +[DBeaver workflow map](docs/DBEAVER-PORTING-MAP.md). -Open `http://127.0.0.1:1420`. The demo starts with the connected **Acme Warehouse** sample profile. It supports deterministic metadata and queries, two result pages at the default 50-row size, local AI responses, CSV export, profile lifecycle simulation, and reviewed in-memory relational edits. +## Install -Requirements: Node.js 22.12+ and npm 11+. +Every release carries, under the names Redrob Launcher reads: -## Desktop development +| Platform | Assets | +|---|---| +| Linux x86_64 | `redrob-query--linux-x86_64.tar.gz`, `redrob-query--linux-x86_64.deb` | +| macOS arm64 | `redrob-query--macos-arm64.dmg`, `redrob-query--macos-arm64.zip` (signed, notarized, stapled) | +| Windows x64 | `redrob-query--windows-x64-setup.exe` (English + Korean setup), `redrob-query--windows-x64-portable.zip` (signed) | -1. Install Node.js 22.12+, npm 11+, and the platform packages from the [Tauri v2 prerequisites guide](https://v2.tauri.app/start/prerequisites/). -2. Install Rust through rustup; `rust-toolchain.toml` pins Rust 1.94.0, rustfmt, and Clippy. -3. Install dependencies and launch Tauri: +plus `SHA256SUMS.txt` and `redrob-launcher.json`. The Windows setup installs per user into +`%LOCALAPPDATA%\Programs\Redrob Query`; the installed product is **ProductName `Redrob Query`** with +the executable **`bin\redrob-query.exe`** and the Settings > Apps entry `RedrobQuery` +(publisher `Redrob`) -- the names Redrob Launcher detects it by. Details: +[installer/windows/README.md](installer/windows/README.md). -```bash -npm ci -npm run tauri dev -``` +## Build from source -Build a package for the current host platform with: +Rust comes from rustup; `rust-toolchain.toml` pins 1.95.0 with rustfmt and Clippy. On Linux the +shell links the X11 / Wayland / GL development headers and GTK 3 (for the file dialogs): ```bash -npm run tauri build +sudo apt-get install -y libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev \ + libgl1-mesa-dev libgtk-3-dev libdbus-1-dev +cargo run --release -p redrob-query ``` -Linux requires the WebKitGTK 4.1, JavaScriptCoreGTK 4.1, libsoup 3, and librsvg development metadata expected by Tauri. Frontend production builds and all `redrob-core` checks run without those packages; only `npm run tauri build` needs them. - -`src-tauri/icons/` holds standard Tauri PNG, ICNS, ICO, iOS, and Android icons. Every PNG must be RGBA — `tauri::generate_context!` panics at compile time on anything else — and `npm run release:check` asserts it. - -Signed installers are produced only by the **Signed desktop release** workflow, which a pushed `vMAJOR.MINOR.PATCH` tag starts. It builds Linux x64, macOS Intel, macOS Apple Silicon, and Windows x64, signs the updater bundles, notarizes the macOS builds, and uploads everything plus `latest.json` to a **draft** GitHub Release. Publishing that draft is the release: GitHub Releases is the update channel, so `releases/latest/download/latest.json` only ever serves a version a human published. Missing credentials stop the release rather than publishing unsigned artifacts. - -## Desktop local workspace data - -Desktop mode stores up to 30 open query tabs and 50 successful first-page history entries in versioned renderer local storage. Only query text and tab/history metadata are stored, never result rows, database credentials, or AI prompts. Empty tab drafts are valid and restored; execution history retains only nonempty queries that completed successfully on the first page. Query text is plaintext and may itself contain sensitive literals, so the history menu provides **Stop saving & clear local data**. That opt-out is stored separately and remains disabled across restarts until the user explicitly re-enables saving. Invalid, duplicate, engine-incompatible, oversized, or corrupt entries are rejected; storage failure is shown instead of claiming a successful save. +`redrob-query --version` prints the version and exits; it needs no display. -Browser-demo tabs and history remain in memory for the current page session only. Selecting, restoring, or loading desktop state never auto-connects or auto-runs a query. - -## Validation +### Validation ```bash -npm run release:check -npm run typecheck -npm test -npm run test:coverage -npm run build -npm audit --audit-level=low cargo fmt --all -- --check -cargo clippy --locked -p redrob-core --all-targets --all-features -- -D warnings -cargo test --locked -p redrob-core --all-features -cargo audit --file Cargo.lock +cargo clippy -p redrob-query -p redrob-query-core --all-targets --locked -- -D warnings +cargo test -p redrob-query-core --locked +python3 scripts/check-brand.py ``` -`npm run release:check` verifies npm, Cargo, Tauri, lockfile, Rust toolchain, product name, bundle identifier, window label, and development URL metadata. Coverage has global regression floors of 85% statements/lines, 75% branches, and 65% functions. - -The default Rust core run executes hermetic tests and reports three external connector tests as ignored. It is not live-server evidence. To run the ignored gates, export disposable service URLs and explicitly opt in: +The default test run is hermetic and reports three connector tests as ignored; they need live +servers. To run them, export disposable service URLs and opt in: ```bash export REDROB_TEST_POSTGRES_URL='postgresql://…' export REDROB_TEST_MYSQL_URL='mysql://…' export REDROB_TEST_MONGO_URL='mongodb://…' export REDROB_TEST_MONGO_DATABASE='redrob_test' # optional -cargo test --locked -p redrob-core --all-features -- --ignored +cargo test -p redrob-query-core --locked -- --ignored ``` -An explicitly run live test fails when its required URL is absent. Use non-production credentials; the Mongo gate creates and drops only a UUID-named collection. Rust does not automatically load `.env` files. +### Brand assets -`npm run check` also includes workspace-wide Rust checks and therefore requires native Tauri system packages on Linux. +Every file under `resources/icons`, `resources/brand` and the installer `res/` folders is built from +the design system's published files by `scripts/build-brand-assets.py --design-system ` and +pinned by sha256 in `brand/SOURCES.json`; `scripts/check-brand.py` (CI and the release) refuses a +hand-edited or unpinned file. -## Security summary +### Release -- Non-secret profile metadata is stored in plaintext `connections.json`; passwords and the Redrob key use the OS keyring. -- A secret-free fsynced journal, staged keyring entry, and exclusive profile lock make updates recoverable and fail closed under unsafe recovery or concurrent ownership. -- Typed queries are reads only: each run accepts read-only statements (several only when every one is read-only, each executed on its own), with PostgreSQL/MySQL read-only transactions and a fail-closed SQLite PRAGMA allowlist. Writes happen only through reviewed edits on a connection that allows them. -- Mongo requests are strict and bounded; aggregate `$out` and `$merge` are rejected recursively. Metadata merges top-level type/presence/nullability hints from at most 25 documents and is not a complete schema. -- Enabled PostgreSQL/MySQL TLS verifies certificate identity with public trust roots. Private/self-signed CA configuration is unavailable. -- Desktop AI sends the prompt and optional active query to Redrob. Result rows and credentials are not attached, but sensitive literals manually included in either text are transmitted. +The **Release** workflow (`gh workflow run release.yml --ref main -f tag=vX.Y.Z`) builds the three +targets from the one cargo binary, signs Windows through Azure Artifact Signing with the job's OpenID +Connect login, signs and notarizes macOS with the organisation's Developer ID, and the shared +`redrob-labs/.github` publish job writes `SHA256SUMS.txt` and `redrob-launcher.json` and publishes the +GitHub Release as latest. The tag must be on `main`, equal the workspace version in `Cargo.toml`, and +have a `## ` section in [CHANGELOG.md](CHANGELOG.md). `-f dry_run=true` builds and signs a +branch and publishes nothing. -Read the complete [security model and limitations](docs/SECURITY.md). +## Local data -## Current boundaries +| File | What | +|---|---| +| `connections.json` | Non-secret profile metadata (the engine's own file, atomic, journaled) | +| `account.json` | This app's own Redrob sign-in, if the launcher's Connect or you wrote one; wins over the shared file | +| `workspace.json` | Open tabs (query text only) and the run history; never result rows, passwords or prompts | +| `ui.ron` | eframe's window geometry and panel sizes | + +Directory: `%APPDATA%\Redrob Query` on Windows, `~/Library/Application Support/Redrob Query` on macOS, +`$XDG_CONFIG_HOME/redrob-query` (`~/.config/redrob-query`) elsewhere; `REDROB_QUERY_CONFIG_DIR` +overrides it. Query text is plain text and may contain sensitive literals; set +`REDROB_QUERY_NO_PREFS=1` to keep no workspace or window state. -- No free-form write SQL: changes go through the reviewed edit and table forms only. Tables need a single-column primary key to be edited; MongoDB results are not editable. -- Paging is server/bridge bounded, while sorting and filtering apply only to the currently loaded page. -- No custom CA/client-certificate UI, SSH tunnel, cloud-auth plugin, script runner, ER diagram, administration suite, data-transfer pipeline, compare/migration tooling, driver marketplace, or SQL Server connector. -- Mongo sampled metadata is a bounded hint, not authoritative collection schema inference. -- External PostgreSQL/MySQL/Mongo and Redrob API integration require user-supplied services/credentials and are not exercised by the browser demo. +## Security summary -## Redrob AI configuration +- Non-secret profile metadata is stored in plaintext `connections.json`; passwords use the OS keyring. +- A secret-free fsynced journal, staged keyring entry and exclusive profile lock make updates + recoverable and fail closed under unsafe recovery or concurrent ownership. +- Typed queries are reads only: each run accepts read-only statements (several only when every one + is read-only, each executed on its own), with PostgreSQL / MySQL read-only transactions and a + fail-closed SQLite PRAGMA allowlist. Writes happen only through reviewed edits and the structure + forms, on a profile that is not read-only, with every value bound. +- Mongo requests are strict and bounded; aggregate `$out` and `$merge` are rejected recursively. +- Enabled PostgreSQL / MySQL TLS verifies certificate identity with public trust roots. +- Ask sends the prompt and the active tab's text to the Redrob console with the account's bearer + key. Result rows and credentials are not attached, but sensitive literals you typed into either + text are transmitted. -**Requires Redrob Code.** Desktop AI talks to the one shared Redrob Code daemon on this machine (`~/.redrob/run/server.json`; started with `redrob service start` when none is running) at: +Read the complete [security model and limitations](docs/SECURITY.md). -```text -/v1/chat/completions -``` +## The Redrob account + +Ask uses the account Redrob Launcher signed this computer in with: `account.json` in +`$XDG_CONFIG_HOME/redrob-shared`, `~/Library/Application Support/ai.redrob.shared` or +`%APPDATA%\Redrob\Shared` (`REDROB_SHARED_DIR` overrides), read on every request and watched while +the app is open, as the launcher's `docs/shared-account.md` contract asks. `REDROB_API_KEY` in the +environment, then this app's own `account.json`, win over it. The key is sent only as a bearer token to +`{apiBaseUrl}/chat/completions` with model `auto`; the console's own error message is shown when it +refuses. Without an account the Ask dock says so and offers to open the launcher; this app has no +sign-in of its own. + +## Current boundaries -Every request carries the daemon's own authorization, `x-redrob-app: query` and a per-run `x-redrob-session`; the request uses model `auto`. The daemon holds the Redrob console key and calls the console with it; this app never has a key of its own and has no direct path to the console, so there is one place masking applies. Sign in once -- in Redrob Code, in Redrob Launcher, or here: **Redrob settings** hands a pasted key to the engine (`PUT /auth/redrob`) and reads whether the engine is signed in from `GET /provider`, saying **Signed in with Redrob Launcher** when that is whose key the engine uses. Without Redrob Code the dialog and every AI request say "Redrob Code is not installed. Install it with Redrob Launcher." with a link to [the launcher's latest release](https://github.com/redrob-labs/redrob-launcher/releases/latest). A key an earlier version of this app saved in the OS keyring is handed to the engine once and deleted. `REDROB_API_KEY` and the launcher shared-account reader are gone. The daemon record and binary come from `redrob-engine-client` (redrob-code `docs/LOCAL-ENGINE-API.md`, section 3); `REDROB_RUN_DIR` and `REDROB_CODE_BIN` point tests at a private daemon. Browser-demo AI is local and deterministic; an entered demo key is not retained or sent. +- No free-form write SQL: changes go through the reviewed edit set and the table forms. Tables need a + one-column primary key to be edited; MongoDB results and composite keys are not editable yet. +- Sorting and filtering apply to the fetched page (up to the tab's limit), not to the database. +- No custom CA or client-certificate UI, SSH tunnel, ER diagram, data-transfer pipeline, compare or + migration tooling, driver marketplace, or SQL Server connector. +- Mongo sampled metadata is a bounded hint, not a schema. ## License and attribution -Redrob Query is licensed under the [GNU General Public License, version 3 or later](LICENSE). It was Apache-2.0 until it began incorporating Beekeeper Studio Community Edition source, which is GPL-3.0-or-later; the stronger copyleft travels with the code. See [NOTICE](NOTICE) for attribution and for the trademark reservation. Third-party components remain subject to their own licenses. +Redrob Query is licensed under the [GNU General Public License, version 3 or later](LICENSE). It was +Apache-2.0 until it began incorporating Beekeeper Studio Community Edition source, which is +GPL-3.0-or-later; the stronger copyleft travels with the code. See [NOTICE](NOTICE) for attribution +and the trademark reservation, and [UPSTREAM_NOTICES.md](UPSTREAM_NOTICES.md) for the upstream code +notices. Third-party components remain subject to their own licenses. diff --git a/apps/redrob-query/Cargo.toml b/apps/redrob-query/Cargo.toml new file mode 100644 index 0000000..16f2f9d --- /dev/null +++ b/apps/redrob-query/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "redrob-query" +description = "Redrob Query: an agentic database client for PostgreSQL, MySQL, SQLite and MongoDB" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +publish = false + +[[bin]] +name = "redrob-query" +path = "src/main.rs" + +[dependencies] +redrob-query-core = { path = "../../crates/redrob-query-core" } +eframe = { workspace = true, features = ["wgpu", "accesskit", "wayland", "x11", "persistence", "default_fonts"] } +egui = { workspace = true } +egui_extras = { workspace = true } +env_logger = { workspace = true } +log = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true } + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +arboard = { workspace = true } +rfd = { workspace = true } +webbrowser = { workspace = true } + +[build-dependencies] +winresource = "0.1" + +[lints] +workspace = true diff --git a/apps/redrob-query/build.rs b/apps/redrob-query/build.rs new file mode 100644 index 0000000..a760ef1 --- /dev/null +++ b/apps/redrob-query/build.rs @@ -0,0 +1,33 @@ +//! Windows only: embed the app icon and version info (VERSIONINFO) into `redrob-query.exe`, so the +//! Start menu, Settings > Apps and Redrob Launcher show the Redrob mark and the product name. +//! +//! On every other target this does nothing. A missing resource compiler is a warning, so a +//! cross-compile from macOS or Linux still links, unless `REDROB_QUERY_REQUIRE_WINRES=1` (set by the +//! release workflow) turns it into an error. + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-changed=../../resources/icons/redrob-query.ico"); + println!("cargo:rerun-if-env-changed=REDROB_QUERY_REQUIRE_WINRES"); + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + let mut res = winresource::WindowsResource::new(); + res.set_icon("../../resources/icons/redrob-query.ico") + .set("ProductName", "Redrob Query") + .set("FileDescription", "Redrob Query") + .set("CompanyName", "Redrob") + .set( + "LegalCopyright", + "Copyright (c) 2026 Janghoon Lee (Redrob) and contributors. GPL-3.0-or-later.", + ) + .set("OriginalFilename", "redrob-query.exe") + .set("InternalName", "redrob-query"); + if let Err(e) = res.compile() { + if std::env::var_os("REDROB_QUERY_REQUIRE_WINRES").is_some() { + println!("cargo:warning=embedding Windows resources failed: {e}"); + std::process::exit(1); + } + println!("cargo:warning=redrob-query.exe built without icon/version resources: {e}"); + } +} diff --git a/apps/redrob-query/src/account.rs b/apps/redrob-query/src/account.rs new file mode 100644 index 0000000..bffc491 --- /dev/null +++ b/apps/redrob-query/src/account.rs @@ -0,0 +1,64 @@ +//! The shared Redrob account in the shell: who is signed in (never the key), and a watcher that +//! notices the launcher writing or deleting `account.json` while the app is open. +//! +//! The contract (redrob-launcher `docs/shared-account.md`) asks apps to read the file when they +//! need it rather than once at start. The engine does exactly that on every AI request; the shell +//! additionally polls the files' modification stamps every couple of seconds so the status line +//! and the AI dock follow a sign-in or sign-out without a restart. There is no sign-in UI of this +//! app's own beyond opening Redrob Launcher. + +use std::path::PathBuf; +use std::time::{Duration, Instant, SystemTime}; + +use redrob_query_core::{Workspace, ai::AiKeyStatus}; + +const POLL_EVERY: Duration = Duration::from_secs(2); + +pub struct AccountWatch { + pub status: AiKeyStatus, + stamp: Vec<(PathBuf, Option<(SystemTime, u64)>)>, + last_poll: Instant, +} + +impl AccountWatch { + pub fn new(workspace: &Workspace) -> Self { + Self { + status: workspace.ai_key_status(), + stamp: workspace.account_stamp(), + last_poll: Instant::now(), + } + } + + /// Re-reads the account when a file changed. Returns `true` when the status moved, so the + /// caller can refresh what shows it. Cheap: metadata only, every [`POLL_EVERY`]. + pub fn poll(&mut self, workspace: &Workspace) -> bool { + if self.last_poll.elapsed() < POLL_EVERY { + return false; + } + self.last_poll = Instant::now(); + let stamp = workspace.account_stamp(); + if stamp == self.stamp { + return false; + } + self.stamp = stamp; + let status = workspace.ai_key_status(); + let changed = status != self.status; + self.status = status; + changed + } + + /// Where the shared file is read from, for the status tooltip. + pub fn describe_paths(&self) -> String { + self.stamp + .iter() + .map(|(path, stamp)| { + format!( + "{} ({})", + path.display(), + if stamp.is_some() { "present" } else { "absent" } + ) + }) + .collect::>() + .join("\n") + } +} diff --git a/apps/redrob-query/src/ai_dock.rs b/apps/redrob-query/src/ai_dock.rs new file mode 100644 index 0000000..ffea040 --- /dev/null +++ b/apps/redrob-query/src/ai_dock.rs @@ -0,0 +1,259 @@ +//! The AI dock: ask for a query in plain words, get a draft back, put it in the editor. +//! +//! Each turn sends only the prompt and the active tab's text (never result rows or credentials) +//! to the console with the shared account. Fenced `sql` / `mql` / `json` blocks in the answer each +//! get an Insert button. Without a signed-in account the dock says so and offers the launcher. + +use egui::{RichText, Ui}; +use redrob_query_core::{ + AiAssistantRequest, AiChatResponse, DatabaseKind, Pending, QueryLanguage, Workspace, + ai::{AiKeySource, AiKeyStatus, NOT_SIGNED_IN_MESSAGE}, +}; + +use crate::theme; + +#[derive(Clone, Debug)] +pub struct Turn { + pub user: bool, + pub text: String, +} + +#[derive(Default)] +pub struct AiDock { + pub prompt: String, + pub turns: Vec, + pending: Option>, + error: Option, + pub status: AiKeyStatus, +} + +pub enum AiEvent { + /// Put this draft in the active editor tab. + Insert(String), + Notice(String, bool), +} + +impl AiDock { + pub fn new(workspace: &Workspace) -> Self { + Self { + status: workspace.ai_key_status(), + ..Self::default() + } + } + + pub fn refresh_status(&mut self, workspace: &Workspace) { + self.status = workspace.ai_key_status(); + } + + pub fn poll(&mut self, events: &mut Vec) { + if let Some(pending) = &mut self.pending + && let Some(result) = pending.poll() + { + self.pending = None; + match result { + Ok(response) => self.turns.push(Turn { + user: false, + text: response.message.content, + }), + Err(error) => { + let text = error.to_string(); + self.error = Some(text.clone()); + events.push(AiEvent::Notice(text, true)); + } + } + } + } + + pub fn ui( + &mut self, + ui: &mut Ui, + workspace: &Workspace, + active: Option<(DatabaseKind, QueryLanguage, String)>, + events: &mut Vec, + ) { + ui.horizontal(|ui| { + ui.heading("Ask"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.small_button("Clear").clicked() { + self.turns.clear(); + self.error = None; + } + }); + }); + match self.status.source { + AiKeySource::None => { + ui.label(RichText::new(NOT_SIGNED_IN_MESSAGE).color(theme::CAUTION)); + if ui.button("Open Redrob Launcher").clicked() { + let _ = webbrowser::open(Workspace::launcher_url()); + } + } + AiKeySource::Launcher => { + ui.label( + RichText::new(format!("{} · via Redrob Launcher", self.status.label)) + .small() + .weak(), + ); + } + AiKeySource::Own => { + ui.label( + RichText::new(format!("{} · this app's own key", self.status.label)) + .small() + .weak(), + ); + } + } + ui.separator(); + + let signed_in = self.status.signed_in(); + let scroll_height = (ui.available_height() - 120.0).max(80.0); + egui::ScrollArea::vertical() + .id_salt("ai-turns") + .auto_shrink([false, false]) + .max_height(scroll_height) + .stick_to_bottom(true) + .show(ui, |ui| { + if self.turns.is_empty() { + ui.label(RichText::new( + "Describe the query you want. The active tab's text is sent along as context; result rows never are.", + ).weak()); + } + for (index, turn) in self.turns.iter().enumerate() { + let who = if turn.user { "You" } else { "Redrob" }; + ui.label(RichText::new(who).small().strong().color(if turn.user { ui.visuals().text_color() } else { theme::REDROB_BLUE })); + if turn.user { + ui.label(&turn.text); + } else { + render_answer(ui, index, &turn.text, events); + } + ui.add_space(6.0); + } + if self.pending.is_some() { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new("Thinking…").weak()); + }); + } + if let Some(error) = &self.error { + ui.label(RichText::new(error).color(theme::DANGER)); + } + }); + + ui.separator(); + let can_ask = signed_in && self.pending.is_none() && active.is_some(); + let response = ui.add_enabled( + signed_in, + egui::TextEdit::multiline(&mut self.prompt) + .desired_rows(3) + .desired_width(f32::INFINITY) + .hint_text(if active.is_some() { + "e.g. customers who ordered twice this month, newest first" + } else { + "Open a query tab first" + }), + ); + let submit = response.has_focus() + && ui.input(|i| { + i.key_pressed(egui::Key::Enter) && (i.modifiers.command || i.modifiers.ctrl) + }); + ui.horizontal(|ui| { + if (ui + .add_enabled( + can_ask && !self.prompt.trim().is_empty(), + theme::primary_button("Ask (Ctrl+Enter)"), + ) + .clicked() + || (submit && can_ask)) + && let Some((kind, language, text)) = active + { + self.ask(workspace, kind, language, text); + } + }); + } + + fn ask( + &mut self, + workspace: &Workspace, + kind: DatabaseKind, + language: QueryLanguage, + active: String, + ) { + let prompt = self.prompt.trim().to_owned(); + if prompt.is_empty() { + return; + } + self.error = None; + self.turns.push(Turn { + user: true, + text: prompt.clone(), + }); + self.prompt.clear(); + let language = match language { + QueryLanguage::Auto => { + if kind == DatabaseKind::MongoDb { + QueryLanguage::MongoJson + } else { + QueryLanguage::Sql + } + } + other => other, + }; + self.pending = Some(workspace.ai_chat(AiAssistantRequest { + prompt, + active_query: Some(active).filter(|t| !t.trim().is_empty()), + database_kind: kind, + query_language: language, + })); + } +} + +/// Renders an answer: prose as labels, fenced code blocks as code with an Insert button. +fn render_answer(ui: &mut Ui, index: usize, text: &str, events: &mut Vec) { + for (block, piece) in split_fences(text) { + if block { + egui::Frame::group(ui.style()).show(ui, |ui| { + ui.horizontal(|ui| { + ui.add( + egui::Label::new(RichText::new(piece.trim()).monospace().small()).wrap(), + ); + }); + ui.horizontal(|ui| { + if ui + .add(theme::primary_button("Insert into editor")) + .clicked() + { + events.push(AiEvent::Insert(piece.trim().to_owned())); + } + if ui.small_button("Copy").clicked() { + ui.ctx().copy_text(piece.trim().to_owned()); + } + }); + }); + } else if !piece.trim().is_empty() { + ui.push_id(("ai-prose", index, piece.len()), |ui| { + ui.add(egui::Label::new(piece.trim()).wrap()); + }); + } + } +} + +/// Splits markdown into `(is_code_block, text)` pieces by triple-backtick fences; the language tag is dropped. +pub fn split_fences(text: &str) -> Vec<(bool, String)> { + let mut pieces = Vec::new(); + let mut in_block = false; + let mut current = String::new(); + for line in text.lines() { + if line.trim_start().starts_with("```") { + if !current.is_empty() || in_block { + pieces.push((in_block, std::mem::take(&mut current))); + } + in_block = !in_block; + continue; + } + current.push_str(line); + current.push('\n'); + } + if !current.is_empty() { + pieces.push((in_block, current)); + } + pieces +} diff --git a/apps/redrob-query/src/app.rs b/apps/redrob-query/src/app.rs new file mode 100644 index 0000000..af88226 --- /dev/null +++ b/apps/redrob-query/src/app.rs @@ -0,0 +1,663 @@ +//! The shell: one window with the profiles and schema on the left, query tabs in the middle (editor +//! above, result below, structure beside), the Ask dock on the right and a status line at the +//! bottom. Every panel talks to the engine through the `Workspace` and reports back as events the +//! app routes here, so no panel reaches into another. + +use egui::{RichText, Ui}; +use redrob_query_core::{ConnectionProfile, DatabaseKind, MetadataNode, QueryLanguage, Workspace}; +use uuid::Uuid; + +use crate::account::AccountWatch; +use crate::ai_dock::{AiDock, AiEvent}; +use crate::changes::{ChangesEvent, ChangesPanel}; +use crate::editor::QueryTab; +use crate::highlight::Dialect; +use crate::profiles::{ProfilesEvent, ProfilesPanel}; +use crate::schema::{SchemaBrowser, SchemaEvent, preview_query}; +use crate::services; +use crate::structure::{StructureEvent, StructurePanel}; +use crate::theme; +use crate::workspace::{HistoryEntry, SavedTab, WorkspaceFile, now_unix}; + +pub struct QueryApp { + workspace: Workspace, + profiles: ProfilesPanel, + schema: SchemaBrowser, + structure: StructurePanel, + changes: ChangesPanel, + ai: AiDock, + account: AccountWatch, + tabs: Vec, + active: usize, + history: Vec, + show_structure: bool, + show_ai: bool, + show_history: bool, + notice: Option<(String, bool)>, + tab_counter: usize, + dirty_since_save: bool, +} + +impl QueryApp { + pub fn new(ctx: &egui::Context) -> Self { + theme::apply(ctx); + let workspace = match services::config_dir() { + Some(dir) => Workspace::persistent(&dir).or_else(|error| { + log::warn!("profile storage unavailable ({error}); running without it"); + Workspace::ephemeral() + }), + None => Workspace::ephemeral(), + } + .unwrap_or_else(|error| { + // Without a tokio runtime nothing can run; say so and exit rather than show a dead window. + eprintln!("redrob-query: could not start the engine: {error}"); + std::process::exit(1); + }); + let repaint = ctx.clone(); + workspace.set_wake(move || repaint.request_repaint()); + + let saved = WorkspaceFile::load().unwrap_or_default(); + let mut tabs: Vec = saved + .tabs + .iter() + .map(|tab| QueryTab::new(tab.title.clone(), tab.text.clone(), None)) + .collect(); + for (tab, saved) in tabs.iter_mut().zip(&saved.tabs) { + tab.connection_id = saved.connection_id; + } + let profiles = ProfilesPanel::new(&workspace); + let ai = AiDock::new(&workspace); + let account = AccountWatch::new(&workspace); + let tab_counter = tabs.len(); + Self { + profiles, + schema: SchemaBrowser::default(), + structure: StructurePanel::default(), + changes: ChangesPanel::default(), + ai, + account, + tabs, + active: saved.active_tab.min(tab_counter.saturating_sub(1)), + history: saved.history, + show_structure: saved.structure_open, + show_ai: saved.ai_dock_open, + show_history: false, + notice: None, + tab_counter, + dirty_since_save: false, + workspace, + } + } + + fn notify(&mut self, text: impl Into, is_error: bool) { + let text = text.into(); + if is_error { + log::warn!("{text}"); + } + self.notice = Some((text, is_error)); + } + + fn profile(&self, id: Option) -> Option<&ConnectionProfile> { + id.and_then(|id| self.profiles.profile(id)) + } + + fn save_workspace(&mut self) { + let file = WorkspaceFile { + schema: 1, + tabs: self + .tabs + .iter() + .map(|tab| SavedTab { + title: tab.title.clone(), + text: tab.text.clone(), + connection_id: tab.connection_id, + }) + .collect(), + active_tab: self.active, + history: self.history.clone(), + ai_dock_open: self.show_ai, + structure_open: self.show_structure, + }; + file.save(); + self.dirty_since_save = false; + } + + fn open_tab_for(&mut self, profile: &ConnectionProfile, text: Option) { + // Re-use an open tab on this connection that is still its untouched starter. + if text.is_none() + && let Some(index) = self.tabs.iter().position(|t| { + t.connection_id == Some(profile.id) + && t.text.trim() == crate::highlight::starter_query(profile.kind).trim() + && t.result.is_none() + }) + { + self.active = index; + return; + } + self.tab_counter += 1; + let mut tab = match text { + Some(text) => QueryTab::new( + format!("{} {}", profile.name, self.tab_counter), + text, + Some(profile), + ), + None => QueryTab::for_connection(profile, self.tab_counter), + }; + tab.focus_editor = true; + if self.tabs.len() >= crate::workspace::MAX_TABS { + self.notify( + format!( + "At most {} tabs are kept; close one first.", + crate::workspace::MAX_TABS + ), + true, + ); + return; + } + self.tabs.push(tab); + self.active = self.tabs.len() - 1; + self.dirty_since_save = true; + } + + /// Tabs that were restored with a connection id get the live profile's dialect once the + /// profile list arrives. + fn bind_restored_tabs(&mut self) { + for tab in &mut self.tabs { + if let Some(profile) = tab.connection_id.and_then(|id| self.profiles.profile(id)) { + let dialect = Dialect::for_kind(profile.kind); + if tab.dialect != dialect || tab.read_only_profile != profile.read_only { + tab.set_connection(profile); + } + } + } + } + + fn poll(&mut self) { + let mut profile_events = Vec::new(); + self.profiles.poll(&self.workspace, &mut profile_events); + let mut connected_now = Vec::new(); + for event in profile_events { + match event { + ProfilesEvent::OpenQuery(profile) => self.open_tab_for(&profile, None), + ProfilesEvent::Connected(id) => connected_now.push(id), + ProfilesEvent::Disconnected(id) => { + self.schema.forget(id); + self.structure.forget(id); + } + ProfilesEvent::Notice(text, is_error) => self.notify(text, is_error), + } + } + for id in connected_now { + self.schema.load_root(&self.workspace, id); + } + self.bind_restored_tabs(); + + let mut schema_events = Vec::new(); + self.schema.poll(&mut schema_events); + self.handle_schema_events(schema_events); + + let mut structure_events = Vec::new(); + self.structure.poll(&mut structure_events); + for event in structure_events { + match event { + StructureEvent::Changed(id) => { + self.schema.load_root(&self.workspace, id); + self.notify("Table change applied", false); + } + StructureEvent::Notice(text, is_error) => self.notify(text, is_error), + } + } + + let mut changes_events = Vec::new(); + self.changes.poll(&mut changes_events); + for event in changes_events { + match event { + ChangesEvent::Applied(result) => { + self.notify( + format!( + "Applied {} row change(s) in {} ms", + result.rows_affected, result.elapsed_ms + ), + false, + ); + // Re-run the tab so the grid shows the committed state. + if let Some(tab) = self.tabs.get_mut(self.active) { + if let Some(grid) = &mut tab.result { + grid.clear_changes(); + } + tab.run_now(&self.workspace, None); + } + } + ChangesEvent::Notice(text, is_error) => self.notify(text, is_error), + } + } + + let mut ai_events = Vec::new(); + self.ai.poll(&mut ai_events); + for event in ai_events { + match event { + AiEvent::Insert(draft) => { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.insert(draft); + tab.focus_editor = true; + } else { + self.notify("Open a query tab to insert the draft into", true); + } + } + AiEvent::Notice(text, is_error) => self.notify(text, is_error), + } + } + + if self.account.poll(&self.workspace) { + self.ai.refresh_status(&self.workspace); + let text = if self.account.status.signed_in() { + format!("Signed in: {}", self.account.status.label) + } else { + "Signed out of Redrob".to_owned() + }; + self.notify(text, false); + } + + for index in 0..self.tabs.len() { + let profile = self.tabs[index] + .connection_id + .and_then(|id| self.profiles.profile(id)) + .cloned(); + self.tabs[index].poll(&self.workspace, profile.as_ref()); + for finished in self.tabs[index].take_finished() { + self.history.push(HistoryEntry { + connection_id: finished.connection_id, + connection_name: finished.connection_name, + query: finished.query, + ran_at_unix: now_unix(), + rows: finished.rows, + elapsed_ms: finished.elapsed_ms, + ok: finished.ok, + }); + if self.history.len() > crate::workspace::MAX_HISTORY { + let drop = self.history.len() - crate::workspace::MAX_HISTORY; + self.history.drain(..drop); + } + self.dirty_since_save = true; + } + } + } + + fn handle_schema_events(&mut self, events: Vec) { + for event in events { + match event { + SchemaEvent::Select { + connection_id, + node, + } => { + if let Some(kind) = self.profiles.profile(connection_id).map(|p| p.kind) { + self.structure + .show_table(&self.workspace, connection_id, kind, node); + self.show_structure = true; + } + } + SchemaEvent::Preview { + connection_id, + node, + } => { + if let Some(profile) = self.profiles.profile(connection_id).cloned() { + let query = preview_query(profile.kind, &node); + self.open_tab_for(&profile, Some(query)); + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.title.clone_from(&node.name); + tab.run_now(&self.workspace, None); + } + } + } + SchemaEvent::Notice(text, is_error) => self.notify(text, is_error), + } + } + } + + fn top_bar(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + ui.label(RichText::new(services::APP_NAME).strong().size(16.0)); + ui.label(RichText::new(env!("CARGO_PKG_VERSION")).small().weak()); + ui.separator(); + ui.toggle_value(&mut self.show_structure, "Structure"); + ui.toggle_value(&mut self.show_history, "History"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.toggle_value(&mut self.show_ai, "Ask").changed() { + self.dirty_since_save = true; + } + ui.separator(); + let status = &self.account.status; + if status.signed_in() { + theme::status_dot(ui, theme::SUCCESS); + ui.label(RichText::new(&status.label).small()) + .on_hover_text(self.account.describe_paths()); + } else { + let weak = ui.visuals().weak_text_color(); + theme::status_dot(ui, weak); + if ui + .small_button("Sign in with Redrob Launcher") + .on_hover_text(self.account.describe_paths()) + .clicked() + { + let _ = webbrowser::open(Workspace::launcher_url()); + } + } + }); + }); + } + + fn status_bar(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + match &self.notice { + Some((text, is_error)) => { + let colour = theme::tone_for_error(ui, *is_error); + ui.label(RichText::new(text).small().color(colour)); + } + None => { + ui.label(RichText::new("Ready").small().weak()); + } + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let connected = self + .profiles + .profiles + .iter() + .filter(|p| self.profiles.is_connected(p.id)) + .count(); + ui.label( + RichText::new(format!("{connected} connected · {} tabs", self.tabs.len())) + .small() + .weak(), + ); + }); + }); + } + + fn tab_bar(&mut self, ui: &mut Ui) { + let mut close: Option = None; + let mut new_tab = false; + egui::ScrollArea::horizontal() + .id_salt("tabs") + .auto_shrink([false, true]) + .show(ui, |ui| { + ui.horizontal(|ui| { + for (index, tab) in self.tabs.iter().enumerate() { + let mut title = tab.title.clone(); + if tab.is_running() { + title.push_str(" ⟳"); + } else if tab + .result + .as_ref() + .is_some_and(crate::grid::ResultGrid::has_changes) + { + title.push_str(" •"); + } + let selected = index == self.active; + let text = if selected { + RichText::new(title).color(theme::REDROB_BLUE).strong() + } else { + RichText::new(title) + }; + let response = ui.selectable_label(selected, text); + if response.clicked() { + self.active = index; + } + if response.middle_clicked() { + close = Some(index); + } + response.context_menu(|ui| { + if ui.button("Close").clicked() { + close = Some(index); + ui.close(); + } + }); + } + if ui + .button("+") + .on_hover_text("New tab on the selected profile") + .clicked() + { + new_tab = true; + } + }); + }); + if let Some(index) = close { + self.tabs.remove(index); + if self.active >= self.tabs.len() { + self.active = self.tabs.len().saturating_sub(1); + } + self.dirty_since_save = true; + } + if new_tab { + let selected = self + .profiles + .selected + .and_then(|id| self.profiles.profile(id)) + .cloned() + .or_else(|| { + self.profiles + .profiles + .iter() + .find(|p| self.profiles.is_connected(p.id)) + .cloned() + }); + match selected { + Some(profile) if self.profiles.is_connected(profile.id) => { + self.tab_counter += 1; + let mut tab = QueryTab::for_connection(&profile, self.tab_counter); + tab.focus_editor = true; + self.tabs.push(tab); + self.active = self.tabs.len() - 1; + self.dirty_since_save = true; + } + Some(profile) => self.profiles.connect(&self.workspace, profile.id), + None => self.notify("Select or connect a profile first", true), + } + } + } + + fn history_ui(&mut self, ui: &mut Ui) { + ui.heading("History"); + let mut rerun: Option = None; + egui::ScrollArea::vertical() + .id_salt("history") + .auto_shrink([false, false]) + .show(ui, |ui| { + if self.history.is_empty() { + ui.label(RichText::new("Nothing has run yet.").weak()); + } + for entry in self.history.iter().rev() { + let one_line: String = + entry.query.split_whitespace().collect::>().join(" "); + let shown: String = one_line.chars().take(90).collect(); + let colour = if entry.ok { + ui.visuals().text_color() + } else { + theme::DANGER + }; + let response = ui.add( + egui::Label::new(RichText::new(shown).monospace().small().color(colour)) + .sense(egui::Sense::click()) + .truncate(), + ); + let meta = format!( + "{} · {} rows · {} ms", + entry.connection_name, entry.rows, entry.elapsed_ms + ); + ui.label(RichText::new(meta).small().weak()); + if response.double_clicked() { + rerun = Some(entry.clone()); + } + response + .on_hover_text(format!("{}\n\nDouble-click to open in a tab", entry.query)); + ui.separator(); + } + }); + if let Some(entry) = rerun { + if let Some(profile) = self.profile(entry.connection_id).cloned() { + self.open_tab_for(&profile, Some(entry.query)); + } else { + self.notify("That connection is not available any more", true); + } + } + } + + fn central(&mut self, ui: &mut Ui) { + self.tab_bar(ui); + ui.separator(); + if self.tabs.is_empty() { + ui.vertical_centered(|ui| { + ui.add_space(ui.available_height() * 0.3); + ui.label( + RichText::new("Connect a profile on the left, then open a query tab.").weak(), + ); + ui.label( + RichText::new("The built-in Demo SQLite database works offline.") + .small() + .weak(), + ); + }); + return; + } + let active = self.active.min(self.tabs.len() - 1); + self.active = active; + let profile = self.tabs[active] + .connection_id + .and_then(|id| self.profiles.profile(id)) + .cloned(); + let read_only = profile.as_ref().is_none_or(|p| p.read_only); + let collections: Vec = profile + .as_ref() + .filter(|p| p.kind == DatabaseKind::MongoDb) + .map(|p| self.schema.roots(p.id).to_vec()) + .unwrap_or_default(); + + let mut notice = None; + egui::Panel::top("editor-pane") + .resizable(true) + .default_size(260.0) + .size_range(120.0..=f32::INFINITY) + .show(ui, |ui| { + notice = self.tabs[active].editor_ui( + ui, + &self.workspace, + profile.as_ref(), + &collections, + ); + }); + if let Some(text) = notice { + self.notify(text, false); + } + + if self.show_structure { + egui::Panel::right("structure-pane") + .resizable(true) + .default_size(300.0) + .show(ui, |ui| { + self.structure.ui(ui, &self.workspace, read_only); + }); + } + let tab = &mut self.tabs[active]; + if let Some(grid) = &mut tab.result + && grid.has_changes() + { + egui::Panel::bottom("changes-pane") + .resizable(true) + .default_size(160.0) + .show(ui, |ui| { + self.changes + .ui(ui, &self.workspace, grid, tab.source.as_ref(), read_only); + }); + } + egui::CentralPanel::default().show(ui, |ui| match &mut tab.result { + Some(grid) => grid.ui(ui), + None => { + ui.label(RichText::new("Run a query to see its rows here (Ctrl+Enter).").weak()); + } + }); + } +} + +impl eframe::App for QueryApp { + fn ui(&mut self, ui: &mut Ui, _frame: &mut eframe::Frame) { + self.poll(); + + egui::Panel::top("top-bar").show(ui, |ui| self.top_bar(ui)); + egui::Panel::bottom("status-bar").show(ui, |ui| self.status_bar(ui)); + egui::Panel::left("side") + .resizable(true) + .default_size(280.0) + .size_range(200.0..=600.0) + .show(ui, |ui| { + let mut events = Vec::new(); + self.profiles.ui(ui, &self.workspace, &mut events); + for event in events { + match event { + ProfilesEvent::OpenQuery(profile) => self.open_tab_for(&profile, None), + ProfilesEvent::Connected(id) => self.schema.load_root(&self.workspace, id), + ProfilesEvent::Disconnected(id) => { + self.schema.forget(id); + self.structure.forget(id); + } + ProfilesEvent::Notice(text, is_error) => self.notify(text, is_error), + } + } + ui.separator(); + let connected: Vec = self + .profiles + .profiles + .iter() + .filter(|p| self.profiles.is_connected(p.id)) + .cloned() + .collect(); + let refs: Vec<&ConnectionProfile> = connected.iter().collect(); + let mut events = Vec::new(); + self.schema.ui(ui, &self.workspace, &refs, &mut events); + self.handle_schema_events(events); + }); + if self.show_ai { + egui::Panel::right("ai-dock") + .resizable(true) + .default_size(340.0) + .size_range(260.0..=700.0) + .show(ui, |ui| { + let active = self.tabs.get(self.active).and_then(|tab| { + let profile = tab.connection_id.and_then(|id| self.profiles.profile(id))?; + let language = match tab.dialect { + Dialect::Sql => QueryLanguage::Sql, + Dialect::MongoJson => QueryLanguage::MongoJson, + }; + Some((profile.kind, language, tab.text.clone())) + }); + let mut events = Vec::new(); + self.ai.ui(ui, &self.workspace, active, &mut events); + for event in events { + match event { + AiEvent::Insert(draft) => { + if let Some(tab) = self.tabs.get_mut(self.active) { + tab.insert(draft); + tab.focus_editor = true; + } + } + AiEvent::Notice(text, is_error) => self.notify(text, is_error), + } + } + }); + } + if self.show_history { + egui::Panel::right("history-pane") + .resizable(true) + .default_size(300.0) + .show(ui, |ui| self.history_ui(ui)); + } + egui::CentralPanel::default().show(ui, |ui| self.central(ui)); + + if self.dirty_since_save { + self.save_workspace(); + } + } + + fn on_exit(&mut self) { + self.save_workspace(); + } +} diff --git a/apps/redrob-query/src/changes.rs b/apps/redrob-query/src/changes.rs new file mode 100644 index 0000000..1eb550c --- /dev/null +++ b/apps/redrob-query/src/changes.rs @@ -0,0 +1,225 @@ +//! The Changes panel: the staged cell edits and row deletions of a result, reviewed as a list, +//! then applied through `apply_cell_edits` in one transaction (each row update must hit exactly +//! one row or everything rolls back). Nothing is written until Apply is pressed. + +use egui::{RichText, Ui}; +use redrob_query_core::{ + DataValue, MutationResult, Pending, Workspace, + cell_edits::{CellChange, CellEditSet, RowEdit}, +}; +use uuid::Uuid; + +use crate::grid::{ResultGrid, format_value}; +use crate::theme; + +/// Which table a result came from, when the editor could tell: a single-table SELECT, or a +/// preview opened from the schema tree. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ResultSource { + pub connection_id: Uuid, + pub schema: Option, + pub table: String, +} + +/// The one-column primary key the result can be edited by, or why it cannot. +pub fn editable_key(grid: &ResultGrid) -> Result<(String, usize), &'static str> { + match grid.key_columns.as_slice() { + [] => Err("the table has no primary key"), + [key] => grid + .page + .columns + .iter() + .position(|c| &c.name == key) + .map(|index| (key.clone(), index)) + .ok_or("select the primary key column to edit"), + _ => Err("composite primary keys cannot be edited here yet"), + } +} + +/// The staged state as the engine's change set. Rows whose key is NULL are refused. +pub fn build_edit_set(grid: &ResultGrid, source: &ResultSource) -> Result { + let (key, key_index) = editable_key(grid).map_err(str::to_owned)?; + let key_of = |row: usize| -> Result { + match grid.page.rows.get(row).and_then(|r| r.get(key_index)) { + Some(DataValue::Null) | None => { + Err(format!("row {} has no primary key value", row + 1)) + } + Some(value) => Ok(value.clone()), + } + }; + let mut rows: Vec = Vec::new(); + for (&(row, column), value) in &grid.staged { + if grid.deleted.contains(&row) { + continue; + } + if column == key_index { + return Err("the primary key column itself cannot be edited".to_owned()); + } + let change = CellChange { + column: grid.page.columns[column].name.clone(), + value: value.clone(), + }; + let key_value = key_of(row)?; + match rows.iter_mut().find(|r| r.key == key_value) { + Some(existing) => existing.changes.push(change), + None => rows.push(RowEdit { + key: key_value, + changes: vec![change], + }), + } + } + let deletes = grid + .deleted + .iter() + .map(|&row| key_of(row)) + .collect::, _>>()?; + Ok(CellEditSet { + schema: source.schema.clone(), + table: source.table.clone(), + primary_key: key, + rows, + inserts: Vec::new(), + deletes, + }) +} + +#[derive(Default)] +pub struct ChangesPanel { + apply: Option>, + pub last: Option<(String, bool)>, +} + +/// What the panel asks the app to do after an apply. +pub enum ChangesEvent { + Applied(MutationResult), + Notice(String, bool), +} + +impl ChangesPanel { + pub fn busy(&self) -> bool { + self.apply.is_some() + } + + pub fn poll(&mut self, events: &mut Vec) { + if let Some(apply) = &mut self.apply + && let Some(result) = apply.poll() + { + self.apply = None; + match result { + Ok(outcome) => { + self.last = Some(( + format!( + "Applied: {} rows changed in {} ms", + outcome.rows_affected, outcome.elapsed_ms + ), + true, + )); + events.push(ChangesEvent::Applied(outcome)); + } + Err(error) => { + self.last = Some((error.to_string(), false)); + events.push(ChangesEvent::Notice(error.to_string(), true)); + } + } + } + } + + pub fn ui( + &mut self, + ui: &mut Ui, + workspace: &Workspace, + grid: &mut ResultGrid, + source: Option<&ResultSource>, + read_only_profile: bool, + ) { + ui.horizontal(|ui| { + ui.heading("Changes"); + let count = grid.staged.len() + grid.deleted.len(); + ui.label(RichText::new(format!("{count} staged")).weak()); + }); + if let Some((text, ok)) = &self.last { + ui.label(RichText::new(text).color(if *ok { theme::SUCCESS } else { theme::DANGER })); + } + let Some(source) = source else { + ui.label(RichText::new("Edits need a result from one table.").weak()); + return; + }; + if read_only_profile { + ui.label(RichText::new("This profile is read-only. Untick \"Read-only\" in its connection form to allow reviewed edits.").color(theme::CAUTION)); + } + let set = build_edit_set(grid, source); + egui::ScrollArea::vertical() + .id_salt("changes") + .auto_shrink([false, true]) + .max_height(220.0) + .show(ui, |ui| { + for (&(row, column), value) in &grid.staged { + let before = grid + .page + .rows + .get(row) + .and_then(|r| r.get(column)) + .map(format_value) + .unwrap_or_default(); + ui.horizontal(|ui| { + ui.label(RichText::new(format!("row {}", row + 1)).small().weak()); + ui.label(RichText::new(&grid.page.columns[column].name).monospace()); + ui.label(RichText::new(before).weak().strikethrough()); + ui.label("→"); + ui.label(RichText::new(format_value(value)).color(theme::CAUTION)); + }); + } + for &row in &grid.deleted { + ui.horizontal(|ui| { + ui.label(RichText::new(format!("row {}", row + 1)).small().weak()); + ui.label(RichText::new("delete").color(theme::DANGER)); + }); + } + }); + match &set { + Ok(set) => { + ui.label( + RichText::new(format!( + "{} row update(s), {} delete(s) on {}{} keyed by {}", + set.rows.len(), + set.deletes.len(), + set.schema + .as_deref() + .map(|s| format!("{s}.")) + .unwrap_or_default(), + set.table, + set.primary_key + )) + .small(), + ); + } + Err(reason) => { + ui.label(RichText::new(reason).small().color(theme::CAUTION)); + } + } + ui.horizontal(|ui| { + let can_apply = set.is_ok() && grid.has_changes() && !self.busy() && !read_only_profile; + if ui + .add_enabled(can_apply, theme::primary_button("Apply")) + .clicked() + && let Ok(set) = set + { + self.last = None; + self.apply = Some(workspace.apply_cell_edits(source.connection_id, set)); + } + if ui + .add_enabled( + grid.has_changes() && !self.busy(), + egui::Button::new("Discard"), + ) + .clicked() + { + grid.clear_changes(); + self.last = None; + } + if self.busy() { + ui.spinner(); + } + }); + } +} diff --git a/apps/redrob-query/src/editor.rs b/apps/redrob-query/src/editor.rs new file mode 100644 index 0000000..ed25ae1 --- /dev/null +++ b/apps/redrob-query/src/editor.rs @@ -0,0 +1,502 @@ +//! Query tabs: the editor with syntax colouring, the run / format / save shortcuts, and each tab's +//! result grid with its source table (for the changes and structure panels). +//! +//! Ctrl+Enter runs the selection, else the statement under the cursor (SQL) or the whole text +//! (Mongo JSON); a batch of several read-only statements runs them one by one and shows the last +//! result. Ctrl+Shift+F formats. Ctrl+S saves the tab's text to a file (asking for one the first +//! time). The workspace itself (tabs, history) is saved by the app. + +use std::path::PathBuf; + +use egui::{RichText, Ui}; +use redrob_query_core::{ + ConnectionProfile, DatabaseKind, Pending, QueryClassification, QueryRequest, QueryResultPage, + Workspace, query::classify_sql, +}; +use uuid::Uuid; + +use crate::changes::ResultSource; +use crate::grid::ResultGrid; +use crate::highlight::{self, Dialect}; +use crate::mongo::MongoStrip; +use crate::theme; + +const DEFAULT_LIMIT: usize = 500; + +/// A run that finished, for the history list. +pub struct Finished { + pub connection_id: Option, + pub connection_name: String, + pub query: String, + pub rows: usize, + pub elapsed_ms: u64, + pub ok: bool, +} + +pub struct QueryTab { + pub title: String, + pub text: String, + pub connection_id: Option, + pub dialect: Dialect, + pub result: Option, + pub source: Option, + pub error: Option, + pub mongo: MongoStrip, + pub limit: usize, + pub file: Option, + run: Option>, + keys: Option>>, + /// Statements still to run in a batch, and how many ran. + queue: Vec, + ran_in_batch: usize, + running_text: String, + cursor_char: usize, + finished: Vec, + pub read_only_profile: bool, + pending_insert: Option, + pub focus_editor: bool, +} + +impl QueryTab { + pub fn new(title: String, text: String, connection: Option<&ConnectionProfile>) -> Self { + let dialect = connection.map_or(Dialect::Sql, |p| Dialect::for_kind(p.kind)); + let mut mongo = MongoStrip::default(); + if dialect == Dialect::MongoJson { + mongo.sync_from(&text); + } + Self { + title, + text, + connection_id: connection.map(|p| p.id), + dialect, + result: None, + source: None, + error: None, + mongo, + limit: DEFAULT_LIMIT, + file: None, + run: None, + keys: None, + queue: Vec::new(), + ran_in_batch: 0, + running_text: String::new(), + cursor_char: 0, + finished: Vec::new(), + read_only_profile: connection.is_none_or(|p| p.read_only), + pending_insert: None, + focus_editor: false, + } + } + + pub fn for_connection(profile: &ConnectionProfile, index: usize) -> Self { + let text = highlight::starter_query(profile.kind).to_owned(); + Self::new(format!("{} {}", profile.name, index), text, Some(profile)) + } + + pub fn is_running(&self) -> bool { + self.run.is_some() + } + + /// Runs finished since the last call. + pub fn take_finished(&mut self) -> Vec { + std::mem::take(&mut self.finished) + } + + /// Appends (or replaces an empty tab with) a draft from the AI dock. + pub fn insert(&mut self, draft: String) { + self.pending_insert = Some(draft); + } + + pub fn set_connection(&mut self, profile: &ConnectionProfile) { + self.connection_id = Some(profile.id); + self.dialect = Dialect::for_kind(profile.kind); + self.read_only_profile = profile.read_only; + } + + pub fn poll(&mut self, workspace: &Workspace, profile: Option<&ConnectionProfile>) { + if let Some(run) = &mut self.run + && let Some(result) = run.poll() + { + self.run = None; + let (rows, elapsed, ok) = match &result { + Ok(page) => (page.stats.rows_returned, page.stats.elapsed_ms, true), + Err(_) => (0, 0, false), + }; + self.finished.push(Finished { + connection_id: self.connection_id, + connection_name: profile.map(|p| p.name.clone()).unwrap_or_default(), + query: self.running_text.clone(), + rows, + elapsed_ms: elapsed, + ok, + }); + match result { + Ok(page) => { + self.error = None; + let mut grid = ResultGrid::new(page); + grid.json_view = self.dialect == Dialect::MongoJson; + self.result = Some(grid); + self.ran_in_batch += 1; + if self.queue.is_empty() { + self.resolve_source(workspace, profile); + } else if let Some(connection_id) = self.connection_id { + let next = self.queue.remove(0); + self.start(workspace, connection_id, next); + } + } + Err(error) => { + self.queue.clear(); + self.error = Some(error.to_string()); + } + } + } + if let Some(keys) = &mut self.keys + && let Some(result) = keys.poll() + { + self.keys = None; + if let (Ok(columns), Some(grid)) = (result, &mut self.result) { + grid.key_columns = columns; + grid.editable = grid.key_columns.len() == 1 && !self.read_only_profile; + } + } + } + + /// Works out which table the result came from (a single-table SELECT), then asks for its key. + fn resolve_source(&mut self, workspace: &Workspace, profile: Option<&ConnectionProfile>) { + self.source = None; + let Some(profile) = profile else { return }; + if profile.kind == DatabaseKind::MongoDb { + return; + } + if let Some((schema, table)) = single_table_of(&self.running_text, profile.kind) { + self.source = Some(ResultSource { + connection_id: profile.id, + schema: schema.clone(), + table: table.clone(), + }); + self.keys = Some(workspace.primary_key_columns(profile.id, schema, table)); + } + } + + fn start(&mut self, workspace: &Workspace, connection_id: Uuid, query: String) { + self.running_text.clone_from(&query); + self.run = Some(workspace.execute_query(QueryRequest { + connection_id, + query, + language: self.dialect.language(), + parameters: Vec::new(), + limit: self.limit, + offset: 0, + timeout_ms: 30_000, + })); + } + + /// Runs `selection` when given, else the statement under the cursor; a read-only batch runs + /// statement by statement. + pub fn run_now(&mut self, workspace: &Workspace, selection: Option) { + let Some(connection_id) = self.connection_id else { + self.error = Some("choose a connection for this tab first".to_owned()); + return; + }; + if self.run.is_some() { + return; + } + self.error = None; + self.ran_in_batch = 0; + let text = match selection { + Some(s) if !s.trim().is_empty() => s, + _ => highlight::statement_at(&self.text, self.cursor_char, self.dialect), + }; + if text.trim().is_empty() { + self.error = Some("nothing to run".to_owned()); + return; + } + if self.dialect == Dialect::Sql + && let Some(mut statements) = Workspace::split_read_only_batch(&text) + && statements.len() > 1 + { + let first = statements.remove(0); + self.queue = statements; + self.start(workspace, connection_id, first); + return; + } + self.queue.clear(); + self.start(workspace, connection_id, text); + } + + pub fn format_text(&mut self) { + self.text = highlight::format(&self.text, self.dialect); + } + + /// Ctrl+S: write the tab's text to its file, asking for one the first time. + pub fn save_to_file(&mut self, save_as: bool) -> Option { + if save_as || self.file.is_none() { + let extension = if self.dialect == Dialect::MongoJson { + "json" + } else { + "sql" + }; + let picked = rfd::FileDialog::new() + .set_file_name(format!( + "{}.{extension}", + self.title.replace(' ', "-").to_lowercase() + )) + .save_file()?; + self.file = Some(picked); + } + let path = self.file.clone()?; + match std::fs::write(&path, &self.text) { + Ok(()) => { + if let Some(stem) = path.file_stem() { + self.title = stem.to_string_lossy().into_owned(); + } + Some(format!("Saved {}", path.display())) + } + Err(error) => { + self.error = Some(format!("could not save: {error}")); + None + } + } + } + + /// Draws the editor half of the tab. Returns a status-line message when there is one. + pub fn editor_ui( + &mut self, + ui: &mut Ui, + workspace: &Workspace, + profile: Option<&ConnectionProfile>, + collections: &[redrob_query_core::MetadataNode], + ) -> Option { + let mut notice = None; + if let Some(draft) = self.pending_insert.take() { + if self.text.trim().is_empty() { + self.text = draft; + } else { + if !self.text.ends_with('\n') { + self.text.push('\n'); + } + self.text.push('\n'); + self.text.push_str(&draft); + } + } + // Toolbar. + ui.horizontal(|ui| { + let running = self.run.is_some(); + let can_run = self.connection_id.is_some() && !running; + if ui + .add_enabled(can_run, theme::primary_button("▶ Run")) + .on_hover_text("Ctrl+Enter: the selection or the statement under the cursor") + .clicked() + { + self.run_now(workspace, None); + } + if running { + ui.spinner(); + if !self.queue.is_empty() || self.ran_in_batch > 0 { + ui.label( + RichText::new(format!( + "statement {} of {}", + self.ran_in_batch + 1, + self.ran_in_batch + 1 + self.queue.len() + )) + .small(), + ); + } + } + if ui.button("Format").on_hover_text("Ctrl+Shift+F").clicked() { + self.format_text(); + } + if ui + .button("Save") + .on_hover_text("Ctrl+S: save the text to a file") + .clicked() + { + notice = self.save_to_file(false); + } + ui.label(RichText::new("Limit").small()); + ui.add( + egui::DragValue::new(&mut self.limit) + .range(1..=10_000) + .speed(10), + ); + if let Some(profile) = profile { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + let classification = if self.dialect == Dialect::Sql { + Some(classify_sql(&highlight::statement_at( + &self.text, + self.cursor_char, + self.dialect, + ))) + } else { + None + }; + if let Some(c) = classification { + let (text, colour) = match c { + QueryClassification::ReadOnly => ("read-only", theme::SUCCESS), + QueryClassification::Mutation => { + ("mutation (use the grid to edit)", theme::CAUTION) + } + QueryClassification::Destructive => { + ("destructive (refused)", theme::DANGER) + } + QueryClassification::TransactionControl => { + ("transaction control (refused)", theme::CAUTION) + } + QueryClassification::Unknown => ("unknown (refused)", theme::CAUTION), + }; + ui.label(RichText::new(text).small().color(colour)); + } + ui.label( + RichText::new(format!( + "{} · {}", + profile.name, + crate::profiles::kind_name(profile.kind) + )) + .small() + .weak(), + ); + }); + } else { + ui.label( + RichText::new("no connection: connect a profile, then open a tab from it") + .small() + .color(theme::CAUTION), + ); + } + }); + if self.dialect == Dialect::MongoJson + && let Some(template) = self.mongo.ui(ui, collections) + { + self.text = template; + } + + // The editor itself. + let dialect = self.dialect; + let mut layouter = move |ui: &Ui, text: &dyn egui::TextBuffer, wrap: f32| { + let job = highlight::layout(ui, text.as_str(), dialect, wrap); + ui.fonts_mut(|f| f.layout_job(job)) + }; + let output = egui::TextEdit::multiline(&mut self.text) + .code_editor() + .id_salt("query-editor") + .desired_width(f32::INFINITY) + .desired_rows(8) + .lock_focus(true) + .layouter(&mut layouter) + .show(ui); + if self.focus_editor { + output.response.request_focus(); + self.focus_editor = false; + } + if let Some(range) = output.cursor_range { + self.cursor_char = range.primary.index.0; + } + let selection = output + .cursor_range + .filter(|r| !r.is_empty()) + .map(|r| r.slice_str(&self.text).to_owned()); + let focused = output.response.has_focus(); + let (run, format, save, save_as) = ui.input_mut(|i| { + let cmd = egui::Modifiers::COMMAND; + ( + focused && i.consume_key(cmd, egui::Key::Enter), + focused && i.consume_key(cmd | egui::Modifiers::SHIFT, egui::Key::F), + i.consume_key(cmd, egui::Key::S), + i.consume_key(cmd | egui::Modifiers::SHIFT, egui::Key::S), + ) + }); + if run { + self.run_now(workspace, selection); + } + if format { + self.format_text(); + } + if save_as { + notice = self.save_to_file(true); + } else if save { + notice = self.save_to_file(false); + } + if self.dialect == Dialect::MongoJson && output.response.changed() { + self.mongo.sync_from(&self.text); + } + if let Some(error) = &self.error { + ui.label(RichText::new(error).color(theme::DANGER)); + } + notice + } +} + +/// `(schema, table)` of a SELECT that reads from exactly one table with no join, so the result can +/// be edited by its primary key. Quoted identifiers are unquoted; anything else is `None`. +pub fn single_table_of(sql: &str, kind: DatabaseKind) -> Option<(Option, String)> { + let upper = sql.to_ascii_uppercase(); + let words: Vec<&str> = upper.split_whitespace().collect(); + if words.first().copied() != Some("SELECT") { + return None; + } + if words.iter().any(|w| { + matches!( + *w, + "JOIN" | "UNION" | "GROUP" | "DISTINCT" | "INTERSECT" | "EXCEPT" + ) + }) || upper.matches("SELECT").count() != 1 + || upper.contains(" FROM (") + { + return None; + } + // The token after FROM, in the original text's case. + let from_at = upper.find(" FROM ")?; + let after = sql[from_at + 6..].trim_start(); + let end = after + .find(|c: char| c.is_whitespace() || c == ';' || c == ',') + .unwrap_or(after.len()); + let target = &after[..end]; + if target.is_empty() || target.contains('(') { + return None; + } + // An alias or a second table after a comma means more than one relation. + let rest = after[end..].trim_start(); + if rest.starts_with(',') { + return None; + } + let unquote = |s: &str| -> String { + let s = s.trim(); + let quoted = (s.starts_with('"') && s.ends_with('"')) + || (s.starts_with('`') && s.ends_with('`')) + || (s.starts_with('[') && s.ends_with(']')); + if quoted && s.len() >= 2 { + s[1..s.len() - 1].replace("\"\"", "\"").replace("``", "`") + } else { + s.to_owned() + } + }; + let parts: Vec<&str> = split_qualified(target); + match (parts.as_slice(), kind) { + ([table], _) => Some((None, unquote(table))), + ([schema, table], DatabaseKind::PostgreSql | DatabaseKind::MySql) => { + Some((Some(unquote(schema)), unquote(table))) + } + _ => None, + } +} + +/// Splits `a.b` on dots outside quotes. +fn split_qualified(target: &str) -> Vec<&str> { + let mut parts = Vec::new(); + let mut start = 0; + let mut quote: Option = None; + for (i, c) in target.char_indices() { + match (quote, c) { + (Some(q), c) if c == q => quote = None, + (None, '"' | '`') => quote = Some(c), + (None, '[') => quote = Some(']'), + (None, '.') => { + parts.push(&target[start..i]); + start = i + 1; + } + _ => {} + } + } + parts.push(&target[start..]); + parts +} diff --git a/apps/redrob-query/src/grid.rs b/apps/redrob-query/src/grid.rs new file mode 100644 index 0000000..8cd7cb9 --- /dev/null +++ b/apps/redrob-query/src/grid.rs @@ -0,0 +1,630 @@ +//! The result grid: one `QueryResultPage` shown as a virtualised table. +//! +//! Sorting and filtering act on the page the engine returned (at most the request's limit), never +//! on the database, so they cost nothing and promise nothing more than what was fetched. Cells are +//! formatted from `DataValue` for display and parsed back on edit; an edit is only staged -- the +//! changes panel turns the staged cells into a `CellEditSet` the engine applies in one transaction. + +use std::collections::{BTreeMap, BTreeSet}; + +use egui::{RichText, Ui}; +use egui_extras::{Column, TableBuilder}; +use redrob_query_core::{DataValue, QueryResultPage}; + +use crate::theme; + +const ROW_HEIGHT: f32 = 22.0; +const MAX_CELL_CHARS: usize = 200; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SortDirection { + Ascending, + Descending, +} + +enum EditAction { + Start(usize, usize), + Draft(String), + Commit, + Cancel, + SetNull(usize, usize), + Revert(usize, usize), +} + +pub struct ResultGrid { + pub page: QueryResultPage, + sort: Option<(usize, SortDirection)>, + pub filter: String, + hidden: BTreeSet, + /// Row indices of the page in display order after filter and sort. + order: Vec, + order_dirty: bool, + /// (row, column) -> new value, staged by inline editing. + pub staged: BTreeMap<(usize, usize), DataValue>, + pub deleted: BTreeSet, + editing: Option<(usize, usize, String)>, + /// Columns of the primary key, when the result is one editable table; empty is read-only. + pub key_columns: Vec, + pub editable: bool, + pub json_view: bool, + pub selected_row: Option, +} + +impl ResultGrid { + pub fn new(page: QueryResultPage) -> Self { + let rows = page.rows.len(); + Self { + page, + sort: None, + filter: String::new(), + hidden: BTreeSet::new(), + order: (0..rows).collect(), + order_dirty: false, + staged: BTreeMap::new(), + deleted: BTreeSet::new(), + editing: None, + key_columns: Vec::new(), + editable: false, + json_view: false, + selected_row: None, + } + } + + pub fn has_changes(&self) -> bool { + !self.staged.is_empty() || !self.deleted.is_empty() + } + + pub fn clear_changes(&mut self) { + self.staged.clear(); + self.deleted.clear(); + self.editing = None; + } + + fn rebuild_order(&mut self) { + let filter = self.filter.trim().to_lowercase(); + let mut order: Vec = (0..self.page.rows.len()) + .filter(|&row| { + filter.is_empty() + || self.page.rows[row] + .iter() + .any(|value| format_value(value).to_lowercase().contains(&filter)) + }) + .collect(); + if let Some((column, direction)) = self.sort { + order.sort_by(|&a, &b| { + let left = self.page.rows[a].get(column); + let right = self.page.rows[b].get(column); + let ordering = compare(left, right); + match direction { + SortDirection::Ascending => ordering, + SortDirection::Descending => ordering.reverse(), + } + }); + } + self.order = order; + self.order_dirty = false; + } + + /// The value shown in a cell: a staged edit wins over the fetched one. + pub fn cell(&self, row: usize, column: usize) -> Option<&DataValue> { + self.staged + .get(&(row, column)) + .or_else(|| self.page.rows.get(row).and_then(|r| r.get(column))) + } + + /// The whole page as tab-separated text (header first), hidden columns left out. + pub fn to_tsv(&self) -> String { + let columns: Vec = (0..self.page.columns.len()) + .filter(|c| !self.hidden.contains(c)) + .collect(); + let mut out = String::new(); + out.push_str( + &columns + .iter() + .map(|&c| self.page.columns[c].name.clone()) + .collect::>() + .join("\t"), + ); + out.push('\n'); + for &row in &self.order { + let line = columns + .iter() + .map(|&c| { + self.cell(row, c) + .map(format_value) + .unwrap_or_default() + .replace(['\t', '\n'], " ") + }) + .collect::>() + .join("\t"); + out.push_str(&line); + out.push('\n'); + } + out + } + + /// The page as a JSON array of objects, in display order. + pub fn to_json(&self) -> String { + let documents: Vec = + self.order.iter().map(|&row| self.row_json(row)).collect(); + serde_json::to_string_pretty(&documents).unwrap_or_default() + } + + pub fn row_json(&self, row: usize) -> serde_json::Value { + let mut object = serde_json::Map::new(); + for (c, column) in self.page.columns.iter().enumerate() { + if self.hidden.contains(&c) { + continue; + } + if let Some(value) = self.cell(row, c) { + object.insert(column.name.clone(), value_json(value)); + } + } + serde_json::Value::Object(object) + } + + pub fn ui(&mut self, ui: &mut Ui) { + if self.order_dirty || self.order.len() > self.page.rows.len() { + self.rebuild_order(); + } + self.toolbar(ui); + if self.json_view { + self.json_ui(ui); + } else { + self.table_ui(ui); + } + } + + fn toolbar(&mut self, ui: &mut Ui) { + ui.horizontal(|ui| { + let stats = &self.page.stats; + let mut summary = format!("{} rows · {} ms", stats.rows_returned, stats.elapsed_ms); + if stats.rows_affected > 0 { + summary.push_str(&format!(" · {} affected", stats.rows_affected)); + } + ui.label(RichText::new(summary).small()); + if stats.truncated { + ui.label( + RichText::new("truncated at the limit") + .small() + .color(theme::CAUTION), + ); + } + if self.editable { + ui.label(RichText::new("editable").small().color(theme::SUCCESS)) + .on_hover_text(format!("Primary key: {}", self.key_columns.join(", "))); + } else { + ui.label(RichText::new("read-only").small().weak()) + .on_hover_text( + "Edits need one table with a primary key and a writable profile", + ); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.menu_button("Export", |ui| { + if ui.button("Copy as TSV").clicked() { + ui.ctx().copy_text(self.to_tsv()); + ui.close(); + } + if ui.button("Copy as JSON").clicked() { + ui.ctx().copy_text(self.to_json()); + ui.close(); + } + if ui.button("Save TSV…").clicked() { + if let Some(path) = rfd::FileDialog::new() + .set_file_name("result.tsv") + .save_file() + { + let _ = std::fs::write(path, self.to_tsv()); + } + ui.close(); + } + if ui.button("Save JSON…").clicked() { + if let Some(path) = rfd::FileDialog::new() + .set_file_name("result.json") + .save_file() + { + let _ = std::fs::write(path, self.to_json()); + } + ui.close(); + } + }); + ui.menu_button("Columns", |ui| { + for (index, column) in self.page.columns.iter().enumerate() { + let mut shown = !self.hidden.contains(&index); + if ui.checkbox(&mut shown, &column.name).changed() { + if shown { + self.hidden.remove(&index); + } else { + self.hidden.insert(index); + } + } + } + if !self.hidden.is_empty() { + ui.separator(); + if ui.button("Show all").clicked() { + self.hidden.clear(); + } + } + }); + ui.selectable_value(&mut self.json_view, true, "JSON"); + ui.selectable_value(&mut self.json_view, false, "Grid"); + let response = ui.add( + egui::TextEdit::singleline(&mut self.filter) + .hint_text("Filter rows") + .desired_width(160.0), + ); + if response.changed() { + self.order_dirty = true; + } + }); + }); + } + + fn json_ui(&mut self, ui: &mut Ui) { + egui::ScrollArea::both() + .id_salt("json-view") + .auto_shrink([false, false]) + .show(ui, |ui| { + let text = self.to_json(); + let mut layouter = |ui: &Ui, text: &dyn egui::TextBuffer, wrap: f32| { + let job = crate::highlight::layout( + ui, + text.as_str(), + crate::highlight::Dialect::MongoJson, + wrap, + ); + ui.fonts_mut(|f| f.layout_job(job)) + }; + let mut shown = text; + ui.add( + egui::TextEdit::multiline(&mut shown) + .code_editor() + .interactive(false) + .desired_width(f32::INFINITY) + .layouter(&mut layouter), + ); + }); + } + + fn table_ui(&mut self, ui: &mut Ui) { + let columns: Vec = (0..self.page.columns.len()) + .filter(|c| !self.hidden.contains(c)) + .collect(); + if columns.is_empty() { + ui.label(RichText::new("No columns to show.").weak()); + return; + } + let order = self.order.clone(); + let mut sort_click: Option = None; + let mut edit: Option = None; + let mut toggle_delete: Option = None; + let mut select_row: Option = None; + let editing = self.editing.clone(); + let editable = self.editable; + let text_height = ROW_HEIGHT; + + let mut builder = TableBuilder::new(ui) + .striped(true) + .resizable(true) + .cell_layout(egui::Layout::left_to_right(egui::Align::Center)) + .column(Column::exact(44.0)) + .min_scrolled_height(0.0); + for _ in &columns { + builder = builder.column(Column::initial(140.0).at_least(48.0).clip(true)); + } + builder + .header(ROW_HEIGHT + 2.0, |mut header| { + header.col(|ui| { + ui.label(RichText::new("#").small().weak()); + }); + for &c in &columns { + header.col(|ui| { + let column = &self.page.columns[c]; + let arrow = match self.sort { + Some((s, SortDirection::Ascending)) if s == c => " ▲", + Some((s, SortDirection::Descending)) if s == c => " ▼", + _ => "", + }; + let label = RichText::new(format!("{}{arrow}", column.name)).strong(); + let response = ui + .add( + egui::Label::new(label) + .sense(egui::Sense::click()) + .truncate(), + ) + .on_hover_text(format!("{} · click to sort", column.data_type)); + if response.clicked() { + sort_click = Some(c); + } + }); + } + }) + .body(|body| { + body.rows(text_height, order.len(), |mut row| { + let index = row.index(); + let page_row = order[index]; + let deleted = self.deleted.contains(&page_row); + row.set_selected(self.selected_row == Some(page_row)); + row.col(|ui| { + let text = RichText::new((page_row + 1).to_string()).small().weak(); + let text = if deleted { + text.strikethrough().color(theme::DANGER) + } else { + text + }; + let response = ui.add(egui::Label::new(text).sense(egui::Sense::click())); + if response.clicked() { + select_row = Some(page_row); + } + response.context_menu(|ui| { + if ui.button("Copy row as JSON").clicked() { + ui.ctx().copy_text( + serde_json::to_string_pretty(&self.row_json(page_row)) + .unwrap_or_default(), + ); + ui.close(); + } + if editable { + let label = if deleted { "Undo delete" } else { "Delete row" }; + if ui.button(label).clicked() { + toggle_delete = Some(page_row); + ui.close(); + } + } + }); + }); + for &c in &columns { + row.col(|ui| { + if let Some((er, ec, text)) = &editing + && *er == page_row + && *ec == c + { + let mut draft = text.clone(); + let response = ui.add( + egui::TextEdit::singleline(&mut draft) + .desired_width(f32::INFINITY) + .font(egui::TextStyle::Monospace), + ); + if !response.has_focus() && !response.lost_focus() { + response.request_focus(); + } + if draft != *text { + edit = Some(EditAction::Draft(draft)); + } + let enter = ui.input(|i| i.key_pressed(egui::Key::Enter)); + let escape = ui.input(|i| i.key_pressed(egui::Key::Escape)); + if escape { + edit = Some(EditAction::Cancel); + } else if enter || response.lost_focus() { + edit = Some(EditAction::Commit); + } + return; + } + let value = self.cell(page_row, c); + let staged = self.staged.contains_key(&(page_row, c)); + let shown = value.map_or_else(String::new, format_cell); + let mut text = RichText::new(shown).monospace(); + if deleted { + text = text.strikethrough().color(theme::DANGER); + } else if staged { + text = text.color(theme::CAUTION); + } else if matches!(value, Some(DataValue::Null) | None) { + text = text.weak().italics(); + } + let response = ui.add( + egui::Label::new(text) + .sense(egui::Sense::click()) + .truncate(), + ); + if response.clicked() { + select_row = Some(page_row); + } + if response.double_clicked() && editable && !deleted { + edit = Some(EditAction::Start(page_row, c)); + } + if let Some(value) = value { + let full = format_value(value); + if full.chars().count() > MAX_CELL_CHARS || full.contains('\n') { + response + .clone() + .on_hover_text(full.chars().take(2000).collect::()); + } + } + response.context_menu(|ui| { + if ui.button("Copy value").clicked() { + ui.ctx() + .copy_text(value.map(format_value).unwrap_or_default()); + ui.close(); + } + if editable && !deleted && ui.button("Edit cell").clicked() { + edit = Some(EditAction::Start(page_row, c)); + ui.close(); + } + if editable && !deleted && ui.button("Set NULL").clicked() { + edit = Some(EditAction::SetNull(page_row, c)); + ui.close(); + } + if staged && ui.button("Revert cell").clicked() { + edit = Some(EditAction::Revert(page_row, c)); + ui.close(); + } + }); + }); + } + }); + }); + + if let Some(row) = select_row { + self.selected_row = Some(row); + } + if let Some(c) = sort_click { + self.sort = match self.sort { + Some((s, SortDirection::Ascending)) if s == c => { + Some((c, SortDirection::Descending)) + } + Some((s, SortDirection::Descending)) if s == c => None, + _ => Some((c, SortDirection::Ascending)), + }; + self.order_dirty = true; + } + match edit { + Some(EditAction::Start(row, column)) => { + let current = self.cell(row, column).map(format_value).unwrap_or_default(); + self.editing = Some((row, column, current)); + } + Some(EditAction::Draft(text)) => { + if let Some(state) = &mut self.editing { + state.2 = text; + } + } + Some(EditAction::Commit) => { + if let Some((row, column, text)) = self.editing.take() { + self.stage(row, column, &text); + } + } + Some(EditAction::Cancel) => self.editing = None, + Some(EditAction::SetNull(row, column)) => self.stage(row, column, "NULL"), + Some(EditAction::Revert(row, column)) => { + self.staged.remove(&(row, column)); + } + None => {} + } + if let Some(row) = toggle_delete + && !self.deleted.remove(&row) + { + self.deleted.insert(row); + } + } + + /// Stages `text` as the new value of a cell, parsed against the fetched value's type. The text + /// `NULL` (any case) stages a NULL; a text equal to the fetched value un-stages the cell. + pub fn stage(&mut self, row: usize, column: usize, text: &str) { + let Some(original) = self.page.rows.get(row).and_then(|r| r.get(column)) else { + return; + }; + let parsed = parse_value(text, original); + if parsed == *original { + self.staged.remove(&(row, column)); + } else { + self.staged.insert((row, column), parsed); + } + } +} + +/// A value as text, in full. +pub fn format_value(value: &DataValue) -> String { + match value { + DataValue::Null => "NULL".to_owned(), + DataValue::Boolean(b) => b.to_string(), + DataValue::Integer(s) | DataValue::Decimal(s) | DataValue::Text(s) => s.clone(), + DataValue::Float(f) => { + if f.fract() == 0.0 && f.abs() < 1e15 { + format!("{f:.1}") + } else { + f.to_string() + } + } + DataValue::Binary(b) => format!("base64:{b}"), + DataValue::Date(s) | DataValue::Time(s) | DataValue::DateTime(s) => s.clone(), + DataValue::Uuid(u) => u.to_string(), + DataValue::Json(v) | DataValue::Bson(v) => match v { + serde_json::Value::String(s) => s.clone(), + other => serde_json::to_string(other).unwrap_or_default(), + }, + } +} + +/// A value as it fits a grid cell: one line, bounded. +fn format_cell(value: &DataValue) -> String { + let text = format_value(value); + let one_line = text.replace(['\n', '\r'], "⏎"); + if one_line.chars().count() > MAX_CELL_CHARS { + let mut cut: String = one_line.chars().take(MAX_CELL_CHARS).collect(); + cut.push('…'); + cut + } else { + one_line + } +} + +/// `text` parsed into the variant of `original`, so an edited integer stays an integer and an +/// edited JSON column stays JSON. Anything that does not parse is kept as text, which the database +/// then accepts or refuses with its own error. +pub fn parse_value(text: &str, original: &DataValue) -> DataValue { + if text.eq_ignore_ascii_case("null") { + return DataValue::Null; + } + let trimmed = text.trim(); + match original { + DataValue::Boolean(_) => match trimmed.to_ascii_lowercase().as_str() { + "true" | "t" | "1" | "yes" => DataValue::Boolean(true), + "false" | "f" | "0" | "no" => DataValue::Boolean(false), + _ => DataValue::Text(text.to_owned()), + }, + DataValue::Integer(_) if trimmed.parse::().is_ok() => { + DataValue::Integer(trimmed.to_owned()) + } + DataValue::Float(_) => trimmed + .parse::() + .map_or_else(|_| DataValue::Text(text.to_owned()), DataValue::Float), + DataValue::Decimal(_) if trimmed.parse::().is_ok() => { + DataValue::Decimal(trimmed.to_owned()) + } + DataValue::Uuid(_) => trimmed + .parse() + .map_or_else(|_| DataValue::Text(text.to_owned()), DataValue::Uuid), + DataValue::Date(_) => DataValue::Date(trimmed.to_owned()), + DataValue::Time(_) => DataValue::Time(trimmed.to_owned()), + DataValue::DateTime(_) => DataValue::DateTime(trimmed.to_owned()), + DataValue::Binary(_) => DataValue::Binary(trimmed.trim_start_matches("base64:").to_owned()), + DataValue::Json(_) => serde_json::from_str(trimmed).map_or_else( + |_| DataValue::Json(serde_json::Value::String(text.to_owned())), + DataValue::Json, + ), + DataValue::Bson(_) => serde_json::from_str(trimmed).map_or_else( + |_| DataValue::Bson(serde_json::Value::String(text.to_owned())), + DataValue::Bson, + ), + // A NULL cell has no type to parse into: numbers become numbers, the rest text. + DataValue::Null if trimmed.parse::().is_ok() => DataValue::Integer(trimmed.to_owned()), + _ => DataValue::Text(text.to_owned()), + } +} + +/// A value as JSON, for the JSON view and exports. +pub fn value_json(value: &DataValue) -> serde_json::Value { + match value { + DataValue::Null => serde_json::Value::Null, + DataValue::Boolean(b) => serde_json::Value::Bool(*b), + DataValue::Integer(s) => s.parse::().map_or_else( + |_| serde_json::Value::String(s.clone()), + serde_json::Value::from, + ), + DataValue::Float(f) => serde_json::Number::from_f64(*f) + .map_or(serde_json::Value::Null, serde_json::Value::Number), + DataValue::Json(v) | DataValue::Bson(v) => v.clone(), + other => serde_json::Value::String(format_value(other)), + } +} + +fn compare(left: Option<&DataValue>, right: Option<&DataValue>) -> std::cmp::Ordering { + use std::cmp::Ordering; + match (left, right) { + (None | Some(DataValue::Null), None | Some(DataValue::Null)) => Ordering::Equal, + (None | Some(DataValue::Null), _) => Ordering::Greater, + (_, None | Some(DataValue::Null)) => Ordering::Less, + (Some(a), Some(b)) => match (numeric(a), numeric(b)) { + (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal), + _ => format_value(a).cmp(&format_value(b)), + }, + } +} + +fn numeric(value: &DataValue) -> Option { + match value { + DataValue::Integer(s) | DataValue::Decimal(s) => s.parse().ok(), + DataValue::Float(f) => Some(*f), + DataValue::Boolean(b) => Some(f64::from(u8::from(*b))), + _ => None, + } +} diff --git a/apps/redrob-query/src/highlight.rs b/apps/redrob-query/src/highlight.rs new file mode 100644 index 0000000..a44ac02 --- /dev/null +++ b/apps/redrob-query/src/highlight.rs @@ -0,0 +1,498 @@ +//! Syntax colouring and a small formatter for SQL and Mongo JSON (MQL). +//! +//! A hand-rolled lexer rather than syntect: the editor needs keywords, strings, numbers, comments +//! and identifiers told apart in a few hundred lines, cached per frame by egui's layouter. The +//! formatter is deliberately simple -- it puts the major clauses on their own lines and indents +//! what follows -- so it never changes the meaning of a statement it does not understand. + +use egui::text::{LayoutJob, TextFormat}; +use egui::{Color32, FontId, Ui}; +use redrob_query_core::{DatabaseKind, QueryLanguage}; + +/// The query language the editor colours with. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Dialect { + Sql, + MongoJson, +} + +impl Dialect { + pub fn for_kind(kind: DatabaseKind) -> Self { + match kind { + DatabaseKind::MongoDb => Self::MongoJson, + _ => Self::Sql, + } + } + + pub fn language(self) -> QueryLanguage { + match self { + Self::Sql => QueryLanguage::Sql, + Self::MongoJson => QueryLanguage::MongoJson, + } + } +} + +const SQL_KEYWORDS: &[&str] = &[ + "SELECT", + "FROM", + "WHERE", + "AND", + "OR", + "NOT", + "IN", + "IS", + "NULL", + "AS", + "ON", + "JOIN", + "LEFT", + "RIGHT", + "INNER", + "OUTER", + "FULL", + "CROSS", + "GROUP", + "BY", + "ORDER", + "HAVING", + "LIMIT", + "OFFSET", + "ASC", + "DESC", + "DISTINCT", + "UNION", + "ALL", + "INSERT", + "INTO", + "VALUES", + "UPDATE", + "SET", + "DELETE", + "CREATE", + "TABLE", + "INDEX", + "VIEW", + "DROP", + "ALTER", + "ADD", + "COLUMN", + "PRIMARY", + "KEY", + "FOREIGN", + "REFERENCES", + "UNIQUE", + "DEFAULT", + "CHECK", + "CONSTRAINT", + "BEGIN", + "COMMIT", + "ROLLBACK", + "TRANSACTION", + "WITH", + "RECURSIVE", + "CASE", + "WHEN", + "THEN", + "ELSE", + "END", + "EXISTS", + "BETWEEN", + "LIKE", + "ILIKE", + "CAST", + "COUNT", + "SUM", + "AVG", + "MIN", + "MAX", + "COALESCE", + "NULLIF", + "TRUE", + "FALSE", + "EXPLAIN", + "ANALYZE", + "SHOW", + "DESCRIBE", + "PRAGMA", + "RETURNING", + "USING", + "NATURAL", + "OVER", + "PARTITION", + "WINDOW", + "ROWS", + "RANGE", + "FETCH", + "FIRST", + "NEXT", + "ONLY", + "TOP", + "INTERSECT", + "EXCEPT", + "TRUNCATE", + "GRANT", + "REVOKE", + "IF", + "REPLACE", + "TEMPORARY", + "TEMP", +]; + +/// Clause-leading keywords the formatter starts a new line at (GROUP/ORDER carry their BY, +/// INSERT its INTO, UNION its ALL, and a LEFT/RIGHT/INNER/FULL/CROSS its JOIN). +const SQL_CLAUSES: &[&str] = &[ + "SELECT", + "FROM", + "WHERE", + "GROUP", + "ORDER", + "HAVING", + "LIMIT", + "OFFSET", + "UNION", + "VALUES", + "SET", + "RETURNING", + "INSERT", + "UPDATE", + "DELETE", + "ON", + "WITH", + "INTERSECT", + "EXCEPT", +]; + +const MQL_KEYWORDS: &[&str] = &[ + "collection", + "operation", + "filter", + "projection", + "sort", + "limit", + "explain", + "pipeline", + "find", + "aggregate", + "true", + "false", + "null", +]; + +#[derive(Clone, Copy, PartialEq, Eq)] +enum Token { + Keyword, + Identifier, + Number, + Str, + Comment, + Operator, + Punct, + Mongo, + Whitespace, +} + +/// The colours of each token class, read from the theme so both themes stay legible. +struct Palette { + keyword: Color32, + identifier: Color32, + number: Color32, + string: Color32, + comment: Color32, + operator: Color32, + mongo: Color32, +} + +impl Palette { + fn from_ui(ui: &Ui) -> Self { + let dark = ui.visuals().dark_mode; + let text = ui.visuals().text_color(); + if dark { + Self { + keyword: Color32::from_rgb(0x7F, 0xA6, 0xFF), + identifier: text, + number: Color32::from_rgb(0xD6, 0xB5, 0x6E), + string: Color32::from_rgb(0x8F, 0xD1, 0x9A), + comment: Color32::from_rgb(0x80, 0x86, 0x90), + operator: Color32::from_rgb(0xC9, 0xCE, 0xD6), + mongo: Color32::from_rgb(0xC9, 0x9A, 0xF0), + } + } else { + Self { + keyword: crate::theme::REDROB_BLUE, + identifier: text, + number: Color32::from_rgb(0x9A, 0x5B, 0x00), + string: Color32::from_rgb(0x1E, 0x7A, 0x3E), + comment: Color32::from_rgb(0x74, 0x7A, 0x84), + operator: Color32::from_rgb(0x40, 0x46, 0x50), + mongo: Color32::from_rgb(0x7A, 0x3E, 0xB8), + } + } + } + + fn colour(&self, token: Token) -> Color32 { + match token { + Token::Keyword => self.keyword, + Token::Identifier | Token::Whitespace | Token::Punct => self.identifier, + Token::Number => self.number, + Token::Str => self.string, + Token::Comment => self.comment, + Token::Operator => self.operator, + Token::Mongo => self.mongo, + } + } +} + +/// Splits `text` into `(token, byte range)` pieces. Every byte of the input is covered. +fn lex(text: &str, dialect: Dialect) -> Vec<(Token, std::ops::Range)> { + let bytes = text.as_bytes(); + let mut out = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let start = i; + let c = bytes[i]; + let token = if c.is_ascii_whitespace() { + while i < bytes.len() && bytes[i].is_ascii_whitespace() { + i += 1; + } + Token::Whitespace + } else if dialect == Dialect::Sql && c == b'-' && bytes.get(i + 1) == Some(&b'-') { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + Token::Comment + } else if c == b'/' && bytes.get(i + 1) == Some(&b'/') { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + Token::Comment + } else if c == b'/' && bytes.get(i + 1) == Some(&b'*') { + i += 2; + while i < bytes.len() && !(bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/')) { + i += 1; + } + i = (i + 2).min(bytes.len()); + Token::Comment + } else if c == b'\'' || c == b'"' || c == b'`' { + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' && dialect == Dialect::MongoJson { + i += 2; + continue; + } + if bytes[i] == c { + // SQL doubles a quote to escape it. + if dialect == Dialect::Sql && bytes.get(i + 1) == Some(&c) { + i += 2; + continue; + } + i += 1; + break; + } + i += 1; + } + i = i.min(bytes.len()); + if c == b'\'' || dialect == Dialect::MongoJson { + Token::Str + } else { + Token::Identifier + } + } else if c.is_ascii_digit() + || (c == b'.' && bytes.get(i + 1).is_some_and(u8::is_ascii_digit)) + { + while i < bytes.len() + && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'.' || bytes[i] == b'_') + { + i += 1; + } + Token::Number + } else if c.is_ascii_alphabetic() || c == b'_' || c == b'$' || c >= 0x80 { + while i < bytes.len() + && (bytes[i].is_ascii_alphanumeric() + || bytes[i] == b'_' + || bytes[i] == b'$' + || bytes[i] >= 0x80) + { + i += 1; + } + let word = &text[start..i]; + match dialect { + Dialect::Sql if SQL_KEYWORDS.iter().any(|k| k.eq_ignore_ascii_case(word)) => { + Token::Keyword + } + Dialect::MongoJson if word.starts_with('$') => Token::Mongo, + Dialect::MongoJson if MQL_KEYWORDS.contains(&word) => Token::Keyword, + _ => Token::Identifier, + } + } else if b"=<>!+-*/%|&^~".contains(&c) { + i += 1; + Token::Operator + } else { + i += 1; + Token::Punct + }; + out.push((token, start..i)); + } + out +} + +/// A coloured layout of `text` for the editor's layouter; `wrap_width` is the editor's width. +pub fn layout(ui: &Ui, text: &str, dialect: Dialect, wrap_width: f32) -> LayoutJob { + let palette = Palette::from_ui(ui); + let font = FontId::monospace(13.0); + let mut job = LayoutJob::default(); + job.wrap.max_width = wrap_width; + for (token, range) in lex(text, dialect) { + let mut format = TextFormat::simple(font.clone(), palette.colour(token)); + if token == Token::Comment { + format.italics = true; + } + job.append(&text[range], 0.0, format); + } + job +} + +/// Reformats `text`: SQL gets its major clauses on their own lines, keywords upper-cased outside +/// strings and comments; Mongo JSON is pretty-printed when it parses, left alone otherwise. +pub fn format(text: &str, dialect: Dialect) -> String { + match dialect { + Dialect::MongoJson => serde_json::from_str::(text) + .ok() + .and_then(|value| serde_json::to_string_pretty(&value).ok()) + .unwrap_or_else(|| text.to_owned()), + Dialect::Sql => format_sql(text), + } +} + +fn format_sql(text: &str) -> String { + // Keywords upper-cased; whitespace outside strings and comments collapsed; each major clause + // at paren depth 0 starts a new line. Strings, identifiers and comments are left untouched. + let mut out = String::new(); + let mut depth: i32 = 0; + let mut pending_space = false; + let tokens: Vec<(Token, &str)> = lex(text, Dialect::Sql) + .into_iter() + .filter(|(token, _)| *token != Token::Whitespace) + .map(|(token, range)| (token, &text[range])) + .collect(); + for (index, (token, piece)) in tokens.iter().enumerate() { + let upper = piece.to_ascii_uppercase(); + let word = if *token == Token::Keyword { + upper.as_str() + } else { + piece + }; + let previous = index + .checked_sub(1) + .map(|i| tokens[i].1.to_ascii_uppercase()); + let starts_clause = *token == Token::Keyword + && depth == 0 + && index > 0 + && (SQL_CLAUSES.contains(&word) + || (word == "JOIN" + && !previous.as_deref().is_some_and(|p| { + matches!( + p, + "LEFT" | "RIGHT" | "INNER" | "FULL" | "CROSS" | "OUTER" | "NATURAL" + ) + })) + || (matches!(word, "LEFT" | "RIGHT" | "INNER" | "FULL" | "CROSS") + && tokens.get(index + 1).is_some_and(|(_, n)| { + let n = n.to_ascii_uppercase(); + n == "JOIN" || n == "OUTER" + }))); + let is_second_word = matches!(word, "BY" | "INTO" | "ALL" | "OUTER" | "JOIN") + && previous.as_deref().is_some_and(|p| { + matches!( + p, + "GROUP" + | "ORDER" + | "INSERT" + | "UNION" + | "LEFT" + | "RIGHT" + | "INNER" + | "FULL" + | "CROSS" + | "OUTER" + ) + }); + if starts_clause && !is_second_word && !out.is_empty() { + out.push('\n'); + pending_space = false; + } else if *token == Token::Comment { + if !out.is_empty() && !out.ends_with('\n') { + out.push('\n'); + } + pending_space = false; + } + let tight_before = matches!(*piece, "," | ")" | ";" | ".") + || (*piece == "(" + && previous.as_deref().is_some_and(|p| { + // A call: `count(` not `IN (`. + p.chars().all(|c| c.is_alphanumeric() || c == '_') && !SQL_KEYWORDS.contains(&p) + })); + let tight_after = out.ends_with('(') || out.ends_with('.'); + if pending_space && !tight_before && !tight_after { + out.push(' '); + } + out.push_str(word); + if *token == Token::Comment { + out.push('\n'); + pending_space = false; + } else { + pending_space = true; + } + match *piece { + "(" => depth += 1, + ")" => depth = (depth - 1).max(0), + _ => {} + } + } + out.trim_end().to_owned() +} + +/// The query a new tab opens with for a connection kind, so the first run shows something real. +pub fn starter_query(kind: DatabaseKind) -> &'static str { + match kind { + DatabaseKind::PostgreSql => { + "SELECT table_schema, table_name\nFROM information_schema.tables\nWHERE table_schema NOT IN ('pg_catalog', 'information_schema')\nORDER BY table_schema, table_name\nLIMIT 100;" + } + DatabaseKind::MySql => { + "SELECT table_name, table_rows\nFROM information_schema.tables\nWHERE table_schema = DATABASE()\nORDER BY table_name\nLIMIT 100;" + } + DatabaseKind::SQLite => { + "SELECT name, type\nFROM sqlite_master\nWHERE type IN ('table', 'view') AND name NOT LIKE 'sqlite_%'\nORDER BY name;" + } + DatabaseKind::MongoDb => { + "{\n \"collection\": \"\",\n \"operation\": \"find\",\n \"filter\": {},\n \"limit\": 50\n}" + } + DatabaseKind::SqlServer => "-- SQL Server is not supported in this release.", + } +} + +/// The statement under the cursor: the text between the semicolons (or blank lines) around +/// `cursor_char`, or the whole text when there is no separator. Mongo JSON is always the whole text. +pub fn statement_at(text: &str, cursor_char: usize, dialect: Dialect) -> String { + if dialect == Dialect::MongoJson { + return text.trim().to_owned(); + } + let chars: Vec = text.chars().collect(); + let cursor = cursor_char.min(chars.len()); + // Walk back to the previous ';' (outside strings would be nicer; a cursor-local heuristic). + let mut start = cursor; + while start > 0 && chars[start - 1] != ';' { + start -= 1; + } + let mut end = cursor; + while end < chars.len() && chars[end] != ';' { + end += 1; + } + let statement: String = chars[start..end].iter().collect(); + let statement = statement.trim(); + if statement.is_empty() { + // The cursor sits right after a ';': take the statement before it. + let before: String = chars[..start.saturating_sub(1)].iter().collect(); + return before.rsplit(';').next().unwrap_or("").trim().to_owned(); + } + statement.to_owned() +} diff --git a/apps/redrob-query/src/main.rs b/apps/redrob-query/src/main.rs new file mode 100644 index 0000000..4fa10d9 --- /dev/null +++ b/apps/redrob-query/src/main.rs @@ -0,0 +1,96 @@ +//! Redrob Query desktop app: an agentic database client in one egui window. +//! +//! Usage: `redrob-query [--version]` +#![cfg_attr(all(windows, not(debug_assertions)), windows_subsystem = "windows")] +#![deny(clippy::unwrap_used, clippy::expect_used)] +// Stylistic pedantic lints the shell does not follow: egui code reads better with a few bool flags +// per panel, `format!` pushed onto a status string, and `if`/`match` shapes mirroring the widgets. +#![allow( + clippy::format_push_string, + clippy::struct_excessive_bools, + clippy::match_same_arms, + clippy::single_match_else, + clippy::semicolon_if_nothing_returned, + clippy::if_same_then_else +)] + +mod account; +mod ai_dock; +mod app; +mod changes; +mod editor; +mod grid; +mod highlight; +mod mongo; +mod profiles; +mod schema; +mod services; +mod structure; +mod theme; +mod workspace; + +use app::QueryApp; + +/// The 256px app icon from the brand pipeline (resources/icons, pinned in brand/SOURCES.json). +const ICON_PNG: &[u8] = + include_bytes!("../../../resources/icons/hicolor/256x256/apps/redrob-query.png"); + +/// The main window: 1440 × 900 (eframe shrinks it to the monitor), at least 900 × 560. +fn native_options() -> eframe::NativeOptions { + let mut viewport = egui::ViewportBuilder::default() + .with_app_id(services::APP_ID) + .with_title(services::APP_NAME) + .with_inner_size([1440.0, 900.0]) + .with_min_inner_size([900.0, 560.0]); + if let Ok(icon) = eframe::icon_data::from_png_bytes(ICON_PNG) { + viewport = viewport.with_icon(icon); + } + eframe::NativeOptions { + viewport, + centered: true, + // eframe saves the window geometry and egui panel sizes beside the preferences. + persistence_path: services::persistence_path(), + ..Default::default() + } +} + +fn main() -> eframe::Result { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("warn")).init(); + for arg in std::env::args().skip(1) { + match arg.as_str() { + "--version" => { + println!("redrob-query {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + other => eprintln!("redrob-query: ignoring argument {other:?}"), + } + } + let mut options = native_options(); + // Nothing draws or dispatches indirectly, so wgpu's indirect-call validation only costs a + // compute shader at start-up that some drivers cannot compile. + if let eframe::egui_wgpu::WgpuSetup::CreateNew(create) = &mut options.wgpu_options.wgpu_setup { + create.instance_descriptor.flags = create + .instance_descriptor + .flags + .difference(eframe::wgpu::InstanceFlags::VALIDATION_INDIRECT_CALL) + .with_env(); + } + eframe::run_native( + services::APP_NAME, + options, + Box::new(|cc| { + if let Some(rs) = &cc.wgpu_render_state { + let info = rs.adapter.get_info(); + log::info!( + "graphics: {} on {:?} ({:?}; driver {} {})", + info.name, + info.backend, + info.device_type, + info.driver, + info.driver_info + ); + } + Ok(Box::new(QueryApp::new(&cc.egui_ctx))) + }), + ) +} diff --git a/apps/redrob-query/src/mongo.rs b/apps/redrob-query/src/mongo.rs new file mode 100644 index 0000000..5d399c2 --- /dev/null +++ b/apps/redrob-query/src/mongo.rs @@ -0,0 +1,97 @@ +//! Mongo tabs: a strip above the editor that writes a `find` or `aggregate` request body for a +//! collection, with `explain`, so the JSON the engine accepts does not have to be typed from +//! memory. The engine (`connection.rs`) accepts exactly these keys: `collection`, `operation` +//! (`find` | `aggregate`), `filter`, `projection`, `sort`, `limit`, `explain` (a verbosity string), +//! `pipeline`. + +use egui::{RichText, Ui}; +use redrob_query_core::MetadataNode; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum MongoOperation { + Find, + Aggregate, +} + +#[derive(Clone, Debug)] +pub struct MongoStrip { + pub collection: String, + pub operation: MongoOperation, + pub explain: bool, +} + +impl Default for MongoStrip { + fn default() -> Self { + Self { + collection: String::new(), + operation: MongoOperation::Find, + explain: false, + } + } +} + +impl MongoStrip { + /// Draws the strip; returns a request body to put in the editor when a template was asked for. + pub fn ui(&mut self, ui: &mut Ui, collections: &[MetadataNode]) -> Option { + let mut template = None; + ui.horizontal(|ui| { + ui.label(RichText::new("Collection").small()); + egui::ComboBox::from_id_salt("mongo-collection") + .selected_text(if self.collection.is_empty() { + "choose…" + } else { + self.collection.as_str() + }) + .width(180.0) + .show_ui(ui, |ui| { + for node in collections { + ui.selectable_value(&mut self.collection, node.name.clone(), &node.name); + } + }); + ui.selectable_value(&mut self.operation, MongoOperation::Find, "find"); + ui.selectable_value(&mut self.operation, MongoOperation::Aggregate, "aggregate"); + ui.checkbox(&mut self.explain, "explain"); + if ui + .button("Insert template") + .on_hover_text("Replace the editor text with a request for this collection") + .clicked() + { + template = Some(self.template()); + } + }); + template + } + + pub fn template(&self) -> String { + let collection = serde_json::Value::String(self.collection.clone()); + // `explain` is a verbosity, as the engine reads it (query_planner | execution_stats | all_plans_execution). + let explain = if self.explain { + ",\n \"explain\": \"execution_stats\"" + } else { + "" + }; + match self.operation { + MongoOperation::Find => format!( + "{{\n \"collection\": {collection},\n \"operation\": \"find\",\n \"filter\": {{}},\n \"sort\": {{ \"_id\": 1 }},\n \"limit\": 50{explain}\n}}" + ), + MongoOperation::Aggregate => format!( + "{{\n \"collection\": {collection},\n \"operation\": \"aggregate\",\n \"pipeline\": [\n {{ \"$match\": {{}} }},\n {{ \"$limit\": 50 }}\n ]{explain}\n}}" + ), + } + } + + /// Picks up the collection named in the editor text, so the strip follows a typed query. + pub fn sync_from(&mut self, text: &str) { + if let Ok(serde_json::Value::Object(body)) = serde_json::from_str::(text) + { + if let Some(serde_json::Value::String(name)) = body.get("collection") { + self.collection.clone_from(name); + } + self.operation = match body.get("operation").and_then(|v| v.as_str()) { + Some("aggregate") => MongoOperation::Aggregate, + _ => MongoOperation::Find, + }; + self.explain = body.get("explain").is_some_and(|v| !v.is_null()); + } + } +} diff --git a/apps/redrob-query/src/profiles.rs b/apps/redrob-query/src/profiles.rs new file mode 100644 index 0000000..53de218 --- /dev/null +++ b/apps/redrob-query/src/profiles.rs @@ -0,0 +1,750 @@ +//! The Profiles tree and the connection form. +//! +//! Built-in profiles (the Demo `SQLite` database) are immutable; user profiles can be added, edited, +//! tested and removed. Passwords go to the OS keyring through the engine and are never kept here +//! beyond the form's own text field, which is cleared when the form closes. + +use std::collections::HashMap; + +use egui::{RichText, Ui}; +use redrob_query_core::{ + ConnectionConfig, ConnectionProfile, ConnectionState, ConnectionStatus, DatabaseKind, Pending, + RemoveProfileOutcome, SaveProfileOutcome, Workspace, +}; +use uuid::Uuid; + +use crate::theme; + +/// What the panel asks the app to do. +#[derive(Debug)] +pub enum ProfilesEvent { + /// A profile connected (or was already connected): open or focus a query tab on it. + OpenQuery(ConnectionProfile), + /// The schema tree for this profile should be (re)loaded. + Connected(Uuid), + Disconnected(Uuid), + /// Something worth the status line. + Notice(String, bool), +} + +pub struct ProfilesPanel { + pub profiles: Vec, + pub status: HashMap, + pub selected: Option, + list: Option>>, + connects: HashMap>, + disconnects: HashMap>, + removes: HashMap>, + pub form: Option, + confirm_remove: Option, + pub load_warnings: Vec, +} + +impl ProfilesPanel { + pub fn new(workspace: &Workspace) -> Self { + Self { + profiles: Vec::new(), + status: HashMap::new(), + selected: None, + list: Some(workspace.list_profiles()), + connects: HashMap::new(), + disconnects: HashMap::new(), + removes: HashMap::new(), + form: None, + confirm_remove: None, + load_warnings: workspace.profile_load_warnings(), + } + } + + pub fn refresh(&mut self, workspace: &Workspace) { + self.list = Some(workspace.list_profiles()); + } + + pub fn profile(&self, id: Uuid) -> Option<&ConnectionProfile> { + self.profiles.iter().find(|p| p.id == id) + } + + pub fn is_connected(&self, id: Uuid) -> bool { + self.status + .get(&id) + .is_some_and(|s| s.state == ConnectionState::Connected) + } + + pub fn connect(&mut self, workspace: &Workspace, id: Uuid) { + self.connects + .entry(id) + .or_insert_with(|| workspace.connect(id)); + } + + /// Drains finished operations; call once per frame before drawing. + pub fn poll(&mut self, workspace: &Workspace, events: &mut Vec) { + if let Some(list) = &mut self.list + && let Some(result) = list.poll() + { + self.list = None; + match result { + Ok(mut profiles) => { + profiles.sort_by(|a, b| { + b.built_in + .cmp(&a.built_in) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + }); + self.profiles = profiles; + } + Err(error) => events.push(ProfilesEvent::Notice(error.to_string(), true)), + } + } + let mut done = Vec::new(); + for (id, pending) in &mut self.connects { + if let Some(result) = pending.poll() { + done.push((*id, result)); + } + } + for (id, result) in done { + self.connects.remove(&id); + match result { + Ok(status) => { + let connected = status.state == ConnectionState::Connected; + let message = status.message.clone(); + self.status.insert(id, status); + if connected { + events.push(ProfilesEvent::Connected(id)); + if let Some(profile) = self.profile(id).cloned() { + events.push(ProfilesEvent::OpenQuery(profile)); + } + } else { + events.push(ProfilesEvent::Notice(message, true)); + } + } + Err(error) => events.push(ProfilesEvent::Notice(error.to_string(), true)), + } + } + let mut done = Vec::new(); + for (id, pending) in &mut self.disconnects { + if let Some(result) = pending.poll() { + done.push((*id, result)); + } + } + for (id, result) in done { + self.disconnects.remove(&id); + self.status.remove(&id); + events.push(ProfilesEvent::Disconnected(id)); + if let Err(error) = result { + events.push(ProfilesEvent::Notice(error.to_string(), true)); + } + } + let mut done = Vec::new(); + for (id, pending) in &mut self.removes { + if let Some(result) = pending.poll() { + done.push((*id, result)); + } + } + for (id, result) in done { + self.removes.remove(&id); + match result { + Ok(outcome) => { + self.status.remove(&id); + if self.selected == Some(id) { + self.selected = None; + } + events.push(ProfilesEvent::Disconnected(id)); + if let Some(warning) = outcome.warning { + events.push(ProfilesEvent::Notice(warning, false)); + } + self.refresh(workspace); + } + Err(error) => events.push(ProfilesEvent::Notice(error.to_string(), true)), + } + } + if let Some(form) = &mut self.form + && let Some(saved) = form.poll(events) + { + self.form = None; + self.selected = Some(saved.id); + self.refresh(workspace); + } + } + + pub fn ui(&mut self, ui: &mut Ui, workspace: &Workspace, events: &mut Vec) { + ui.horizontal(|ui| { + ui.heading("Profiles"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .button("+ New") + .on_hover_text("Add a connection profile") + .clicked() + { + self.form = Some(ProfileForm::new_profile()); + } + if ui + .small_button("⟳") + .on_hover_text("Reload profiles") + .clicked() + { + self.refresh(workspace); + } + }); + }); + for warning in &self.load_warnings { + ui.label(RichText::new(warning).small().color(theme::CAUTION)); + } + ui.separator(); + + let mut action: Option<(Uuid, RowAction)> = None; + egui::ScrollArea::vertical() + .id_salt("profiles") + .auto_shrink([false, true]) + .max_height(ui.available_height() * 0.45) + .show(ui, |ui| { + let (built_in, user): (Vec<_>, Vec<_>) = + self.profiles.iter().partition(|p| p.built_in); + if !built_in.is_empty() { + ui.label(RichText::new("Built-in").small().weak()); + for profile in built_in { + if let Some(a) = self.profile_row(ui, profile) { + action = Some(a); + } + } + } + ui.label(RichText::new("Your connections").small().weak()); + if user.is_empty() { + ui.label(RichText::new("None yet. Press New to add one.").weak()); + } + for profile in user { + if let Some(a) = self.profile_row(ui, profile) { + action = Some(a); + } + } + }); + + if let Some((id, action)) = action { + match action { + RowAction::Select => self.selected = Some(id), + RowAction::Connect => { + self.selected = Some(id); + if self.is_connected(id) { + if let Some(profile) = self.profile(id).cloned() { + events.push(ProfilesEvent::OpenQuery(profile)); + } + } else { + self.connect(workspace, id); + } + } + RowAction::Disconnect => { + self.disconnects + .entry(id) + .or_insert_with(|| workspace.disconnect(id)); + } + RowAction::Edit => { + if let Some(profile) = self.profile(id).cloned() { + self.form = Some(ProfileForm::edit(profile)); + } + } + RowAction::Remove => self.confirm_remove = Some(id), + } + } + + if let Some(id) = self.confirm_remove { + let name = self.profile(id).map(|p| p.name.clone()).unwrap_or_default(); + let mut open = true; + let mut decided = None; + egui::Window::new("Remove profile") + .collapsible(false) + .resizable(false) + .open(&mut open) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .show(ui.ctx(), |ui| { + ui.label(format!( + "Remove \"{name}\" and its saved password? The database itself is not touched." + )); + ui.horizontal(|ui| { + if ui + .add(egui::Button::new(RichText::new("Remove").color(egui::Color32::WHITE)).fill(theme::DANGER)) + .clicked() + { + decided = Some(true); + } + if ui.button("Cancel").clicked() { + decided = Some(false); + } + }); + }); + match decided { + Some(true) => { + self.confirm_remove = None; + self.removes.insert(id, workspace.remove_profile(id)); + } + Some(false) => self.confirm_remove = None, + None if !open => self.confirm_remove = None, + None => {} + } + } + + if let Some(form) = &mut self.form { + let mut keep = true; + form.ui(ui.ctx(), workspace, &mut keep); + if !keep { + self.form = None; + } + } + } + + fn profile_row(&self, ui: &mut Ui, profile: &ConnectionProfile) -> Option<(Uuid, RowAction)> { + let mut action = None; + let connected = self.is_connected(profile.id); + let connecting = self.connects.contains_key(&profile.id); + let selected = self.selected == Some(profile.id); + ui.horizontal(|ui| { + let dot = if connected { + theme::SUCCESS + } else if !profile.capabilities.connect { + theme::DANGER + } else { + ui.visuals().weak_text_color() + }; + theme::status_dot(ui, dot); + let label = format!("{} {}", kind_glyph(profile.kind), profile.name); + let response = ui.selectable_label(selected, label); + if response.clicked() { + action = Some((profile.id, RowAction::Select)); + } + if response.double_clicked() { + action = Some((profile.id, RowAction::Connect)); + } + response.context_menu(|ui| { + if connected { + if ui.button("New query tab").clicked() { + action = Some((profile.id, RowAction::Connect)); + ui.close(); + } + if ui.button("Disconnect").clicked() { + action = Some((profile.id, RowAction::Disconnect)); + ui.close(); + } + } else if ui.button("Connect").clicked() { + action = Some((profile.id, RowAction::Connect)); + ui.close(); + } + if !profile.built_in { + ui.separator(); + if ui.button("Edit…").clicked() { + action = Some((profile.id, RowAction::Edit)); + ui.close(); + } + if ui.button("Remove…").clicked() { + action = Some((profile.id, RowAction::Remove)); + ui.close(); + } + } + }); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if connecting { + ui.spinner(); + } else if connected { + if ui.small_button("Query").clicked() { + action = Some((profile.id, RowAction::Connect)); + } + } else if profile.capabilities.connect && ui.small_button("Connect").clicked() { + action = Some((profile.id, RowAction::Connect)); + } + }); + }); + if selected { + ui.indent("detail", |ui| { + let text = describe(profile); + ui.label(RichText::new(text).small().weak()); + if let Some(status) = self.status.get(&profile.id) { + let mut line = status.message.clone(); + if let Some(ms) = status.latency_ms { + line.push_str(&format!(" ({ms} ms)")); + } + ui.label(RichText::new(line).small()); + } + }); + } + action + } +} + +enum RowAction { + Select, + Connect, + Disconnect, + Edit, + Remove, +} + +pub fn kind_glyph(kind: DatabaseKind) -> &'static str { + match kind { + DatabaseKind::PostgreSql => "PG", + DatabaseKind::MySql => "My", + DatabaseKind::SQLite => "Lite", + DatabaseKind::MongoDb => "Mongo", + DatabaseKind::SqlServer => "MSSQL", + } +} + +pub fn kind_name(kind: DatabaseKind) -> &'static str { + match kind { + DatabaseKind::PostgreSql => "PostgreSQL", + DatabaseKind::MySql => "MySQL", + DatabaseKind::SQLite => "SQLite", + DatabaseKind::MongoDb => "MongoDB", + DatabaseKind::SqlServer => "SQL Server", + } +} + +fn describe(profile: &ConnectionProfile) -> String { + let c = &profile.config; + let mut text = kind_name(profile.kind).to_owned(); + match profile.kind { + DatabaseKind::SQLite => { + text.push_str(" · "); + text.push_str(c.file_path.as_deref().unwrap_or(":memory:")); + } + _ => { + if let Some(host) = &c.host { + text.push_str(" · "); + text.push_str(host); + if let Some(port) = c.port.or(profile.kind.default_port()) { + text.push_str(&format!(":{port}")); + } + } + if let Some(db) = c.database.as_deref().filter(|d| !d.is_empty()) { + text.push_str(&format!(" / {db}")); + } + } + } + if profile.read_only { + text.push_str(" · read-only"); + } + text +} + +/// The connection form: a window over the app with the fields of one profile. +pub struct ProfileForm { + pub profile: ConnectionProfile, + pub password: String, + pub editing: bool, + port_text: String, + auth_source: String, + test: Option>, + test_result: Option<(String, bool)>, + save: Option>, + error: Option, +} + +impl ProfileForm { + pub fn new_profile() -> Self { + let kind = DatabaseKind::PostgreSql; + let profile = ConnectionProfile { + id: Uuid::new_v4(), + name: String::new(), + kind, + config: ConnectionConfig { + host: Some("localhost".to_owned()), + port: kind.default_port(), + ..ConnectionConfig::default() + }, + read_only: true, + built_in: false, + capabilities: kind.capabilities(), + }; + Self::over(profile, false) + } + + pub fn edit(profile: ConnectionProfile) -> Self { + Self::over(profile, true) + } + + fn over(profile: ConnectionProfile, editing: bool) -> Self { + let port_text = profile + .config + .port + .map(|p| p.to_string()) + .unwrap_or_default(); + let auth_source = profile + .config + .options + .get("authSource") + .cloned() + .unwrap_or_default(); + Self { + profile, + password: String::new(), + editing, + port_text, + auth_source, + test: None, + test_result: None, + save: None, + error: None, + } + } + + fn poll(&mut self, events: &mut Vec) -> Option { + if let Some(test) = &mut self.test + && let Some(result) = test.poll() + { + self.test = None; + self.test_result = Some(match result { + Ok(status) => { + let ok = status.state == ConnectionState::Connected; + let mut text = status.message; + if let Some(ms) = status.latency_ms { + text.push_str(&format!(" ({ms} ms)")); + } + (text, ok) + } + Err(error) => (error.to_string(), false), + }); + } + if let Some(save) = &mut self.save + && let Some(result) = save.poll() + { + self.save = None; + match result { + Ok(outcome) => { + if let Some(warning) = outcome.warning { + events.push(ProfilesEvent::Notice(warning, false)); + } + events.push(ProfilesEvent::Notice( + format!("Saved profile \"{}\"", outcome.profile.name), + false, + )); + self.password.clear(); + return Some(outcome.profile); + } + Err(error) => self.error = Some(error.to_string()), + } + } + None + } + + /// Reads the form fields back into the profile; the text port becomes a number or nothing. + fn commit_fields(&mut self) { + let kind = self.profile.kind; + self.profile.capabilities = kind.capabilities(); + let config = &mut self.profile.config; + config.port = self.port_text.trim().parse::().ok(); + config.options.clear(); + if kind == DatabaseKind::MongoDb && !self.auth_source.trim().is_empty() { + config + .options + .insert("authSource".to_owned(), self.auth_source.trim().to_owned()); + } + if kind != DatabaseKind::MongoDb { + config.srv = false; + } + if kind == DatabaseKind::SQLite { + config.host = None; + config.port = None; + config.username = None; + config.database = None; + config.tls = false; + } else { + config.file_path = None; + for field in [&mut config.host, &mut config.username, &mut config.database] { + if field.as_deref().is_some_and(|s| s.trim().is_empty()) { + *field = None; + } + } + } + } + + fn secret(&self) -> Option { + (!self.password.is_empty() && self.profile.kind != DatabaseKind::SQLite) + .then(|| self.password.clone()) + } + + pub fn ui(&mut self, ctx: &egui::Context, workspace: &Workspace, keep: &mut bool) { + let title = if self.editing { + "Edit connection" + } else { + "New connection" + }; + let mut open = true; + egui::Window::new(title) + .collapsible(false) + .resizable(false) + .default_width(460.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .open(&mut open) + .show(ctx, |ui| self.fields(ui, workspace, keep)); + if !open { + *keep = false; + } + } + + fn fields(&mut self, ui: &mut Ui, workspace: &Workspace, keep: &mut bool) { + let busy = self.test.is_some() || self.save.is_some(); + egui::Grid::new("profile-form") + .num_columns(2) + .spacing([12.0, 8.0]) + .show(ui, |ui| { + ui.label("Name"); + ui.add(egui::TextEdit::singleline(&mut self.profile.name).desired_width(300.0)); + ui.end_row(); + + ui.label("Database"); + let before = self.profile.kind; + egui::ComboBox::from_id_salt("kind") + .selected_text(kind_name(self.profile.kind)) + .show_ui(ui, |ui| { + for kind in [ + DatabaseKind::PostgreSql, + DatabaseKind::MySql, + DatabaseKind::SQLite, + DatabaseKind::MongoDb, + ] { + ui.selectable_value(&mut self.profile.kind, kind, kind_name(kind)); + } + }); + if before != self.profile.kind { + self.port_text = self + .profile + .kind + .default_port() + .map(|p| p.to_string()) + .unwrap_or_default(); + self.test_result = None; + } + ui.end_row(); + + if self.profile.kind == DatabaseKind::SQLite { + ui.label("File"); + ui.horizontal(|ui| { + let path = self + .profile + .config + .file_path + .get_or_insert_with(String::new); + ui.add( + egui::TextEdit::singleline(path) + .desired_width(220.0) + .hint_text(":memory:"), + ); + if ui.button("Browse…").clicked() + && let Some(picked) = rfd::FileDialog::new() + .add_filter("SQLite", &["db", "sqlite", "sqlite3"]) + .pick_file() + { + *path = picked.display().to_string(); + } + }); + ui.end_row(); + } else { + ui.label("Host"); + ui.add( + egui::TextEdit::singleline( + self.profile.config.host.get_or_insert_with(String::new), + ) + .desired_width(300.0), + ); + ui.end_row(); + if !(self.profile.kind == DatabaseKind::MongoDb && self.profile.config.srv) { + ui.label("Port"); + ui.add( + egui::TextEdit::singleline(&mut self.port_text).desired_width(100.0), + ); + ui.end_row(); + } + ui.label("Database"); + ui.add( + egui::TextEdit::singleline( + self.profile.config.database.get_or_insert_with(String::new), + ) + .desired_width(300.0), + ); + ui.end_row(); + ui.label("User"); + ui.add( + egui::TextEdit::singleline( + self.profile.config.username.get_or_insert_with(String::new), + ) + .desired_width(300.0), + ); + ui.end_row(); + ui.label("Password"); + ui.add( + egui::TextEdit::singleline(&mut self.password) + .password(true) + .desired_width(300.0) + .hint_text(if self.editing { + "unchanged unless typed" + } else { + "" + }), + ); + ui.end_row(); + ui.label("Options"); + ui.horizontal(|ui| { + ui.checkbox(&mut self.profile.config.tls, "TLS"); + if self.profile.kind == DatabaseKind::MongoDb { + ui.checkbox(&mut self.profile.config.srv, "SRV (mongodb+srv)"); + } + }); + ui.end_row(); + if self.profile.kind == DatabaseKind::MongoDb { + ui.label("authSource"); + ui.add( + egui::TextEdit::singleline(&mut self.auth_source) + .desired_width(200.0) + .hint_text("admin"), + ); + ui.end_row(); + } + } + ui.label("Edits"); + ui.checkbox( + &mut self.profile.read_only, + "Read-only (uncheck to allow reviewed edits)", + ); + ui.end_row(); + }); + + if let Some((text, ok)) = &self.test_result { + let colour = if *ok { theme::SUCCESS } else { theme::DANGER }; + ui.label(RichText::new(text).color(colour)); + } + if let Some(error) = &self.error { + ui.label(RichText::new(error).color(theme::DANGER)); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.add_enabled(!busy, egui::Button::new("Test")).clicked() { + self.commit_fields(); + self.error = None; + self.test_result = None; + match self.profile.validate() { + Ok(()) => { + self.test = + Some(workspace.test_connection(self.profile.clone(), self.secret())) + } + Err(error) => self.error = Some(error.to_string()), + } + } + if ui + .add_enabled(!busy, theme::primary_button("Save")) + .clicked() + { + self.commit_fields(); + self.error = None; + match self.profile.validate() { + Ok(()) => { + self.save = + Some(workspace.save_profile(self.profile.clone(), self.secret())) + } + Err(error) => self.error = Some(error.to_string()), + } + } + if ui.button("Cancel").clicked() { + self.password.clear(); + *keep = false; + } + if busy { + ui.spinner(); + } + }); + } +} diff --git a/apps/redrob-query/src/schema.rs b/apps/redrob-query/src/schema.rs new file mode 100644 index 0000000..b20f9ea --- /dev/null +++ b/apps/redrob-query/src/schema.rs @@ -0,0 +1,321 @@ +//! The Schema browser: tables/views/collections of each connected profile, with columns loaded on +//! expand. Nodes come from the engine's `load_metadata`, which returns the root (tables) for a +//! connection and the children (columns, or a Mongo collection's sampled fields) for a node id. + +use std::collections::HashMap; + +use egui::{RichText, Ui}; +use redrob_query_core::{ + ConnectionProfile, DatabaseKind, MetadataKind, MetadataNode, MetadataRequest, Pending, + Workspace, +}; +use uuid::Uuid; + +use crate::theme; + +/// What the browser asks the app to do. +#[derive(Debug)] +pub enum SchemaEvent { + /// Show this table in the structure panel and (double-click) run a starter SELECT on it. + Select { + connection_id: Uuid, + node: MetadataNode, + }, + Preview { + connection_id: Uuid, + node: MetadataNode, + }, + Notice(String, bool), +} + +#[derive(Default)] +struct Tree { + roots: Vec, + children: HashMap>, + loading: HashMap, Pending>>, + expanded: HashMap, + filter: String, +} + +#[derive(Default)] +pub struct SchemaBrowser { + trees: HashMap, + pub selected: Option<(Uuid, String)>, +} + +impl SchemaBrowser { + pub fn load_root(&mut self, workspace: &Workspace, connection_id: Uuid) { + let tree = self.trees.entry(connection_id).or_default(); + tree.roots.clear(); + tree.children.clear(); + tree.loading.insert( + None, + workspace.load_metadata(MetadataRequest { + connection_id, + parent_id: None, + }), + ); + } + + pub fn forget(&mut self, connection_id: Uuid) { + self.trees.remove(&connection_id); + if self + .selected + .as_ref() + .is_some_and(|(id, _)| *id == connection_id) + { + self.selected = None; + } + } + + /// The root nodes of a connection, for the editor's completion and the structure panel. + pub fn roots(&self, connection_id: Uuid) -> &[MetadataNode] { + self.trees + .get(&connection_id) + .map_or(&[], |tree| tree.roots.as_slice()) + } + + pub fn poll(&mut self, events: &mut Vec) { + for tree in self.trees.values_mut() { + let mut done = Vec::new(); + for (parent, pending) in &mut tree.loading { + if let Some(result) = pending.poll() { + done.push((parent.clone(), result)); + } + } + for (parent, result) in done { + tree.loading.remove(&parent); + match (parent, result) { + (None, Ok(nodes)) => tree.roots = nodes, + (Some(id), Ok(nodes)) => { + tree.children.insert(id, nodes); + } + (_, Err(error)) => events.push(SchemaEvent::Notice(error.to_string(), true)), + } + } + } + } + + pub fn ui( + &mut self, + ui: &mut Ui, + workspace: &Workspace, + connected: &[&ConnectionProfile], + events: &mut Vec, + ) { + ui.heading("Schema"); + if connected.is_empty() { + ui.label(RichText::new("Connect a profile to browse its tables.").weak()); + return; + } + egui::ScrollArea::vertical() + .id_salt("schema") + .auto_shrink([false, false]) + .show(ui, |ui| { + for profile in connected { + self.connection_ui(ui, workspace, profile, events); + } + }); + } + + fn connection_ui( + &mut self, + ui: &mut Ui, + workspace: &Workspace, + profile: &ConnectionProfile, + events: &mut Vec, + ) { + let connection_id = profile.id; + let tree = self.trees.entry(connection_id).or_default(); + let root_loading = tree.loading.contains_key(&None); + let header = egui::CollapsingHeader::new(RichText::new(&profile.name).strong()) + .id_salt(("schema-root", connection_id)) + .default_open(true); + let mut to_load: Vec = Vec::new(); + let mut reload = false; + let mut selected = self.selected.clone(); + header.show(ui, |ui| { + ui.horizontal(|ui| { + ui.add( + egui::TextEdit::singleline(&mut tree.filter) + .hint_text("Filter tables") + .desired_width(ui.available_width() - 40.0), + ); + if ui.small_button("⟳").on_hover_text("Reload").clicked() { + reload = true; + } + }); + if root_loading { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new("Loading…").weak()); + }); + } else if tree.roots.is_empty() { + ui.label(RichText::new("No tables or collections.").weak()); + } + let filter = tree.filter.to_lowercase(); + // Group by schema attribute when present (PostgreSQL, MySQL). + let mut groups: Vec<(String, Vec<&MetadataNode>)> = Vec::new(); + for node in &tree.roots { + if !filter.is_empty() && !node.name.to_lowercase().contains(&filter) { + continue; + } + let schema = node.attributes.get("schema").cloned().unwrap_or_default(); + match groups.iter_mut().find(|(s, _)| *s == schema) { + Some((_, nodes)) => nodes.push(node), + None => groups.push((schema, vec![node])), + } + } + let grouped = groups.len() > 1 || groups.first().is_some_and(|(s, _)| !s.is_empty()); + for (schema, nodes) in groups { + let body = |ui: &mut Ui| { + for node in nodes { + let expanded = tree.expanded.get(&node.id).copied().unwrap_or(false); + let is_selected = selected + .as_ref() + .is_some_and(|(c, id)| *c == connection_id && *id == node.id); + let glyph = match node.kind { + MetadataKind::View => "▤", + MetadataKind::Collection => "{}", + _ => "▦", + }; + ui.horizontal(|ui| { + let arrow = if expanded { "▾" } else { "▸" }; + if ui.small_button(arrow).clicked() { + let now = !expanded; + tree.expanded.insert(node.id.clone(), now); + if now + && !tree.children.contains_key(&node.id) + && !tree.loading.contains_key(&Some(node.id.clone())) + { + to_load.push(node.id.clone()); + } + } + let response = + ui.selectable_label(is_selected, format!("{glyph} {}", node.name)); + if response.clicked() { + selected = Some((connection_id, node.id.clone())); + events.push(SchemaEvent::Select { + connection_id, + node: node.clone(), + }); + } + if response.double_clicked() { + events.push(SchemaEvent::Preview { + connection_id, + node: node.clone(), + }); + } + response.context_menu(|ui| { + if ui.button("Preview rows").clicked() { + events.push(SchemaEvent::Preview { + connection_id, + node: node.clone(), + }); + ui.close(); + } + if ui.button("Copy name").clicked() { + ui.ctx().copy_text(node.name.clone()); + ui.close(); + } + }); + }); + if expanded { + ui.indent(("cols", &node.id), |ui| match tree.children.get(&node.id) { + Some(children) => { + for column in children { + column_row(ui, column); + } + } + None => { + ui.horizontal(|ui| { + ui.spinner(); + ui.label(RichText::new("Loading…").weak().small()); + }); + } + }); + } + } + }; + if grouped { + egui::CollapsingHeader::new(RichText::new(if schema.is_empty() { + "(default)".to_owned() + } else { + schema.clone() + })) + .id_salt(("schema-group", connection_id, &schema)) + .default_open(true) + .show(ui, body); + } else { + body(ui); + } + } + }); + self.selected = selected; + for id in to_load { + let request = MetadataRequest { + connection_id, + parent_id: Some(id.clone()), + }; + if let Some(tree) = self.trees.get_mut(&connection_id) { + tree.loading + .insert(Some(id), workspace.load_metadata(request)); + } + } + if reload { + self.load_root(workspace, connection_id); + } + } +} + +fn column_row(ui: &mut Ui, column: &MetadataNode) { + ui.horizontal(|ui| { + ui.label(RichText::new(&column.name).monospace().small()); + if let Some(data_type) = &column.data_type { + ui.label(RichText::new(data_type).small().weak()); + } + if column.nullable == Some(false) { + ui.label(RichText::new("not null").small().color(theme::CAUTION)); + } + }); +} + +/// `schema`/`table` out of a root node id (`table:.` or `table:`, +/// `collection:`), the way the engine addresses them. +pub fn split_table_id(node: &MetadataNode, kind: DatabaseKind) -> (Option, String) { + let schema = node + .attributes + .get("schema") + .cloned() + .filter(|s| !s.is_empty()); + match kind { + DatabaseKind::PostgreSql | DatabaseKind::MySql => (schema, node.name.clone()), + _ => (None, node.name.clone()), + } +} + +/// A bounded SELECT (or find) on a table, for a preview tab. +pub fn preview_query(kind: DatabaseKind, node: &MetadataNode) -> String { + let (schema, table) = split_table_id(node, kind); + match kind { + DatabaseKind::MongoDb => format!( + "{{\n \"collection\": {},\n \"operation\": \"find\",\n \"filter\": {{}},\n \"limit\": 50\n}}", + serde_json::Value::String(table) + ), + DatabaseKind::MySql => { + let target = match schema { + Some(s) => format!("`{}`.`{}`", s.replace('`', "``"), table.replace('`', "``")), + None => format!("`{}`", table.replace('`', "``")), + }; + format!("SELECT *\nFROM {target}\nLIMIT 200;") + } + _ => { + let quote = |n: &str| format!("\"{}\"", n.replace('"', "\"\"")); + let target = match schema { + Some(s) => format!("{}.{}", quote(&s), quote(&table)), + None => quote(&table), + }; + format!("SELECT *\nFROM {target}\nLIMIT 200;") + } + } +} diff --git a/apps/redrob-query/src/services.rs b/apps/redrob-query/src/services.rs new file mode 100644 index 0000000..0330fa4 --- /dev/null +++ b/apps/redrob-query/src/services.rs @@ -0,0 +1,65 @@ +//! Where Redrob Query keeps its per-user data, and the names every platform shows for it. +//! +//! One config directory for the whole app (see [`config_dir`]): `connections.json` (profiles, no +//! secrets; the engine's own file), `account.json` (this app's own sign-in, if the launcher's +//! Connect wrote one), `workspace.json` (open tabs and history) and eframe's `ui.ron` (window +//! geometry, panel sizes). Passwords are in the OS keyring, never here. + +use std::ffi::OsString; +use std::path::PathBuf; + +/// Desktop app ID: the Linux `.desktop` file name, hicolor icon name and Wayland app id. +pub const APP_ID: &str = "ai.redrob.query"; +/// The window title and the brand text in the top bar. +pub const APP_NAME: &str = "Redrob Query"; +/// `REDROB_QUERY_CONFIG_DIR` overrides the platform directory (tests, agents, custom setups). +pub const CONFIG_DIR_ENV: &str = "REDROB_QUERY_CONFIG_DIR"; +/// `REDROB_QUERY_NO_PREFS`: read and write no workspace or window state (tests, demos). Profiles +/// are still stored: they are the engine's, and a demo without them would have nothing to connect. +pub const NO_PREFS_ENV: &str = "REDROB_QUERY_NO_PREFS"; + +/// Per-user settings directory: `REDROB_QUERY_CONFIG_DIR`, else the platform convention +/// (macOS `~/Library/Application Support/Redrob Query`, Windows `%APPDATA%\Redrob Query`, +/// elsewhere `$XDG_CONFIG_HOME/redrob-query` or `~/.config/redrob-query`). +pub fn config_dir() -> Option { + config_dir_with(std::env::consts::OS, |key| std::env::var_os(key)) +} + +/// [`config_dir`] from an explicit environment, so tests can fake `HOME` / `APPDATA`. +pub fn config_dir_with(os: &str, env: impl Fn(&str) -> Option) -> Option { + if let Some(root) = env(CONFIG_DIR_ENV) { + // An explicitly empty override must not fall back to the user's profile. + return (!root.is_empty()).then(|| PathBuf::from(root)); + } + let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from); + match os { + "macos" => home.map(|h| h.join("Library/Application Support").join(APP_NAME)), + "windows" => env("APPDATA") + .filter(|a| !a.is_empty()) + .map(|a| PathBuf::from(a).join(APP_NAME)), + _ => env("XDG_CONFIG_HOME") + .filter(|x| !x.is_empty()) + .map(PathBuf::from) + .or_else(|| home.map(|h| h.join(".config"))) + .map(|c| c.join("redrob-query")), + } +} + +/// Runs without preferences neither read nor write the workspace or window state. +pub fn prefs_enabled() -> bool { + std::env::var_os(NO_PREFS_ENV).is_none() +} + +/// eframe's own persistence file (window geometry, panel sizes). +pub fn persistence_path() -> Option { + config_dir() + .filter(|_| prefs_enabled()) + .map(|d| d.join("ui.ron")) +} + +/// The open tabs and the query history (`workspace.rs`). +pub fn workspace_path() -> Option { + config_dir() + .filter(|_| prefs_enabled()) + .map(|d| d.join("workspace.json")) +} diff --git a/apps/redrob-query/src/structure.rs b/apps/redrob-query/src/structure.rs new file mode 100644 index 0000000..3d44025 --- /dev/null +++ b/apps/redrob-query/src/structure.rs @@ -0,0 +1,535 @@ +//! The Structure panel: the selected table's columns, primary key, foreign keys and indexes, and +//! the table-change form (create a table, add a column) that runs through the engine's `ddl`. + +use egui::{RichText, Ui}; +use redrob_query_core::{ + DatabaseKind, MetadataNode, MetadataRequest, Pending, Workspace, + ddl::{ColumnSpec, ColumnType, TableChange}, + keys::TableKeys, +}; +use uuid::Uuid; + +use crate::schema::split_table_id; +use crate::theme; + +pub enum StructureEvent { + /// A DDL change was applied: reload the schema tree of this connection. + Changed(Uuid), + Notice(String, bool), +} + +#[derive(Default)] +pub struct StructurePanel { + pub target: Option, + columns: Option>, + keys: Option, + columns_load: Option>>, + keys_load: Option>, + form: Option, + apply: Option>, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Target { + pub connection_id: Uuid, + pub kind: DatabaseKind, + pub node: MetadataNode, +} + +impl StructurePanel { + pub fn show_table( + &mut self, + workspace: &Workspace, + connection_id: Uuid, + kind: DatabaseKind, + node: MetadataNode, + ) { + let (schema, table) = split_table_id(&node, kind); + self.columns = None; + self.keys = None; + self.columns_load = Some(workspace.load_metadata(MetadataRequest { + connection_id, + parent_id: Some(node.id.clone()), + })); + self.keys_load = Some(workspace.table_keys(connection_id, schema, table)); + self.target = Some(Target { + connection_id, + kind, + node, + }); + } + + pub fn forget(&mut self, connection_id: Uuid) { + if self + .target + .as_ref() + .is_some_and(|t| t.connection_id == connection_id) + { + *self = Self::default(); + } + } + + pub fn poll(&mut self, events: &mut Vec) { + if let Some(load) = &mut self.columns_load + && let Some(result) = load.poll() + { + self.columns_load = None; + match result { + Ok(columns) => self.columns = Some(columns), + Err(error) => events.push(StructureEvent::Notice(error.to_string(), true)), + } + } + if let Some(load) = &mut self.keys_load + && let Some(result) = load.poll() + { + self.keys_load = None; + match result { + Ok(keys) => self.keys = Some(keys), + // Keys are a nicety: a dialect without them still shows its columns. + Err(error) => { + log::info!("table keys unavailable: {error}"); + self.keys = Some(TableKeys::default()); + } + } + } + if let Some(apply) = &mut self.apply + && let Some(result) = apply.poll() + { + self.apply = None; + match result { + Ok(()) => { + if let Some(form) = self.form.take() { + events.push(StructureEvent::Notice( + format!("Applied: {}", form.describe()), + false, + )); + events.push(StructureEvent::Changed(form.connection_id)); + } + } + Err(error) => { + if let Some(form) = &mut self.form { + form.error = Some(error.to_string()); + } + } + } + } + } + + pub fn ui(&mut self, ui: &mut Ui, workspace: &Workspace, read_only_profile: bool) { + ui.horizontal(|ui| { + ui.heading("Structure"); + if let Some(target) = &self.target { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if target.kind != DatabaseKind::MongoDb + && ui + .small_button("+ Column") + .on_hover_text("Add a column to this table") + .clicked() + { + let (schema, table) = split_table_id(&target.node, target.kind); + self.form = Some(TableChangeForm::add_column( + target.connection_id, + target.kind, + schema, + table, + )); + } + }); + } + }); + let Some(target) = self.target.clone() else { + ui.label(RichText::new("Select a table in the schema browser.").weak()); + return; + }; + ui.label(RichText::new(&target.node.name).strong().monospace()); + if let Some(schema) = target.node.attributes.get("schema") { + ui.label(RichText::new(format!("schema {schema}")).small().weak()); + } + egui::ScrollArea::vertical() + .id_salt("structure") + .auto_shrink([false, false]) + .show(ui, |ui| { + ui.label(RichText::new("Columns").small().strong()); + match &self.columns { + None => { + ui.spinner(); + } + Some(columns) if columns.is_empty() => { + ui.label(RichText::new("No columns reported.").weak()); + } + Some(columns) => { + let keys = self.keys.clone().unwrap_or_default(); + egui::Grid::new("structure-columns") + .num_columns(3) + .striped(true) + .spacing([12.0, 2.0]) + .show(ui, |ui| { + for column in columns { + let is_key = keys.primary.contains(&column.name); + let name = RichText::new(&column.name).monospace(); + ui.label(if is_key { + name.color(theme::REDROB_BLUE) + } else { + name + }); + ui.label( + RichText::new(column.data_type.clone().unwrap_or_default()) + .small() + .weak(), + ); + let mut flags = Vec::new(); + if is_key { + flags.push("PK"); + } + if column.nullable == Some(false) { + flags.push("not null"); + } + if keys + .foreign + .iter() + .any(|fk| fk.columns.contains(&column.name)) + { + flags.push("FK"); + } + ui.label(RichText::new(flags.join(" · ")).small()); + ui.end_row(); + } + }); + } + } + if let Some(keys) = &self.keys { + if !keys.foreign.is_empty() { + ui.add_space(6.0); + ui.label(RichText::new("Foreign keys").small().strong()); + for fk in &keys.foreign { + let mut line = format!( + "{} → {} ({})", + fk.columns.join(", "), + fk.referenced_table, + fk.referenced_columns.join(", ") + ); + if !fk.on_delete.is_empty() && fk.on_delete != "NO ACTION" { + line.push_str(&format!( + " on delete {}", + fk.on_delete.to_lowercase() + )); + } + ui.label(RichText::new(line).monospace().small()) + .on_hover_text(if fk.name.is_empty() { + "unnamed constraint".to_owned() + } else { + fk.name.clone() + }); + } + } + if !keys.indexes.is_empty() { + ui.add_space(6.0); + ui.label(RichText::new("Indexes").small().strong()); + for index in &keys.indexes { + let mut tags = Vec::new(); + if index.primary { + tags.push("primary"); + } else if index.unique { + tags.push("unique"); + } + ui.horizontal(|ui| { + ui.label(RichText::new(&index.name).monospace().small()); + ui.label( + RichText::new(format!("({})", index.columns.join(", "))) + .small() + .weak(), + ); + if !tags.is_empty() { + ui.label( + RichText::new(tags.join(", ")) + .small() + .color(theme::REDROB_BLUE), + ); + } + }); + } + } + } else if self.keys_load.is_some() { + ui.spinner(); + } + }); + if let Some(form) = &mut self.form { + let mut keep = true; + let mut submit = false; + form.ui( + ui.ctx(), + &mut keep, + &mut submit, + read_only_profile, + self.apply.is_some(), + ); + if submit { + match form.change() { + Ok(change) => { + form.error = None; + self.apply = Some(workspace.apply_table_change(form.connection_id, change)); + } + Err(reason) => form.error = Some(reason), + } + } + if !keep { + self.form = None; + } + } + } +} + +#[derive(Clone)] +struct ColumnRow { + name: String, + column_type: ColumnType, + nullable: bool, + primary_key: bool, +} + +impl Default for ColumnRow { + fn default() -> Self { + Self { + name: String::new(), + column_type: ColumnType::Text, + nullable: true, + primary_key: false, + } + } +} + +pub struct TableChangeForm { + pub connection_id: Uuid, + kind: DatabaseKind, + schema: String, + table: String, + create: bool, + columns: Vec, + pub error: Option, +} + +impl TableChangeForm { + fn add_column( + connection_id: Uuid, + kind: DatabaseKind, + schema: Option, + table: String, + ) -> Self { + Self { + connection_id, + kind, + schema: schema.unwrap_or_default(), + table, + create: false, + columns: vec![ColumnRow::default()], + error: None, + } + } + + fn describe(&self) -> String { + if self.create { + format!("created table {}", self.table) + } else { + format!( + "added column {} to {}", + self.columns.first().map_or("", |c| c.name.as_str()), + self.table + ) + } + } + + fn specs(&self) -> Vec { + self.columns + .iter() + .map(|c| ColumnSpec { + name: c.name.trim().to_owned(), + column_type: c.column_type, + nullable: c.nullable && !c.primary_key, + primary_key: c.primary_key, + }) + .collect() + } + + fn change(&self) -> Result { + let schema = Some(self.schema.trim().to_owned()).filter(|s| !s.is_empty()); + let table = self.table.trim().to_owned(); + if table.is_empty() { + return Err("name the table".to_owned()); + } + if self.columns.iter().any(|c| c.name.trim().is_empty()) { + return Err("every column needs a name".to_owned()); + } + let change = if self.create { + TableChange::CreateTable { + schema, + table, + columns: self.specs(), + } + } else { + let column = self + .specs() + .into_iter() + .next() + .ok_or_else(|| "name the column".to_owned())?; + TableChange::AddColumn { + schema, + table, + column, + } + }; + // The engine renders the statement; a refusal here is the same one it would give. + change.statement(self.kind).map_err(|e| e.to_string())?; + Ok(change) + } + + fn ui( + &mut self, + ctx: &egui::Context, + keep: &mut bool, + submit: &mut bool, + read_only: bool, + busy: bool, + ) { + let title = if self.create { + "Create table" + } else { + "Add column" + }; + let mut open = true; + egui::Window::new(title) + .collapsible(false) + .resizable(false) + .default_width(520.0) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .open(&mut open) + .show(ctx, |ui| { + egui::Grid::new("ddl-head") + .num_columns(2) + .spacing([12.0, 6.0]) + .show(ui, |ui| { + if matches!(self.kind, DatabaseKind::PostgreSql | DatabaseKind::MySql) { + ui.label("Schema"); + ui.add( + egui::TextEdit::singleline(&mut self.schema) + .desired_width(200.0) + .hint_text("default"), + ); + ui.end_row(); + } + ui.label("Table"); + ui.add_enabled( + self.create, + egui::TextEdit::singleline(&mut self.table).desired_width(300.0), + ); + ui.end_row(); + }); + ui.add_space(6.0); + ui.label( + RichText::new(if self.create { "Columns" } else { "Column" }) + .small() + .strong(), + ); + let mut remove: Option = None; + let create = self.create; + let count = self.columns.len(); + egui::Grid::new("ddl-columns") + .num_columns(5) + .spacing([10.0, 4.0]) + .show(ui, |ui| { + ui.label(RichText::new("Name").small().weak()); + ui.label(RichText::new("Type").small().weak()); + ui.label(RichText::new("Nullable").small().weak()); + ui.label(RichText::new("Primary").small().weak()); + ui.label(""); + ui.end_row(); + for (index, column) in self.columns.iter_mut().enumerate() { + ui.add( + egui::TextEdit::singleline(&mut column.name).desired_width(160.0), + ); + egui::ComboBox::from_id_salt(("ddl-type", index)) + .selected_text(type_name(column.column_type)) + .show_ui(ui, |ui| { + for t in [ + ColumnType::Text, + ColumnType::Integer, + ColumnType::Decimal, + ColumnType::Boolean, + ColumnType::Date, + ColumnType::Timestamp, + ] { + ui.selectable_value( + &mut column.column_type, + t, + type_name(t), + ); + } + }); + ui.add_enabled( + !column.primary_key, + egui::Checkbox::without_text(&mut column.nullable), + ); + ui.add_enabled( + create, + egui::Checkbox::without_text(&mut column.primary_key), + ); + if create && count > 1 && ui.small_button("✕").clicked() { + remove = Some(index); + } else if !create { + ui.label(""); + } + ui.end_row(); + } + }); + if let Some(index) = remove { + self.columns.remove(index); + } + if self.create && ui.small_button("+ column").clicked() { + self.columns.push(ColumnRow::default()); + } + if let Ok(change) = self.change() + && let Ok(statement) = change.statement(self.kind) + { + ui.add_space(6.0); + ui.label(RichText::new(statement).monospace().small().weak()); + } + if read_only { + ui.label( + RichText::new("This profile is read-only; the change will be refused.") + .color(theme::CAUTION), + ); + } + if let Some(error) = &self.error { + ui.label(RichText::new(error).color(theme::DANGER)); + } + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui + .add_enabled(!busy && !read_only, theme::primary_button("Apply")) + .clicked() + { + *submit = true; + } + if ui.button("Cancel").clicked() { + *keep = false; + } + if busy { + ui.spinner(); + } + }); + }); + if !open { + *keep = false; + } + } +} + +fn type_name(t: ColumnType) -> &'static str { + match t { + ColumnType::Text => "text", + ColumnType::Integer => "integer", + ColumnType::Decimal => "decimal", + ColumnType::Boolean => "boolean", + ColumnType::Date => "date", + ColumnType::Timestamp => "timestamp", + } +} diff --git a/apps/redrob-query/src/theme.rs b/apps/redrob-query/src/theme.rs new file mode 100644 index 0000000..d2f0061 --- /dev/null +++ b/apps/redrob-query/src/theme.rs @@ -0,0 +1,54 @@ +//! The shell's look: egui's dark and light themes with the design system's tokens where they are +//! what distinguishes a Redrob window (redrob-design-system `20-color.md`: "Blue acts", ground +//! `deep` is Redrob Black). Every colour literal in the shell lives here, named after its token. + +use egui::Color32; + +/// `--blue-6`, Redrob Blue: the one accent. Buttons that act, the selected tab, the caret. +pub const REDROB_BLUE: Color32 = Color32::from_rgb(0x2B, 0x52, 0xFF); +/// `--redrob-black`: the deep ground behind the brand lockup (kept for the About surface). +#[allow(dead_code)] +pub const REDROB_BLACK: Color32 = Color32::from_rgb(0x0A, 0x0B, 0x0C); +/// `--red-6`, danger: destructive classifications and failed runs. +pub const DANGER: Color32 = Color32::from_rgb(0xE5, 0x3E, 0x3E); +/// `--green-6`, success: a connected profile, an applied change set. +pub const SUCCESS: Color32 = Color32::from_rgb(0x1F, 0xA9, 0x71); +/// `--amber-6`, caution: an elevated-risk mutation, a truncated result. +pub const CAUTION: Color32 = Color32::from_rgb(0xE0, 0x9A, 0x1F); + +/// Applies the shell's style on top of egui's theme: the accent, slightly roomier spacing, and +/// a monospace size that reads as code without crowding the grid. +pub fn apply(ctx: &egui::Context) { + ctx.all_styles_mut(|style| { + style.visuals.selection.bg_fill = REDROB_BLUE.linear_multiply(0.35); + style.visuals.selection.stroke.color = REDROB_BLUE; + style.visuals.hyperlink_color = REDROB_BLUE; + style.visuals.text_cursor.stroke.color = REDROB_BLUE; + style.visuals.widgets.active.bg_fill = REDROB_BLUE.linear_multiply(0.6); + style.spacing.item_spacing = egui::vec2(8.0, 6.0); + style.spacing.button_padding = egui::vec2(10.0, 4.0); + if let Some(mono) = style.text_styles.get_mut(&egui::TextStyle::Monospace) { + mono.size = 13.0; + } + }); +} + +/// A filled accent button (the primary action of a panel). +pub fn primary_button(text: &str) -> egui::Button<'_> { + egui::Button::new(egui::RichText::new(text).color(Color32::WHITE)).fill(REDROB_BLUE) +} + +/// A small status dot: green connected, grey not, red unsupported. +pub fn status_dot(ui: &mut egui::Ui, colour: Color32) { + let (rect, _) = ui.allocate_exact_size(egui::vec2(8.0, 8.0), egui::Sense::hover()); + ui.painter().circle_filled(rect.center(), 4.0, colour); +} + +/// The caution/danger/neutral colour for a message that may be a warning. +pub fn tone_for_error(ui: &egui::Ui, is_error: bool) -> Color32 { + if is_error { + DANGER + } else { + ui.visuals().text_color() + } +} diff --git a/apps/redrob-query/src/workspace.rs b/apps/redrob-query/src/workspace.rs new file mode 100644 index 0000000..f5f27d8 --- /dev/null +++ b/apps/redrob-query/src/workspace.rs @@ -0,0 +1,125 @@ +//! Workspace persistence: the open query tabs and the run history, in `workspace.json` under the +//! config directory. At most [`MAX_TABS`] tabs and [`MAX_HISTORY`] history entries are kept; with +//! `REDROB_QUERY_NO_PREFS` set nothing is read or written. Written atomically (temp file, rename). + +use std::path::Path; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::services; + +pub const MAX_TABS: usize = 30; +pub const MAX_HISTORY: usize = 50; +/// A tab's text past this is not persisted (the tab is kept, truncated); the editor is for +/// queries, not for pasting a dump. +const MAX_TAB_BYTES: usize = 256 * 1024; + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct SavedTab { + pub title: String, + pub text: String, + pub connection_id: Option, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct HistoryEntry { + pub connection_id: Option, + pub connection_name: String, + pub query: String, + /// RFC 3339, local time is not needed: the list shows relative order. + pub ran_at_unix: u64, + pub rows: usize, + pub elapsed_ms: u64, + pub ok: bool, +} + +impl Default for HistoryEntry { + fn default() -> Self { + Self { + connection_id: None, + connection_name: String::new(), + query: String::new(), + ran_at_unix: 0, + rows: 0, + elapsed_ms: 0, + ok: true, + } + } +} + +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct WorkspaceFile { + pub schema: u32, + pub tabs: Vec, + pub active_tab: usize, + pub history: Vec, + /// The AI dock and the structure panel are remembered open or closed. + pub ai_dock_open: bool, + pub structure_open: bool, +} + +impl WorkspaceFile { + pub fn load() -> Option { + let path = services::workspace_path()?; + let bytes = std::fs::read(path).ok()?; + let mut file: Self = serde_json::from_slice(&bytes).ok()?; + file.clamp(); + Some(file) + } + + /// Writes the file; a failure is logged, not shown -- losing the tab list is not worth a dialog. + pub fn save(&self) { + let Some(path) = services::workspace_path() else { + return; + }; + let mut copy = self.clone(); + copy.schema = 1; + copy.clamp(); + if let Err(error) = write_atomic(&path, ©) { + log::warn!("workspace not saved: {error}"); + } + } + + fn clamp(&mut self) { + if self.tabs.len() > MAX_TABS { + self.tabs.truncate(MAX_TABS); + } + for tab in &mut self.tabs { + if tab.text.len() > MAX_TAB_BYTES { + let mut end = MAX_TAB_BYTES; + while !tab.text.is_char_boundary(end) { + end -= 1; + } + tab.text.truncate(end); + } + } + if self.history.len() > MAX_HISTORY { + let drop = self.history.len() - MAX_HISTORY; + self.history.drain(..drop); + } + if self.active_tab >= self.tabs.len() { + self.active_tab = 0; + } + } +} + +fn write_atomic(path: &Path, file: &WorkspaceFile) -> std::io::Result<()> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let json = serde_json::to_vec_pretty(file).map_err(std::io::Error::other)?; + let temp = path.with_extension("json.tmp"); + std::fs::write(&temp, json)?; + std::fs::rename(&temp, path) +} + +/// Seconds since the Unix epoch, for history stamps. +pub fn now_unix() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} diff --git a/brand/SOURCES.json b/brand/SOURCES.json new file mode 100644 index 0000000..235ac1c --- /dev/null +++ b/brand/SOURCES.json @@ -0,0 +1,54 @@ +{ + "//": "Written by scripts/build-brand-assets.py; checked by scripts/check-brand.py. Do not edit.", + "design_system": "redrob-design-system (the published distribution)", + "sources": { + "assets/Favicons/favicon-16x16.png": "da56595c43ae2a707c9c910aaf7c62624a9f2a6593c5386c098a923c5c574926", + "assets/Favicons/favicon-32x32.png": "e15e5d44332ebd83806de82691926ce69e829779957f70dfb1f3a102eea20e86", + "assets/Favicons/favicon-48x48.png": "cea52085a4011e2ad18a8b3ec2f41b9e4dbb3fdafd3d3223bd66721e4423351f", + "assets/Favicons/favicon-64x64.png": "113869795269f386b7f601dd83febd99d196fcb9414593d97745c2d8be0f9bd0", + "assets/Favicons/icon-512.png": "9c168d6215a4e1957456d9aff3041eb8e33c4c8c9189663bc77ff505c2401e17", + "assets/Favicons/favicon.svg": "fd971f9e388afffa3c0f21153be36e23abb242f335c91ee7d8d1522acf88e09b", + "assets/Logos/redrob-logo-primary.png": "08ebbff1bf720d22fd9fdf60d0b31163276b27a5cc9deeb8358ae35e4f618df4", + "assets/Logos/redrob-logo-primary-white.png": "aefab23bcb9f9184e08775ebad80768039d9187e370e67b73b1b124a7613c7a8", + "assets/Products/redrob-browser-lockup-white.png": "c63bad2a52944e64deafbeda48467f8e2fd88f9ab8266dba6ab8a6e94328553c", + "fonts/Pretendard-SemiBold.woff2": "837b16488ab5264761a8e8bf871ec1be16a2997d157ace3cc7355d0a809d8639", + "tokens.css": "846a49dab8397554f1fb2505252de675ab2964cbfd44e6f3a6e03b17bd961ba9" + }, + "symbol_path_sha256": "bff33e6b4993876bde64dbd20861f620332b0294607569299450cfce9d05e08d", + "tokens": { + "blue-6": "#2b52ff", + "redrob-black": "#0a0b0c", + "ink-primary": "#0a0b0c" + }, + "copied_unchanged": { + "resources/icons/redrob-query.svg": "assets/Favicons/favicon.svg", + "resources/icons/hicolor/16x16/apps/redrob-query.png": "assets/Favicons/favicon-16x16.png", + "resources/icons/hicolor/32x32/apps/redrob-query.png": "assets/Favicons/favicon-32x32.png", + "resources/icons/hicolor/48x48/apps/redrob-query.png": "assets/Favicons/favicon-48x48.png", + "resources/icons/hicolor/64x64/apps/redrob-query.png": "assets/Favicons/favicon-64x64.png", + "resources/icons/hicolor/512x512/apps/redrob-query.png": "assets/Favicons/icon-512.png" + }, + "owned_dirs": [ + "resources/icons", + "resources/brand" + ], + "outputs": { + "installer/macos/res/icon.png": "26ebd3ba903656f2b3bc39ae9a8fd40160a7e088ad864f9c2816930509653050", + "installer/windows/res/icon.png": "26ebd3ba903656f2b3bc39ae9a8fd40160a7e088ad864f9c2816930509653050", + "installer/windows/res/lockup.png": "47875fa45360dbe16c3365c5abe4cf3f9deecc9d63d13b6675b6af48319bb3f1", + "resources/brand/redrob-query-lockup-white.png": "ffbb2641ad2a81161380c5ffcf489f10bb07f3a34e033564e294421ad5720863", + "resources/brand/redrob-query-lockup.png": "cc3e3e5d7a065a9a032dc7bed01ff68d841094a78ced490ddf577a15d3ebe9e7", + "resources/brand/redrob-query-splash.png": "0f5740de5c1730eeb3f5fade9f88d05191c861f48ac2aae159b2c13adb84b8db", + "resources/icons/hicolor/128x128/apps/redrob-query.png": "ddd419d662f512151e577eb066d8d11fffe6231e6b60cd1248de8535b2b21afb", + "resources/icons/hicolor/16x16/apps/redrob-query.png": "da56595c43ae2a707c9c910aaf7c62624a9f2a6593c5386c098a923c5c574926", + "resources/icons/hicolor/256x256/apps/redrob-query.png": "26ebd3ba903656f2b3bc39ae9a8fd40160a7e088ad864f9c2816930509653050", + "resources/icons/hicolor/32x32/apps/redrob-query.png": "e15e5d44332ebd83806de82691926ce69e829779957f70dfb1f3a102eea20e86", + "resources/icons/hicolor/48x48/apps/redrob-query.png": "cea52085a4011e2ad18a8b3ec2f41b9e4dbb3fdafd3d3223bd66721e4423351f", + "resources/icons/hicolor/512x512/apps/redrob-query.png": "9c168d6215a4e1957456d9aff3041eb8e33c4c8c9189663bc77ff505c2401e17", + "resources/icons/hicolor/64x64/apps/redrob-query.png": "113869795269f386b7f601dd83febd99d196fcb9414593d97745c2d8be0f9bd0", + "resources/icons/redrob-query-small.svg": "91726578f96b8cf36af6ea1cb261aeb7cbf4d3f17c9c5008f30177fa828c9550", + "resources/icons/redrob-query.icns": "2610d619fecd51000bde5b12385393873988bc371ff5bcfddea5b09704371e8f", + "resources/icons/redrob-query.ico": "c4dd7386998aefbd2dd6356742503e3d46e80e2b97229bd5fa07e8a77ad778d7", + "resources/icons/redrob-query.svg": "fd971f9e388afffa3c0f21153be36e23abb242f335c91ee7d8d1522acf88e09b" + } +} diff --git a/crates/redrob-core/Cargo.toml b/crates/redrob-core/Cargo.toml deleted file mode 100644 index c3ffef1..0000000 --- a/crates/redrob-core/Cargo.toml +++ /dev/null @@ -1,38 +0,0 @@ -[package] -name = "redrob-core" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true -authors.workspace = true -repository.workspace = true -homepage.workspace = true -description = "Secure Rust core for Redrob Query database connectivity, metadata, querying, and AI transport" -readme = "../../README.md" -publish = false - -[dependencies] -bigdecimal = "0.4" -chrono = { version = "0.4", features = ["serde"] } -futures-util = "0.3" -fs2 = "0.4" -base64 = "0.22" -keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } -mongodb = "3" -# The shared Redrob Code daemon, from redrob-code's own crate (docs/LOCAL-ENGINE-API.md section 3): -# finds or starts the one engine on this machine and hands back its record. The AI client talks to -# that daemon only; the daemon holds the Redrob console key. -redrob-engine-client = { git = "https://github.com/redrob-labs/redrob-code", tag = "v0.6.3" } -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } -secrecy = { version = "0.10", features = ["serde"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" -sqlx = { version = "0.9", default-features = false, features = ["runtime-tokio", "tls-rustls-ring", "postgres", "mysql", "sqlite", "chrono", "bigdecimal", "uuid", "json"] } -tempfile = "3" -thiserror = "2" -tokio = { version = "1", features = ["sync", "time", "rt-multi-thread", "macros"] } -url = { version = "2", features = ["serde"] } -uuid = { version = "1", features = ["v4", "serde"] } - -[lints] -workspace = true diff --git a/crates/redrob-core/src/ai.rs b/crates/redrob-core/src/ai.rs deleted file mode 100644 index 971dc38..0000000 --- a/crates/redrob-core/src/ai.rs +++ /dev/null @@ -1,1277 +0,0 @@ -//! Redrob chat-completion client, through the shared Redrob Code daemon. -//! -//! One `redrob serve --register` runs per user and every Redrob app attaches to it (redrob-code -//! `docs/LOCAL-ENGINE-API.md`, section 3). The daemon holds the Redrob console key and calls the -//! console with its own credential; this app sends the daemon's Basic secret, its app name and a -//! session id on every chat request and never sees a console key on that path. There is -//! deliberately no direct path to the console: two paths would hide whether masking applied. - -use std::{ - sync::{Arc, Mutex}, - time::Duration, -}; - -use futures_util::StreamExt; -use redrob_engine_client::{Record, Unavailable, app_headers, openai_base}; -use reqwest::{Client, StatusCode, header::HeaderValue}; -use secrecy::ExposeSecret; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tokio::time::sleep; -use uuid::Uuid; - -use crate::{ - AiAssistantRequest, AiChatRequest, AiChatResponse, AiMessage, AiRole, AiUsage, DataError, - DatabaseKind, QueryLanguage, Result, secret::SecretStore, -}; - -/// The name the daemon labels this app's requests with (`x-redrob-app`). -pub const ENGINE_APP: &str = "query"; -/// How long a daemon started by this app stays up with nobody attached. -pub const DEFAULT_IDLE_EXIT_MINUTES: u32 = 10; -/// Where a person gets Redrob Code when it is not installed: the launcher installs it. -pub const INSTALL_URL: &str = "https://github.com/redrob-labs/redrob-launcher/releases/latest"; -/// The sentence shown when no `redrob` binary exists. The link is [`INSTALL_URL`]. -pub const NOT_INSTALLED_MESSAGE: &str = - "Redrob Code is not installed. Install it with Redrob Launcher."; -/// The provider id the daemon lists the Redrob console under (`GET /provider`, `PUT /auth/redrob`). -pub const REDROB_PROVIDER_ID: &str = "redrob"; -/// The daemon's own error code when it holds no console credential (LOCAL-ENGINE-API.md, Errors). -const ENGINE_NOT_AUTHENTICATED: &str = "engine_not_authenticated"; -/// The daemon lists a credential-less Redrob provider with this placeholder; it is not a key. -const PUBLIC_SENTINEL: &str = "public"; -/// The keyring slot an earlier version of this app saved its own console key in. It is only read -/// to hand that key to the engine once, then deleted; profile storage still reserves the id. -pub const AI_SECRET_ID: Uuid = Uuid::from_u128(0x7265_6472_6f62_4169_0000_0000_0000_0001); -pub const MAX_AI_PROMPT_BYTES: usize = 8 * 1024; -pub const MAX_AI_ACTIVE_QUERY_BYTES: usize = 64 * 1024; -const MAX_ATTEMPTS: usize = 3; -const AI_TEMPERATURE: f32 = 0.2; -const MAX_ERROR_BODY_BYTES: usize = 64 * 1024; - -/// What the app renders for a missing engine: the install sentence with its link, or the start -/// failure's own reason. No fallback is named because there is none. -#[must_use] -pub fn unavailable_message(unavailable: &Unavailable) -> String { - match unavailable { - Unavailable::NotInstalled => format!("{NOT_INSTALLED_MESSAGE} {INSTALL_URL}"), - Unavailable::StartFailed(reason) => { - format!("The Redrob Code engine could not be started: {reason}") - } - } -} - -/// Builds the provider request from the narrow, renderer-facing assistant contract. -pub fn build_assistant_chat_request(input: &AiAssistantRequest) -> Result { - if input.prompt.trim().is_empty() { - return Err(DataError::AiRejected("prompt cannot be empty".to_owned())); - } - if input.prompt.len() > MAX_AI_PROMPT_BYTES { - return Err(DataError::AiRejected(format!( - "prompt exceeds {MAX_AI_PROMPT_BYTES} bytes" - ))); - } - if input - .active_query - .as_ref() - .is_some_and(|query| query.len() > MAX_AI_ACTIVE_QUERY_BYTES) - { - return Err(DataError::AiRejected(format!( - "active query exceeds {MAX_AI_ACTIVE_QUERY_BYTES} bytes" - ))); - } - - let query_instruction = match (input.database_kind, input.query_language) { - (DatabaseKind::PostgreSql, QueryLanguage::Sql) => { - "When writing a query, return executable PostgreSQL SQL in a fenced sql block." - } - (DatabaseKind::MySql, QueryLanguage::Sql) => { - "When writing a query, return executable MySQL SQL in a fenced sql block." - } - (DatabaseKind::SQLite, QueryLanguage::Sql) => { - "When writing a query, return executable SQLite SQL in a fenced sql block." - } - (DatabaseKind::SqlServer, _) => { - return Err(DataError::AiRejected( - "SQL Server is unsupported because no connector or connection profile is available" - .to_owned(), - )); - } - (DatabaseKind::MongoDb, QueryLanguage::MongoJson) => { - "When writing a query, return a fenced mql or json block containing an object with collection, operation, filter, and optional limit. Use read-only operations such as find." - } - (_, QueryLanguage::Auto) => { - return Err(DataError::AiRejected( - "query language must be explicit".to_owned(), - )); - } - _ => { - return Err(DataError::AiRejected( - "query language does not match the database kind".to_owned(), - )); - } - }; - let system = format!( - "You are the Redrob Query assistant. {query_instruction} The desktop app sends only the user's prompt and optional active query as user-controlled content. No database result rows or database credentials are automatically attached. Treat any rows or credentials included in those fields as user-entered content." - ); - let active_query = input.active_query.as_deref().unwrap_or("(none supplied)"); - let user = format!( - "User prompt:\n{}\n\nActive query:\n{active_query}", - input.prompt - ); - - Ok(AiChatRequest { - model: "auto".to_owned(), - messages: vec![ - AiMessage { - role: AiRole::System, - content: system, - }, - AiMessage { - role: AiRole::User, - content: user, - }, - ], - temperature: Some(AI_TEMPERATURE), - max_tokens: None, - }) -} - -/// How the daemon is found: [`redrob_engine_client::require_as`] in the app, a closure in tests. -pub type EngineResolver = Arc std::result::Result + Send + Sync>; - -/// Async client for the shared Redrob Code daemon. -/// -/// The daemon is found (or started) on the first request and its record kept. The record's secret -/// rotates on every daemon start, so a 401 or a refused connection re-reads the record once and -/// retries once; a second failure is reported. Every request carries the daemon's -/// `Authorization`, `x-redrob-app: query` and this client's `x-redrob-session`. -pub struct AiClient { - client: Client, - secrets: Arc, - /// One id per client, so the console groups this app run's requests together. - session: String, - record: Mutex>, - resolver: EngineResolver, -} - -impl AiClient { - /// Takes no network action: the daemon is looked up when the first request needs it. - pub fn new(secrets: Arc) -> Self { - Self::with_resolver( - secrets, - Arc::new(|| redrob_engine_client::require_as(ENGINE_APP, DEFAULT_IDLE_EXIT_MINUTES)), - ) - } - - /// A client whose daemon lookup is `resolver` (tests point it at a fake daemon's record). - pub fn with_resolver(secrets: Arc, resolver: EngineResolver) -> Self { - // The daemon listens on 127.0.0.1 over plain HTTP; nothing here leaves the machine. - let client = Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(Duration::from_secs(45)) - .user_agent("Redrob-Query/0.2") - .build() - .expect("static HTTP client configuration is valid"); - Self { - client, - secrets, - session: Uuid::new_v4().to_string(), - record: Mutex::new(None), - resolver, - } - } - - pub async fn chat(&self, request: AiChatRequest) -> Result { - validate_request(&request)?; - let mut last_error = DataError::AiUnavailable; - - for attempt in 0..MAX_ATTEMPTS { - let response = self - .send(|record| self.client.post(chat_endpoint(record)).json(&request)) - .await; - match response { - Ok(response) if response.status().is_success() => { - let bytes = bounded_response(response, 2 * 1024 * 1024).await?; - return parse_response(&bytes); - } - Ok(response) => { - let status = response.status(); - last_error = classify_status(status); - if !retryable_status(status) || attempt + 1 == MAX_ATTEMPTS { - return Err(last_error); - } - } - // A missing engine or a daemon that is not signed in does not get better by asking - // again; a transport failure might. - Err(error @ (DataError::EngineUnavailable(_) | DataError::AiAuthentication)) => { - return Err(error); - } - Err(error) => { - last_error = error; - if attempt + 1 == MAX_ATTEMPTS { - return Err(last_error); - } - } - } - sleep(Duration::from_millis(200 * (1_u64 << attempt))).await; - } - Err(last_error) - } - - /// Whether the daemon is signed in to the Redrob console (`GET /provider`), and with whose key. - /// Read fresh on every call: the person may sign in or out elsewhere while this app is open. - pub async fn account_status(&self) -> Result { - let response = self - .send(|record| self.client.get(daemon_endpoint(record, "provider"))) - .await?; - if !response.status().is_success() { - return Err(classify_status(response.status())); - } - let bytes = bounded_response(response, 2 * 1024 * 1024).await?; - let list: Value = - serde_json::from_slice(&bytes).map_err(|_| DataError::AiInvalidResponse)?; - Ok(AiKeyStatus::from_provider_list(&list)) - } - - /// Hands a console key to the daemon (`PUT /auth/redrob`), which keeps it from here on. A second - /// call rotates it. Nothing in this app holds the key afterwards. - pub async fn sign_in(&self, key: &str) -> Result<()> { - if key.trim().is_empty() { - return Err(DataError::SecretUnavailable); - } - let response = self.send(|record| self.auth_request(record, key)).await?; - accepted_key(response.status()) - } - - /// Finds or starts the daemon, off the async runtime: the client crate shells out to - /// `redrob service start` and probes with blocking HTTP. - async fn connect(&self) -> Result { - if let Some(record) = self.lock_record().clone() { - return Ok(record); - } - let resolver = Arc::clone(&self.resolver); - let record = tokio::task::spawn_blocking(move || resolver()) - .await - .map_err(|_| DataError::AiUnavailable)? - .map_err(|unavailable| { - DataError::EngineUnavailable(unavailable_message(&unavailable)) - })?; - *self.lock_record() = Some(record.clone()); - self.migrate_legacy_key(&record).await; - Ok(record) - } - - /// A key an earlier version of this app saved in the OS keyring is handed to the engine once - /// and deleted locally; the engine keeps it from then on. A failure leaves it for the next run. - async fn migrate_legacy_key(&self, record: &Record) { - let Ok(Some(key)) = self.secrets.get(AI_SECRET_ID) else { - return; - }; - let accepted = self - .headers(record, self.auth_request(record, key.expose_secret())) - .send() - .await - .is_ok_and(|response| response.status().is_success()); - if accepted { - let _ = self.secrets.delete(AI_SECRET_ID); - } - } - - fn auth_request(&self, record: &Record, key: &str) -> reqwest::RequestBuilder { - self.client - .put(daemon_endpoint( - record, - &format!("auth/{REDROB_PROVIDER_ID}"), - )) - .json(&serde_json::json!({"type": "api", "key": key})) - } - - fn lock_record(&self) -> std::sync::MutexGuard<'_, Option> { - self.record - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) - } - - /// Sends one request to the daemon with its headers, retrying once with a fresh record when the - /// daemon has evidently restarted (connection refused, or 401 against the secret in hand). A 401 - /// whose code says the daemon itself is not signed in is reported as such, not retried. - async fn send( - &self, - build: impl Fn(&Record) -> reqwest::RequestBuilder, - ) -> Result { - let record = self.connect().await?; - let first = self.headers(&record, build(&record)).send().await; - match first { - Ok(response) if response.status() != StatusCode::UNAUTHORIZED => return Ok(response), - Ok(response) => { - let body = bounded_response(response, MAX_ERROR_BODY_BYTES) - .await - .unwrap_or_default(); - if names_engine_not_authenticated(&body) { - return Err(DataError::AiAuthentication); - } - } - Err(error) if !error.is_connect() => return Err(transport_error(&error)), - Err(_) => {} - } - *self.lock_record() = None; - let record = self.connect().await?; - self.headers(&record, build(&record)) - .send() - .await - .map_err(|error| transport_error(&error)) - } - - fn headers( - &self, - record: &Record, - builder: reqwest::RequestBuilder, - ) -> reqwest::RequestBuilder { - let mut builder = builder; - for (name, value) in app_headers(record, ENGINE_APP, Some(&self.session)) { - let Ok(mut header) = HeaderValue::from_str(&value) else { - continue; - }; - if name == "Authorization" { - header.set_sensitive(true); - } - builder = builder.header(name, header); - } - builder - } -} - -/// `/v1/chat/completions`: the OpenAI-compatible route. -fn chat_endpoint(record: &Record) -> String { - format!("{}/chat/completions", openai_base(record)) -} - -/// `/`: the daemon's own routes (`provider`, `auth/`). -fn daemon_endpoint(record: &Record, path: &str) -> String { - format!("{}/{path}", record.url.trim_end_matches('/')) -} - -fn transport_error(error: &reqwest::Error) -> DataError { - if error.is_timeout() { - DataError::Timeout(Duration::from_secs(45)) - } else { - DataError::AiUnavailable - } -} - -/// The daemon's failure body could echo the payload; only the status is kept. -fn accepted_key(status: StatusCode) -> Result<()> { - if status.is_success() { - Ok(()) - } else { - Err(DataError::AiRejected( - "the engine did not accept the Redrob key".to_owned(), - )) - } -} - -/// Branches on the daemon's error `code`, never on its message, which is localized. -fn names_engine_not_authenticated(body: &[u8]) -> bool { - serde_json::from_slice::(body) - .ok() - .and_then(|value| { - value - .pointer("/error/code") - .and_then(Value::as_str) - .map(str::to_owned) - }) - .is_some_and(|code| code == ENGINE_NOT_AUTHENTICATED) -} - -/// Whether the engine is signed in to the Redrob console, and with whose key. Never a key. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AiKeyStatus { - pub source: AiKeySource, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum AiKeySource { - /// The engine holds a console key handed to it by an app, its environment or its config. - Engine, - /// The engine uses Redrob Launcher's shared sign-in. Signing out happens there. - Launcher, - /// The engine has no console key. - None, -} - -impl AiKeyStatus { - #[must_use] - pub const fn signed_out() -> Self { - Self { - source: AiKeySource::None, - } - } - - /// Reads `GET /provider`: a `redrob` entry with a credential means signed in. The shape is the - /// one the other Redrob apps read (`{"all": [{"id", "source", "key", "options": {"apiKey"}}]}`). - #[must_use] - pub fn from_provider_list(list: &Value) -> Self { - let Some(entry) = list.get("all").and_then(Value::as_array).and_then(|all| { - all.iter() - .find(|entry| entry.get("id").and_then(Value::as_str) == Some(REDROB_PROVIDER_ID)) - }) else { - return Self::signed_out(); - }; - if entry - .pointer("/options/apiKey") - .and_then(Value::as_str) - .is_some_and(|key| key == PUBLIC_SENTINEL) - { - return Self::signed_out(); - } - let source = entry.get("source").and_then(Value::as_str); - let has_key = entry - .get("key") - .and_then(Value::as_str) - .is_some_and(|key| !key.trim().is_empty()); - match source { - Some("launcher") => Self { - source: AiKeySource::Launcher, - }, - Some("api" | "env") => Self { - source: AiKeySource::Engine, - }, - _ if has_key => Self { - source: AiKeySource::Engine, - }, - _ => Self::signed_out(), - } - } -} - -async fn bounded_response(response: reqwest::Response, limit: usize) -> Result> { - if response - .content_length() - .is_some_and(|length| length > u64::try_from(limit).unwrap_or(u64::MAX)) - { - return Err(DataError::AiInvalidResponse); - } - let mut body = Vec::with_capacity(response.content_length().map_or(0, |length| { - usize::try_from(length).unwrap_or(limit).min(limit) - })); - let mut stream = response.bytes_stream(); - while let Some(chunk) = stream.next().await { - let chunk = chunk.map_err(|_| DataError::AiInvalidResponse)?; - if body.len().saturating_add(chunk.len()) > limit { - return Err(DataError::AiInvalidResponse); - } - body.extend_from_slice(&chunk); - } - Ok(body) -} - -fn validate_request(request: &AiChatRequest) -> Result<()> { - if request.messages.is_empty() || request.messages.len() > 100 { - return Err(DataError::AiRejected( - "provide between 1 and 100 messages".to_owned(), - )); - } - if request.model.trim().is_empty() || request.model.len() > 100 { - return Err(DataError::AiRejected("model is invalid".to_owned())); - } - if request - .messages - .iter() - .any(|message| message.content.len() > 100_000) - { - return Err(DataError::AiRejected("message is too large".to_owned())); - } - if request - .temperature - .is_some_and(|value| !(0.0..=2.0).contains(&value)) - { - return Err(DataError::AiRejected( - "temperature must be between 0 and 2".to_owned(), - )); - } - Ok(()) -} - -fn retryable_status(status: StatusCode) -> bool { - status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() -} - -fn classify_status(status: StatusCode) -> DataError { - match status { - StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN => DataError::AiAuthentication, - StatusCode::TOO_MANY_REQUESTS => DataError::AiUnavailable, - value if value.is_server_error() => DataError::AiUnavailable, - _ => DataError::AiRejected(format!("service returned HTTP {status}")), - } -} - -#[derive(Deserialize)] -struct WireResponse { - id: String, - model: String, - choices: Vec, - usage: Option, -} - -#[derive(Deserialize)] -struct WireChoice { - message: WireMessage, -} - -#[derive(Deserialize)] -struct WireMessage { - role: String, - content: String, -} - -#[derive(Deserialize)] -struct WireUsage { - #[serde(rename = "prompt_tokens")] - prompt: u32, - #[serde(rename = "completion_tokens")] - completion: u32, - #[serde(rename = "total_tokens")] - total: u32, -} - -fn parse_response(bytes: &[u8]) -> Result { - let wire: WireResponse = - serde_json::from_slice(bytes).map_err(|_| DataError::AiInvalidResponse)?; - let choice = wire - .choices - .into_iter() - .next() - .ok_or(DataError::AiInvalidResponse)?; - if choice.message.content.is_empty() { - return Err(DataError::AiInvalidResponse); - } - let role = match choice.message.role.as_str() { - "assistant" => AiRole::Assistant, - "system" => AiRole::System, - "user" => AiRole::User, - _ => return Err(DataError::AiInvalidResponse), - }; - Ok(AiChatResponse { - id: wire.id, - model: wire.model, - message: AiMessage { - role, - content: choice.message.content, - }, - usage: wire.usage.map(|usage| AiUsage { - prompt_tokens: usage.prompt, - completion_tokens: usage.completion, - total_tokens: usage.total, - }), - }) -} - -#[cfg(test)] -mod tests { - use std::{ - collections::{HashMap, VecDeque}, - io::{BufRead, BufReader, Read, Write}, - net::{TcpListener, TcpStream}, - sync::{ - Arc, Mutex, - atomic::{AtomicUsize, Ordering}, - }, - thread, - time::Duration as StdDuration, - }; - - use serde_json::json; - - use crate::secret::MemorySecretStore; - - use super::*; - - /// One request as the fake daemon saw it. - #[derive(Clone, Debug)] - struct Seen { - method: String, - path: String, - headers: HashMap, - body: String, - } - - impl Seen { - fn header(&self, name: &str) -> Option<&str> { - self.headers - .get(&name.to_ascii_lowercase()) - .map(String::as_str) - } - } - - /// What the fake daemon answers with. `declared_length` lies in `Content-Length` when set. - struct Answer { - status: u16, - body: String, - declared_length: Option, - } - - fn reply(status: u16, body: impl Into) -> Answer { - Answer { - status, - body: body.into(), - declared_length: None, - } - } - - fn completion(id: &str, content: &str) -> Answer { - reply( - 200, - json!({ - "id": id, "model": "mock-model", - "choices": [{"message": {"role": "assistant", "content": content}}], - "usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7} - }) - .to_string(), - ) - } - - /// A tiny stand-in for the shared daemon on 127.0.0.1: the wrong secret is 401 everywhere, as - /// the real daemon does; `/api/health` answers; everything else is up to `answer`. - struct FakeDaemon { - url: String, - secret: String, - seen: Arc>>, - } - - impl FakeDaemon { - fn start(secret: &str, answer: impl Fn(&Seen) -> Answer + Send + Sync + 'static) -> Self { - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); - let port = listener.local_addr().unwrap().port(); - let seen = Arc::new(Mutex::new(Vec::new())); - let expected = redrob_engine_client::authorization(secret); - let log = Arc::clone(&seen); - thread::spawn(move || { - for stream in listener.incoming() { - let Ok(mut stream) = stream else { break }; - let Some(request) = read_request(&mut stream) else { - continue; - }; - log.lock().unwrap().push(request.clone()); - let answer = if request.header("authorization") != Some(expected.as_str()) { - reply( - 401, - json!({"error": {"message": "unauthorized"}}).to_string(), - ) - } else if request.path == "/api/health" { - reply(200, json!({"healthy": true}).to_string()) - } else { - answer(&request) - }; - let length = answer.declared_length.unwrap_or(answer.body.len()); - let _ = write!( - stream, - "HTTP/1.1 {} X\r\nContent-Type: application/json\r\nContent-Length: {length}\r\nConnection: close\r\n\r\n{}", - answer.status, answer.body - ); - let _ = stream.flush(); - } - }); - Self { - url: format!("http://127.0.0.1:{port}"), - secret: secret.into(), - seen, - } - } - - fn record(&self) -> Record { - self.record_with_secret(&self.secret) - } - - fn record_with_secret(&self, secret: &str) -> Record { - Record { - id: format!("id-{secret}"), - version: "0.6.3".into(), - url: self.url.clone(), - port: self.url.rsplit(':').next().unwrap().parse().unwrap(), - pid: std::process::id(), - secret: secret.into(), - } - } - - fn seen(&self) -> Vec { - self.seen.lock().unwrap().clone() - } - } - - fn read_request(stream: &mut TcpStream) -> Option { - stream - .set_read_timeout(Some(StdDuration::from_secs(5))) - .ok()?; - let mut reader = BufReader::new(stream); - let mut line = String::new(); - reader.read_line(&mut line).ok()?; - let mut parts = line.split_whitespace(); - let method = parts.next()?.to_owned(); - let path = parts.next()?.to_owned(); - let mut headers = HashMap::new(); - loop { - let mut header = String::new(); - reader.read_line(&mut header).ok()?; - let header = header.trim_end(); - if header.is_empty() { - break; - } - let (name, value) = header.split_once(':')?; - headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_owned()); - } - let length: usize = headers - .get("content-length") - .and_then(|value| value.parse().ok()) - .unwrap_or(0); - let mut body = vec![0; length]; - reader.read_exact(&mut body).ok()?; - Some(Seen { - method, - path, - headers, - body: String::from_utf8_lossy(&body).into_owned(), - }) - } - - /// A resolver that answers from a script, repeating its last entry, and counts its calls. - fn scripted( - script: Vec>, - ) -> (EngineResolver, Arc) { - let calls = Arc::new(AtomicUsize::new(0)); - let counter = Arc::clone(&calls); - let script = Mutex::new(VecDeque::from(script)); - let resolver: EngineResolver = Arc::new(move || { - counter.fetch_add(1, Ordering::SeqCst); - let mut script = script.lock().unwrap(); - if script.len() > 1 { - script.pop_front().unwrap() - } else { - script.front().cloned().unwrap() - } - }); - (resolver, calls) - } - - fn client_for(daemon: &FakeDaemon) -> (AiClient, Arc) { - let (resolver, calls) = scripted(vec![Ok(daemon.record())]); - ( - AiClient::with_resolver(Arc::new(MemorySecretStore::default()), resolver), - calls, - ) - } - - fn test_chat_request() -> AiChatRequest { - AiChatRequest { - model: "auto".to_owned(), - messages: vec![AiMessage { - role: AiRole::User, - content: "show indexes".to_owned(), - }], - temperature: Some(0.2), - max_tokens: Some(128), - } - } - - #[tokio::test] - async fn chat_hits_the_daemon_v1_route_with_its_authorization_app_and_session() { - let daemon = FakeDaemon::start("s3cret", |request| { - assert_eq!(request.method, "POST"); - completion("chat-local", "Use an index.") - }); - let (client, calls) = client_for(&daemon); - - let response = client.chat(test_chat_request()).await.unwrap(); - assert_eq!(response.id, "chat-local"); - assert_eq!(response.model, "mock-model"); - assert_eq!(response.message.content, "Use an index."); - assert_eq!(response.usage.unwrap().total_tokens, 7); - assert_eq!(calls.load(Ordering::SeqCst), 1); - - let seen = daemon.seen(); - assert_eq!(seen.len(), 1, "{seen:?}"); - let request = &seen[0]; - assert_eq!(request.path, "/v1/chat/completions"); - assert_eq!( - request.header("authorization"), - Some(redrob_engine_client::authorization("s3cret").as_str()) - ); - assert_eq!(request.header("x-redrob-app"), Some("query")); - let session = request.header("x-redrob-session").unwrap(); - assert!(Uuid::parse_str(session).is_ok(), "{session}"); - assert!( - request - .header("content-type") - .is_some_and(|value| value.starts_with("application/json")) - ); - // No console key travels on this path: the daemon's Basic secret is the only credential. - assert!( - !request - .header("authorization") - .unwrap() - .starts_with("Bearer") - ); - - let body: Value = serde_json::from_str(&request.body).unwrap(); - assert_eq!(body["model"], "auto"); - assert_eq!(body["messages"][0]["role"], "user"); - assert_eq!(body["messages"][0]["content"], "show indexes"); - assert_eq!(body["temperature"], 0.2); - assert_eq!(body["maxTokens"], 128); - } - - #[tokio::test] - async fn a_401_against_a_stale_secret_re_reads_the_record_once_and_retries() { - let daemon = FakeDaemon::start("fresh", |_| completion("chat-fresh", "ok")); - // The record in hand names the secret of a daemon that has since restarted. - let (resolver, calls) = scripted(vec![ - Ok(daemon.record_with_secret("stale")), - Ok(daemon.record()), - ]); - let client = AiClient::with_resolver(Arc::new(MemorySecretStore::default()), resolver); - - let response = client.chat(test_chat_request()).await.unwrap(); - assert_eq!(response.id, "chat-fresh"); - assert_eq!(calls.load(Ordering::SeqCst), 2); - let seen = daemon.seen(); - assert_eq!(seen.len(), 2, "{seen:?}"); - assert_eq!( - seen[0].header("authorization"), - Some(redrob_engine_client::authorization("stale").as_str()) - ); - assert_eq!( - seen[1].header("authorization"), - Some(redrob_engine_client::authorization("fresh").as_str()) - ); - - // The fresh record is kept: the next request needs no lookup. - client.chat(test_chat_request()).await.unwrap(); - assert_eq!(calls.load(Ordering::SeqCst), 2); - assert_eq!(daemon.seen().len(), 3); - } - - #[tokio::test] - async fn a_401_that_persists_is_reported_not_retried_forever() { - let daemon = FakeDaemon::start("fresh", |_| completion("never", "no")); - let (resolver, calls) = scripted(vec![Ok(daemon.record_with_secret("stale"))]); - let client = AiClient::with_resolver(Arc::new(MemorySecretStore::default()), resolver); - - assert!(matches!( - client.chat(test_chat_request()).await, - Err(DataError::AiAuthentication) - )); - assert_eq!(calls.load(Ordering::SeqCst), 2); - assert_eq!(daemon.seen().len(), 2); - } - - #[tokio::test] - async fn the_daemon_without_a_console_key_is_told_apart_by_its_code() { - let daemon = FakeDaemon::start("s", |_| { - reply( - 401, - json!({"error": {"message": "로그인이 필요합니다", "code": "engine_not_authenticated"}}) - .to_string(), - ) - }); - let (client, calls) = client_for(&daemon); - - assert!(matches!( - client.chat(test_chat_request()).await, - Err(DataError::AiAuthentication) - )); - // Not a stale secret: no record re-read, no second request. - assert_eq!(calls.load(Ordering::SeqCst), 1); - assert_eq!(daemon.seen().len(), 1); - } - - #[tokio::test] - async fn a_missing_engine_renders_the_install_sentence_with_the_launcher_link() { - let (resolver, calls) = scripted(vec![Err(Unavailable::NotInstalled)]); - let client = AiClient::with_resolver(Arc::new(MemorySecretStore::default()), resolver); - - let error = client.chat(test_chat_request()).await.unwrap_err(); - assert_eq!( - error.to_string(), - "Redrob Code is not installed. Install it with Redrob Launcher. https://github.com/redrob-labs/redrob-launcher/releases/latest" - ); - assert!(matches!(error, DataError::EngineUnavailable(_))); - assert_eq!( - calls.load(Ordering::SeqCst), - 1, - "no retry against a missing engine" - ); - assert_eq!( - client.account_status().await.unwrap_err().to_string(), - format!("{NOT_INSTALLED_MESSAGE} {INSTALL_URL}") - ); - - let (resolver, _) = scripted(vec![Err(Unavailable::StartFailed("port busy".into()))]); - let client = AiClient::with_resolver(Arc::new(MemorySecretStore::default()), resolver); - assert_eq!( - client - .chat(test_chat_request()) - .await - .unwrap_err() - .to_string(), - "The Redrob Code engine could not be started: port busy" - ); - } - - /// The real lookup against a real (empty) environment: `REDROB_CODE_BIN` names a file that does - /// not exist and `REDROB_RUN_DIR` an empty directory. The process environment is shared and the - /// crate forbids `unsafe`, so the check runs in a child test process with that environment. - #[test] - fn not_installed_in_a_real_environment_reports_the_install_sentence() { - let dir = tempfile::tempdir().unwrap(); - let output = std::process::Command::new(std::env::current_exe().unwrap()) - .args([ - "--exact", - "ai::tests::child_without_an_engine_sees_the_install_sentence", - "--ignored", - "--nocapture", - ]) - .env("REDROB_CODE_BIN", dir.path().join("missing-redrob")) - .env("REDROB_RUN_DIR", dir.path()) - .output() - .unwrap(); - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - assert!(output.status.success(), "{stdout}\n{stderr}"); - assert!( - stdout.contains("test result: ok. 1 passed"), - "{stdout}\n{stderr}" - ); - } - - #[tokio::test] - #[ignore = "run by not_installed_in_a_real_environment_reports_the_install_sentence"] - async fn child_without_an_engine_sees_the_install_sentence() { - assert!(std::env::var_os("REDROB_CODE_BIN").is_some()); - let client = AiClient::new(Arc::new(MemorySecretStore::default())); - let error = client.chat(test_chat_request()).await.unwrap_err(); - assert_eq!( - error.to_string(), - format!("{NOT_INSTALLED_MESSAGE} {INSTALL_URL}") - ); - } - - #[tokio::test] - async fn sign_in_puts_the_key_to_the_daemon_and_status_reads_the_provider_list() { - let daemon = FakeDaemon::start("s", |request| { - match (request.method.as_str(), request.path.as_str()) { - ("PUT", "/auth/redrob") => { - let body: Value = serde_json::from_str(&request.body).unwrap(); - assert_eq!(body, json!({"type": "api", "key": "rrk_test_key"})); - reply(200, "true") - } - ("GET", "/provider") => reply( - 200, - json!({"all": [{"id": "openai"}, {"id": "redrob", "source": "launcher"}]}) - .to_string(), - ), - other => panic!("unexpected {other:?}"), - } - }); - let (client, _) = client_for(&daemon); - - client.sign_in("rrk_test_key").await.unwrap(); - assert!(matches!( - client.sign_in(" ").await, - Err(DataError::SecretUnavailable) - )); - assert_eq!( - client.account_status().await.unwrap(), - AiKeyStatus { - source: AiKeySource::Launcher - } - ); - let seen = daemon.seen(); - assert_eq!(seen.len(), 2, "{seen:?}"); - assert_eq!( - (seen[0].method.as_str(), seen[0].path.as_str()), - ("PUT", "/auth/redrob") - ); - assert_eq!(seen[0].header("x-redrob-app"), Some("query")); - assert_eq!( - (seen[1].method.as_str(), seen[1].path.as_str()), - ("GET", "/provider") - ); - assert!(!seen[1].body.contains("rrk_test_key")); - } - - #[tokio::test] - async fn the_engine_refusing_a_key_is_reported_without_echoing_it() { - let daemon = FakeDaemon::start("s", |_| { - reply( - 400, - json!({"error": {"message": "rrk_bad echoed"}}).to_string(), - ) - }); - let (client, _) = client_for(&daemon); - let error = client.sign_in("rrk_bad").await.unwrap_err(); - assert!( - matches!(error, DataError::AiRejected(ref message) if !message.contains("rrk_bad")), - "{error}" - ); - } - - #[tokio::test] - async fn a_legacy_keyring_key_is_handed_to_the_engine_once_and_deleted() { - let daemon = FakeDaemon::start("s", |request| match request.path.as_str() { - "/auth/redrob" => reply(200, "true"), - _ => completion("chat-after-migration", "ok"), - }); - let secrets = Arc::new(MemorySecretStore::default()); - secrets.set(AI_SECRET_ID, "rrk_legacy").unwrap(); - let (resolver, _) = scripted(vec![Ok(daemon.record())]); - let client = - AiClient::with_resolver(Arc::clone(&secrets) as Arc, resolver); - - client.chat(test_chat_request()).await.unwrap(); - let seen = daemon.seen(); - assert_eq!(seen.len(), 2, "{seen:?}"); - assert_eq!( - (seen[0].method.as_str(), seen[0].path.as_str()), - ("PUT", "/auth/redrob") - ); - assert_eq!( - serde_json::from_str::(&seen[0].body).unwrap(), - json!({"type": "api", "key": "rrk_legacy"}) - ); - assert_eq!(seen[0].header("x-redrob-app"), Some("query")); - assert_eq!(seen[1].path, "/v1/chat/completions"); - assert!(!seen[1].body.contains("rrk_legacy")); - assert!( - secrets.get(AI_SECRET_ID).unwrap().is_none(), - "the migrated key is gone from the keyring" - ); - - // A second chat migrates nothing: the record is cached and the slot is empty. - client.chat(test_chat_request()).await.unwrap(); - assert_eq!(daemon.seen().len(), 3); - } - - #[test] - fn the_provider_list_tells_signed_in_from_public_and_absent() { - assert_eq!( - AiKeyStatus::from_provider_list(&json!({"all": []})), - AiKeyStatus::signed_out() - ); - assert_eq!( - AiKeyStatus::from_provider_list( - &json!({"all": [{"id": "redrob", "options": {"apiKey": "public"}}]}) - ), - AiKeyStatus::signed_out() - ); - assert_eq!( - AiKeyStatus::from_provider_list( - &json!({"all": [{"id": "redrob", "source": "config"}]}) - ), - AiKeyStatus::signed_out(), - "a config entry without a key is not a sign-in" - ); - assert_eq!( - AiKeyStatus::from_provider_list( - &json!({"all": [{"id": "redrob", "source": "launcher"}]}) - ) - .source, - AiKeySource::Launcher - ); - assert_eq!( - AiKeyStatus::from_provider_list( - &json!({"all": [{"id": "openai"}, {"id": "redrob", "source": "api", "key": "rrk_x"}]}) - ) - .source, - AiKeySource::Engine - ); - assert_eq!( - AiKeyStatus::from_provider_list(&json!({"all": [{"id": "redrob", "key": "rrk_x"}]})) - .source, - AiKeySource::Engine - ); - assert_eq!( - serde_json::to_value(AiKeyStatus::signed_out()).unwrap(), - json!({"source": "none"}) - ); - } - - #[tokio::test] - async fn ai_client_rejects_declared_oversized_response() { - let daemon = FakeDaemon::start("s", |_| Answer { - status: 200, - body: String::new(), - declared_length: Some(2 * 1024 * 1024 + 1), - }); - let (client, _) = client_for(&daemon); - - assert!(matches!( - client.chat(test_chat_request()).await, - Err(DataError::AiInvalidResponse) - )); - assert_eq!(daemon.seen().len(), 1); - } - - #[test] - fn the_daemon_sign_in_code_is_read_from_the_error_code_not_the_message() { - assert!(names_engine_not_authenticated( - br#"{"error":{"message":"localized","code":"engine_not_authenticated"}}"# - )); - assert!(!names_engine_not_authenticated( - br#"{"error":{"message":"engine_not_authenticated"}}"# - )); - assert!(!names_engine_not_authenticated(b"not json")); - } - - #[test] - fn parses_openai_response() { - let response = parse_response( - br#"{ - "id":"chat-1","model":"auto", - "choices":[{"message":{"role":"assistant","content":"Use an index."}}], - "usage":{"prompt_tokens":5,"completion_tokens":4,"total_tokens":9} - }"#, - ) - .unwrap(); - assert_eq!(response.message.content, "Use an index."); - assert_eq!(response.usage.unwrap().total_tokens, 9); - } - - #[test] - fn rejects_malformed_or_empty_responses() { - assert!(matches!( - parse_response(br#"{"id":"x","model":"m","choices":[]}"#), - Err(DataError::AiInvalidResponse) - )); - assert!(matches!( - parse_response(b"not json"), - Err(DataError::AiInvalidResponse) - )); - } - - #[test] - fn classifies_auth_client_and_retryable_errors() { - assert!(matches!( - classify_status(StatusCode::UNAUTHORIZED), - DataError::AiAuthentication - )); - assert!(matches!( - classify_status(StatusCode::BAD_REQUEST), - DataError::AiRejected(_) - )); - assert!(matches!( - classify_status(StatusCode::SERVICE_UNAVAILABLE), - DataError::AiUnavailable - )); - assert!(retryable_status(StatusCode::TOO_MANY_REQUESTS)); - assert!(!retryable_status(StatusCode::BAD_REQUEST)); - } - - #[test] - fn builds_fixed_chat_request_from_minimal_sql_context() { - let input = AiAssistantRequest { - prompt: "write the customer query".to_owned(), - active_query: Some("SELECT id FROM customers;".to_owned()), - database_kind: DatabaseKind::SQLite, - query_language: QueryLanguage::Sql, - }; - - let request = build_assistant_chat_request(&input).unwrap(); - assert_eq!(request.model, "auto"); - assert_eq!(request.temperature, Some(0.2)); - assert_eq!(request.max_tokens, None); - assert_eq!(request.messages.len(), 2); - assert_eq!(request.messages[0].role, AiRole::System); - assert!(request.messages[0].content.contains("SQLite SQL")); - assert!( - request.messages[0] - .content - .contains("only the user's prompt and optional active query") - ); - assert!(request.messages[0].content.contains( - "No database result rows or database credentials are automatically attached" - )); - assert!(!request.messages[0].content.contains(&input.prompt)); - assert!(!request.messages[0].content.contains("SELECT id")); - assert_eq!(request.messages[1].role, AiRole::User); - assert!(request.messages[1].content.contains(&input.prompt)); - assert!( - request.messages[1] - .content - .contains("SELECT id FROM customers;") - ); - } - - #[test] - fn builds_mongo_instruction_and_rejects_mismatched_language() { - let mongo = AiAssistantRequest { - prompt: "find customers".to_owned(), - active_query: None, - database_kind: DatabaseKind::MongoDb, - query_language: QueryLanguage::MongoJson, - }; - let request = build_assistant_chat_request(&mongo).unwrap(); - assert!(request.messages[0].content.contains("fenced mql or json")); - assert!(request.messages[1].content.contains("(none supplied)")); - - let mismatched = AiAssistantRequest { - query_language: QueryLanguage::Sql, - ..mongo - }; - assert!(matches!( - build_assistant_chat_request(&mismatched), - Err(DataError::AiRejected(message)) if message.contains("does not match") - )); - - let sql_server = AiAssistantRequest { - prompt: "write a query".to_owned(), - active_query: None, - database_kind: DatabaseKind::SqlServer, - query_language: QueryLanguage::Sql, - }; - assert!(matches!( - build_assistant_chat_request(&sql_server), - Err(DataError::AiRejected(message)) - if message.contains("no connector or connection profile") - )); - } - - #[test] - fn enforces_context_limits_and_rejects_extra_renderer_fields() { - let oversized_prompt = AiAssistantRequest { - prompt: "x".repeat(MAX_AI_PROMPT_BYTES + 1), - active_query: None, - database_kind: DatabaseKind::PostgreSql, - query_language: QueryLanguage::Sql, - }; - assert!(matches!( - build_assistant_chat_request(&oversized_prompt), - Err(DataError::AiRejected(message)) if message.contains("prompt exceeds") - )); - - let oversized_query = AiAssistantRequest { - prompt: "explain".to_owned(), - active_query: Some("q".repeat(MAX_AI_ACTIVE_QUERY_BYTES + 1)), - database_kind: DatabaseKind::PostgreSql, - query_language: QueryLanguage::Sql, - }; - assert!(matches!( - build_assistant_chat_request(&oversized_query), - Err(DataError::AiRejected(message)) if message.contains("active query exceeds") - )); - - for extra in ["messages", "resultRows", "credentials", "model", "baseUrl"] { - let mut value = serde_json::json!({ - "prompt": "help", - "databaseKind": "postgre_sql", - "queryLanguage": "sql" - }); - value - .as_object_mut() - .unwrap() - .insert(extra.to_owned(), serde_json::json!([])); - assert!(serde_json::from_value::(value).is_err()); - } - } -} diff --git a/crates/redrob-query-core/Cargo.toml b/crates/redrob-query-core/Cargo.toml new file mode 100644 index 0000000..033b226 --- /dev/null +++ b/crates/redrob-query-core/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "redrob-query-core" +version.workspace = true +edition.workspace = true +license.workspace = true +rust-version.workspace = true +authors.workspace = true +repository.workspace = true +homepage.workspace = true +description = "Redrob Query engine: database connectivity, metadata, bounded querying, reviewed edits and the console AI transport" +readme = "../../README.md" +publish = false + +[dependencies] +base64 = { workspace = true } +bigdecimal = { workspace = true } +chrono = { workspace = true } +fs2 = { workspace = true } +futures-util = { workspace = true } +keyring = { workspace = true } +mongodb = { workspace = true } +reqwest = { workspace = true } +secrecy = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sqlx = { workspace = true } +tempfile = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +url = { workspace = true } +uuid = { workspace = true } + +[lints] +workspace = true diff --git a/crates/redrob-query-core/src/account.rs b/crates/redrob-query-core/src/account.rs new file mode 100644 index 0000000..5eca982 --- /dev/null +++ b/crates/redrob-query-core/src/account.rs @@ -0,0 +1,209 @@ +//! The Redrob account this app spends: the launcher's shared `account.json`, read on demand. +//! +//! Redrob Launcher signs a computer in once and writes the file described in its +//! `docs/shared-account.md`; every Redrob app reads it instead of running a sign-in of its own. +//! The rules for readers, as that contract states them, are the rules here: +//! +//! 1. Read, never write. Only the launcher writes the shared file. +//! 2. Read it when it is needed, not once at start: the person may sign in or out while the app +//! is open. A missing file, unreadable JSON or an empty `apiKey` all mean "not signed in". +//! 3. Never log, display or copy the key. Only `accountName` and `apiKeyName` are shown. +//! 4. The app's own key wins: `REDROB_API_KEY` in the environment, then an `account.json` of the +//! same shape in this app's own config directory, then the shared file. + +use std::{ + ffi::OsString, + fmt, + path::{Path, PathBuf}, + time::SystemTime, +}; + +use secrecy::{ExposeSecret, SecretString}; +use serde::{Deserialize, Serialize}; + +/// Where a key is spent when the file names no `apiBaseUrl`. +pub const DEFAULT_API_BASE_URL: &str = "https://console.redrob.ai/api/backend/v1"; +/// Replaces the shared directory on every platform (tests, portable setups). +pub const SHARED_DIR_ENV: &str = "REDROB_SHARED_DIR"; +/// A key exported in a terminal wins over every file (rule 4 of the contract). +pub const OWN_KEY_ENV: &str = "REDROB_API_KEY"; +/// Where a person signs in: Redrob Launcher's latest release. +pub const LAUNCHER_URL: &str = "https://github.com/redrob-labs/redrob-launcher/releases/latest"; +const FILE_NAME: &str = "account.json"; +/// The file is a few hundred bytes; anything larger is not the launcher's. +const MAX_FILE_BYTES: u64 = 64 * 1024; + +/// Which file (or variable) the account in use came from. Shown in the status line; never the key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AccountSource { + /// `REDROB_API_KEY` in this process's environment. + Environment, + /// This app's own `account.json`, written by the launcher's Connect or by hand. + Own, + /// The launcher's shared `account.json`. + Launcher, +} + +/// A signed-in Redrob account. `Debug` and `Display` never show the key. +#[derive(Clone)] +pub struct SharedAccount { + pub api_key: SecretString, + pub api_key_name: String, + pub account_name: String, + pub api_base_url: String, + pub source: AccountSource, +} + +impl fmt::Debug for SharedAccount { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("SharedAccount") + .field("api_key", &"[redacted]") + .field("api_key_name", &self.api_key_name) + .field("account_name", &self.account_name) + .field("api_base_url", &self.api_base_url) + .field("source", &self.source) + .finish() + } +} + +impl SharedAccount { + /// `/chat/completions`, the OpenAI-compatible route the console serves. + #[must_use] + pub fn chat_completions_url(&self) -> String { + format!( + "{}/chat/completions", + self.api_base_url.trim_end_matches('/') + ) + } + + /// What the status line shows: the account, and whose key it is. Never the key. + #[must_use] + pub fn label(&self) -> String { + match (self.account_name.trim(), self.api_key_name.trim()) { + ("", "") => "Signed in".to_owned(), + (account, "") => format!("Signed in as {account}"), + ("", key) => format!("Signed in with {key}"), + (account, key) => format!("{account} ({key})"), + } + } +} + +/// The wire shape of `account.json`. Unknown fields are ignored so the launcher can grow it. +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +struct AccountFile { + #[serde(default)] + api_key: String, + #[serde(default)] + api_key_name: String, + #[serde(default)] + account_name: String, + #[serde(default)] + api_base_url: String, +} + +/// The shared directory for `os` given an environment: `REDROB_SHARED_DIR`, else the platform +/// convention (Linux `$XDG_CONFIG_HOME/redrob-shared`, macOS +/// `~/Library/Application Support/ai.redrob.shared`, Windows `%APPDATA%\Redrob\Shared`). +#[must_use] +pub fn shared_dir_with(os: &str, env: impl Fn(&str) -> Option) -> Option { + if let Some(dir) = env(SHARED_DIR_ENV).filter(|d| !d.is_empty()) { + return Some(PathBuf::from(dir)); + } + let home = env("HOME").filter(|h| !h.is_empty()).map(PathBuf::from); + match os { + "macos" => home.map(|h| h.join("Library/Application Support/ai.redrob.shared")), + "windows" => env("APPDATA") + .filter(|a| !a.is_empty()) + .map(|a| PathBuf::from(a).join("Redrob").join("Shared")), + _ => env("XDG_CONFIG_HOME") + .filter(|x| !x.is_empty()) + .map(PathBuf::from) + .or_else(|| home.map(|h| h.join(".config"))) + .map(|c| c.join("redrob-shared")), + } +} + +/// The shared `account.json` on this machine, or `None` when no home is known. +#[must_use] +pub fn shared_account_path() -> Option { + shared_dir_with(std::env::consts::OS, |key| std::env::var_os(key)).map(|d| d.join(FILE_NAME)) +} + +/// Reads one `account.json`. `None` for a missing file, unreadable JSON or an empty key. +#[must_use] +pub fn read_account_file(path: &Path, source: AccountSource) -> Option { + let meta = std::fs::metadata(path).ok()?; + if !meta.is_file() || meta.len() > MAX_FILE_BYTES { + return None; + } + let bytes = std::fs::read(path).ok()?; + let file: AccountFile = serde_json::from_slice(&bytes).ok()?; + let api_key = file.api_key.trim(); + if api_key.is_empty() { + return None; + } + let api_base_url = match file.api_base_url.trim() { + "" => DEFAULT_API_BASE_URL.to_owned(), + url => url.trim_end_matches('/').to_owned(), + }; + Some(SharedAccount { + api_key: SecretString::from(api_key.to_owned()), + api_key_name: file.api_key_name.trim().to_owned(), + account_name: file.account_name.trim().to_owned(), + api_base_url, + source, + }) +} + +/// The account in force right now, by the contract's precedence: `REDROB_API_KEY`, then +/// `own_file` (this app's own `account.json`, when it has one), then the launcher's shared file. +#[must_use] +pub fn current_account(own_file: Option<&Path>) -> Option { + if let Some(key) = std::env::var(OWN_KEY_ENV) + .ok() + .map(|k| k.trim().to_owned()) + .filter(|k| !k.is_empty()) + { + let api_base_url = std::env::var("REDROB_CONSOLE_API") + .ok() + .map(|u| u.trim().trim_end_matches('/').to_owned()) + .filter(|u| !u.is_empty()) + .unwrap_or_else(|| DEFAULT_API_BASE_URL.to_owned()); + return Some(SharedAccount { + api_key: SecretString::from(key), + api_key_name: String::new(), + account_name: String::new(), + api_base_url, + source: AccountSource::Environment, + }); + } + if let Some(account) = own_file.and_then(|p| read_account_file(p, AccountSource::Own)) { + return Some(account); + } + shared_account_path().and_then(|p| read_account_file(&p, AccountSource::Launcher)) +} + +/// A cheap fingerprint of the account files, so a caller can notice a sign-in or sign-out +/// without parsing: the modification time and size of each file that exists. +#[must_use] +pub fn account_files_stamp(own_file: Option<&Path>) -> Vec<(PathBuf, Option<(SystemTime, u64)>)> { + own_file + .map(Path::to_path_buf) + .into_iter() + .chain(shared_account_path()) + .map(|path| { + let stamp = std::fs::metadata(&path) + .ok() + .and_then(|m| m.modified().ok().map(|t| (t, m.len()))); + (path, stamp) + }) + .collect() +} + +/// `true` when the key is non-empty; a convenience for callers that only need the yes/no. +#[must_use] +pub fn has_key(account: Option<&SharedAccount>) -> bool { + account.is_some_and(|a| !a.api_key.expose_secret().is_empty()) +} diff --git a/crates/redrob-query-core/src/ai.rs b/crates/redrob-query-core/src/ai.rs new file mode 100644 index 0000000..abbaf75 --- /dev/null +++ b/crates/redrob-query-core/src/ai.rs @@ -0,0 +1,953 @@ +//! Redrob chat-completion client, straight to the Redrob console with the shared account. +//! +//! The launcher signs the computer in once and writes `account.json` (`crate::account`); this +//! client reads that account when a request is made and posts to `{apiBaseUrl}/chat/completions` +//! with `Authorization: Bearer ` and model `auto`. There is no daemon in between any more: +//! the Redrob Code engine path answered `auto` with "model not available" and swallowed the +//! console's error body, so a person saw neither the real reason nor a way to sign in. +//! +//! The console's own `error.message` is surfaced verbatim (bounded, with the key never echoed by +//! construction: it travels only in the header). A missing account is `DataError::AiAuthentication`, +//! which the shell renders as "sign in with Redrob Launcher". + +use std::{ + path::{Path, PathBuf}, + sync::Arc, + time::Duration, +}; + +use futures_util::StreamExt; +use reqwest::{Client, StatusCode, header::HeaderValue}; +use secrecy::ExposeSecret; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tokio::time::sleep; +use uuid::Uuid; + +use crate::{ + AiAssistantRequest, AiChatRequest, AiChatResponse, AiMessage, AiRole, AiUsage, DataError, + DatabaseKind, QueryLanguage, Result, + account::{self, AccountSource, SharedAccount}, +}; + +/// Where a person gets Redrob Launcher, which is where signing in happens. +pub const INSTALL_URL: &str = account::LAUNCHER_URL; +/// The sentence shown when no account is signed in. The link is [`INSTALL_URL`]. +pub const NOT_SIGNED_IN_MESSAGE: &str = + "Not signed in. Sign in with Redrob Launcher and this app will use that account."; +/// The keyring slot an earlier version of this app saved its own console key in. Profile storage +/// still reserves the id; nothing reads it any more. +pub const AI_SECRET_ID: Uuid = Uuid::from_u128(0x7265_6472_6f62_4169_0000_0000_0000_0001); +pub const MAX_AI_PROMPT_BYTES: usize = 8 * 1024; +pub const MAX_AI_ACTIVE_QUERY_BYTES: usize = 64 * 1024; +const MAX_ATTEMPTS: usize = 3; +const AI_TEMPERATURE: f32 = 0.2; +const MAX_ERROR_BODY_BYTES: usize = 64 * 1024; +const MAX_RESPONSE_BYTES: usize = 2 * 1024 * 1024; +/// How much of the console's `error.message` is shown; longer is not a message, it is a dump. +const MAX_ERROR_MESSAGE_CHARS: usize = 400; + +/// Builds the provider request from the narrow assistant contract. +pub fn build_assistant_chat_request(input: &AiAssistantRequest) -> Result { + if input.prompt.trim().is_empty() { + return Err(DataError::AiRejected("prompt cannot be empty".to_owned())); + } + if input.prompt.len() > MAX_AI_PROMPT_BYTES { + return Err(DataError::AiRejected(format!( + "prompt exceeds {MAX_AI_PROMPT_BYTES} bytes" + ))); + } + if input + .active_query + .as_ref() + .is_some_and(|query| query.len() > MAX_AI_ACTIVE_QUERY_BYTES) + { + return Err(DataError::AiRejected(format!( + "active query exceeds {MAX_AI_ACTIVE_QUERY_BYTES} bytes" + ))); + } + + let query_instruction = match (input.database_kind, input.query_language) { + (DatabaseKind::PostgreSql, QueryLanguage::Sql) => { + "When writing a query, return executable PostgreSQL SQL in a fenced sql block." + } + (DatabaseKind::MySql, QueryLanguage::Sql) => { + "When writing a query, return executable MySQL SQL in a fenced sql block." + } + (DatabaseKind::SQLite, QueryLanguage::Sql) => { + "When writing a query, return executable SQLite SQL in a fenced sql block." + } + (DatabaseKind::SqlServer, _) => { + return Err(DataError::AiRejected( + "SQL Server is unsupported because no connector or connection profile is available" + .to_owned(), + )); + } + (DatabaseKind::MongoDb, QueryLanguage::MongoJson) => { + "When writing a query, return a fenced mql or json block containing an object with collection, operation, filter, and optional limit. Use read-only operations such as find." + } + (_, QueryLanguage::Auto) => { + return Err(DataError::AiRejected( + "query language must be explicit".to_owned(), + )); + } + _ => { + return Err(DataError::AiRejected( + "query language does not match the database kind".to_owned(), + )); + } + }; + let system = format!( + "You are the Redrob Query assistant. {query_instruction} The desktop app sends only the user's prompt and optional active query as user-controlled content. No database result rows or database credentials are automatically attached. Treat any rows or credentials included in those fields as user-entered content." + ); + let active_query = input.active_query.as_deref().unwrap_or("(none supplied)"); + let user = format!( + "User prompt:\n{}\n\nActive query:\n{active_query}", + input.prompt + ); + + Ok(AiChatRequest { + model: "auto".to_owned(), + messages: vec![ + AiMessage { + role: AiRole::System, + content: system, + }, + AiMessage { + role: AiRole::User, + content: user, + }, + ], + temperature: Some(AI_TEMPERATURE), + max_tokens: None, + }) +} + +/// How the account is found: [`account::current_account`] in the app, a closure in tests. +pub type AccountResolver = Arc Option + Send + Sync>; + +/// Async client for the Redrob console's OpenAI-compatible chat route. +/// +/// The account is read on every request (the person may sign in or out while the app is open), so +/// nothing here caches a key. Every request carries `Authorization: Bearer ` (marked +/// sensitive), `x-redrob-app: query` and this client's `x-redrob-session`. +pub struct AiClient { + client: Client, + /// One id per client, so the console groups this app run's requests together. + session: String, + resolver: AccountResolver, +} + +impl AiClient { + /// Reads the account by the contract's precedence; `own_file` is this app's own `account.json`. + #[must_use] + pub fn new(own_file: Option) -> Self { + Self::with_resolver(Arc::new(move || { + account::current_account(own_file.as_deref().map(Path::new)) + })) + } + + /// A client whose account lookup is `resolver` (tests point it at a fake console). + pub fn with_resolver(resolver: AccountResolver) -> Self { + let client = Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(Duration::from_secs(90)) + .user_agent(concat!("Redrob-Query/", env!("CARGO_PKG_VERSION"))) + .build() + .expect("static HTTP client configuration is valid"); + Self { + client, + session: Uuid::new_v4().to_string(), + resolver, + } + } + + /// The account in force, read fresh. `None` means not signed in. + #[must_use] + pub fn account(&self) -> Option { + (self.resolver)() + } + + /// Whether an account is signed in, and whose it is. Never a key. + #[must_use] + pub fn account_status(&self) -> AiKeyStatus { + self.account() + .as_ref() + .map_or_else(AiKeyStatus::signed_out, AiKeyStatus::from_account) + } + + pub async fn chat(&self, request: AiChatRequest) -> Result { + validate_request(&request)?; + let account = self.account().ok_or(DataError::AiAuthentication)?; + let url = account.chat_completions_url(); + let wire = WireRequest::from(&request); + let mut last_error = DataError::AiUnavailable; + + for attempt in 0..MAX_ATTEMPTS { + let response = self + .authorised(&account, self.client.post(&url).json(&wire)) + .send() + .await; + match response { + Ok(response) if response.status().is_success() => { + let bytes = bounded_response(response, MAX_RESPONSE_BYTES).await?; + return parse_response(&bytes); + } + Ok(response) => { + let status = response.status(); + let body = bounded_response(response, MAX_ERROR_BODY_BYTES) + .await + .unwrap_or_default(); + last_error = classify_failure(status, &body); + if !retryable_status(status) || attempt + 1 == MAX_ATTEMPTS { + return Err(last_error); + } + } + Err(error) => { + last_error = transport_error(&error); + if attempt + 1 == MAX_ATTEMPTS { + return Err(last_error); + } + } + } + sleep(Duration::from_millis(200 * (1_u64 << attempt))).await; + } + Err(last_error) + } + + fn authorised( + &self, + account: &SharedAccount, + builder: reqwest::RequestBuilder, + ) -> reqwest::RequestBuilder { + let mut bearer = + HeaderValue::from_str(&format!("Bearer {}", account.api_key.expose_secret())) + .unwrap_or_else(|_| HeaderValue::from_static("Bearer")); + bearer.set_sensitive(true); + builder + .header(reqwest::header::AUTHORIZATION, bearer) + .header("x-redrob-app", "query") + .header("x-redrob-session", &self.session) + } +} + +fn transport_error(error: &reqwest::Error) -> DataError { + if error.is_timeout() { + DataError::Timeout(Duration::from_secs(90)) + } else { + DataError::AiUnavailable + } +} + +/// The console's `error.message`, when its body carries one, trimmed to a sentence or two. +fn error_message(body: &[u8]) -> Option { + let value: Value = serde_json::from_slice(body).ok()?; + let message = value + .pointer("/error/message") + .or_else(|| value.get("message")) + .and_then(Value::as_str)? + .trim(); + if message.is_empty() { + return None; + } + let mut text: String = message.chars().take(MAX_ERROR_MESSAGE_CHARS).collect(); + if text.len() < message.len() { + text.push('…'); + } + Some(text) +} + +/// Whether an account is signed in, and whose it is. Never a key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AiKeyStatus { + pub source: AiKeySource, + /// `accountName` and `apiKeyName` as the status line shows them; empty when signed out. + #[serde(default)] + pub label: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AiKeySource { + /// `REDROB_API_KEY` in the environment, or this app's own `account.json`. + Own, + /// Redrob Launcher's shared sign-in. Signing out happens there. + Launcher, + /// No account is signed in. + None, +} + +impl Default for AiKeyStatus { + fn default() -> Self { + Self::signed_out() + } +} + +impl AiKeyStatus { + #[must_use] + pub fn signed_out() -> Self { + Self { + source: AiKeySource::None, + label: String::new(), + } + } + + #[must_use] + pub fn from_account(account: &SharedAccount) -> Self { + Self { + source: match account.source { + AccountSource::Launcher => AiKeySource::Launcher, + AccountSource::Environment | AccountSource::Own => AiKeySource::Own, + }, + label: account.label(), + } + } + + #[must_use] + pub fn signed_in(&self) -> bool { + self.source != AiKeySource::None + } +} + +async fn bounded_response(response: reqwest::Response, limit: usize) -> Result> { + if response + .content_length() + .is_some_and(|length| length > u64::try_from(limit).unwrap_or(u64::MAX)) + { + return Err(DataError::AiInvalidResponse); + } + let mut body = Vec::with_capacity(response.content_length().map_or(0, |length| { + usize::try_from(length).unwrap_or(limit).min(limit) + })); + let mut stream = response.bytes_stream(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.map_err(|_| DataError::AiInvalidResponse)?; + if body.len().saturating_add(chunk.len()) > limit { + return Err(DataError::AiInvalidResponse); + } + body.extend_from_slice(&chunk); + } + Ok(body) +} + +fn validate_request(request: &AiChatRequest) -> Result<()> { + if request.messages.is_empty() || request.messages.len() > 100 { + return Err(DataError::AiRejected( + "provide between 1 and 100 messages".to_owned(), + )); + } + if request.model.trim().is_empty() || request.model.len() > 100 { + return Err(DataError::AiRejected("model is invalid".to_owned())); + } + if request + .messages + .iter() + .any(|message| message.content.len() > 100_000) + { + return Err(DataError::AiRejected("message is too large".to_owned())); + } + if request + .temperature + .is_some_and(|value| !(0.0..=2.0).contains(&value)) + { + return Err(DataError::AiRejected( + "temperature must be between 0 and 2".to_owned(), + )); + } + Ok(()) +} + +fn retryable_status(status: StatusCode) -> bool { + status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error() +} + +/// The console's own sentence when it gave one; the status class otherwise. +fn classify_failure(status: StatusCode, body: &[u8]) -> DataError { + match (status, error_message(body)) { + (StatusCode::UNAUTHORIZED | StatusCode::FORBIDDEN, _) => DataError::AiAuthentication, + (StatusCode::TOO_MANY_REQUESTS, _) => DataError::AiUnavailable, + (value, Some(message)) if value.is_server_error() => { + DataError::AiRejected(format!("the console answered HTTP {status}: {message}")) + } + (value, None) if value.is_server_error() => DataError::AiUnavailable, + (_, Some(message)) => DataError::AiRejected(message), + (_, None) => DataError::AiRejected(format!("service returned HTTP {status}")), + } +} + +/// The OpenAI-compatible request body: `snake_case` on the wire, whatever the models use in-app. +#[derive(Serialize)] +struct WireRequest<'a> { + model: &'a str, + messages: &'a [AiMessage], + #[serde(skip_serializing_if = "Option::is_none")] + temperature: Option, + #[serde(skip_serializing_if = "Option::is_none")] + max_tokens: Option, +} + +impl<'a> From<&'a AiChatRequest> for WireRequest<'a> { + fn from(request: &'a AiChatRequest) -> Self { + Self { + model: &request.model, + messages: &request.messages, + temperature: request.temperature, + max_tokens: request.max_tokens, + } + } +} + +#[derive(Deserialize)] +struct WireResponse { + #[serde(default)] + id: String, + #[serde(default)] + model: String, + choices: Vec, + usage: Option, +} + +#[derive(Deserialize)] +struct WireChoice { + message: WireMessage, +} + +#[derive(Deserialize)] +struct WireMessage { + role: String, + /// Some providers answer `null` content alongside tool calls; that is not an answer here. + #[serde(default)] + content: Option, +} + +#[derive(Deserialize)] +struct WireUsage { + #[serde(rename = "prompt_tokens", default)] + prompt: u32, + #[serde(rename = "completion_tokens", default)] + completion: u32, + #[serde(rename = "total_tokens", default)] + total: u32, +} + +fn parse_response(bytes: &[u8]) -> Result { + let wire: WireResponse = + serde_json::from_slice(bytes).map_err(|_| DataError::AiInvalidResponse)?; + let choice = wire + .choices + .into_iter() + .next() + .ok_or(DataError::AiInvalidResponse)?; + let content = choice.message.content.unwrap_or_default(); + if content.is_empty() { + return Err(DataError::AiInvalidResponse); + } + let role = match choice.message.role.as_str() { + "assistant" => AiRole::Assistant, + "system" => AiRole::System, + "user" => AiRole::User, + _ => return Err(DataError::AiInvalidResponse), + }; + Ok(AiChatResponse { + id: wire.id, + model: wire.model, + message: AiMessage { role, content }, + usage: wire.usage.map(|usage| AiUsage { + prompt_tokens: usage.prompt, + completion_tokens: usage.completion, + total_tokens: usage.total, + }), + }) +} + +#[cfg(test)] +mod tests { + use std::{ + collections::HashMap, + io::{BufRead, BufReader, Read, Write}, + net::{TcpListener, TcpStream}, + sync::{Arc, Mutex}, + thread, + time::Duration as StdDuration, + }; + + use secrecy::SecretString; + use serde_json::json; + + use super::*; + + /// One request as the fake console saw it. + #[derive(Clone, Debug)] + struct Seen { + method: String, + path: String, + headers: HashMap, + body: String, + } + + impl Seen { + fn header(&self, name: &str) -> Option<&str> { + self.headers + .get(&name.to_ascii_lowercase()) + .map(String::as_str) + } + } + + /// What the fake console answers with. `declared_length` lies in `Content-Length` when set. + struct Answer { + status: u16, + body: String, + declared_length: Option, + } + + fn reply(status: u16, body: impl Into) -> Answer { + Answer { + status, + body: body.into(), + declared_length: None, + } + } + + fn completion(id: &str, content: &str) -> Answer { + reply( + 200, + json!({ + "id": id, "model": "mock-model", + "choices": [{"message": {"role": "assistant", "content": content}}], + "usage": {"prompt_tokens": 4, "completion_tokens": 3, "total_tokens": 7} + }) + .to_string(), + ) + } + + /// A tiny stand-in for the console on 127.0.0.1: the wrong bearer is 401 everywhere, as the + /// real console does; everything else is up to `answer`. + struct FakeConsole { + base_url: String, + key: String, + seen: Arc>>, + } + + impl FakeConsole { + fn start(key: &str, answer: impl Fn(&Seen) -> Answer + Send + Sync + 'static) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + let seen = Arc::new(Mutex::new(Vec::new())); + let expected = format!("Bearer {key}"); + let log = Arc::clone(&seen); + thread::spawn(move || { + for stream in listener.incoming() { + let Ok(mut stream) = stream else { break }; + let Some(request) = read_request(&mut stream) else { + continue; + }; + log.lock().unwrap().push(request.clone()); + let answer = if request.header("authorization") == Some(expected.as_str()) { + answer(&request) + } else { + reply( + 401, + json!({"error": {"message": "invalid api key"}}).to_string(), + ) + }; + let length = answer.declared_length.unwrap_or(answer.body.len()); + let _ = write!( + stream, + "HTTP/1.1 {} X\r\nContent-Type: application/json\r\nContent-Length: {length}\r\nConnection: close\r\n\r\n{}", + answer.status, answer.body + ); + let _ = stream.flush(); + } + }); + Self { + base_url: format!("http://127.0.0.1:{port}/api/backend/v1"), + key: key.into(), + seen, + } + } + + fn account(&self) -> SharedAccount { + self.account_with_key(&self.key) + } + + fn account_with_key(&self, key: &str) -> SharedAccount { + SharedAccount { + api_key: SecretString::from(key.to_owned()), + api_key_name: "Redrob Launcher".to_owned(), + account_name: "Acme".to_owned(), + api_base_url: self.base_url.clone(), + source: AccountSource::Launcher, + } + } + + fn seen(&self) -> Vec { + self.seen.lock().unwrap().clone() + } + } + + fn read_request(stream: &mut TcpStream) -> Option { + stream + .set_read_timeout(Some(StdDuration::from_secs(5))) + .ok()?; + let mut reader = BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).ok()?; + let mut parts = line.split_whitespace(); + let method = parts.next()?.to_owned(); + let path = parts.next()?.to_owned(); + let mut headers = HashMap::new(); + loop { + let mut header = String::new(); + reader.read_line(&mut header).ok()?; + let header = header.trim_end(); + if header.is_empty() { + break; + } + let (name, value) = header.split_once(':')?; + headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_owned()); + } + let length: usize = headers + .get("content-length") + .and_then(|value| value.parse().ok()) + .unwrap_or(0); + let mut body = vec![0; length]; + reader.read_exact(&mut body).ok()?; + Some(Seen { + method, + path, + headers, + body: String::from_utf8_lossy(&body).into_owned(), + }) + } + + fn client_with(account: Option) -> AiClient { + AiClient::with_resolver(Arc::new(move || account.clone())) + } + + fn test_chat_request() -> AiChatRequest { + AiChatRequest { + model: "auto".to_owned(), + messages: vec![AiMessage { + role: AiRole::User, + content: "show indexes".to_owned(), + }], + temperature: Some(0.2), + max_tokens: Some(128), + } + } + + #[tokio::test] + async fn chat_hits_the_console_route_with_the_bearer_app_and_session() { + let console = FakeConsole::start("rk_s3cret", |request| { + assert_eq!(request.method, "POST"); + completion("chat-1", "Use an index.") + }); + let client = client_with(Some(console.account())); + + let response = client.chat(test_chat_request()).await.unwrap(); + assert_eq!(response.id, "chat-1"); + assert_eq!(response.model, "mock-model"); + assert_eq!(response.message.content, "Use an index."); + assert_eq!(response.usage.unwrap().total_tokens, 7); + + let seen = console.seen(); + assert_eq!(seen.len(), 1, "{seen:?}"); + let request = &seen[0]; + assert_eq!(request.path, "/api/backend/v1/chat/completions"); + assert_eq!(request.header("authorization"), Some("Bearer rk_s3cret")); + assert_eq!(request.header("x-redrob-app"), Some("query")); + let session = request.header("x-redrob-session").unwrap(); + assert!(Uuid::parse_str(session).is_ok(), "{session}"); + assert!( + request + .header("content-type") + .is_some_and(|value| value.starts_with("application/json")) + ); + + // The OpenAI-compatible body: snake_case, the key nowhere in it. + let body: Value = serde_json::from_str(&request.body).unwrap(); + assert_eq!(body["model"], "auto"); + assert_eq!(body["messages"][0]["role"], "user"); + assert_eq!(body["messages"][0]["content"], "show indexes"); + assert_eq!(body["temperature"], 0.2); + assert_eq!(body["max_tokens"], 128); + assert!(body.get("maxTokens").is_none()); + assert!(!request.body.contains("rk_s3cret")); + } + + #[tokio::test] + async fn the_account_is_read_on_every_request_so_a_sign_in_counts_at_once() { + let console = FakeConsole::start("rk_fresh", |_| completion("chat-fresh", "ok")); + let slot: Arc>> = Arc::new(Mutex::new(None)); + let read = Arc::clone(&slot); + let client = AiClient::with_resolver(Arc::new(move || read.lock().unwrap().clone())); + + assert!(matches!( + client.chat(test_chat_request()).await, + Err(DataError::AiAuthentication) + )); + assert_eq!(client.account_status(), AiKeyStatus::signed_out()); + assert!(console.seen().is_empty(), "no request without an account"); + + *slot.lock().unwrap() = Some(console.account()); + let response = client.chat(test_chat_request()).await.unwrap(); + assert_eq!(response.id, "chat-fresh"); + let status = client.account_status(); + assert_eq!(status.source, AiKeySource::Launcher); + assert_eq!(status.label, "Acme (Redrob Launcher)"); + assert!(status.signed_in()); + } + + #[tokio::test] + async fn a_401_is_not_signed_in_and_is_not_retried() { + let console = FakeConsole::start("rk_fresh", |_| completion("never", "no")); + let client = client_with(Some(console.account_with_key("rk_stale"))); + + assert!(matches!( + client.chat(test_chat_request()).await, + Err(DataError::AiAuthentication) + )); + assert_eq!(console.seen().len(), 1); + } + + #[tokio::test] + async fn the_console_error_message_is_surfaced_without_the_key() { + let console = FakeConsole::start("rk_s", |_| { + reply( + 400, + json!({"error": {"message": "model auto is not available for this workspace", "type": "invalid_request_error"}}) + .to_string(), + ) + }); + let client = client_with(Some(console.account())); + + let error = client.chat(test_chat_request()).await.unwrap_err(); + assert_eq!( + error.to_string(), + "AI request was rejected: model auto is not available for this workspace" + ); + assert!(!error.to_string().contains("rk_s")); + assert_eq!(console.seen().len(), 1, "a 400 is not retried"); + } + + #[tokio::test] + async fn a_server_error_is_retried_then_reported_with_its_message() { + let console = FakeConsole::start("rk_s", |_| { + reply( + 503, + json!({"error": {"message": "upstream provider timed out"}}).to_string(), + ) + }); + let client = client_with(Some(console.account())); + + let error = client.chat(test_chat_request()).await.unwrap_err(); + assert!( + matches!(error, DataError::AiRejected(ref message) if message.contains("HTTP 503") && message.contains("upstream provider timed out")), + "{error}" + ); + assert_eq!(console.seen().len(), MAX_ATTEMPTS); + } + + #[test] + fn a_missing_account_renders_the_sign_in_sentence_with_the_launcher_link() { + let client = client_with(None); + assert_eq!(client.account_status(), AiKeyStatus::signed_out()); + assert!(!client.account_status().signed_in()); + assert!(NOT_SIGNED_IN_MESSAGE.contains("Redrob Launcher")); + assert!(INSTALL_URL.starts_with("https://github.com/redrob-labs/redrob-launcher")); + assert_eq!( + serde_json::to_value(AiKeyStatus::signed_out()).unwrap(), + json!({"source": "none", "label": ""}) + ); + } + + #[tokio::test] + async fn ai_client_rejects_declared_oversized_response() { + let console = FakeConsole::start("rk_s", |_| Answer { + status: 200, + body: String::new(), + declared_length: Some(MAX_RESPONSE_BYTES + 1), + }); + let client = client_with(Some(console.account())); + + assert!(matches!( + client.chat(test_chat_request()).await, + Err(DataError::AiInvalidResponse) + )); + assert_eq!(console.seen().len(), 1); + } + + #[test] + fn the_error_message_is_read_from_the_body_and_bounded() { + assert_eq!( + error_message(br#"{"error":{"message":"localized","code":"x"}}"#).as_deref(), + Some("localized") + ); + assert_eq!( + error_message(br#"{"message":"flat"}"#).as_deref(), + Some("flat") + ); + assert_eq!(error_message(b"not json"), None); + assert_eq!(error_message(br#"{"error":{"message":" "}}"#), None); + let long = format!(r#"{{"error":{{"message":"{}"}}}}"#, "x".repeat(1000)); + let shown = error_message(long.as_bytes()).unwrap(); + assert_eq!(shown.chars().count(), MAX_ERROR_MESSAGE_CHARS + 1); + assert!(shown.ends_with('…')); + } + + #[test] + fn parses_openai_response() { + let response = parse_response( + br#"{ + "id":"chat-1","model":"auto", + "choices":[{"message":{"role":"assistant","content":"Use an index."}}], + "usage":{"prompt_tokens":5,"completion_tokens":4,"total_tokens":9} + }"#, + ) + .unwrap(); + assert_eq!(response.message.content, "Use an index."); + assert_eq!(response.usage.unwrap().total_tokens, 9); + } + + #[test] + fn rejects_malformed_or_empty_responses() { + assert!(matches!( + parse_response(br#"{"id":"x","model":"m","choices":[]}"#), + Err(DataError::AiInvalidResponse) + )); + assert!(matches!( + parse_response(br#"{"choices":[{"message":{"role":"assistant","content":null}}]}"#), + Err(DataError::AiInvalidResponse) + )); + assert!(matches!( + parse_response(b"not json"), + Err(DataError::AiInvalidResponse) + )); + } + + #[test] + fn classifies_auth_client_and_retryable_errors() { + assert!(matches!( + classify_failure(StatusCode::UNAUTHORIZED, b""), + DataError::AiAuthentication + )); + assert!(matches!( + classify_failure(StatusCode::BAD_REQUEST, b""), + DataError::AiRejected(_) + )); + assert!(matches!( + classify_failure(StatusCode::SERVICE_UNAVAILABLE, b""), + DataError::AiUnavailable + )); + assert!(retryable_status(StatusCode::TOO_MANY_REQUESTS)); + assert!(!retryable_status(StatusCode::BAD_REQUEST)); + } + + #[test] + fn builds_fixed_chat_request_from_minimal_sql_context() { + let input = AiAssistantRequest { + prompt: "write the customer query".to_owned(), + active_query: Some("SELECT id FROM customers;".to_owned()), + database_kind: DatabaseKind::SQLite, + query_language: QueryLanguage::Sql, + }; + + let request = build_assistant_chat_request(&input).unwrap(); + assert_eq!(request.model, "auto"); + assert_eq!(request.temperature, Some(0.2)); + assert_eq!(request.max_tokens, None); + assert_eq!(request.messages.len(), 2); + assert_eq!(request.messages[0].role, AiRole::System); + assert!(request.messages[0].content.contains("SQLite SQL")); + assert!( + request.messages[0] + .content + .contains("only the user's prompt and optional active query") + ); + assert!(request.messages[0].content.contains( + "No database result rows or database credentials are automatically attached" + )); + assert!(!request.messages[0].content.contains(&input.prompt)); + assert!(!request.messages[0].content.contains("SELECT id")); + assert_eq!(request.messages[1].role, AiRole::User); + assert!(request.messages[1].content.contains(&input.prompt)); + assert!( + request.messages[1] + .content + .contains("SELECT id FROM customers;") + ); + } + + #[test] + fn builds_mongo_instruction_and_rejects_mismatched_language() { + let mongo = AiAssistantRequest { + prompt: "find customers".to_owned(), + active_query: None, + database_kind: DatabaseKind::MongoDb, + query_language: QueryLanguage::MongoJson, + }; + let request = build_assistant_chat_request(&mongo).unwrap(); + assert!(request.messages[0].content.contains("fenced mql or json")); + assert!(request.messages[1].content.contains("(none supplied)")); + + let mismatched = AiAssistantRequest { + query_language: QueryLanguage::Sql, + ..mongo + }; + assert!(matches!( + build_assistant_chat_request(&mismatched), + Err(DataError::AiRejected(message)) if message.contains("does not match") + )); + + let sql_server = AiAssistantRequest { + prompt: "write a query".to_owned(), + active_query: None, + database_kind: DatabaseKind::SqlServer, + query_language: QueryLanguage::Sql, + }; + assert!(matches!( + build_assistant_chat_request(&sql_server), + Err(DataError::AiRejected(message)) + if message.contains("no connector or connection profile") + )); + } + + #[test] + fn enforces_context_limits_and_rejects_extra_renderer_fields() { + let oversized_prompt = AiAssistantRequest { + prompt: "x".repeat(MAX_AI_PROMPT_BYTES + 1), + active_query: None, + database_kind: DatabaseKind::PostgreSql, + query_language: QueryLanguage::Sql, + }; + assert!(matches!( + build_assistant_chat_request(&oversized_prompt), + Err(DataError::AiRejected(message)) if message.contains("prompt exceeds") + )); + + let oversized_query = AiAssistantRequest { + prompt: "explain".to_owned(), + active_query: Some("q".repeat(MAX_AI_ACTIVE_QUERY_BYTES + 1)), + database_kind: DatabaseKind::PostgreSql, + query_language: QueryLanguage::Sql, + }; + assert!(matches!( + build_assistant_chat_request(&oversized_query), + Err(DataError::AiRejected(message)) if message.contains("active query exceeds") + )); + + for extra in ["messages", "resultRows", "credentials", "model", "baseUrl"] { + let mut value = serde_json::json!({ + "prompt": "help", + "databaseKind": "postgre_sql", + "queryLanguage": "sql" + }); + value + .as_object_mut() + .unwrap() + .insert(extra.to_owned(), serde_json::json!([])); + assert!(serde_json::from_value::(value).is_err()); + } + } +} diff --git a/crates/redrob-core/src/cell_edits.rs b/crates/redrob-query-core/src/cell_edits.rs similarity index 100% rename from crates/redrob-core/src/cell_edits.rs rename to crates/redrob-query-core/src/cell_edits.rs diff --git a/crates/redrob-core/src/connection.rs b/crates/redrob-query-core/src/connection.rs similarity index 99% rename from crates/redrob-core/src/connection.rs rename to crates/redrob-query-core/src/connection.rs index 64265e1..ed59429 100644 --- a/crates/redrob-core/src/connection.rs +++ b/crates/redrob-query-core/src/connection.rs @@ -35,6 +35,7 @@ use crate::{ MetadataNode, MetadataRequest, MutationPlan, MutationResult, MutationRisk, QueryClassification, QueryColumn, QueryLanguage, QueryRequest, QueryResultPage, QueryStats, Result, ai::{AI_SECRET_ID, AiClient, AiKeyStatus, build_assistant_chat_request}, + keys::{self, TableKeys}, profile_store::{ JournalOperation, JournalPhase, PersistencePhase, ProfileJournal, ProfileRepository, RepositoryOpenError, deserialize_journal, profiles_from_map, reconcile, remove_journal, @@ -127,7 +128,13 @@ impl DataService { /// produces a Demo-only, fail-closed service. #[must_use] pub fn persistent(path: impl Into) -> Self { - Self::build_persistent(Arc::new(KeyringSecretStore), path.into()) + let path = path.into(); + // This app's own `account.json`, beside the profile file: the launcher's Connect (or a + // person) may write one; it wins over the shared file (`account` module, rule 4). + let own_account = path.parent().map(|dir| dir.join("account.json")); + let mut service = Self::build_persistent(Arc::new(KeyringSecretStore), path); + service.ai = AiClient::new(own_account); + service } /// Creates an ephemeral service with an injectable secret store for tests. @@ -186,7 +193,7 @@ impl DataService { profile_load_warnings, recovery_blocked, persistent_storage_unavailable, - ai: AiClient::new(Arc::clone(&secrets)), + ai: AiClient::new(None), secrets, } } @@ -743,12 +750,6 @@ impl DataService { } } - /// Hands a Redrob console key to the shared Redrob Code engine, which keeps it. Nothing is - /// written to this app's own storage. - pub async fn ai_sign_in(&self, secret: &str) -> Result<()> { - self.ai.sign_in(secret).await - } - async fn connection_test_secret( &self, profile: &ConnectionProfile, @@ -1001,6 +1002,28 @@ impl DataService { } } + /// The primary key, foreign keys and indexes of one table (or the indexes of one `MongoDB` + /// collection), for the structure panel. Names are bound values where the dialect allows it. + pub async fn table_keys( + &self, + connection_id: Uuid, + schema: Option, + table: String, + ) -> Result { + match self.ensure_connected(connection_id).await? { + ActiveConnection::PostgreSql(pool) => { + keys::postgres_table_keys(&pool, schema.as_deref(), &table).await + } + ActiveConnection::MySql(pool) => { + keys::mysql_table_keys(&pool, schema.as_deref(), &table).await + } + ActiveConnection::SQLite(pool) => keys::sqlite_table_keys(&pool, &table).await, + ActiveConnection::Mongo { client, database } => { + keys::mongo_collection_keys(&client, &database, &table).await + } + } + } + /// Create a table or add a column from the form (see `ddl`). Refused on a read-only profile. pub async fn apply_table_change(&self, connection_id: Uuid, change: TableChange) -> Result<()> { self.ensure_persistent_storage_available()?; @@ -1086,9 +1109,11 @@ impl DataService { self.ai.chat(build_assistant_chat_request(&input)?).await } - /// Whether the engine is signed in to the Redrob console, for the settings surface. Never a key. - pub async fn ai_key_status(&self) -> Result { - self.ai.account_status().await + /// Whether a Redrob account is signed in (the launcher's shared file, or this app's own), for + /// the status line. Read fresh on every call; never a key. + #[must_use] + pub fn ai_key_status(&self) -> AiKeyStatus { + self.ai.account_status() } async fn profile(&self, id: Uuid) -> Result { diff --git a/crates/redrob-core/src/ddl.rs b/crates/redrob-query-core/src/ddl.rs similarity index 100% rename from crates/redrob-core/src/ddl.rs rename to crates/redrob-query-core/src/ddl.rs diff --git a/crates/redrob-core/src/error.rs b/crates/redrob-query-core/src/error.rs similarity index 93% rename from crates/redrob-core/src/error.rs rename to crates/redrob-query-core/src/error.rs index bace175..5dd3e68 100644 --- a/crates/redrob-core/src/error.rs +++ b/crates/redrob-query-core/src/error.rs @@ -40,10 +40,6 @@ pub enum DataError { InvalidApproval, #[error("AI authentication is not configured")] AiAuthentication, - /// The shared Redrob Code engine is missing or would not start. The message is the sentence the - /// app shows verbatim (`ai::unavailable_message`); there is no fallback path to name. - #[error("{0}")] - EngineUnavailable(String), #[error("AI service is temporarily unavailable")] AiUnavailable, #[error("AI request was rejected: {0}")] diff --git a/crates/redrob-query-core/src/keys.rs b/crates/redrob-query-core/src/keys.rs new file mode 100644 index 0000000..81da016 --- /dev/null +++ b/crates/redrob-query-core/src/keys.rs @@ -0,0 +1,405 @@ +//! Key and index metadata of one table (or collection): primary key, foreign keys, indexes. +//! +//! The structure panel reads these. They used to be Beekeeper-ported TypeScript querying through +//! the Tauri bridge; here they are plain dialect queries against the live pool, with every +//! identifier passed as a bound value and resolved by the database itself where the dialect +//! allows it (`PostgreSQL`, `SQLite`'s table-valued pragmas, `information_schema` on `MySQL`). +//! `MongoDB` lists the collection's indexes; it has no keys to speak of beyond `_id`. + +use futures_util::TryStreamExt as _; +use mongodb::{Client as MongoClient, bson::Document}; +use serde::{Deserialize, Serialize}; +use sqlx::{MySqlPool, PgPool, Row, SqlitePool}; + +use crate::{DataError, Result}; + +/// One foreign-key constraint, column by column. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ForeignKey { + /// The constraint name; empty on `SQLite`, which names none. + pub name: String, + /// Columns of this table, in constraint order. + pub columns: Vec, + /// The table the columns point at, schema-qualified when the dialect has schemas. + pub referenced_table: String, + /// Columns of the referenced table, parallel to `columns`. + pub referenced_columns: Vec, + #[serde(default)] + pub on_update: String, + #[serde(default)] + pub on_delete: String, +} + +/// One index: its columns in order, whether it is unique, and whether it is the primary key. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IndexInfo { + pub name: String, + pub columns: Vec, + pub unique: bool, + pub primary: bool, +} + +/// What the structure panel shows under the columns. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TableKeys { + /// Primary-key columns in key order; empty when the table has none. + pub primary: Vec, + pub foreign: Vec, + pub indexes: Vec, +} + +fn failed(error: &impl std::fmt::Display) -> DataError { + DataError::database("key metadata lookup failed", error) +} + +/// Folds `(group, member)` rows, which arrive sorted by group then position, into groups. +fn group_rows(rows: Vec<(K, V)>) -> Vec<(K, Vec)> { + let mut groups: Vec<(K, Vec)> = Vec::new(); + for (key, value) in rows { + match groups.last_mut() { + Some((last, members)) if *last == key => members.push(value), + _ => groups.push((key, vec![value])), + } + } + groups +} + +pub(crate) async fn postgres_table_keys( + pool: &PgPool, + schema: Option<&str>, + table: &str, +) -> Result { + let primary = sqlx::query_scalar::<_, String>( + "SELECT a.attname::text FROM pg_index i \ + JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey) \ + WHERE i.indisprimary AND i.indrelid = to_regclass(CASE WHEN $1::text IS NULL THEN quote_ident($2) ELSE quote_ident($1) || '.' || quote_ident($2) END) \ + ORDER BY array_position(i.indkey, a.attnum)", + ) + .bind(schema) + .bind(table) + .fetch_all(pool) + .await + .map_err(|e| failed(&e))?; + + // One row per (constraint, column position): the constraint's columns and the referenced + // table's columns are parallel arrays in pg_constraint, unnested side by side. + let fk_rows = sqlx::query( + "SELECT c.conname::text, a.attname::text, \ + quote_ident(rn.nspname) || '.' || quote_ident(rc.relname) AS referenced_table, \ + ra.attname::text AS referenced_column, \ + c.confupdtype::text, c.confdeltype::text \ + FROM pg_constraint c \ + JOIN unnest(c.conkey, c.confkey) WITH ORDINALITY AS k(attnum, refattnum, position) ON true \ + JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum \ + JOIN pg_class rc ON rc.oid = c.confrelid \ + JOIN pg_namespace rn ON rn.oid = rc.relnamespace \ + JOIN pg_attribute ra ON ra.attrelid = c.confrelid AND ra.attnum = k.refattnum \ + WHERE c.contype = 'f' AND c.conrelid = to_regclass(CASE WHEN $1::text IS NULL THEN quote_ident($2) ELSE quote_ident($1) || '.' || quote_ident($2) END) \ + ORDER BY c.conname, k.position", + ) + .bind(schema) + .bind(table) + .fetch_all(pool) + .await + .map_err(|e| failed(&e))?; + let mut fk_pairs = Vec::with_capacity(fk_rows.len()); + for row in &fk_rows { + let name: String = row.try_get(0).map_err(|e| failed(&e))?; + let column: String = row.try_get(1).map_err(|e| failed(&e))?; + let referenced_table: String = row.try_get(2).map_err(|e| failed(&e))?; + let referenced_column: String = row.try_get(3).map_err(|e| failed(&e))?; + let on_update: String = row.try_get(4).map_err(|e| failed(&e))?; + let on_delete: String = row.try_get(5).map_err(|e| failed(&e))?; + fk_pairs.push(( + (name, referenced_table, on_update, on_delete), + (column, referenced_column), + )); + } + let foreign = group_rows(fk_pairs) + .into_iter() + .map(|((name, referenced_table, on_update, on_delete), pairs)| { + let (columns, referenced_columns) = pairs.into_iter().unzip(); + ForeignKey { + name, + columns, + referenced_table, + referenced_columns, + on_update: postgres_action(&on_update), + on_delete: postgres_action(&on_delete), + } + }) + .collect(); + + let index_rows = sqlx::query( + "SELECT ic.relname::text, a.attname::text, i.indisunique, i.indisprimary \ + FROM pg_index i \ + JOIN pg_class ic ON ic.oid = i.indexrelid \ + JOIN unnest(i.indkey) WITH ORDINALITY AS k(attnum, position) ON true \ + LEFT JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = k.attnum \ + WHERE i.indrelid = to_regclass(CASE WHEN $1::text IS NULL THEN quote_ident($2) ELSE quote_ident($1) || '.' || quote_ident($2) END) \ + ORDER BY ic.relname, k.position", + ) + .bind(schema) + .bind(table) + .fetch_all(pool) + .await + .map_err(|e| failed(&e))?; + let mut index_pairs = Vec::with_capacity(index_rows.len()); + for row in &index_rows { + let name: String = row.try_get(0).map_err(|e| failed(&e))?; + // An expression index has no attribute for the position; it shows as "(expression)". + let column: Option = row.try_get(1).map_err(|e| failed(&e))?; + let unique: bool = row.try_get(2).map_err(|e| failed(&e))?; + let is_primary: bool = row.try_get(3).map_err(|e| failed(&e))?; + index_pairs.push(( + (name, unique, is_primary), + column.unwrap_or_else(|| "(expression)".to_owned()), + )); + } + let indexes = group_rows(index_pairs) + .into_iter() + .map(|((name, unique, primary), columns)| IndexInfo { + name, + columns, + unique, + primary, + }) + .collect(); + + Ok(TableKeys { + primary, + foreign, + indexes, + }) +} + +/// `pg_constraint.confupdtype` / `confdeltype` letters, spelled out. +fn postgres_action(code: &str) -> String { + match code { + "a" => "NO ACTION", + "r" => "RESTRICT", + "c" => "CASCADE", + "n" => "SET NULL", + "d" => "SET DEFAULT", + _ => "", + } + .to_owned() +} + +pub(crate) async fn mysql_table_keys( + pool: &MySqlPool, + schema: Option<&str>, + table: &str, +) -> Result { + // information_schema with the schema bound (DATABASE() when the profile names none). Column + // lists arrive one row per position, in SEQ_IN_INDEX / ORDINAL_POSITION order. + let schema_expr = schema.filter(|s| !s.is_empty()); + let stat_rows = sqlx::query( + "SELECT INDEX_NAME, COLUMN_NAME, NON_UNIQUE FROM information_schema.STATISTICS \ + WHERE TABLE_SCHEMA = COALESCE(?, DATABASE()) AND TABLE_NAME = ? \ + ORDER BY INDEX_NAME, SEQ_IN_INDEX", + ) + .bind(schema_expr) + .bind(table) + .fetch_all(pool) + .await + .map_err(|e| failed(&e))?; + let mut index_pairs = Vec::with_capacity(stat_rows.len()); + for row in &stat_rows { + let name: String = row.try_get(0).map_err(|e| failed(&e))?; + let column: Option = row.try_get(1).map_err(|e| failed(&e))?; + let non_unique: i64 = row.try_get(2).map_err(|e| failed(&e))?; + let primary = name == "PRIMARY"; + index_pairs.push(( + (name, non_unique == 0, primary), + column.unwrap_or_else(|| "(expression)".to_owned()), + )); + } + let indexes: Vec = group_rows(index_pairs) + .into_iter() + .map(|((name, unique, primary), columns)| IndexInfo { + name, + columns, + unique, + primary, + }) + .collect(); + let primary = indexes + .iter() + .find(|index| index.primary) + .map(|index| index.columns.clone()) + .unwrap_or_default(); + + let fk_rows = sqlx::query( + "SELECT k.CONSTRAINT_NAME, k.COLUMN_NAME, \ + CONCAT(k.REFERENCED_TABLE_SCHEMA, '.', k.REFERENCED_TABLE_NAME), k.REFERENCED_COLUMN_NAME, \ + r.UPDATE_RULE, r.DELETE_RULE \ + FROM information_schema.KEY_COLUMN_USAGE k \ + JOIN information_schema.REFERENTIAL_CONSTRAINTS r \ + ON r.CONSTRAINT_SCHEMA = k.CONSTRAINT_SCHEMA AND r.CONSTRAINT_NAME = k.CONSTRAINT_NAME \ + WHERE k.TABLE_SCHEMA = COALESCE(?, DATABASE()) AND k.TABLE_NAME = ? AND k.REFERENCED_TABLE_NAME IS NOT NULL \ + ORDER BY k.CONSTRAINT_NAME, k.ORDINAL_POSITION", + ) + .bind(schema_expr) + .bind(table) + .fetch_all(pool) + .await + .map_err(|e| failed(&e))?; + let mut fk_pairs = Vec::with_capacity(fk_rows.len()); + for row in &fk_rows { + let name: String = row.try_get(0).map_err(|e| failed(&e))?; + let column: String = row.try_get(1).map_err(|e| failed(&e))?; + let referenced_table: String = row.try_get(2).map_err(|e| failed(&e))?; + let referenced_column: String = row.try_get(3).map_err(|e| failed(&e))?; + let on_update: String = row.try_get(4).map_err(|e| failed(&e))?; + let on_delete: String = row.try_get(5).map_err(|e| failed(&e))?; + fk_pairs.push(( + (name, referenced_table, on_update, on_delete), + (column, referenced_column), + )); + } + let foreign = group_rows(fk_pairs) + .into_iter() + .map(|((name, referenced_table, on_update, on_delete), pairs)| { + let (columns, referenced_columns) = pairs.into_iter().unzip(); + ForeignKey { + name, + columns, + referenced_table, + referenced_columns, + on_update, + on_delete, + } + }) + .collect(); + + Ok(TableKeys { + primary, + foreign, + indexes, + }) +} + +pub(crate) async fn sqlite_table_keys(pool: &SqlitePool, table: &str) -> Result { + // Table-valued pragmas take the name as a bound value, unlike `PRAGMA x(name)`. + let primary = sqlx::query_scalar::<_, String>( + "SELECT name FROM pragma_table_info(?) WHERE pk > 0 ORDER BY pk", + ) + .bind(table) + .fetch_all(pool) + .await + .map_err(|e| failed(&e))?; + + let fk_rows = sqlx::query( + "SELECT id, seq, \"table\", \"from\", \"to\", on_update, on_delete FROM pragma_foreign_key_list(?) ORDER BY id, seq", + ) + .bind(table) + .fetch_all(pool) + .await + .map_err(|e| failed(&e))?; + let mut fk_pairs = Vec::with_capacity(fk_rows.len()); + for row in &fk_rows { + let id: i64 = row.try_get(0).map_err(|e| failed(&e))?; + let referenced_table: String = row.try_get(2).map_err(|e| failed(&e))?; + let column: String = row.try_get(3).map_err(|e| failed(&e))?; + // `to` is NULL when the constraint points at the parent's primary key implicitly. + let referenced_column: Option = row.try_get(4).map_err(|e| failed(&e))?; + let on_update: String = row.try_get(5).map_err(|e| failed(&e))?; + let on_delete: String = row.try_get(6).map_err(|e| failed(&e))?; + fk_pairs.push(( + (id, referenced_table, on_update, on_delete), + (column, referenced_column.unwrap_or_default()), + )); + } + let foreign = group_rows(fk_pairs) + .into_iter() + .map(|((_, referenced_table, on_update, on_delete), pairs)| { + let (columns, referenced_columns) = pairs.into_iter().unzip(); + ForeignKey { + name: String::new(), + columns, + referenced_table, + referenced_columns, + on_update, + on_delete, + } + }) + .collect(); + + let index_rows = + sqlx::query("SELECT name, \"unique\", origin FROM pragma_index_list(?) ORDER BY seq") + .bind(table) + .fetch_all(pool) + .await + .map_err(|e| failed(&e))?; + let mut indexes = Vec::with_capacity(index_rows.len()); + for row in &index_rows { + let name: String = row.try_get(0).map_err(|e| failed(&e))?; + let unique: i64 = row.try_get(1).map_err(|e| failed(&e))?; + let origin: String = row.try_get(2).map_err(|e| failed(&e))?; + let columns = sqlx::query_scalar::<_, Option>( + "SELECT name FROM pragma_index_info(?) ORDER BY seqno", + ) + .bind(&name) + .fetch_all(pool) + .await + .map_err(|e| failed(&e))? + .into_iter() + .map(|column| column.unwrap_or_else(|| "(expression)".to_owned())) + .collect(); + indexes.push(IndexInfo { + name, + columns, + unique: unique != 0, + primary: origin == "pk", + }); + } + + Ok(TableKeys { + primary, + foreign, + indexes, + }) +} + +pub(crate) async fn mongo_collection_keys( + client: &MongoClient, + database: &str, + collection: &str, +) -> Result { + let mut cursor = client + .database(database) + .collection::(collection) + .list_indexes() + .await + .map_err(|e| failed(&e))?; + let mut indexes = Vec::new(); + while let Some(model) = cursor.try_next().await.map_err(|e| failed(&e))? { + let name = model + .options + .as_ref() + .and_then(|options| options.name.clone()) + .unwrap_or_default(); + let unique = model + .options + .as_ref() + .and_then(|options| options.unique) + .unwrap_or(false); + let columns = model.keys.keys().cloned().collect::>(); + let primary = name == "_id_"; + indexes.push(IndexInfo { + name, + columns, + unique: unique || primary, + primary, + }); + } + Ok(TableKeys { + primary: vec!["_id".to_owned()], + foreign: Vec::new(), + indexes, + }) +} diff --git a/crates/redrob-core/src/lib.rs b/crates/redrob-query-core/src/lib.rs similarity index 50% rename from crates/redrob-core/src/lib.rs rename to crates/redrob-query-core/src/lib.rs index a09c700..86e3628 100644 --- a/crates/redrob-core/src/lib.rs +++ b/crates/redrob-query-core/src/lib.rs @@ -1,15 +1,20 @@ -//! Database and AI runtime for Redrob Query. +//! Redrob Query engine: database connectivity, metadata, bounded querying, reviewed edits and the +//! console AI transport. The desktop shell (`apps/redrob-query`) drives it through [`Workspace`]. +pub mod account; pub mod ai; pub mod cell_edits; pub mod connection; pub mod ddl; pub mod error; +pub mod keys; pub mod models; pub(crate) mod profile_store; pub mod query; pub mod secret; +pub mod workspace; pub use connection::{DataService, RemoveProfileOutcome, SaveProfileOutcome}; pub use error::{DataError, Result}; pub use models::*; +pub use workspace::{Pending, Workspace}; diff --git a/crates/redrob-core/src/models.rs b/crates/redrob-query-core/src/models.rs similarity index 100% rename from crates/redrob-core/src/models.rs rename to crates/redrob-query-core/src/models.rs diff --git a/crates/redrob-core/src/profile_store.rs b/crates/redrob-query-core/src/profile_store.rs similarity index 100% rename from crates/redrob-core/src/profile_store.rs rename to crates/redrob-query-core/src/profile_store.rs diff --git a/crates/redrob-core/src/query.rs b/crates/redrob-query-core/src/query.rs similarity index 100% rename from crates/redrob-core/src/query.rs rename to crates/redrob-query-core/src/query.rs diff --git a/crates/redrob-core/src/secret.rs b/crates/redrob-query-core/src/secret.rs similarity index 100% rename from crates/redrob-core/src/secret.rs rename to crates/redrob-query-core/src/secret.rs diff --git a/crates/redrob-query-core/src/workspace.rs b/crates/redrob-query-core/src/workspace.rs new file mode 100644 index 0000000..7ca6f10 --- /dev/null +++ b/crates/redrob-query-core/src/workspace.rs @@ -0,0 +1,294 @@ +//! The `Workspace`: the engine's surface for the desktop shell, one method per former command. +//! +//! The Tauri shell exposed `DataService` as fifteen thin commands (list / save / remove profiles and +//! secrets, load warnings, test / connect / disconnect, metadata, query, primary keys, table changes, +//! cell edits, AI status and chat). The egui shell keeps that surface, as plain Rust: each call hands +//! the work to a tokio runtime the workspace owns and returns a [`Pending`] the UI polls once per +//! frame, so a slow database never blocks a paint. Synchronous reads (load warnings, batch splitting, +//! the account status) answer at once. +//! +//! Signing in is not here: the account comes from Redrob Launcher's shared file (`crate::account`), +//! read on every request. The one thing this app does about sign-in is open the launcher's page. + +use std::{ + future::Future, + path::PathBuf, + sync::{Arc, Mutex}, +}; + +use tokio::sync::oneshot; +use uuid::Uuid; + +use crate::{ + AiAssistantRequest, AiChatResponse, ConnectionProfile, ConnectionStatus, DataError, + DataService, MetadataNode, MetadataRequest, MutationResult, QueryRequest, QueryResultPage, + RemoveProfileOutcome, Result, SaveProfileOutcome, + account::{self, SharedAccount}, + ai::{AiKeyStatus, build_assistant_chat_request}, + cell_edits::CellEditSet, + ddl::TableChange, + keys::TableKeys, + query::read_only_statements, +}; + +/// A result that is on its way. Poll it once per frame; it answers exactly once. +pub struct Pending { + receiver: Option>>, +} + +impl Pending { + /// An already-answered pending, for paths that fail before any work is scheduled. + pub fn ready(value: Result) -> Self { + let (sender, receiver) = oneshot::channel(); + let _ = sender.send(value); + Self { + receiver: Some(receiver), + } + } + + /// The answer, the first time it is available; `None` while the work is still running and + /// forever after the answer was taken. + pub fn poll(&mut self) -> Option> { + let receiver = self.receiver.as_mut()?; + match receiver.try_recv() { + Ok(value) => { + self.receiver = None; + Some(value) + } + Err(oneshot::error::TryRecvError::Empty) => None, + Err(oneshot::error::TryRecvError::Closed) => { + self.receiver = None; + Some(Err(DataError::Unsupported( + "the operation was dropped before it answered".to_owned(), + ))) + } + } + } + + /// Whether an answer is still owed. + #[must_use] + pub fn in_flight(&self) -> bool { + self.receiver.is_some() + } +} + +type Wake = Arc; + +/// The shell's handle on the engine. +pub struct Workspace { + service: Arc, + runtime: tokio::runtime::Runtime, + own_account_file: Option, + wake: Mutex>, +} + +impl Workspace { + /// A workspace over a persistent service: `connections.json` and `account.json` in `data_dir`, + /// passwords in the OS keyring. + pub fn persistent(data_dir: &std::path::Path) -> std::io::Result { + std::fs::create_dir_all(data_dir)?; + let service = DataService::persistent(data_dir.join("connections.json")); + Self::over(service, Some(data_dir.join("account.json"))) + } + + /// A workspace over an ephemeral service: nothing is written to disk. + pub fn ephemeral() -> std::io::Result { + Self::over(DataService::new(), None) + } + + fn over(service: DataService, own_account_file: Option) -> std::io::Result { + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .thread_name("redrob-query-engine") + .enable_all() + .build()?; + Ok(Self { + service: Arc::new(service), + runtime, + own_account_file, + wake: Mutex::new(None), + }) + } + + /// Called when a pending answer lands, so the UI repaints without a timer. + pub fn set_wake(&self, wake: impl Fn() + Send + Sync + 'static) { + if let Ok(mut slot) = self.wake.lock() { + *slot = Some(Arc::new(wake)); + } + } + + fn spawn(&self, make: impl FnOnce(Arc) -> F) -> Pending + where + T: Send + 'static, + F: Future> + Send + 'static, + { + let (sender, receiver) = oneshot::channel(); + let future = make(Arc::clone(&self.service)); + let wake = self.wake.lock().ok().and_then(|slot| slot.clone()); + self.runtime.spawn(async move { + let value = future.await; + let _ = sender.send(value); + if let Some(wake) = wake { + wake(); + } + }); + Pending { + receiver: Some(receiver), + } + } + + // ---- profiles --------------------------------------------------------------------------- + + pub fn list_profiles(&self) -> Pending> { + self.spawn(|service| async move { Ok(service.list_profiles().await) }) + } + + /// Warnings produced while loading stored profiles (a corrupt file, storage owned by another + /// instance). Sanitised; safe to show. + #[must_use] + pub fn profile_load_warnings(&self) -> Vec { + self.service.profile_load_warnings() + } + + /// Save a profile and, when given, its password (into the keyring, never the profile file). + pub fn save_profile( + &self, + profile: ConnectionProfile, + secret: Option, + ) -> Pending { + self.spawn(|service| async move { + service + .save_profile_with_secret_outcome(profile, secret.as_deref()) + .await + }) + } + + pub fn remove_profile(&self, id: Uuid) -> Pending { + self.spawn(move |service| async move { service.remove_profile_outcome(id).await }) + } + + // ---- connections ------------------------------------------------------------------------ + + /// Try the profile as entered in the form, with the password typed there (or the stored one). + pub fn test_connection( + &self, + profile: ConnectionProfile, + secret: Option, + ) -> Pending { + self.spawn( + |service| async move { service.test_connection(&profile, secret.as_deref()).await }, + ) + } + + pub fn connect(&self, id: Uuid) -> Pending { + self.spawn(move |service| async move { service.connect(id).await }) + } + + pub fn disconnect(&self, id: Uuid) -> Pending<()> { + self.spawn(move |service| async move { service.disconnect(id).await }) + } + + // ---- metadata --------------------------------------------------------------------------- + + pub fn load_metadata(&self, request: MetadataRequest) -> Pending> { + self.spawn(|service| async move { service.load_metadata(request).await }) + } + + /// The primary-key columns of a table; empty when it has none (the result stays read-only). + pub fn primary_key_columns( + &self, + connection_id: Uuid, + schema: Option, + table: String, + ) -> Pending> { + self.spawn(move |service| async move { + service + .primary_key_columns(connection_id, schema, table) + .await + }) + } + + /// Primary key, foreign keys and indexes, for the structure panel. + pub fn table_keys( + &self, + connection_id: Uuid, + schema: Option, + table: String, + ) -> Pending { + self.spawn( + move |service| async move { service.table_keys(connection_id, schema, table).await }, + ) + } + + // ---- queries ---------------------------------------------------------------------------- + + /// Run one statement through the engine's read-only gate (writes go through the changes panel + /// and `apply_cell_edits`, or the table-change form and `apply_table_change`). + pub fn execute_query(&self, request: QueryRequest) -> Pending { + self.spawn(|service| async move { service.execute_query(request).await }) + } + + /// The statements of a batch the editor may run one by one, each through `execute_query` and + /// its own single-statement gate. `None` unless every statement is read-only, so a batch never + /// widens what may run; it only lets the grid show each statement's result. + #[must_use] + pub fn split_read_only_batch(query: &str) -> Option> { + read_only_statements(query) + } + + // ---- writes ----------------------------------------------------------------------------- + + /// Create a table or add a column from the form. Types come from a fixed list (`ddl`). + pub fn apply_table_change(&self, connection_id: Uuid, change: TableChange) -> Pending<()> { + self.spawn( + move |service| async move { service.apply_table_change(connection_id, change).await }, + ) + } + + /// Apply a change set the person reviewed in the changes panel. The engine checks the profile + /// is writable, binds every value, and commits only if every row update hits exactly one row. + pub fn apply_cell_edits( + &self, + connection_id: Uuid, + edits: CellEditSet, + ) -> Pending { + self.spawn( + move |service| async move { service.apply_cell_edits(connection_id, edits).await }, + ) + } + + // ---- AI and account --------------------------------------------------------------------- + + /// Whether a Redrob account is signed in, and whose. Read fresh; never a key. + #[must_use] + pub fn ai_key_status(&self) -> AiKeyStatus { + self.service.ai_key_status() + } + + /// The account in force, by the contract's precedence (environment, own file, shared file). + #[must_use] + pub fn account(&self) -> Option { + account::current_account(self.own_account_file.as_deref()) + } + + /// A fingerprint of the account files, so the shell can notice a sign-in or sign-out. + #[must_use] + pub fn account_stamp(&self) -> Vec<(PathBuf, Option<(std::time::SystemTime, u64)>)> { + account::account_files_stamp(self.own_account_file.as_deref()) + } + + /// One assistant turn: the prompt and the active query, nothing else, to the console with the + /// shared account. The request is validated before anything leaves the machine. + pub fn ai_chat(&self, request: AiAssistantRequest) -> Pending { + if let Err(error) = build_assistant_chat_request(&request) { + return Pending::ready(Err(error)); + } + self.spawn(|service| async move { service.ai_chat(request).await }) + } + + /// Where a person signs in: the launcher's latest release page. + #[must_use] + pub fn launcher_url() -> &'static str { + account::LAUNCHER_URL + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a7a7898..49435f1 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,5 +1,14 @@ # Architecture +> **0.3.0.** The shell is now a native Rust egui/wgpu app (`apps/redrob-query`) over the engine crate +> `crates/redrob-query-core`; the React renderer, the Tauri IPC layer and the browser demo described +> below no longer exist. The shell drives the engine through `redrob_query_core::Workspace` (one +> method per former Tauri command, answered through `Pending` handles polled once per frame), AI +> goes straight to the Redrob console with Redrob Launcher's shared account (`account.rs`, `ai.rs`), +> and key / index metadata lives in `keys.rs`. The engine sections below (`DataService`, query +> classification, profile storage, secrets, reviewed edits, Mongo bounds) still describe the code as +> it is; the renderer and IPC sections are history. See the README for the current layout. + Redrob Query 0.1 separates browser-demo behavior, renderer state, native IPC, and database/AI runtime responsibilities. Java and the JVM are not part of the product. ## Layers diff --git a/docs/DEMO.md b/docs/DEMO.md deleted file mode 100644 index 6e6bb1e..0000000 --- a/docs/DEMO.md +++ /dev/null @@ -1,67 +0,0 @@ -# Demo guide - -Redrob Query has two distinct demo experiences. - -## Browser sample-data demo - -Run: - -```bash -npm ci -npm run dev -``` - -Open `http://127.0.0.1:1420`. This uses `DemoBridge`, not native drivers. The title bar says **Interactive browser workspace · sample data only**, and query tabs/history display **In-memory demo**. - -### Demonstrable flow - -1. Start on the connected **Acme Warehouse** PostgreSQL sample profile. -2. Expand `commerce → public → customers` or focus navigator search from the activity rail. -3. Run the starter query. The default page contains rows 1–50; use the next control for the remaining rows 51–64 or switch among 25/50/100/250 page sizes. -4. Sort or filter the current page, hide/show columns, inspect execution messages, and export the currently visible rows/columns to CSV. -5. Edit a supported relational sample cell, inspect staged changes, and apply it. The whole batch is checked against exact previous values before the profile's in-memory fixture changes. -6. Open local query history, restore the successful first-page query, or clear history. Browser history disappears on reload. -7. Ask Redrob AI for monthly revenue, insert the generated SQL, and run it. -8. Create, test, edit, disconnect/reconnect, and remove a user sample profile. Built-in profiles cannot be edited or removed. -9. Add a MongoDB profile with a target database and optional `authSource`; switch to its Mongo-owned MQL tab and run the deterministic read-only customer `find`. - -Connection tests and lifecycle transitions are simulated; no host is contacted. Redrob responses are local deterministic fixtures. An entered AI key is neither stored nor sent. Added profiles, queries, history, and edits disappear when the page reloads. - -Browser editing is intentionally limited to sample relational fixtures. Mongo sample results and all native desktop results are read-only. A stale or partly invalid mutation batch is rejected atomically. - -## Desktop built-in demo - -The native core always includes non-removable **Demo SQLite**, backed by `:memory:`. Desktop startup does not select or connect automatically. Select the profile and invoke **Connect**; the core then seeds four customers and five orders and exposes native SQLite metadata, guarded reads, typed rows, and paging. - -Desktop results remain read-only and do not expose cell editing. Desktop query tabs/history can be restored locally, but restoration never auto-selects, connects, or runs a query. - -## Recorded acceptance demo - -Demo-recorder configuration and output are intentionally excluded by `.gitignore`. A validated run produces: - -- `video/demo.mp4` — H.264 deliverable; -- `video/demo.webm` — original browser recording; -- per-step screenshots and accessibility snapshots; -- `action-log.json` and `review-report.md`. - -A passing review requires every scenario assertion to pass, no unexpected console errors, no failed network response, and a decodable MP4. The recording is evidence only for the browser sample-data workflow; it is not evidence that external databases, the native WebKit shell, or Redrob were contacted. - -## Live connector tests - -The three external connector tests are marked `#[ignore]`. A default Rust run executes hermetic tests and reports those three as ignored; it never silently treats a missing service as a live pass. - -Export all disposable URLs and explicitly run ignored tests: - -```bash -export REDROB_TEST_POSTGRES_URL='postgresql://…' -export REDROB_TEST_MYSQL_URL='mysql://…' -export REDROB_TEST_MONGO_URL='mongodb://…' -export REDROB_TEST_MONGO_DATABASE='redrob_test' # optional -cargo test --locked -p redrob-core --all-features -- --ignored -``` - -Or run one named gate with `-- --ignored`. An explicitly selected gate fails immediately if its required URL is missing. - -The PostgreSQL gate covers native scalar and parameter behavior. The MySQL gate covers scalars, unsigned values, BIT/TINYINT, parameters, and file-output rejection. The Mongo gate creates one UUID-named collection and covers find/aggregate pagination, explain modes, BSON fidelity, and bounded metadata sampling before dropping that collection. - -Use only non-production databases and least-privilege credentials. Rust does not automatically load `.env` files. diff --git a/e2e/run.mjs b/e2e/run.mjs deleted file mode 100644 index ead9265..0000000 --- a/e2e/run.mjs +++ /dev/null @@ -1,213 +0,0 @@ -#!/usr/bin/env node -// End-to-end: the real desktop build, driven over W3C WebDriver through tauri-driver. -// -// add a SQLite connection -> "Query this table" on people -> Run -> edit row 1's name -// -> Apply -> Run again -> the new name is on screen (and the CI step then reads it from the file) -// -// Every step is a click or a keystroke a person could make; nothing calls a native command -// directly. Elements are reached with `execute/sync` page scripts, as in redrob-recall's E2E: on -// WebKitWebDriver, element lookup by XPath or CSS failed for elements present in the page source. -// -// No test framework: a handful of HTTP calls, and the WebdriverIO dependency tree carried -// high-severity advisories that no override reached. -// -// Needs: tauri-driver on WEBDRIVER_URL (default http://127.0.0.1:4444), the debug app at APP, and -// REDROB_E2E_DB pointing at a SQLite file with a `people` table (see e2e/seed.sql). Screenshots go -// to SHOTS (default e2e/screenshots). -// -// Step names, cited from docs/compatibility.md as `e2e:e2e/run.mjs::` (the matrix guard checks -// that this file contains the name): edit_cell_apply, delete_row_apply, insert_row_apply, -// no_primary_key_read_only, create_table_add_column. -import { mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; - -const DRIVER = process.env.WEBDRIVER_URL ?? 'http://127.0.0.1:4444'; -const APP = process.env.APP; -const DB = process.env.REDROB_E2E_DB; -const SHOTS = process.env.SHOTS ?? 'e2e/screenshots'; -const NEW_NAME = 'Ann Lee-Kim'; -if (!APP || !DB) { - console.error('APP and REDROB_E2E_DB must be set'); - process.exit(2); -} -mkdirSync(SHOTS, { recursive: true }); - -async function wd(method, path, body) { - const response = await fetch(`${DRIVER}${path}`, { - method, - headers: { 'content-type': 'application/json' }, - body: body === undefined ? undefined : JSON.stringify(body), - }); - const json = await response.json(); - if (!response.ok || json.value?.error) throw new Error(`${method} ${path}: ${json.value?.error} ${json.value?.message ?? ''}`); - return json.value; -} - -let session; -const js = (body, ...args) => wd('POST', `/session/${session}/execute/sync`, { script: body, args }); - -/** Click the element with this data-testid; false when it is not on screen or disabled. */ -const click = (id) => js("const el = document.querySelector('[data-testid=\"' + arguments[0] + '\"]'); if (!el || el.disabled) return false; el.click(); return true;", id); -/** Click the button or menu item whose accessible name or visible text is exactly `label`. Exact on - * purpose: "Connect" must not hit "Connection actions", which a contains-match did on the first run. */ -const clickLabel = (label) => js( - `const want = arguments[0]; - const els = [...document.querySelectorAll('button, [role=menuitem], [role=tab]')]; - const el = els.find((e) => !e.disabled && (e.getAttribute('aria-label') === want || (e.textContent ?? '').trim() === want)); - if (!el) return false; el.click(); return true;`, label); -/** True when some element's visible text includes `fragment`. */ -const shows = (fragment) => js("return (document.body.innerText ?? '').includes(arguments[0]);", fragment); -/** Type into a React-controlled field: set through the native setter, then fire `input`. */ -const fill = (id, value) => js( - `const el = document.querySelector('[data-testid="' + arguments[0] + '"]'); - if (!el) return false; - const proto = el instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype; - Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, arguments[1]); - el.dispatchEvent(new Event('input', { bubbles: true })); - return true;`, id, value); -/** Text of the grid cell with this accessible name, or null. */ -const cell = (name) => js("const el = document.querySelector('[role=cell][aria-label=\"' + arguments[0] + '\"]'); return el ? el.textContent : null;", name); - -async function shot(name) { - const png = await wd('GET', `/session/${session}/screenshot`); - writeFileSync(join(SHOTS, `${name}.png`), Buffer.from(png, 'base64')); -} -async function until(what, check, seconds = 60) { - const deadline = Date.now() + seconds * 1000; - let last = 'the check returned nothing'; - for (;;) { - try { - const value = await check(); - if (value) return value; - } catch (error) { - if (error.fatal) throw error; - last = error.message; - } - if (Date.now() > deadline) throw new Error(`timed out waiting for ${what}; last: ${last}`); - await new Promise((resolve) => setTimeout(resolve, 500)); - } -} - -let failed = false; -try { - session = (await wd('POST', '/session', { capabilities: { alwaysMatch: { 'tauri:options': { application: APP } } } })).sessionId; - - // 1. Add the SQLite file as a connection. - await until('the add-connection button', () => click('open-connection-modal')); - await until('the SQLite choice', () => click('database-sqlite')); - await until('the name field', () => fill('connection-name', 'E2E people')); - await until('the file field', () => fill('connection-file', DB)); - // New connections are read-only; editing needs the person's explicit yes. - await until('the Allow edits switch', () => click('connection-allow-edits')); - if (!(await js("return document.querySelector('[data-testid=connection-allow-edits]').checked;"))) throw new Error('Allow edits did not turn on'); - await shot('1-connection'); - await until('Save to accept a click', () => click('save-connection')); - // The saved connection becomes the active one; connect it from the sidebar's actions menu. - await until('the new connection to be active', () => shows('E2E people')); - await until('the connection actions menu', async () => (await clickLabel('Connection actions')) && clickLabel('Connect')); - await until('the connection to connect', () => shows('E2E people · connected')); - - // 2. "Query this table" on people writes and opens the SELECT. - // SQLite puts tables under a schema node; open collapsed nodes until the table's menu is there. - await until('the people table and its menu', async () => { - if (await clickLabel('Actions for people')) return clickLabel('Query this table'); - await js("const el = document.querySelector('[data-testid=navigator] button[aria-expanded=false]'); if (el) el.click(); return true;"); - return false; - }); - await until('Run to accept a click', () => click('run-query')); - const before = await until("row 1's name", () => cell('name, result row 1')); - if (before !== 'Ann') throw new Error(`row 1 name before the edit is ${JSON.stringify(before)}, not "Ann"`); - await shot('2-result'); - - // 3. Edit the cell in place, as a double-click and typing would, then leave it. [edit_cell_apply] - await until('the name cell to be editable', () => js( - `const el = document.querySelector('[role=cell][aria-label="name, result row 1"]'); - if (!el || el.getAttribute('contenteditable') !== 'true') return false; - el.focus(); el.textContent = arguments[0]; el.dispatchEvent(new FocusEvent('blur')); el.blur(); return true;`, NEW_NAME)); - await until('the staged change', () => js("return [...document.querySelectorAll('button')].some((b) => /1 staged change/.test(b.textContent ?? ''));")); - await shot('3-staged'); - - // 4. Apply, then read the table again. - await until('the changes panel', async () => (await text('changes-panel')) !== null || clickLabel('1 staged change')); - await until('Apply to accept a click', () => click('apply-changes')); - // Apply reports a failure as a toast that fades in seconds, so read it while waiting. - await until('the staged change to clear', async () => { - const failure = await js("const t = document.body.innerText ?? ''; const i = t.indexOf('Could not apply changes'); return i < 0 ? null : t.slice(i, i + 300);"); - if (failure) throw Object.assign(new Error(`Apply failed: ${failure}`), { fatal: true }); - return js("return ![...document.querySelectorAll('button')].some((b) => /staged change/.test(b.textContent ?? ''));"); - }); - await until('Run to accept a click', () => click('run-query')); - await until('the new name after a fresh run', async () => (await cell('name, result row 1')) === NEW_NAME); - await shot('4-applied'); - - // 5. Delete row 2 from its menu, Apply, and read the table again. [delete_row_apply] - await until("row 2's menu and Delete row", async () => (await clickLabel('Row 2 actions')) && clickLabel('Delete row')); - await until('the staged delete', () => shows('Delete this row')); - await until('Apply to accept a click', () => click('apply-changes')); - await until('the staged delete to clear', async () => { - const failure = await js("const t = document.body.innerText ?? ''; const i = t.indexOf('Could not apply changes'); return i < 0 ? null : t.slice(i, i + 300);"); - if (failure) throw Object.assign(new Error(`Apply failed: ${failure}`), { fatal: true }); - return !(await shows('Delete this row')); - }); - await until('Run to accept a click', () => click('run-query')); - await until('row 2 gone after a fresh run', async () => (await cell('name, result row 1')) === NEW_NAME && (await cell('name, result row 2')) === null); - await shot('5-deleted'); - - // 6. Add a row, leaving the key for the database to assign, Apply, and read the table again. [insert_row_apply] - await until('Add row to accept a click', () => click('add-row')); - await until('the new-row name field', () => fill('new-row-name', 'Cy')); - await until('the new-row plan field', () => fill('new-row-plan', 'Free')); - await until('Stage new row to accept a click', () => click('stage-new-row')); - await until('the staged new row', () => shows('name = Cy')); - await until('Apply to accept a click', () => click('apply-changes')); - await until('the staged new row to clear', async () => { - const failure = await js("const t = document.body.innerText ?? ''; const i = t.indexOf('Could not apply changes'); return i < 0 ? null : t.slice(i, i + 300);"); - if (failure) throw Object.assign(new Error(`Apply failed: ${failure}`), { fatal: true }); - return !(await shows('name = Cy')); - }); - await until('Run to accept a click', () => click('run-query')); - await until('the new row after a fresh run', async () => (await cell('name, result row 2')) === 'Cy'); - await shot('6-inserted'); - - // 7. [no_primary_key_read_only] A table without a primary key: Query this table works, but nothing can be edited, and the - // grid says why. - await until('the notes table and its menu', async () => (await clickLabel('Actions for notes')) && clickLabel('Query this table')); - await until('Run to accept a click', () => click('run-query')); - await until('the notes rows', async () => (await cell('body, result row 1')) === 'first'); - await until('the reason it is read-only', () => shows('notes has no primary key')); - const locked = await js( - `const cell = document.querySelector('[role=cell][aria-label="body, result row 1"]'); - const add = document.querySelector('[data-testid=add-row]'); - return cell.getAttribute('contenteditable') !== 'true' && add.disabled;`); - if (!locked) throw new Error('the key-less table offered editing'); - await shot('7-no-key'); - - // 8. Create a table from the form, then add a column to it from its row menu. [create_table_add_column] - await until('New table to accept a click', () => clickLabel('New table')); - await until('the table name field', () => fill('table-change-table', 'pets')); - await until('the key column name', () => fill('table-change-column-0', 'id')); - await until('the second column name', () => fill('table-change-column-1', 'name')); - await until('Create table to accept a click', () => click('table-change-submit')); - await until('the pets table in the tree', async () => (await clickLabel('Actions for pets')) && clickLabel('Add column')); - await until('the added column name', () => fill('table-change-column-0', 'born')); - await until('the date type', () => js("const el = document.querySelector('[data-testid=table-change-type-0]'); if (!el) return false; const set = Object.getOwnPropertyDescriptor(HTMLSelectElement.prototype, 'value').set; set.call(el, 'date'); el.dispatchEvent(new Event('change', { bubbles: true })); return el.value === 'date';")); - await until('Add column to accept a click', () => click('table-change-submit')); - await until('the column to be added', () => shows('Column added')); - await shot('8-ddl'); - console.log(`e2e: SQLite connection -> Query this table -> Run -> edit name -> Apply -> Run -> "${NEW_NAME}" -> delete row 2 -> Apply -> Run -> 1 row -> add "Cy" -> Apply -> Run -> 2 rows -> key-less notes: read-only, reason shown -> New table pets -> Add column born`); -} catch (error) { - failed = true; - console.error(`e2e failed: ${error.message}`); - if (session) { - await shot('failure').catch(() => {}); - const source = await wd('GET', `/session/${session}/source`).catch((e) => `source unavailable: ${e.message}`); - writeFileSync(join(SHOTS, 'failure.html'), String(source)); - } -} finally { - if (session) await wd('DELETE', `/session/${session}`).catch(() => {}); -} -process.exit(failed ? 1 : 0); - -async function text(id) { - return js("const el = document.querySelector('[data-testid=\"' + arguments[0] + '\"]'); return el ? el.textContent : null;", id); -} diff --git a/e2e/seed.sql b/e2e/seed.sql deleted file mode 100644 index c2a394a..0000000 --- a/e2e/seed.sql +++ /dev/null @@ -1,6 +0,0 @@ --- The E2E database: a table with a single-column primary key, so its result is editable. -CREATE TABLE people (id INTEGER PRIMARY KEY, name TEXT NOT NULL, plan TEXT NOT NULL); -INSERT INTO people (id, name, plan) VALUES (1, 'Ann', 'Free'), (2, 'Bo', 'Scale'); --- A table without a primary key: its results must stay read-only and say why. -CREATE TABLE notes (body TEXT NOT NULL); -INSERT INTO notes (body) VALUES ('first'), ('second'); diff --git a/index.html b/index.html deleted file mode 100644 index faad59a..0000000 --- a/index.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - Redrob Query - - -
- - - diff --git a/installer/macos/dmg_settings.py b/installer/macos/dmg_settings.py new file mode 100644 index 0000000..a850660 --- /dev/null +++ b/installer/macos/dmg_settings.py @@ -0,0 +1,42 @@ +# SPDX-License-Identifier: GPL-3.0-or-later +# dmgbuild settings for the Redrob Query disk image (installer/macos/make-dmg.sh passes the -D values). +# +# dmgbuild writes the Finder layout (.DS_Store) itself instead of scripting Finder, so the image comes +# out the same on a build machine reached over ssh and on a CI runner, neither of which can be relied +# on to let a script drive Finder. Icon centres and the window size match res/background.html. +import os.path + +app = defines["app"] # noqa: F821 -- dmgbuild injects `defines` +uninstaller = defines["uninstaller"] # noqa: F821 +here = defines["here"] # noqa: F821 + +format = "UDZO" +compression_level = 9 +filesystem = "HFS+" + +files = [app, uninstaller] +# The licence texts and notices the zip carries beside the app, so the image ships them too. +licenses = defines.get("licenses") # noqa: F821 +if licenses: + files.append((licenses, "Licenses")) +symlinks = {"Applications": "/Applications"} +icon = os.path.join(app, "Contents", "Resources", "AppIcon.icns") # the volume's own icon + +# dmgbuild picks up background@2x.png beside background.png and makes a Retina TIFF from the pair. +background = os.path.join(here, "res", "background.png") +window_rect = ((200, 120), (660, 400)) +default_view = "icon-view" +show_status_bar = False +show_tab_view = False +show_toolbar = False +show_pathbar = False +show_sidebar = False +arrange_by = None +icon_size = 96 +text_size = 13 +icon_locations = { + os.path.basename(app): (170, 180), + "Applications": (490, 180), + os.path.basename(uninstaller): (250, 318), + "Licenses": (410, 318), +} diff --git a/installer/macos/make-dmg.sh b/installer/macos/make-dmg.sh new file mode 100644 index 0000000..1cff038 --- /dev/null +++ b/installer/macos/make-dmg.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Build the Redrob Query disk image: the app, an Applications link, and the uninstaller, on a branded +# background. Runs on macOS. +# +# make-dmg.sh --app "/Redrob Query.app" --out --version X.Y.Z +# [--licenses ] [--sign-identity ] +# +# --licenses is the staged share/redrob-query/licenses folder (tools/release/stage.py: LICENSE, NOTICE, +# UPSTREAM_NOTICES.md); it is copied in as "Licenses", as the zip carries it beside the app. +# +# Needs dmgbuild (pinned 1.6.5, importable by `python3`; a venv where pip refuses --user), which writes the Finder +# layout itself, so no GUI session or automation permission is needed (dmg_settings.py). +# +# With --sign-identity the uninstaller and the image are signed (hardened runtime, timestamp). The app +# must already be signed, notarized and stapled: the image is built around it, not into it. The release +# workflow then notarizes and staples the image itself. +set -euo pipefail + +here="$(cd "$(dirname "$0")" && pwd)" +app="" out="" version="" identity="" licenses="" +while [ $# -gt 0 ]; do + case "$1" in + --app) app="$2"; shift 2 ;; + --out) out="$2"; shift 2 ;; + --version) version="$2"; shift 2 ;; + --licenses) licenses="$2"; shift 2 ;; + --sign-identity) identity="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -d "$app" ] || { echo "--app must be the Redrob Query.app bundle" >&2; exit 2; } +[[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.]+)?$ ]] || { echo "--version must be X.Y.Z" >&2; exit 2; } +[ -n "$out" ] || { echo "--out is required" >&2; exit 2; } +icns="$app/Contents/Resources/AppIcon.icns" +[ -f "$icns" ] || { echo "the app carries no Contents/Resources/AppIcon.icns" >&2; exit 2; } +python3 -c 'import dmgbuild' 2>/dev/null || { echo "dmgbuild is missing: python3 -m pip install --user dmgbuild==1.6.5" >&2; exit 2; } + +work="$(mktemp -d "${TMPDIR:-/tmp}/redrob-dmg.XXXXXX")" +trap 'rm -rf "$work"' EXIT + +# The uninstaller: a script in a bundle, so it opens from Finder with the product's icon. +un="$work/Uninstall Redrob Query.app" +mkdir -p "$un/Contents/MacOS" "$un/Contents/Resources" +cp "$here/uninstaller/uninstall.sh" "$un/Contents/MacOS/Uninstall Redrob Query" +chmod 755 "$un/Contents/MacOS/Uninstall Redrob Query" +cp "$icns" "$un/Contents/Resources/AppIcon.icns" +cat > "$un/Contents/Info.plist" < + + + + CFBundleExecutableUninstall Redrob Query + CFBundleIdentifierai.redrob.query.uninstaller + CFBundleNameUninstall Redrob Query + CFBundleDisplayNameUninstall Redrob Query + CFBundleIconFileAppIcon + CFBundlePackageTypeAPPL + CFBundleShortVersionString${version} + CFBundleVersion${version} + LSMinimumSystemVersion12.0 + NSHumanReadableCopyrightCopyright 2026 Janghoon Lee (Redrob) and contributors. GPL-3.0-or-later. + + +PLIST +if [ -n "$identity" ]; then + codesign --force --options runtime --timestamp --sign "$identity" "$un" + codesign --verify --strict --verbose=2 "$un" +fi + +mkdir -p "$(dirname "$out")" +rm -f "$out" +extra=() +if [ -n "$licenses" ]; then + [ -f "$licenses/LICENSE" ] && [ -f "$licenses/NOTICE" ] || { echo "--licenses has no LICENSE/NOTICE: $licenses" >&2; exit 2; } + extra=(-D licenses="$licenses") +fi +python3 -m dmgbuild -s "$here/dmg_settings.py" \ + -D app="$app" -D uninstaller="$un" -D here="$here" ${extra[@]+"${extra[@]}"} \ + "Redrob Query" "$out" +if [ -n "$identity" ]; then + codesign --force --timestamp --sign "$identity" "$out" + codesign --verify --verbose=2 "$out" +fi +hdiutil verify -quiet "$out" +echo "built $out ($(du -h "$out" | cut -f1))" diff --git a/installer/macos/res/background.html b/installer/macos/res/background.html new file mode 100644 index 0000000..c23a7e0 --- /dev/null +++ b/installer/macos/res/background.html @@ -0,0 +1,55 @@ + + + + + + + + +
+
+ Redrob Query +
Drag the app onto Applications to install it.
+ + + +
+ + diff --git a/installer/macos/res/background.png b/installer/macos/res/background.png new file mode 100644 index 0000000..fc24ed1 Binary files /dev/null and b/installer/macos/res/background.png differ diff --git a/installer/macos/res/background@2x.png b/installer/macos/res/background@2x.png new file mode 100644 index 0000000..4cf8337 Binary files /dev/null and b/installer/macos/res/background@2x.png differ diff --git a/installer/macos/res/icon.png b/installer/macos/res/icon.png new file mode 100644 index 0000000..1e5101f Binary files /dev/null and b/installer/macos/res/icon.png differ diff --git a/installer/macos/uninstaller/uninstall.sh b/installer/macos/uninstaller/uninstall.sh new file mode 100644 index 0000000..7a899a9 --- /dev/null +++ b/installer/macos/uninstaller/uninstall.sh @@ -0,0 +1,109 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-or-later +# Uninstall Redrob Query.app -- the macOS uninstaller shipped on the disk image. +# +# macOS has no uninstall entry to register, so this is what removes Redrob Query: it asks first, moves the app +# to the Trash (so a mistake is one drag to undo), and only when asked, deletes what Redrob Query keeps in the +# Library. It never closes a running Redrob Query, which may hold unsaved edits. +# +# (no arguments) the dialogs +# --silent [--delete-data] no dialogs, for scripted removal and for tests; exit code is the result +# +# The app keeps its state in ~/Library/Application Support/Redrob Query (apps/redrob-query +# services::config_dir). The "Redrob/Redrob Query" paths are where the 0.6 shell kept its settings, +# console key and AI engine; they are removed too so an upgrade-then-uninstall leaves nothing behind. +# The shared "Redrob" folders hold other Redrob apps' data and are left alone. +set -u + +silent=0 +delete_data=0 +for arg in "$@"; do + case "$arg" in + --silent) silent=1 ;; + --delete-data) delete_data=1 ;; + esac +done + +TITLE="Uninstall Redrob Query" +ICON="$(cd "$(dirname "$0")/../Resources" && pwd)/AppIcon.icns" + +# One AppleScript dialog. Prints the button pressed; empty when the person pressed Cancel or Esc. +ask() { # message, default button, buttons... + local message="$1" default="$2"; shift 2 + local buttons="" b + for b in "$@"; do buttons="${buttons:+$buttons, }\"$b\""; done + local icon_clause="with icon caution" + [ -f "$ICON" ] && icon_clause="with icon (POSIX file \"$ICON\" as alias)" + osascript - "$message" </dev/null +on run argv + try + set r to display dialog (item 1 of argv) with title "$TITLE" buttons {$buttons} default button "$default" $icon_clause + return button returned of r + on error number -128 + return "" + end try +end run +OSA +} + +say_done() { # message + [ "$silent" -eq 1 ] && return 0 + ask "$1" "OK" "OK" >/dev/null +} + +apps=() +for candidate in "/Applications/Redrob Query.app" "$HOME/Applications/Redrob Query.app"; do + [ -d "$candidate" ] && apps+=("$candidate") +done + +if [ "${#apps[@]}" -eq 0 ]; then + say_done "Redrob Query is not in Applications, so there is nothing to remove." + exit 3 +fi + +# Only a Redrob Query started from a copy being removed blocks it; one opened from a download folder does not. +for app in "${apps[@]}"; do + if pgrep -f "^$app/Contents/MacOS/" >/dev/null 2>&1; then + say_done "Redrob Query is open. Save your work, quit it, then open the uninstaller again." + exit 4 + fi +done + +if [ "$silent" -eq 0 ]; then + answer="$(ask "Remove Redrob Query from this Mac? It goes to the Trash. Your databases are not touched." \ + "Uninstall" "Cancel" "Uninstall")" + [ "$answer" = "Uninstall" ] || exit 1 + answer="$(ask "Also delete its settings and connection profiles? Keep them if you may install Redrob Query again. Passwords stay in Keychain." \ + "Keep" "Delete" "Keep")" + [ -n "$answer" ] || exit 1 + [ "$answer" = "Delete" ] && delete_data=1 +fi + +# To the Trash, not deleted: a name already in the Trash gets a timestamp rather than being replaced. +trash="$HOME/.Trash" +mkdir -p "$trash" +for app in "${apps[@]}"; do + target="$trash/Redrob Query.app" + [ -e "$target" ] && target="$trash/Redrob Query $(date +%Y-%m-%d\ %H.%M.%S).app" + if ! mv "$app" "$target" 2>/dev/null; then + say_done "Redrob Query could not be moved to the Trash. Drag it from Applications to the Trash instead." + exit 5 + fi +done + +if [ "$delete_data" -eq 1 ]; then + defaults delete "com.redrob.Redrob Query" >/dev/null 2>&1 # also flushes cfprefsd's cached copy + for path in \ + "$HOME/Library/Application Support/Redrob Query" \ + "$HOME/Library/Preferences/com.redrob.Redrob Query.plist" \ + "$HOME/Library/Application Support/Redrob/Redrob Query" \ + "$HOME/Library/Preferences/Redrob/Redrob Query" \ + "$HOME/Library/Caches/Redrob/Redrob Query" \ + "$HOME/Library/Saved Application State/ai.redrob.query.savedState"; do + rm -rf -- "$path" + done + say_done "Redrob Query is in the Trash, and its settings and AI engine were deleted." +else + say_done "Redrob Query is in the Trash. Its settings stay on this Mac for a later install." +fi +exit 0 diff --git a/installer/windows/README.md b/installer/windows/README.md new file mode 100644 index 0000000..4177a10 --- /dev/null +++ b/installer/windows/README.md @@ -0,0 +1,72 @@ +# Redrob Query: Windows setup + +`redrob_query_setup.cpp` is the branded Windows setup and its uninstaller, in one small native Win32 app +(Direct2D, DirectWrite, WIC; nothing beyond the Windows SDK). It is the Redrob Launcher setup +(`redrob-labs/redrob-launcher`, `installer/windows/`, our own code, itself from the Redrob Canvas and Redrob +Browser setups) with Redrob Query's payload layout and wording: one wizard look across the Redrob apps, a +language page, an `.exe` that carries the Redrob icon and builds one SmartScreen reputation. + +For Redrob Launcher the installed product is **ProductName `Redrob Query`**, the executable is +**`bin\redrob-query.exe`** under `%LOCALAPPDATA%\Programs\Redrob Query`, and the Settings > Apps entry is +`HKCU\Software\Microsoft\Windows\CurrentVersion\Uninstall\RedrobQuery` with publisher `Redrob`. + +## What it does + +- **Language first.** The first screen is bilingual and offers English / 한국어; every screen after it, the + window title included, follows the choice. It is preselected from a previous install's answer, else + Korean on a Korean Windows, else English (`--lang en|ko` overrides). The answer goes to + `HKCU\Software\Redrob\Redrob Query\Language` so the next setup preselects it. +- **Install**, per user, no administrator rights, into `%LOCALAPPDATA%\Programs\Redrob Query`. The payload is + the release's own signed `-portable.zip` (`bin\redrob-query.exe` and the licence set, from + `tools/release/stage.py`), unpacked with System32's `tar.exe` beside the old install and swapped in, so a + failed update never leaves a half-updated folder. A running Redrob Query is never closed for the person: + the setup asks them to close it, then Retry. +- **Shortcuts**: Start menu always, desktop when the welcome screen's box is ticked (the choice is + remembered for the next update). Both carry the AppUserModelID `ai.redrob.query`, set through + `IPropertyStore`, which Windows needs for toast notifications from an unpackaged app. +- **Registry**: `InstallDir`, `Language`, `DesktopShortcutChoice` under `HKCU\Software\Redrob\Redrob Query`; + the App Paths entry for Win+R `redrob-query`; and the **Settings > Apps** entry + (`...\Uninstall\RedrobQuery`: name, version, publisher, icon, size, `UninstallString`). +- **Uninstall**: the same app built without a payload, written into the install folder as + `Uninstall Redrob Query.exe`. It asks first, with "Also delete its settings and connection profiles" off by + default, then removes the shortcuts, the entries and the folder. With the box: `%APPDATA%\Redrob Query` + (connection profiles, workspace, `ui.ron`) and the registry key. Passwords live in the Windows Credential + Manager and are not touched. `%APPDATA%\Redrob\Shared` (the launcher's sign-in every Redrob app reads) is + never touched. + +## Flags + +| Flag | Does | +| --- | --- | +| (none) | the setup window; the uninstaller build opens its confirm screen | +| `--lang en` / `--lang ko` | preselect the language | +| `--uninstall` | the uninstall window (what Settings > Apps runs) | +| `--silent` | the same work with no window; the exit code is the result (0 = success, `0xE0000001` = Redrob Query is running). Never opens the app afterwards | +| `--delete-data` | with `--uninstall --silent`: also delete settings and connection profiles | +| `--demo`, `--demo-error`, `--demo-uninstall` | the window's states on a timer, touching nothing | +| `--render-stages ` | draw every screen (both languages) to PNGs in ``, no window | + +## Build + +Visual Studio with the C++ x64 toolset (found with `vswhere`) and Python 3: + +``` +py -3 installer\windows\build.py --out --arch x64 --payload redrob-query-X.Y.Z-windows-x64-portable.zip --version X.Y.Z +``` + +The release workflow builds it in two stages so every binary is signed before it is embedded: +`--uninstaller-only`, sign that, then `--payload … --uninstaller --name …-setup.exe`, sign that +(`.github/workflows/release.yml`, Windows leg; signing is the shared `redrob-labs/.github/actions/sign-windows` +action through Azure Artifact Signing). + +The art is committed: `res/background.png` is `res/background.html` rasterised at 1320x800 (shared with the +other Redrob setups); `res/lockup.png` (the Redrob Query lockup, white, scaled) and +`resources/icons/redrob-query.ico` come from `scripts/build-brand-assets.py` and are pinned in +`brand/SOURCES.json`. + +## Verify + +`verify-install.ps1` runs on a Windows machine against a built setup: install, shortcuts, the Settings entry, +the signed exe, an upgrade over the install, an update refused while Redrob Query runs, and the uninstall. It +does not exercise `--delete-data`, which would delete that machine's real connection profiles. Both languages: +`--lang ko` and `--lang en`. diff --git a/installer/windows/build.py b/installer/windows/build.py new file mode 100644 index 0000000..50d45d3 --- /dev/null +++ b/installer/windows/build.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Build the Redrob Query Windows setup (and its uninstaller). + +Standard library only. It: + 1. locates Visual Studio (vswhere) and runs rc.exe / cl.exe under its developer environment for the + requested --arch (x64, x86 or arm64; the x86 and arm64 compilers are cross compilers hosted on x64); + 2. compiles redrob_query_setup.rc + redrob_query_setup.cpp twice: + uninstall/Uninstall Redrob Query.exe no payload; this IS the uninstaller (--uninstall) + embeds the release zip and the uninstaller above + +Usage: + build.py --out --arch x64 --payload --version 0.4.0 [--name ] + build.py --out --version 0.4.0 # demo-only build (REDROB_NO_PAYLOAD): --demo flags only + +The payload is the release's own -portable.zip (tools/release/stage.py: bin/redrob-query.exe and the +licence set), already Authenticode-signed inside, so the setup must be built after signing and then signed +itself (release workflow, Windows leg). + +The art is committed, not rendered here: res/background.png is res/background.html rasterised at 1320x800 +(shared with the other Redrob setups); res/lockup.png and resources/icons/redrob-query.ico are built by +scripts/build-brand-assets.py from the design system delivery and pinned in brand/SOURCES.json. Pretendard +is embedded when the design system delivery is present (--design-system, default ../redrob-design-system) +and fontTools is installed; otherwise the window uses Segoe UI Variable (DirectWrite falls back to a system +font for Hangul). + +Adapted from redrob-labs/redrob-launcher installer/windows/build.py (our own code). +""" +from __future__ import annotations + +import argparse +import os +import re +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + +HERE = Path(__file__).resolve().parent +RES = HERE / "res" +REPO = HERE.parent.parent +DEFAULT_DS = REPO.parent / "redrob-design-system" +ICON = REPO / "resources" / "icons" / "redrob-query.ico" +BACKGROUND = RES / "background.png" +LOCKUP_PNG = RES / "lockup.png" + + +def find_vswhere() -> Path: + pf = os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)") + vswhere = Path(pf) / "Microsoft Visual Studio" / "Installer" / "vswhere.exe" + if vswhere.exists(): + return vswhere + found = shutil.which("vswhere") + if found: + return Path(found) + sys.exit("vswhere.exe not found; install Visual Studio with the C++ x64 toolset.") + + +# vcvarsall.bat's name for each target, hosted on an x64 machine. +VCVARS_ARCH = {"x64": "x64", "x86": "x64_x86", "arm64": "x64_arm64"} + + +def vs_dev_env(arch: str) -> dict: + """The environment of a developer command prompt for `arch` (x64, x86 or arm64; x64 host).""" + vsinstall = subprocess.run( + [str(find_vswhere()), "-latest", "-products", "*", + "-requires", "Microsoft.VisualStudio.Component.VC.Tools.x86.x64", + "-property", "installationPath"], + capture_output=True, text=True, check=True).stdout.strip() + if not vsinstall: + sys.exit("No Visual Studio install with the C++ x64 toolset found.") + vcvars = Path(vsinstall) / "VC" / "Auxiliary" / "Build" / "vcvarsall.bat" + if not vcvars.exists(): + sys.exit(f"vcvarsall.bat missing under {vsinstall}") + # vcvars can leave a non-zero errorlevel on success, so key off a marker and a sane env instead. + # A real batch file carries vcvars' variables into `set`; an inline `cmd /c "call ... & set"` does not. + marker = "__REDROB_ENV__" + bat = Path(tempfile.gettempdir()) / "_redrob_query_vcvars.bat" + bat.write_text(f'@echo off\r\ncall "{vcvars}" {VCVARS_ARCH[arch]}\r\necho {marker}\r\nset\r\n', encoding="ascii") + try: + out = subprocess.run(["cmd", "/c", str(bat)], capture_output=True, text=True, errors="replace").stdout + finally: + bat.unlink(missing_ok=True) + env, seen = {}, False + for line in out.splitlines(): + if line.strip() == marker: + seen = True + elif seen and "=" in line: + k, v = line.split("=", 1) + env[k] = v + keys = {k.upper() for k in env} + if "PATH" not in keys or "INCLUDE" not in keys: + sys.exit("vcvarsall did not produce a developer environment (no INCLUDE/PATH).") + env.setdefault("SystemRoot", os.environ.get("SystemRoot", r"C:\Windows")) + return env + + +def prepare_font(design_system: Path, out_ttf: Path) -> bool: + """Pretendard SemiBold. AddFontMemResourceEx needs TTF/OTF; the design system ships woff2.""" + woff2 = design_system / "fonts" / "Pretendard-SemiBold.woff2" + if not woff2.exists(): + return False + try: + from fontTools.ttLib import TTFont # type: ignore + font = TTFont(str(woff2)) + font.flavor = None + font.save(str(out_ttf)) + return out_ttf.exists() + except Exception: + print("note: fontTools not available; the window uses Segoe UI Variable.") + return False + + +def run_in_env(cmd: list, env: dict, cwd: str) -> None: + """CreateProcess resolves the program on the PARENT's PATH, so resolve it on the dev env's first.""" + path = next((v for k, v in env.items() if k.upper() == "PATH"), None) + resolved = shutil.which(cmd[0], path=path) + if resolved: + cmd = [resolved, *cmd[1:]] + print("$", " ".join(cmd), flush=True) + subprocess.run(cmd, env=env, check=True, cwd=cwd) + + +def compile_app(env, out: Path, exe_name: str, font, ver: str, payload, uninstaller, machine: str = "X64") -> Path: + """rc + cl + link into out/exe_name. Paths reach RC through a generated header with forward slashes: + RC's preprocessor reads backslashes in string macros as escapes.""" + def fwd(p) -> str: + return str(p).replace("\\", "/") + + out.mkdir(parents=True, exist_ok=True) + parts = (ver.split(".") + ["0", "0", "0", "0"])[:4] + lines = [ + "// generated by build.py -- do not edit", + f'#define REDROB_BACKGROUND_PATH "{fwd(BACKGROUND)}"', + f'#define REDROB_LOCKUP_PATH "{fwd(LOCKUP_PNG)}"', + f'#define REDROB_ICON_PATH "{fwd(ICON)}"', + f"#define REDROB_VER_FILE {','.join(parts)}", + f'#define REDROB_VER_STR "{ver}"', + f'#define REDROB_VER_WSTR L"{ver}"', + f'#define REDROB_SETUP_KIND "{"Uninstaller" if payload is None else "Setup"}"', + ] + if font: + lines.append(f'#define REDROB_FONT_PATH "{fwd(font)}"') + if payload: + lines.append(f'#define REDROB_PAYLOAD_PATH "{fwd(payload)}"') + else: + lines.append("#define REDROB_NO_PAYLOAD 1") + if uninstaller: + lines.append(f'#define REDROB_UNINSTALLER_PATH "{fwd(uninstaller)}"') + (out / "build_defs.h").write_text("\n".join(lines) + "\n", encoding="ascii") + + res = out / "redrob_query_setup.res" + run_in_env(["rc.exe", "/nologo", f"/I{out}", f"/I{HERE}", f"/fo{res}", str(HERE / "redrob_query_setup.rc")], + env, str(HERE)) + exe = out / exe_name + obj = out / "redrob_query_setup.obj" + run_in_env(["cl.exe", "/nologo", "/EHsc", "/O2", "/W3", "/WX", "/MT", "/std:c++17", "/utf-8", + "/DUNICODE", "/D_UNICODE", f"/I{out}", f"/Fo{obj}", str(HERE / "redrob_query_setup.cpp"), + "/link", "/SUBSYSTEM:WINDOWS", "/MANIFEST:NO", f"/MACHINE:{machine}", str(res), f"/OUT:{exe}"], + env, str(out)) + if not exe.exists(): + sys.exit(f"link produced no {exe_name}") + return exe + + +def main() -> None: + ap = argparse.ArgumentParser() + ap.add_argument("--out", required=True) + ap.add_argument("--payload", help="the release's windows zip (omit for a demo-only build)") + ap.add_argument("--version", required=True, help="e.g. 0.4.0") + ap.add_argument("--arch", default="x64", choices=sorted(VCVARS_ARCH), help="target architecture (default x64)") + ap.add_argument("--name", default="Redrob Query Setup.exe", help="output file name of the setup") + ap.add_argument("--design-system", default=str(DEFAULT_DS)) + ap.add_argument("--uninstaller-only", action="store_true", + help="build only /uninstall/Uninstall Redrob Query.exe, so it can be signed first") + ap.add_argument("--uninstaller", help="an already built (and signed) uninstaller to embed, instead of " + "building one") + args = ap.parse_args() + + if not re.fullmatch(r"\d+\.\d+\.\d+", args.version): + sys.exit(f"--version must be X.Y.Z, got {args.version!r}") + for f in (BACKGROUND, LOCKUP_PNG, ICON): + if not f.exists(): + sys.exit(f"missing committed asset {f}; run installer/windows/build-art.py / packaging/brand-assets.sh") + + out = Path(args.out).resolve() + out.mkdir(parents=True, exist_ok=True) + env = vs_dev_env(args.arch) + machine = {"x64": "X64", "x86": "X86", "arm64": "ARM64"}[args.arch] + font = out / "Pretendard-SemiBold.ttf" + font = font if prepare_font(Path(args.design_system), font) else None + + if args.uninstaller_only: + exe = compile_app(env, out / "uninstall", "Uninstall Redrob Query.exe", font, args.version, + payload=None, uninstaller=None, machine=machine) + elif args.payload: + payload = Path(args.payload).resolve() + if not payload.is_file(): + sys.exit(f"payload not found: {payload}") + if args.uninstaller: + uninstaller = Path(args.uninstaller).resolve() + if not uninstaller.is_file(): + sys.exit(f"uninstaller not found: {uninstaller}") + else: + uninstaller = compile_app(env, out / "uninstall", "Uninstall Redrob Query.exe", font, args.version, + payload=None, uninstaller=None, machine=machine) + exe = compile_app(env, out, args.name, font, args.version, payload=payload, uninstaller=uninstaller, machine=machine) + else: + exe = compile_app(env, out, args.name, font, args.version, payload=None, uninstaller=None, machine=machine) + print(f"\nbuilt {exe} {exe.stat().st_size // 1024} KB (payload: {'yes' if args.payload else 'none'})", flush=True) + + +if __name__ == "__main__": + main() diff --git a/installer/windows/redrob_query_setup.cpp b/installer/windows/redrob_query_setup.cpp new file mode 100644 index 0000000..3552e9a --- /dev/null +++ b/installer/windows/redrob_query_setup.cpp @@ -0,0 +1,1494 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// redrob_query_setup -- the branded Windows setup and uninstaller for Redrob Query. +// +// Adapted from the Redrob Launcher setup (redrob-labs/redrob-launcher, installer/windows/), itself adapted +// from the Redrob Canvas and Redrob Browser setup windows; all our own code. The window and the design-system +// rules are the same. The first screen asks the language (English or Korean) and every later screen follows +// it; a "desktop shortcut" box is the one option; there is no Redrob Code opt-in. +// +// What it is: a native Win32 app, no dependencies beyond the Windows SDK. It renders a small borderless, +// rounded, DWM-shadowed window with the Redrob Query lockup, a status line and a progress bar. +// +// Design system (redrob-design-system): +// * Ground "deep" (20-color.md): field is Redrob Black #0A0B0C. type is gray-1 / gray-5; the call to +// action is a white pill with black type. +// * "Blue acts" (20-color.md): the progress fill is action-primary, Redrob Blue #2B52FF. +// * Foot light is `wash-edge` from the brand gradient, pre-rendered into res/background.png from +// res/background.html. +// * Type is Pretendard (30-typography.md, 35-korean.md: it carries Hangul), embedded in-memory when +// available, else Segoe UI Variable with DirectWrite's font fallback for Hangul. +// * Motion: every transition under a third of a second (40-surfaces-and-motion.md). +// +// Language: the first screen is bilingual and offers English / 한국어. The choice is stored under +// HKCU\Software\Redrob\Redrob Query\Language (en, ko) so the next setup preselects it. The screen defaults +// to the value a previous install recorded, else Korean on a Korean Windows, else English; --lang en|ko +// overrides. +// +// Install (per user, no administrator rights): +// 1. The payload is the release's own signed portable zip (bin\redrob-query.exe and the licence set, +// tools/release/stage.py), embedded as RCDATA. It is written to %TEMP% and unpacked with Windows' own +// tar.exe (bsdtar, in System32 since Windows 10 1803, reads zip) into %LOCALAPPDATA%\Programs\Redrob +// Query.new. +// 2. The old install, if any, is renamed aside and the new one renamed into place, so a failed update +// never leaves half of two versions. A running Redrob Query keeps its files locked; the setup asks the +// person to close it, then Retry. +// 3. The uninstaller (this same app built without a payload) is written into the install folder; the +// Start-menu shortcut (with the AppUserModelID ai.redrob.query, which Windows needs to show toast +// notifications from an unpackaged app) and, if chosen, the desktop shortcut are made; the +// Settings > Apps entry, the App Paths entry (Win+R "redrob-query") and the app's own key +// (InstallDir, Language, DesktopShortcutChoice) are written under HKCU. +// 4. Redrob Query is opened (not with --silent). +// The steps report no progress of their own, so the bar is a time-based eased ramp capped at ~90% until +// they finish. +// +// Uninstall (Settings > Apps, or the uninstaller directly): confirm, with "Also delete settings" off by +// default; remove the shortcuts, the registry entries and the install folder. The running uninstaller +// cannot delete itself, so a hidden PowerShell removes it and the folder once it exits. The shared account +// and language files (%APPDATA%\Redrob\Shared) belong to every Redrob app and are never touched. +// +// Flags: +// --lang en|ko start in that language (skips nothing: the language screen still shows, preselected). +// --silent install (or, with --uninstall, remove) with no window; the exit code is the result. +// --delete-data with --uninstall --silent: also delete the app's settings (connection profiles). +// --demo run the whole install sequence on a timer with NO payload. +// --demo-error show the error state immediately. +// --uninstall remove Redrob Query (what Settings > Apps runs). +// --demo-uninstall the uninstall sequence on a timer, removing nothing. +// --render-stages DIR draw each screen to a PNG in DIR, no window. +// (no flag) install the embedded payload for real. + +#ifndef UNICODE +#define UNICODE +#endif +#ifndef NOMINMAX +#define NOMINMAX // keep windows.h's min/max macros from breaking std::min/std::max +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include // so propkey.h DEFINES the PKEY_* keys in this one translation unit +#include +#include +#include +#include +#include +#include +#include + +#include "resource.h" +#include "build_defs.h" // generated by build.py: version, resource paths + +#pragma comment(lib, "dwmapi.lib") +#pragma comment(lib, "d2d1.lib") +#pragma comment(lib, "dwrite.lib") +#pragma comment(lib, "windowscodecs.lib") +#pragma comment(lib, "shell32.lib") +#pragma comment(lib, "shlwapi.lib") +#pragma comment(lib, "ole32.lib") +#pragma comment(lib, "user32.lib") +#pragma comment(lib, "gdi32.lib") +#pragma comment(lib, "advapi32.lib") +#pragma comment(lib, "propsys.lib") + +namespace { + +// ---- design-system colors (deep ground) ----------------------------------- +constexpr D2D1_COLOR_F kRedrobBlack = {0x0A / 255.0f, 0x0B / 255.0f, 0x0C / 255.0f, 1.0f}; +constexpr D2D1_COLOR_F kInkPrimary = {0xF8 / 255.0f, 0xF9 / 255.0f, 0xFB / 255.0f, 1.0f}; // gray-1 +constexpr D2D1_COLOR_F kInkMuted = {0x83 / 255.0f, 0x8A / 255.0f, 0x97 / 255.0f, 1.0f}; // ink-muted dark +constexpr D2D1_COLOR_F kActionBlue = {0x2B / 255.0f, 0x52 / 255.0f, 0xFF / 255.0f, 1.0f}; // action-primary +constexpr D2D1_COLOR_F kTrack = {0xFF / 255.0f, 0xFF / 255.0f, 0xFF / 255.0f, 0.14f}; // border-subtle on dark +constexpr D2D1_COLOR_F kDanger = {0xFF / 255.0f, 0x4B / 255.0f, 0x4B / 255.0f, 1.0f}; // accent-red-3 / status-danger +constexpr D2D1_COLOR_F kWhite = {1.0f, 1.0f, 1.0f, 1.0f}; + +// ---- geometry (logical px at 96 DPI) --------------------------------------- +constexpr int kBaseW = 660; +constexpr int kBaseH = 400; +constexpr float kRadius = 14.0f; // rounded corners +constexpr float kCaption = 44.0f; // top drag strip height + +// window messages / timers +constexpr UINT_PTR kTickTimer = 1; +constexpr UINT kTickMs = 16; // ~60fps for the eased bar / fades +constexpr UINT_PTR kPayloadMsgDone = (WM_APP + 1); +constexpr UINT_PTR kUninstallMsgDone = (WM_APP + 2); +constexpr UINT_PTR kStageMsg = (WM_APP + 3); // the worker moved on: wParam is the Stage + +// Results the install and uninstall workers post. 0 is success; anything else is a Win32 error code, except +// these, which have their own sentences. +constexpr DWORD kResultAppRunning = 0xE0000001; // Redrob Query is open from the install folder +constexpr DWORD kResultUnpackFailed = 0xE0000002; // tar.exe could not unpack the payload +constexpr DWORD kResultNoExe = 0xE0000003; // the unpacked payload has no redrob-query.exe + +// Where everything goes. Per user, like every other app that installs without administrator rights. +constexpr wchar_t kInstallSubdir[] = L"\\Programs\\Redrob Query"; // under %LOCALAPPDATA% +constexpr wchar_t kAppExeRel[] = L"\\bin\\redrob-query.exe"; // the portable zip's own layout (stage.py) +constexpr wchar_t kPortableMarker[] = L"\\portable.txt"; // not in this zip; harmless if absent +constexpr wchar_t kUninstallerName[] = L"\\Uninstall Redrob Query.exe"; +constexpr wchar_t kShortcutName[] = L"\\Redrob Query.lnk"; +constexpr wchar_t kAppUserModelId[] = L"ai.redrob.query"; +// Settings > Apps. Its own key. +constexpr wchar_t kArpKey[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\RedrobQuery"; +// The app's own key: InstallDir, Language, DesktopShortcutChoice. +constexpr wchar_t kAppKey[] = L"Software\\Redrob\\Redrob Query"; +constexpr wchar_t kAppPathsKey[] = L"Software\\Microsoft\\Windows\\CurrentVersion\\App Paths\\redrob-query.exe"; + +enum class Stage { Preparing, Installing, Opening, Error, Confirm, Removing, Removed, Welcome, Language }; +enum class Mode { Install, Uninstall }; +enum class Lang { En, Ko }; + +// The language every screen after the first draws in. Set by the language screen (or --lang, or the +// value a previous install recorded, or the Windows UI language), read by Tr() at every string. +Lang g_lang = Lang::En; +inline const wchar_t* Tr(const wchar_t* en, const wchar_t* ko) { return g_lang == Lang::Ko ? ko : en; } + +// A tiny COM pointer so cleanup is not a maze of gotos. Move-only: a plain +// memberwise copy would double-Release (and a function returning one by value +// would hand back an already-freed interface). +template struct ComPtr { + T* p = nullptr; + ComPtr() = default; + ComPtr(const ComPtr&) = delete; + ComPtr& operator=(const ComPtr&) = delete; + ComPtr(ComPtr&& o) noexcept : p(o.p) { o.p = nullptr; } + ComPtr& operator=(ComPtr&& o) noexcept { + if (std::addressof(o) != this) { if (p) p->Release(); p = o.p; o.p = nullptr; } + return *this; + } + ~ComPtr() { if (p) p->Release(); } + T** operator&() { return &p; } + T* operator->() const { return p; } + explicit operator bool() const { return p != nullptr; } + void reset() { if (p) { p->Release(); p = nullptr; } } +}; + +std::wstring g_statusOverride; + +// --------------------------------------------------------------------------- +// The app object. One window, Direct2D render target, DirectWrite text, WIC for +// the PNG resources, and a worker thread that runs the payload. +// --------------------------------------------------------------------------- +class SetupApp { + public: + SetupApp(bool demo, bool demoError, Mode mode) : demo_(demo), demoError_(demoError), mode_(mode) {} + + int Run(HINSTANCE hinst); + // --silent: the same work with no window, for scripted installs. + // The process exit code is the worker's result (0 = success). Never opens the app afterwards. + int RunSilent(HINSTANCE hinst, bool deleteData); + + private: + static LRESULT CALLBACK WndProcThunk(HWND, UINT, WPARAM, LPARAM); + LRESULT WndProc(UINT, WPARAM, LPARAM); + + void CreateDeviceResources(); + void DiscardDeviceResources(); + void OnPaint(); + void OnTick(); + void LayoutText(); + RECT ToDevice(float x, float y, float w, float h); + void computeButtonRects(float minX, float closeX, float cy); + + ComPtr LoadPngResource(int resId, ID2D1RenderTarget* rt); + HRESULT DrawScene(); // BeginDraw..EndDraw on rt_, whatever kind of target it is + public: + // --render-stages : draw each screen into a PNG with no window, so the layout can be checked + // on a machine reached over ssh, where no window can be shown. Returns 0 when every file was written. + int RenderStages(HINSTANCE hinst, const std::wstring& dir); + private: + + // payload + static DWORD WINAPI PayloadThunk(LPVOID self); + DWORD RunPayload(); + DWORD InstallApp(); + bool ExtractPayload(std::wstring& outPath); + bool InstallUninstaller(const std::wstring& dir); + void RecordChoices(const std::wstring& dir); + + void StartInstall(); + + // uninstall + static DWORD WINAPI UninstallThunk(LPVOID self); + DWORD RunUninstall(); + void StartUninstall(); + void ScheduleSelfCleanup(); + + void SetStage(Stage s); + void RequestClose(bool force); + void ChooseLanguage(Lang l); + + HINSTANCE hinst_ = nullptr; + HWND hwnd_ = nullptr; + UINT dpi_ = 96; + bool demo_ = false; + bool demoError_ = false; + Mode mode_ = Mode::Install; + bool deleteData_ = false; // the confirm screen's "also delete settings" + bool silent_ = false; + bool desktopShortcut_ = true; // the welcome screen's "create a desktop shortcut"; a previous choice is kept + + // Direct2D / DirectWrite / WIC + ComPtr d2dFactory_; + ComPtr dwFactory_; + ComPtr wicFactory_; + ComPtr rt_; + ComPtr bgBitmap_; + ComPtr lockupBitmap_; + ComPtr fmtStatus_; + ComPtr fmtButton_; + ComPtr fontColl_; + std::wstring fontFamily_ = L"Segoe UI Variable Display"; + + // state + Stage stage_ = Stage::Preparing; + double progress_ = 0.0; // 0..1 displayed + double progressTarget_ = 0.0; + ULONGLONG startTick_ = 0; + ULONGLONG openedAt_ = 0; + ULONGLONG stageTick_ = 0; // when the current stage began; the install ramps from here + bool payloadDone_ = false; + DWORD payloadExit_ = 0; + HANDLE payloadThread_ = nullptr; + std::wstring errorMessage_; + std::wstring extractedPayload_; + bool closing_ = false; + + // hit-tested button rects (device px), recomputed each paint + RECT rcMin_{}, rcClose_{}, rcRetry_{}, rcErrClose_{}, rcCheck_{}; + bool confirmingQuit_ = false; +}; + +SetupApp* g_app = nullptr; + +std::wstring StatusText(Stage s) { + if (!g_statusOverride.empty()) return g_statusOverride; + switch (s) { + case Stage::Language: return L"Choose your language \u00B7 언어를 선택하세요"; + case Stage::Welcome: return Tr(L"Install Redrob Query", L"Redrob Query 설치"); + case Stage::Preparing: return Tr(L"Preparing\u2026", L"준비 중\u2026"); + case Stage::Installing: return Tr(L"Installing\u2026", L"설치 중\u2026"); + case Stage::Opening: return Tr(L"Opening Redrob Query\u2026", L"Redrob Query 여는 중\u2026"); + case Stage::Error: return Tr(L"Something went wrong", L"문제가 생겼어요"); + case Stage::Confirm: return Tr(L"Uninstall Redrob Query?", L"Redrob Query를 제거할까요?"); + case Stage::Removing: return Tr(L"Removing Redrob Query\u2026", L"Redrob Query 제거 중\u2026"); + case Stage::Removed: return Tr(L"Redrob Query has been removed", L"Redrob Query를 제거했어요"); + } + return L""; +} + +// What the error screen says for a worker's result. Retry is on the screen, so each one says what to do. +std::wstring ResultSentence(DWORD code, bool uninstall) { + if (code == kResultAppRunning) + return uninstall ? Tr(L"Redrob Query is open. Close it, then press Retry.", + L"Redrob Query가 실행 중이에요. 창을 닫은 뒤 다시 시도를 누르세요.") + : Tr(L"Redrob Query is open. Close it, then press Retry to update it.", + L"Redrob Query가 실행 중이에요. 창을 닫은 뒤 다시 시도를 눌러 업데이트하세요."); + if (code == kResultUnpackFailed) + return Tr(L"The app files could not be unpacked. Please try again.", L"앱 파일을 풀 수 없었어요. 다시 시도해 주세요."); + if (code == kResultNoExe) + return Tr(L"This setup file is damaged. Please download it again.", L"이 설치 파일이 손상됐어요. 다시 내려받아 주세요."); + if (code == ERROR_DISK_FULL || code == ERROR_HANDLE_DISK_FULL) + return Tr(L"There is not enough free disk space. Free some space, then press Retry.", + L"디스크 공간이 부족해요. 공간을 비운 뒤 다시 시도를 누르세요."); + if (code == ERROR_ACCESS_DENIED || code == ERROR_SHARING_VIOLATION) + return Tr(L"A file is in use. Close Redrob Query and anything opened from its folder, then press Retry.", + L"파일이 사용 중이에요. Redrob Query와 그 폴더에서 연 것을 모두 닫은 뒤 다시 시도를 누르세요."); + const std::wstring n = std::to_wstring(code); + if (uninstall) + return g_lang == Lang::Ko ? L"Redrob Query를 제거하지 못했어요 (코드 " + n + L"). 다시 시도해 주세요." + : L"Redrob Query could not be removed (code " + n + L"). Please try again."; + return g_lang == Lang::Ko ? L"설치를 마치지 못했어요 (코드 " + n + L"). 다시 시도해 주세요." + : L"Setup did not complete (code " + n + L"). Please try again."; +} + +// --------------------------------------------------------------------------- + +// The language a previous install recorded (en, ko), or nothing. +static std::wstring RecordedLanguage() { + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kAppKey, 0, KEY_READ, &key) != ERROR_SUCCESS) return L""; + wchar_t buf[16] = {}; + DWORD size = sizeof(buf), type = 0; + std::wstring out; + if (RegQueryValueExW(key, L"Language", nullptr, &type, reinterpret_cast(buf), &size) == ERROR_SUCCESS && type == REG_SZ) + out = buf; + RegCloseKey(key); + return out; +} + +// The desktop-shortcut choice a previous install recorded: 1, 0, or -1 for none. +static int RecordedDesktopShortcut() { + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kAppKey, 0, KEY_READ, &key) != ERROR_SUCCESS) return -1; + wchar_t buf[8] = {}; + DWORD size = sizeof(buf), type = 0; + int out = -1; + if (RegQueryValueExW(key, L"DesktopShortcutChoice", nullptr, &type, reinterpret_cast(buf), &size) == ERROR_SUCCESS && type == REG_SZ) + out = (buf[0] == L'1') ? 1 : 0; + RegCloseKey(key); + return out; +} + +// The language to preselect: --lang, else the recorded one, else Korean on a Korean Windows, else English. +static Lang DefaultLanguage(const std::wstring& flag) { + if (lstrcmpiW(flag.c_str(), L"ko") == 0) return Lang::Ko; + if (lstrcmpiW(flag.c_str(), L"en") == 0) return Lang::En; + const std::wstring recorded = RecordedLanguage(); + if (lstrcmpiW(recorded.c_str(), L"ko") == 0) return Lang::Ko; + if (lstrcmpiW(recorded.c_str(), L"en") == 0) return Lang::En; + return PRIMARYLANGID(GetUserDefaultUILanguage()) == LANG_KOREAN ? Lang::Ko : Lang::En; +} + +int SetupApp::Run(HINSTANCE hinst) { + hinst_ = hinst; + g_app = this; + + SetProcessDpiAwarenessContext(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2); + HRESULT hr = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + (void)hr; + + D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, &d2dFactory_); + DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), + reinterpret_cast(&dwFactory_)); + CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(&wicFactory_)); + + WNDCLASSEX wc = {sizeof(wc)}; + wc.style = CS_HREDRAW | CS_VREDRAW; + wc.lpfnWndProc = &SetupApp::WndProcThunk; + wc.hInstance = hinst; + wc.hCursor = LoadCursor(nullptr, IDC_ARROW); + wc.lpszClassName = L"RedrobQuerySetupWindow"; + wc.hIcon = LoadIconW(hinst, MAKEINTRESOURCEW(IDI_APPICON)); + wc.hIconSm = wc.hIcon; + RegisterClassEx(&wc); + + // Center on the primary monitor's work area. + dpi_ = GetDpiForSystem(); + int w = MulDiv(kBaseW, dpi_, 96); + int h = MulDiv(kBaseH, dpi_, 96); + RECT wa{}; + SystemParametersInfo(SPI_GETWORKAREA, 0, &wa, 0); + int x = wa.left + ((wa.right - wa.left) - w) / 2; + int y = wa.top + ((wa.bottom - wa.top) - h) / 2; + + // Borderless (WS_POPUP). WS_THICKFRAME is kept out; a DWM shadow is attached + // separately so a popup still casts one. + hwnd_ = CreateWindowExW(WS_EX_APPWINDOW, wc.lpszClassName, + mode_ == Mode::Uninstall ? Tr(L"Uninstall Redrob Query", L"Redrob Query 제거") + : Tr(L"Redrob Query Setup", L"Redrob Query 설치"), + WS_POPUP, x, y, w, h, nullptr, nullptr, hinst, this); + if (!hwnd_) return 2; + + // DWM: a real shadow on a borderless window + rounded corners (Win11). + MARGINS m = {1, 1, 1, 1}; + DwmExtendFrameIntoClientArea(hwnd_, &m); + DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUND; + DwmSetWindowAttribute(hwnd_, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner)); + BOOL dark = TRUE; + DwmSetWindowAttribute(hwnd_, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark)); + + LayoutText(); + + startTick_ = GetTickCount64(); + if (mode_ == Mode::Uninstall) { + SetStage(Stage::Confirm); + } else if (demoError_) { + errorMessage_ = L"The installer could not be started. Please try again."; + SetStage(Stage::Error); + } else { + // Nothing is installed until the person presses Install. The language comes first: every screen + // after it, every later screen follows the choice. + const int previous = RecordedDesktopShortcut(); + if (previous >= 0) desktopShortcut_ = previous == 1; + SetStage(Stage::Language); + } + + ShowWindow(hwnd_, SW_SHOW); + UpdateWindow(hwnd_); + + MSG msg; + while (GetMessage(&msg, nullptr, 0, 0) > 0) { + TranslateMessage(&msg); + DispatchMessage(&msg); + } + if (payloadThread_) { + WaitForSingleObject(payloadThread_, 5000); + CloseHandle(payloadThread_); + } + // Every COM object goes BEFORE CoUninitialize. Left to the destructor, the WIC factory was released after + // COM was torn down, and the process died with an access violation on its way out of a successful install. + DiscardDeviceResources(); + fmtStatus_.reset(); + fmtButton_.reset(); + fontColl_.reset(); + wicFactory_.reset(); + dwFactory_.reset(); + d2dFactory_.reset(); + CoUninitialize(); + return (int)msg.wParam; +} + +void SetupApp::LayoutText() { + // Load Pretendard SemiBold from the embedded TTF into a private font + // collection; fall back to Segoe UI Variable if the resource is absent. + HRSRC res = FindResourceW(hinst_, MAKEINTRESOURCEW(IDR_FONT_SANS), RT_RCDATA); + if (res) { + HGLOBAL h = LoadResource(hinst_, res); + void* data = LockResource(h); + DWORD sz = SizeofResource(hinst_, res); + if (data && sz) { + DWORD nFonts = 0; + HANDLE added = AddFontMemResourceEx(data, sz, nullptr, &nFonts); + if (added) fontFamily_ = L"Pretendard SemiBold"; + } + } + dwFactory_->CreateTextFormat(fontFamily_.c_str(), nullptr, DWRITE_FONT_WEIGHT_SEMI_BOLD, + DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL, + 17.0f, L"en-us", &fmtStatus_); + if (fmtStatus_) { + fmtStatus_->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER); + fmtStatus_->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER); + } + dwFactory_->CreateTextFormat(fontFamily_.c_str(), nullptr, DWRITE_FONT_WEIGHT_SEMI_BOLD, + DWRITE_FONT_STYLE_NORMAL, DWRITE_FONT_STRETCH_NORMAL, + 13.0f, L"en-us", &fmtButton_); + if (fmtButton_) { + fmtButton_->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER); + fmtButton_->SetParagraphAlignment(DWRITE_PARAGRAPH_ALIGNMENT_CENTER); + } +} + +LRESULT CALLBACK SetupApp::WndProcThunk(HWND hwnd, UINT msg, WPARAM wp, LPARAM lp) { + if (msg == WM_NCCREATE) { + auto* cs = reinterpret_cast(lp); + SetWindowLongPtr(hwnd, GWLP_USERDATA, (LONG_PTR)cs->lpCreateParams); + } + auto* self = reinterpret_cast(GetWindowLongPtr(hwnd, GWLP_USERDATA)); + if (self) { self->hwnd_ = hwnd; return self->WndProc(msg, wp, lp); } + return DefWindowProc(hwnd, msg, wp, lp); +} + +LRESULT SetupApp::WndProc(UINT msg, WPARAM wp, LPARAM lp) { + switch (msg) { + case WM_PAINT: OnPaint(); return 0; + case WM_ERASEBKGND: return 1; + + case WM_TIMER: + if (wp == kTickTimer) OnTick(); + return 0; + + case kStageMsg: + if (stage_ != Stage::Error) SetStage((Stage)wp); + return 0; + + case kPayloadMsgDone: { + payloadDone_ = true; + payloadExit_ = (DWORD)wp; + if (payloadExit_ == 0) { + progressTarget_ = 1.0; + SetStage(Stage::Opening); + } else { + errorMessage_ = ResultSentence(payloadExit_, false); + SetStage(Stage::Error); + } + return 0; + } + + case kUninstallMsgDone: { + const DWORD code = (DWORD)wp; + if (code == 0) { + progressTarget_ = 1.0; + progress_ = 1.0; + SetStage(Stage::Removed); + if (!demo_) ScheduleSelfCleanup(); + } else { + errorMessage_ = ResultSentence(code, true); + SetStage(Stage::Error); + } + return 0; + } + + case WM_GETDPISCALEDSIZE: return TRUE; + case WM_DPICHANGED: { + dpi_ = HIWORD(wp); + RECT* r = reinterpret_cast(lp); + SetWindowPos(hwnd_, nullptr, r->left, r->top, r->right - r->left, r->bottom - r->top, + SWP_NOZORDER | SWP_NOACTIVATE); + DiscardDeviceResources(); + InvalidateRect(hwnd_, nullptr, FALSE); + return 0; + } + + case WM_SIZE: DiscardDeviceResources(); return 0; + + case WM_LBUTTONDOWN: { + POINT pt{GET_X_LPARAM(lp), GET_Y_LPARAM(lp)}; + if (PtInRect(&rcClose_, pt)) { RequestClose(false); return 0; } + if (PtInRect(&rcMin_, pt)) { ShowWindow(hwnd_, SW_MINIMIZE); return 0; } + if (stage_ == Stage::Language) { + // The two pills ARE the choice: picking one moves on, in that language. + if (PtInRect(&rcRetry_, pt)) { ChooseLanguage(Lang::En); return 0; } + if (PtInRect(&rcErrClose_, pt)) { ChooseLanguage(Lang::Ko); return 0; } + } + if (stage_ == Stage::Confirm) { + if (PtInRect(&rcCheck_, pt)) { deleteData_ = !deleteData_; InvalidateRect(hwnd_, nullptr, FALSE); return 0; } + if (PtInRect(&rcRetry_, pt)) { StartUninstall(); return 0; } // "Uninstall" + if (PtInRect(&rcErrClose_, pt)) { RequestClose(true); return 0; } // "Cancel" + } + if (stage_ == Stage::Welcome) { + if (PtInRect(&rcCheck_, pt)) { + desktopShortcut_ = !desktopShortcut_; + InvalidateRect(hwnd_, nullptr, FALSE); + return 0; + } + if (PtInRect(&rcRetry_, pt)) { StartInstall(); return 0; } // "Install" + if (PtInRect(&rcErrClose_, pt)) { SetStage(Stage::Language); return 0; } // "Back" + } + if (stage_ == Stage::Removed && PtInRect(&rcRetry_, pt)) { RequestClose(true); return 0; } // "Close" + if (stage_ == Stage::Error && mode_ == Mode::Uninstall && PtInRect(&rcRetry_, pt)) { + errorMessage_.clear(); + SetStage(Stage::Confirm); // back to the question, so a retry is still a choice + return 0; + } + if (stage_ == Stage::Error && PtInRect(&rcRetry_, pt)) { + errorMessage_.clear(); + StartInstall(); + return 0; + } + if (stage_ == Stage::Error && PtInRect(&rcErrClose_, pt)) { RequestClose(true); return 0; } + // Anywhere else on the caption strip drags the window. + if (pt.y < MulDiv((int)kCaption, dpi_, 96)) { + ReleaseCapture(); + SendMessage(hwnd_, WM_NCLBUTTONDOWN, HTCAPTION, 0); + } + return 0; + } + + case WM_KEYDOWN: + if (wp == VK_ESCAPE) { RequestClose(false); return 0; } + if (stage_ == Stage::Language) { + // Left/Right or E/K pick; Enter keeps the preselected language. + if (wp == VK_LEFT || wp == 'E') { ChooseLanguage(Lang::En); return 0; } + if (wp == VK_RIGHT || wp == 'K') { ChooseLanguage(Lang::Ko); return 0; } + if (wp == VK_RETURN) { ChooseLanguage(g_lang); return 0; } + return 0; + } + if (stage_ == Stage::Confirm && wp == VK_SPACE) { + deleteData_ = !deleteData_; + InvalidateRect(hwnd_, nullptr, FALSE); + return 0; + } + if (stage_ == Stage::Confirm && wp == VK_RETURN) { StartUninstall(); return 0; } + if (stage_ == Stage::Welcome && wp == VK_SPACE) { + desktopShortcut_ = !desktopShortcut_; + InvalidateRect(hwnd_, nullptr, FALSE); + return 0; + } + if (stage_ == Stage::Welcome && wp == VK_RETURN) { StartInstall(); return 0; } + if (stage_ == Stage::Welcome && wp == VK_BACK) { SetStage(Stage::Language); return 0; } + if (stage_ == Stage::Removed && wp == VK_RETURN) { RequestClose(true); return 0; } + return 0; + + case WM_SYSKEYDOWN: + if (wp == VK_F4) { RequestClose(false); return 0; } // Alt+F4 + return DefWindowProc(hwnd_, msg, wp, lp); + + case WM_CLOSE: + RequestClose(false); + return 0; + + case WM_DESTROY: + KillTimer(hwnd_, kTickTimer); + DiscardDeviceResources(); + PostQuitMessage(0); + return 0; + } + return DefWindowProc(hwnd_, msg, wp, lp); +} + +void SetupApp::RequestClose(bool force) { + // While installing, Esc / close ask for confirmation first (do not abandon an + // install silently). In demo and error states, close immediately. + bool installing = (stage_ == Stage::Installing || stage_ == Stage::Preparing) && !demo_ && !force && + mode_ == Mode::Install; + // A removal cannot be safely abandoned halfway; closing waits for it to finish. + if (stage_ == Stage::Removing && !force) return; + if (installing) { + int r = MessageBoxW(hwnd_, + Tr(L"Redrob Query is still installing. Stop the installation and quit?", + L"Redrob Query를 아직 설치하고 있어요. 설치를 멈추고 끝낼까요?"), + Tr(L"Redrob Query Setup", L"Redrob Query 설치"), MB_YESNO | MB_ICONWARNING | MB_DEFBUTTON2); + if (r != IDYES) return; + } + DestroyWindow(hwnd_); +} + +void SetupApp::SetStage(Stage s) { + if (s != stage_) stageTick_ = GetTickCount64(); + stage_ = s; + g_statusOverride.clear(); + if (s == Stage::Opening) openedAt_ = GetTickCount64(); + if (s == Stage::Error || s == Stage::Confirm || s == Stage::Removed || s == Stage::Welcome || s == Stage::Language) + KillTimer(hwnd_, kTickTimer); + if (hwnd_) InvalidateRect(hwnd_, nullptr, FALSE); +} + +// The language screen's answer: from here on every string, including the window title, is in `l`. +void SetupApp::ChooseLanguage(Lang l) { + g_lang = l; + if (hwnd_) SetWindowTextW(hwnd_, mode_ == Mode::Uninstall ? Tr(L"Uninstall Redrob Query", L"Redrob Query 제거") + : Tr(L"Redrob Query Setup", L"Redrob Query 설치")); + SetStage(Stage::Welcome); +} + +// The Install button (and Retry). +void SetupApp::StartInstall() { + progress_ = progressTarget_ = 0.0; + startTick_ = GetTickCount64(); + payloadDone_ = false; + SetStage(Stage::Preparing); + SetTimer(hwnd_, kTickTimer, kTickMs, nullptr); + if (!demo_) { + if (payloadThread_) { WaitForSingleObject(payloadThread_, 5000); CloseHandle(payloadThread_); } + payloadThread_ = CreateThread(nullptr, 0, &SetupApp::PayloadThunk, this, 0, nullptr); + } +} + +static double EaseOut(double t) { return 1 - (1 - t) * (1 - t); } + +void SetupApp::OnTick() { + ULONGLONG elapsed = GetTickCount64() - startTick_; + + if (mode_ == Mode::Uninstall) { + // Removal reports no progress either: the same eased ramp, over ~12s, capped until it finishes. + if (stage_ == Stage::Removing) { + double t = std::min(1.0, (GetTickCount64() - startTick_) / 12000.0); + progressTarget_ = 0.90 * (1 - (1 - t) * (1 - t)); + } + } else if (demo_ && stage_ != Stage::Error) { + // Scripted demo, same shape as a real run: Preparing (0-0.8s) -> Installing (0.8-5s, bar to ~0.9) + // -> Opening (5-6.5s) -> close. + if (elapsed < 800) { if (stage_ != Stage::Preparing) SetStage(Stage::Preparing); progressTarget_ = 0.04; } + else if (elapsed < 5000) { if (stage_ != Stage::Installing) SetStage(Stage::Installing); + progressTarget_ = 0.90 * EaseOut((elapsed - 800) / 4200.0); } + else if (elapsed < 6500) { if (stage_ != Stage::Opening) SetStage(Stage::Opening); progressTarget_ = 1.0; } + else { DestroyWindow(hwnd_); return; } + } else if (!demo_) { + // Real run. Preparing turns into Installing after 0.6s; the bar is a time-based eased ramp, capped + // until the worker finishes. + if (stage_ == Stage::Preparing && elapsed > 600) SetStage(Stage::Installing); + const double since = (double)(GetTickCount64() - stageTick_); + if (stage_ == Stage::Installing && !payloadDone_) { + progressTarget_ = 0.90 * EaseOut(std::min(1.0, since / 30000.0)); // approach over ~30s + } + if (stage_ == Stage::Opening) { + progressTarget_ = 1.0; + // Close on a fixed delay after Opening began: the app has been started (InstallApp), so + // there is nothing left for this window to do but get out of the way. + if (GetTickCount64() - openedAt_ > 1400) { DestroyWindow(hwnd_); return; } + } + } + + // Ease displayed progress toward the target (motion < 1/3 s per step). + progress_ += (progressTarget_ - progress_) * 0.12; + if (progress_ > 0.9999) progress_ = 1.0; + InvalidateRect(hwnd_, nullptr, FALSE); +} + +// ---- rendering ------------------------------------------------------------- + +void SetupApp::CreateDeviceResources() { + if (rt_) return; + RECT rc; GetClientRect(hwnd_, &rc); + D2D1_SIZE_U size = D2D1::SizeU(rc.right - rc.left, rc.bottom - rc.top); + D2D1_RENDER_TARGET_PROPERTIES props = D2D1::RenderTargetProperties( + D2D1_RENDER_TARGET_TYPE_DEFAULT, + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED)); + ComPtr hwndRt; + HRESULT hr = d2dFactory_->CreateHwndRenderTarget( + props, D2D1::HwndRenderTargetProperties(hwnd_, size, D2D1_PRESENT_OPTIONS_NONE), &hwndRt); + (void)hr; + if (!hwndRt) return; + rt_.p = hwndRt.p; // ID2D1HwndRenderTarget is an ID2D1RenderTarget; rt_ takes the reference + hwndRt.p = nullptr; + rt_->SetDpi((float)dpi_, (float)dpi_); + bgBitmap_ = LoadPngResource(IDR_BACKGROUND_PNG, rt_.p); + lockupBitmap_ = LoadPngResource(IDR_LOCKUP_PNG, rt_.p); +} + +void SetupApp::DiscardDeviceResources() { + bgBitmap_.reset(); + lockupBitmap_.reset(); + rt_.reset(); +} + +ComPtr SetupApp::LoadPngResource(int resId, ID2D1RenderTarget* rt) { + ComPtr out; + HRSRC res = FindResourceW(hinst_, MAKEINTRESOURCEW(resId), RT_RCDATA); + if (!res) return out; + HGLOBAL h = LoadResource(hinst_, res); + void* data = LockResource(h); + DWORD sz = SizeofResource(hinst_, res); + if (!data || !sz) return out; + + ComPtr stream; + wicFactory_->CreateStream(&stream); + stream->InitializeFromMemory((BYTE*)data, sz); + ComPtr dec; + if (FAILED(wicFactory_->CreateDecoderFromStream(stream.p, nullptr, + WICDecodeMetadataCacheOnLoad, &dec))) return out; + ComPtr frame; + dec->GetFrame(0, &frame); + ComPtr conv; + wicFactory_->CreateFormatConverter(&conv); + conv->Initialize(frame.p, GUID_WICPixelFormat32bppPBGRA, WICBitmapDitherTypeNone, + nullptr, 0.0, WICBitmapPaletteTypeMedianCut); + rt->CreateBitmapFromWicBitmap(conv.p, nullptr, &out); + return out; +} + +void SetupApp::OnPaint() { + CreateDeviceResources(); + PAINTSTRUCT ps; BeginPaint(hwnd_, &ps); + if (!rt_) { EndPaint(hwnd_, &ps); return; } + if (DrawScene() == D2DERR_RECREATE_TARGET) DiscardDeviceResources(); + EndPaint(hwnd_, &ps); +} + +HRESULT SetupApp::DrawScene() { + rt_->BeginDraw(); + rt_->Clear(kRedrobBlack); + + const float W = (float)kBaseW, H = (float)kBaseH; + + // Background art (deep ground + wash-edge), drawn to fill the window. + if (bgBitmap_) { + rt_->DrawBitmap(bgBitmap_.p, D2D1::RectF(0, 0, W, H), 1.0f, + D2D1_BITMAP_INTERPOLATION_MODE_LINEAR); + } + + ComPtr ink, muted, blue, track, danger, white; + rt_->CreateSolidColorBrush(kInkPrimary, &ink); + rt_->CreateSolidColorBrush(kInkMuted, &muted); + rt_->CreateSolidColorBrush(kActionBlue, &blue); + rt_->CreateSolidColorBrush(kTrack, &track); + rt_->CreateSolidColorBrush(kDanger, &danger); + rt_->CreateSolidColorBrush(kWhite, &white); + + // ---- caption glyphs (min / close), top-right ----------------------------- + const float gl = 14.0f; // glyph half-size + const float cy = kCaption / 2.0f; + const float closeX = W - 28.0f; + const float minX = W - 64.0f; + computeButtonRects(minX, closeX, cy); + // close (x) + rt_->DrawLine(D2D1::Point2F(closeX - gl / 2, cy - gl / 2), + D2D1::Point2F(closeX + gl / 2, cy + gl / 2), ink.p, 1.6f); + rt_->DrawLine(D2D1::Point2F(closeX + gl / 2, cy - gl / 2), + D2D1::Point2F(closeX - gl / 2, cy + gl / 2), ink.p, 1.6f); + // minimize (-) + rt_->DrawLine(D2D1::Point2F(minX - gl / 2, cy), + D2D1::Point2F(minX + gl / 2, cy), ink.p, 1.6f); + + // ---- lockup (product mark + name), centred in the upper third --------------- + // res/lockup.png, built by installer/windows/build-art.py as the design system builds its product + // lockups. Same size and place as the Redrob Query and Redrob Browser setups'. + if (lockupBitmap_) { + D2D1_SIZE_F ls = lockupBitmap_->GetSize(); + float lh = 34.0f; // >= 18px min screen (10-logo.md) + float lw = ls.width * (lh / ls.height); + float lx = (W - lw) / 2.0f; + float ly = H * 0.30f; + rt_->DrawBitmap(lockupBitmap_.p, D2D1::RectF(lx, ly, lx + lw, ly + lh), 1.0f, + D2D1_BITMAP_INTERPOLATION_MODE_LINEAR); + } + + // ---- status line, centered ------------------------------------------------ + std::wstring status = StatusText(stage_); + D2D1_RECT_F statusRc = D2D1::RectF(40, H * 0.46f, W - 40, H * 0.46f + 30); + if (fmtStatus_) rt_->DrawTextW(status.c_str(), (UINT32)status.size(), fmtStatus_.p, statusRc, + (stage_ == Stage::Error ? danger.p : ink.p)); + + ComPtr black; + rt_->CreateSolidColorBrush(kRedrobBlack, &black); + // A white pill (the one prominent action, black type) and an outline pill, as the error state draws them. + auto pill = [&](float x, float y, float w, float h, const wchar_t* label, bool primary) { + D2D1_ROUNDED_RECT rr = {D2D1::RectF(x, y, x + w, y + h), h / 2, h / 2}; + if (primary) rt_->FillRoundedRectangle(rr, white.p); + else rt_->DrawRoundedRectangle(rr, track.p, 1.4f); + if (fmtButton_) rt_->DrawTextW(label, (UINT32)wcslen(label), fmtButton_.p, + D2D1::RectF(x, y, x + w, y + h), primary ? black.p : ink.p); + }; + const float pw = 120.0f, ph = 38.0f, py = H * 0.72f; + + // A 16px box and its label, centred together as one row. The whole row is the hit target, not just the box. + auto checkRow = [&](const wchar_t* label, bool checked) { + float labelW = 200.0f; + ComPtr layout; + if (fmtButton_ && SUCCEEDED(dwFactory_->CreateTextLayout(label, (UINT32)wcslen(label), fmtButton_.p, + W, 24.0f, &layout))) { + layout->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING); + DWRITE_TEXT_METRICS tm{}; + if (SUCCEEDED(layout->GetMetrics(&tm))) labelW = tm.widthIncludingTrailingWhitespace; + } + const float box = 16.0f, gap = 10.0f, rowY = H * 0.615f; + const float rowX = (W - (box + gap + labelW)) / 2.0f; + D2D1_ROUNDED_RECT boxRR = {D2D1::RectF(rowX, rowY, rowX + box, rowY + box), 4.0f, 4.0f}; + if (checked) { + rt_->FillRoundedRectangle(boxRR, blue.p); + rt_->DrawLine(D2D1::Point2F(rowX + 4.0f, rowY + 8.5f), D2D1::Point2F(rowX + 7.0f, rowY + 11.5f), white.p, 1.8f); + rt_->DrawLine(D2D1::Point2F(rowX + 7.0f, rowY + 11.5f), D2D1::Point2F(rowX + 12.5f, rowY + 5.0f), white.p, 1.8f); + } else { + rt_->DrawRoundedRectangle(boxRR, muted.p, 1.4f); + } + if (layout) rt_->DrawTextLayout(D2D1::Point2F(rowX + box + gap, rowY + box / 2 - 12.0f), layout.p, ink.p); + rcCheck_ = ToDevice(rowX - 6.0f, rowY - 6.0f, box + gap + labelW + 12.0f, box + 12.0f); + }; + auto bodyLine = [&](const wchar_t* body) { + // Tall enough for two lines (the language screen says it in both languages). + if (fmtButton_) rt_->DrawTextW(body, (UINT32)wcslen(body), fmtButton_.p, + D2D1::RectF(60, H * 0.52f, W - 60, H * 0.62f), muted.p); + }; + auto twoPills = [&](const wchar_t* primary, const wchar_t* secondary) { + const float ux = W / 2.0f - pw - 8.0f, cx = W / 2.0f + 8.0f; + rcRetry_ = ToDevice(ux, py, pw, ph); + rcErrClose_ = ToDevice(cx, py, pw, ph); + pill(ux, py, pw, ph, primary, true); + pill(cx, py, pw, ph, secondary, false); + }; + + if (stage_ == Stage::Language) { + // Bilingual by nature: it comes before the choice. The two pills are the choice; the preselected + // one (a previous install's answer, or the Windows UI language) is drawn as the primary. + bodyLine(L"Setup will continue in this language.\n설치 과정이 이 언어로 진행됩니다."); + rcCheck_ = RECT{}; + const float ux = W / 2.0f - pw - 8.0f, cx = W / 2.0f + 8.0f; + rcRetry_ = ToDevice(ux, py, pw, ph); // English + rcErrClose_ = ToDevice(cx, py, pw, ph); // 한국어 + pill(ux, py, pw, ph, L"English", g_lang == Lang::En); + pill(cx, py, pw, ph, L"한국어", g_lang == Lang::Ko); + } else if (stage_ == Stage::Welcome) { + bodyLine(Tr(L"Installs for this Windows account. No administrator rights needed.", + L"이 Windows 계정에만 설치됩니다. 관리자 권한이 필요하지 않아요.")); + checkRow(Tr(L"Create a desktop shortcut", L"바탕 화면에 바로 가기 만들기"), desktopShortcut_); + twoPills(Tr(L"Install", L"설치"), Tr(L"Back", L"뒤로")); + } else if (stage_ == Stage::Confirm) { + bodyLine(Tr(L"Redrob Query will be removed from this PC. Your databases are not touched.", + L"이 PC에서 Redrob Query를 제거합니다. 데이터베이스는 건드리지 않아요.")); + checkRow(Tr(L"Also delete its settings and connection profiles", L"설정과 연결 프로필도 삭제"), deleteData_); + twoPills(Tr(L"Uninstall", L"제거"), Tr(L"Cancel", L"취소")); + } else if (stage_ == Stage::Removed) { + const wchar_t* body = deleteData_ ? Tr(L"Its settings and connection profiles were deleted too.", L"설정과 연결 프로필도 삭제했어요.") + : Tr(L"Its settings stay on this PC for a later install.", L"설정은 다음 설치를 위해 이 PC에 남아 있어요."); + if (fmtButton_) rt_->DrawTextW(body, (UINT32)wcslen(body), fmtButton_.p, + D2D1::RectF(60, H * 0.53f, W - 60, H * 0.59f), muted.p); + const float x = (W - pw) / 2.0f; + rcRetry_ = ToDevice(x, py, pw, ph); // Close + rcErrClose_ = RECT{}; + pill(x, py, pw, ph, Tr(L"Close", L"닫기"), true); + + } else if (stage_ != Stage::Error) { + // ---- progress bar: thin, rounded, blue fill on a faint track ----------- + const float barW = W * 0.76f, barH = 6.0f; + const float barX = (W - barW) / 2.0f, barY = H * 0.60f; + D2D1_ROUNDED_RECT trackRR = {D2D1::RectF(barX, barY, barX + barW, barY + barH), barH / 2, barH / 2}; + rt_->FillRoundedRectangle(trackRR, track.p); + float fillW = (float)(barW * progress_); + if (fillW > barH) { + D2D1_ROUNDED_RECT fillRR = {D2D1::RectF(barX, barY, barX + fillW, barY + barH), barH / 2, barH / 2}; + rt_->FillRoundedRectangle(fillRR, blue.p); + } + } else { + // ---- error state: message + Retry (white pill, black type) / Close ----- + D2D1_RECT_F msgRc = D2D1::RectF(60, H * 0.54f, W - 60, H * 0.66f); + if (fmtButton_) rt_->DrawTextW(errorMessage_.c_str(), (UINT32)errorMessage_.size(), + fmtButton_.p, msgRc, muted.p); + // Retry pill (white fill, black type) -- the single prominent CTA. + const float pw = 120.0f, ph = 38.0f, py = H * 0.72f; + const float rx = W / 2.0f - pw - 8.0f, cx = W / 2.0f + 8.0f; + rcRetry_ = ToDevice(rx, py, pw, ph); + rcErrClose_ = ToDevice(cx, py, pw, ph); + D2D1_ROUNDED_RECT pill = {D2D1::RectF(rx, py, rx + pw, py + ph), ph / 2, ph / 2}; + rt_->FillRoundedRectangle(pill, white.p); + D2D1_RECT_F rtxt = D2D1::RectF(rx, py, rx + pw, py + ph); + const wchar_t* retry = Tr(L"Retry", L"다시 시도"); + if (fmtButton_) rt_->DrawTextW(retry, (UINT32)wcslen(retry), fmtButton_.p, rtxt, black.p); + // Close (outline) + D2D1_ROUNDED_RECT closeRR = {D2D1::RectF(cx, py, cx + pw, py + ph), ph / 2, ph / 2}; + rt_->DrawRoundedRectangle(closeRR, track.p, 1.4f); + D2D1_RECT_F ctxt = D2D1::RectF(cx, py, cx + pw, py + ph); + const wchar_t* close = Tr(L"Close", L"닫기"); + if (fmtButton_) rt_->DrawTextW(close, (UINT32)wcslen(close), fmtButton_.p, ctxt, ink.p); + } + + return rt_->EndDraw(); +} + +int SetupApp::RenderStages(HINSTANCE hinst, const std::wstring& dir) { + hinst_ = hinst; + g_app = this; + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + D2D1CreateFactory(D2D1_FACTORY_TYPE_SINGLE_THREADED, &d2dFactory_); + DWriteCreateFactory(DWRITE_FACTORY_TYPE_SHARED, __uuidof(IDWriteFactory), + reinterpret_cast(&dwFactory_)); + CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&wicFactory_)); + if (!d2dFactory_ || !dwFactory_ || !wicFactory_) return 2; + LayoutText(); + dpi_ = 192; // 200%: the sharpest the art is drawn for + const UINT pw = MulDiv(kBaseW, dpi_, 96), ph = MulDiv(kBaseH, dpi_, 96); + int failures = 0; + struct Shot { Stage stage; double progress; const wchar_t* name; Lang lang; }; + const Shot shots[] = { + {Stage::Language, 0.0, L"0-language.png", Lang::En}, + {Stage::Welcome, 0.0, L"1-welcome.png", Lang::En}, + {Stage::Welcome, 0.0, L"1-welcome-ko.png", Lang::Ko}, + {Stage::Installing, 0.55, L"2-installing.png", Lang::En}, + {Stage::Installing, 0.55, L"2-installing-ko.png", Lang::Ko}, + {Stage::Opening, 1.0, L"3-opening.png", Lang::En}, + {Stage::Error, 0.0, L"4-error.png", Lang::En}, + {Stage::Error, 0.0, L"4-error-ko.png", Lang::Ko}, + {Stage::Confirm, 0.0, L"5-uninstall-confirm.png", Lang::En}, + {Stage::Confirm, 0.0, L"5-uninstall-confirm-ko.png", Lang::Ko}, + {Stage::Removing, 0.4, L"6-removing.png", Lang::En}, + {Stage::Removed, 1.0, L"7-removed.png", Lang::En}, + }; + for (const Shot& s : shots) { + ComPtr bitmap; + if (FAILED(wicFactory_->CreateBitmap(pw, ph, GUID_WICPixelFormat32bppPBGRA, WICBitmapCacheOnLoad, &bitmap))) { + ++failures; continue; + } + D2D1_RENDER_TARGET_PROPERTIES props = D2D1::RenderTargetProperties( + D2D1_RENDER_TARGET_TYPE_DEFAULT, + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED), (float)dpi_, (float)dpi_); + DiscardDeviceResources(); + if (FAILED(d2dFactory_->CreateWicBitmapRenderTarget(bitmap.p, props, &rt_))) { ++failures; continue; } + bgBitmap_ = LoadPngResource(IDR_BACKGROUND_PNG, rt_.p); + lockupBitmap_ = LoadPngResource(IDR_LOCKUP_PNG, rt_.p); + mode_ = (s.stage == Stage::Confirm || s.stage == Stage::Removing || s.stage == Stage::Removed) + ? Mode::Uninstall : Mode::Install; + stage_ = s.stage; + progress_ = s.progress; + g_lang = s.lang; + if (s.stage == Stage::Error) errorMessage_ = ResultSentence(kResultAppRunning, false); + if (FAILED(DrawScene())) { ++failures; continue; } + + const std::wstring path = dir + L"\\" + s.name; + ComPtr stream; + ComPtr encoder; + ComPtr frame; + bool ok = SUCCEEDED(wicFactory_->CreateStream(&stream)) && + SUCCEEDED(stream->InitializeFromFilename(path.c_str(), GENERIC_WRITE)) && + SUCCEEDED(wicFactory_->CreateEncoder(GUID_ContainerFormatPng, nullptr, &encoder)) && + SUCCEEDED(encoder->Initialize(stream.p, WICBitmapEncoderNoCache)) && + SUCCEEDED(encoder->CreateNewFrame(&frame, nullptr)) && + SUCCEEDED(frame->Initialize(nullptr)) && + SUCCEEDED(frame->WriteSource(bitmap.p, nullptr)) && + SUCCEEDED(frame->Commit()) && SUCCEEDED(encoder->Commit()); + if (!ok) ++failures; + } + DiscardDeviceResources(); + fmtStatus_.reset(); + fmtButton_.reset(); + wicFactory_.reset(); + dwFactory_.reset(); + d2dFactory_.reset(); + CoUninitialize(); + return failures; +} + +// device-pixel rect from logical (dip) coordinates +RECT SetupApp::ToDevice(float x, float y, float w, float h) { + float s = dpi_ / 96.0f; + RECT r; + r.left = (LONG)(x * s); r.top = (LONG)(y * s); + r.right = (LONG)((x + w) * s); r.bottom = (LONG)((y + h) * s); + return r; +} + +void SetupApp::computeButtonRects(float minX, float closeX, float cy) { + float s = dpi_ / 96.0f; + auto box = [&](float cx) { + RECT r; r.left = (LONG)((cx - 16) * s); r.top = (LONG)((cy - 16) * s); + r.right = (LONG)((cx + 16) * s); r.bottom = (LONG)((cy + 16) * s); return r; + }; + rcMin_ = box(minX); + rcClose_ = box(closeX); +} + +// ---- payload --------------------------------------------------------------- + +DWORD WINAPI SetupApp::PayloadThunk(LPVOID self) { + return reinterpret_cast(self)->RunPayload(); +} + +// %LOCALAPPDATA%, or empty. +static std::wstring LocalAppData() { + wchar_t local[MAX_PATH]; + if (FAILED(SHGetFolderPathW(nullptr, CSIDL_LOCAL_APPDATA, nullptr, 0, local))) return L""; + return local; +} + +static std::wstring KnownFolder(REFKNOWNFOLDERID id) { + PWSTR p = nullptr; + std::wstring out; + if (SUCCEEDED(SHGetKnownFolderPath(id, 0, nullptr, &p))) out = p; + CoTaskMemFree(p); + return out; +} + +static std::wstring InstallDir() { + const std::wstring local = LocalAppData(); + return local.empty() ? L"" : local + kInstallSubdir; +} + +static bool Exists(const std::wstring& p) { return GetFileAttributesW(p.c_str()) != INVALID_FILE_ATTRIBUTES; } + +// The one subfolder of `dir` that holds `rel` (a path starting with a backslash), or empty. +static std::wstring SingleSubfolderWith(const std::wstring& dir, const wchar_t* rel) { + std::wstring found; + WIN32_FIND_DATAW fd; + HANDLE find = FindFirstFileW((dir + L"\\*").c_str(), &fd); + if (find == INVALID_HANDLE_VALUE) return found; + do { + const std::wstring name = fd.cFileName; + if (name == L"." || name == L".." || !(fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY)) continue; + const std::wstring sub = dir + L"\\" + name; + if (Exists(sub + rel)) { found = sub; break; } + } while (FindNextFileW(find, &fd)); + FindClose(find); + return found; +} + +// Write one RCDATA resource to `path`. False if the resource is missing or the write was short. +static bool WriteResource(HINSTANCE hinst, int id, const std::wstring& path) { + HRSRC res = FindResourceW(hinst, MAKEINTRESOURCEW(id), RT_RCDATA); + if (!res) return false; + HGLOBAL h = LoadResource(hinst, res); + const void* data = LockResource(h); + const DWORD sz = SizeofResource(hinst, res); + if (!data || !sz) return false; + HANDLE f = CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (f == INVALID_HANDLE_VALUE) return false; + DWORD written = 0; + const BOOL ok = WriteFile(f, data, sz, &written, nullptr); + CloseHandle(f); + if (!ok || written != sz) { DeleteFileW(path.c_str()); return false; } + return true; +} + +bool SetupApp::ExtractPayload(std::wstring& outPath) { + wchar_t tmp[MAX_PATH]; + GetTempPathW(MAX_PATH, tmp); + wchar_t name[MAX_PATH]; + if (!GetTempFileNameW(tmp, L"rcs", 0, name)) return false; + const std::wstring zip = std::wstring(name) + L".zip"; + DeleteFileW(name); // the 0-byte file GetTempFileName created + if (!WriteResource(hinst_, IDR_PAYLOAD, zip)) return false; + outPath = zip; + return true; +} + +// Delete a directory tree. Paths are checked by the callers to be inside the install folder or our own +// data folders; this never runs on anything it was not given. `keep` (a full path) is left in place. +static DWORD DeleteTree(const std::wstring& dir, const std::wstring& keep = L"") { + DWORD firstError = 0; + WIN32_FIND_DATAW fd; + HANDLE find = FindFirstFileW((dir + L"\\*").c_str(), &fd); + if (find != INVALID_HANDLE_VALUE) { + do { + const std::wstring name = fd.cFileName; + if (name == L"." || name == L"..") continue; + const std::wstring path = dir + L"\\" + name; + if (!keep.empty() && lstrcmpiW(path.c_str(), keep.c_str()) == 0) continue; + DWORD err = 0; + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + // A junction is removed as a link; its target is not followed. + if (fd.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT) err = RemoveDirectoryW(path.c_str()) ? 0 : GetLastError(); + else err = DeleteTree(path, keep); + } else { + if (fd.dwFileAttributes & FILE_ATTRIBUTE_READONLY) + SetFileAttributesW(path.c_str(), fd.dwFileAttributes & ~FILE_ATTRIBUTE_READONLY); + err = DeleteFileW(path.c_str()) ? 0 : GetLastError(); + } + if (err && !firstError) firstError = err; + } while (FindNextFileW(find, &fd)); + FindClose(find); + } + if (!RemoveDirectoryW(dir.c_str())) { + const DWORD err = GetLastError(); + // The kept file, or a missing folder, is not a failure. + if (err != ERROR_FILE_NOT_FOUND && err != ERROR_PATH_NOT_FOUND && err != ERROR_DIR_NOT_EMPTY && !firstError) + firstError = err; + if (err == ERROR_DIR_NOT_EMPTY && keep.empty() && !firstError) firstError = err; + } + return firstError; +} + +// Whether a process is running from inside `dir` (Redrob Query itself). +static bool AnythingRunningFrom(const std::wstring& dir) { + HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0); + if (snap == INVALID_HANDLE_VALUE) return false; + const std::wstring prefix = dir + L"\\"; + const DWORD self = GetCurrentProcessId(); + bool found = false; + PROCESSENTRY32W pe = {sizeof(pe)}; + for (BOOL ok = Process32FirstW(snap, &pe); ok && !found; ok = Process32NextW(snap, &pe)) { + if (pe.th32ProcessID == self) continue; + HANDLE h = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE, pe.th32ProcessID); + if (!h) continue; + wchar_t path[MAX_PATH]; + DWORD len = MAX_PATH; + if (QueryFullProcessImageNameW(h, 0, path, &len) && _wcsnicmp(path, prefix.c_str(), prefix.size()) == 0) + found = true; + CloseHandle(h); + } + CloseHandle(snap); + return found; +} + +// Run a command hidden and wait. Returns its exit code, or the CreateProcess error. +static DWORD RunHidden(std::wstring cmd, const wchar_t* cwd = nullptr) { + STARTUPINFOW si = {sizeof(si)}; + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + PROCESS_INFORMATION pi = {}; + if (!CreateProcessW(nullptr, &cmd[0], nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, cwd, &si, &pi)) + return GetLastError(); + WaitForSingleObject(pi.hProcess, INFINITE); + DWORD exitCode = 1; + GetExitCodeProcess(pi.hProcess, &exitCode); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + return exitCode; +} + +// A shortcut to the app. It carries the AppUserModelID ai.redrob.query: Windows needs it to show +// toast notifications from an unpackaged app and to group the taskbar button (the MSI's ShortcutProperty +// for the same thing failed with "Warning 1946" on some machines; IPropertyStore is the direct way). +static bool MakeShortcut(const std::wstring& lnk, const std::wstring& target, const std::wstring& workDir) { + ComPtr link; + if (FAILED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&link)))) return false; + link->SetPath(target.c_str()); + link->SetWorkingDirectory(workDir.c_str()); + link->SetIconLocation(target.c_str(), 0); + link->SetDescription(Tr(L"Install and update the Redrob apps", L"Redrob 앱을 설치하고 업데이트합니다")); + ComPtr store; + if (SUCCEEDED(link->QueryInterface(IID_PPV_ARGS(&store)))) { + PROPVARIANT pv; + if (SUCCEEDED(InitPropVariantFromString(kAppUserModelId, &pv))) { + store->SetValue(PKEY_AppUserModel_ID, pv); + store->Commit(); + PropVariantClear(&pv); + } + } + ComPtr file; + if (FAILED(link->QueryInterface(IID_PPV_ARGS(&file)))) return false; + return SUCCEEDED(file->Save(lnk.c_str(), TRUE)); +} + +static bool SetArpString(HKEY key, const wchar_t* name, const std::wstring& value) { + return RegSetValueExW(key, name, 0, REG_SZ, reinterpret_cast(value.c_str()), + (DWORD)((value.size() + 1) * sizeof(wchar_t))) == ERROR_SUCCESS; +} + +static ULONGLONG TreeSizeBytes(const std::wstring& dir) { + ULONGLONG total = 0; + WIN32_FIND_DATAW fd; + HANDLE find = FindFirstFileW((dir + L"\\*").c_str(), &fd); + if (find == INVALID_HANDLE_VALUE) return 0; + do { + const std::wstring name = fd.cFileName; + if (name == L"." || name == L"..") continue; + if (fd.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + if (!(fd.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT)) total += TreeSizeBytes(dir + L"\\" + name); + } else { + total += ((ULONGLONG)fd.nFileSizeHigh << 32) | fd.nFileSizeLow; + } + } while (FindNextFileW(find, &fd)); + FindClose(find); + return total; +} + +// The Settings > Apps entry. Everything Windows shows there: name, version, publisher, icon, size. +static bool WriteArpEntry(const std::wstring& dir) { + HKEY key = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, kArpKey, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) != ERROR_SUCCESS) + return false; + const std::wstring exe = dir + kAppExeRel; + const std::wstring uninstaller = dir + kUninstallerName; + bool ok = SetArpString(key, L"DisplayName", L"Redrob Query") && + SetArpString(key, L"DisplayVersion", REDROB_VER_WSTR) && + SetArpString(key, L"Publisher", L"Redrob") && + SetArpString(key, L"DisplayIcon", L"\"" + exe + L"\",0") && + SetArpString(key, L"InstallLocation", dir) && + SetArpString(key, L"URLInfoAbout", L"https://redrob.ai") && + SetArpString(key, L"HelpLink", L"https://github.com/redrob-labs/redrob-query") && + SetArpString(key, L"URLUpdateInfo", L"https://github.com/redrob-labs/redrob-query/releases") && + SetArpString(key, L"UninstallString", L"\"" + uninstaller + L"\" --uninstall"); + const DWORD one = 1; + const DWORD sizeKb = (DWORD)std::min(TreeSizeBytes(dir) / 1024, 0xFFFFFFFFull); + RegSetValueExW(key, L"NoModify", 0, REG_DWORD, reinterpret_cast(&one), sizeof(one)); + RegSetValueExW(key, L"NoRepair", 0, REG_DWORD, reinterpret_cast(&one), sizeof(one)); + RegSetValueExW(key, L"EstimatedSize", 0, REG_DWORD, reinterpret_cast(&sizeKb), sizeof(sizeKb)); + RegCloseKey(key); + return ok; +} + +// The payload-less build of this app (IDR_UNINSTALLER) goes into the install folder; Settings > Apps runs it. +bool SetupApp::InstallUninstaller(const std::wstring& dir) { + return WriteResource(hinst_, IDR_UNINSTALLER, dir + kUninstallerName); +} + +static std::wstring StartMenuShortcut() { + const std::wstring programs = KnownFolder(FOLDERID_Programs); + return programs.empty() ? L"" : programs + kShortcutName; +} + +static std::wstring DesktopShortcut() { + const std::wstring desktop = KnownFolder(FOLDERID_Desktop); + return desktop.empty() ? L"" : desktop + kShortcutName; +} + +// What the next setup preselects: the install +// folder, the language, the desktop-shortcut choice; and the App Paths entry for Win+R "redrob-query". +void SetupApp::RecordChoices(const std::wstring& dir) { + HKEY key = nullptr; + if (RegCreateKeyExW(HKEY_CURRENT_USER, kAppKey, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) == ERROR_SUCCESS) { + SetArpString(key, L"InstallDir", dir); + SetArpString(key, L"Language", g_lang == Lang::Ko ? L"ko" : L"en"); + SetArpString(key, L"DesktopShortcutChoice", desktopShortcut_ ? L"1" : L"0"); + RegCloseKey(key); + } + if (RegCreateKeyExW(HKEY_CURRENT_USER, kAppPathsKey, 0, nullptr, 0, KEY_WRITE, nullptr, &key, nullptr) == ERROR_SUCCESS) { + SetArpString(key, nullptr, dir + kAppExeRel); + RegCloseKey(key); + } +} + +// The install proper. Unpack beside the old install, then swap, so neither a failed unpack nor a locked +// file leaves a half-updated folder behind. +DWORD SetupApp::InstallApp() { + const std::wstring dir = InstallDir(); + if (dir.empty()) return ERROR_PATH_NOT_FOUND; + if (AnythingRunningFrom(dir)) return kResultAppRunning; + + std::wstring zip; + if (!ExtractPayload(zip)) return ERROR_RESOURCE_DATA_NOT_FOUND; + + const std::wstring parent = LocalAppData() + L"\\Programs"; + CreateDirectoryW(parent.c_str(), nullptr); + const std::wstring staging = dir + L".new"; + const std::wstring aside = dir + L".old"; + DeleteTree(staging); // leftovers of an interrupted earlier run + DeleteTree(aside); + if (!CreateDirectoryW(staging.c_str(), nullptr)) { DeleteFileW(zip.c_str()); return GetLastError(); } + + // System32's tar is bsdtar, which reads zip. Its full path, so a tar.exe elsewhere on PATH is never used. + wchar_t sys[MAX_PATH]; + GetSystemDirectoryW(sys, MAX_PATH); + const DWORD tarExit = RunHidden(L"\"" + std::wstring(sys) + L"\\tar.exe\" -x -f \"" + zip + L"\" -C \"" + + staging + L"\""); + DeleteFileW(zip.c_str()); + if (tarExit != 0) { DeleteTree(staging); return kResultUnpackFailed; } + // The portable zip wraps its files in one folder (redrob-query--windows--portable\). + // Lift that folder's contents up so the exe sits in the install folder itself. + if (!Exists(staging + kAppExeRel)) { + const std::wstring inner = SingleSubfolderWith(staging, kAppExeRel); + if (inner.empty()) { DeleteTree(staging); return kResultNoExe; } + const std::wstring lifted = dir + L".lift"; + DeleteTree(lifted); + if (!MoveFileExW(inner.c_str(), lifted.c_str(), 0)) { const DWORD err = GetLastError(); DeleteTree(staging); return err; } + DeleteTree(staging); + if (!MoveFileExW(lifted.c_str(), staging.c_str(), 0)) { const DWORD err = GetLastError(); DeleteTree(lifted); return err; } + } + // A portable marker, if a zip ever carried one, would make the app run portable; it is dropped + // (settings next to itself, no self-update). An installed copy must not have it. + DeleteFileW((staging + kPortableMarker).c_str()); + + // Swap. The rename fails, rather than half-copying, if anything still holds a file in the old folder. + if (Exists(dir)) { + if (!MoveFileExW(dir.c_str(), aside.c_str(), 0)) { + const DWORD err = GetLastError(); + DeleteTree(staging); + return err == ERROR_ACCESS_DENIED || err == ERROR_SHARING_VIOLATION ? kResultAppRunning : err; + } + } + if (!MoveFileExW(staging.c_str(), dir.c_str(), 0)) { + const DWORD err = GetLastError(); + if (Exists(aside)) MoveFileExW(aside.c_str(), dir.c_str(), 0); // put the old install back + DeleteTree(staging); + return err; + } + DeleteTree(aside); + + // The rest is best effort: the app is installed and runs without any of it. + InstallUninstaller(dir); + const std::wstring exe = dir + kAppExeRel; + const std::wstring start = StartMenuShortcut(); + if (!start.empty()) MakeShortcut(start, exe, dir); + const std::wstring desktop = DesktopShortcut(); + if (!desktop.empty()) { + if (desktopShortcut_) MakeShortcut(desktop, exe, dir); + else DeleteFileW(desktop.c_str()); // an update where the box was unticked removes the old one + } + RecordChoices(dir); + WriteArpEntry(dir); + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr); // refresh shortcut icons + + if (!silent_) ShellExecuteW(nullptr, L"open", exe.c_str(), nullptr, dir.c_str(), SW_SHOWNORMAL); + return 0; +} + +DWORD WINAPI SetupApp::UninstallThunk(LPVOID self) { + return reinterpret_cast(self)->RunUninstall(); +} + +void SetupApp::StartUninstall() { + progress_ = progressTarget_ = 0.0; + startTick_ = GetTickCount64(); + SetStage(Stage::Removing); + SetTimer(hwnd_, kTickTimer, kTickMs, nullptr); + if (payloadThread_) { WaitForSingleObject(payloadThread_, 5000); CloseHandle(payloadThread_); } + payloadThread_ = CreateThread(nullptr, 0, &SetupApp::UninstallThunk, this, 0, nullptr); +} + +// Remove what InstallApp made. The uninstaller itself is still running from the install folder, so it +// is kept here and removed by ScheduleSelfCleanup once this window closes. +DWORD SetupApp::RunUninstall() { + if (demo_) { + Sleep(3000); + PostMessage(hwnd_, kUninstallMsgDone, 0, 0); + return 0; + } + const std::wstring dir = InstallDir(); + if (dir.empty()) { PostMessage(hwnd_, kUninstallMsgDone, (WPARAM)ERROR_PATH_NOT_FOUND, 0); return 1; } + if (AnythingRunningFrom(dir)) { PostMessage(hwnd_, kUninstallMsgDone, (WPARAM)kResultAppRunning, 0); return 1; } + + const std::wstring start = StartMenuShortcut(); + if (!start.empty()) DeleteFileW(start.c_str()); + const std::wstring desktop = DesktopShortcut(); + if (!desktop.empty()) DeleteFileW(desktop.c_str()); + RegDeleteTreeW(HKEY_CURRENT_USER, kArpKey); + RegDeleteTreeW(HKEY_CURRENT_USER, kAppPathsKey); + // The folder is gone, so the record of it goes too; the language and the shortcut choice + // stay so the next install preselects them. + HKEY key = nullptr; + if (RegOpenKeyExW(HKEY_CURRENT_USER, kAppKey, 0, KEY_WRITE, &key) == ERROR_SUCCESS) { + RegDeleteValueW(key, L"InstallDir"); + RegCloseKey(key); + } + + wchar_t self[MAX_PATH]; + GetModuleFileNameW(nullptr, self, MAX_PATH); + DWORD err = Exists(dir) ? DeleteTree(dir, self) : 0; + + if (deleteData_) { + // Where Redrob Query keeps its state on Windows: %APPDATA%\Redrob Query (connection profiles, + // workspace, eframe's ui.ron; apps/redrob-query/src/services.rs). Passwords live in the Windows + // Credential Manager and are removed by the app, not here. Its registry key goes too. + // %APPDATA%\Redrob\Shared (the launcher's sign-in every Redrob app reads) stays. + const std::wstring roaming = KnownFolder(FOLDERID_RoamingAppData); + if (!roaming.empty()) DeleteTree(roaming + L"\\Redrob Query"); + RegDeleteTreeW(HKEY_CURRENT_USER, kAppKey); + } + SHChangeNotify(SHCNE_ASSOCCHANGED, SHCNF_IDLIST, nullptr, nullptr); + PostMessage(hwnd_, kUninstallMsgDone, (WPARAM)err, 0); + return err; +} + +// Once removed, this exe is the last thing left. A running exe cannot delete itself, so a hidden +// PowerShell waits for this process to exit, then removes it and the install folder. Only when this copy +// runs from the install folder: a copy started anywhere else is never deleted. +void SetupApp::ScheduleSelfCleanup() { + wchar_t self[MAX_PATH]; + if (!GetModuleFileNameW(nullptr, self, MAX_PATH)) return; + const std::wstring dir = InstallDir(); + const std::wstring me = self; + if (dir.size() < 4 || _wcsnicmp(me.c_str(), (dir + L"\\").c_str(), dir.size() + 1) != 0) return; + std::wstring cmd = L"powershell.exe -NoProfile -NonInteractive -WindowStyle Hidden -Command \"Wait-Process -Id " + + std::to_wstring(GetCurrentProcessId()) + + L" -ErrorAction SilentlyContinue; Remove-Item -LiteralPath '" + me + + L"' -Force -ErrorAction SilentlyContinue; Remove-Item -LiteralPath '" + dir + + L"' -Recurse -Force -ErrorAction SilentlyContinue\""; + STARTUPINFOW si = {sizeof(si)}; + si.dwFlags = STARTF_USESHOWWINDOW; + si.wShowWindow = SW_HIDE; + PROCESS_INFORMATION pi = {}; + // From the parent folder: a process whose working directory is the install folder would hold it open. + const std::wstring parent = LocalAppData() + L"\\Programs"; + if (CreateProcessW(nullptr, &cmd[0], nullptr, nullptr, FALSE, CREATE_NO_WINDOW, nullptr, parent.c_str(), &si, &pi)) { + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } +} + +int SetupApp::RunSilent(HINSTANCE hinst, bool deleteData) { + hinst_ = hinst; + silent_ = true; + deleteData_ = deleteData; + CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); // the shortcut writer is COM + DWORD result; + if (mode_ == Mode::Uninstall) { + result = RunUninstall(); // hwnd_ is null: its PostMessage lands on this thread's queue, unread + if (result == 0) ScheduleSelfCleanup(); + } else { + result = InstallApp(); + } + CoUninitialize(); + return (int)result; +} + +DWORD SetupApp::RunPayload() { + PostMessage(hwnd_, kStageMsg, (WPARAM)Stage::Installing, 0); + const DWORD result = InstallApp(); + PostMessage(hwnd_, kPayloadMsgDone, (WPARAM)result, 0); + return result; +} + +} // namespace + +// ---- entry point ----------------------------------------------------------- + +int WINAPI wWinMain(HINSTANCE hinst, HINSTANCE, PWSTR, int) { + bool demo = false, demoError = false; + bool silent = false, deleteData = false, explicitInstall = false; + std::wstring renderDir, lang; + Mode mode = Mode::Install; + int argc = 0; + LPWSTR* argv = CommandLineToArgvW(GetCommandLineW(), &argc); + for (int i = 1; i < argc; ++i) { + if (lstrcmpiW(argv[i], L"--demo") == 0) { demo = true; explicitInstall = true; } + else if (lstrcmpiW(argv[i], L"--demo-error") == 0) { demo = true; demoError = true; explicitInstall = true; } + else if (lstrcmpiW(argv[i], L"--uninstall") == 0) mode = Mode::Uninstall; + else if (lstrcmpiW(argv[i], L"--demo-uninstall") == 0) { demo = true; mode = Mode::Uninstall; } + else if (lstrcmpiW(argv[i], L"--silent") == 0) silent = true; + else if (lstrcmpiW(argv[i], L"--delete-data") == 0) deleteData = true; + else if (lstrcmpiW(argv[i], L"--lang") == 0 && i + 1 < argc) lang = argv[++i]; + else if (lstrcmpiW(argv[i], L"--render-stages") == 0 && i + 1 < argc) renderDir = argv[++i]; + } + if (argv) LocalFree(argv); + g_lang = DefaultLanguage(lang); + +#ifdef REDROB_NO_PAYLOAD + // The uninstaller carries nothing to install, so opened by a double-click it offers removal, not an + // Install button that could only fail. + if (!explicitInstall) mode = Mode::Uninstall; +#endif + + SetupApp app(demo, demoError, mode); + if (!renderDir.empty()) return app.RenderStages(hinst, renderDir); + if (silent && !demo) return app.RunSilent(hinst, deleteData); + return app.Run(hinst); +} diff --git a/installer/windows/redrob_query_setup.manifest b/installer/windows/redrob_query_setup.manifest new file mode 100644 index 0000000..ed2a83d --- /dev/null +++ b/installer/windows/redrob_query_setup.manifest @@ -0,0 +1,26 @@ + + + + + + + true/pm + PerMonitorV2 + + + + + + + + + + + + + + diff --git a/installer/windows/redrob_query_setup.rc b/installer/windows/redrob_query_setup.rc new file mode 100644 index 0000000..e35f3e6 --- /dev/null +++ b/installer/windows/redrob_query_setup.rc @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Resources for the Redrob Query setup and uninstaller. build.py writes build_defs.h with the file +// paths and version; a build without a payload (the uninstaller, or a demo build) defines REDROB_NO_PAYLOAD. + +#include +#include "resource.h" +#include "build_defs.h" + +IDI_APPICON ICON REDROB_ICON_PATH + +IDR_MANIFEST RT_MANIFEST "redrob_query_setup.manifest" + +#ifndef REDROB_NO_PAYLOAD +IDR_PAYLOAD RCDATA REDROB_PAYLOAD_PATH +#endif +#ifdef REDROB_UNINSTALLER_PATH +IDR_UNINSTALLER RCDATA REDROB_UNINSTALLER_PATH +#endif +IDR_BACKGROUND_PNG RCDATA REDROB_BACKGROUND_PATH +IDR_LOCKUP_PNG RCDATA REDROB_LOCKUP_PATH +#ifdef REDROB_FONT_PATH +IDR_FONT_SANS RCDATA REDROB_FONT_PATH +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION REDROB_VER_FILE + PRODUCTVERSION REDROB_VER_FILE + FILEFLAGSMASK 0x3fL + FILEFLAGS 0x0L + FILEOS VOS_NT_WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE VFT2_UNKNOWN +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "Redrob" + VALUE "FileDescription", "Redrob Query " REDROB_SETUP_KIND + VALUE "FileVersion", REDROB_VER_STR + VALUE "ProductName", "Redrob Query" + VALUE "ProductVersion", REDROB_VER_STR + VALUE "LegalCopyright", "Copyright (C) 2026 Janghoon Lee (Redrob) and contributors" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END diff --git a/installer/windows/res/background.html b/installer/windows/res/background.html new file mode 100644 index 0000000..a978ff3 --- /dev/null +++ b/installer/windows/res/background.html @@ -0,0 +1,106 @@ + + + + + + + + +
+
+
+
+
+
+ + diff --git a/installer/windows/res/background.png b/installer/windows/res/background.png new file mode 100644 index 0000000..31d9be8 Binary files /dev/null and b/installer/windows/res/background.png differ diff --git a/installer/windows/res/icon.png b/installer/windows/res/icon.png new file mode 100644 index 0000000..1e5101f Binary files /dev/null and b/installer/windows/res/icon.png differ diff --git a/installer/windows/res/lockup.png b/installer/windows/res/lockup.png new file mode 100644 index 0000000..9c130a8 Binary files /dev/null and b/installer/windows/res/lockup.png differ diff --git a/installer/windows/resource.h b/installer/windows/resource.h new file mode 100644 index 0000000..dbee8d8 --- /dev/null +++ b/installer/windows/resource.h @@ -0,0 +1,14 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Resource identifiers shared between redrob_query_setup.rc and redrob_query_setup.cpp. +#pragma once + +// RCDATA +#define IDR_PAYLOAD 101 // the release's windows zip (absent from the uninstaller) +#define IDR_BACKGROUND_PNG 102 // res/background.png +#define IDR_LOCKUP_PNG 103 // res/lockup.png -- the Redrob Query lockup, white, for the deep ground +#define IDR_FONT_SANS 104 // Pretendard SemiBold, when the design system delivery was present +#define IDR_UNINSTALLER 106 // the payload-less build of this app, written into the install folder + +#define IDI_APPICON 105 // assets/app-icon/redrob-query.ico + +#define IDR_MANIFEST 1 // per-monitor-v2 DPI + common controls diff --git a/installer/windows/verify-install.ps1 b/installer/windows/verify-install.ps1 new file mode 100644 index 0000000..2464849 --- /dev/null +++ b/installer/windows/verify-install.ps1 @@ -0,0 +1,51 @@ +# Verify the Redrob Query setup end to end on this PC: install, upgrade over it, uninstall. Prints PASS/FAIL lines. +$ErrorActionPreference = 'Stop' +$setup = 'C:\Users\USER\redrob\setup-out\Redrob Query Setup.exe' +$dir = Join-Path $env:LOCALAPPDATA 'Programs\Redrob Query' +$exe = Join-Path $dir 'bin\redrob-query.exe' +$un = Join-Path $dir 'Uninstall Redrob Query.exe' +$arp = 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall\RedrobQuery' +$start = Join-Path ([Environment]::GetFolderPath('Programs')) 'Redrob Query.lnk' +$desk = Join-Path ([Environment]::GetFolderPath('Desktop')) 'Redrob Query.lnk' +function Check($name, $ok) { if ($ok) { "PASS $name" } else { "FAIL $name" } } + +$p = Start-Process -FilePath $setup -ArgumentList '--silent' -Wait -PassThru +Check "install exit 0 (got $($p.ExitCode))" ($p.ExitCode -eq 0) +Check 'exe present' (Test-Path -LiteralPath $exe) +Check 'uninstaller present' (Test-Path -LiteralPath $un) +Check 'start menu shortcut' (Test-Path -LiteralPath $start) +Check 'desktop shortcut' (Test-Path -LiteralPath $desk) +$k = Get-ItemProperty -LiteralPath $arp -ErrorAction SilentlyContinue +Check "arp DisplayName=$($k.DisplayName) DisplayVersion=$($k.DisplayVersion) SizeKB=$($k.EstimatedSize)" ($k.DisplayName -eq 'Redrob Query' -and $k.DisplayVersion -eq '0.5.5') +Check "arp UninstallString=$($k.UninstallString)" ($k.UninstallString -like '*Uninstall Redrob Query.exe*--uninstall') +$lnk = (New-Object -ComObject WScript.Shell).CreateShortcut($start) +Check "shortcut target=$($lnk.TargetPath)" ($lnk.TargetPath -eq $exe) +$ver = (Get-Item -LiteralPath $exe).VersionInfo +Check "exe ProductName=$($ver.ProductName)" ($ver.ProductName -eq 'Redrob Query') +$sig = Get-AuthenticodeSignature -LiteralPath $exe +Check "exe signed ($($sig.SignerCertificate.Subject))" ($null -ne $sig.SignerCertificate) + +# Upgrade over the existing install. +New-Item -ItemType File -Path (Join-Path $dir 'stale-marker.txt') -Force | Out-Null +$p = Start-Process -FilePath $setup -ArgumentList '--silent' -Wait -PassThru +Check "reinstall exit 0 (got $($p.ExitCode))" ($p.ExitCode -eq 0) +Check 'reinstall replaced the folder (stale file gone)' (-not (Test-Path -LiteralPath (Join-Path $dir 'stale-marker.txt'))) +Check 'no .new/.old leftovers' (-not (Test-Path -LiteralPath "$dir.new") -and -not (Test-Path -LiteralPath "$dir.old")) + +# Running app blocks the update. +$app = Start-Process -FilePath $exe -PassThru +Start-Sleep -Seconds 3 +$p = Start-Process -FilePath $setup -ArgumentList '--silent' -Wait -PassThru +Check ("install while app runs refused (got 0x{0:X8})" -f $p.ExitCode) ($p.ExitCode -eq [int]0xE0000001 -or $p.ExitCode -eq -536870911) +Check 'app files intact after refusal' (Test-Path -LiteralPath $exe) +Stop-Process -Id $app.Id -Force +Start-Sleep -Seconds 2 + +# Uninstall, keeping data. +$p = Start-Process -FilePath $un -ArgumentList '--uninstall','--silent' -Wait -PassThru +Check "uninstall exit 0 (got $($p.ExitCode))" ($p.ExitCode -eq 0) +Start-Sleep -Seconds 6 +Check 'install folder gone' (-not (Test-Path -LiteralPath $dir)) +Check 'start menu shortcut gone' (-not (Test-Path -LiteralPath $start)) +Check 'desktop shortcut gone' (-not (Test-Path -LiteralPath $desk)) +Check 'arp key gone' (-not (Test-Path -LiteralPath $arp)) diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index c42edd4..0000000 --- a/package-lock.json +++ /dev/null @@ -1,3920 +0,0 @@ -{ - "name": "redrob-query", - "version": "0.2.4", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "redrob-query", - "version": "0.2.4", - "license": "GPL-3.0-or-later", - "dependencies": { - "@beekeeperstudio/bks-er-diagram": "1.1.2", - "@monaco-editor/react": "^4.7.0", - "@redrob-labs/ui": "^1.0.2", - "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.12", - "@tauri-apps/api": "^2.8.0", - "clsx": "^2.1.1", - "framer-motion": "^12.23.12", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "zustand": "^5.0.8" - }, - "devDependencies": { - "@tauri-apps/cli": "^2.8.4", - "@testing-library/jest-dom": "^6.8.0", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.3.1", - "@types/react": "^19.1.12", - "@types/react-dom": "^19.1.9", - "@vitejs/plugin-react": "^5.0.2", - "@vitest/coverage-v8": "^3.2.7", - "jsdom": "^26.1.0", - "typescript": "^5.9.2", - "vite": "^7.1.5", - "vitest": "^3.2.4" - }, - "engines": { - "node": ">=22.12.0", - "npm": ">=11.0.0" - } - }, - "node_modules/@adobe/css-tools": { - "version": "4.5.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@asamuzakjp/css-color": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@csstools/css-calc": "^2.1.3", - "@csstools/css-color-parser": "^3.0.9", - "@csstools/css-parser-algorithms": "^3.0.4", - "@csstools/css-tokenizer": "^3.0.3", - "lru-cache": "^10.4.3" - } - }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "10.4.3", - "dev": true, - "license": "ISC" - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.8", - "@babel/types": "^7.29.8", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.8" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/runtime": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.8", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.8", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.8", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.8", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@bcoe/v8-coverage": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", - "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@beekeeperstudio/bks-er-diagram": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@beekeeperstudio/bks-er-diagram/-/bks-er-diagram-1.1.2.tgz", - "integrity": "sha512-633LYkE/mGaRS25VECpYl8cqgulfCr9o7vK4G74+NBXT/rSM0iZc3xGh2f0axbieGBKQ5gh91OjrukZ+gtf0wA==", - "license": "GPL-3.0" - }, - "node_modules/@csstools/color-helpers": { - "version": "5.1.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT-0", - "engines": { - "node": ">=18" - } - }, - "node_modules/@csstools/css-calc": { - "version": "2.1.4", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-color-parser": { - "version": "3.1.0", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "dependencies": { - "@csstools/color-helpers": "^5.1.0", - "@csstools/css-calc": "^2.1.4" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-parser-algorithms": "^3.0.5", - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-parser-algorithms": { - "version": "3.0.5", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@csstools/css-tokenizer": "^3.0.4" - } - }, - "node_modules/@csstools/css-tokenizer": { - "version": "3.0.4", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/csstools" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/csstools" - } - ], - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", - "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", - "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", - "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", - "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", - "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", - "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", - "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", - "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", - "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", - "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", - "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", - "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", - "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", - "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", - "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", - "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.28.2", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", - "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", - "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", - "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", - "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/openharmony-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", - "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", - "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", - "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", - "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.28.2", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", - "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/@isaacs/cliui": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", - "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", - "dev": true, - "license": "ISC", - "dependencies": { - "string-width": "^5.1.2", - "string-width-cjs": "npm:string-width@^4.2.0", - "strip-ansi": "^7.0.1", - "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", - "wrap-ansi": "^8.1.0", - "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@istanbuljs/schema": { - "version": "0.1.6", - "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", - "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.6.0", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@monaco-editor/loader": { - "version": "1.7.0", - "license": "MIT", - "dependencies": { - "state-local": "^1.0.6" - } - }, - "node_modules/@monaco-editor/react": { - "version": "4.7.0", - "license": "MIT", - "dependencies": { - "@monaco-editor/loader": "^1.5.0" - }, - "peerDependencies": { - "monaco-editor": ">= 0.25.0 < 1", - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@napi-rs/lzma-linux-x64-gnu": { - "version": "1.5.1", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^22.20 || ^24.12 || >=25" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@redrob-labs/ui": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@redrob-labs/ui/-/ui-1.0.2.tgz", - "integrity": "sha512-QfjxT1AFq74oJYLuobviBXLR53FAopjZXZolVaqvSmZgDpU7A2CI342rBAtCZuis00WxMcu7xjkrcU7bfVPLWQ==", - "license": "MIT", - "peerDependencies": { - "react": "^18.2.0 || ^19.0.0", - "react-dom": "^18.2.0 || ^19.0.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "dev": true, - "license": "MIT" - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz", - "integrity": "sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz", - "integrity": "sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz", - "integrity": "sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz", - "integrity": "sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz", - "integrity": "sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz", - "integrity": "sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz", - "integrity": "sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz", - "integrity": "sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz", - "integrity": "sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz", - "integrity": "sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz", - "integrity": "sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz", - "integrity": "sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz", - "integrity": "sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz", - "integrity": "sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz", - "integrity": "sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz", - "integrity": "sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz", - "integrity": "sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.63.1", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz", - "integrity": "sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz", - "integrity": "sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz", - "integrity": "sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz", - "integrity": "sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz", - "integrity": "sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz", - "integrity": "sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.63.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz", - "integrity": "sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@tanstack/react-table": { - "version": "8.21.3", - "license": "MIT", - "dependencies": { - "@tanstack/table-core": "8.21.3" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": ">=16.8", - "react-dom": ">=16.8" - } - }, - "node_modules/@tanstack/react-virtual": { - "version": "3.14.11", - "license": "MIT", - "dependencies": { - "@tanstack/virtual-core": "3.17.9" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" - } - }, - "node_modules/@tanstack/table-core": { - "version": "8.21.3", - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tanstack/virtual-core": { - "version": "3.17.9", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "node_modules/@tauri-apps/api": { - "version": "2.11.1", - "license": "Apache-2.0 OR MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - } - }, - "node_modules/@tauri-apps/cli": { - "version": "2.11.4", - "dev": true, - "license": "Apache-2.0 OR MIT", - "bin": { - "tauri": "tauri.js" - }, - "engines": { - "node": ">= 10" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/tauri" - }, - "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" - } - }, - "node_modules/@tauri-apps/cli-darwin-arm64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.4.tgz", - "integrity": "sha512-1ryOF3ZhpZ/nemHV5zVwBQBz9jDGKmKPvWPADOhc83ig0P4bMc2iER4NbC6r9sjeIZ6RVQ4g3RZIYvezhcl4TQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-darwin-x64": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.4.tgz", - "integrity": "sha512-uFsGQAAfuyz1k/yGLmkWfkBlgKAqZfxqlHmLWx81QU27RJWfmbNHCIq8T8w1e+VClleIuZUjpHWfoE4E3DLo3A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.4.tgz", - "integrity": "sha512-IaHZn5CdBL21oUmjiVOS1ctw6Ip1O0pjp70FwOWmYz1myWe0SY96ZIj2FYf7pT0m8bI2h/hrs5ZbEXXh44/MkQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.4.tgz", - "integrity": "sha512-N41/ukTRVe6XSuUTESuFdGeOW2i7k62tK+6gHK5Kd5/q5RPvvi19GaWAVPPb9u95HSGmTChSolBfzynUsssFaA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-arm64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.4.tgz", - "integrity": "sha512-v277UnT/fB64xAfSroL5N3Km3tLmvATWqJJw/wRI+g6o+HkeD0slyE7gOhNs1MbjE41R7bQOTxMVoL3aomUJmw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.4.tgz", - "integrity": "sha512-qqgNkQ2u1yZHxjhxsZaxUtRDW8dIqIYm33rx/mzwQv0SfY9x1B+iraj8vWeFiXjjSVVhEMepXSOts1TqPzvXNQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-gnu": { - "version": "2.11.4", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-linux-x64-musl": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.4.tgz", - "integrity": "sha512-o9GyhYor/nc7xarmwDE3ka2szuW3uuZzXjHWh64Q8YX5AtSgxdQkFWzrY4O8KiGtVNvFBI14H3Q49Qj5TOIP/A==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-arm64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.4.tgz", - "integrity": "sha512-ld5Ehb598m0VkYyylRPNeCFsBe/km0jxis6KgMpl3IGY6I/i1RwQXO05I1AsXUXO2WC6AvB/Lw4qTf/asiuEiQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-ia32-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.4.tgz", - "integrity": "sha512-12Hxi0XX/H5VFxO/bGgHkFWhml9VMgEOu9CidjeCeTNQ1l6fpUlbiGgSP7CLI3PFtW9/FfbeHieZ+kyWK5H7CA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tauri-apps/cli-win32-x64-msvc": { - "version": "2.11.4", - "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.4.tgz", - "integrity": "sha512-+vDiqBIU5dMISg/wNvX3sF+ZHfgJGJ5T0AcO+EHNXV9GGAG+P5fzodlDXD3QdKCRgZxMoCm5PPvj3BqLNjBthw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "Apache-2.0 OR MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">= 10" - } - }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@testing-library/jest-dom": { - "version": "6.9.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@adobe/css-tools": "^4.4.0", - "aria-query": "^5.0.0", - "css.escape": "^1.5.1", - "dom-accessibility-api": "^0.6.3", - "picocolors": "^1.1.1", - "redent": "^3.0.0" - }, - "engines": { - "node": ">=14", - "npm": ">=6", - "yarn": ">=1" - } - }, - "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { - "version": "0.6.3", - "dev": true, - "license": "MIT" - }, - "node_modules/@testing-library/react": { - "version": "16.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.12.5" - }, - "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 - } - } - }, - "node_modules/@testing-library/user-event": { - "version": "14.6.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12", - "npm": ">=6" - }, - "peerDependencies": { - "@testing-library/dom": ">=7.21.4" - } - }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/estree": { - "version": "1.0.9", - "dev": true, - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "24.13.3", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~7.18.0" - } - }, - "node_modules/@types/react": { - "version": "19.2.18", - "devOptional": true, - "license": "MIT", - "dependencies": { - "csstype": "^3.2.2" - } - }, - "node_modules/@types/react-dom": { - "version": "19.2.7", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@types/react": "^19.2.0" - } - }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "license": "MIT", - "optional": true, - "peer": true - }, - "node_modules/@vitejs/plugin-react": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@vitest/coverage-v8": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.7.tgz", - "integrity": "sha512-NEGWJS2XNu2PfRLQwOO3CTKj1tTETxNBdk454vDxVBhxJYhPaA/eS0nAI0c+1El1P7a60z8+i+ZrQoGESweGKg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@ampproject/remapping": "^2.3.0", - "@bcoe/v8-coverage": "^1.0.2", - "ast-v8-to-istanbul": "^0.3.3", - "debug": "^4.4.1", - "istanbul-lib-coverage": "^3.2.2", - "istanbul-lib-report": "^3.0.1", - "istanbul-lib-source-maps": "^5.0.6", - "istanbul-reports": "^3.1.7", - "magic-string": "^0.30.17", - "magicast": "^0.3.5", - "std-env": "^3.9.0", - "test-exclude": "^7.0.1", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@vitest/browser": "3.2.7", - "vitest": "3.2.7" - }, - "peerDependenciesMeta": { - "@vitest/browser": { - "optional": true - } - } - }, - "node_modules/@vitest/expect": { - "version": "3.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "3.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "3.2.7", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.17" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "3.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "3.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "3.2.7", - "pathe": "^2.0.3", - "strip-literal": "^3.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "3.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "magic-string": "^0.30.17", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "3.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyspy": "^4.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "3.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "3.2.7", - "loupe": "^3.1.4", - "tinyrainbow": "^2.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/agent-base": { - "version": "7.1.4", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-styles": { - "version": "5.2.0", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/aria-query": { - "version": "5.3.0", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "dequal": "^2.0.3" - } - }, - "node_modules/assertion-error": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, - "node_modules/ast-v8-to-istanbul": { - "version": "0.3.12", - "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.12.tgz", - "integrity": "sha512-BRRC8VRZY2R4Z4lFIL35MwNXmwVqBityvOIwETtsCSwvjl0IdgFsy9NhdaA6j74nUdtJJlIypeRhpDam19Wq3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.31", - "estree-walker": "^3.0.3", - "js-tokens": "^10.0.0" - } - }, - "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", - "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/balanced-match": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", - "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", - "dev": true, - "license": "MIT", - "engines": { - "node": "18 || 20 || >=22" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.11.21", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/browserslist": { - "version": "4.28.9", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.11.20", - "caniuse-lite": "^1.0.30001810", - "electron-to-chromium": "^1.5.420", - "node-releases": "^2.0.54", - "update-browserslist-db": "^1.3.2" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/cac": { - "version": "6.7.14", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001810", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/chai": { - "version": "5.3.3", - "dev": true, - "license": "MIT", - "dependencies": { - "assertion-error": "^2.0.1", - "check-error": "^2.1.1", - "deep-eql": "^5.0.1", - "loupe": "^3.1.0", - "pathval": "^2.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/check-error": { - "version": "2.1.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 16" - } - }, - "node_modules/clsx": { - "version": "2.1.1", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "license": "MIT" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css.escape": { - "version": "1.5.1", - "dev": true, - "license": "MIT" - }, - "node_modules/cssstyle": { - "version": "4.6.0", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^3.2.0", - "rrweb-cssom": "^0.8.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/csstype": { - "version": "3.2.3", - "devOptional": true, - "license": "MIT" - }, - "node_modules/data-urls": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/decimal.js": { - "version": "10.6.0", - "dev": true, - "license": "MIT" - }, - "node_modules/deep-eql": { - "version": "5.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/dequal": { - "version": "2.0.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/dompurify": { - "version": "3.4.15", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.15.tgz", - "integrity": "sha512-EUBjM+B+lkDE41iE82DDSCfkoPGfXx8IxFxPMjNzm/Uk4xDet77rTN9wqlxlVg71kK7XGuUMv6wUxJUwwv+Xyw==", - "license": "(MPL-2.0 OR Apache-2.0)", - "peer": true, - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, - "node_modules/eastasianwidth": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", - "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-to-chromium": { - "version": "1.5.422", - "dev": true, - "license": "ISC" - }, - "node_modules/emoji-regex": { - "version": "9.2.2", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", - "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", - "dev": true, - "license": "MIT" - }, - "node_modules/entities": { - "version": "6.0.1", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-module-lexer": { - "version": "1.7.0", - "dev": true, - "license": "MIT" - }, - "node_modules/esbuild": { - "version": "0.28.2", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.28.2", - "@esbuild/android-arm": "0.28.2", - "@esbuild/android-arm64": "0.28.2", - "@esbuild/android-x64": "0.28.2", - "@esbuild/darwin-arm64": "0.28.2", - "@esbuild/darwin-x64": "0.28.2", - "@esbuild/freebsd-arm64": "0.28.2", - "@esbuild/freebsd-x64": "0.28.2", - "@esbuild/linux-arm": "0.28.2", - "@esbuild/linux-arm64": "0.28.2", - "@esbuild/linux-ia32": "0.28.2", - "@esbuild/linux-loong64": "0.28.2", - "@esbuild/linux-mips64el": "0.28.2", - "@esbuild/linux-ppc64": "0.28.2", - "@esbuild/linux-riscv64": "0.28.2", - "@esbuild/linux-s390x": "0.28.2", - "@esbuild/linux-x64": "0.28.2", - "@esbuild/netbsd-arm64": "0.28.2", - "@esbuild/netbsd-x64": "0.28.2", - "@esbuild/openbsd-arm64": "0.28.2", - "@esbuild/openbsd-x64": "0.28.2", - "@esbuild/openharmony-arm64": "0.28.2", - "@esbuild/sunos-x64": "0.28.2", - "@esbuild/win32-arm64": "0.28.2", - "@esbuild/win32-ia32": "0.28.2", - "@esbuild/win32-x64": "0.28.2" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "3.0.3", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, - "node_modules/expect-type": { - "version": "1.4.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, - "node_modules/fdir": { - "version": "6.5.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/foreground-child": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", - "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", - "dev": true, - "license": "ISC", - "dependencies": { - "cross-spawn": "^7.0.6", - "signal-exit": "^4.0.1" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/framer-motion": { - "version": "12.43.0", - "license": "MIT", - "dependencies": { - "motion-dom": "^12.43.0", - "motion-utils": "^12.39.0", - "tslib": "^2.4.0" - }, - "peerDependencies": { - "@emotion/is-prop-valid": "*", - "react": "^18.0.0 || ^19.0.0", - "react-dom": "^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/is-prop-valid": { - "optional": true - }, - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/glob": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", - "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", - "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/glob/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", - "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/glob/node_modules/minimatch": { - "version": "9.0.9", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", - "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.2" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/html-encoding-sniffer": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "whatwg-encoding": "^3.1.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/html-escaper": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", - "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true, - "license": "MIT" - }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/iconv-lite": { - "version": "0.6.3", - "dev": true, - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/indent-string": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-fullwidth-code-point": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", - "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/is-potential-custom-element-name": { - "version": "1.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, - "license": "ISC" - }, - "node_modules/istanbul-lib-coverage": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", - "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-report": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", - "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "istanbul-lib-coverage": "^3.0.0", - "make-dir": "^4.0.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-lib-source-maps": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", - "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "@jridgewell/trace-mapping": "^0.3.23", - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/istanbul-reports": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", - "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jackspeak": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", - "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "dev": true, - "license": "MIT" - }, - "node_modules/jsdom": { - "version": "26.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "cssstyle": "^4.2.1", - "data-urls": "^5.0.0", - "decimal.js": "^10.5.0", - "html-encoding-sniffer": "^4.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", - "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.16", - "parse5": "^7.2.1", - "rrweb-cssom": "^0.8.0", - "saxes": "^6.0.0", - "symbol-tree": "^3.2.4", - "tough-cookie": "^5.1.1", - "w3c-xmlserializer": "^5.0.0", - "webidl-conversions": "^7.0.0", - "whatwg-encoding": "^3.1.1", - "whatwg-mimetype": "^4.0.0", - "whatwg-url": "^14.1.1", - "ws": "^8.18.0", - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "canvas": "^3.0.0" - }, - "peerDependenciesMeta": { - "canvas": { - "optional": true - } - } - }, - "node_modules/jsesc": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/loupe": { - "version": "3.2.1", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/lz-string": { - "version": "1.5.0", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, - "node_modules/magic-string": { - "version": "0.30.21", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" - } - }, - "node_modules/magicast": { - "version": "0.3.5", - "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", - "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.25.4", - "@babel/types": "^7.25.4", - "source-map-js": "^1.2.0" - } - }, - "node_modules/make-dir": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", - "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", - "dev": true, - "license": "MIT", - "dependencies": { - "semver": "^7.5.3" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/make-dir/node_modules/semver": { - "version": "7.8.5", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", - "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/marked": { - "version": "14.0.0", - "license": "MIT", - "peer": true, - "bin": { - "marked": "bin/marked.js" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/min-indent": { - "version": "1.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=4" - } - }, - "node_modules/minimatch": { - "version": "10.2.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", - "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "brace-expansion": "^5.0.8" - }, - "engines": { - "node": "18 || 20 || >=22" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minipass": { - "version": "7.1.3", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", - "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/monaco-editor": { - "version": "0.56.0", - "license": "MIT", - "peer": true, - "dependencies": { - "dompurify": "3.4.8", - "marked": "14.0.0" - } - }, - "node_modules/motion-dom": { - "version": "12.43.0", - "license": "MIT", - "dependencies": { - "motion-utils": "^12.39.0" - } - }, - "node_modules/motion-utils": { - "version": "12.39.0", - "license": "MIT" - }, - "node_modules/ms": { - "version": "2.1.3", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.18", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-releases": { - "version": "2.0.54", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/nwsapi": { - "version": "2.2.27", - "dev": true, - "license": "MIT" - }, - "node_modules/package-json-from-dist": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", - "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", - "dev": true, - "license": "BlueOak-1.0.0" - }, - "node_modules/parse5": { - "version": "7.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "entities": "^6.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, - "node_modules/path-key": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", - "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/path-scurry": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", - "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", - "dev": true, - "license": "BlueOak-1.0.0", - "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" - }, - "engines": { - "node": ">=16 || 14 >=14.18" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.4.3", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", - "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", - "dev": true, - "license": "ISC" - }, - "node_modules/pathe": { - "version": "2.0.3", - "dev": true, - "license": "MIT" - }, - "node_modules/pathval": { - "version": "2.0.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14.16" - } - }, - "node_modules/picocolors": { - "version": "1.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "4.0.7", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.28", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.18", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/pretty-format": { - "version": "27.5.1", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/punycode": { - "version": "2.3.1", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/react": { - "version": "19.2.8", - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.8", - "license": "MIT", - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.8" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/react-refresh": { - "version": "0.18.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/redent": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "indent-string": "^4.0.0", - "strip-indent": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/rollup": { - "version": "4.63.1", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.9" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@napi-rs/lzma-linux-x64-gnu": "1.5.1", - "@rollup/rollup-android-arm-eabi": "4.63.1", - "@rollup/rollup-android-arm64": "4.63.1", - "@rollup/rollup-darwin-arm64": "4.63.1", - "@rollup/rollup-darwin-x64": "4.63.1", - "@rollup/rollup-freebsd-arm64": "4.63.1", - "@rollup/rollup-freebsd-x64": "4.63.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.63.1", - "@rollup/rollup-linux-arm-musleabihf": "4.63.1", - "@rollup/rollup-linux-arm64-gnu": "4.63.1", - "@rollup/rollup-linux-arm64-musl": "4.63.1", - "@rollup/rollup-linux-loong64-gnu": "4.63.1", - "@rollup/rollup-linux-loong64-musl": "4.63.1", - "@rollup/rollup-linux-ppc64-gnu": "4.63.1", - "@rollup/rollup-linux-ppc64-musl": "4.63.1", - "@rollup/rollup-linux-riscv64-gnu": "4.63.1", - "@rollup/rollup-linux-riscv64-musl": "4.63.1", - "@rollup/rollup-linux-s390x-gnu": "4.63.1", - "@rollup/rollup-linux-x64-gnu": "4.63.1", - "@rollup/rollup-linux-x64-musl": "4.63.1", - "@rollup/rollup-openbsd-x64": "4.63.1", - "@rollup/rollup-openharmony-arm64": "4.63.1", - "@rollup/rollup-win32-arm64-msvc": "4.63.1", - "@rollup/rollup-win32-ia32-msvc": "4.63.1", - "@rollup/rollup-win32-x64-gnu": "4.63.1", - "@rollup/rollup-win32-x64-msvc": "4.63.1", - "fsevents": "~2.3.2" - } - }, - "node_modules/rrweb-cssom": { - "version": "0.8.0", - "dev": true, - "license": "MIT" - }, - "node_modules/safer-buffer": { - "version": "2.1.2", - "dev": true, - "license": "MIT" - }, - "node_modules/saxes": { - "version": "6.0.0", - "dev": true, - "license": "ISC", - "dependencies": { - "xmlchars": "^2.2.0" - }, - "engines": { - "node": ">=v12.22.7" - } - }, - "node_modules/scheduler": { - "version": "0.27.0", - "license": "MIT" - }, - "node_modules/semver": { - "version": "6.3.1", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/shebang-command": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", - "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, - "license": "MIT", - "dependencies": { - "shebang-regex": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/shebang-regex": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", - "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - } - }, - "node_modules/siginfo": { - "version": "2.0.0", - "dev": true, - "license": "ISC" - }, - "node_modules/signal-exit": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", - "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, - "license": "ISC", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stackback": { - "version": "0.0.2", - "dev": true, - "license": "MIT" - }, - "node_modules/state-local": { - "version": "1.0.7", - "license": "MIT" - }, - "node_modules/std-env": { - "version": "3.10.0", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", - "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "eastasianwidth": "^0.2.0", - "emoji-regex": "^9.2.2", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/string-width-cjs": { - "name": "string-width", - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/string-width-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/string-width-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", - "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^6.2.2" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/strip-ansi?sponsor=1" - } - }, - "node_modules/strip-ansi-cjs": { - "name": "strip-ansi", - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-ansi/node_modules/ansi-regex": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.3.0.tgz", - "integrity": "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-regex?sponsor=1" - } - }, - "node_modules/strip-indent": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "min-indent": "^1.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/strip-literal": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "js-tokens": "^9.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/antfu" - } - }, - "node_modules/strip-literal/node_modules/js-tokens": { - "version": "9.0.1", - "dev": true, - "license": "MIT" - }, - "node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/symbol-tree": { - "version": "3.2.4", - "dev": true, - "license": "MIT" - }, - "node_modules/test-exclude": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.2.tgz", - "integrity": "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw==", - "dev": true, - "license": "ISC", - "dependencies": { - "@istanbuljs/schema": "^0.1.2", - "glob": "^10.4.1", - "minimatch": "^10.2.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tinybench": { - "version": "2.9.0", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "0.3.2", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinypool": { - "version": "1.1.1", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.0.0 || >=20.0.0" - } - }, - "node_modules/tinyrainbow": { - "version": "2.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tinyspy": { - "version": "4.0.6", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, - "node_modules/tldts": { - "version": "6.1.86", - "dev": true, - "license": "MIT", - "dependencies": { - "tldts-core": "^6.1.86" - }, - "bin": { - "tldts": "bin/cli.js" - } - }, - "node_modules/tldts-core": { - "version": "6.1.86", - "dev": true, - "license": "MIT" - }, - "node_modules/tough-cookie": { - "version": "5.1.2", - "dev": true, - "license": "BSD-3-Clause", - "dependencies": { - "tldts": "^6.1.32" - }, - "engines": { - "node": ">=16" - } - }, - "node_modules/tr46": { - "version": "5.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "punycode": "^2.3.1" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/tslib": { - "version": "2.8.1", - "license": "0BSD" - }, - "node_modules/typescript": { - "version": "5.9.3", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "7.18.2", - "dev": true, - "license": "MIT" - }, - "node_modules/update-browserslist-db": { - "version": "1.3.2", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "7.3.6", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.27.0 || ^0.28.0", - "fdir": "^6.5.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.6", - "rollup": "^4.43.0", - "tinyglobby": "^0.2.15" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "lightningcss": "^1.21.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 - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite-node": { - "version": "3.2.4", - "dev": true, - "license": "MIT", - "dependencies": { - "cac": "^6.7.14", - "debug": "^4.4.1", - "es-module-lexer": "^1.7.0", - "pathe": "^2.0.3", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" - }, - "bin": { - "vite-node": "vite-node.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/vitest": { - "version": "3.2.7", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/chai": "^5.2.2", - "@vitest/expect": "3.2.7", - "@vitest/mocker": "3.2.7", - "@vitest/pretty-format": "^3.2.7", - "@vitest/runner": "3.2.7", - "@vitest/snapshot": "3.2.7", - "@vitest/spy": "3.2.7", - "@vitest/utils": "3.2.7", - "chai": "^5.2.0", - "debug": "^4.4.1", - "expect-type": "^1.2.1", - "magic-string": "^0.30.17", - "pathe": "^2.0.3", - "picomatch": "^4.0.2", - "std-env": "^3.9.0", - "tinybench": "^2.9.0", - "tinyexec": "^0.3.2", - "tinyglobby": "^0.2.14", - "tinypool": "^1.1.1", - "tinyrainbow": "^2.0.0", - "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", - "vite-node": "3.2.4", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@types/debug": "^4.1.12", - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "@vitest/browser": "3.2.7", - "@vitest/ui": "3.2.7", - "happy-dom": "*", - "jsdom": "*" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@types/debug": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - } - } - }, - "node_modules/w3c-xmlserializer": { - "version": "5.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "xml-name-validator": "^5.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/webidl-conversions": { - "version": "7.0.0", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=12" - } - }, - "node_modules/whatwg-encoding": { - "version": "3.1.1", - "dev": true, - "license": "MIT", - "dependencies": { - "iconv-lite": "0.6.3" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-mimetype": { - "version": "4.0.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/whatwg-url": { - "version": "14.2.0", - "dev": true, - "license": "MIT", - "dependencies": { - "tr46": "^5.1.0", - "webidl-conversions": "^7.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/which": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", - "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, - "license": "ISC", - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/node-which" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", - "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^6.1.0", - "string-width": "^5.0.1", - "strip-ansi": "^7.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs": { - "name": "wrap-ansi", - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", - "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-styles": "^4.0.0", - "string-width": "^4.1.0", - "strip-ansi": "^6.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/wrap-ansi?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "license": "MIT", - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/wrap-ansi-cjs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "license": "MIT", - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wrap-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/ws": { - "version": "8.21.3", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/xml-name-validator": { - "version": "5.0.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18" - } - }, - "node_modules/xmlchars": { - "version": "2.2.0", - "dev": true, - "license": "MIT" - }, - "node_modules/yallist": { - "version": "3.1.1", - "dev": true, - "license": "ISC" - }, - "node_modules/zustand": { - "version": "5.0.15", - "license": "MIT", - "engines": { - "node": ">=12.20.0" - }, - "peerDependencies": { - "@types/react": ">=18.0.0", - "immer": ">=9.0.6", - "react": ">=18.0.0", - "use-sync-external-store": ">=1.2.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - }, - "immer": { - "optional": true - }, - "react": { - "optional": true - }, - "use-sync-external-store": { - "optional": true - } - } - } - } -} diff --git a/package.json b/package.json deleted file mode 100644 index 9fbfadf..0000000 --- a/package.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "name": "redrob-query", - "version": "0.2.4", - "private": true, - "description": "A JVM-free, AI-native desktop database workspace for PostgreSQL, MySQL, SQLite, and MongoDB.", - "license": "GPL-3.0-or-later", - "author": "Redrob", - "repository": { - "type": "git", - "url": "git+https://github.com/redrob-labs/redrob-query.git" - }, - "homepage": "https://github.com/redrob-labs/redrob-query#readme", - "bugs": { - "url": "https://github.com/redrob-labs/redrob-query/issues" - }, - "engines": { - "node": ">=22.12.0", - "npm": ">=11.0.0" - }, - "packageManager": "npm@11.4.2", - "type": "module", - "scripts": { - "dev": "vite --host 127.0.0.1", - "build": "npm run typecheck && vite build", - "preview": "vite preview --host 127.0.0.1", - "typecheck": "tsc --noEmit -p tsconfig.app.json --pretty false", - "test": "vitest run", - "test:coverage": "vitest run --coverage", - "release:check": "node scripts/validate-release.mjs", - "release:prepare": "node scripts/prepare-release-config.mjs", - "tauri": "tauri", - "rust:fmt": "cargo fmt --all -- --check", - "rust:clippy": "cargo clippy --locked --workspace --all-targets --all-features -- -D warnings", - "rust:test": "cargo test --locked --workspace --all-features", - "check": "npm run release:check && npm run check:upstream && npm run check:upstream-paths && npm run check:design && npm test && npm run build && npm run rust:fmt && npm run rust:clippy && npm run rust:test", - "check:design": "node scripts/check-design-system.mjs", - "check:upstream": "node scripts/verify-upstream.mjs", - "check:upstream-paths": "node scripts/check-upstream-paths.mjs", - "plugins:install": "node scripts/install-plugin-assets.mjs", - "predev": "npm run plugins:install", - "prebuild": "npm run plugins:install", - "prepreview": "npm run plugins:install", - "pretest": "npm run plugins:install", - "check:matrix": "node scripts/check-compatibility-matrix.mjs" - }, - "dependencies": { - "@beekeeperstudio/bks-er-diagram": "1.1.2", - "@monaco-editor/react": "^4.7.0", - "@redrob-labs/ui": "^1.0.2", - "@tanstack/react-table": "^8.21.3", - "@tanstack/react-virtual": "^3.13.12", - "@tauri-apps/api": "^2.8.0", - "clsx": "^2.1.1", - "framer-motion": "^12.23.12", - "react": "^19.1.1", - "react-dom": "^19.1.1", - "zustand": "^5.0.8" - }, - "overrides": { - "dompurify": "^3.4.15" - }, - "devDependencies": { - "@tauri-apps/cli": "^2.8.4", - "@testing-library/jest-dom": "^6.8.0", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "@types/node": "^24.3.1", - "@types/react": "^19.1.12", - "@types/react-dom": "^19.1.9", - "@vitejs/plugin-react": "^5.0.2", - "@vitest/coverage-v8": "^3.2.7", - "jsdom": "^26.1.0", - "typescript": "^5.9.2", - "vite": "^7.1.5", - "vitest": "^3.2.4" - } -} diff --git a/resources/brand/redrob-query-lockup-white.png b/resources/brand/redrob-query-lockup-white.png new file mode 100644 index 0000000..141bb97 Binary files /dev/null and b/resources/brand/redrob-query-lockup-white.png differ diff --git a/resources/brand/redrob-query-lockup.png b/resources/brand/redrob-query-lockup.png new file mode 100644 index 0000000..9169cf8 Binary files /dev/null and b/resources/brand/redrob-query-lockup.png differ diff --git a/resources/brand/redrob-query-splash.png b/resources/brand/redrob-query-splash.png new file mode 100644 index 0000000..0db3592 Binary files /dev/null and b/resources/brand/redrob-query-splash.png differ diff --git a/resources/icons/hicolor/128x128/apps/redrob-query.png b/resources/icons/hicolor/128x128/apps/redrob-query.png new file mode 100644 index 0000000..6812b56 Binary files /dev/null and b/resources/icons/hicolor/128x128/apps/redrob-query.png differ diff --git a/resources/icons/hicolor/16x16/apps/redrob-query.png b/resources/icons/hicolor/16x16/apps/redrob-query.png new file mode 100644 index 0000000..11ca3c3 Binary files /dev/null and b/resources/icons/hicolor/16x16/apps/redrob-query.png differ diff --git a/resources/icons/hicolor/256x256/apps/redrob-query.png b/resources/icons/hicolor/256x256/apps/redrob-query.png new file mode 100644 index 0000000..1e5101f Binary files /dev/null and b/resources/icons/hicolor/256x256/apps/redrob-query.png differ diff --git a/resources/icons/hicolor/32x32/apps/redrob-query.png b/resources/icons/hicolor/32x32/apps/redrob-query.png new file mode 100644 index 0000000..c5b67c8 Binary files /dev/null and b/resources/icons/hicolor/32x32/apps/redrob-query.png differ diff --git a/resources/icons/hicolor/48x48/apps/redrob-query.png b/resources/icons/hicolor/48x48/apps/redrob-query.png new file mode 100644 index 0000000..5cf8284 Binary files /dev/null and b/resources/icons/hicolor/48x48/apps/redrob-query.png differ diff --git a/resources/icons/hicolor/512x512/apps/redrob-query.png b/resources/icons/hicolor/512x512/apps/redrob-query.png new file mode 100644 index 0000000..1245af8 Binary files /dev/null and b/resources/icons/hicolor/512x512/apps/redrob-query.png differ diff --git a/resources/icons/hicolor/64x64/apps/redrob-query.png b/resources/icons/hicolor/64x64/apps/redrob-query.png new file mode 100644 index 0000000..0182fc8 Binary files /dev/null and b/resources/icons/hicolor/64x64/apps/redrob-query.png differ diff --git a/resources/icons/redrob-query-small.svg b/resources/icons/redrob-query-small.svg new file mode 100644 index 0000000..db04806 --- /dev/null +++ b/resources/icons/redrob-query-small.svg @@ -0,0 +1 @@ +Redrob diff --git a/resources/icons/redrob-query.icns b/resources/icons/redrob-query.icns new file mode 100644 index 0000000..f4dbb54 Binary files /dev/null and b/resources/icons/redrob-query.icns differ diff --git a/resources/icons/redrob-query.ico b/resources/icons/redrob-query.ico new file mode 100644 index 0000000..68cff0d Binary files /dev/null and b/resources/icons/redrob-query.ico differ diff --git a/resources/icons/redrob-query.svg b/resources/icons/redrob-query.svg new file mode 100644 index 0000000..3cf4abf --- /dev/null +++ b/resources/icons/redrob-query.svg @@ -0,0 +1 @@ +Redrob diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 28bb9c5..8934eb4 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel = "1.94.0" +channel = "1.95.0" components = ["clippy", "rustfmt"] profile = "minimal" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..4937083 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2024" +max_width = 100 +use_field_init_shorthand = true diff --git a/scripts/build-app-icon.py b/scripts/build-app-icon.py deleted file mode 100644 index 5337584..0000000 --- a/scripts/build-app-icon.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -"""Build the Redrob Query app icon master, then hand it to `tauri icon` for the platform sizes. - -Geometry and rules are assets/Products/README.md, not invention: - - Tile square top-left corner, 22% radius on the other three from 48px up; 19% at 32px. - Symbol the shipped file, Solid White, on every tile. Never redrawn or recoloured. - Doorway "Redrob's doorway is empty. Something in the doorway means a product." Query's is a - three-tier store. Every shipped glyph is FILLED white with no stroke, and Desk's folder - measures x 212..300, y 207..314, which is the room a glyph has. - Perspective "Every row runs toward the door's vanishing point and slants less nearer the middle, as - Office's rows do." So the three tiers narrow with depth rather than stacking square. - Tile colour Code's band. 20-color.md closes the spectrum at seven and states there is no eighth - product colour; 10-logo.md's way out is that a new product ships as a feature of the band - it fits, and Query is a developer tool over a database. Pinned in DESIGN_SYSTEM_PIN.json. - -The symbol path is LIFTED from a shipped product icon rather than retyped, so it cannot drift. -""" -import json -import pathlib -import re -import subprocess -import sys - -REPO = pathlib.Path(__file__).resolve().parent.parent -DELIVERY = pathlib.Path.home() / "workplace/redrob-design-system/assets/Products" -MASTER = REPO / "src-tauri/icons/app-icon.svg" -SMALL = REPO / "src-tauri/icons/app-icon-small.svg" -PNG = REPO / "src-tauri/icons/app-icon.png" - -pin = json.loads((REPO / "DESIGN_SYSTEM_PIN.json").read_text()) -TILE_COLOUR = pin["product_band"]["tile"] - -TILE = ("M0 0H399.36A112.64 112.64 0 0 1 512 112.64V399.36A112.64 112.64 0 0 1 399.36 512" - "H112.64A112.64 112.64 0 0 1 0 399.36Z") -TILE_32 = ("M0 0H414.72A97.28 97.28 0 0 1 512 97.28V414.72A97.28 97.28 0 0 1 414.72 512" - "H97.28A97.28 97.28 0 0 1 0 414.72Z") - -source = DELIVERY / "redrob-code-icon.svg" -if not source.exists(): - sys.exit(f"design system delivery not found: {source}") -symbol = re.search(r'( str: - """Three tiers in the doorway, each narrower and shorter than the one above it.""" - tiers = [] - top, height, gap = 214.0, 26.0, 8.0 - for i in range(3): - inset = i * 7.0 - left, right = 212.0 + inset, 300.0 - inset - y = top + i * (height + gap) - lip = (right - left) / 2 * 0.26 # the ellipse that gives the tier its lid - tiers.append( - f"M{left} {y}H{right}V{y + height - lip}" - f"A{(right - left) / 2} {lip} 0 0 1 {left} {y + height - lip}Z" - ) - return f'' - - -MASTER.write_text( - 'Redrob Query' - f'{symbol.group(1)}{store()}\n' -) -print(f"wrote {MASTER.relative_to(REPO)} tile {TILE_COLOUR}") - -# "-32.svg and -16.svg carry tile and symbol only (the doorway is two or three pixels wide there)." -SMALL.write_text( - 'Redrob Query' - f'{symbol.group(1)}\n' -) -print(f"wrote {SMALL.relative_to(REPO)} tile and symbol only, for 32px and below") - -subprocess.run( - ["magick", "-background", "none", str(MASTER), "-resize", "1024x1024", str(PNG)], check=True -) -subprocess.run(["npx", "tauri", "icon", str(PNG.relative_to(REPO))], cwd=REPO, check=True) - -ICONS = REPO / "src-tauri/icons" -for name, size in (("32x32.png", 32), ("Square30x30Logo.png", 30), ("Square44x44Logo.png", 44)): - subprocess.run( - ["magick", "-background", "none", str(SMALL), "-resize", f"{size}x{size}", - str(ICONS / name)], - check=True, - ) - print(f"re-rendered {name} from the small master") - -ico_parts = [] -for src, sizes in ((SMALL, (16, 24, 32, 48)), (MASTER, (64, 128, 256))): - for s in sizes: - out = ICONS / f".ico-{s}.png" - subprocess.run( - ["magick", "-background", "none", str(src), "-resize", f"{s}x{s}", str(out)], check=True - ) - ico_parts.append(out) -subprocess.run(["magick", *map(str, ico_parts), str(ICONS / "icon.ico")], check=True) -for p in ico_parts: - p.unlink() -print("rebuilt icon.ico: 16/24/32/48 without the doorway, 64/128/256 with it") diff --git a/scripts/build-brand-assets.py b/scripts/build-brand-assets.py new file mode 100755 index 0000000..836a77e --- /dev/null +++ b/scripts/build-brand-assets.py @@ -0,0 +1,308 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Build every Redrob Query brand asset from the design system's published files, and pin them. + + scripts/build-brand-assets.py --design-system ~/workplace/redrob-design-system + +Nothing here draws a mark. Every symbol pixel and every symbol path comes from a file the design +system ships, and the files those come from are pinned by sha256 in brand/SOURCES.json together +with the sha256 of every output. scripts/check-brand.py, which tools/check_design_system.py and CI +run, refuses an output tree that differs from the pins, so a hand edit, a re-trace or a recolor +fails the gate. + +Redrob Query is not one of the seven suite products, so it has no doorway glyph and no product +color (20-color.md: "No eighth product color"; assets/Products/README.md: "Something in the doorway +means a product"). Its icon is therefore the group's icon, as Redrob Canvas's and Redrob Shape's are: +the favicon, a white symbol on a Redrob Blue tile with an empty doorway (10-logo.md, +assets/Favicons/README.md). Ported from redrob-canvas's scripts/build-brand-assets.py. + +What is derived, and how: + - Icon sizes the design system ships (16, 32, 48, 64, 512) are copied byte for byte. 128 and 256 + exist only as downsizes of the 512 file, whose spec (radius 22%, mark 66%) the design system + gives for the whole 192-512 band. The one upscale is the 1024 macOS element (ic10): the design + system ships nothing larger than 512, and the .icns would otherwise be blurred by macOS itself. + - The .ico, the .icns and the freedesktop hicolor set are containers around those pixels. + - redrob-query.svg is favicon.svg, byte for byte. redrob-query-small.svg carries favicon.svg's + own symbol path, unchanged, in Solid White with no tile: the mark for a dark ground that is + not Redrob Blue (the shell's chrome, a symbolic icon theme), as assets/Favicons/README.md's + symbol-white-*.png are. + - The lockups follow assets/Products/README.md's product-lockup rule: the Redrob Primary lockup + placed untouched, 400px tall, with the name in Pretendard SemiBold at 70% of the height, set 40% + of the height after the wordmark, on the wordmark's baseline. The construction is proven before + anything is written: the shipped Browser lockup is rebuilt the same way and must match. + - The splash / About image is Redrob Black with the white lockup unit at 56px, the maths of + redrob-shape's splash. + - The installer PNGs are the 256 icon and the white lockup scaled, never redrawn. + +Needs ImageMagick 7 (`magick`) for resizing and the lockups; the check does not. +""" + +import argparse +import hashlib +import json +import re +import struct +import subprocess +import sys +import tempfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SOURCES_JSON = ROOT / "brand" / "SOURCES.json" + +FAVICONS = "assets/Favicons" +SOURCES = { + "favicon-16": f"{FAVICONS}/favicon-16x16.png", + "favicon-32": f"{FAVICONS}/favicon-32x32.png", + "favicon-48": f"{FAVICONS}/favicon-48x48.png", + "favicon-64": f"{FAVICONS}/favicon-64x64.png", + "icon-512": f"{FAVICONS}/icon-512.png", + "favicon-svg": f"{FAVICONS}/favicon.svg", + "lockup": "assets/Logos/redrob-logo-primary.png", + "lockup-white": "assets/Logos/redrob-logo-primary-white.png", + "browser-lockup-white": "assets/Products/redrob-browser-lockup-white.png", + "pretendard-semibold": "fonts/Pretendard-SemiBold.woff2", + "tokens": "tokens.css", +} + +# Outputs, relative to the repository root. +ICONS = "resources/icons" +BRAND = "resources/brand" +OUT_ICO = f"{ICONS}/redrob-query.ico" +OUT_ICNS = f"{ICONS}/redrob-query.icns" +OUT_SVG_APP = f"{ICONS}/redrob-query.svg" +OUT_SVG_SMALL = f"{ICONS}/redrob-query-small.svg" +HICOLOR_SIZES = (16, 32, 48, 64, 128, 256, 512) +OUT_LOCKUP = f"{BRAND}/redrob-query-lockup.png" +OUT_LOCKUP_WHITE = f"{BRAND}/redrob-query-lockup-white.png" +OUT_SPLASH = f"{BRAND}/redrob-query-splash.png" +OUT_WIN_ICON = "installer/windows/res/icon.png" +OUT_WIN_LOCKUP = "installer/windows/res/lockup.png" +OUT_MAC_ICON = "installer/macos/res/icon.png" + +PRODUCT = "Query" +LOCKUP_HEIGHT = 400 # assets/Products/README.md: 400px tall, transparent +SPLASH_SIZE = (1000, 500) +SPLASH_LOCKUP_HEIGHT = 56 # px; the lockup's minimum on screen is 18 +# The setup window draws the lockup 34 dip tall (above the 18px minimum); 68px covers 200% displays. +SETUP_LOCKUP_HEIGHT = 68 +# Measured from redrob-browser-lockup-white.png (3218 x 400): the name's ink spans rows 128..328 and +# "Browser" has no descender, so the baseline is row 327 (annotate puts the cap tops on row 128). Pretendard SemiBold at +# 280pt (70% of 400) reproduces the shipped name's width to the pixel with this tracking. +NAME_BASELINE = 327 / 400 +NAME_KERNING = -4.5 / 280 # per point +NAME_TAIL = 6 # the shipped lockups end 6px after the name's ink +# The rebuilt Browser lockup differs from the shipped one in 0.64% of alpha pixels (antialiasing +# along the name's edges); moving the name 1px right or down raises that past 0.75%. +BROWSER_TOLERANCE = 0.0075 + + +def sha256(path): + return hashlib.sha256(Path(path).read_bytes()).hexdigest() + + +def magick(*args, capture=False): + result = subprocess.run(["magick", *map(str, args)], check=True, capture_output=True) + return result.stdout if capture else None + + +def png_size(data): + if data[:8] != b"\x89PNG\r\n\x1a\n": + raise ValueError("not a PNG") + return struct.unpack(">II", data[16:24]) + + +def token(tokens_css, name): + """A token's hex value as the light theme resolves it (its block comes first in tokens.css).""" + match = re.search(rf"--{re.escape(name)}:\s*(#[0-9a-fA-F]{{6}}|var\(--[\w-]+\))", tokens_css) + if not match: + sys.exit(f"tokens.css has no --{name}") + value = match.group(1) + if value.startswith("var("): + return token(tokens_css, value[6:-1]) + return value.lower() + + +def write_ico(path, pngs): + """ICO with PNG entries (Windows Vista and later).""" + header = struct.pack("I", 8 + len(data)) + data for kind, data in entries) + path.write_bytes(b"icns" + struct.pack(">I", 8 + len(body)) + body) + + +def symbol_path(favicon_svg): + """The white symbol path from favicon.svg, unchanged.""" + paths = re.findall(r'', favicon_svg) + white = [d for fill, d in paths if fill.upper() in {"#FFF", "#FFFFFF"}] + if len(white) != 1: + sys.exit("favicon.svg no longer has exactly one white symbol path") + return white[0] + + +def symbol_svg(d, fill, label): + # favicon.svg's own 64-unit coordinates; only the tile is left out. + return ( + '{label}\n' + ) + + +def resize(source, size, out): + magick(source, "-filter", "Lanczos", "-resize", f"{size}x{size}", + "-define", "png:exclude-chunks=date,time", "-strip", f"PNG32:{out}") + return out.read_bytes() + + +def compose_lockup(logo, name, colour, height, font, out, scratch): + """The product-lockup rule: the Primary lockup untouched, the name after it on its baseline.""" + mark = scratch / f"mark-{height}-{out.name}" + magick(logo, "-filter", "Lanczos", "-resize", f"x{height}", "-strip", + "-define", "png:exclude-chunks=date,time", f"PNG32:{mark}") + mark_width = int(magick(mark, "-format", "%w", "info:", capture=True)) + size = 0.7 * height + gap = round(0.4 * height) + kerning = NAME_KERNING * size + text = ["-font", font, "-pointsize", f"{size:g}", "-kerning", f"{kerning:g}", "-fill", colour] + # Generous, then trimmed on the east only: the height stays `height` and the mark stays at x 0. + canvas_width = mark_width + gap + int(size * len(name) * 1.2) + magick( + "-size", f"{canvas_width}x{height}", "xc:none", + mark, "-geometry", "+0+0", "-composite", + *text, "-annotate", f"+{mark_width + gap}+{round(NAME_BASELINE * height)}", name, + "-define", "trim:edges=east", "-trim", "+repage", + "-gravity", "east", "-background", "none", "-splice", f"{NAME_TAIL}x0", "+gravity", + "-strip", "-define", "png:exclude-chunks=date,time", f"PNG32:{out}", + ) + + +def verify_construction(design, font, scratch): + """Rebuild the shipped Browser lockup this way; refuse to write anything if it differs.""" + shipped = design / SOURCES["browser-lockup-white"] + rebuilt = scratch / "browser.png" + compose_lockup(design / SOURCES["lockup-white"], "Browser", "white", LOCKUP_HEIGHT, font, rebuilt, scratch) + width, height = magick(shipped, "-format", "%w %h", "info:", capture=True).decode().split() + padded = scratch / "browser-padded.png" + magick(rebuilt, "-background", "none", "-extent", f"{width}x{height}", f"PNG32:{padded}") + # Alpha only: the mark is the same file either way, so the name is what is compared. + share = float(magick( + "(", shipped, "-alpha", "extract", ")", "(", padded, "-alpha", "extract", ")", + "-compose", "difference", "-composite", "-threshold", "50%", "-format", "%[fx:mean]", "info:", + capture=True, + )) + if share > BROWSER_TOLERANCE: + sys.exit(f"rebuilt Browser lockup differs from the shipped one in {share:.2%} of pixels; " + "the construction is not the delivery's, so nothing was written") + print(f"construction check: rebuilt Browser lockup matches the shipped one ({share:.3%} pixels differ)") + + +def build_splash(path, design, black, font, scratch): + width, height = SPLASH_SIZE + unit = scratch / "splash-unit.png" + compose_lockup(design / SOURCES["lockup-white"], PRODUCT, "white", SPLASH_LOCKUP_HEIGHT, font, unit, scratch) + unit_width = int(magick(unit, "-format", "%w", "info:", capture=True)) + left = (width - unit_width) // 2 + top = (height - SPLASH_LOCKUP_HEIGHT) // 2 + magick( + "-size", f"{width}x{height}", f"xc:{black}", + unit, "-geometry", f"+{left}+{top}", "-composite", + "-alpha", "off", "-define", "png:exclude-chunks=date,time", "-strip", f"PNG24:{path}", + ) + + +def build(design): + missing = [rel for rel in SOURCES.values() if not (design / rel).is_file()] + if missing: + sys.exit(f"design system files missing: {missing}") + read = {key: (design / rel).read_bytes() for key, rel in SOURCES.items()} + tokens_css = read["tokens"].decode("utf-8") + blue, black, ink = token(tokens_css, "blue-6"), token(tokens_css, "redrob-black"), token(tokens_css, "ink-primary") + favicon_svg = read["favicon-svg"].decode("utf-8") + if blue.upper() not in favicon_svg.upper(): + sys.exit("favicon.svg's tile is not tokens.css --blue-6") + d = symbol_path(favicon_svg) + font = design / SOURCES["pretendard-semibold"] + + with tempfile.TemporaryDirectory() as temporary: + scratch = Path(temporary) + verify_construction(design, font, scratch) + icon_512 = design / SOURCES["icon-512"] + resized = {size: resize(icon_512, size, scratch / f"icon-{size}.png") for size in (128, 256, 1024)} + pngs = {16: read["favicon-16"], 32: read["favicon-32"], 48: read["favicon-48"], 64: read["favicon-64"], + 128: resized[128], 256: resized[256], 512: read["icon-512"]} + + def write_lockup(logo_key, colour): + return lambda p: compose_lockup(design / SOURCES[logo_key], PRODUCT, colour, LOCKUP_HEIGHT, font, p, scratch) + + def scale_lockup_white(p): + magick(ROOT / OUT_LOCKUP_WHITE, "-filter", "Lanczos", "-resize", f"x{SETUP_LOCKUP_HEIGHT}", + "-strip", "-define", "png:exclude-chunks=date,time", f"PNG32:{p}") + + outputs = { + OUT_SVG_APP: lambda p: p.write_bytes(read["favicon-svg"]), + OUT_SVG_SMALL: lambda p: p.write_text(symbol_svg(d, "#FFF", "Redrob"), encoding="utf-8"), + OUT_ICO: lambda p: write_ico(p, [pngs[16], pngs[32], pngs[48], pngs[64], pngs[256]]), + OUT_ICNS: lambda p: write_icns(p, [ + ("icp4", pngs[16]), ("icp5", pngs[32]), ("icp6", pngs[64]), + ("ic07", pngs[128]), ("ic08", pngs[256]), ("ic09", pngs[512]), ("ic10", resized[1024]), + ("ic11", pngs[32]), ("ic12", pngs[64]), ("ic13", pngs[256]), ("ic14", pngs[512]), + ]), + **{f"{ICONS}/hicolor/{s}x{s}/apps/redrob-query.png": (lambda data: lambda p: p.write_bytes(data))(pngs[s]) + for s in HICOLOR_SIZES}, + OUT_LOCKUP: write_lockup("lockup", ink), + OUT_LOCKUP_WHITE: write_lockup("lockup-white", "white"), + OUT_SPLASH: lambda p: build_splash(p, design, black, font, scratch), + OUT_WIN_ICON: lambda p: p.write_bytes(pngs[256]), + OUT_MAC_ICON: lambda p: p.write_bytes(pngs[256]), + OUT_WIN_LOCKUP: scale_lockup_white, + } + for rel, make in outputs.items(): + target = ROOT / rel + target.parent.mkdir(parents=True, exist_ok=True) + make(target) + + copied = { + OUT_SVG_APP: SOURCES["favicon-svg"], + f"{ICONS}/hicolor/16x16/apps/redrob-query.png": SOURCES["favicon-16"], + f"{ICONS}/hicolor/32x32/apps/redrob-query.png": SOURCES["favicon-32"], + f"{ICONS}/hicolor/48x48/apps/redrob-query.png": SOURCES["favicon-48"], + f"{ICONS}/hicolor/64x64/apps/redrob-query.png": SOURCES["favicon-64"], + f"{ICONS}/hicolor/512x512/apps/redrob-query.png": SOURCES["icon-512"], + } + pins = { + "//": "Written by scripts/build-brand-assets.py; checked by scripts/check-brand.py. Do not edit.", + "design_system": "redrob-design-system (the published distribution)", + "sources": {rel: sha256(design / rel) for rel in SOURCES.values()}, + "symbol_path_sha256": hashlib.sha256(d.encode("utf-8")).hexdigest(), + "tokens": {"blue-6": blue, "redrob-black": black, "ink-primary": ink}, + "copied_unchanged": copied, + "owned_dirs": [ICONS, BRAND], + "outputs": {rel: sha256(ROOT / rel) for rel in sorted(outputs)}, + } + SOURCES_JSON.parent.mkdir(parents=True, exist_ok=True) + SOURCES_JSON.write_text(json.dumps(pins, indent=2) + "\n", encoding="utf-8") + for rel in sorted(outputs): + print(f" {rel} {pins['outputs'][rel][:12]}") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0]) + parser.add_argument("--design-system", required=True, type=Path) + args = parser.parse_args() + build(args.design_system.expanduser().resolve()) + + +if __name__ == "__main__": + main() diff --git a/scripts/check-brand.py b/scripts/check-brand.py new file mode 100755 index 0000000..2a8ed67 --- /dev/null +++ b/scripts/check-brand.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later +"""Refuse brand assets that are not exactly what build-brand-assets.py made from the design system. + + scripts/check-brand.py + +No dependencies: the gate and CI run it without ImageMagick and without the design system. A build +reads no pixels, so a hand-drawn or recolored mark passes every other check for as long as nobody +looks; this one compares bytes against the pins brand/SOURCES.json recorded when the files were +built from the design system, and refuses any file under resources/icons or resources/brand that +the pins do not account for. +""" + +import hashlib +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SOURCES_JSON = ROOT / "brand" / "SOURCES.json" +# Subtrees under the owned directories that are not brand assets (none today). +NOT_BRAND: set[str] = set() + + +def sha256(path): + return hashlib.sha256(path.read_bytes()).hexdigest() + + +def main(): + errors = [] + pins = json.loads(SOURCES_JSON.read_text(encoding="utf-8")) + outputs = pins["outputs"] + + present = set() + for owned in pins["owned_dirs"]: + for path in (ROOT / owned).rglob("*"): + rel = path.relative_to(ROOT).as_posix() + if path.is_file() and not any(rel.startswith(f"{skip}/") for skip in NOT_BRAND): + present.add(rel) + for rel in sorted(present - set(outputs)): + errors.append(f"{rel} is not built from the design system (not in brand/SOURCES.json)") + for rel, digest in sorted(outputs.items()): + path = ROOT / rel + if not path.is_file(): + errors.append(f"{rel} is missing") + elif sha256(path) != digest: + errors.append(f"{rel} was changed by hand: rebuild it with scripts/build-brand-assets.py") + + # Files the design system ships are carried unchanged, so their pins must agree. + for rel, source in pins["copied_unchanged"].items(): + if outputs.get(rel) != pins["sources"].get(source): + errors.append(f"{rel} is not byte-identical to the design system's {source}") + + # Every SVG mark carries favicon.svg's own symbol path, untouched, and no effect on it. + for rel in outputs: + if not rel.endswith(".svg") or not (ROOT / rel).is_file(): + continue + text = (ROOT / rel).read_text(encoding="utf-8") + for fill, d in re.findall(r'', text): + if fill.upper() in {"#FFF", "#FFFFFF", "#000", "#000000"}: + if hashlib.sha256(d.encode()).hexdigest() != pins["symbol_path_sha256"]: + errors.append(f"{rel} carries a symbol path that is not the design system's") + if re.search(r"\b(filter|mask|opacity|transform)=", text): + errors.append(f"{rel} applies an effect to the mark (filter, mask, opacity or transform)") + + if errors: + print("\n".join(f"error: {error}" for error in errors), file=sys.stderr) + sys.exit(1) + print(f"brand assets match the design system pins: {len(outputs)} files") + + +if __name__ == "__main__": + main() diff --git a/scripts/check-compatibility-matrix.mjs b/scripts/check-compatibility-matrix.mjs deleted file mode 100644 index 6f45a6a..0000000 --- a/scripts/check-compatibility-matrix.mjs +++ /dev/null @@ -1,219 +0,0 @@ -#!/usr/bin/env node -// SPDX-License-Identifier: GPL-3.0-or-later -// -// Check docs/compatibility.md: every `parity` row cites a harness, and every citation names a check that -// harness actually makes. -// -// WHY. `parity` is stage 3's output -- the claim that this product matches its authority, as against `native`, -// which claims only that an implementation is here. This repository had NO compatibility matrix at all until -// stage 3 asked for rows to be promoted and there was nothing to promote them in. -// -// Two failures are possible and the second is the worse one: -// -// * a row claiming parity with nothing to justify it -- a judgement dressed as a measurement; -// * a row citing a check no harness makes -- a citation that cannot be followed, which looks verified and is -// not. A reworded label, a deleted check, or a key invented while writing the row all land here. -// -// The citation keys are read from the harnesses themselves (`--list`), not hardcoded, so this cannot drift from -// what they cover. Both list their keys WITHOUT needing a database, which is what lets this guard run in -// `npm test` while the harnesses themselves cannot. - -import { readFileSync, existsSync } from "node:fs"; -import { execFileSync } from "node:child_process"; - -const MATRIX = "docs/compatibility.md"; -const STATUSES = new Set(["planned", "native", "adapter", "parity"]); - -// harness key prefix -> how to ask that harness what it covers. -const HARNESSES = { - dialect: ["npx", ["tsx", "scripts/verify-dialect-sql.mjs", "--list"]], - "upstream-sql": ["node", ["scripts/verify-upstream-sql.mjs", "--list"]], -}; - -const problems = []; - -if (!existsSync(MATRIX)) { - console.error(`error: ${MATRIX} is missing`); - process.exit(1); -} - -// Ask each harness for its keys. A harness that cannot be asked is a failure: without its list every citation -// into it would be unverifiable, and passing anyway would be the silent-pass this repository refuses elsewhere. -const covered = {}; -for (const [prefix, [command, args]] of Object.entries(HARNESSES)) { - try { - covered[prefix] = new Set( - execFileSync(command, args, { encoding: "utf8" }) - .trim() - .split("\n") - .map((line) => line.trim()) - .filter(Boolean), - ); - } catch (error) { - problems.push( - `harness '${prefix}' could not list its coverage: ${error.message.split("\n")[0]}`, - ); - covered[prefix] = null; - } -} - -const rows = []; -let inFeatureTable = false; -for (const [index, line] of readFileSync(MATRIX, "utf8") - .split("\n") - .entries()) { - if (line.startsWith("| Domain")) inFeatureTable = true; - else if (!line.startsWith("|")) inFeatureTable = false; - if ( - !line.startsWith("| ") || - line.startsWith("| Domain") || - /^[|\-\s]+$/.test(line) - ) - continue; - const cellCount = line - .trim() - .replace(/^\||\|$/g, "") - .split("|").length; - if (inFeatureTable && cellCount !== 6 && cellCount !== 7) { - // Measured: a stray `|` made a row 8 cells wide and it silently left the parse, so the guard passed with - // one row fewer. Inside the feature table every row must be counted or rejected. - problems.push( - `line ${index + 1}: feature row has ${cellCount} cells; expected 7`, - ); - continue; - } - const cells = line - .trim() - .replace(/^\||\|$/g, "") - .split("|") - .map((cell) => cell.trim()); - // A 6-cell row is kept so it can be rejected: a row that drops the Works column must fail, not vanish - // from the parse and pass. - if ((cells.length === 6 || cells.length === 7) && STATUSES.has(cells[4])) - rows.push({ line: index + 1, cells }); -} - -if (!rows.length) { - // A parser that finds nothing passes every check vacuously, which is the one outcome this must never call - // success. - console.error( - "error: no matrix rows parsed; the table shape changed and this guard is now blind", - ); - process.exit(1); -} - -const counts = { planned: 0, native: 0, adapter: 0, parity: 0 }; -const cited = new Set(); -const verifiedNotParity = []; -// `Works` answers a different question from `Status`: `native` meant "the code is here", and the shipped app had -// native rows no button reaches (docs/user-paths.md). `yes` needs a citation of a test that drives the feature -// the way a user does -- `e2e:::`, where exists and contains . -const E2E = /`e2e:([A-Za-z0-9_./-]+)::([A-Za-z0-9_]+)`/g; -let working = 0; - -for (const { line, cells } of rows) { - if (cells.length !== 7) { - problems.push( - `line ${line}: ${cells[0]}/${cells[1].slice(0, 40)} has no Works column`, - ); - continue; - } - const [domain, feature, authority, route, status, evidence, works] = cells; - counts[status] += 1; - if (works !== "yes" && works !== "no") { - problems.push( - `line ${line}: ${domain}/${feature.slice(0, 40)} Works must be yes or no, not '${works}'`, - ); - } else if (works === "yes") { - working += 1; - if (status === "planned") { - problems.push( - `line ${line}: ${domain}/${feature.slice(0, 40)} is planned but claims Works: yes`, - ); - } - const e2e = [...evidence.matchAll(E2E)]; - if (!e2e.length) { - problems.push( - `line ${line}: ${domain}/${feature.slice(0, 40)} claims Works: yes but cites no \`e2e:::\``, - ); - } - for (const [, path, name] of e2e) { - const target = path; // relative to the repo root, like MATRIX - if (!existsSync(target)) { - problems.push( - `line ${line}: cites \`e2e:${path}::${name}\` but ${path} does not exist`, - ); - } else if (!readFileSync(target, "utf8").includes(name)) { - problems.push( - `line ${line}: cites \`e2e:${path}::${name}\` but ${path} has no ${name}`, - ); - } - } - } - const keys = [...evidence.matchAll(/`([a-z-]+):([a-z0-9-]+)`/g)].map( - (match) => [match[1], match[2]], - ); - - if (status === "parity" && !keys.length) { - problems.push( - `line ${line}: ${domain}/${feature.slice(0, 40)} claims parity but cites no harness. ` + - "`parity` is a citation, not a judgement", - ); - } - if (status !== "parity" && keys.length) { - // NOT an error, and this was a correction. The first version rejected evidence on a non-parity row, - // reasoning "promote it or drop the citation". redrob-recall produced the case that disproves it: its - // grammar harness verifies that its search grammar is bloop's REDUCED, with three constructs that are - // its own. That is a real, checkable relationship and it is not equivalence, so the row must stay - // `native` while carrying its evidence. Reported so nothing hides. - verifiedNotParity.push(`${domain}/${feature.slice(0, 40)}`); - } - for (const [prefix, key] of keys) { - cited.add(`${prefix}:${key}`); - if (!(prefix in covered)) { - problems.push(`line ${line}: cites unknown harness '${prefix}'`); - } else if (covered[prefix] && !covered[prefix].has(key)) { - problems.push( - `line ${line}: cites \`${prefix}:${key}\` but that harness does not make a check by that name`, - ); - } - } - if (!authority) - problems.push( - `line ${line}: ${domain}/${feature.slice(0, 40)} names no authority`, - ); - if (!route) - problems.push( - `line ${line}: ${domain}/${feature.slice(0, 40)} describes no route`, - ); -} - -console.log(`compatibility matrix: ${rows.length} rows`); -console.log( - ` works ${working} of ${rows.length} (driven end to end by a cited test)`, -); -for (const status of ["parity", "native", "adapter", "planned"]) { - console.log(` ${status.padEnd(8)} ${counts[status]}`); -} -// The reverse direction, reported rather than enforced: a check nobody cites may mean a missing row. -for (const [prefix, keys] of Object.entries(covered)) { - if (!keys) continue; - const uncited = [...keys].filter((key) => !cited.has(`${prefix}:${key}`)); - console.log( - ` ${prefix}: ${keys.size} checks, ${keys.size - uncited.length} cited`, - ); - if (uncited.length) console.log(` no row cites: ${uncited.join(", ")}`); -} - -if (verifiedNotParity.length) { - console.log( - ` cited without claiming parity: ${verifiedNotParity.join(", ")}`, - ); -} - -if (problems.length) { - console.error(""); - for (const problem of problems) console.error(`error: ${problem}`); - process.exit(1); -} -console.log("every parity claim cites a check its harness makes"); diff --git a/scripts/check-design-system.mjs b/scripts/check-design-system.mjs deleted file mode 100644 index d2a6699..0000000 --- a/scripts/check-design-system.mjs +++ /dev/null @@ -1,173 +0,0 @@ -#!/usr/bin/env node -// Guards the design system against the four ways it drifts back, each of which a build happily passes. -// -// 1. A raw colour literal in CSS. Once one exists the next edit copies it, and the app stops -// following `data-theme`, because a literal cannot retint. -// 2. A lucide import. 45-icons.md: "Do not import Feather, Lucide (lucide-react), Material or -// framework icons." The package can come back as a transitive dependency of a UI kit, so the -// manifest is checked too, not only the source. -// 3. A brand file edited. 10-logo.md forbids recolouring, restretching and effects, and nothing in a -// build reads a PNG's pixels -- so a nudged gradient ships silently. The hash is the only gate. -// 4. A typeface that is not in the system. A second display face is a second voice. -// -// Reverse-verified: each check was confirmed to FAIL with the defect it names put back. - -import { createHash } from "node:crypto"; -import { readdirSync, readFileSync, statSync } from "node:fs"; -import { join, relative } from "node:path"; -import { fileURLToPath } from "node:url"; -import { findInkOnGroundCollisions } from "./ink-on-ground.mjs"; - -const ROOT = join(fileURLToPath(new URL(".", import.meta.url)), ".."); -const failures = []; - -function walk(dir, out = []) { - for (const entry of readdirSync(dir)) { - if (entry === "node_modules" || entry === "dist" || entry.startsWith(".")) continue; - const full = join(dir, entry); - if (statSync(full).isDirectory()) walk(full, out); - else out.push(full); - } - return out; -} - -const files = walk(join(ROOT, "src")); -const rel = (f) => relative(ROOT, f); - -// 1. Colour literals. The token imports at the top of a sheet are the only place a colour may be -// named, and they name a file, not a value. -const COLOUR = /#[0-9a-fA-F]{3,8}\b|\brgba?\(\s*\d|:\s*(?:white|black)\b/g; -for (const file of files.filter((f) => f.endsWith(".css"))) { - readFileSync(file, "utf8") - .split("\n") - .forEach((line, i) => { - if (line.trimStart().startsWith("@import")) return; - if (line.trimStart().startsWith("*") || line.trimStart().startsWith("/*")) return; - for (const hit of line.match(COLOUR) ?? []) { - failures.push(`${rel(file)}:${i + 1} raw colour ${hit} -- use a token from tokens.css`); - } - }); -} - -// 2. Lucide, in source and in the manifest. -for (const file of files) { - const text = readFileSync(file, "utf8"); - if (/from\s+["']lucide/.test(text)) { - failures.push(`${rel(file)} imports lucide -- 45-icons.md forbids it; use icons from @redrob-labs/ui`); - } -} -const pkg = JSON.parse(readFileSync(join(ROOT, "package.json"), "utf8")); -for (const field of ["dependencies", "devDependencies"]) { - for (const name of Object.keys(pkg[field] ?? {})) { - if (/lucide|feather-icons|@mui\/icons/.test(name)) { - failures.push(`package.json ${field} carries ${name} -- 45-icons.md forbids a framework icon set`); - } - } -} - -// 3. The pinned brand artwork, byte for byte. -const pin = JSON.parse(readFileSync(join(ROOT, "DESIGN_SYSTEM_PIN.json"), "utf8")); -for (const asset of pin.brand_assets) { - let actual; - try { - actual = createHash("sha256").update(readFileSync(join(ROOT, asset.file))).digest("hex"); - } catch { - failures.push(`${asset.file} is missing -- it is pinned in DESIGN_SYSTEM_PIN.json`); - continue; - } - if (actual !== asset.sha256) { - failures.push( - `${asset.file} does not match its pin.\n expected ${asset.sha256}\n actual ${actual}\n` + - ` 10-logo.md: the artwork is never redrawn, restretched, recoloured or otherwise modified.\n` + - ` Re-copy it from the delivery at ${asset.delivery_path}, or update the pin deliberately.`, - ); - } -} -if (pin.version !== (pkg.dependencies?.["@redrob-labs/ui"] ?? "").replace(/^[^\d]*/, "")) { - failures.push( - `DESIGN_SYSTEM_PIN.json pins @redrob-labs/ui ${pin.version} but package.json asks for ` + - `${pkg.dependencies?.["@redrob-labs/ui"]} -- the pin records which delivery the values came from`, - ); -} - -// 4. Typefaces. Only the system's own font tokens may set a family. -for (const file of files.filter((f) => f.endsWith(".css"))) { - readFileSync(file, "utf8") - .split("\n") - .forEach((line, i) => { - const m = /font-family:\s*([^;]+);/.exec(line); - if (!m) return; - if (/^(inherit|var\(--font-(sans|sans-kr|sans-hi|mono|serif|serif-kr|serif-display|display)\))$/.test(m[1].trim())) return; - failures.push( - `${rel(file)}:${i + 1} font-family ${m[1].trim()} -- use var(--font-sans) or another font token`, - ); - }); -} - -// 5. Ink that cannot be read on its own ground, in either theme. See ink-on-ground.mjs for why this is -// a check rather than a review: a class that sits on both grounds is right in one screen and -// invisible in the other, and a screenshot only shows the screen you rendered. -for (const problem of findInkOnGroundCollisions( - files.filter((f) => f.endsWith(".css")), - join(ROOT, "node_modules/@redrob-labs/ui/dist/styles/tokens.json"), -)) { - failures.push(problem); -} - -// 6. A button with a visible label and no icon. The first audit found letters standing in for icons -// ('i', '‹', '›', '×') and action buttons with none, and nothing failed. Dialog footers and the -// command-bar trigger stay text-only on purpose, so they are named here rather than exempted by a -// pattern that would also wave through the next bare button. Menu items are checked too: an -// `items` entry with a `label` must carry an `icon`. -// A JSX opening tag ends at the first `>` outside any `{...}`: `onClick={() => x}` contains one. -function openingTagEnd(tag) { - let depth = 0; - for (let i = 0; i < tag.length; i++) { - if (tag[i] === "{") depth++; - else if (tag[i] === "}") depth--; - else if (tag[i] === ">" && depth === 0) return i; - } - return tag.length; -} -// Drop every brace-balanced `{...}` expression, nested ones included, leaving the literal text. -function stripExpressions(text) { - let out = ""; - let depth = 0; - for (const ch of text) { - if (ch === "{") depth++; - else if (ch === "}") depth = Math.max(0, depth - 1); - else if (depth === 0) out += ch; - } - return out; -} -const TEXT_ONLY_BUTTONS = new Set([ - "Cancel", // dialog footers: AiSettingsModal, Navigator - "Discard all", // ChangesPanel footer, beside its own staged list - "Search or run a command", // the command-bar trigger (its key hint is written from shortcuts.ts), which is a field, not an action -]); -for (const file of files.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))) { - const source = readFileSync(file, "utf8"); - for (const m of source.matchAll(//g)) { - const body = m[0]; - if (/".length); - const visible = stripExpressions(inner) - .replace(/<[^>]+>/g, " ") - .replace(/\s+/g, " ") - .trim(); - if (!/[A-Za-z\u2039\u203a\u00d7]/.test(visible) || TEXT_ONLY_BUTTONS.has(visible)) continue; - const line = source.slice(0, m.index).split("\n").length; - failures.push(`${rel(file)}:${line} button "${visible.slice(0, 40)}" has no icon -- add one from @redrob-labs/ui`); - } - for (const m of source.matchAll(/\{\s*id:\s*'[^']+',\s*label:\s*'([^']+)'(?![^}]*\bicon:)[^}]*\}/g)) { - const line = source.slice(0, m.index).split("\n").length; - failures.push(`${rel(file)}:${line} menu item "${m[1]}" has no icon`); - } -} - -if (failures.length) { - console.error(`design system guard: ${failures.length} problem(s)\n`); - for (const f of failures) console.error(" " + f); - process.exit(1); -} -console.log("design system guard: clean"); diff --git a/scripts/check-design-system.verify.py b/scripts/check-design-system.verify.py deleted file mode 100644 index a094c4d..0000000 --- a/scripts/check-design-system.verify.py +++ /dev/null @@ -1,50 +0,0 @@ -#!/usr/bin/env python3 -"""Prove the guard fails when the thing it guards is broken. - -A guard that has never failed is a guard nobody has tested, and a green suite is exactly what a broken -guard produces. Each defect is put back, the guard is run, and the file is restored from bytes held in -memory -- not from git, because none of this is committed yet and a checkout would delete the work. -""" -import pathlib -import subprocess -import sys - -REPO = pathlib.Path(__file__).resolve().parent.parent -GUARD = ["node", "scripts/check-design-system.mjs"] - -CASES = [ - ("src/styles/app.css", lambda t: t + "\n.guard-probe { color: #ff0000; }\n", "raw colour literal"), - ("src/ui/Icon.tsx", lambda t: 'import { X } from "lucide-react";\n' + t, "lucide import"), - ("src/styles/app.css", lambda t: t + '\n.guard-probe { font-family: "Comic Sans MS"; }\n', - "off-system typeface"), - ("src/assets/brand/redrob-symbol.png", None, "edited brand artwork"), -] - -failed = [] -for path, mutate, label in CASES: - target = REPO / path - original = target.read_bytes() - try: - if mutate is None: - target.write_bytes(original + b"\x00") # one byte is enough to change the hash - else: - target.write_text(mutate(original.decode())) - result = subprocess.run(GUARD, cwd=REPO, capture_output=True, text=True) - if result.returncode == 0: - failed.append(f"{label}: guard PASSED with the defect present") - else: - first = next((l.strip() for l in result.stderr.splitlines() if l.strip().startswith(("src/", "package.json"))), "") - print(f" caught {label}\n {first[:110]}") - finally: - target.write_bytes(original) - -clean = subprocess.run(GUARD, cwd=REPO, capture_output=True, text=True) -if clean.returncode != 0: - failed.append("the tree did not restore cleanly after the probes:\n" + clean.stderr) - -if failed: - print("\nreverse verification FAILED:") - for f in failed: - print(" " + f) - sys.exit(1) -print("\nall four checks fail on their defect, and the tree restored clean") diff --git a/scripts/check-upstream-paths.mjs b/scripts/check-upstream-paths.mjs deleted file mode 100644 index 43128bf..0000000 --- a/scripts/check-upstream-paths.mjs +++ /dev/null @@ -1,193 +0,0 @@ -#!/usr/bin/env node -// -// Refuse any file copied from Beekeeper Studio's commercially-licensed directories. -// -// WHY THIS EXISTS AND WHY IT IS A MACHINE CHECK. -// -// Beekeeper Studio's repository is MIXED licence. LICENSE-COMMERCIAL.md puts every -// file under a `src-commercial` directory under a commercial licence that forbids -// use in a separate project; all 1,817 other files are GPLv3 and are what this -// product ports. Measured at the pinned commit: 55 commercial files, 544 KB. -// -// 55 of 1,872 is far too small a fraction for review to catch reliably. One file -// pasted in a hurry is a licence violation that looks exactly like ordinary work, -// reads correctly, passes every test, and would ship. So the boundary is enforced -// here rather than trusted to attention. -// -// THIS GUARD HANDLES NO COMMERCIAL CONTENT. It compares git blob SHA-1 values, -// taken from the upstream repository's git TREE listing -- which publishes each -// blob's hash without its bytes. Nothing under src-commercial was fetched to build -// the fingerprint, nothing is stored here, and this file could be published without -// disclosing a line of it. A guard that embedded the material it protects against -// would be the very copy it exists to prevent. -// -// Four layers, because each catches a different mistake: -// -// 1. CONTENT. Every tracked source file in this repository is hashed and compared -// against the 54 recorded hashes. Catches the realistic failure: a verbatim -// copy. That is what a paste IS at the moment it happens, and this guard runs -// in `npm run check`, so it fires before any adaptation can disguise it. -// -// 2. PROVENANCE. Every upstream path recorded in UPSTREAM_NOTICES.md's subsystem -// table is checked for a commercial path. Catches a DECLARED copy -- somebody -// writing down that they took a commercial file, which review would also -// catch, but which should never reach review. -// -// 3. WORKING TREE. If a Beekeeper checkout exists under upstream/, it must not -// have commercial files staged into git. Catches the accident of adding a -// whole upstream tree to the index. -// -// The empty blob is deliberately absent from the fingerprint. src-commercial/.keep -// is an empty file and every empty file shares its hash; recording it would flag -// every empty file here. Excluded by hash, not by name, so a future empty -// commercial file is still only excluded for being empty. - -import { readFileSync, existsSync } from "node:fs"; -import { execFileSync } from "node:child_process"; -import { createHash } from "node:crypto"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const root = join(dirname(fileURLToPath(import.meta.url)), ".."); -const fingerprintPath = join(root, "docs/beekeeper-commercial-fingerprint.json"); - -if (!existsSync(fingerprintPath)) { - console.error("error: docs/beekeeper-commercial-fingerprint.json is missing"); - console.error(" Without it nothing stops a commercial file being copied in."); - process.exit(1); -} - -const fingerprint = JSON.parse(readFileSync(fingerprintPath, "utf8")); -if (!Array.isArray(fingerprint.files) || fingerprint.files.length === 0) { - console.error("error: the commercial fingerprint records no files; it cannot guard anything"); - process.exit(1); -} - -const byHash = new Map(fingerprint.files.map((f) => [f.sha1, f.path])); -const problems = []; - -/** git's blob hash: sha1 of "blob \0". The same value the tree listing - * publishes, so an identical file here and there produces an identical hash. */ -function gitBlobSha1(bytes) { - return createHash("sha1") - .update(`blob ${bytes.length}\0`) - .update(bytes) - .digest("hex"); -} - -// --- 1. content --------------------------------------------------------------- -// Tracked files only. An untracked file is not in this repository yet; the check -// that matters runs before a commit, and `git ls-files` is what "in the repository" -// means. -let tracked = []; -try { - tracked = execFileSync("git", ["ls-files", "-z"], { cwd: root, maxBuffer: 64 * 1024 * 1024 }) - .toString() - .split("\0") - .filter(Boolean); -} catch { - console.error("error: could not list tracked files; is this a git checkout?"); - process.exit(1); -} - -// The fingerprint itself would not match anything, but skip it and the guard -// explicitly: reasoning about a file that contains hashes is a distraction. -const selfPaths = new Set([ - "docs/beekeeper-commercial-fingerprint.json", - "scripts/check-upstream-paths.mjs", -]); - -let hashed = 0; -for (const rel of tracked) { - if (selfPaths.has(rel)) continue; - let bytes; - try { - bytes = readFileSync(join(root, rel)); - } catch { - continue; // deleted or unreadable; not our concern - } - // An empty file can never match: the empty blob is excluded from the - // fingerprint, and skipping here makes that explicit rather than incidental. - if (bytes.length === 0) continue; - hashed += 1; - const sha = gitBlobSha1(bytes); - const upstream = byHash.get(sha); - if (upstream) { - problems.push( - `${rel} is byte-identical to ${upstream}, which is under Beekeeper Studio's ` + - `COMMERCIAL licence and may not be used in this project`, - ); - } -} - -// --- 2. provenance ------------------------------------------------------------ -// Scoped to the "Which subsystems" table, and that scoping is not fussiness: the -// first run of this guard failed on UPSTREAM_NOTICES.md's own boundary table, the -// one whose whole purpose is to state that src-commercial must never be copied. -// A file that documents a prohibition mentions the prohibited thing. Only the -// subsystem table is a claim about what this repository CONTAINS, so only it is a -// provenance claim. -const noticesPath = join(root, "UPSTREAM_NOTICES.md"); -if (existsSync(noticesPath)) { - const notices = readFileSync(noticesPath, "utf8"); - const lines = notices.split("\n"); - let inSubsystemTable = false; - for (const [index, line] of lines.entries()) { - if (/^#{2,4}\s/.test(line)) { - inSubsystemTable = /which subsystems/i.test(line); - continue; - } - if (!inSubsystemTable) continue; - if (!line.trimStart().startsWith("|")) continue; - if (!line.includes("src-commercial")) continue; - problems.push( - `UPSTREAM_NOTICES.md line ${index + 1} records a commercial upstream path in the ` + - `subsystem table; that file may not be ported`, - ); - } -} - -// --- 3. working tree ---------------------------------------------------------- -// If somebody fetches Beekeeper into upstream/ to port from it, the tree itself is -// fine -- reading GPLv3 files is the whole point. What must never happen is those -// paths entering git. -for (const rel of tracked) { - if (/(^|\/)src-commercial(\/|$)/.test(rel)) { - problems.push(`${rel} is a tracked path under src-commercial and must not be committed`); - } -} - -// --- 4. the ignore rule itself ------------------------------------------------ -// Layer 3 catches a commercial path that reached the index. This asserts the rule -// that should stop it getting there. The probe uses a path INSIDE upstream/ rather -// than the directory name, because the rule is written `/upstream/` with a trailing -// slash to match a directory, and `git check-ignore upstream` misses it when the -// directory does not exist locally -- which is exactly the state a fresh clone is -// in, and therefore the state this check has to work in. -for (const probe of [ - "upstream/beekeeper/apps/studio/src-commercial/backend/handlers/awsHandlers.ts", - "upstream/beekeeper/apps/studio/src/common/appdb/models/user.ts", -]) { - try { - execFileSync("git", ["check-ignore", "-q", probe], { cwd: root, stdio: "ignore" }); - } catch { - problems.push( - `${probe} is not ignored by git, so an upstream checkout could be committed ` + - `wholesale -- 284 MB of it, including the 54 commercial files`, - ); - } -} - -if (problems.length > 0) { - console.error("error: Beekeeper Studio commercial-licence boundary violated"); - for (const p of problems) console.error(` ${p}`); - console.error(""); - console.error("Only files OUTSIDE src-commercial are GPLv3 and portable. See"); - console.error("UPSTREAM_NOTICES.md and docs/upstream-sources.toml."); - process.exit(1); -} - -console.log( - `commercial boundary: clean (${hashed} tracked files hashed against ` + - `${fingerprint.files.length} commercial blobs at ${fingerprint.commit.slice(0, 12)})`, -); diff --git a/scripts/fix-danger-vs-brand.py b/scripts/fix-danger-vs-brand.py deleted file mode 100644 index 8e15f4e..0000000 --- a/scripts/fix-danger-vs-brand.py +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env python3 -"""Tell an error apart from the old brand red, which the classifier cannot. - -Both were #ff405c, so classifying by hue sent every one of them to `status-danger`. That makes the DEMO -badge, the Redrob AI mark, the bot avatar and the REDROB DATA label in the status bar all render in the -colour this app uses for failure -- a workspace that looks broken while nothing is wrong. - -A site keeps `status-danger` only when its own selector says it is a failure or a destructive action. -Everything else was the brand, and 20-color.md sends the brand signal to Redrob Blue: `ink-brand` where -it is ink on dark, which is `blue-4` and reads at text size, not `action-primary`, which is a fill. -""" -import pathlib -import re -import sys - -SRC = pathlib.Path("src/styles/app.css") - -# A failure, or an action that destroys something. These keep the danger colour. -KEEP = ( - "error", "danger", "invalid", "del", "remove-confirmation", - "change-card > button:hover", "profile-actions-menu button.danger", -) - -text = SRC.read_text() -lines = text.split("\n") -changed = kept = 0 -report: list[str] = [] - -for i, line in enumerate(lines): - if "var(--status-danger)" not in line: - continue - # One rule per line in this sheet, so the selector is whatever precedes the first brace. - for rule in re.finditer(r"([^{}]+)\{([^{}]*)\}", line): - selector, body = rule.group(1).strip(), rule.group(2) - if "var(--status-danger)" not in body: - continue - if any(k in selector for k in KEEP): - kept += 1 - report.append(f" keep {selector[:70]}") - continue - new_body = body.replace("var(--status-danger)", "var(--ink-brand)") - lines[i] = lines[i].replace(rule.group(0), selector + " {" + new_body + "}", 1) - changed += 1 - report.append(f" brand {selector[:70]}") - -SRC.write_text("\n".join(lines)) -for r in report: - print(r) -print(f"\n{changed} site(s) were the brand, {kept} are genuinely a failure or destructive") - -# A border in the danger colour on the PRIMARY button was never right either: the fill is the action -# colour, so its edge cannot be the failure colour. -text = SRC.read_text() -before = text -text = text.replace( - ".primary-button { border-color: var(--ink-brand); background: var(--accent); color: white;", - ".primary-button { border-color: var(--action-primary); background: var(--action-primary);" - " color: var(--ink-on-brand);", -) -text = text.replace( - ".danger-button { border-color: var(--status-danger); background: var(--action-primary);" - " color: white;", - ".danger-button { border-color: var(--status-danger); background: var(--status-danger);" - " color: var(--ink-on-brand);", -) -if text == before: - sys.exit("neither button rule matched -- check the selectors before trusting this script") -SRC.write_text(text) -print("primary button: action fill and edge; danger button: danger fill; both take ink-on-brand") -print("remaining literal 'white' keywords:", len(re.findall(r":\s*white\b", text))) diff --git a/scripts/ink-on-ground.mjs b/scripts/ink-on-ground.mjs deleted file mode 100644 index 2b0242d..0000000 --- a/scripts/ink-on-ground.mjs +++ /dev/null @@ -1,87 +0,0 @@ -// Fails when a rule sets both an ink and a ground whose tokens are too close to read, in EITHER theme. -// -// Why a check and not a review. The same class can sit on a light ground in one screen and a dark one -// in another -- `.search-submit` is inside the dark onboarding AND inside the light search page -- so a -// colour chosen for one is invisible in the other, and a screenshot only shows the screen you rendered. -// This found seven such pairs in recall and one in query, of which exactly one had been noticed by eye. -// -// 3:1 is the floor here rather than 4.5:1 because these pairs include badges and glyphs, which WCAG -// treats as non-text; the design system's own marks are set where their symbol clears 3.3:1. A rule -// that genuinely needs a lower ratio is decoration and should not be setting an ink at all. -import { readFileSync } from "node:fs"; - -const THRESHOLD = 3.0; - -function srgbToLinear(channel) { - return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; -} - -function luminance(hex) { - const h = hex.replace("#", ""); - if (h.length !== 6) return null; - const [r, g, b] = [0, 2, 4].map((i) => srgbToLinear(parseInt(h.slice(i, i + 2), 16) / 255)); - return 0.2126 * r + 0.7152 * g + 0.0722 * b; -} - -function contrast(a, b) { - const [la, lb] = [luminance(a), luminance(b)]; - if (la === null || lb === null) return null; - return (Math.max(la, lb) + 0.05) / (Math.min(la, lb) + 0.05); -} - -/** - * @param {string[]} sheets absolute paths to the app's own stylesheets - * @param {string} tokensJson absolute path to @redrob-labs/ui's dist/styles/tokens.json - * @returns {string[]} one message per pair below the threshold - */ -export function findInkOnGroundCollisions(sheets, tokensJson) { - const resolved = new Map(); - for (const token of JSON.parse(readFileSync(tokensJson, "utf8")).tokens) { - if (token.kind === "color") resolved.set(token.name, { light: token.light, dark: token.dark }); - } - - // The app's own aliases, so `var(--ink)` resolves to the token it points at. - const aliases = new Map(); - for (const sheet of sheets) { - const text = readFileSync(sheet, "utf8"); - for (const [, name, target] of text.matchAll(/^\s*(--[\w-]+):\s*var\((--[\w-]+)\)\s*;/gm)) { - aliases.set(name.slice(2), target.slice(2)); - } - } - - const tokenOf = (value) => { - const found = /^var\((--[\w-]+)\)$/.exec(value.trim()); - if (!found) return null; - let name = found[1].slice(2); - const seen = new Set(); - while (aliases.has(name) && !seen.has(name)) { - seen.add(name); - name = aliases.get(name); - } - return resolved.has(name) ? name : null; - }; - - const problems = []; - for (const sheet of sheets) { - const text = readFileSync(sheet, "utf8"); - for (const rule of text.matchAll(/([^{}]+)\{([^{}]*)\}/g)) { - const selector = rule[1].trim().split("\n").pop().trim(); - const ink = /(?= THRESHOLD) continue; - problems.push( - `${selector} — ${theme} theme: ${inkToken} on ${groundToken} is ${ratio.toFixed(2)}:1, ` + - `below ${THRESHOLD}:1. The same class can sit on both grounds, so pick tokens that pair ` + - `in each: an action fill takes ink-on-brand, a neutral surface takes ink-primary.`, - ); - } - } - } - return problems; -} diff --git a/scripts/install-plugin-assets.mjs b/scripts/install-plugin-assets.mjs deleted file mode 100644 index 20ae819..0000000 --- a/scripts/install-plugin-assets.mjs +++ /dev/null @@ -1,153 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later -// -// Copy installed plugin packages' prebuilt files into public/, where the app serves them. -// -// Generated rather than committed. The ER diagram's dist is 1.8 MB of third-party build output, and -// committing build output means every version bump lands as a megabyte-scale binary diff in this -// repository's history. The npm package is the pinned artefact -- docs/upstream-sources.toml records -// the version and the licence -- so this script only places what that pin already decided. -// -// Runs before dev, build and preview, because all three serve public/ and a missing plugin there is -// exactly the failure the registry's `installed` flag is there to prevent. - -import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { createRequire } from 'node:module'; -import { dirname, join, relative } from 'node:path'; -import { fileURLToPath } from 'node:url'; - -const require = createRequire(import.meta.url); -const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); - -/** - * Plugins whose files this app serves. - * - * `id` must match the registry entry in src/plugins/registry.ts, since that is what maps a view key - * to the base URL these files land on. - */ -const PLUGINS = [ - { - id: 'bks-er-diagram', - package: '@beekeeperstudio/bks-er-diagram', - // Copied from the package root, so the manifest travels with its own dist. - include: ['dist', 'manifest.json', 'LICENSE'], - }, -]; - -/** - * Source maps are excluded, and the reason is arithmetic rather than taste. - * - * The ER diagram ships a 3.9 MB .js.map beside a 1.3 MB .js -- three quarters of the package by - * size. It is debug data for third-party code we do not debug, and including it would put 5.6 MB into - * every build instead of 1.8 MB. If someone ever does need to debug inside a plugin, the map is one - * `npm pack` away. - */ -const EXCLUDE = /\.map$/; - -const pluginRoot = (packageName) => { - // Resolve through the package's own manifest rather than guessing node_modules/: that is what - // makes this work under a workspace, a pnpm store or a hoisted install. - const manifestPath = require.resolve(`${packageName}/package.json`); - return dirname(manifestPath); -}; - -/** - * Injected into every plugin's entry HTML, before its own scripts run. - * - * The sandbox that makes plugin content safe (`allow-scripts` WITHOUT `allow-same-origin`) gives the - * frame an opaque origin, and reading `window.localStorage` from an opaque origin throws - * SecurityError rather than returning null. That is fatal at startup, not merely degraded: the ER - * diagram reads localStorage in a ref initialiser (`show-all-columns`) and in a store's state factory - * (`debug-ui`), both of which run while the app is being created, so the throw would leave a blank - * frame with no error the user can see. - * - * Measured in the installed bundle: 4 localStorage references, 0 sessionStorage, 0 document.cookie. - * - * The shim is in-memory and per-mount, so a plugin's own UI preferences do not survive reopening the - * view. That is the honest cost, and it is the right way round: the protocol already carries - * getViewState/setViewState for state a plugin wants persisted, and the alternative -- granting - * allow-same-origin -- would put untrusted third-party code in the application's own origin to save a - * checkbox's position. - * - * It installs itself ONLY if the real storage is unreachable, so a future arrangement that serves - * plugins from their own distinct origin gets genuine persistent storage and this steps aside. - */ -const STORAGE_SHIM = ``; - -let copied = 0; -let skipped = 0; -const report = []; - -for (const plugin of PLUGINS) { - let source; - try { - source = pluginRoot(plugin.package); - } catch { - // A missing package leaves `installed` false in the registry and the app shows its empty state, - // so this is a warning rather than a failed build -- an optional view must not stop the app from - // being built. - report.push(` ${plugin.id}: package ${plugin.package} is not installed, skipping`); - skipped += 1; - continue; - } - - const destination = join(ROOT, 'public', 'plugins', plugin.id); - rmSync(destination, { recursive: true, force: true }); - mkdirSync(destination, { recursive: true }); - - for (const entry of plugin.include) { - const from = join(source, entry); - if (!existsSync(from)) { - report.push(` ${plugin.id}: ${entry} is not in the package, skipping`); - continue; - } - cpSync(from, join(destination, entry), { - recursive: true, - filter: (path) => !EXCLUDE.test(path), - }); - } - - // Two edits to the shipped entry HTML, both recorded because editing third-party content should - // never be silent. - const indexPath = join(destination, 'dist', 'index.html'); - if (existsSync(indexPath)) { - const before = readFileSync(indexPath, 'utf8'); - // 1. The plugin references /vite.svg absolutely, which resolves against the APP root rather than - // the plugin's directory -- the app's own favicon would answer a request the plugin made for - // its own. - let after = before.replace(/(href|src)="\/(?!\/)/g, '$1="./'); - if (after !== before) { - report.push(` ${plugin.id}: rewrote absolute asset paths in dist/index.html to relative`); - } - // 2. The storage shim, inserted at the top of so it runs before the plugin's own module - // script, which is deferred by virtue of being type="module". - if (!after.includes('redrob-plugin-storage-shim')) { - const shim = STORAGE_SHIM.replace('']); - render(); - const warning = await screen.findByTestId('startup-warnings'); - expect(warning).toHaveTextContent('Skipped '); - expect(warning.querySelector('script')).toBeNull(); - }); - - it('supports current-page controls, paging, messages, sorting, filtering, and columns', async () => { - const user = await renderWorkspace(); - await user.click(screen.getByTestId('run-query')); - await screen.findByText(/Showing 1–50 · more available/); - await user.click(screen.getByRole('button', { name: 'Next page' })); - expect(await screen.findByText(/Showing 51–64 · end of results/)).toBeInTheDocument(); - await user.selectOptions(screen.getByRole('combobox', { name: 'Rows per page' }), '25'); - expect(await screen.findByText(/Showing 1–25 · more available/)).toBeInTheDocument(); - - const sortName = screen.getByRole('button', { name: 'Sort name' }); - await user.click(sortName); - expect(screen.getByRole('button', { name: 'Sort name asc' })).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Columns' })); - const email = screen.getByRole('checkbox', { name: 'email' }); - await user.click(email); - expect(screen.queryByRole('button', { name: 'Sort email' })).not.toBeInTheDocument(); - - const filter = screen.getByRole('textbox', { name: 'Filter current page' }); - await user.type(filter, 'Avery'); - expect(screen.getByRole('button', { name: 'Clear filter' })).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: 'Clear filter' })); - expect(filter).toHaveValue(''); - - await user.click(screen.getByRole('button', { name: /Messages/ })); - expect(screen.getByText('Execution messages')).toBeInTheDocument(); - expect(screen.getByText('Query completed successfully.')).toBeInTheDocument(); - }); - - it('provides local history restore and clear controls', async () => { - const user = await renderWorkspace(); - await user.click(screen.getByTestId('run-query')); - await screen.findByText(/Showing 1–50/); - await user.click(screen.getByRole('button', { name: 'Query history' })); - expect(screen.getByText('Local query history')).toBeInTheDocument(); - expect(screen.getByText(/Successful first-page queries remain in memory for this browser session/)).toBeInTheDocument(); - const historyDialog = screen.getByRole('dialog', { name: 'Query history' }); - await user.click(within(historyDialog).getByRole('button', { name: /Customer overview/ })); - expect((screen.getByTestId('monaco-editor') as HTMLTextAreaElement).value).toContain('LIMIT 250'); - await user.click(screen.getByRole('button', { name: 'Query history' })); - await user.click(screen.getByRole('button', { name: 'Clear history' })); - expect(screen.getByText('No successful queries yet.')).toBeInTheDocument(); - }); - - it('prefills edit mode without a secret and requires confirmation before removing user profiles', async () => { - const user = userEvent.setup(); - const bridge = new DemoBridge(); - const saveConnection = vi.spyOn(bridge, 'saveConnection'); - const removeConnection = vi.spyOn(bridge, 'removeConnection'); - const testConnection = vi.spyOn(bridge, 'testConnection'); - render(); - await screen.findByTestId('demo-banner'); - await user.click(screen.getByTestId('open-connection-modal')); - await user.clear(screen.getByTestId('connection-name')); - await user.type(screen.getByTestId('connection-name'), 'Temporary'); - await user.type(screen.getByTestId('connection-password'), 'never-display-this'); - await user.click(screen.getByTestId('save-connection')); - await waitFor(() => expect(screen.queryByTestId('connection-modal')).not.toBeInTheDocument()); - await user.click(screen.getByRole('button', { name: 'Connection actions' })); - await user.click(screen.getByRole('button', { name: 'Edit' })); - expect(screen.getByRole('heading', { name: 'Edit connection' })).toBeInTheDocument(); - expect(screen.getByTestId('connection-name')).toHaveValue('Temporary'); - expect(screen.getByTestId('connection-password')).toHaveValue(''); - expect(document.body).not.toHaveTextContent('never-display-this'); - await user.click(screen.getByTestId('test-connection')); - await waitFor(() => expect(testConnection).toHaveBeenCalledOnce()); - await waitFor(() => expect(screen.getByTestId('test-connection')).toBeEnabled()); - expect(testConnection).toHaveBeenLastCalledWith(expect.objectContaining({ id: expect.any(String), password: '' })); - await user.clear(screen.getByTestId('connection-host')); - await user.type(screen.getByTestId('connection-host'), 'changed.example.test'); - await user.click(screen.getByTestId('test-connection')); - expect(await screen.findByText(/Enter the password again before testing a changed endpoint or database identity/)).toBeInTheDocument(); - expect(testConnection).toHaveBeenCalledOnce(); - - await user.clear(screen.getByTestId('connection-host')); - await user.type(screen.getByTestId('connection-host'), 'localhost'); - await user.clear(screen.getByTestId('connection-database')); - await user.type(screen.getByTestId('connection-database'), 'changed_database'); - await user.click(screen.getByTestId('test-connection')); - expect(await screen.findByText(/Enter the password again before testing a changed endpoint or database identity/)).toBeInTheDocument(); - expect(testConnection).toHaveBeenCalledOnce(); - - await user.clear(screen.getByTestId('connection-database')); - await user.type(screen.getByTestId('connection-database'), 'analytics'); - await user.click(screen.getByTestId('connection-tls')); - await user.click(screen.getByTestId('test-connection')); - expect(await screen.findByText(/Enter the password again before testing a changed endpoint or database identity/)).toBeInTheDocument(); - expect(testConnection).toHaveBeenCalledOnce(); - await user.click(screen.getByTestId('save-connection')); - expect(await screen.findByText(/Enter the password again after changing the endpoint or database identity/)).toBeInTheDocument(); - expect(saveConnection).toHaveBeenCalledOnce(); - await user.click(screen.getByRole('button', { name: 'Close connection manager' })); - await user.click(screen.getByRole('button', { name: 'Connection actions' })); - await user.click(screen.getByRole('button', { name: 'Remove' })); - const confirmation = screen.getByRole('dialog', { name: 'Remove “Temporary”?' }); - expect(removeConnection).not.toHaveBeenCalled(); - expect(within(confirmation).getByText(/stored credential, open queries, and local query history/)).toBeInTheDocument(); - await user.click(within(confirmation).getByRole('button', { name: 'Cancel' })); - expect(screen.queryByRole('dialog', { name: 'Remove “Temporary”?' })).not.toBeInTheDocument(); - expect(removeConnection).not.toHaveBeenCalled(); - expect(screen.getByRole('button', { name: 'Choose connection' })).toHaveTextContent('Temporary'); - - await user.click(screen.getByRole('button', { name: 'Connection actions' })); - await user.click(screen.getByRole('button', { name: 'Remove' })); - await user.click(screen.getByRole('button', { name: 'Remove Temporary' })); - await waitFor(() => expect(removeConnection).toHaveBeenCalledOnce()); - await waitFor(() => expect(screen.getByRole('button', { name: 'Choose connection' })).toHaveTextContent('No connection')); - await user.click(screen.getByRole('button', { name: 'Choose connection' })); - expect(screen.getByRole('button', { name: /Acme Warehouse connected/ })).toBeInTheDocument(); - expect(screen.queryByText('Temporary')).not.toBeInTheDocument(); - }); -}); - - - -describe('built-in profile actions', () => { - it('disables edit and remove for the seeded built-in profile', async () => { - const user = await renderWorkspace(); - await user.click(screen.getByRole('button', { name: 'Connection actions' })); - expect(screen.getByRole('button', { name: 'Edit' })).toBeDisabled(); - expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled(); - }); -}); diff --git a/src/App.tsx b/src/App.tsx deleted file mode 100644 index f086434..0000000 --- a/src/App.tsx +++ /dev/null @@ -1,103 +0,0 @@ -import { TableChangeModal } from './components/TableChangeModal'; -import { useEffect, useMemo } from 'react'; -import { Loader, Mark } from '@redrob-labs/ui'; -import symbolDark from './assets/brand/redrob-symbol-solid-white.png'; -import symbolLight from './assets/brand/redrob-symbol.png'; -import { Icon } from './ui/Icon'; -import clsx from 'clsx'; -import { createBridge, type DataBridge } from './api/bridge'; -import { ActivityRail } from './components/ActivityRail'; -import { AiPanel } from './components/AiPanel'; -import { AiSettingsModal } from './components/AiSettingsModal'; -import { ChangesPanel } from './components/ChangesPanel'; -import { CommandPalette } from './components/CommandPalette'; -import { ConnectionModal } from './components/ConnectionModal'; -import { Navigator } from './components/Navigator'; -import { QueryEditor } from './components/QueryEditor'; -import { ResultGrid } from './components/ResultGrid'; -import { StatusBar } from './components/StatusBar'; -import { StructurePanel } from './components/StructurePanel'; -import { PluginWorkspace } from './plugins/PluginWorkspace'; -import { WorkspaceProvider, useWorkspace } from './store/WorkspaceProvider'; -import './styles/app.css'; -import { formatShortcut, matches } from './shortcuts'; - -function Workspace() { - const initialize = useWorkspace((state) => state.initialize); - const initialized = useWorkspace((state) => state.initialized); - const bridgeMode = useWorkspace((state) => state.bridge.mode); - const startupWarnings = useWorkspace((state) => state.startupWarnings); - const activeConnectionId = useWorkspace((state) => state.activeConnectionId); - const navigatorOpen = useWorkspace((state) => state.navigatorOpen); - const aiOpen = useWorkspace((state) => state.aiOpen); - const changesOpen = useWorkspace((state) => state.changesOpen); - const pluginViewId = useWorkspace((state) => state.pluginViewId); - const setUi = useWorkspace((state) => state.setUi); - const runQuery = useWorkspace((state) => state.runQuery); - const newTab = useWorkspace((state) => state.newTab); - const saveActiveQuery = useWorkspace((state) => state.saveActiveQuery); - const toasts = useWorkspace((state) => state.toasts); - const dismissToast = useWorkspace((state) => state.dismissToast); - - useEffect(() => { void initialize(); }, [initialize]); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (matches(event, 'palette')) { event.preventDefault(); setUi({ commandPaletteOpen: true }); } - if (matches(event, 'runQuery')) { event.preventDefault(); void runQuery(); } - if (matches(event, 'saveQuery')) { event.preventDefault(); saveActiveQuery(); } - if (matches(event, 'newQuery')) { event.preventDefault(); newTab(); } - if (matches(event, 'askAi')) { event.preventDefault(); setUi({ aiOpen: true }); } - if (event.key === 'Escape') setUi({ connectionModalOpen: false, connectionModalProfileId: null, commandPaletteOpen: false, aiSettingsOpen: false }); - }; - window.addEventListener('keydown', onKeyDown); - return () => window.removeEventListener('keydown', onKeyDown); - }, [newTab, runQuery, saveActiveQuery, setUi]); - - useEffect(() => { - if (!toasts.length) return; - const timers = toasts.map((toast) => window.setTimeout(() => dismissToast(toast.id), toast.tone === 'error' ? 10_000 : 5_000)); - return () => timers.forEach((timer) => window.clearTimeout(timer)); - }, [dismissToast, toasts]); - - if (!initialized) return

Opening your data workspace…

; - return ( -
-
- - {bridgeMode === 'demo' ?
DEMO Interactive browser workspace · sample data only
:
Redrob Query · Desktop workspace · edits apply only after review
} - - {bridgeMode === 'demo' ? 'No server connection' : activeConnectionId ? 'Desktop workspace' : 'No connection selected'} -
- {startupWarnings.length ?
Some local workspace items need attention{startupWarnings.map((warning, index) => {warning})}
: null} -
- - {navigatorOpen ? : null} -
- {pluginViewId ? ( - setUi({ pluginViewId: null })} /> - ) : ( - <> - - - - )} -
- - {changesOpen ? : null} - {aiOpen ? : null} -
- - - - -
{toasts.map((toast) => )}
-
- ); -} - -export function App({ bridge }: { bridge?: DataBridge }) { - const dataBridge = useMemo(() => bridge ?? createBridge(), [bridge]); - return ; -} - -export default App; diff --git a/src/api/bridge.test.ts b/src/api/bridge.test.ts deleted file mode 100644 index 3f9862d..0000000 --- a/src/api/bridge.test.ts +++ /dev/null @@ -1,546 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; - -const invokeMock = vi.hoisted(() => vi.fn()); -vi.mock('@tauri-apps/api/core', () => ({ invoke: invokeMock })); - -import { DemoBridge, TauriBridge } from './bridge'; - -const request = (query: string, connectionId = 'demo-postgres') => ({ connectionId, query, language: 'sql' as const, limit: 50 }); - -describe('DemoBridge', () => { - it('provides deterministic connections and expandable PostgreSQL metadata', async () => { - const bridge = new DemoBridge(); - const connections = await bridge.listConnections(); - expect(connections).toEqual([expect.objectContaining({ id: 'demo-postgres', name: 'Acme Warehouse', isDemo: true, tls: true })]); - - const roots = await bridge.loadMetadata(connections[0].id); - expect(roots).toEqual([expect.objectContaining({ id: 'db-commerce', name: 'commerce' })]); - const schemas = await bridge.loadMetadata(connections[0].id, 'db-commerce'); - expect(schemas.map((node) => node.name)).toEqual(['public', 'analytics']); - const tables = await bridge.loadMetadata(connections[0].id, 'schema-public'); - expect(tables.map((node) => node.name)).toContain('customers'); - }); - - it('runs SELECT queries and returns typed rows', async () => { - const bridge = new DemoBridge(); - const result = await bridge.executeQuery(request('SELECT * FROM public.customers LIMIT 3;')); - expect(result.rows).toHaveLength(3); - expect(result.columns).toContainEqual(expect.objectContaining({ key: 'mrr', dataType: 'number' })); - expect(result.rows[0]).toEqual(expect.objectContaining({ id: 'cus_0001', plan: 'Scale' })); - }); - - it('saves Mongo profiles with Mongo metadata and an honest read-only find fixture', async () => { - const bridge = new DemoBridge(); - const { profile: mongo } = await bridge.saveConnection({ - name: 'Documents', kind: 'mongodb', host: 'localhost', port: 27017, database: 'accounts', username: 'reader', tls: true, authSource: 'admin', - }); - expect((await bridge.listConnections()).at(-1)).toEqual(expect.objectContaining({ kind: 'mongodb', database: 'accounts', authSource: 'admin' })); - const roots = await bridge.loadMetadata(mongo.id); - expect(roots).toEqual([expect.objectContaining({ name: 'accounts' })]); - expect(roots).not.toContainEqual(expect.objectContaining({ name: 'commerce' })); - const collections = await bridge.loadMetadata(mongo.id, roots[0].id); - expect(collections).toEqual([expect.objectContaining({ name: 'customers', kind: 'table' })]); - const fields = await bridge.loadMetadata(mongo.id, collections[0].id); - expect(fields.map((node) => node.name)).toContain('_id'); - - const result = await bridge.executeQuery({ - connectionId: mongo.id, - language: 'mql', - query: '{"collection":"customers","operation":"find","filter":{"active":true},"limit":2}', - }); - expect(result.rows).toHaveLength(2); - expect(result.rows[0]).toEqual(expect.objectContaining({ _id: expect.any(String), tier: 'Growth', active: true })); - expect(result.editSource).toBeUndefined(); - await expect(bridge.executeQuery({ connectionId: mongo.id, language: 'sql', query: 'SELECT * FROM customers' })).rejects.toThrow('MQL JSON'); - await expect(bridge.executeQuery({ connectionId: 'demo-postgres', language: 'mql', query: '{}' })).rejects.toThrow('accept SQL'); - const ai = await bridge.askAi({ prompt: 'Show monthly revenue', connectionId: mongo.id, databaseKind: 'mongodb', language: 'mql' }); - expect(ai.generatedLanguage).toBe('mql'); - expect(JSON.parse(ai.generatedQuery!)).toEqual(expect.objectContaining({ operation: 'find' })); - expect(ai.generatedQuery).not.toContain('public.orders'); - }); - - it('uses SQLite-specific metadata and starter-compatible rows', async () => { - const bridge = new DemoBridge(); - const { profile: sqlite } = await bridge.saveConnection({ - name: 'Local file', kind: 'sqlite', host: '', port: 0, database: '', username: '', filePath: '/tmp/demo.sqlite', tls: true, - }); - const roots = await bridge.loadMetadata(sqlite.id); - expect(roots[0].name).toBe('demo.sqlite'); - const result = await bridge.executeQuery({ connectionId: sqlite.id, language: 'sql', query: 'SELECT id, name, email, city, created_at FROM customers LIMIT 1;' }); - expect(result.rows[0]).toEqual(expect.objectContaining({ id: 1, city: 'Austin' })); - expect(result.rows[0]).not.toHaveProperty('mrr'); - }); - - it('rejects direct writes and supports deterministic AI revenue generation', async () => { - const bridge = new DemoBridge(); - await expect(bridge.executeQuery(request('DELETE FROM customers;'))).rejects.toThrow('accepts SELECT/CTE'); - const response = await bridge.askAi({ prompt: 'Show monthly revenue', databaseKind: 'postgresql', language: 'sql' }); - expect(response.intent).toBe('query'); - expect(response.generatedLanguage).toBe('sql'); - expect(response.generatedQuery).toContain('SUM(total) AS revenue'); - const result = await bridge.executeQuery(request(response.generatedQuery!)); - expect(result.rows[0]).toEqual({ month: '2025-08', revenue: 128420, orders: 1842 }); - }); - - it('acknowledges AI key saves without persisting or sending the secret', async () => { - const bridge = new DemoBridge(); - const secret = 'rrk_demo_secret_123'; - await expect(bridge.saveAiKey(secret)).resolves.toBeUndefined(); - expect(invokeMock).not.toHaveBeenCalled(); - expect(JSON.stringify(bridge)).not.toContain(secret); - }); - - it('isolates PostgreSQL customer and order edits from a same-engine peer saved afterward', async () => { - const bridge = new DemoBridge(); - const result = await bridge.applyMutations([ - { id: 'cus_0001-plan', connectionId: 'demo-postgres', table: 'public.customers', primaryKey: 'id', rowKey: 'cus_0001', column: 'plan', previousValue: 'Scale', nextValue: 'Enterprise' }, - { id: 'ord_00001-status', connectionId: 'demo-postgres', table: 'public.orders', primaryKey: 'id', rowKey: 'ord_00001', column: 'status', previousValue: 'completed', nextValue: 'processing' }, - ]); - expect(result).toEqual(expect.objectContaining({ applied: 2 })); - - const { profile: peer } = await bridge.saveConnection({ - name: 'PostgreSQL peer', kind: 'postgresql', host: 'localhost', port: 5432, database: 'commerce_peer', username: 'demo', tls: true, - }); - const [ownerCustomers, ownerOrders, peerCustomers, peerOrders] = await Promise.all([ - bridge.executeQuery(request('SELECT * FROM public.customers LIMIT 1;')), - bridge.executeQuery(request('SELECT * FROM public.orders LIMIT 1;')), - bridge.executeQuery(request('SELECT * FROM public.customers LIMIT 1;', peer.id)), - bridge.executeQuery(request('SELECT * FROM public.orders LIMIT 1;', peer.id)), - ]); - expect(ownerCustomers.rows[0].plan).toBe('Enterprise'); - expect(ownerOrders.rows[0].status).toBe('processing'); - expect(peerCustomers.rows[0].plan).toBe('Scale'); - expect(peerOrders.rows[0].status).toBe('completed'); - - await expect(bridge.applyMutations([{ id: 'cross', connectionId: 'demo-postgres', table: 'customers', primaryKey: 'id', rowKey: 'cus_0001', column: 'plan', previousValue: 'Enterprise', nextValue: 'Scale' }])).rejects.toThrow('does not belong'); - }); - - it('isolates MySQL customer edits from a same-engine peer saved afterward', async () => { - const bridge = new DemoBridge(); - const { profile: owner } = await bridge.saveConnection({ - name: 'MySQL owner', kind: 'mysql', host: 'localhost', port: 3306, database: 'commerce', username: 'demo', tls: true, - }); - await bridge.applyMutations([ - { id: 'mysql-plan', connectionId: owner.id, table: 'customers', primaryKey: 'id', rowKey: 'cus_0001', column: 'plan', previousValue: 'Scale', nextValue: 'MySQL Enterprise' }, - ]); - - const { profile: peer } = await bridge.saveConnection({ - name: 'MySQL peer', kind: 'mysql', host: 'localhost', port: 3306, database: 'commerce_peer', username: 'demo', tls: true, - }); - const [ownerCustomers, peerCustomers] = await Promise.all([ - bridge.executeQuery(request('SELECT * FROM customers LIMIT 1;', owner.id)), - bridge.executeQuery(request('SELECT * FROM customers LIMIT 1;', peer.id)), - ]); - expect(ownerCustomers.rows[0].plan).toBe('MySQL Enterprise'); - expect(peerCustomers.rows[0].plan).toBe('Scale'); - }); - - it('isolates SQLite customer edits from a same-engine peer saved afterward', async () => { - const bridge = new DemoBridge(); - const { profile: owner } = await bridge.saveConnection({ - name: 'SQLite owner', kind: 'sqlite', host: '', port: 0, database: '', username: '', filePath: '/tmp/owner.sqlite', tls: true, - }); - await bridge.applyMutations([ - { id: 'sqlite-city', connectionId: owner.id, table: 'customers', primaryKey: 'id', rowKey: '1', column: 'city', previousValue: 'Austin', nextValue: 'Portland' }, - ]); - - const { profile: peer } = await bridge.saveConnection({ - name: 'SQLite peer', kind: 'sqlite', host: '', port: 0, database: '', username: '', filePath: '/tmp/peer.sqlite', tls: true, - }); - const [ownerCustomers, peerCustomers] = await Promise.all([ - bridge.executeQuery(request('SELECT * FROM customers LIMIT 1;', owner.id)), - bridge.executeQuery(request('SELECT * FROM customers LIMIT 1;', peer.id)), - ]); - expect(ownerCustomers.rows[0]).toEqual(expect.objectContaining({ id: 1, city: 'Portland' })); - expect(peerCustomers.rows[0]).toEqual(expect.objectContaining({ id: 1, city: 'Austin' })); - }); -}); - -describe('TauriBridge wire adapter', () => { - beforeEach(() => invokeMock.mockReset()); - - it('saves a connection read-only unless edits are allowed, and keeps what the core returns', async () => { - const saved = (readOnly: boolean) => ({ profile: { id: 'b1e5165c-a925-4f9c-b065-e395bbd433b4', name: 'Local', kind: 's_q_lite', config: { filePath: '/tmp/a.db', tls: false, options: {} }, readOnly, builtIn: false }, warning: null }); - const bridge = new TauriBridge(); - invokeMock.mockResolvedValueOnce(saved(true)); - const quiet = await bridge.saveConnection({ name: 'Local', kind: 'sqlite', host: '', port: 0, database: '', username: '', filePath: '/tmp/a.db', tls: false }); - expect(invokeMock.mock.calls[0][1].profile.readOnly).toBe(true); - expect(quiet.profile.readOnly).toBe(true); - invokeMock.mockResolvedValueOnce(saved(false)); - const writable = await bridge.saveConnection({ name: 'Local', kind: 'sqlite', host: '', port: 0, database: '', username: '', filePath: '/tmp/a.db', tls: false, readOnly: false }); - expect(invokeMock.mock.calls[1][1].profile.readOnly).toBe(false); - expect(writable.profile.readOnly).toBe(false); - }); - - it('saves nested connection config and its secret through one atomic command', async () => { - invokeMock.mockResolvedValueOnce({ - profile: { - id: 'a8e5165c-a925-4f9c-b065-e395bbd433b4', name: 'Production', kind: 'postgre_sql', - config: { host: 'db.internal', port: 5432, database: 'warehouse', username: 'analyst', tls: true, options: {} }, - readOnly: false, builtIn: false, - }, - warning: null, - }); - const bridge = new TauriBridge(); - const { profile } = await bridge.saveConnection({ name: 'Production', kind: 'postgresql', host: 'db.internal', port: 5432, database: 'warehouse', username: 'analyst', password: 'secret', tls: true }); - expect(profile).toEqual(expect.objectContaining({ state: 'disconnected', tls: true })); - expect(invokeMock).toHaveBeenCalledTimes(1); - expect(invokeMock).toHaveBeenCalledWith('save_connection_with_secret', { - profile: expect.objectContaining({ kind: 'postgre_sql', readOnly: true, config: expect.objectContaining({ host: 'db.internal', database: 'warehouse', tls: true, options: {} }) }), - secret: 'secret', - }); - expect(invokeMock.mock.calls[0][1].profile).not.toHaveProperty('password'); - }); - - it('treats a whitespace-only password as no secret', async () => { - invokeMock.mockResolvedValueOnce({ - profile: { - id: 'sqlite-1', name: 'Local', kind: 's_q_lite', - config: { filePath: '/tmp/local.sqlite', srv: false, tls: false, options: {} }, - readOnly: false, builtIn: false, - }, - warning: null, - }); - const bridge = new TauriBridge(); - await bridge.saveConnection({ name: 'Local', kind: 'sqlite', host: '', port: 0, database: '', username: '', filePath: '/tmp/local.sqlite', password: ' ', tls: false }); - expect(invokeMock).toHaveBeenCalledTimes(1); - expect(invokeMock).toHaveBeenCalledWith('save_connection_with_secret', expect.objectContaining({ secret: null })); - }); - - it('maps controlled TLS and separate Mongo authSource on save and hydration', async () => { - invokeMock.mockResolvedValueOnce({ - profile: { - id: 'mongo-1', name: 'Documents', kind: 'mongo_db', - config: { host: 'mongo.internal', port: 27017, database: 'app', username: 'reader', srv: false, tls: false, options: { authSource: 'admin' } }, - readOnly: false, builtIn: false, - }, - warning: null, - }); - const bridge = new TauriBridge(); - const { profile } = await bridge.saveConnection({ name: 'Documents', kind: 'mongodb', host: 'mongo.internal', port: 27017, database: 'app', username: 'reader', tls: false, authSource: 'admin' }); - expect(invokeMock).toHaveBeenCalledTimes(1); - expect(invokeMock).toHaveBeenCalledWith('save_connection_with_secret', { - profile: expect.objectContaining({ config: expect.objectContaining({ database: 'app', tls: false, options: { authSource: 'admin' } }) }), - secret: null, - }); - expect(profile).toEqual(expect.objectContaining({ kind: 'mongodb', database: 'app', authSource: 'admin', tls: false })); - }); - - it('tests an edited profile by ID without exposing its blank stored secret', async () => { - invokeMock.mockResolvedValueOnce({ state: 'connected', message: 'Connection test succeeded', latencyMs: 12 }); - const bridge = new TauriBridge(); - await bridge.testConnection({ id: 'profile-1', name: 'Production', kind: 'postgresql', host: 'db.internal', port: 5432, database: 'warehouse', username: 'analyst', password: '', tls: true }); - expect(invokeMock).toHaveBeenCalledWith('test_connection', { - profile: expect.objectContaining({ id: 'profile-1', kind: 'postgre_sql', readOnly: true }), - secret: null, - }); - expect(invokeMock.mock.calls[0][1].profile).not.toHaveProperty('password'); - }); - - it('rejects SQL Server save and test calls before invoking native commands', async () => { - const bridge = new TauriBridge(); - const draft = { name: 'SQL Server', kind: 'sqlserver' as const, host: 'localhost', port: 1433, database: 'master', username: 'sa', tls: true }; - await expect(bridge.testConnection(draft)).rejects.toThrow('not supported'); - await expect(bridge.saveConnection(draft)).rejects.toThrow('not supported'); - expect(invokeMock).not.toHaveBeenCalled(); - }); - - it('says why a desktop result cannot be edited, and makes it editable when it can', async () => { - const id = 'c1e5165c-a925-4f9c-b065-e395bbd433b4'; - let keys: string[] = ['id']; let readOnly = false; - invokeMock.mockImplementation(async (command: string) => { - if (command === 'list_connections') return [{ id, name: 'Local', kind: 's_q_lite', config: { filePath: '/tmp/a.db', tls: false, options: {} }, readOnly, builtIn: false }]; - if (command === 'execute_query') return { columns: [{ name: 'id', dataType: 'INTEGER' }, { name: 'name', dataType: 'TEXT' }], rows: [[{ type: 'integer', value: '1' }, { type: 'text', value: 'Ann' }]], stats: { rowsReturned: 1, elapsedMs: 1 } }; - if (command === 'primary_key_columns') return keys; - return []; - }); - const run = async (query: string) => { const bridge = new TauriBridge(); await bridge.listConnections(); return bridge.executeQuery({ connectionId: id, language: 'sql', query }); }; - - expect((await run('SELECT id, name FROM people')).editSource).toEqual({ table: 'people', primaryKey: 'id' }); - expect((await run('SELECT p.name AS who FROM people p')).readOnlyReason).toMatch(/plain SELECT from one table/); - keys = []; expect((await run('SELECT * FROM people')).readOnlyReason).toMatch(/people has no primary key/); - keys = ['a', 'b']; expect((await run('SELECT * FROM people')).readOnlyReason).toMatch(/key of 2 columns/); - keys = ['uid']; expect((await run('SELECT * FROM people')).readOnlyReason).toMatch(/include the key column uid/); - readOnly = true; keys = ['id']; expect((await run('SELECT * FROM people')).readOnlyReason).toMatch(/Allow edits/); - }); - - it('sends a staged new row as typed values, leaving out what was not filled in', async () => { - invokeMock.mockResolvedValueOnce({ rowsAffected: 1, elapsedMs: 2, committed: true }); - const bridge = new TauriBridge(); - await bridge.applyMutations([{ id: 'new-1', kind: 'insert', connectionId: 'connection-1', table: 'people', primaryKey: 'id', rowKey: 'new-1', column: '', previousValue: null, nextValue: null, - values: [{ column: 'name', value: 'Cy', wireType: 'text' }, { column: 'mrr', value: '9.50', wireType: 'decimal' }] }]); - expect(invokeMock.mock.calls[0][1].edits).toEqual({ schema: null, table: 'people', primaryKey: 'id', rows: [], - inserts: [{ values: [{ column: 'name', value: { type: 'text', value: 'Cy' } }, { column: 'mrr', value: { type: 'decimal', value: '9.50' } }] }], deletes: [] }); - }); - - it('sends a staged row delete as a typed key, apart from the cell edits', async () => { - invokeMock.mockResolvedValueOnce({ rowsAffected: 2, elapsedMs: 3, committed: true }); - const bridge = new TauriBridge(); - const base = { connectionId: 'connection-1', table: 'people', primaryKey: 'id', previousValue: null, keyWireType: 'integer' }; - await bridge.applyMutations([ - { ...base, id: '1-name', rowKey: '1', keyValue: 1, column: 'name', nextValue: 'Ann Lee', wireType: 'text' }, - { ...base, id: '2-delete', kind: 'delete', rowKey: '2', keyValue: 2, column: '', nextValue: null }, - ]); - expect(invokeMock.mock.calls[0][1].edits).toEqual({ schema: null, table: 'people', primaryKey: 'id', - rows: [{ key: { type: 'integer', value: '1' }, changes: [{ column: 'name', value: { type: 'text', value: 'Ann Lee' } }] }], - inserts: [], deletes: [{ type: 'integer', value: '2' }] }); - }); - - it('sends staged edits as one typed change set, grouped by row', async () => { - invokeMock.mockResolvedValueOnce({ rowsAffected: 2, elapsedMs: 3, committed: true }); - const bridge = new TauriBridge(); - const base = { connectionId: 'connection-1', schema: 'public', table: 'customers', primaryKey: 'id', previousValue: null }; - const result = await bridge.applyMutations([ - { ...base, id: 'a', rowKey: '7', keyValue: 7, keyWireType: 'integer', column: 'email', nextValue: 'a@example.test', wireType: 'text' }, - { ...base, id: 'b', rowKey: '7', keyValue: 7, keyWireType: 'integer', column: 'mrr', nextValue: '12.50', wireType: 'decimal' }, - { ...base, id: 'c', rowKey: '8', keyValue: 8, keyWireType: 'integer', column: 'active', nextValue: null, wireType: 'boolean' }, - ]); - expect(invokeMock).toHaveBeenCalledWith('apply_cell_edits', { - connectionId: 'connection-1', - edits: { schema: 'public', table: 'customers', primaryKey: 'id', rows: [ - { key: { type: 'integer', value: '7' }, changes: [{ column: 'email', value: { type: 'text', value: 'a@example.test' } }, { column: 'mrr', value: { type: 'decimal', value: '12.50' } }] }, - { key: { type: 'integer', value: '8' }, changes: [{ column: 'active', value: { type: 'null' } }] }, - ], inserts: [], deletes: [] }, - }); - expect(result.message).toBe('Saved 3 changes in 2 rows.'); - }); - - it('saves the Redrob key through secure desktop storage', async () => { - invokeMock.mockResolvedValueOnce(undefined); - const bridge = new TauriBridge(); - await bridge.saveAiKey('rrk_desktop_secret_123'); - expect(invokeMock).toHaveBeenCalledWith('save_ai_secret', { secret: 'rrk_desktop_secret_123' }); - }); - - it('sends only the minimal SQL AI context and strips renderer-supplied extras', async () => { - invokeMock.mockResolvedValueOnce({ message: { content: '```sql\nSELECT 1;\n```' } }); - const bridge = new TauriBridge(); - const rendererInput = { - prompt: 'Write it', databaseKind: 'sqlite', language: 'sql', query: 'SELECT * FROM customers;', - messages: [{ role: 'system', content: 'renderer-controlled' }], - resultRows: [{ secret: 'row-must-not-cross-ipc' }], - credentials: 'database-password-must-not-cross-ipc', - model: 'renderer-model', baseUrl: 'https://renderer.invalid', - } as Parameters[0] & Record; - const response = await bridge.askAi(rendererInput); - const requestBody = invokeMock.mock.calls[0][1].request; - expect(invokeMock).toHaveBeenCalledWith('ai_chat', { request: { - prompt: 'Write it', - activeQuery: 'SELECT * FROM customers;', - databaseKind: 's_q_lite', - queryLanguage: 'sql', - } }); - expect(requestBody).not.toHaveProperty('messages'); - expect(requestBody).not.toHaveProperty('resultRows'); - expect(requestBody).not.toHaveProperty('credentials'); - expect(requestBody).not.toHaveProperty('model'); - expect(requestBody).not.toHaveProperty('baseUrl'); - expect(JSON.stringify(requestBody)).not.toContain('row-must-not-cross-ipc'); - expect(JSON.stringify(requestBody)).not.toContain('database-password-must-not-cross-ipc'); - expect(response).toEqual(expect.objectContaining({ generatedQuery: 'SELECT 1;', generatedLanguage: 'sql' })); - }); - - it('requests Mongo JSON through the minimal contract and parses fenced mql/json without treating SQL as MQL', async () => { - invokeMock.mockResolvedValueOnce({ message: { content: '```mql\n{"collection":"customers","operation":"find","filter":{}}\n```' } }); - const bridge = new TauriBridge(); - const response = await bridge.askAi({ prompt: 'Find customers', databaseKind: 'mongodb', language: 'mql', query: '{}' }); - expect(invokeMock).toHaveBeenCalledWith('ai_chat', { request: { - prompt: 'Find customers', activeQuery: '{}', databaseKind: 'mongo_db', queryLanguage: 'mongo_json', - } }); - expect(response.generatedLanguage).toBe('mql'); - expect(JSON.parse(response.generatedQuery!)).toEqual(expect.objectContaining({ operation: 'find' })); - - invokeMock.mockReset(); - invokeMock.mockResolvedValueOnce({ message: { content: '```sql\nSELECT * FROM customers;\n```' } }); - const mismatched = await bridge.askAi({ prompt: 'Find customers', databaseKind: 'mongodb', language: 'mql' }); - expect(invokeMock.mock.calls[0][1].request).toEqual({ - prompt: 'Find customers', databaseKind: 'mongo_db', queryLanguage: 'mongo_json', - }); - expect(mismatched.generatedQuery).toBeUndefined(); - }); - - it('maps every BIT value as numeric while BOOL and BOOLEAN stay boolean', async () => { - invokeMock.mockResolvedValueOnce({ - columns: [ - { name: 'bit_zero', dataType: 'BIT', nullable: false }, - { name: 'bit_one', dataType: 'BIT(1)', nullable: false }, - { name: 'bit_two', dataType: 'BIT(2)', nullable: false }, - { name: 'bit_five', dataType: 'BIT(5)', nullable: false }, - { name: 'bool_value', dataType: 'BOOL', nullable: false }, - { name: 'boolean_value', dataType: 'BOOLEAN', nullable: false }, - ], - rows: [[ - { type: 'integer', value: '0' }, - { type: 'integer', value: '1' }, - { type: 'integer', value: '2' }, - { type: 'integer', value: '5' }, - { type: 'boolean', value: false }, - { type: 'boolean', value: true }, - ]], - stats: { elapsedMs: 4, rowsReturned: 1, rowsAffected: 0, truncated: false }, nextOffset: null, - }); - const bridge = new TauriBridge(); - const result = await bridge.executeQuery({ connectionId: 'connection-1', query: 'SELECT bits', language: 'sql' }); - expect(result.columns.map((column) => column.dataType)).toEqual([ - 'number', 'number', 'number', 'number', 'boolean', 'boolean', - ]); - expect(result.rows[0]).toEqual({ - bit_zero: 0, - bit_one: 1, - bit_two: 2, - bit_five: 5, - bool_value: false, - boolean_value: true, - }); - }); - - it('keeps finite floats numeric while preserving decimals and unsafe integers exactly', async () => { - invokeMock.mockResolvedValueOnce({ - columns: [ - { name: 'safe', dataType: 'integer', nullable: false }, - { name: 'unsafe', dataType: 'integer', nullable: false }, - { name: 'ratio', dataType: 'double', nullable: false }, - { name: 'amount', dataType: 'numeric', nullable: false }, - { name: 'invalid', dataType: 'double', nullable: false }, - ], - rows: [[ - { type: 'integer', value: '42' }, - { type: 'integer', value: '9007199254740993' }, - { type: 'float', value: '1.25e-3' }, - { type: 'decimal', value: '42.50' }, - { type: 'float', value: 'NaN' }, - ]], - stats: { elapsedMs: 17, rowsReturned: 1, rowsAffected: 0, truncated: false }, nextOffset: null, - }); - const bridge = new TauriBridge(); - const result = await bridge.executeQuery({ connectionId: 'connection-1', query: 'SELECT values', language: 'sql' }); - expect(result.rows[0]).toEqual({ safe: 42, unsafe: '9007199254740993', ratio: 0.00125, amount: '42.50', invalid: 'NaN' }); - }); -}); - - - -describe('release bridge contracts', () => { - beforeEach(() => invokeMock.mockReset()); - - it('provides demo lifecycle truth and protects the built-in profile', async () => { - const bridge = new DemoBridge(); - expect(await bridge.profileWarnings()).toEqual([]); - await bridge.disconnect('demo-postgres'); - expect((await bridge.listConnections())[0].state).toBe('disconnected'); - await expect(bridge.connect('demo-postgres')).resolves.toEqual(expect.objectContaining({ ok: true })); - expect((await bridge.listConnections())[0].state).toBe('connected'); - await expect(bridge.removeConnection('demo-postgres')).rejects.toThrow('Built-in'); - const { profile: added } = await bridge.saveConnection({ name: 'Disposable', kind: 'mysql', host: 'localhost', port: 3306, database: 'db', username: 'reader', tls: true }); - await bridge.removeConnection(added.id); - expect((await bridge.listConnections()).some((profile) => profile.id === added.id)).toBe(false); - }); - - it('pages demo fixtures deterministically with honest continuation metadata', async () => { - const bridge = new DemoBridge(); - const first = await bridge.executeQuery({ ...request('SELECT * FROM public.customers LIMIT 250;'), limit: 25, offset: 0 }); - const second = await bridge.executeQuery({ ...request('SELECT * FROM public.customers LIMIT 250;'), limit: 25, offset: first.nextOffset! }); - const last = await bridge.executeQuery({ ...request('SELECT * FROM public.customers LIMIT 250;'), limit: 25, offset: 50 }); - expect(first).toEqual(expect.objectContaining({ offset: 0, limit: 25, nextOffset: 25, truncated: true, rowCount: 25 })); - expect(second.rows[0].id).toBe('cus_0026'); - expect(last).toEqual(expect.objectContaining({ offset: 50, nextOffset: null, truncated: false, rowCount: 14 })); - }); - - it('rejects an entirely stale demo mutation batch without partially writing valid cells', async () => { - const bridge = new DemoBridge(); - await expect(bridge.applyMutations([ - { id: 'valid', connectionId: 'demo-postgres', table: 'public.customers', primaryKey: 'id', rowKey: 'cus_0001', column: 'plan', previousValue: 'Scale', nextValue: 'Enterprise' }, - { id: 'stale', connectionId: 'demo-postgres', table: 'public.customers', primaryKey: 'id', rowKey: 'cus_0002', column: 'plan', previousValue: 'outdated', nextValue: 'Enterprise' }, - ])).rejects.toThrow('changed since'); - const result = await bridge.executeQuery(request('SELECT * FROM public.customers LIMIT 2;')); - expect(result.rows[0].plan).toBe('Scale'); - expect(result.rows[1].plan).toBe('Growth'); - }); - - it('invokes native warning and lifecycle commands with exact arguments', async () => { - const bridge = new TauriBridge(); - invokeMock.mockResolvedValueOnce(['Skipped corrupt profile']); - await expect(bridge.profileWarnings()).resolves.toEqual(['Skipped corrupt profile']); - expect(invokeMock).toHaveBeenLastCalledWith('profile_load_warnings'); - - invokeMock.mockResolvedValueOnce([{ id: 'profile-1', name: 'Local', kind: 'postgre_sql', config: { host: 'localhost', port: 5432, database: 'db', username: 'reader', tls: true, options: {} }, builtIn: false }]); - await bridge.listConnections(); - invokeMock.mockResolvedValueOnce({ state: 'connected', message: 'Connected', latencyMs: 8 }); - await expect(bridge.connect('profile-1')).resolves.toEqual({ ok: true, message: 'Connected', latencyMs: 8 }); - expect(invokeMock).toHaveBeenLastCalledWith('connect', { id: 'profile-1' }); - invokeMock.mockResolvedValueOnce(undefined); - await bridge.disconnect('profile-1'); - expect(invokeMock).toHaveBeenLastCalledWith('disconnect', { id: 'profile-1' }); - invokeMock.mockResolvedValueOnce({ warning: 'Credential cleanup is pending.' }); - await expect(bridge.removeConnection('profile-1')).resolves.toEqual({ warning: 'Credential cleanup is pending.' }); - expect(invokeMock).toHaveBeenLastCalledWith('remove_connection', { id: 'profile-1' }); - }); - - it('sends and maps native paging fields', async () => { - invokeMock.mockResolvedValueOnce({ - columns: [{ name: 'id', dataType: 'integer', nullable: false }], rows: [[{ type: 'integer', value: 51 }]], - stats: { elapsedMs: 7, rowsReturned: 1, truncated: true }, nextOffset: 51, message: 'partial page', - }); - const bridge = new TauriBridge(); - const result = await bridge.executeQuery({ connectionId: 'profile-1', query: 'SELECT id FROM t', language: 'sql', limit: 25, offset: 50 }); - expect(invokeMock).toHaveBeenCalledWith('execute_query', { request: expect.objectContaining({ connectionId: 'profile-1', limit: 25, offset: 50 }) }); - expect(result).toEqual(expect.objectContaining({ offset: 50, limit: 25, nextOffset: 51, truncated: true, message: 'partial page' })); - }); -}); - - - -describe('profile edit wire contract', () => { - beforeEach(() => invokeMock.mockReset()); - it('includes the existing profile id and leaves secret replacement unspecified when scope is unchanged', async () => { - invokeMock.mockResolvedValueOnce({ - profile: { id: 'profile-1', name: 'Renamed', kind: 'postgre_sql', config: { host: 'localhost', port: 5432, database: 'db', username: 'reader', tls: true, options: {} }, builtIn: false }, - warning: 'Transaction cleanup is pending.', - }); - const bridge = new TauriBridge(); - const outcome = await bridge.saveConnection({ id: 'profile-1', name: 'Renamed', kind: 'postgresql', host: 'localhost', port: 5432, database: 'db', username: 'reader', password: '', tls: true }); - expect(outcome).toEqual(expect.objectContaining({ - profile: expect.objectContaining({ id: 'profile-1', name: 'Renamed' }), - warning: 'Transaction cleanup is pending.', - })); - expect(invokeMock).toHaveBeenCalledWith('save_connection_with_secret', { profile: expect.objectContaining({ id: 'profile-1', name: 'Renamed' }), secret: null }); - expect(JSON.stringify(invokeMock.mock.calls[0][1])).not.toContain('password'); - }); -}); - - - -describe('lifecycle edge cases', () => { - beforeEach(() => invokeMock.mockReset()); - - it('does not reuse demo profile ids after deletion', async () => { - const bridge = new DemoBridge(); - const draft = { name: 'Peer', kind: 'mysql' as const, host: 'localhost', port: 3306, database: 'db', username: 'reader', tls: true }; - const { profile: first } = await bridge.saveConnection(draft); - const { profile: second } = await bridge.saveConnection({ ...draft, name: 'Second' }); - await bridge.removeConnection(first.id); - const { profile: third } = await bridge.saveConnection({ ...draft, name: 'Third' }); - expect(new Set([second.id, third.id]).size).toBe(2); - }); - - it('rejects forged primary-key metadata without changing the actual id', async () => { - const bridge = new DemoBridge(); - await expect(bridge.applyMutations([{ id: 'forged', connectionId: 'demo-postgres', table: 'public.customers', primaryKey: 'plan', rowKey: 'Scale', column: 'id', previousValue: 'cus_0001', nextValue: 'forged-id' }])).rejects.toThrow('primary key'); - const result = await bridge.executeQuery(request('SELECT * FROM public.customers LIMIT 1;')); - expect(result.rows[0].id).toBe('cus_0001'); - }); - - it('marks a previously connected native profile disconnected after edit save', async () => { - const bridge = new TauriBridge(); - invokeMock.mockResolvedValueOnce([{ id: 'profile-1', name: 'Local', kind: 'postgre_sql', config: { host: 'localhost', port: 5432, database: 'db', username: 'reader', tls: true, options: {} }, builtIn: false }]); - await bridge.listConnections(); - invokeMock.mockResolvedValueOnce({ state: 'connected', message: 'Connected' }); - await bridge.connect('profile-1'); - invokeMock.mockResolvedValueOnce({ - profile: { id: 'profile-1', name: 'Renamed', kind: 'postgre_sql', config: { host: 'localhost', port: 5432, database: 'db', username: 'reader', tls: true, options: {} }, builtIn: false }, - warning: null, - }); - const edited = await bridge.saveConnection({ id: 'profile-1', name: 'Renamed', kind: 'postgresql', host: 'localhost', port: 5432, database: 'db', username: 'reader', tls: true }); - expect(edited.profile.state).toBe('disconnected'); - }); -}); diff --git a/src/api/bridge.ts b/src/api/bridge.ts deleted file mode 100644 index 698e3f9..0000000 --- a/src/api/bridge.ts +++ /dev/null @@ -1,756 +0,0 @@ -import { editableSource } from './editSource'; -import { invoke } from '@tauri-apps/api/core'; -import type { TableChange, - AiRequest, - AiResponse, - CellMutation, - CellValue, - ConnectionDraft, - ConnectionProfile, - ConnectionStatus, - DatabaseKind, - DataType, - MetadataNode, - MutationResult, - QueryRequest, - QueryResult, - RemoveConnectionOutcome, - SaveConnectionOutcome, -} from '../domain/types'; - -export interface DataBridge { - readonly mode: 'desktop' | 'demo'; - listConnections(): Promise; - profileWarnings(): Promise; - saveConnection(draft: ConnectionDraft): Promise; - removeConnection(id: string): Promise; - connect(id: string): Promise; - disconnect(id: string): Promise; - testConnection(draft: ConnectionDraft): Promise; - loadMetadata(connectionId: string, parentId?: string | null): Promise; - executeQuery(request: QueryRequest): Promise; - /** The statements of an all-read-only SQL batch, or null to run the text as one query. Optional: a - * bridge without it runs every query whole, as before batches existed. */ - splitReadOnlyBatch?(query: string): Promise; - applyMutations(mutations: CellMutation[]): Promise; - applyTableChange(connectionId: string, change: TableChange): Promise; - saveAiKey(secret: string): Promise; - /** Whether the shared Redrob Code engine is signed in to the Redrob console, and with whose key. - * Never the key itself. Rejects with the engine's own sentence when Redrob Code is not installed or - * would not start. Optional: a bridge without it cannot say, and the settings show nothing about - * the current sign-in. */ - aiKeyStatus?(): Promise; - askAi(request: AiRequest): Promise; -} - -/** Whose Redrob console key the shared Redrob Code engine uses: one handed to it by an app or its own - * environment (`engine`), Redrob Launcher's shared sign-in (`launcher`), or none (`none`). The - * desktop app itself holds no key. */ -export interface AiKeyStatus { - source: 'engine' | 'launcher' | 'none'; -} - -/** Where a person gets Redrob Code when the engine reports it is not installed. */ -export const REDROB_LAUNCHER_RELEASES_URL = 'https://github.com/redrob-labs/redrob-launcher/releases/latest'; - -const wait = (ms = 180) => new Promise((resolve) => setTimeout(resolve, ms)); -const deepEqual = (left: unknown, right: unknown): boolean => { - if (Object.is(left, right)) return true; - if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false; - if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) && left.length === right.length && left.every((value, index) => deepEqual(value, right[index])); - const leftRecord = left as Record; const rightRecord = right as Record; - const leftKeys = Object.keys(leftRecord).sort(); const rightKeys = Object.keys(rightRecord).sort(); - return leftKeys.length === rightKeys.length && leftKeys.every((key, index) => key === rightKeys[index] && deepEqual(leftRecord[key], rightRecord[key])); -}; - -const seededConnections: ConnectionProfile[] = [ - { - id: 'demo-postgres', name: 'Acme Warehouse', kind: 'postgresql', host: 'demo.local', port: 5432, - database: 'commerce', username: 'demo_user', tls: true, state: 'connected', builtIn: true, isDemo: true, - }, -]; - -const postgresMetadata: MetadataNode[] = [ - { id: 'db-commerce', parentId: null, name: 'commerce', kind: 'database', childCount: 2 }, - { id: 'schema-public', parentId: 'db-commerce', name: 'public', kind: 'schema', childCount: 4 }, - { id: 'schema-analytics', parentId: 'db-commerce', name: 'analytics', kind: 'schema', childCount: 2 }, - { id: 'table-customers', parentId: 'schema-public', name: 'customers', kind: 'table', childCount: 7 }, - { id: 'table-orders', parentId: 'schema-public', name: 'orders', kind: 'table', childCount: 6 }, - { id: 'view-active', parentId: 'schema-public', name: 'active_customers', kind: 'view', childCount: 4 }, - { id: 'table-products', parentId: 'schema-public', name: 'products', kind: 'table', childCount: 5 }, - { id: 'view-revenue', parentId: 'schema-analytics', name: 'monthly_revenue', kind: 'view', childCount: 3 }, - { id: 'table-events', parentId: 'schema-analytics', name: 'events', kind: 'table', childCount: 4 }, - ...[ - ['id', 'uuid'], ['name', 'varchar'], ['email', 'varchar'], ['plan', 'varchar'], - ['mrr', 'numeric'], ['active', 'boolean'], ['created_at', 'timestamptz'], - ].map(([name, dataType]) => ({ id: `customers-${name}`, parentId: 'table-customers', name, kind: 'column' as const, dataType })), - ...[ - ['id', 'uuid'], ['customer_id', 'uuid'], ['status', 'varchar'], ['total', 'numeric'], - ['currency', 'char(3)'], ['created_at', 'timestamptz'], - ].map(([name, dataType]) => ({ id: `orders-${name}`, parentId: 'table-orders', name, kind: 'column' as const, dataType })), -]; - -const metadataForProfile = (profile: ConnectionProfile): MetadataNode[] => { - if (profile.id === 'demo-postgres') return postgresMetadata; - const prefix = profile.id; - const databaseName = profile.database || (profile.kind === 'sqlite' ? profile.filePath?.split('/').at(-1) || 'demo.sqlite' : 'sample'); - const databaseId = `${prefix}-database`; - if (profile.kind === 'mongodb') { - const collectionId = `${prefix}-customers`; - return [ - { id: databaseId, parentId: null, name: databaseName, kind: 'database', childCount: 1 }, - { id: collectionId, parentId: databaseId, name: 'customers', kind: 'table', childCount: 6 }, - ...[ - ['_id', 'objectId'], ['name', 'string'], ['email', 'string'], ['tier', 'string'], ['active', 'boolean'], ['joinedAt', 'date'], - ].map(([name, dataType]) => ({ id: `${collectionId}-${name}`, parentId: collectionId, name, kind: 'column' as const, dataType })), - ]; - } - const tableId = `${prefix}-customers`; - return [ - { id: databaseId, parentId: null, name: databaseName, kind: 'database', childCount: 1 }, - { id: tableId, parentId: databaseId, name: 'customers', kind: 'table', childCount: profile.kind === 'sqlite' ? 5 : 7 }, - ...(profile.kind === 'sqlite' - ? [['id', 'integer'], ['name', 'text'], ['email', 'text'], ['city', 'text'], ['created_at', 'text']] - : [['id', 'varchar'], ['name', 'varchar'], ['email', 'varchar'], ['plan', 'varchar'], ['mrr', 'numeric'], ['active', 'boolean'], ['created_at', 'timestamp']] - ).map(([name, dataType]) => ({ id: `${tableId}-${name}`, parentId: tableId, name, kind: 'column' as const, dataType })), - ]; -}; - -const names = ['Avery Stone', 'Mia Chen', 'Noah Williams', 'Sofia Patel', 'Leo Martin', 'Emma Garcia', 'Kai Johnson', 'Isla Brown', 'Mateo Silva', 'Zoe Kim']; -const plans = ['Scale', 'Growth', 'Starter']; -const seededCustomerRows = Array.from({ length: 64 }, (_, index) => { - const sequence = index + 1; - const name = names[index % names.length]; - return { - id: `cus_${String(sequence).padStart(4, '0')}`, - name: sequence <= names.length ? name : `${name} ${Math.floor(index / names.length) + 1}`, - email: `${name.toLowerCase().replace(' ', '.').replace(/\s+/g, '')}${sequence}@example.com`, - plan: plans[index % plans.length], - mrr: [249, 599, 99, 799, 349][index % 5], - active: index % 7 !== 0, - created_at: `2025-${String((index % 8) + 1).padStart(2, '0')}-${String((index % 26) + 1).padStart(2, '0')}T10:30:00Z`, - }; -}); - -const seededMongoRows = seededCustomerRows.map((row) => ({ - _id: row.id.replace('cus_', '65f0a000000000000000'), - name: row.name, - email: row.email, - tier: row.plan, - active: row.active, - joinedAt: row.created_at, -})); -const cities = ['Austin', 'Berlin', 'London', 'Singapore', 'Toronto']; -const seededSqliteRows = seededCustomerRows.map((row, index) => ({ - id: index + 1, - name: row.name, - email: row.email, - city: cities[index % cities.length], - created_at: row.created_at, -})); - -const seededOrderRows = Array.from({ length: 96 }, (_, index) => ({ - id: `ord_${String(index + 1).padStart(5, '0')}`, - customer_id: seededCustomerRows[index % seededCustomerRows.length].id, - status: ['completed', 'completed', 'processing', 'refunded'][index % 4], - total: [129.5, 799, 248.75, 64][index % 4], - currency: 'USD', - created_at: `2025-${String((index % 8) + 1).padStart(2, '0')}-${String((index % 26) + 1).padStart(2, '0')}T14:15:00Z`, -})); - -const revenueRows = [ - { month: '2025-08', revenue: 128420, orders: 1842 }, - { month: '2025-07', revenue: 117890, orders: 1716 }, - { month: '2025-06', revenue: 109340, orders: 1628 }, - { month: '2025-05', revenue: 98420, orders: 1491 }, - { month: '2025-04', revenue: 91760, orders: 1407 }, - { month: '2025-03', revenue: 84610, orders: 1322 }, -]; - -type RelationalDatabaseKind = Exclude; -type RelationalFixture = { - customerRows: Array>; - orderRows: Array>; -}; - -const createRelationalFixture = (kind: RelationalDatabaseKind): RelationalFixture => ({ - customerRows: structuredClone(kind === 'sqlite' ? seededSqliteRows : seededCustomerRows), - orderRows: structuredClone(seededOrderRows), -}); - -const pageRows = (rows: T[], offset = 0, limit = 50, maximum = rows.length) => { - const safeOffset = Math.max(0, offset); - const safeLimit = Math.max(1, limit); - const available = rows.slice(0, Math.max(0, maximum)); - const page = available.slice(safeOffset, safeOffset + safeLimit); - const nextOffset = safeOffset + page.length < available.length ? safeOffset + page.length : null; - return { rows: page, offset: safeOffset, limit: safeLimit, nextOffset, truncated: nextOffset !== null }; -}; - -export class DemoBridge implements DataBridge { - readonly mode = 'demo' as const; - private connections = structuredClone(seededConnections); - private connectionSequence = seededConnections.length; - private relationalFixtures = new Map([ - ['demo-postgres', createRelationalFixture('postgresql')], - ]); - - async listConnections(): Promise { - await wait(40); - return structuredClone(this.connections); - } - - async profileWarnings(): Promise { - return []; - } - - async saveConnection(draft: ConnectionDraft): Promise { - await wait(); - if (draft.kind === 'sqlserver') throw new Error('SQL Server is not supported.'); - if (!draft.name.trim()) throw new Error('Connection name is required.'); - const existingIndex = draft.id ? this.connections.findIndex((item) => item.id === draft.id) : -1; - const existing = existingIndex >= 0 ? this.connections[existingIndex] : undefined; - const { password: _password, id: _id, ...profileDraft } = draft; - const profile: ConnectionProfile = { - ...profileDraft, - id: existing?.id ?? `demo-${draft.kind}-${++this.connectionSequence}`, - state: existing?.state ?? 'connected', - builtIn: existing?.builtIn ?? false, - isDemo: true, - }; - if (profile.kind !== 'mongodb' && (!existing || existing.kind !== profile.kind)) { - this.relationalFixtures.set(profile.id, createRelationalFixture(profile.kind)); - } - if (existingIndex >= 0) this.connections[existingIndex] = profile; - else this.connections.push(profile); - return { profile: structuredClone(profile) }; - } - - async removeConnection(id: string): Promise { - await wait(80); - const profile = this.connections.find((item) => item.id === id); - if (!profile) throw new Error('Connection not found.'); - if (profile.builtIn) throw new Error('Built-in demo connections cannot be removed.'); - this.connections = this.connections.filter((item) => item.id !== id); - this.relationalFixtures.delete(id); - return {}; - } - - async connect(id: string): Promise { - const profile = this.connections.find((item) => item.id === id); - if (!profile) throw new Error('Connection not found.'); - profile.state = 'connecting'; - await wait(120); - profile.state = 'connected'; - return { ok: true, latencyMs: 18, message: 'Connected' }; - } - - async disconnect(id: string): Promise { - const profile = this.connections.find((item) => item.id === id); - if (!profile) throw new Error('Connection not found.'); - await wait(60); - profile.state = 'disconnected'; - } - - async testConnection(draft: ConnectionDraft): Promise { - await wait(260); - if (draft.kind === 'sqlserver') throw new Error('SQL Server is not supported.'); - if (!draft.name.trim()) throw new Error('Add a connection name before testing.'); - if (draft.host.toLowerCase().includes('fail')) throw new Error('Demo test failed: host is unreachable.'); - return { ok: true, latencyMs: 28, message: 'Demo configuration looks valid' }; - } - - async loadMetadata(connectionId: string, parentId: string | null = null): Promise { - await wait(110); - const profile = this.connections.find((item) => item.id === connectionId); - if (!profile) throw new Error('Connection not found.'); - return structuredClone(metadataForProfile(profile).filter((node) => node.parentId === parentId)); - } - - // The demo has no core, so it splits on semicolons outside quotes and accepts the same SELECT/CTE - // statements its runner does. The desktop app asks the core instead. - async splitReadOnlyBatch(query: string): Promise { - const statements: string[] = []; let current = ''; let quote: string | null = null; - for (const character of query) { - if (quote) { current += character; if (character === quote) quote = null; continue; } - if (character === "'" || character === '"') { quote = character; current += character; continue; } - if (character === ';') { if (current.trim()) statements.push(current.trim()); current = ''; continue; } - current += character; - } - if (current.trim()) statements.push(current.trim()); - const readOnly = statements.every((statement) => /^(select|with)\b/i.test(statement)); - return statements.length && readOnly ? statements : null; - } - - async executeQuery(request: QueryRequest): Promise { - await wait(340); - const profile = this.connections.find((item) => item.id === request.connectionId); - if (!profile) throw new Error('Connection not found.'); - const normalized = request.query.trim().toLowerCase(); - if (!normalized) throw new Error('Write a query before running it.'); - if (profile.kind === 'mongodb') { - if (request.language !== 'mql') throw new Error('MongoDB connections accept MQL JSON, not SQL.'); - let operation: { collection?: string; operation?: string; filter?: Record; limit?: number }; - try { operation = JSON.parse(request.query) as typeof operation; } - catch { throw new Error('MongoDB demo queries must be valid JSON.'); } - if (operation.operation !== 'find') throw new Error('MongoDB demo supports the read-only find operation.'); - if (operation.collection !== 'customers') throw new Error('MongoDB demo collection not found.'); - const filter = operation.filter ?? {}; - const matches = seededMongoRows.filter((row) => Object.entries(filter).every(([key, value]) => row[key as keyof typeof row] === value)); - const maximum = Math.min(operation.limit ?? matches.length, matches.length); - const page = pageRows(matches, request.offset, request.limit ?? 50, maximum); - return { - columns: [ - { key: '_id', label: '_id', dataType: 'string', primaryKey: true }, - { key: 'name', label: 'name', dataType: 'string' }, - { key: 'email', label: 'email', dataType: 'string' }, - { key: 'tier', label: 'tier', dataType: 'string' }, - { key: 'active', label: 'active', dataType: 'boolean' }, - { key: 'joinedAt', label: 'joinedAt', dataType: 'date' }, - ], rows: structuredClone(page.rows), rowCount: page.rows.length, durationMs: 31, - offset: page.offset, limit: page.limit, nextOffset: page.nextOffset, truncated: page.truncated, - }; - } - if (request.language !== 'sql') throw new Error('Relational connections accept SQL, not MQL.'); - if (!normalized.startsWith('select') && !normalized.startsWith('with')) { - throw new Error('The demo query runner accepts SELECT/CTE statements; use staged cell edits for changes.'); - } - if (normalized.includes('syntax_error') || normalized.includes('missing_table')) { - throw new Error('Query failed near “missing_table”. Check the table name and try again.'); - } - const fixture = this.relationalFixtures.get(request.connectionId); - if (!fixture) throw new Error('Demo relational fixture not found.'); - if (profile.kind === 'sqlite') { - const limitMatch = normalized.match(/limit\s+(\d+)/); - const maximum = Math.min(Number(limitMatch?.[1] ?? fixture.customerRows.length), fixture.customerRows.length); - const page = pageRows(fixture.customerRows, request.offset, request.limit ?? 50, maximum); - return { - columns: [ - { key: 'id', label: 'id', dataType: 'number', primaryKey: true }, - { key: 'name', label: 'name', dataType: 'string' }, - { key: 'email', label: 'email', dataType: 'string' }, - { key: 'city', label: 'city', dataType: 'string' }, - { key: 'created_at', label: 'created_at', dataType: 'date' }, - ], rows: page.rows, rowCount: page.rows.length, durationMs: 24, editSource: { table: 'customers', primaryKey: 'id' }, - offset: page.offset, limit: page.limit, nextOffset: page.nextOffset, truncated: page.truncated, - }; - } - if (normalized.includes('sum(') || normalized.includes('revenue') || normalized.includes('group by')) { - const limitMatch = normalized.match(/limit\s+(\d+)/); - const page = pageRows(revenueRows, request.offset, request.limit ?? 50, Number(limitMatch?.[1] ?? revenueRows.length)); - return { - columns: [ - { key: 'month', label: 'month', dataType: 'string' }, - { key: 'revenue', label: 'revenue', dataType: 'number' }, - { key: 'orders', label: 'orders', dataType: 'number' }, - ], rows: page.rows, rowCount: page.rows.length, durationMs: 42, - offset: page.offset, limit: page.limit, nextOffset: page.nextOffset, truncated: page.truncated, - }; - } - if (normalized.includes('from public.orders')) { - const limitMatch = normalized.match(/limit\s+(\d+)/); - const maximum = Math.min(Number(limitMatch?.[1] ?? fixture.orderRows.length), fixture.orderRows.length); - const page = pageRows(fixture.orderRows, request.offset, request.limit ?? 50, maximum); - return { - columns: [ - { key: 'id', label: 'id', dataType: 'string', primaryKey: true }, - { key: 'customer_id', label: 'customer_id', dataType: 'string' }, - { key: 'status', label: 'status', dataType: 'string' }, - { key: 'total', label: 'total', dataType: 'number' }, - { key: 'currency', label: 'currency', dataType: 'string' }, - { key: 'created_at', label: 'created_at', dataType: 'date' }, - ], rows: page.rows, rowCount: page.rows.length, durationMs: 39, editSource: { table: 'public.orders', primaryKey: 'id' }, - offset: page.offset, limit: page.limit, nextOffset: page.nextOffset, truncated: page.truncated, - }; - } - const limitMatch = normalized.match(/limit\s+(\d+)/); - const matchingRows = normalized.includes("active = false") ? fixture.customerRows.filter((row) => !row.active) : fixture.customerRows; - const maximum = Math.min(Number(limitMatch?.[1] ?? matchingRows.length), matchingRows.length); - const page = pageRows(matchingRows, request.offset, request.limit ?? 50, maximum); - return { - columns: [ - { key: 'id', label: 'id', dataType: 'string', primaryKey: true }, - { key: 'name', label: 'name', dataType: 'string' }, - { key: 'email', label: 'email', dataType: 'string' }, - { key: 'plan', label: 'plan', dataType: 'string' }, - { key: 'mrr', label: 'mrr', dataType: 'number' }, - { key: 'active', label: 'active', dataType: 'boolean' }, - { key: 'created_at', label: 'created_at', dataType: 'date' }, - ], rows: page.rows, rowCount: page.rows.length, durationMs: 36, editSource: { table: profile.kind === 'postgresql' ? 'public.customers' : 'customers', primaryKey: 'id' }, - offset: page.offset, limit: page.limit, nextOffset: page.nextOffset, truncated: page.truncated, - }; - } - - async applyTableChange(_connectionId: string, _change: TableChange): Promise { - await wait(80); - throw new Error('Creating tables needs a desktop connection; the browser demo has fixed sample tables.'); - } - - async applyMutations(mutations: CellMutation[]): Promise { - await wait(260); - if (!mutations.length) throw new Error('There are no staged changes to apply.'); - const connectionId = mutations[0].connectionId; - if (mutations.some((mutation) => mutation.connectionId !== connectionId)) throw new Error('All changes must belong to the same connection.'); - const profile = this.connections.find((item) => item.id === connectionId); - if (!profile) throw new Error('Connection not found.'); - if (profile.kind === 'mongodb') throw new Error('MongoDB demo results are read-only.'); - const fixture = this.relationalFixtures.get(connectionId); - if (!fixture) throw new Error('Demo relational fixture not found.'); - const expectedTables = profile.kind === 'postgresql' ? ['public.customers', 'public.orders'] : ['customers']; - const seen = new Set(); - const validated = mutations.map((mutation) => { - if (!expectedTables.includes(mutation.table)) throw new Error('Mutation target does not belong to this demo connection.'); - if (mutation.primaryKey !== 'id') throw new Error('Mutation primary key does not match the demo table schema.'); - if (mutation.kind === 'update' || !mutation.kind) { if (mutation.column === 'id') throw new Error('Primary keys cannot be edited.'); } - if (mutation.kind === 'insert') { - const sourceRows = mutation.table === 'public.orders' ? fixture.orderRows : fixture.customerRows; - return { row: Object.fromEntries((mutation.values ?? []).map((item) => [item.column, item.value])), mutation, sourceRows }; - } - const identity = `${mutation.table}:${mutation.rowKey}:${mutation.column}`; - if (seen.has(identity)) throw new Error('The mutation batch contains duplicate cell changes.'); - seen.add(identity); - const sourceRows = mutation.table === 'public.orders' ? fixture.orderRows : fixture.customerRows; - const row = sourceRows.find((item) => String(item.id) === mutation.rowKey); - if (!row) throw new Error(`Row ${mutation.rowKey} no longer exists.`); - if (mutation.kind === 'delete') return { row, mutation, sourceRows }; - if (!(mutation.column in row)) throw new Error(`Column ${mutation.column} does not exist.`); - if (!deepEqual(row[mutation.column], mutation.previousValue)) { - throw new Error(`Cell ${mutation.column} changed since it was loaded.`); - } - return { row, mutation, sourceRows }; - }); - validated.forEach(({ row, mutation, sourceRows }) => { - if (mutation.kind === 'delete') sourceRows.splice(sourceRows.indexOf(row), 1); - else if (mutation.kind === 'insert') sourceRows.push({ ...Object.fromEntries(Object.keys(sourceRows[0] ?? {}).map((key) => [key, null])), id: row.id ?? `demo_${sourceRows.length + 1}`, ...row }); - else row[mutation.column] = structuredClone(mutation.nextValue); - }); - return { applied: validated.length, message: `${validated.length} change${validated.length === 1 ? '' : 's'} applied in demo memory` }; - } - - async saveAiKey(secret: string): Promise { - void secret; - await wait(80); - } - - async askAi(request: AiRequest): Promise { - await wait(420); - const prompt = request.prompt.toLowerCase(); - if (request.databaseKind === 'mongodb' || request.language === 'mql') { - if (prompt.includes('explain')) { - return { intent: 'explanation', message: 'I can explain the active MongoDB JSON query. Result documents are not included in AI requests.' }; - } - return { - intent: 'query', - message: prompt.includes('revenue') - ? 'The MongoDB demo has no orders fixture, so I prepared the supported read-only customer find instead.' - : 'I prepared a read-only customer find for the active MongoDB demo profile.', - generatedLanguage: 'mql', - generatedQuery: '{\n "collection": "customers",\n "operation": "find",\n "filter": {},\n "limit": 50\n}', - }; - } - if (prompt.includes('revenue') || prompt.includes('monthly')) { - return { - intent: 'query', - message: 'I grouped completed orders by month and included order volume so you can compare growth at a glance.', - generatedLanguage: 'sql', - generatedQuery: "SELECT\n TO_CHAR(created_at, 'YYYY-MM') AS month,\n SUM(total) AS revenue,\n COUNT(*) AS orders\nFROM public.orders\nWHERE status = 'completed'\nGROUP BY 1\nORDER BY 1 DESC\nLIMIT 12;", - }; - } - if (prompt.includes('explain') || prompt.includes('result')) { - return { - intent: 'explanation', - message: 'I can explain the active query, but result rows are not included in AI requests. Share the relevant aggregate in your prompt if you want analysis of its values.', - }; - } - return { intent: 'guidance', message: 'I can write a query or explain the active query. Result rows and credentials are not included in AI requests.' }; - } -} - -type WireKind = 'postgre_sql' | 'my_sql' | 's_q_lite' | 'mongo_db' | 'sql_server'; -type WireValue = { type: string; value?: unknown }; -interface WireProfile { - id: string; - name: string; - kind: WireKind; - config: { host?: string; port?: number; database?: string; username?: string; filePath?: string; srv?: boolean; tls: boolean; options: Record }; - readOnly?: boolean; - builtIn?: boolean; -} -interface WireSaveProfileOutcome { profile: WireProfile; warning?: string | null } -interface WireRemoveProfileOutcome { warning?: string | null } -interface WireMetadataNode { - id: string; name: string; kind: string; dataType?: string; hasChildren: boolean; -} -interface WireResult { - columns: Array<{ name: string; dataType: string; nullable: boolean | null }>; - rows: WireValue[][]; - stats: { elapsedMs: number; rowsReturned: number; truncated?: boolean }; - nextOffset?: number | null; - message?: string; -} - -const kindToWire: Record = { - postgresql: 'postgre_sql', mysql: 'my_sql', sqlite: 's_q_lite', mongodb: 'mongo_db', sqlserver: 'sql_server', -}; -const wireToKind: Record = { - postgre_sql: 'postgresql', my_sql: 'mysql', s_q_lite: 'sqlite', mongo_db: 'mongodb', sql_server: 'sqlserver', -}; -const toWireProfile = (draft: ConnectionDraft): Omit & { id?: string } => ({ - ...(draft.id ? { id: draft.id } : {}), - name: draft.name, - kind: kindToWire[draft.kind], - config: { - ...(draft.host ? { host: draft.host } : {}), - ...(draft.port ? { port: draft.port } : {}), - ...(draft.database ? { database: draft.database } : {}), - ...(draft.username ? { username: draft.username } : {}), - ...(draft.filePath ? { filePath: draft.filePath } : {}), - srv: Boolean(draft.srv), - tls: draft.tls, - options: draft.kind === 'mongodb' && draft.authSource ? { authSource: draft.authSource } : {}, - }, - // Read-only unless the person ticked "Allow edits": the core refuses every write to such a profile. - readOnly: draft.readOnly ?? true, - builtIn: false, -}); -const fromWireProfile = (profile: WireProfile): ConnectionProfile => ({ - id: profile.id, - name: profile.name, - kind: wireToKind[profile.kind], - host: profile.config.host ?? '', - port: profile.config.port ?? 0, - database: profile.config.database ?? '', - username: profile.config.username ?? '', - filePath: profile.config.filePath, - srv: Boolean(profile.config.srv), - tls: profile.config.tls, - authSource: profile.kind === 'mongo_db' ? profile.config.options.authSource : undefined, - state: 'disconnected', - builtIn: Boolean(profile.builtIn), - isDemo: Boolean(profile.builtIn), - readOnly: profile.readOnly ?? true, -}); -// Back to the engine's type: a decimal, date or uuid reaches the grid as text and must not be written -// back as text. Without a known type, the JavaScript type decides. -const toWireValue = (value: CellValue, wireType?: string): WireValue => { - if (value === null) return { type: 'null' }; - if (typeof value === 'boolean') return { type: 'boolean', value }; - if (typeof value === 'object') return { type: wireType === 'bson' ? 'bson' : 'json', value }; - if (wireType === 'float') return { type: 'float', value: Number(value) }; - if (wireType && wireType !== 'boolean' && wireType !== 'json' && wireType !== 'bson') return { type: wireType, value: String(value) }; - if (typeof value === 'number') return Number.isInteger(value) ? { type: 'integer', value: String(value) } : { type: 'float', value }; - return { type: 'text', value }; -}; -const fromWireValue = (cell: WireValue): CellValue => { - if (cell.type === 'null') return null; - if (cell.type === 'boolean') return Boolean(cell.value); - if (cell.type === 'integer') { - const number = Number(cell.value); - return Number.isSafeInteger(number) ? number : String(cell.value); - } - if (cell.type === 'float') { - const number = Number(cell.value); - return Number.isFinite(number) ? number : String(cell.value); - } - if (cell.type === 'decimal') return String(cell.value); - if (cell.type === 'json' || cell.type === 'bson') return (cell.value ?? null) as CellValue; - return String(cell.value ?? ''); -}; -const toDataType = (value: string): DataType => { - const type = value.trim().toLowerCase(); - if (/^bit(?:\(\d+\))?$/.test(type)) return 'number'; - if (/int|decimal|numeric|float|double|real/.test(type)) return 'number'; - if (/^(?:bool|boolean)$/.test(type)) return 'boolean'; - if (/date|time/.test(type)) return 'date'; - if (/json|bson|object|array/.test(type)) return 'json'; - return 'string'; -}; -export class TauriBridge implements DataBridge { - readonly mode = 'desktop' as const; - private profiles = new Map(); - - async listConnections(): Promise { - const profiles = (await invoke('list_connections')).map(fromWireProfile); - this.profiles = new Map(profiles.map((profile) => [profile.id, profile])); - return profiles; - } - - async profileWarnings(): Promise { - return invoke('profile_load_warnings'); - } - - async saveConnection(draft: ConnectionDraft): Promise { - if (draft.kind === 'sqlserver') throw new Error('SQL Server is not supported.'); - const outcome = await invoke('save_connection_with_secret', { - profile: toWireProfile(draft), - secret: draft.password?.trim() ? draft.password : null, - }); - const profile = fromWireProfile(outcome.profile); - this.profiles.set(profile.id, profile); - return { profile, ...(outcome.warning ? { warning: outcome.warning } : {}) }; - } - - async removeConnection(id: string): Promise { - const outcome = await invoke('remove_connection', { id }); - this.profiles.delete(id); - return outcome.warning ? { warning: outcome.warning } : {}; - } - - async connect(id: string): Promise { - const profile = this.profiles.get(id); - if (profile) this.profiles.set(id, { ...profile, state: 'connecting' }); - try { - const status = await invoke<{ state: string; message: string; latencyMs?: number }>('connect', { id }); - const current = this.profiles.get(id); - const ok = status.state === 'connected'; - if (current) this.profiles.set(id, { ...current, state: ok ? 'connected' : 'error' }); - return { ok, latencyMs: status.latencyMs, message: status.message }; - } catch (error) { - const current = this.profiles.get(id); - if (current) this.profiles.set(id, { ...current, state: 'error' }); - throw error; - } - } - - async disconnect(id: string): Promise { - await invoke('disconnect', { id }); - const profile = this.profiles.get(id); - if (profile) this.profiles.set(id, { ...profile, state: 'disconnected' }); - } - - async testConnection(draft: ConnectionDraft): Promise { - if (draft.kind === 'sqlserver') throw new Error('SQL Server is not supported.'); - const status = await invoke<{ state: string; message: string; latencyMs?: number }>('test_connection', { profile: toWireProfile(draft), secret: draft.password || null }); - return { ok: status.state === 'connected', latencyMs: status.latencyMs, message: status.message }; - } - - async loadMetadata(connectionId: string, parentId: string | null = null): Promise { - const nodes = await invoke('load_metadata', { request: { connectionId, parentId } }); - return nodes.map((node) => ({ - id: node.id, parentId, name: node.name, - kind: node.kind === 'collection' ? 'table' : node.kind === 'server' ? 'database' : node.kind === 'index' ? 'column' : node.kind as MetadataNode['kind'], - dataType: node.dataType, - childCount: node.hasChildren ? 1 : 0, - })); - } - - // The core splits (query::read_only_statements), so the rule for what is one statement and what is - // read-only lives in one place, next to the gate that enforces it. - async splitReadOnlyBatch(query: string): Promise { - return invoke('split_read_only_batch', { query }); - } - - async executeQuery(request: QueryRequest): Promise { - const page = await invoke('execute_query', { request: { - connectionId: request.connectionId, - query: request.query, - language: request.language === 'mql' ? 'mongo_json' : 'sql', - parameters: [], limit: request.limit ?? 500, offset: request.offset ?? 0, timeoutMs: 30000, - } }); - const keys = page.columns.map((column, index) => page.columns.findIndex((item) => item.name === column.name) === index ? column.name : `${column.name}_${index + 1}`); - const wireTypes = page.columns.map((_, index) => page.rows.map((row) => row[index]?.type).find((type) => type && type !== 'null')); - const { editSource, readOnlyReason } = await this.findEditSource(request, page.columns.map((column) => column.name)); - return { - ...(editSource ? { editSource } : { readOnlyReason }), - columns: page.columns.map((column, index) => ({ key: keys[index], label: column.name, dataType: toDataType(column.dataType), nullable: column.nullable ?? undefined, wireType: wireTypes[index], primaryKey: editSource?.primaryKey === keys[index] || undefined })), - rows: page.rows.map((row) => Object.fromEntries(keys.map((key, index) => [key, fromWireValue(row[index] ?? { type: 'null' })]))), - rowCount: page.stats.rowsReturned, - durationMs: page.stats.elapsedMs, - offset: request.offset ?? 0, - limit: request.limit ?? 500, - nextOffset: page.nextOffset ?? null, - truncated: Boolean(page.stats.truncated), - message: page.message, - }; - } - - // A result is editable only when its SQL plainly reads one table (see editSource.ts) and that table - // has a single-column primary key that the result includes. Anything else stays read-only. - // Either the source a result can be written back to, or the plain reason it cannot -- shown under - // the grid, so a person knows what would make it editable rather than guessing. - private async findEditSource(request: QueryRequest, columnNames: string[]): Promise<{ editSource?: QueryResult['editSource']; readOnlyReason?: string }> { - const profile = this.profiles.get(request.connectionId); - const kind = profile?.kind; - if (!kind) return { readOnlyReason: 'Read-only · the connection is not known' }; - if (request.language !== 'sql') return { readOnlyReason: 'Read-only · MongoDB results cannot be edited here' }; - if (kind === 'sqlserver') return { readOnlyReason: 'Read-only · SQL Server editing is not supported' }; - if ((profile as { readOnly?: boolean }).readOnly !== false) return { readOnlyReason: 'Read-only connection · turn on "Allow edits" in its settings to edit' }; - const source = editableSource(request.query, kind); - if (!source) return { readOnlyReason: 'Read-only · only a plain SELECT from one table, without aliases or joins, can be edited' }; - try { - const keys = await invoke('primary_key_columns', { connectionId: request.connectionId, schema: source.schema ?? null, table: source.table }); - if (keys.length === 0) return { readOnlyReason: `Read-only · ${source.table} has no primary key, so a row cannot be identified` }; - if (keys.length > 1) return { readOnlyReason: `Read-only · ${source.table} has a key of ${keys.length} columns; only single-column keys can be edited` }; - if (!columnNames.includes(keys[0])) return { readOnlyReason: `Read-only · include the key column ${keys[0]} in the SELECT to edit` }; - return { editSource: { ...source, primaryKey: keys[0] } }; - } catch { - return { readOnlyReason: 'Read-only · the table key could not be looked up' }; - } - } - - async applyTableChange(connectionId: string, change: TableChange): Promise { - await invoke('apply_table_change', { connectionId, change: { ...change, schema: change.schema || null } }); - } - - async applyMutations(mutations: CellMutation[]): Promise { - if (!mutations.length) return { applied: 0, message: 'No staged changes.' }; - const [first] = mutations; - const rows = new Map(); - const deletes = mutations.filter((mutation) => mutation.kind === 'delete').map((mutation) => toWireValue(mutation.keyValue ?? mutation.rowKey, mutation.keyWireType)); - const inserts = mutations.filter((mutation) => mutation.kind === 'insert').map((mutation) => ({ values: (mutation.values ?? []).map((item) => ({ column: item.column, value: toWireValue(item.value, item.wireType) })) })); - for (const mutation of mutations.filter((item) => item.kind !== 'delete' && item.kind !== 'insert')) { - const row = rows.get(mutation.rowKey) ?? { key: toWireValue(mutation.keyValue ?? mutation.rowKey, mutation.keyWireType), changes: [] }; - row.changes.push({ column: mutation.column, value: toWireValue(mutation.nextValue, mutation.wireType) }); - rows.set(mutation.rowKey, row); - } - const result = await invoke<{ rowsAffected: number }>('apply_cell_edits', { - connectionId: first.connectionId, - edits: { schema: first.schema ?? null, table: first.table, primaryKey: first.primaryKey, rows: [...rows.values()], inserts, deletes }, - }); - return { applied: mutations.length, message: `Saved ${mutations.length} change${mutations.length === 1 ? '' : 's'} in ${result.rowsAffected} row${result.rowsAffected === 1 ? '' : 's'}.` }; - } - - async saveAiKey(secret: string): Promise { - await invoke('save_ai_secret', { secret }); - } - - async aiKeyStatus(): Promise { - return invoke('ai_key_status'); - } - - async askAi(request: AiRequest): Promise { - const kind = request.databaseKind ?? (request.connectionId ? this.profiles.get(request.connectionId)?.kind : undefined) ?? 'postgresql'; - const language = request.language ?? (kind === 'mongodb' ? 'mql' : 'sql'); - const response = await invoke<{ message: { content: string } }>('ai_chat', { request: { - prompt: request.prompt, - ...(request.query === undefined ? {} : { activeQuery: request.query }), - databaseKind: kindToWire[kind], - queryLanguage: language === 'mql' ? 'mongo_json' : 'sql', - } }); - const content = response.message.content; - let generatedQuery: string | undefined; - if (language === 'mql') { - const fenced = content.match(/```(?:mql|json)\s*([\s\S]*?)```/i)?.[1]?.trim(); - if (fenced) { - try { - const candidate = JSON.parse(fenced) as { collection?: unknown; operation?: unknown }; - if (typeof candidate.collection === 'string' && typeof candidate.operation === 'string') generatedQuery = fenced; - } catch { /* Ignore malformed generated JSON. */ } - } - } else { - const fenced = content.match(/```sql\s*([\s\S]*?)```/i)?.[1]?.trim(); - const inline = content.match(/(?:SELECT|WITH)\s[\s\S]*?;/i)?.[0]; - const candidate = fenced ?? inline; - if (candidate && /^(?:SELECT|WITH)\b/i.test(candidate)) generatedQuery = candidate; - } - return { - message: content, - generatedQuery, - generatedLanguage: generatedQuery ? language : undefined, - intent: generatedQuery ? 'query' : /explain|result/i.test(request.prompt) ? 'explanation' : 'guidance', - }; - } -} - -export const isTauriRuntime = () => typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window; -export const createBridge = (): DataBridge => isTauriRuntime() ? new TauriBridge() : new DemoBridge(); diff --git a/src/api/editSource.test.ts b/src/api/editSource.test.ts deleted file mode 100644 index 08e44cf..0000000 --- a/src/api/editSource.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { editableSource } from './editSource'; - -describe('editableSource', () => { - it('finds the table of a plain select, quoted or not', () => { - expect(editableSource('SELECT * FROM "public"."customers" LIMIT 100', 'postgresql')).toEqual({ schema: 'public', table: 'customers' }); - expect(editableSource('select id, name from people where plan = \'Free\' order by id;', 'sqlite')).toEqual({ table: 'people' }); - expect(editableSource('SELECT * FROM `shop`.`order items`', 'mysql')).toEqual({ schema: 'shop', table: 'order items' }); - expect(editableSource('SELECT * FROM "we""ird"', 'postgresql')).toEqual({ table: 'we"ird' }); - }); - - it('refuses what has no single row per result row, or renames a column', () => { - for (const sql of [ - 'SELECT email AS name FROM people', - 'SELECT id, upper(name) FROM people', - 'SELECT * FROM a JOIN b ON a.id = b.id', - 'SELECT * FROM a, b', - 'SELECT DISTINCT name FROM people', - 'SELECT plan FROM people GROUP BY plan', - 'SELECT * FROM a UNION SELECT * FROM b', - 'WITH x AS (SELECT 1) SELECT * FROM x', - 'SELECT * FROM people WHERE id IN (SELECT id FROM other)', - 'SELECT * FROM people; DELETE FROM people', - 'UPDATE people SET name = 1', - ]) { - expect(editableSource(sql, 'postgresql'), sql).toBeNull(); - } - }); - - it('is not fooled by keywords inside string literals', () => { - expect(editableSource("SELECT * FROM notes WHERE body = 'a join b (x)'", 'sqlite')).toEqual({ table: 'notes' }); - }); - - it('refuses engines whose edits are not supported', () => { - expect(editableSource('SELECT * FROM t', 'sqlserver')).toBeNull(); - expect(editableSource('SELECT * FROM t', 'mongodb')).toBeNull(); - }); -}); diff --git a/src/api/editSource.ts b/src/api/editSource.ts deleted file mode 100644 index 741a5da..0000000 --- a/src/api/editSource.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { DatabaseKind } from '../domain/types'; - -// Which table a result can be written back to, or null. Strict on purpose: a cell edit becomes -// `UPDATE SET = ? WHERE = ?`, so the result's columns must BE the table's -// columns. `SELECT email AS name FROM t` would put an edit of "name" into the wrong column, and a -// join, group, union or expression has no single row to write to. Anything this does not -// recognise stays read-only, which is what desktop results were before. - -export interface EditableSource { schema?: string; table: string } - -const IDENT = String.raw`(?:"(?:[^"]|"")+"|\x60(?:[^\x60]|\x60\x60)+\x60|\[[^\]]+\]|[A-Za-z_][A-Za-z0-9_$]*)`; -const COLUMN_LIST = String.raw`\*|${IDENT}(?:\s*,\s*${IDENT})*`; -const SOURCE = new RegExp(String.raw`^\s*select\s+(${COLUMN_LIST})\s+from\s+(${IDENT})(?:\s*\.\s*(${IDENT}))?(?:\s+(?:where|order\s+by|limit|offset)\b[\s\S]*)?\s*;?\s*$`, 'i'); -const FORBIDDEN = /\b(join|union|intersect|except|group\s+by|having|distinct|with|window|over|returning|into)\b|\(/i; - -function unquote(name: string): string { - if (name.startsWith('"')) return name.slice(1, -1).replace(/""/g, '"'); - if (name.startsWith('`')) return name.slice(1, -1).replace(/``/g, '`'); - if (name.startsWith('[')) return name.slice(1, -1); - return name; -} - -// String literals can hold anything; blank them before looking for forbidden words. -const withoutStrings = (sql: string) => sql.replace(/'(?:[^']|'')*'/g, "''"); - -export function editableSource(sql: string, kind: DatabaseKind): EditableSource | null { - if (kind === 'mongodb' || kind === 'sqlserver') return null; - const text = withoutStrings(sql); - if (text.split(';').filter((part) => part.trim()).length !== 1) return null; - const match = SOURCE.exec(text); - if (!match) return null; - const [, , first, second] = match; - // Anything after the table must not reintroduce what the shape forbids (a subquery in WHERE). - const tail = text.slice(text.toLowerCase().indexOf(' from ') + 6); - if (FORBIDDEN.test(tail) || FORBIDDEN.test(match[1])) return null; - return second ? { schema: unquote(first), table: unquote(second) } : { table: unquote(first) }; -} diff --git a/src/assets/brand/redrob-symbol-solid-white.png b/src/assets/brand/redrob-symbol-solid-white.png deleted file mode 100644 index 2700498..0000000 Binary files a/src/assets/brand/redrob-symbol-solid-white.png and /dev/null differ diff --git a/src/assets/brand/redrob-symbol.png b/src/assets/brand/redrob-symbol.png deleted file mode 100644 index fc6e081..0000000 Binary files a/src/assets/brand/redrob-symbol.png and /dev/null differ diff --git a/src/components/ActivityRail.tsx b/src/components/ActivityRail.tsx deleted file mode 100644 index d180643..0000000 --- a/src/components/ActivityRail.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { Icon } from '../ui/Icon'; -import { Mark } from '@redrob-labs/ui'; -import symbolDark from '../assets/brand/redrob-symbol-solid-white.png'; -import symbolLight from '../assets/brand/redrob-symbol.png'; -import { useWorkspace } from '../store/WorkspaceProvider'; -import { IconButton } from './IconButton'; - -export function ActivityRail() { - const navigatorOpen = useWorkspace((state) => state.navigatorOpen); - const aiOpen = useWorkspace((state) => state.aiOpen); - const mutations = useWorkspace((state) => state.mutations.length); - const activeTabId = useWorkspace((state) => state.activeTabId); - const pluginViewId = useWorkspace((state) => state.pluginViewId); - const openConnectionModal = useWorkspace((state) => state.openConnectionModal); - const focusNavigatorSearch = useWorkspace((state) => state.focusNavigatorSearch); - const setUi = useWorkspace((state) => state.setUi); - return ( - - ); -} diff --git a/src/components/AiPanel.tsx b/src/components/AiPanel.tsx deleted file mode 100644 index 73d2156..0000000 --- a/src/components/AiPanel.tsx +++ /dev/null @@ -1,65 +0,0 @@ -import { useEffect, useRef, useState } from 'react'; -import { Icon } from '../ui/Icon'; -import { useWorkspace } from '../store/WorkspaceProvider'; -import { IconButton } from './IconButton'; - -export function AiPanel() { - const messages = useWorkspace((state) => state.aiMessages); - const status = useWorkspace((state) => state.aiStatus); - const mode = useWorkspace((state) => state.bridge.mode); - const width = useWorkspace((state) => state.aiWidth); - const connections = useWorkspace((state) => state.connections); - const activeConnectionId = useWorkspace((state) => state.activeConnectionId); - const tabs = useWorkspace((state) => state.tabs); - const activeTabId = useWorkspace((state) => state.activeTabId); - const askAi = useWorkspace((state) => state.askAi); - const useQuery = useWorkspace((state) => state.useGeneratedQuery); - const setUi = useWorkspace((state) => state.setUi); - const [prompt, setPrompt] = useState(''); - const messagesRef = useRef(null); - useEffect(() => { messagesRef.current?.scrollTo({ top: messagesRef.current.scrollHeight, behavior: 'smooth' }); }, [messages, status]); - const send = async () => { - if (!prompt.trim() || status === 'loading') return; - const value = prompt; - setPrompt(''); - await askAi(value); - }; - const activeConnection = connections.find((connection) => connection.id === activeConnectionId); - const activeTab = tabs.find((tab) => tab.id === activeTabId); - const resize = (next: number) => setUi({ aiWidth: Math.max(290, Math.min(480, next)) }); - return ( -